Skip to content

fix(chain): close TRAC auto-approve gap (1n floor) + configurable allowance policy#720

Merged
branarakic merged 2 commits into
release/rc.12from
fix/chain-publish-allowance-min
May 27, 2026
Merged

fix(chain): close TRAC auto-approve gap (1n floor) + configurable allowance policy#720
branarakic merged 2 commits into
release/rc.12from
fix/chain-publish-allowance-min

Conversation

@branarakic
Copy link
Copy Markdown
Contributor

@branarakic branarakic commented May 27, 2026

Summary

Two commits, both about how the EVM adapter sizes its TRAC allowance approval before a V10 publish or update:

  1. fix(chain): enforce 1n on-chain TRAC allowance minimum on V10 publish/update — closes the TooLowAllowance(token, 0, 1) revert we reproduced on Base Sepolia. The auto-approve check currentAllowance < params.tokenAmount evaluates false whenever the JS-side tokenAmount === 0n (testnet pricing, dust CGs on mainnet, pricing-oracle edge cases), but the contract still pulls transferFrom(..., 1n) from the direct-spend branch. Floor the approval at 1n.

  2. feat(chain): configurable TRAC auto-approve policy (per-publish/replenishing/unlimited) — once the floor is in place, the existing bounded-per-publish policy is correct but expensive at mainnet scale. Adds a config knob (chain.approvalPolicy in dkg.config.yaml) so operators can choose between three modes; the default per-publish is bit-for-bit identical to today's behaviour.

The second commit builds on the first — the effectivePublishAllowance helper from commit 1 is the lower-bound clamp for every mode in commit 2.

Empirical motivation

Both commits are driven by the May 2026 Base Sepolia publish-stress run (miles-publish-stress-26may CG, 800+ publishes via scripts/testnet-publish-stress/publish-loop.mjs landing in PR #722):

  • Without the 1n floor: every op-wallet that wasn't manually pre-approved reverted with TooLowAllowance(token, 0, 1) on its first publish (we had to ship approve-op-wallets.mjs as a workaround).
  • Even with the floor, the bounded-per-publish policy submits a fresh approve tx whenever tokenAmount > previousAllowance. On Base mainnet at ~$0.02-0.05 per approve, that's $400-1000/day on approve gas alone at the publish volumes mainnet integrators are designing for.

What the policy looks like

# dkg.config.yaml
chain:
  type: evm
  rpcUrl: ...
  hubAddress: ...
  approvalPolicy:
    mode: per-publish          # 'per-publish' (default) | 'replenishing' | 'unlimited'
    # `replenishing` mode only:
    targetAllowance: '1000000000000000000000'   # 1000 TRAC (decimal wei-TRAC string)
    refillBelowFraction: 0.1                     # refill at 10% remaining
Mode Per-publish gas Blast radius if KA compromised When to use
per-publish (default, today's behaviour) 1 approve tx every time tokenAmount > previousAllowance One publish's cost ceiling Low publish volume, conservative
replenishing 1 approve per ~9 publishes' worth of TRAC (with defaults) Capped at targetAllowance Recommended for mainnet
unlimited (V9 pattern) 1 approve ever per wallet Operational wallet's full TRAC balance High-volume, fully-trusted KA contract

Files touched

Package File Change
chain src/chain-adapter.ts Public types: ApprovalPolicyMode, ApprovalPolicy, defaults
chain src/evm-adapter.ts effectivePublishAllowance (1n floor) + computeApprovalAction (policy dispatch); both V10 call sites swap to it
chain src/index.ts Re-export the helpers
chain test/evm-adapter.unit.test.ts +28 tests (6 floor, 22 policy)
agent src/dkg-agent-types.ts chainConfig.approvalPolicy?: ApprovalPolicy
agent src/dkg-agent.ts Forward to EVMChainAdapter constructor
cli src/config.ts ApprovalPolicyConfig (YAML-friendly, stringly numerics) + resolveApprovalPolicy()
cli src/daemon/lifecycle.ts Wire chain.approvalPolicy → agent chainConfig
cli test/config.test.ts +9 tests for the YAML → runtime conversion
cli skills/dkg-node/SKILL.md New "TRAC auto-approve policy" subsection under §8 with the trade-off table and config shape

Invariants pinned down by tests

Every policy mode honours these (exercised explicitly in the cross-mode invariant tests):

  • targetAllowance >= effectivePublishAllowance(tokenAmount) — even a misconfigured replenishing target gets raised to the on-chain 1n floor so the immediate publish never reverts.
  • needsApprove is monotone in currentAllowance — strictly more existing allowance never flips false to true.
  • Unknown mode (defensive fallback) → behaves as per-publish rather than throwing inside the publish hot path.

Why this is rc.12-worth (both commits)

Commit 1 is a production-blocker: any operator who hasn't pre-approved their op-wallets hits it on the first publish to a zero-cost / dust CG. Must ship in the last RC.

Commit 2 is mainnet-readiness: the bounded-per-publish policy is correct after commit 1 but unnecessarily expensive on mainnet. Shipping the config knob in rc.12 means operators preparing for launch can opt into the better economics before they're on mainnet, without us having to chase a follow-up PR into rc.13. Default is preserved-behaviour so it's a no-op for anyone who doesn't read the new skill section.

Test plan

  • pnpm --filter @origintrail-official/dkg-chain exec vitest run -c vitest.unit.config.ts — 104/104 pass
  • pnpm --filter @origintrail-official/dkg exec vitest run -c vitest.unit.config.ts — 468/468 pass
  • tsc green across chain → agent → mcp-dkg → cli
  • Zero lints across all 10 modified files
  • Hardhat e2e (in CI)
  • On-chain validation: re-run the stress publish loop with fresh op-wallets (no manual pre-approval) and verify zero TooLowAllowance reverts
  • On-chain validation: switch a node to replenishing mode, observe one approve tx every ~9 publishes instead of every publish where cost grows

…/update

`KnowledgeAssetsV10.publish` / `update` call `token.transferFrom(msg.sender,
CSS, fullCost)` from the direct-spend branch — and the contract rounds
`fullCost` up to `1n` wei-TRAC even for zero-value publishes. The JS-side
auto-approve previously skipped approval entirely when `params.tokenAmount`
was `0n`, because:

- the V10 update path was gated on `tokenAmount > 0n`, and
- both paths' inner check was `currentAllowance < params.tokenAmount`,
  which is never true when `tokenAmount === 0n`.

Result: publishes from any operational signer whose allowance is `0n`
revert with `TooLowAllowance(token, 0, 1)` at chain time.

Empirical repro on Base Sepolia, May 2026 (`miles-publish-stress-26may`,
33 publishes successful only after manually approving all op-wallets):

    HTTP 500 POST /api/shared-memory/publish
      ... execution reverted: TooLowAllowance(0x2A58Bdd..., 0, 1)

This isn't testnet-only — mainnet hits it whenever
`getRequiredPublishTokenAmount` returns `0` (dust CGs, certain pricing-oracle
edge cases for new / low-value context graphs).

Fix
---
Extract a small policy helper `effectivePublishAllowance(tokenAmount,
onChainMin = 1n)` that floors the approval ceiling at the on-chain
minimum, and replace both call sites with it. Preserves the existing
bounded-approval philosophy (still per-publish, not MaxUint256) so a
compromised KA contract can't widen approval beyond the requested cost.

Tests
-----
6 new unit tests in `evm-adapter.unit.test.ts` pin the policy: floors at
1n for `tokenAmount = 0n`, passes through larger amounts, respects a
forward-compat injected minimum, and asserts the bounded-approval
property (never returns MaxUint256 unless asked).

Full chain unit suite passes (82/82).

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread packages/chain/src/evm-adapter.ts
Comment thread packages/chain/src/evm-adapter.ts
Comment thread packages/chain/test/evm-adapter.unit.test.ts
…nishing/unlimited)

Builds on the previous commit's `effectivePublishAllowance` floor to give
operators a config knob for how much TRAC the V10 publish + update paths
approve at each top-up, instead of being hard-wired to a bounded-per-publish
policy. The default (`per-publish`) matches today's behaviour bit-for-bit;
two new modes — `replenishing` and `unlimited` — close the mainnet gas
profile that the previous commit exposed.

Why this matters for rc.12
--------------------------
On Base Sepolia the bounded-per-publish policy is free (publishes round
to 0 TRAC). On mainnet, the same code path costs an approve tx every
time `tokenAmount` exceeds the wallet's current allowance — empirically
~$0.02-0.05 per approve on Base. At the publish volumes mainnet
integrators are designing for (Graphify code graphs, EPCIS event streams,
sustained automated publishes), bounded-per-publish burns $400-1000/day
on approve gas across 4 op-wallets. `replenishing` cuts that by ~10×
without widening the security blast radius beyond a configurable ceiling.

The shape
---------
`packages/chain/src/chain-adapter.ts` adds the public types:

  type ApprovalPolicyMode = 'per-publish' | 'replenishing' | 'unlimited';
  interface ApprovalPolicy { mode; targetAllowance?: bigint;
                             refillBelowFraction?: number; }

`packages/chain/src/evm-adapter.ts` adds `computeApprovalAction(policy,
tokenAmount, currentAllowance)` returning `{ needsApprove, targetAllowance }`,
and stores `approvalPolicy` on the adapter. Both V10 call sites (publish at
~L2220, update at ~L2620) drop their inline approval logic in favour of the
single dispatch. Invariants enforced for every mode:

- `targetAllowance >= effectivePublishAllowance(tokenAmount)` — even a
  misconfigured `replenishing` target gets raised to the on-chain 1n
  minimum so the immediate publish never reverts.
- `needsApprove` is monotone in `currentAllowance` — strictly more
  existing allowance never flips false to true.

Wired through the stack:
- `chain` package — public types + helper + adapter field.
- `agent` package — `chainConfig.approvalPolicy?: ApprovalPolicy` on
  `DKGAgentConfig.chainConfig`, forwarded to `EVMChainAdapter` constructor.
- `cli` package — `ApprovalPolicyConfig` (YAML-friendly: stringly-typed
  numerics) on `ChainConfig`; `resolveApprovalPolicy()` converts at startup,
  fails fast on garbage input.
- `dkg-node` skill — new "TRAC auto-approve policy" subsection under §8 with
  the per-mode trade-off table and the YAML config shape.

Tests
-----
22 new unit tests on the chain side (per-publish backward-compat, replenishing
default ceiling + refill threshold + custom target + low-target clamp +
fraction clamp + NaN handling + publish-floor floor; unlimited fresh wallet
+ re-approve-on-revoke; cross-mode invariants — targetAllowance ≥ floor,
monotonicity in currentAllowance, unknown-mode fallback to per-publish).

9 new unit tests on the CLI config side covering the YAML → runtime
conversion: stringly-typed `targetAllowance` → bigint, validation errors
on unknown mode / unparseable bigint / negative target / out-of-range
fraction (incl. NaN), defaults.

Full suites:
  chain unit: 104/104 pass
  CLI unit:   468/468 pass

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review skipped: filtered diff is 9341 lines (cap: 5,000). Please consider splitting this into smaller PRs for reviewability.

@branarakic branarakic changed the title fix(chain): enforce 1n on-chain TRAC allowance minimum on V10 publish/update fix(chain): close TRAC auto-approve gap (1n floor) + configurable allowance policy May 27, 2026
@branarakic branarakic merged commit 65417c6 into release/rc.12 May 27, 2026
3 checks passed
@branarakic
Copy link
Copy Markdown
Contributor Author

Empirical validation — 9.5h publish-stress run

Following merge, ran a continuous publish loop from a Miles edge node against Base Sepolia (miles-publish-stress-26may context graph, cgId=4) to validate that the 1n floor fix and the configurable policy hold up under sustained load.

Window: 2026-05-26 23:38 → 2026-05-27 09:11 UTC (≈ 9h 33m, ~10s pacing)

Headline results

Metric Value
Partitions attempted 889
Successful publishes 865 (kcId 4 → 965 on chain)
Hard failures (3 retries exhausted) 24
Overall success rate 97.3%
Gas spent (testnet) 0.0034 ETH
TRAC spent 0 (free publishes)

Per-publish latency (n=865)

min=7.2s   median=18.9s   p95=40.3s   p99=56.2s   max=66.5s   avg=21.6s

Success rate by 100-partition window

  0- 99   96.6%   ← warmup; manual allowance approvals mid-bucket
100-199   99.0%
200-299   98.0%
300-399   99.0%
400-499   99.0%
500-599   98.0%
600-699   96.0%
700-799   98.0%
800-899   91.9%   ← visible degradation correlated with public RPC rate-limiting tail

Error category breakdown

Category Count Cause
over rate limit (Base Sepolia public RPC -32016) 185 environmental noise (clustered 24-27/hr in peaks)
NO_DATA_IN_SWM / MERKLE_MISMATCH_IN_SWM 48 SWM gossip lag on retries — recovers within 3s sleep
storage_ack_insufficient 36 quorum failures, mostly cascading from rate-limit
operation was aborted (3rd-retry timeout) 12 gives up after 3× retries
TooLowAllowance 2 all in calibration before manual approve workaround; this PR closes that gap permanently
InvalidTokenAmount 1 one-off contract revert

What this validates

  • The 1n floor fix this PR introduces is the only application-level issue this run hit. Every other failure mode trances back to environmental Base Sepolia public-RPC throttling.
  • 97.3% sustained success at ~10s/publish pacing for ~9.5 hours is solid baseline for mainnet.
  • No new daemon-level bugs surfaced over 865 successful publishes.

Artifacts kept locally

~/.dkg-publish-stress/
├── checkpoints/26may2.json    283 KB  (834 kcs recorded)
└── logs/main-26may2.stdout    221 KB  (full publish-loop log)

Cross-reference

branarakic added a commit that referenced this pull request May 27, 2026
test(chain): adapter-level coverage for V10 publish/update approval gate (#720 follow-up)
branarakic pushed a commit that referenced this pull request Jun 1, 2026
…publishes (#875 review)

Codex review on PR #875 flagged a false-positive in the new
`ensureV10ApproveTrac` diagnostic warn: the prior guard fired whenever
`targetAllowance === V10_PUBLISH_ONCHAIN_MIN_ALLOWANCE` (= 1n) and
`policy.mode === 'per-publish'`, but `targetAllowance === 1n` is also the
correct outcome for a legitimate `tokenAmount === 1n` publish — where
1 wei is the real publish cost, not the #720 floor workaround. The warn
would have mislabelled that approval as the workaround.

Tightens the guard to additionally require `tokenAmount === 0n`, which
is the only path through `effectivePublishAllowance` where the floor
actually lifts `targetAllowance` above the caller's `tokenAmount`. The
warn message drops the `tokenAmount=…` interpolation in favour of a
literal `tokenAmount=0` since that's now invariant for the branch.

Pins both behaviours with two new cases in
`evm-adapter.unit.test.ts`:

- positive: `tokenAmount === 0n, allowance < 1n` → approve fires AND
  the warn fires exactly once with the `#720` wording.
- negative: `tokenAmount === 1n, allowance < 1n` → approve still fires
  (1 wei is the real cost) BUT the warn does NOT fire. Directly
  closes the Codex finding.

Both use `vi.spyOn(console, 'warn').mockImplementation(...)` matching
the existing pattern in `filter-error-silencer.test.ts`.

Test count: 132 → 134 in `packages/chain` unit suite. No behaviour
change beyond the tightened diagnostic guard.

Co-authored-by: Cursor <cursoragent@cursor.com>
matic031 pushed a commit to KilianTrunk/dkg that referenced this pull request Jun 2, 2026
… tooling

End-to-end harness used to stress-test V10 publishing against a real DKG
node and observe on-chain Random Sampling activity. Built and battle-tested
during the rc.12 pre-mainnet sweep (Base Sepolia, May 2026) — produces the
empirical evidence that motivated PR OriginTrail#720 (chain auto-approve fix) and the
docs update in PR OriginTrail#721 (Rule 4 root-entity recipe).

What's in the bundle (`scripts/testnet-publish-stress/`):

- `fetch-wikidata-music.mjs`     — Wikidata SPARQL → music-themed partitions
                                   (~100 triples each), resumable.
- `preflight.mjs`                 — daemon up-check + wallet balances +
                                   idempotent public-CG create.
- `approve-op-wallets.mjs`        — workaround for PR OriginTrail#720; one-shot
                                   MaxUint256 TRAC approval per op-wallet.
                                   Drop once OriginTrail#720 merges.
- `publish-loop.mjs`              — the actual stress driver. Crash-resumable
                                   via 50-publish JSON checkpoints; includes
                                   the partition-scoped blank-node rewrite
                                   that sidesteps Rule 4 collisions.
- `rs-scan.mjs`                   — read-only Random Sampling observability:
                                   per-core challenge & valid-proof rates,
                                   plus cross-reference of our minted KCs
                                   against on-chain `ChallengeGenerated`.
- `README.md`                     — end-to-end happy path, configuration
                                   knobs, mainnet caveats, and how to
                                   re-trigger the bugs we discovered.

All scripts:
- Read chain config / RPC / token paths from env vars (default to Miles'
  setup + Base Sepolia; trivially re-targetable to mainnet or any other
  V10 EVM).
- Persist all state under `~/.dkg-publish-stress/` so they don't pollute
  the workspace.
- Are standalone — no new deps beyond what's already in the workspace
  (`ethers` is required-via-createRequire so they work from the repo root
  without their own node_modules).

Syntax-checked via `node --check`; manual end-to-end run produced 200+
successful publishes (kcIds 28-237 and counting) and 4 confirmed RS
samplings of our KCs.

Mainnet operators preparing for rc.12 launch can use this as a sanity-check
harness before opening their node to real traffic.

Co-authored-by: Cursor <cursoragent@cursor.com>
matic031 pushed a commit to KilianTrunk/dkg that referenced this pull request Jun 2, 2026
…ate (OriginTrail#720 follow-up)

PR OriginTrail#720 shipped the `effectivePublishAllowance` / `computeApprovalAction`
helpers with thorough pure-function tests, but the actual `publishV10` /
`updateV10` call sites that wire `token.allowance()` → policy dispatch →
`token.approve()` had no adapter-level coverage. Codex flagged this in
the OriginTrail#716 review-consolidation audit: the helper tests cannot catch
mistakes in the call-site wiring (wrong signer, wrong KA address, swapped
labels, swallowed approve failures, missed `1n` floor at the boundary
between helper and adapter, etc.).

This PR closes that gap.

## Refactor

`publishV10` and `updateV10` had two near-identical inline blocks
(`evm-adapter.ts:2362-2382` and `:2796-2816`) that read the allowance,
called `computeApprovalAction`, and conditionally broadcast `approve`.
Extracted into a single private helper:

  `EVMChainAdapter.ensureV10ApproveTrac(signer, kav10Address, tokenAmount, txLabel)`

Both call sites now delegate. Pure literal extraction — no behaviour
change (verified by the existing helper tests plus the new ones below).

The extraction also gives the tests a single seam to mock around
(`(a as any).contracts.token` + spy on `sendContractTransaction`),
without dragging the broadcast / failover / signing machinery into the
unit-test surface.

## New tests (`evm-adapter.unit.test.ts`, +33 cases)

`ensureV10ApproveTrac — per-publish (default) approval gate`
  - zero-cost publish on fresh wallet → approve(1n)  ← the OriginTrail#720 mainnet
    revert scenario, now asserted end-to-end at the adapter layer
  - metadata-only update with existing 1n allowance → NO approve
  - zero-cost publish with comfortable leftover allowance → NO approve
  - positive tokenAmount with empty allowance → approve(tokenAmount)
  - positive tokenAmount with partial allowance → approve(tokenAmount)
  - positive tokenAmount fully covered → NO approve
  - boundary: allowance exactly equals tokenAmount → NO approve
  - read-only adapter (no token contract bound) → no-op

`ensureV10ApproveTrac — replenishing policy`
  - fresh wallet → approve default 1000 TRAC ceiling
  - allowance comfortably above 10% threshold → NO approve
  - allowance below threshold → refill to target
  - custom targetAllowance + refillBelowFraction honoured both sides
    of the threshold boundary

`ensureV10ApproveTrac — unlimited policy`
  - fresh wallet → approve MaxUint256
  - wallet with MaxUint256 live → NO approve
  - wallet with partial residual ≥ publish floor → NO approve
    (defensive policy-switch case)
  - external revoke (allowance=0) → re-approves MaxUint256

`ensureV10ApproveTrac — call-site invariants`
  - publish label passed through verbatim (on-chain tracing)
  - update label passed through verbatim
  - token contract connected to operational signer (not admin)
  - allowance read against the passed-in KA address (no cache leak)
  - approve broadcast failures propagate (publish/update aborts cleanly)
  - 2^200 allowance handled without Number coercion (bigint safety)

All 126 unit tests pass; `tsc` build clean. No production-code semantics
changed.

Closes the OriginTrail#720 follow-up requested in OriginTrail#716's review audit.

Co-authored-by: Cursor <cursoragent@cursor.com>
matic031 pushed a commit to KilianTrunk/dkg that referenced this pull request Jun 2, 2026
…dler + canonical CG keying (OriginTrail#729 Bug 4)

Adds `packages/publisher/test/v10-ack-v2-chunked.test.ts` — 8 cases
exercising the LU-11 / OT-RFC-39 V2 chunked ACK path landed by
PR OriginTrail#715/OriginTrail#717 and the canonical-CG-keying fix in PR OriginTrail#729 Bug 4.

Background
==========

OriginTrail#716 review-consolidation audit flagged this as the closest analogue
of the gap PR OriginTrail#735 closed for OriginTrail#720: helper-level primitives (chunked
AEAD, ciphertext Merkle tree, proto wire format, on-chain commitment
fields) are well tested, but the StorageACKHandler that wires them
together at the ACK boundary has zero direct coverage. The existing
`v10-ack-edge-cases.test.ts` thoroughly covers the V1 (inline
staging quads / inline encrypted blob) paths but skips V2 entirely.

Worse, PR OriginTrail#729 Bug 4 shipped a fix to the V2 ACK `loadChunk` graph
canonicalisation (`normalizeContextGraphIdForChunkStore` returning
null → wildcard `GRAPH ?g` fallback) without a regression test —
any future refactor of that hook could silently re-introduce the
"keccak the decimal string '42'" miss.

Scope
=====

Eight cases — happy paths first, then the regression and decline
shapes:

1. **Happy path** — cleartext CG, canonicalising normalizer, chunks
   persisted under `ciphertextChunkStoreGraph(canonical(swmGraphId))`,
   ACK signed. Pins the production canonicalisation path.

2. **OriginTrail#729 Bug 4 regression** — V2 intent omits `swmGraphId`,
   normalizer returns `null` on every input → handler MUST widen
   to `GRAPH ?g` and still find the chunks. The pre-fix code
   keccak'd the decimal `cgId` ('42') and missed every persisted
   chunk; the test now locks the fix in.

3. **Legacy no-normalizer shim** — `normalizeContextGraphIdForChunkStore`
   absent → uses raw `swmGraphId` literally, preserving pre-fix
   behaviour for callers that haven't wired the hook.

4. **Multi-CG isolation (PR OriginTrail#715 / ciphertext-chunk-store.ts:28-44)**
   — two CGs publish identical V10 KCs (same batchId, since it's
   plaintext-derived) but persist chunks under different canonical
   named graphs. ACK for CG-A must only see CG-A's chunks; an
   intent claiming CG-B's root for the same batchId is correctly
   declined with `CIPHERTEXT_ROOT_MISMATCH`. This pins the Codex
   finding that drove the per-CG named-graph design.

5. **`MISSING_CIPHERTEXT_CHUNKS` decline** — claim count=4, persist
   only indexes 0 and 2 → declines after the 10-second retry window
   with the missing indexes in the message ("missing 2/4 ... 1,3").

6. **`CIPHERTEXT_ROOT_MISMATCH` decline** — all chunks present but
   publisher lies about the root → declines fast.

7. **Curated-only gate** — `isCgCurated` returns false → declines
   with `SIGNER_NOT_REGISTERED` (the V2-curated-only gate at
   `storage-ack-handler.ts:296-310`). Closes the bypass concern
   called out in the inline comment above the V2 branch.

8. **stagingQuads forbidden on V2 wire** — V2 intent with non-empty
   `stagingQuads` is rejected (the chunked path never carries
   inline ciphertext).

Test harness
============

Real `OxigraphStore` (mirrors `v10-ack-edge-cases.test.ts` pattern),
real `encodePublishIntent` / `decodeStorageACK` proto round-trips,
real `buildCiphertextChunksRoot` Merkle tree construction. The new
`buildV2IntentBytes(opts)` helper derives `ciphertextChunksRoot`,
`ciphertextChunkCount`, and `publicByteSize` from the chunks list
unless explicitly overridden — production wire shape, not a fake.
The `seedChunks(store, opts)` helper inserts chunk literals under
the same `(graph, subject, predicate)` layout that
`ingestSwmCiphertextChunkEnvelope` in `dkg-agent.ts` writes.

Verification
============

- `pnpm --filter @origintrail-official/dkg-publisher build` — clean
- `vitest run test/v10-ack-v2-chunked.test.ts` — 8/8 pass
- `vitest run test/v10-ack-v2-chunked.test.ts test/v10-ack-edge-cases.test.ts`
  — 53/53 pass (8 new + 45 existing); no regressions to the V1 suite

Closes audit finding flagged by the OriginTrail#716 review-consolidation deep dive.
Companion to PR OriginTrail#735 (OriginTrail#720 adapter coverage) and OriginTrail#737 (OriginTrail#700 drain bug).

Co-authored-by: Cursor <cursoragent@cursor.com>
matic031 pushed a commit to KilianTrunk/dkg that referenced this pull request Jun 2, 2026
…onder + OriginTrail#729 Bug 5 canonical CG keying

Adds `packages/agent/test/lu11-handle-get-ciphertext-chunk.test.ts`
— 6 cases exercising the LU-11 / OT-RFC-39 `get-ciphertext-chunk`
sync verb responder shipped by PR OriginTrail#717 and the canonical-CG-keying
fix in PR OriginTrail#729 Bug 5.

Background
==========

OriginTrail#716 review-consolidation audit flagged this as one of the
critical adapter-wiring gaps in the same class as OriginTrail#720/OriginTrail#735:
helper-level signing primitives (`mintSignedCiphertextChunkCatchupRequest`,
`verifySignedCiphertextChunkCatchupRequest`, replay guard) are
present, but the responder that wires them together with the
5-layer authority stack and the SPARQL chunk lookup is completely
untested.

Worse, PR OriginTrail#729 Bug 5 shipped a fix to the responder's chunk-graph
lookup without a regression test:

  - Pre-fix: `gossipWireIdFor(req.contextGraphId)` was called
    unconditionally. For a numeric on-chain id like "42" this
    keccak'd the literal decimal string and produced a graph URI
    nothing was ever persisted under — every late-joining core's
    backfill request silently returned "chunk not found" even
    when the bytes were on disk under the curator's nameHash.

  - Fix: routes through `canonicalChunkStoreCgIdOrNull` which
    returns `null` for inputs it can't safely canonicalise; the
    responder then widens to a `GRAPH ?g` wildcard scan.

Scope
=====

Six cases — happy path, the Bug 5 regression, structured-deny shapes,
and the replay-guard boundary:

1. **Happy path** — subscribed cleartext CG → keccak wire hash →
   scoped lookup → chunk returned. Pins the production
   canonicalisation path the responder relies on.

2. **OriginTrail#729 Bug 5 regression** — numeric `contextGraphId = "42"` with
   no local CG mapping → `canonicalChunkStoreCgIdOrNull` returns
   `null` → handler widens to `GRAPH ?g` → finds the chunk
   persisted under the curator's real nameHash graph. Locks the
   fix in so a future refactor can't silently re-introduce the
   decimal-string keccak miss.

3. **`chunk not found` decline** — authorised requester +
   canonicalisable CG but no chunk persisted → structured
   `denied: 'chunk not found'` with echoed `(contextGraphId,
   batchIdHex, chunkIndex)` for requester correlation.

4. **Unauthorised requester** — full 5-layer auth fall-through:
   none of `resolveOnChainParticipantAgents`, `resolveBeaconPinnedCuratorEoa`,
   `getContextGraphAgentGateAddresses`, `getContextGraphAllowedPeers`,
   `chain.getIdentityIdForAddress` admit the requester →
   structured `denied: 'requester EOA not in any of: ...'`
   (or `'no authority source available'` when every probe
   returns null/undefined on MockChainAdapter).

5. **Malformed request bytes** — decoder throws → handler maps to
   `denied: 'malformed request: ...'` with defensive defaults on
   the echo fields (empty `contextGraphId`, empty `batchIdHex`,
   `-1` chunkIndex) so an attacker-controlled garbage payload
   can't leak back through the response envelope.

6. **Replay-guard boundary** — same wire bytes twice (same nonce,
   same issuedAtMs, same signature) → first attempt succeeds,
   second is rejected with `denied: 'replayed chunk-catchup nonce'`.
   Pins `ciphertextChunkCatchupReplayGuard.recordIfFresh()` so
   the defensive boundary against signed-envelope replay holds.

Test harness
============

Real `DKGAgent` instance booted on `MockChainAdapter`. The
responder method is reached through an `(agent as unknown as
ResponderInternals)` cast — same pattern as the existing
`swm-sender-key-pending-by-agent.test.ts`. Real ciphertext-catchup
proto round-trip (`mintSignedCiphertextChunkCatchupRequest` →
`encodeCiphertextChunkCatchupRequest` →
`handleGetCiphertextChunk` → `decodeCiphertextChunkCatchupResponse`),
real EIP-191 personal-sign via `ethers.Wallet.signMessage`. The
OT-RFC-39 fifth authority is exercised by monkey-patching
`chain.getIdentityIdForAddress` on the agent's chain adapter —
the other four authorities answer null/undefined naturally on
MockChainAdapter.

Verification
============

- `pnpm --filter @origintrail-official/dkg-agent build` — clean
- `vitest run test/lu11-handle-get-ciphertext-chunk.test.ts` —
  6/6 pass

No production code changes. Test-only PR.

Closes audit finding flagged by the OriginTrail#716 review-consolidation deep dive.
Companion to PR OriginTrail#735 (OriginTrail#720), PR OriginTrail#737 (OriginTrail#700 drain bug), PR OriginTrail#738
(OriginTrail#729 Bug 4 V2 ACK loadChunk canonical keying).

Co-authored-by: Cursor <cursoragent@cursor.com>
matic031 pushed a commit to KilianTrunk/dkg that referenced this pull request Jun 2, 2026
…tors (OriginTrail#871)

Investigation of OriginTrail#871 — "rc.12 curator wallet's approve(KAV, X) confirms but
allowance stays at 1 wei" — concluded that the user's manual approve did
update on-chain allowance to 10 TRAC (verified via Approval event replay at
block 42270519, tx 0x9f590889a0...). The reported "ghosting" is most likely a
read-side artifact: the issue body itself lists a typo'd KAV address pointing
at an empty EOA, and the surviving symptom (stale 1-wei reads) is consistent
with provider/RPC caching of `eth_call` for the curator's
`(owner, spender)` slot.

The 1-wei "dust" the user observed _before_ their manual approve is real: it's
the daemon's per-publish auto-approve floor (`V10_PUBLISH_ONCHAIN_MIN_ALLOWANCE`
= 1n), the documented OriginTrail#720 workaround for the contract's `transferFrom(..., 1n)`
minimum on zero-cost publishes. That floor is silent, so operators who inspect
on-chain allowance and see "1 wei" reasonably mistake it for a stuck approval.

This change adds one diagnostic log line in `EVMChainAdapter.ensureV10ApproveTrac`
when the per-publish policy emits its floor approval (`targetAllowance == 1n`
and `mode == 'per-publish'`), explicitly identifying the resulting 1-wei
allowance as the OriginTrail#720 workaround. No behaviour change in the production path —
only the log is new; the underlying floor logic, `effectivePublishAllowance`,
and `computeApprovalAction` invariants are unchanged. The existing
`ensureV10ApproveTrac` unit suite in `evm-adapter.unit.test.ts` (5 cases that
hit the per-publish floor branch) continues to pass.

Full investigation note with on-chain Approval event proofs, hypothesis-by-
hypothesis analysis, and the spender-address typo finding is at
`docs/investigations/871-curator-wallet-allowance-ghosting.md`.

Co-authored-by: Cursor <cursoragent@cursor.com>
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