Rule

nullifier-check-without-insert

Required collection absence lacks a definite surviving insertion on a state-changing history.

Release v0.1.1View Markdown

Specimen 94 of 96 · Other active opt-ins / Source structure and validation

Severity
medium
Confidence
low
Maturity
experimental
Selection
Opt-in
Policy intent
not required
Release
v0.1.1
Engine facts used
collection-observations successful-predicates state-writes completed-entries

Selection and scope#

Not selected by default. Select it with --detectors or the detectors list in nightshade.json. Either one replaces profile selection, so list every rule you want.

Shell
./.nightshade/node_modules/.bin/nightshade ./contracts --detectors nullifier-check-without-insert
./.nightshade/node_modules/.bin/nightshade detector show nullifier-check-without-insert

The first command runs only this rule; --detectors takes a comma-separated list. The second prints the rule's metadata from your installed version.

Severity is review priority. It does not express exploitability or impact. Confidence is detector metadata, not a probability. All built-in rules are experimental.

Compiler scope: the engine supports the pinned profiles 0.28.0, 0.29.0, 0.30.0, 0.31.1, 0.33.0-rc.2, 0.34.0, 0.35.0-rc.1 and 0.35.0, with ZKIR2 by default. Support for this rule can be narrower; the rule documentation below states any narrower scope.

Verified example#

Synthetic examplecompact-0.34.0-zkir2compiler-validated

The circuit checks that an identifier is unused but never inserts it into the used set. From the proof run of the released CLI; the examples page also has the lookalike and the files.

Compactvulnerable.compactline 7 flagged
import CompactStandardLibrary;
ledger used: Set<Field>;
export ledger accepted: Field;
witness token(): Field;
export circuit accept(): [] {
  const id = token();
  assert(!used.member(disclose(id)), "fresh");
  accepted = disclose(id);
}
output.txtexit 1
$ nightshade vulnerable.compact --config config.json --compiler /path/to/compactc

MEDIUM — Required absence lacks a surviving matching insertion [nullifier-check-without-insert]
vulnerable.compact:7:11
  7 |   assert(!used.member(disclose(id)), "fresh");
    |           ^
accept has a modeled successful state-changing history requiring absence from used, without a definite surviving insertion of that exact value/key into the same collection. Review whether this check is intended to consume a one-use identifier.
Source: vulnerable.compact:7:11
Call path: accept
Remediation: If this is a one-use identifier, insert the checked value/key into the same persistent collection on every successful history and retain it through transaction completion. Review intentional absence validation separately.

Semantic profile: compact-0.34.0-zkir2.
Baseline analysis: complete; selected rules: complete.
Rule coverage: 1 selected rule(s); 0 with incomplete entries.
1 finding(s), 0 suppressed, 0 baseline; 1/1 entrypoint(s) completed; analysis complete. Compiler validation passed.

Corrected: Insert the identifier into the used set. Changed lines are marked.

Compactcorrected.compactrule quiet
import CompactStandardLibrary;
ledger used: Set<Field>;
export ledger accepted: Field;
witness token(): Field;
export circuit accept(): [] {
  const id = token();
  assert(!used.member(disclose(id)), "fresh");
  used.insert(disclose(id));
  accepted = disclose(id);
}
corrected-output.txtexit 0
$ nightshade corrected.compact --config config.json --compiler /path/to/compactc

Semantic profile: compact-0.34.0-zkir2.
Baseline analysis: complete; selected rules: complete.
Rule coverage: 1 selected rule(s); 0 with incomplete entries.
0 finding(s), 0 suppressed, 0 baseline; 1/1 entrypoint(s) completed; analysis complete. Compiler validation passed.

The command line is shortened from the recorded run to what you would type; the output below it is verbatim, with absolute file paths shortened to the example folder. Long lines wrap. A quiet result shows that this rule stopped firing, not that the contract is secure.

Corpus checks#

Synthetic corpus checks for this rule in the 0.1.1 proof run. A check counts only with compiler acceptance and complete analysis.

Positive detectedCorrected quietLookalike quietOther negative quietIncomplete
2/22/22/20/00

No deviations were recorded for this rule.

These are behavior checks on synthetic fixtures, not a precision or recall estimate. See evaluation.

Rule documentation#

From docs/detectors/nullifier-check-without-insert.md at v0.1.1.

Availability: opt-in with --detectors nullifier-check-without-insert; no policy required. Severity medium, confidence low. Explicit selection replaces the default detector set.

Owned example and review scenario#

The compiler-qualified fixture requires collection absence before changing state. If the application intends one-time consumption, the correction retains an insertion in the same collection:

Compact
assert(!used.member(disclose(id)), "fresh");
used.insert(disclose(id));
accepted = disclose(id);

Recommendation: first confirm nullifier intent, then consume that exact member/key on every successful relevant history and preserve the insertion. Absence checks used for ordinary collection administration need not consume a nullifier.

Evidence and limitations#

A candidate is a modeled successful nonconstructor history that requires absence of an exact value/key in a ledger Set/Map and performs a persistent or canonical token effect, but does not leave that value/key inserted in the same collection. This is a structural replay-prevention review, not proof that the value is intended to be a nullifier or that another transaction can replay it. Read-only membership helpers and user namesakes do not establish a missing-consumption finding.

The matcher must preserve the actual query Boolean and its value/receiver snapshots. Ignored queries and tautologies are not guards. Different keys, witness invocations, collection instances and stale aliases cannot supply insertion credit. Saved absence results and late atomic assertions remain meaningful; insert-then-remove/reset does not consume a key. Alternate successful paths and nested helper calls remain separate. Unknown overlap/equality must not invent proof of consumption. Unsupported effects and exhausted observation/decoding budgets must be explicitly incomplete, discarding partial rule findings after query exhaustion.

The default engine now correlates repeated dynamic membership queries by exact receiver selectors, member operands and ledger revision, retaining the observation across local cleanup. It never identifies two reads merely by a wildcard region. The opt-in observations also retain immutable hash/commitment operands and the ordered query/write positions. Component equalities rebuild aggregate identities; bounded Boolean equality/negation propagation handles indirect absence guards. Inserts survive descendant value updates, but removal, reset or possible ancestor replacement revokes credit. Each operand traversal is bounded to 4,096 visits and charged to analysis work; rule matching shares a 1,000,000-work allowance. Any exhaustion discards partial rule candidates and emits an incomplete diagnostic.

Read-only absence checks can be intentional for collection administration and need not consume a key. A match is a replay-prevention review prompt; it does not establish that the checked value is a protocol nullifier or that another transaction can replay an operation.

Source examples#

These short regression examples are included here for installed-package readers. Rule-specific policy requirements and limits above still apply.

Example 1#

Fixture (source: tests/corpus/vulnerable/nullifier-check-without-insert.compact).

Compact
import CompactStandardLibrary;
ledger used: Set<Field>;
export ledger accepted: Field;
witness token(): Field;
export circuit accept(): [] {
  const id = token();
  assert(!used.member(disclose(id)), "fresh");
  accepted = disclose(id);
}

Example 2#

Correction (source: tests/corpus/safe/nullifier-check-without-insert.compact).

Compact
import CompactStandardLibrary;
ledger used: Set<Field>;
export ledger accepted: Field;
witness token(): Field;
export circuit accept(): [] {
  const id = token();
  assert(!used.member(disclose(id)), "fresh");
  used.insert(disclose(id));
  accepted = disclose(id);
}

Suppress a match#

If a match is intentional, suppress it on the line before with the exact rule ID and a reason of at least eight characters:

Compact
// nightshade-disable-next-line nullifier-check-without-insert -- Reason this match is intentional

Diagnostics cannot be suppressed, and an unmatched directive for a selected rule makes analysis incomplete. See configuration for fingerprint suppressions and baselines.

References#