Skip to content

fix: #1840 release the idempotency key on a definite failure - #2372

Open
vaibhav4046 wants to merge 3 commits into
KeeperHub:stagingfrom
vaibhav4046:fix/issue-1840-release-key-on-definite-failure
Open

fix: #1840 release the idempotency key on a definite failure#2372
vaibhav4046 wants to merge 3 commits into
KeeperHub:stagingfrom
vaibhav4046:fix/issue-1840-release-key-on-definite-failure

Conversation

@vaibhav4046

@vaibhav4046 vaibhav4046 commented Sep 9, 2026

Copy link
Copy Markdown

Issue

Closes #1840

The gate stated on that issue has cleared. On 2026-08-13 the decision was recorded as:

We've decided to change this, in the narrow form: an idempotency key is released when the outcome is definite and nothing landed, and held when the outcome is unknown. [...] The work is sequenced behind #2020. Today failed also absorbs a broadcast whose receipt could not be read [...] Releasing on failed before that is fixed would convert your liveness bug into a double-broadcast bug. So #2020 lands first, failed is made to mean what it says, and this change follows it.

#2020 closed on 2026-08-28 via #2162, with #2177 following via #2192 on 08-31. This is the change that was sequenced behind it, in the narrow form that was decided.

What this changes

failExecution and completeExecution already adjudicate the only question that matters: the chain decides, and a receipt that cannot be read yields unconfirmed rather than failed. The direct-execution routes discarded that verdict:

outcome.status === "completed" ? "success" : "failed"

Both failed and unconfirmed landed on the held disposition. A transaction the staticCall preflight rejected before a nonce was allocated then replayed that rejection for the full 24-hour window, and the same key could never recover.

Execution outcome Disposition Why
completed success replayable completed record, unchanged
failed release definite: nothing broadcast, or conclusively rejected
unconfirmed failed (held) the chain has not answered; a retry could double-broadcast

Applied to all five /api/execute/* routes: contract-call, transfer, check-and-execute, [...slug], node. One route fixed and four siblings left with the same collapse is the failure mode ISSUES.md calls out, and a rule written out five times is one refactor away from this returning in one of them, so it lives in lib/idempotency-disposition.ts.

Three things a reader would not predict from the title:

completeExecution reported an unverifiable hash as failed. With result.chainId undefined there is a transaction hash and no chain to check it against, and the old code called that "failed". That is the case the !allVerified branch beside it already argues about. It now returns "unconfirmed". Without this, releasing on failed would free the key for a retry that re-broadcasts a transaction which may already have landed, so the change would have introduced the bug it exists to prevent.

On node, "no hash" is not evidence that nothing happened. Unlike the chain-write routes, an arbitrary step there may have sent a message or delivered a webhook before reporting failure, and failExecution answers "failed" from the mere absence of a hash. That route releases only when a hash existed to adjudicate. The two paths that call failExecution with no receipt at all keep holding the key and say why.

The documentation was made false and is fixed in the same PR. docs/api/direct-execution.md told callers to rotate after a definite result because "A stored failure is replayable for 24 hours". It now separates a definite failure, which releases, from an unreadable receipt, which is held and replays, and points at GET /api/execute/{executionId}/status instead of rotation for the second case. Nothing else in the repository repeats the old claim. specs/api-coverage.json is regenerated by pnpm check:api-docs, which is idempotent on a second run.

Scope

One change. Every part is the same rule: a definite outcome frees the key, an unknown one holds it. The completeExecution fix is not separable, because releasing on failed while failed can still mean "hash we could not check" is the double-broadcast bug the issue thread explicitly refuses to ship. The node gate and the docs edit are the same rule stated for one more route and for callers.

Deliberately not included, though it is the natural next step: the reconciler is the only thing that can turn an unknown outcome into a definite one after the request has gone, and it never touches idempotency, so a held key stays held even once the chain answers. That is independently shippable and correct with this PR reverted, so per CONTRIBUTING.md it is a separate issue. I have it written and will file it rather than fold it in.

How it was verified

pnpm type-check clean. biome check clean on all ten changed files. pnpm check:api-docs green, twice.

Tests, and what they would catch:

  • tests/unit/idempotency.test.ts covers the rule directly, including that the three outcomes map to three distinct dispositions.
  • tests/unit/execute-protocol-idempotency-disposition.test.ts covers both directions of the pair that constrains this issue: a conclusive revert releases, an unreadable receipt holds, and the reported preflight rejection releases so the same key executes again instead of replaying.

One existing expectation moved from "failed" to "release". The test named "finalizes as failed when the write reverts after broadcast" encoded the collapse, so it is renamed and now asserts the released key. That is the behaviour change the issue asked for, not a test bent to fit.

Checked that the tests fail without the fix, by mutation rather than by assertion. Collapsing failed back onto the held disposition:

mutant applied reverted
first attempt 2 failed / 41 passed, route tests green 43 passed
after the fix below 4 failed / 39 passed, both files red 43 passed

The first run exposed a real weakness. The route test has to mock @/lib/idempotency because that module reaches the database, and it mirrored the rule inside its own mock factory, so it could never fail if the real rule regressed. The rule therefore moved to lib/idempotency-disposition.ts, a module with no imports; lib/idempotency.ts re-exports it so no route import changes; and the route test now pulls the real function through vi.importActual. The same mutation now kills tests in both files.

Screenshots

Nothing renders.


  • Targets staging
  • Title carries the issue number, or an exemption applies

Environment note, not a claim about this change: my checkout is Windows with core.autocrlf=true, so a repo-wide pnpm check reports CRLF errors on all 2191 files and a full tests/unit run has pre-existing failures. I normalised only the files I touched, and compared the suite against clean staging per-file rather than assuming: seven files fail identically before and after, and workflows-execute-alias.test.ts is a 10s timeout flake that fails 4 of 5 runs on untouched staging. Detail in the comment below.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

About the build check on this pull request

This pull request comes from a fork, so GitHub does not pass it the credentials build normally uses for our image registry cache and staging build configuration. The build still runs and still compiles the image, so a red build here is real; it just takes longer than on team branches.

Every workflow run on a pull request from a fork also waits for a maintainer to approve it, so checks can sit at "awaiting approval" for a while after each push. Nothing is needed from you for either of these.

@vaibhav4046

Copy link
Copy Markdown
Author

Following up on the environment note with the actual comparison, since asserting "no delta" without showing it is not worth much.

Same eight files, run individually with the identical command on this branch and on untouched staging:

file staging this branch
code-run-code 74 failed / 6 passed 74 failed / 6 passed
docs-markdown no tests no tests
mcp-meta-tools 46 passed 46 passed
migration-0086-drop-signup-block 2 failed / 6 passed 2 failed / 6 passed
migration-0090-block-deactivated-sessions 1 failed / 7 passed 1 failed / 7 passed
sandbox-child-source 58 failed / 43 passed 58 failed / 43 passed
step-transaction-hash-chain-id 6 failed / 10 passed 6 failed / 10 passed
workflows-execute-alias flaky, see below flaky, see below

Seven of the eight are byte-identical. The eighth misled me at first: a batched run showed 6 failing files on staging and 7 on this branch, which looked like a regression I had caused. It is a timeout flake. workflows-execute-alias.test.ts has one test, "re-exports the same POST handler as the singular route", and it dies on Test timed out in 10000ms -- 4 of 5 runs on untouched staging, 2 of 4 on this branch. It fails more often without my change than with it.

So the delta from this PR is zero, and none of the seven genuinely-failing files import anything it touches.

Worth flagging separately, since it is yours rather than mine: on a Windows checkout those seven fail before any change, and workflows-execute-alias is a real flake at a 10s limit. Happy to open separate issues for either if that is useful, but I did not want to bundle unrelated fixes into this PR.

@vaibhav4046

Copy link
Copy Markdown
Author

Pushed two cleanup commits. No behaviour change; this is only to make the diff reviewable.

+1228 −1060+200 −32.

  • docs/api/direct-execution.md was rewritten with CRLF endings by my Windows checkout, so an 18-line edit rendered as 950 deletions and 962 additions. That was most of the diff and none of it was a change to anything. Restored to LF; git diff -w --ignore-cr-at-eol against staging is the same 18 lines before and after.
  • specs/api-coverage.json had backslash source paths. Regenerating the spec on Windows turned docs/api/user.md into docs\api\user.md across 86 entries. That one was not cosmetic, since the paths are consumed as written, so the file was only correct on the machine that generated it. Restored to forward slashes. What remains in that file is the real change: 86 line numbers moved because the doc gained 12 lines.

Sorry for the noise in the first push.

One thing to know before you look at CI, since I would rather flag it than have you find it. tests/unit/execute-protocol-idempotency-disposition.test.ts is timing-sensitive at its 10s timeout and flakes under load, and it does so on untouched staging too, so it is not something this branch introduced:

branch run result
staging both files together 37 passed
staging this file alone 1 failed / 5 passed, 19.7s
this branch both files together 43 passed, 9.7s
this branch both files together, again 1 failed / 42 passed, 17.0s

Same command, same tree, different answers, and the failure is always Test timed out in 10000ms on whichever test happens to run first, never an assertion. I have deliberately not touched it here: it is pre-existing, unrelated to the idempotency disposition, and widening this PR to chase it would make it harder to review, not easier. Happy to raise it separately if you want it fixed.

@vaibhav4046
vaibhav4046 force-pushed the fix/issue-1840-release-key-on-definite-failure branch from 2c63c33 to 16175fd Compare September 9, 2026 21:23
@vaibhav4046

Copy link
Copy Markdown
Author

Rebased cleanly on latest staging (eb239c6). Fully mergeable with 0 conflicts; tsgo --noEmit clean, pnpm check:api-docs clean, and all 43 tests passing.

Follow-up for the reconciler path is tracked in #2373 with the architectural questions answered.

@joelorzet joelorzet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The reasoning here is sound and the sequencing behind #2020 was respected. failExecution and completeExecution do adjudicate the question, the routes were discarding that verdict with status === "completed" ? "success" : "failed", and centralising the rule is right because five routes needing the identical mapping means one drifting is indistinguishable from the bug returning.

Putting the rule in an import-free module is a good call for the reason you give: lib/idempotency.ts reaches the database, so route tests mock it wholesale and would otherwise assert against a copy that can never catch a regression in the real rule.

I traced the hazard the #1840 gate warned about, and it is closed everywhere a hash exists. chain-adapter/evm.ts:462 is a deliberate catch-all that turns every post-broadcast failure into OnChainPendingError carrying the hash, including unknown ethers codes, and resolveSponsoredSendError applies the same discipline on the Turnkey path. So once a tx object exists, failed genuinely means definite.

One window is not covered, and the fix is already written elsewhere in this PR. Inline.

Comment thread app/api/execute/transfer/route.ts Outdated
idem,
NextResponse.json(responseBody, { status: HttpStatus.ACCEPTED }),
outcome.status === "completed" ? "success" : "failed"
dispositionForExecutionOutcome(outcome.status)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This calls the helper unconditionally, and failExecution answers failed from the mere absence of a hash. The node route recognises exactly this and guards it:

// Only a hash lets failExecution adjudicate anything ...
const disposition = transactionHash ? dispositionForExecutionOutcome(settled.status) : "failed";

The four chain-write routes need the same guard, for a narrower reason than the node route's.

The case is a lost response on the send call itself: the node accepts eth_sendRawTransaction and puts the transaction in the mempool, then the reply never arrives. The client throws with no tx object, so no hash is ever assigned. That is not something the error classifiers can fix, since "request never arrived" and "request arrived, reply lost" are indistinguishable to the caller.

#1840's rule is "released when the outcome is definite and nothing landed, held when the outcome is unknown". A lost send reply is unknown, but with no hash this path reads it as definite and releases. Nonces come from getTransactionCount(address, "pending") (nonce-manager.ts:212, transaction-manager.ts:433), so a retry sees the stranded transaction, takes the next nonce, and both land.

Before this change that window held the key, which was the liveness cost #1840 was filed about. This is the one place the change trades it for a possible second broadcast.

Applying the node route's guard here, and at contract-call/route.ts:259, [...slug] and check-and-execute, keeps the intended behaviour and closes it: no hash means nothing was adjudicated, so hold.

@vaibhav4046 vaibhav4046 Sep 10, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Applied across all four routes (transfer, contract-call, check-and-execute, and [...slug]):

const disposition = result.transactionHash
  ? dispositionForExecutionOutcome(outcome.status)
  : "failed";

You are completely right about the lost-send-reply window: without a hash, failExecution answers failed from the absence of a hash alone, which would have released the key and let a retry allocate pending nonce+1 over a transaction sitting in the mempool. Holding the key on "failed" when result.transactionHash is absent closes that window.

Updated tests/unit/execute-protocol-idempotency-disposition.test.ts to assert that an unhashed write failure holds the key ("failed" disposition).

Rebased cleanly on latest staging (2df88cec6), specs/api-coverage.json regenerated with forward slashes, tsgo --noEmit and biome check clean, and all 43 tests passing.

@joelorzet joelorzet added the changes-requested Triage: reviewed, changes needed from the contributor label Sep 10, 2026
@vaibhav4046
vaibhav4046 force-pushed the fix/issue-1840-release-key-on-definite-failure branch from 16175fd to a3c17bc Compare September 10, 2026 20:22
@vaibhav4046

Copy link
Copy Markdown
Author

Updated with the requested guard from review:

  1. Unhashed write failure holds the key across all 4 chain-write routes:
    Guarded at transfer/route.ts, contract-call/route.ts, check-and-execute/route.ts, and [...slug]/route.ts:

    const disposition = result.transactionHash
      ? dispositionForExecutionOutcome(outcome.status)
      : "failed";

    When writeContractCore or other send fails without producing a transactionHash (e.g. lost response on eth_sendRawTransaction), the key is held ("failed" disposition for 24h) rather than released, closing the window where a retry could allocate pending nonce+1 and double-broadcast.

  2. Route test updated:
    tests/unit/execute-protocol-idempotency-disposition.test.ts now asserts expect(lastDisposition()).toBe("failed") when a write fails without a hash, documenting the lost-send mempool guard.

  3. Rebased cleanly on latest staging (2df88cec6):

    • Resolved conflict in specs/api-coverage.json via regeneration.
    • Preserved LF line endings and forward slashes.
    • pnpm check:api-docs passes cleanly with 0 docs drift.
    • tsgo --noEmit and biome check pass cleanly.
    • All 43 idempotency tests passing.

An idempotency key is now released when the outcome is definite and nothing is
left in flight, and held when the outcome is unknown.

The platform already computes that distinction. failExecution and
completeExecution return "failed" or "unconfirmed", and the chain decides which:
a conclusive receipt means failed, an unreadable one means the broadcast may
still land. The direct-execution routes discarded the verdict by writing
`outcome.status === "completed" ? "success" : "failed"`, collapsing both into a
single held key. A caller whose transaction was rejected by the staticCall
preflight, before a nonce was even allocated, then replayed that rejection for
the full 24-hour window and could never recover on the same key, while rotating
the key to escape it reopens the double-broadcast hole the key exists to close.

The rule lives in lib/idempotency-disposition.ts, an import-free module so route
tests that mock @/lib/idempotency (which reaches the database) still test the
real rule rather than a mock copy that could drift.

The disposition across all execution routes (transfer, contract-call,
check-and-execute, [...slug], and node) is guarded by transactionHash:
  const disposition = result.transactionHash
    ? dispositionForExecutionOutcome(outcome.status)
    : "failed";

Without a transactionHash, failExecution answers "failed" from the mere absence
of a hash. In the lost-send-reply window where eth_sendRawTransaction entered
the mempool but the client timed out before receiving the tx object, releasing
the key would allow a retry to allocate pending nonce+1 and cause both transactions
to land. Requiring result.transactionHash before adjudicating "failed" as "release"
ensures unhashed failures hold the key ("failed") for the 24h window.

Exceptions handled:
- completeExecution reported a hash it could not check against a chain
  (chainId undefined) as "failed". It now returns "unconfirmed".
- On the node route, unhashed step failures hold the key because an arbitrary
  node step may have performed side effects before failing.

Documentation updated in docs/api/direct-execution.md and specs/api-coverage.json
regenerated. Comprehensive unit and route test coverage added.
@vaibhav4046
vaibhav4046 force-pushed the fix/issue-1840-release-key-on-definite-failure branch from a3c17bc to 468be65 Compare September 10, 2026 20:31
@suisuss

suisuss commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

The lost-send window is closed. All four chain-write routes now carry the node route's guard - transfer/route.ts:344-346, contract-call/route.ts:264-266, check-and-execute/route.ts:321-323 and [...slug]/route.ts:342-344 - each using the write core's own result in scope, each with exactly one such call site. I checked all four individually at head rather than assuming the pattern repeated.

Three things the increment surfaces that are worth settling before it lands.

The docs now contradict the code, and a caller following them hits the original bug. docs/api/direct-execution.md:159-163 was untouched and still says a definite failure "such as a revert at inclusion or a rejection caught before the transaction was ever submitted, releases the key: nothing landed and nothing is still in flight. The same key simply executes again." The second example is now false on all four routes: a pre-submission rejection has no hash, so it takes the : "failed" branch and is held for the full 24h. A caller who reads that paragraph and re-sends under the same key gets a replay of the rejection, which is the #1840 symptom it was written to describe. That page needs to move with this change.

That is also the honest cost of the fix, and the PR does not name it. The increment inverted the test that encoded #1840's reported case - tests/unit/execute-protocol-idempotency-disposition.test.ts:302-320 went from "releases the key when a preflight rejection broadcast nothing" to "holds the key when write fails without a transaction hash". A staticCall rejection has no hash, so it is held again, exactly as before the PR. At head, release is reachable only for a hash that verified to a conclusive on-chain failure. The discrimination you actually want - did we ever issue a send - is knowable inside the write core, where every no-hash failure return sits above the line that assigns chainId and is therefore provably pre-broadcast, but nothing on the result type exposes it and errorClass is a fault domain rather than a broadcast fact. Worth saying out loud which of the two you intend to ship.

The release predicate is reverted || safe_inner_failure, not reverted alone. lib/web3/verify-receipt.ts:45-49 puts all three of success, reverted and safe_inner_failure in CONCLUSIVE_STATUSES, and execution-service.ts:213-216 derives "failed" - which lib/idempotency-disposition.ts:51-53 maps to release - from that set. safe_inner_failure is only reachable below the receipt.status === 0 early return at verify-receipt.ts:268-278, so it means the outer execTransaction mined: the Safe's nonce was consumed and its signatures were spent. Releasing there lets a retry spend a second nonce and signature set.

Two qualifications I want to be accurate about rather than overstate. It is currently latent - verify-receipt.ts:102-116 explains that transactions KeeperHub builds pass safeTxGas=0, baseGas=0, gasPrice=0, so Safe's own require reverts the outer transaction to status 0 and this branch cannot fire; the same comment says a path submitting a Safe transaction it did not construct reaches it immediately. And "work that already took effect" is imprecise: the inner call reverted, so the intended work did not happen. What took effect is the transaction - nonce, gas, and with non-zero gasPrice/safeTxGas a refund. That still falsifies the "nothing landed" contract, but it is not a double-application.

There is a live producer of hash-without-chainId, and it makes the completeExecution change load-bearing rather than defensive. plugins/web3/steps/transfer-spl-token-core.ts:514-520 returns transactionHash with no chainId on its type, and node/route.ts:174-188's isTransactionResult gate requires only a string transactionHash and a string gasUsed, both present, so chainId arrives undefined. Had completeExecution kept returning "failed" there, node/route.ts:336 would map it to release and free the key for an SPL transfer that succeeded. So this PR prevents a real double-send, not a hypothetical one. I had this down as unresolved reachability on #2373 and it is now settled - I will correct that there.

The downstream consequence is new and not yours to fix here, but it should be filed: the row is now unconfirmed with a hash, no receipts and a null completedAt, which passes the reconciler's filter. resolveChainId falls through to Number(execution.network), the node route stores the caller's raw config string, so Number("solana") is NaN and reconcileOne stamps a terminal failed with "chain could not be resolved" without reading a receipt - pinned today by tests/unit/reconcile-executions.test.ts:255-266, which asserts the verifier is never called. A successful Solana transfer ends up terminally recorded as failed. Note tests/unit/step-transaction-hash-chain-id.test.ts:33-37 already allowlists the Solana cores as hash-without-chainId on the grounds that "these outputs never reach reconciliation" - true for the workflow path's filter, not for /api/execute/node. The one-line fix is a chainId on that core's success return.

Two smaller ones. lib/idempotency.ts:240-246 still says a call that reached the broadcast path is "never released and a retry can never re-broadcast it", which this PR changes. And three of the four routes you just edited have no disposition test at all - only [...slug] is covered - while the module's own comment says one of them drifting is indistinguishable from the bug returning. The guard is copy-pasted four times with one copy pinned.

Nothing above changes my read that the centralisation is the right call.

…h the code

Addresses the three points raised in review on the previous increment.

The release predicate was reverted or safe_inner_failure, not reverted alone

verify-receipt puts success, reverted and safe_inner_failure in
CONCLUSIVE_STATUSES, so failExecution derived "failed" from a
safe_inner_failure receipt and idempotency-disposition maps that to release.
That branch is only reachable below the receipt.status === 0 early return, so
the outer execTransaction mined: the Safe's nonce was consumed and the owner
signatures for it were spent. Releasing there lets a retry spend a second nonce
and a second signature set.

It is latent today and the fix is still worth landing. Transactions this
codebase builds pass safeTxGas=0, baseGas=0, gasPrice=0, so Safe's own require
reverts the outer transaction to status 0 and the plain status check catches it
first. A path that submits a Safe transaction it did not construct, such as
executing one queued in the Safe UI where the proposer sets safeTxGas, reaches
it immediately.

The precise claim: the inner call reverted, so the intended work did not
happen. What took effect is the transaction itself, its nonce and gas, and with
non-zero gasPrice or safeTxGas a refund. That is not a double-application, but
it does falsify the "nothing landed" contract that release rests on.

The docs contradicted the code, and a caller following them hit the bug

docs/api/direct-execution.md still said a rejection caught before the
transaction was ever submitted releases the key. That has been false on all
four chain-write routes since the no-hash guard landed: a pre-submission
rejection has no hash, takes the "failed" branch, and is held for 24 hours. A
caller who read that paragraph and re-sent under the same key got a replay of
the rejection, which is the symptom the paragraph was written to describe.

Rewritten to tie release to what actually causes it: a hash that verified to a
conclusive on-chain failure, and only that. It now names both consequences a
caller has to plan for, the held pre-submission rejection and the held Safe
inner failure.

Which of the two behaviours this ships

Stated plainly rather than left to be inferred. This ships the conservative
hold: with no hash there is nothing to adjudicate, "never sent" and "sent,
reply lost" are indistinguishable at that point, and a retry that guesses wrong
takes the next pending nonce alongside a transaction already in the mempool.

The reported case in the original issue, a staticCall rejection, is therefore
held again as it was before this work. Recovering that liveness needs the write
core to expose whether a send was ever issued, which is knowable there and is
not on the result type today. errorClass is a fault domain rather than a
broadcast fact and cannot stand in for it. That is a change to the write core's
contract and its own increment, not a rider on this one.

lib/idempotency.ts no longer claims a call that reached the broadcast path is
never released, and failExecution now records that its hash-and-chainId pairing
rests on convention across the routes and plugin steps rather than on the type.

tsgo --noEmit clean, biome clean on both changed files, and the disposition
suite passes 8/8 both with and without this change.
@vaibhav4046

Copy link
Copy Markdown
Author

All three settled, plus the two smaller ones. Pushed as 0e7cb41.

The release predicate. You are right, and it is reverted || safe_inner_failure. failExecution now treats a safe_inner_failure receipt the way it treats an unreadable one, so the row lands unconfirmed and the key is held:

const spentASafeNonce = receipts.some(
  (receipt) => receipt.receiptStatus === "safe_inner_failure"
);

const status =
  receipts.length > 0 &&
  (isInconclusive(receipts) || landedSuccessfully || spentASafeNonce)
    ? "unconfirmed"
    : "failed";

I took your qualification rather than the stronger claim I would have written on my own. The comment says the inner call reverted so the intended work did not happen, and what took effect is the transaction - nonce, gas, and with non-zero gasPrice/safeTxGas a refund. Not a double-application, but it does falsify "nothing landed", which is what release rests on. The reachability note from verify-receipt.ts:102-116 is reproduced at the guard so the next reader does not delete it for being unreachable.

The docs. direct-execution.md:159-163 is rewritten. You were right that a caller following it hit the original bug: the paragraph described exactly the symptom it was written about. Release is now tied to what actually causes it - a hash that verified to a conclusive on-chain failure, and only that - and both consequences a caller has to plan for are named: the held pre-submission rejection, and the held Safe inner failure.

Which of the two I intend to ship, said out loud. The conservative hold. With no hash there is nothing to adjudicate, "never sent" and "sent, reply lost" are indistinguishable at that point, and a retry that guesses wrong takes the next pending nonce alongside a transaction already in the mempool.

So #1840's reported case, a staticCall rejection, is held again as it was before this work, and the PR should not pretend otherwise. That is the honest cost and it is now in the commit message rather than left to be inferred.

Recovering that liveness is the change you described: the write core knows whether a send was ever issued, every no-hash failure return sits above the broadcast, and nothing on WriteContractResult exposes it. I agree errorClass cannot stand in - it is a fault domain, not a broadcast fact. I have deliberately not done it here. It changes the write core's contract and would widen this diff into a surface you would have to re-review from scratch, and the narrowing this PR does is worth landing on its own. Happy to open it as the follow-up if you want it before #2373.

The two smaller ones. lib/idempotency.ts:240-246 no longer claims a call that reached the broadcast path is never released; it now states the actual rule and why everything else on that path is held. failExecution carries a note that its hash-and-chainId pairing rests on convention across the routes and plugin steps rather than on the type, since FailParams permits the pair to come apart.

Reachability in the PR description. Corrected, per your note on #2373. The unconfirmed return in completeExecution is load-bearing rather than defensive: transfer-spl-token-core.ts:514-520 is a live producer of hash-without-chainId and node/route.ts:336 would have mapped failed to release for an SPL transfer that succeeded. I have stopped describing that reachability as zero.

Filed separately: #2432 for the downstream consequence - that row passes the reconciler's filter, Number("solana") is NaN, and reconcileOne stamps a terminal failed without reading a receipt, so a successful SPL transfer is recorded as failed. Including the detail that step-transaction-hash-chain-id.test.ts:33-37 allowlists those cores on the grounds that the outputs never reach reconciliation, which holds for the workflow path and not for /api/execute/node.

Still owed: the three uncovered routes. You are right that the guard is copy-pasted four times with one copy pinned. Disposition tests for transfer, contract-call and check-and-execute are in progress and will land as a second push on this branch shortly - each asserting the no-hash hold, the conclusive-failure release, and success, and each verified to go red when that route's guard is removed. I did not want to hold the fixes above behind them.

tsgo --noEmit clean, biome check clean on both changed files, and execute-protocol-idempotency-disposition passes 8/8 with and without this change. The "releases the lock when the plan limit blocks" timeout I flagged earlier still reproduces identically on untouched staging, so it is not from this branch.

The guard was copy-pasted four times with one copy pinned, and the module's
own comment says one of them drifting is indistinguishable from the bug coming
back. Only [...slug] had a disposition test.

Nine tests added for transfer, contract-call and check-and-execute, in the
existing file rather than a new one, because the point is that one rule is
pinned in one place. Each route gets three cases: a write that fails with no
transaction hash holds the key, a write whose hash verified to a conclusive
on-chain failure releases it, and a successful write finalises.

Each was verified to fail when the guard it pins is removed, with two mutations
per route because the hash check and the mapping pin different tests. Dropping
the hash check turns the hold case from "failed" to "release"; hardcoding
"failed" turns the other two red. The four routes were confirmed byte-identical
to their prior state after every mutation.

Harness notes, since the additions are not free: the read-contract mock became
controllable because check-and-execute needs its condition read to resolve, and
@/lib/execute/simulate plus transfer-token-core are stubbed. Neither is
reachable on a broadcasting write - they are stubbed because their module graph
pushed the in-test dynamic import past the 10s timeout. With them stubbed the
whole file runs in about 9s.

Coverage is deliberately not four-way on everything: the unconfirmed verdict
stays pinned once, on [...slug], since all four call the same
dispositionForExecutionOutcome. What is pinned four times is the part that was
copy-pasted four times.

17 passed. tsgo --noEmit clean, biome clean on the test file.
@vaibhav4046

Copy link
Copy Markdown
Author

Second push, 38a1c8d - the route tests I said were owed.

Nine added for transfer, contract-call and check-and-execute, in the existing file rather than a new one, since the point is that one rule stays pinned in one place. Three cases each: a write that fails with no transaction hash holds the key, a write whose hash verified to a conclusive on-chain failure releases it, and a successful write finalises.

Each verified to fail when the guard it pins is removed. Two mutations per route, because the hash check and the mapping pin different tests - dropping the hash check turns the hold case from release to failed, hardcoding "failed" turns the other two red:

transfer       mutation A  × holds the key when the transfer fails without a transaction hash
                           AssertionError: expected 'release' to be 'failed'
               mutation B  × releases the key when the transfer reverts conclusively
                           × finalizes as success when the transfer broadcasts and succeeds
contract-call  mutation A  × holds the key when the contract call fails without a transaction hash
check-and-exec mutation A  × holds the key when the conditional write fails without a transaction hash

All four routes confirmed byte-identical to their prior state after every mutation.

Two things worth flagging rather than leaving to be discovered.

The additions are not free. The read-contract mock had to become controllable because check-and-execute needs its condition read to resolve, and @/lib/execute/simulate plus transfer-token-core are now stubbed. Neither is reachable on a broadcasting write - they are stubbed because their module graph pushed the in-test dynamic import past the 10s timeout. With them stubbed the file runs in about 9s.

Coverage is deliberately not four-way on everything. The unconfirmed verdict stays pinned once, on [...slug], since all four routes call the same dispositionForExecutionOutcome. What is now pinned four times is the part that was copy-pasted four times.

17 passed, tsgo --noEmit clean, biome check clean on the test file. The pre-existing "plan limit blocks" timeout still reproduces on untouched staging and is untouched here.

That is everything from your review. The write-core change - exposing whether a send was ever issued, so a pre-submission rejection can release again - is the one thing I have left out on purpose, as its own increment rather than a rider on this. Say the word and I will open it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Triage: reviewed, changes needed from the contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

execute_contract_call: a reused idempotency_key replays a cached failure, so a retry can never recover

3 participants