fix: KMS stale-connection retry, verified broadcast, real signer errors#104
Conversation
Three fixes to the wallet/KMS send path, motivated by a 21-minute production outage in the gator liquidator (2026-07-09) where every liquidation send failed, strictly alternating SigningFailed / RpcError. - kms: retry once when the HTTPS fetch to KMS fails. Signing calls are often minutes apart and KMS drops idle keep-alive connections, so the pooled connection is routinely dead on reuse and the first write fails. The failed fetch discards that connection; one retry dials fresh. This was the SigningFailed half of the outage (every other attempt). - wallet: propagate the signer's own error instead of collapsing it into a blanket SigningFailed. For KMS this distinguishes a failed HTTPS call from bad credentials or a recovery mismatch. Removes the now-unused WalletError.SigningFailed. - wallet: on an RpcError from broadcast, poll briefly for the receipt of the locally-computed tx hash before reporting failure. A JSON-RPC error does not prove the tx missed the chain -- a redelivered request can be answered "already known" while the first copy mines (observed in the outage: a send logged as RpcError actually landed). Adds a test that cross-checks the pre-broadcast hash against viem for Anvil key #0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughKMS client requests now retry once on HTTP fetch failure with per-attempt response body handling and status-based error mapping. Wallet's sendTransaction handles ambiguous JSON-RPC broadcast errors by polling for a receipt via a new transactionLanded helper, signTransaction propagates signer errors directly, and WalletError set is updated accordingly with a new test. ChangesKMS Request Retry
Estimated code review effort: 3 (Moderate) | ~20 minutes Wallet Transaction Broadcast and Signing
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Wallet
participant Provider
Wallet->>Wallet: compute tx_hash from signed_bytes
Wallet->>Provider: sendRawTransaction(signed_bytes)
Provider--xWallet: error.RpcError
Wallet->>Wallet: transactionLanded(tx_hash)
loop up to 3 attempts
Wallet->>Provider: getTransactionReceipt(tx_hash)
Provider->>Wallet: receipt or none
end
alt receipt found
Wallet->>Wallet: return tx_hash
else no receipt
Wallet->>Wallet: return original RpcError
end
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Warning Review ran into problems🔥 ProblemsGit: Failed to clone repository. Please run the Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/wallet.zig (1)
157-171: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winShort landing window (~2s) means a still-pending in-mempool tx triggers a nonce rollback.
When
transactionLandedreturns false, theerrdeferat Lines 117–119 rolls back the managed nonce. If the ambiguous broadcast actually reached the mempool but has not mined within the ~2s poll window, the nonce is returned to the manager and can be reissued, colliding with the pending tx. This matches the "strictly no worse than before" contract, but since the whole point is disambiguation, consider widening/parameterizing the poll window (or only rolling back when a receipt lookup is authoritatively negative).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/wallet.zig` around lines 157 - 171, The broadcast path in wallet.zig is rolling back the managed nonce too early when the receipt check is inconclusive. Update the `sendRawTransaction`/`transactionLanded` flow so `transactionLanded` is not treated as a hard negative after the short poll window; instead, widen or parameterize the polling window in `transactionLanded` and only allow the `errdefer` nonce rollback in the caller when a receipt lookup is definitively negative. Keep the ambiguity-handling logic around `tx_hash`, `provider.sendRawTransaction`, and `transactionLanded` aligned so pending-in-mempool transactions do not get reissued.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/wallet.zig`:
- Around line 157-171: The broadcast path in wallet.zig is rolling back the
managed nonce too early when the receipt check is inconclusive. Update the
`sendRawTransaction`/`transactionLanded` flow so `transactionLanded` is not
treated as a hard negative after the short poll window; instead, widen or
parameterize the polling window in `transactionLanded` and only allow the
`errdefer` nonce rollback in the caller when a receipt lookup is definitively
negative. Keep the ambiguity-handling logic around `tx_hash`,
`provider.sendRawTransaction`, and `transactionLanded` aligned so
pending-in-mempool transactions do not get reissued.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 0ef998d2-f45f-4dd0-af11-227a8f88ba0d
📒 Files selected for processing (2)
src/kms.zigsrc/wallet.zig
Picks up the KMS stale-connection retry, verified broadcast, and real signer errors from eth.zig v0.8.1 (StrobeLabs/eth.zig#104). Required so downstream consumers (gator-liquidators) can bump their own eth to v0.8.1 without pulling two divergent eth copies: Zig dedupes packages by hash, so a consumer and this SDK must reference the identical eth version or the vendored C crypto compiles twice and collides at link. No source changes; the removed WalletError.SigningFailed is unused here. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
v0.8.0 was cut out-of-band (git tag on 8c9a20d / #103 only, no GitHub Release), so release-please's manifest and the version-marked extra-files were never bumped and stayed at 0.7.0. With the manifest at 0.7.0, release-please recomputes 0.8.0 from the feat commits since v0.7.0 and collides with the existing v0.8.0 tag ("proposes wrong versions"). Adopt v0.8.0 as the released version by bumping the manifest plus the three release-please extra-files (build.zig.zon, README.md, installation.mdx) to match the existing tag. After this merges, release-please diffs from v0.8.0 and proposes 0.8.1 for the #104 fix. Also fixes build.zig.zon self-reporting 0.7.0 while downstream (gator, perpcity-zig-sdk) pins v0.8.0. Does not delete the v0.8.0 tag (load-bearing: downstream pins its SHA) and leaves CHANGELOG.md for the separate v0.8.0 GitHub Release backfill.
Follow-up to #105. v0.8.1 was hand-tagged out-of-band at 0b5a320 (#104, the KMS fix) after #105 had already synced main to 0.8.0, leaving main's manifest one version behind the latest tag -- the same drift #105 fixed, shifted up a notch. Bump the manifest and the three release-please extra-files (build.zig.zon, README.md, installation.mdx) 0.8.0 -> 0.8.1 to match the existing v0.8.1 tag. With the org "Actions can create PRs" setting now enabled, this lets release-please treat 0.8.1 as already released (tag exists at 0b5a320), retire its stranded release-please--branches--main branch, and cleanly propose 0.8.2 for the next real change -- without re-tagging the load-bearing v0.8.1 (pinned by perpcity-zig-sdk and gator).
Motivation
Root-caused from a 21-minute production outage in the gator liquidator on 2026-07-09: every batch-liquidation send failed, strictly alternating
error.SigningFailed/error.RpcError, then self-recovered. Tracing both error paths to source turned up three distinct defects in this repo's send path.Changes
1. KMS: retry once on a stale keep-alive connection (
src/kms.zig)Signing calls are often minutes apart, and KMS closes idle keep-alive connections well before that. The pooled connection is therefore routinely dead when the next
kms:Signreuses it, so the first write fails; the failed fetch discards that connection and one retry dials fresh. This was theSigningFailedhalf of the outage — it fired on every other attempt, which is exactly the strict alternation seen in the logs.2. Wallet: propagate the signer's real error (
src/wallet.zig)signHash catch return error.SigningFailedcollapsed every signer failure into one opaque error. For a KMS signer this hid whether the cause was a failed HTTPS call (RequestFailed), bad credentials (Unauthorized), or a recovery mismatch (AddressMismatch). Now propagated. Removes the now-unusedWalletError.SigningFailed.3. Wallet: verify ambiguous broadcasts before reporting failure (
src/wallet.zig)A JSON-RPC error on
eth_sendRawTransactiondoes not prove the transaction missed the chain: a redelivered request can be answeredalready knownwhile the first copy mines. This actually happened in the outage — a send logged asRpcErrorliquidated its position on-chain, and the caller wrongly treated it as failed (put the position in cooldown instead of marking it liquidated). The transaction hash is fully determined by the signed bytes, so onRpcErrorwe now poll briefly for that hash's receipt and report success if it landed.Adds a test that cross-checks the pre-broadcast hash against viem (independent implementation) for the well-known Anvil key #0, locking the
keccak256(signed_bytes)== canonical-tx-hash invariant the receipt poll depends on.Testing
zig build test— 856/856 pass (was 855; +1 new cross-validation test)zig fmt --check src/ tests/— clean🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes