feat: enforce per-anchor operation-count limit in the reader (#28)#36
Merged
Conversation
This is the P0 consensus fix. A spec-compliant ION node rejects an entire
anchored batch — permanently, never retried — when the anchor-string operation
count exceeds the per-writer value-time-lock limit. Our reader enforced none of
it: the only op-count check was gated behind a (previously always-nil, now
optional) value-lock callback, so a >100-op anchor with no/insufficient lock was
accepted and replayed by us while canonical ION rejects it — permanently
diverging our resolved DID state from the network.
Add UNCONDITIONAL per-anchor enforcement in OperationsProcessor.Process(),
independent of any configured fee/value-lock callback:
- reject opCount < 1 (malformed / non-positive / unparseable anchor count)
- reject opCount > MaxOperationsPerBatch (10000): hard ceiling, checked first,
so no value lock can ever lift it
- reject opCount > MaxNumberOfOperationsForNoValueTimeLock (100) with an empty
writerLockId
- reject opCount > 100 with a writerLockId but no value-lock verifier installed
(we cannot verify the lock until the on-chain LockResolver lands — #33/#55 —
so default-reject); when a verifier IS installed, defer to it
- anti-bypass cross-check: reject when the anchored files contain more
operations than the anchor string declares (a writer must not understate the
count to slip past the gate). Overstatement stays legal, matching ION's
paid-count semantics (total anchored ops <= declared/paid).
All rejections route through classifyMalformed → ErrMalformed, so the observer
permanently skips the batch rather than retrying it.
ION mainnet ships value-locking disabled, so every canonical anchor today is
<=100 ops with empty writerLockId; this makes our reader match canonical ION on
the live network. New sentinels: ErrInvalidOperationCount, ErrTooManyOperations,
ErrOperationLimitExceeded, ErrUnverifiableValueLock, ErrOperationCountMismatch.
Tested: TestProcessorOperationLimit (boundaries 100/101/10000/10001, no-lock,
lock-without-verifier, verifier accept/reject, invalid counts 0/abc/-5) and
TestProcessorAnchoredCountExceedsDeclared (the understatement bypass). Each
rejection is asserted to be ErrMalformed. Verified the tests fail if enforcement
is removed (non-vacuous). The TestProcessFilterDIDs fixture now declares the
real count (4) it anchors. Hardened per an adversarial self-review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The P0 consensus fix. A spec-compliant ION node rejects an entire anchored batch — permanently, never retried — when the anchor-string operation count exceeds the per-writer value-time-lock limit. Our reader enforced none of it: the only op-count check was gated behind a value-lock callback that was dead (nil + dropped before #29). So a
>100-op anchor with no/insufficient lock was accepted and replayed by us while canonical ION rejects it — permanently diverging our resolved DID state from the network.This adds unconditional per-anchor enforcement in
OperationsProcessor.Process(), independent of any configured callback.Builds on #30 (the
params.goconstant table) and #29 (callback forwarding). Plan:docs/plans/2026-06-04-001-feat-ion-value-locking-protocol-rules-plan.md.Rules enforced (in order)
opCount < 1— malformed / non-positive / unparseable anchor count (AnchorString.Operations()returns0for a non-numeric count).opCount > MaxOperationsPerBatch (10000)— hard ceiling, checked first, so no value lock can ever lift it.opCount > MaxNumberOfOperationsForNoValueTimeLock (100)with an emptywriterLockId.opCount > 100with awriterLockIdbut no value-lock verifier installed — we can't verify the lock until the on-chain LockResolver lands (P1: Widen the ValueLocking seam to carry amountLocked/normalizedFee/owner/lock-window and port the verifier #33/#55), so default-reject. When a verifier is installed, defer to it (thevalueLockFnseam stays meaningful).total anchored ops <= declared/paid).All rejections route through
classifyMalformed→ErrMalformed, so the observer permanently skips the batch (no retry).Why this matches canonical ION today
ION mainnet ships value-locking disabled (
valueTimeLockUpdateEnabled=false), so every canonical anchor today is<=100ops with an emptywriterLockId. With this change our reader accepts exactly that set and rejects what ION rejects — closing the divergence on the live network. The lock+verifier branch is latent until #33/#55.Testing
TestProcessorOperationLimit— boundaries (100 accept / 101 reject / 10000 ceiling / 10001 reject even with a verified lock), no-lock reject, lock-without-verifier reject, verifier accept, verifier reject, and invalid counts (0/abc/-5). Each rejection is asserted to beErrMalformed(permanent skip).TestProcessorAnchoredCountExceedsDeclared— the understatement bypass (declares 1, packs 101) →ErrOperationCountMismatch.TestProcessFilterDIDsfixture updated to declare the real count (4) it anchors.gofmtclean;go build,go vet,go test -race -count=1 ./...all green.Adversarial self-review
Ran an adversarial review pass that confirmed: the 10000 ceiling is checked first and a lock cannot raise it; the 100/101 boundary is exact; operations cannot hide in chunk deltas (delta count is pinned to the mapping array);
WriterLockIdis available at gate time; tests genuinely fail if enforcement is removed. It surfaced one real gap — malformed/zero/negative declared counts were waved past the quota gate — now fixed by theopCount < 1reject (with tests). It also confirmed the cross-check direction matches ION'sactual <= declaredpaid-count semantics.Post-Deploy Monitoring & Validation
ErrMalformedwith the new messages (exceeds maxOperationsPerBatch,exceeds maxNumberOfOperationsForNoValueTimeLock,non-positive or unparseable operation count,anchored operation count exceeds the anchor-string declared count).<=100ops, no lock. Any rejection here is either genuinely malformed or indicates a writer using value-locking (none on mainnet today).ErrMalformedop-limit rejections on known-good mainnet anchors would mean we're now rejecting anchors ION accepts (divergence in the wrong direction) → revert and investigate the boundary.🤖 Generated with Claude Code