Skip to content

feat: enforce per-anchor operation-count limit in the reader (#28)#36

Merged
LiranCohen merged 1 commit into
masterfrom
feat/enforce-operation-limit
Jun 4, 2026
Merged

feat: enforce per-anchor operation-count limit in the reader (#28)#36
LiranCohen merged 1 commit into
masterfrom
feat/enforce-operation-limit

Conversation

@LiranCohen

Copy link
Copy Markdown
Contributor

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.go constant table) and #29 (callback forwarding). Plan: docs/plans/2026-06-04-001-feat-ion-value-locking-protocol-rules-plan.md.

Rules enforced (in order)

  • reject opCount < 1 — malformed / non-positive / unparseable anchor count (AnchorString.Operations() returns 0 for a non-numeric 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 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 (the valueLockFn seam stays meaningful).
  • anti-bypass cross-check: reject when the anchored files contain more operations than the anchor string declares. Overstatement stays legal, matching ION's paid-count semantics (total anchored ops <= declared/paid).

All rejections route through classifyMalformedErrMalformed, 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 <=100 ops with an empty writerLockId. 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 be ErrMalformed (permanent skip).
  • TestProcessorAnchoredCountExceedsDeclared — the understatement bypass (declares 1, packs 101) → ErrOperationCountMismatch.
  • Verified red if enforcement is removed (tests are non-vacuous). TestProcessFilterDIDs fixture updated to declare the real count (4) it anchors.
  • gofmt clean; 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); WriterLockId is 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 the opCount < 1 reject (with tests). It also confirmed the cross-check direction matches ION's actual <= declared paid-count semantics.

Post-Deploy Monitoring & Validation

  • What to monitor: observer logs for newly-skipped anchors classified ErrMalformed with the new messages (exceeds maxOperationsPerBatch, exceeds maxNumberOfOperationsForNoValueTimeLock, non-positive or unparseable operation count, anchored operation count exceeds the anchor-string declared count).
  • Expected healthy signal: on ION mainnet, effectively zero new rejections — canonical anchors are <=100 ops, no lock. Any rejection here is either genuinely malformed or indicates a writer using value-locking (none on mainnet today).
  • Failure signal / rollback trigger: a spike of ErrMalformed op-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.
  • Validation window & owner: first full resync against mainnet after deploy; owner = indexer maintainer. Cross-check a sample of resolved DIDs against a reference ION resolver.

🤖 Generated with Claude Code

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>
@LiranCohen LiranCohen merged commit 5be7c99 into master Jun 4, 2026
1 check passed
@LiranCohen LiranCohen deleted the feat/enforce-operation-limit branch June 4, 2026 16:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant