fix(chain): close TRAC auto-approve gap (1n floor) + configurable allowance policy#720
Merged
Merged
Conversation
…/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>
…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>
12 tasks
Contributor
Author
Empirical validation — 9.5h publish-stress runFollowing merge, ran a continuous publish loop from a Miles edge node against Base Sepolia ( Window: 2026-05-26 23:38 → 2026-05-27 09:11 UTC (≈ 9h 33m, ~10s pacing) Headline results
Per-publish latency (n=865)Success rate by 100-partition windowError category breakdown
What this validates
Artifacts kept locallyCross-reference
|
This was referenced May 27, 2026
Merged
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>
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
Two commits, both about how the EVM adapter sizes its TRAC allowance approval before a V10 publish or update:
fix(chain): enforce 1n on-chain TRAC allowance minimum on V10 publish/update— closes theTooLowAllowance(token, 0, 1)revert we reproduced on Base Sepolia. The auto-approve checkcurrentAllowance < params.tokenAmountevaluatesfalsewhenever the JS-sidetokenAmount === 0n(testnet pricing, dust CGs on mainnet, pricing-oracle edge cases), but the contract still pullstransferFrom(..., 1n)from the direct-spend branch. Floor the approval at1n.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.approvalPolicyindkg.config.yaml) so operators can choose between three modes; the defaultper-publishis bit-for-bit identical to today's behaviour.The second commit builds on the first — the
effectivePublishAllowancehelper 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-26mayCG, 800+ publishes viascripts/testnet-publish-stress/publish-loop.mjslanding in PR #722):TooLowAllowance(token, 0, 1)on its first publish (we had to shipapprove-op-wallets.mjsas a workaround).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
per-publish(default, today's behaviour)tokenAmount > previousAllowancereplenishingtargetAllowanceunlimited(V9 pattern)Files touched
chainsrc/chain-adapter.tsApprovalPolicyMode,ApprovalPolicy, defaultschainsrc/evm-adapter.tseffectivePublishAllowance(1n floor) +computeApprovalAction(policy dispatch); both V10 call sites swap to itchainsrc/index.tschaintest/evm-adapter.unit.test.tsagentsrc/dkg-agent-types.tschainConfig.approvalPolicy?: ApprovalPolicyagentsrc/dkg-agent.tsEVMChainAdapterconstructorclisrc/config.tsApprovalPolicyConfig(YAML-friendly, stringly numerics) +resolveApprovalPolicy()clisrc/daemon/lifecycle.tschain.approvalPolicy→ agentchainConfigclitest/config.test.tscliskills/dkg-node/SKILL.mdInvariants pinned down by tests
Every policy mode honours these (exercised explicitly in the cross-mode invariant tests):
targetAllowance >= effectivePublishAllowance(tokenAmount)— even a misconfiguredreplenishingtarget gets raised to the on-chain 1n floor so the immediate publish never reverts.needsApproveis monotone incurrentAllowance— strictly more existing allowance never flipsfalsetotrue.per-publishrather 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 passpnpm --filter @origintrail-official/dkg exec vitest run -c vitest.unit.config.ts— 468/468 passtscgreen across chain → agent → mcp-dkg → cliTooLowAllowancerevertsreplenishingmode, observe one approve tx every ~9 publishes instead of every publish where cost grows