Skip to content

feat(publisher): adopt already-minted KAs on KaIdAlreadyMinted revert - #1878

Open
branarakic wants to merge 3 commits into
mainfrom
feat/publish-adopt-existing-mint
Open

feat(publisher): adopt already-minted KAs on KaIdAlreadyMinted revert#1878
branarakic wants to merge 3 commits into
mainfrom
feat/publish-adopt-existing-mint

Conversation

@branarakic

Copy link
Copy Markdown
Contributor

Summary

A transient error during confirm-wait — an RPC getBlock failure, or a replacement fee too low nonce race under concurrent publishing — can land a mint on-chain while the publisher records failure: the KA stays swm-shared, the local _verifiable_memory graph is never materialized, and every retry reverts KaIdAlreadyMinted forever. The publisher's local VM then permanently under-counts its own successful on-chain publishes (observed live: 9 of 410 KAs on a bulk load; chain said 410, publisher said 401).

KaIdAlreadyMinted(kaId) for our own reserved id is cryptographic proof the publish succeeded — the kaId packs author << 96 | number and the contract enforces the author binding at mint. This PR makes the publisher adopt it instead of failing:

  1. Decode the custom error (getKaIdAlreadyMintedKaId, structured err.revert decode only — no string matching; adoption is state-changing) and require the decoded kaId to equal the locally reserved id of a sealed graph publish (graphPublish && precomputedAttestation).
  2. Verify chain truth (ChainAdapter.getMintedKnowledgeAssetProvenance): exactly one on-chain merkle root and it byte-equals the locally sealed root (root mismatch ⇒ typed KA_ID_COLLISION, >1 roots ⇒ KA_SUPERSEDED → named recovery, CG binding mismatch ⇒ KA_CG_MISMATCH — all refuse to adopt, loudly).
  3. Recover the mint's provenance from the chain: binary-search the mint block by the root's stored block.timestamp, scan a ±128-block window for KnowledgeAssetCreated(kaId indexed) via the existing failover-paginated queryEventLogsPage, re-verify the event's merkleRoot against the sealed root, and take txHash/blockNumber/txIndex/author. If the log is unrecoverable (pruned RPC) the original error is rethrown — never synthesize a txHash (the finalization-handler.ts confirmed-metadata invariant).
  4. Synthesize OnChainPublishResult and fall through the unchanged success path — VM graph replace, confirmed metadata with OnChainProvenance, SWM drain, lifecycle/pointer stamps, and the finalization gossip all run exactly as a normal confirmed publish; the local state is by-construction identical.

Live validation (Base Sepolia, public curated CG, on-chain id 126)

  • 9/9 stranded KAs adopted on first retry: status: confirmed, correct UALs, 9 distinct recovered tx hashes matching the original mints.
  • Publisher VM count converged exactly to the chain (getContextGraphKaCount = 410).
  • Zero gas: adoption is chain-reads only.
  • Replica ripple: a syncing peer materialized +9 VM KAs within the same minute via the fall-through's finalization gossip carrying the recovered provenance — no chain sweep needed.

Scope guards

  • Only the sealed graph-publish path adopts (the seal preflight is what makes root-equality proof of authorship); the legacy non-graph mint path rethrows unchanged.
  • Adapters without getMintedKnowledgeAssetProvenance (mocks) rethrow unchanged.
  • A lagging RPC cannot cause a false adopt (it would not revert at estimateGas at all); a fabricating RPC would have to forge both the storage root and a matching event log — the same read-trust model the chain-reconcile sweep already accepts.

Tests

  • packages/chain (enrich-evm-error-extra.test.ts): getKaIdAlreadyMintedKaId classifier — direct revert-data decode through the real aggregate ABI (kaId > 2^53 to catch float round-trips), nested err.cause recursion, foreign custom error (TooLowAllowance) → undefined, garbage data → undefined.
  • packages/publisher (publish-adopt-existing-mint.test.ts, integration-shaped — drives the real graph-scoped publish() with a sealed precomputedAttestation against a mock chain): happy adopt (status confirmed, synthesized txHash flows through, provenance called exactly once with (reservedKaId, sealedRoot, cgId)); foreign-kaId → rethrows the identical original error with zero provenance calls; provenance null → rethrows the original.
  • Both files added to the packages' explicit vitest.unit include lists; full suites green (chain 669 passed, publisher 443 passed).
  • Includes a type-only test-helper fix: PrecomputedAttestation in publisher/test/_helpers/seal.ts was missing reservedKaId (which buildSeal always returns and PublishOptions requires).
  • Follow-up candidate (not in this PR): hardhat e2e that republishes the same sealed content against a real revert and asserts byte-identical store state vs the first publish.

Relation to #1871

Complementary: #1871 makes replicas chain-first; this makes the publisher self-consistent at the moment of failure, so stranded-mint gaps never form for it to repair. It also removes the verified-vm-metadata-pending deferral class for the author's own KAs (the author always has the sealed content; this recovers the one missing field — tx provenance — from the chain itself).

🤖 Generated with Claude Code

Branimir Rakic and others added 3 commits July 21, 2026 10:29
A transient error during confirm-wait (RPC getBlock failure, replacement-fee
nonce race) can land the mint on-chain while the publisher records failure,
stranding the KA at swm-shared; every retry then reverts KaIdAlreadyMinted
forever and the publisher's local VM permanently under-counts its own
on-chain publishes (observed: 9 of 410 on a bulk load).

On a KaIdAlreadyMinted revert whose decoded kaId equals the locally reserved
id of a sealed graph publish: verify chain truth (single merkle root equal to
the sealed root, KA bound to the target CG), recover the mint tx provenance
from the KnowledgeAssetCreated log (timestamp-binary-search + padded getLogs
window), synthesize OnChainPublishResult, and fall through the unchanged
success path — local state is by-construction identical to a normal
confirmed publish. Root/CG mismatches throw typed errors (KA_ID_COLLISION /
KA_SUPERSEDED / KA_CG_MISMATCH); an unrecoverable log rethrows the original
error — never synthesize a txHash (finalization-handler invariant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…and publisher interception

Chain: 4 classifier suites in enrich-evm-error-extra.test.ts pin
getKaIdAlreadyMintedKaId — direct CALL_EXCEPTION decode to bigint (packed
kaId beyond 2^53), err.cause recursion (pre-stamped revert AND raw nested
data), TooLowAllowance -> undefined, garbage/non-object -> undefined. The
file is pure-logic, so it joins the vitest.unit include list (runs without
the Hardhat boot; full config still globs it).

Publisher: publish-adopt-existing-mint.test.ts drives the real graph-scoped
publish() path (seal preflight, ACK collection, chain submit, VM storage)
against a MockChainAdapter subclass whose createKnowledgeAssets throws the
enriched { revert: { name: 'KaIdAlreadyMinted', args } } shape. Happy adopt
resolves 'confirmed' with the synthesized provenance as onChainResult and
asserts getMintedKnowledgeAssetProvenance(reservedKaId, sealedRoot, cgId);
foreign kaId and provenance-null both rethrow the ORIGINAL error object
verbatim (never a synthesized txHash). Added to the explicit vitest.unit
include list.

Test-helper fix: PrecomputedAttestation in _helpers/seal.ts now declares
the reservedKaId that buildSeal has always returned (required by
PublishOptions.precomputedAttestation) so sealed option bags typecheck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ock log index

ethers v6 Log.index is the block-global LOG index; the transaction index is
Log.transactionIndex. The synthesized adopt result fed the wrong one into the
scoped _meta last-writer-wins version stamp, which compares txIndex on a
blockNumber tie (GH#842 clobber guard). Matches the normal mint path, which
takes txIndex from the receipt, and the reconcile path, which already reads
log.transactionIndex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* the contract. Verification failures throw typed errors; an unrecoverable
* log (pruned RPC) returns null so the caller rethrows its original error.
*/
async getMintedKnowledgeAssetProvenance(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Bug: The real provenance verifier is untested

What's wrong
This PR's safety claim depends on the new adapter method refusing to adopt a kaId unless storage roots, context graph binding, and the mint event all match the sealed publish. The added publisher test only verifies that publish calls a stubbed hook with the expected arguments, so the most important adoption checks can regress while the new suite stays green.

Example
A regression that removed the root comparison at packages/chain/src/evm-adapter-base.ts:2748, skipped the CG mismatch check at 2772, or returned the first log without checking parsedArgs.merkleRoot at 2812 would still pass the new publisher tests because the provenance hook is stubbed to return a successful OnChainPublishResult.

Suggested direction
Keep the publisher tests for the handoff, but add unit coverage for the EVM adapter method itself so the security-critical chain-truth checks and log-recovery fallback are validated instead of stubbed away.

For Agents
Add focused chain-package tests around EVMChainAdapterBase.getMintedKnowledgeAssetProvenance. Stub readContract, getBlockTimestamp, resolveKaStorageDeployBlock, and queryEventLogsPage to prove: matching root+CG+event returns the synthesized result; empty/no logs returns null; no roots/root mismatch/multiple roots/CG mismatch/event-root mismatch throw the documented codes; non-collision scan failures return null.

// UNCHANGED success path so the local state is by-construction
// identical to a normal confirmed publish. Anything not provably
// ours rethrows unchanged.
onChainResult = await this.adoptExistingMintOrRethrow({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Issue: Keep already-minted recovery behind the chain publish abstraction

What's wrong
The PR solves a chain-specific retry case by spreading one concept across the publisher, the chain interface, the EVM error decoder, and an EVM log recovery method. That makes the publisher responsible for EVM custom-error semantics even though the adapter already owns transaction submission and provenance extraction. Structurally, this is a missed code-judo move: the retry recovery can be expressed as an idempotent publish submission behavior instead of a special branch bolted into a 9k-line publisher method.

Example
A reader now has to follow publish() catch handling, adoptExistingMintOrRethrow, getKaIdAlreadyMintedKaId, the optional ChainAdapter.getMintedKnowledgeAssetProvenance, and the EVM log scan to understand one retry outcome. The existing createKnowledgeAssets(params) call already has the inputs needed for this recovery: reservedKaId, merkleRoot, and contextGraphId.

Suggested direction
Move this into a single chain-owned publish recovery boundary, either by making createKnowledgeAssets handle KaIdAlreadyMinted when an explicit allowAdoptExistingMint/sealed flag is present, or by adding one adapter method that accepts the original error plus the publish params and returns the recovered OnChainPublishResult. That would let the publisher keep the normal submit/success path and delete the exported EVM classifier plus the extra optional provenance hook.

For Agents
Look at packages/publisher/src/dkg-publisher.ts around the createKnowledgeAssets catch and packages/chain/src/evm-adapter-base.ts/evm-adapter-publish.ts around createKnowledgeAssets. Preserve the current behavior: only adopt an already-minted reserved KA when the caller explicitly allows the sealed-publish recovery, verify root/CG truth, and rethrow the original error when recovery is not provable. Prove the refactor with the existing adopt-existing-mint tests plus a test that the publisher no longer needs to decode KaIdAlreadyMinted itself.

* falls back to string matching on the message / shortMessage / reason /
* nested cause so a stringified or re-wrapped revert is still caught.
*/
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Nit: Do not split the allowance helper from its JSDoc

Why it matters
This file is mostly classifier helpers whose safety depends on precise comments about fallback behavior. Detaching the JSDoc makes the error-classifier section harder to scan and easier to misread.

Suggestion
Move getKaIdAlreadyMintedKaId above the isTooLowAllowanceError comment, or move the allowance JSDoc back down so it remains directly attached to isTooLowAllowanceError.

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.

2 participants