fix(deposit-address): cross-instance write-ahead lock + broadcast verification on the v3 execute path - #3763
Draft
ashwinrava wants to merge 1 commit into
Draft
fix(deposit-address): cross-instance write-ahead lock + broadcast verification on the v3 execute path#3763ashwinrava wants to merge 1 commit into
ashwinrava wants to merge 1 commit into
Conversation
…ification on the v3 execute path Co-Authored-By: Claude Fable 5 <noreply@anthropic.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.
What happened
A persistent-deposit-address (PDA) funding transfer was executed twice by the deposit-address bot — two sweep transactions from the same signer landed in consecutive blocks, each referencing the same inbound ERC-20 transfer in their provenance metadata. The second execute succeeded on-chain only because the deposit address also held funds from a different pending deposit; the duplicate silently consumed that neighbor deposit's balance. The neighbor deposit's row then failed the bot's balance pre-check on every later poll (balance < its recorded amount) and stranded until manual intervention.
Two protections existed and both have a structural gap:
observedExecutedDeposits) is process memory. The instance coordinator hands over by writing the new instance into Redis and having the old instance notice on a 1s poll — so during a handover, two instances briefly poll the queue concurrently. The indexer's delivery queue intentionally redelivers fresh rows for 15 minutes (at-least-once delivery, so bot restarts can't lose transfers), which guarantees both instances receive the same row. Neither can see the other's in-memory lock.sendAndConfirmTransactionswallows the broadcast hash on failure. A receipt timeout or RPC error after a successful broadcast returnsundefined— indistinguishable from "never sent". The caller releases its lock and re-executes on the next 1s poll while the first transaction is still propagating.Exactly-once delivery from the queue is not achievable (marking a row consumed happens when the response is sent, not when the bot has durably acted), so dedupe must live at the effect boundary — in this bot.
The fix
1. Cross-instance write-ahead lock (
_acquireExecuteLock): before requesting/broadcasting an execute, take a RedisSET NX PXlock keyed by depositKey (180s TTL, token = run identifier). A concurrent instance skips the row instead of double-executing. The lock is released when the attempt provably ended before any broadcast; after an unknown-outcome broadcast it is left to expire as a cool-down. Fails open on Redis errors — a Redis blip must not stop sweeping (reverts to today's single-instance dedupe).2. Broadcast verification instead of blind retry (
_resolvePriorExecuteAttempt):sendAndConfirmTransactionWithHash(new, non-breaking;sendAndConfirmTransactionnow delegates to it) surfaces the broadcast tx hash even when confirmation fails. An unknown-outcome broadcast is persisted to Redis (attempted-executerecord, 24h TTL). Before any new execute for the same depositKey, the bot checks that hash's receipt:Notes
Tests
test/DepositAddressHandler.tspass;tsc --noEmit, eslint, prettier clean.🤖 Generated with Claude Code