Skip to content

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

Open
vaibhav4046 wants to merge 1 commit 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 1 commit 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.

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