Skip to content

feat(deposit-address-service): per-transfer lock and durable state - #3678

Merged
amateima merged 2 commits into
masterfrom
feat/deposit-address-service-lock-state
Sep 7, 2026
Merged

feat(deposit-address-service): per-transfer lock and durable state#3678
amateima merged 2 commits into
masterfrom
feat/deposit-address-service-lock-state

Conversation

@amateima

@amateima amateima commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Part of #3663 — PR 3 of nine. Stacked on #3670, which is stacked on #3668. Bases retarget automatically as each merges.

What

The two Redis keys the design turns on, plus the receipt classifier. src/deposit-address-service/transferState.ts.

deposit-address:lock:<transferId>    expiring, SET NX, uuid token generated inside the store
deposit-address:state:<transferId>   broadcast_pending (never expires) | 3 terminal statuses (90d)

They are separate because their lifetimes are opposites: the lock must expire so a dead consumer does not block a transfer forever; the state must not, because a broadcast_pending record has to outlive anything that could still land on-chain. Merging them reintroduces the problem the design exists to remove.

Built entirely on acquireLock / releaseLock / get / set / del, all already on RedisCacheInterfaceno new Redis primitive, which is what #3648 was criticised for.

Guards worth reviewing

  • clearRevertedBroadcast(id, expectedTxHash) deletes only if the record is still that transaction. A stale reconciliation of h1 must not delete a newer pending h2 or a terminal outcome — that would unblock a transfer mid-sweep, which is the exact failure this project exists to prevent. Read-then-check is sufficient because callers hold the lock; it would need to be atomic only if that stopped being true.
  • recordBroadcast refuses to overwrite a terminal outcome — the one transition that loses funds-safety information.
  • Every write throws unless Redis replies "OK". set can answer undefined, and awaiting a confirmation while believing a broadcast hash is durable is the unrecoverable direction.
  • A present-but-unparseable record throws rather than reading as absent: it may describe a transfer already swept, so the transfer stays blocked. CorruptTransferStateError is retriable but distinct from a Redis outage — it clears only when an operator repairs or deletes the key.
  • The lock token never crosses the boundary. acquireLock(id) generates a uuid and returns a TransferLock handle, so no caller can pass a messageId or reuse a token. A message id could not serve anyway: Pub/Sub redelivers the same message.

classifyReceipt returns facts, not actions

"confirmed" | "reverted" | "unresolved". Whether each means ACK, NACK, clear or persist is retry policy and belongs with the orchestration that also holds the lock — so this file has no opinion on it.

A receipt with no explicit status === 0 reads as confirmed. ethers leaves status undefined on some chains; guessing confirmed can strand a deposit for manual recovery, whereas guessing reverted would re-broadcast one that already landed. Only the first is reversible.

Deliberately skipped

  • schemaVersion. Superstruct type() already tolerates unknown keys, so today's records stay readable. Add one when there is a second version — a version number does not help with the case it would not cover anyway (a field whose meaning changes).
  • A full transition table. Only the pending-over-terminal row is enforced. Reaching the other six requires a caller to bypass the state read every path begins with.
  • Wiring. Same reason as PR 2: nothing to lock or record until the execution paths exist, and a handler that ACKed without executing would discard work.

Testing

RELAYER_TEST=true yarn hardhat test "test/DepositAddressService*.ts"79 passing (20 new).

Covers all four classifyReceipt branches, SET NX exclusivity, token-checked release, internally-generated and differing tokens, the lock TTL, round-trip of every status with its TTL, idempotent terminal rewrite, refusal to downgrade a terminal, unacknowledged writes, corrupt and out-of-range records, and that a stale clear cannot delete either a newer pending hash or a terminal outcome.

yarn build reports only the three pre-existing poolRebalanceLeafCount errors. yarn lint clean.

Note this touches errors.ts (three new classes) and the module README, both introduced in #3668.

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 094f8b0c3c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/deposit-address-service/transferState.ts
Comment thread src/deposit-address-service/transferState.ts Outdated
@amateima
amateima force-pushed the feat/deposit-address-service-lock-state branch from 094f8b0 to d06c6eb Compare August 10, 2026 09:15
Base automatically changed from feat/deposit-address-service-message-contract to master August 10, 2026 11:20
@amateima
amateima force-pushed the feat/deposit-address-service-lock-state branch from d06c6eb to db7f9fa Compare August 10, 2026 11:20
@amateima
amateima force-pushed the feat/deposit-address-service-lock-state branch 3 times, most recently from b39a154 to 5066eea Compare August 11, 2026 22:09
@amateima
amateima force-pushed the feat/deposit-address-service-lock-state branch from 5066eea to f57cb94 Compare August 24, 2026 11:16

@dijanin-brat dijanin-brat 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.

lgtm

@amateima
amateima force-pushed the feat/deposit-address-service-lock-state branch from f57cb94 to 6c45599 Compare August 27, 2026 17:19
@amateima
amateima force-pushed the feat/deposit-address-service-lock-state branch from 6c45599 to 2d3af9d Compare September 3, 2026 22:19
amateima and others added 2 commits September 7, 2026 16:21
The two Redis keys the design turns on, plus the receipt classifier. Not wired:
there is nothing to lock or record until the execution paths exist.

The keys are separate because their lifetimes are opposites. The lock must
expire, so a consumer that dies does not block a transfer forever; the state
must not, because a broadcast_pending record has to outlive anything that could
still land on-chain. Merging them would reintroduce the problem the design
exists to remove.

Both are built on acquireLock/releaseLock/get/set/del, already on
RedisCacheInterface — no new primitive, which is what #3648 was criticised for.

Guards worth naming:

- clearRevertedBroadcast deletes only if the record is still the transaction it
  was told about. A stale reconciliation of h1 must not delete a newer pending
  h2 or a terminal outcome, which would unblock a transfer mid-sweep. Read-then-
  check suffices because callers hold the lock; it would need to be atomic only
  if that stopped being true.
- recordBroadcast refuses to overwrite a terminal outcome — the one transition
  that loses funds-safety information.
- Every write throws unless Redis replies OK. Awaiting a confirmation while
  believing a broadcast hash is durable is the unrecoverable direction.
- A present-but-unparseable record throws rather than reading as absent: it may
  describe a transfer already swept, so the transfer stays blocked.
- The lock token is a uuid generated inside the store and never crosses the
  boundary, so no caller can supply or reuse one.

classifyReceipt returns facts, not actions. A receipt with no explicit status of
0 reads as confirmed: ethers leaves status undefined on some chains, and guessing
confirmed can strand a deposit for manual recovery whereas guessing reverted
would re-broadcast one that already landed. Only the first is reversible.

Skipped a schemaVersion field: superstruct type() already tolerates unknown keys,
so add one when a second version exists. Skipped the full transition table — the
other rows need a caller to bypass the state read every path begins with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s failures

Two review findings on the store.

recordTerminal was unconditional, so a worker whose lock had lapsed could write
its own transaction's outcome over a newer pending record, and withdraw_failed
could erase a broadcast that may still land. canReplace now allows a terminal
write only over an absent record, a pending record for that same transaction, or
an identical outcome. The expected hash is read from the terminal record itself
rather than passed in, so a mismatched one cannot be supplied.

The premise is reachable, contrary to first appearances: the 480s application
deadline is enforced by application code, and nothing threads it into
TransactionClient, which accepts no AbortSignal. Its confirmation loop is
`++nTries < maxTries` at 10 tries with a 24s mainnet timeout, and the TIMEOUT
branch resubmits by recursing with maxTries - 1 — so a worker can be occupied far
longer than the 600s lock TTL.

Redis rejections now become TransientDependencyError at the store boundary.
Previously the raw client error escaped, and since an unrecognised throw is
treated as alerting, an ordinary Redis outage would page on every delivery rather
than take the debug-level retry path that error already documents for exactly
this case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amateima
amateima force-pushed the feat/deposit-address-service-lock-state branch from 2d3af9d to 60301f0 Compare September 7, 2026 13:21
@amateima
amateima merged commit 7d1562f into master Sep 7, 2026
7 checks passed
@amateima
amateima deleted the feat/deposit-address-service-lock-state branch September 7, 2026 15:10
amateima added a commit that referenced this pull request Sep 7, 2026
Part of #3663. **Stacked on #3678** — review that first; this diff is
against its branch.

The first PR here that moves funds: guards, the execute request,
broadcast, resolving the
outcome against the chain, terminal state, lock release.

*Review focus: guard-for-guard parity with `initiateDepositV3`.*
`DepositAddressHandler.ts` is
untouched and `EXECUTION_ENABLED` defaults `false`, so nothing here can
move funds until PR 7.

**Two optional fields on `AugmentedTransaction`**, both defaulting to
today's behaviour so every
existing caller is byte-identical:
- `onBroadcast` — records `broadcast_pending` the moment a hash exists,
*before* the confirmation
wait, and again on every hash change. `sendAndConfirmTransaction` can't
do this: it submits and
  confirms in one call and returns `undefined` with no hash on failure.
- `maxTries` — bounds that wait. The hard-coded default of 10 is
`M(M+1)/2` = 55 waits, ~22 min on
  mainnet, outliving both the deadline and the lock. This uses 4.

**The outcome comes from the chain, not the exception.** `submit()`
flattens revert, exhausted
retries and RPC failure into one untyped `Error`, so
`resolvePendingTransaction` reads the receipt
instead. A revert is the only case that clears the record; every "no
receipt" reason is retained.

**Lock TTL 600s → 900s**, asserted at startup as `lockTtl >= deadline +
confirmBudget` — the
deadline check bounds when a broadcast *begins*, not when its
confirmation ends.

**Deliberately not here:** the `MetadataEmitted` pre-submission scan,
nonce bookkeeping (that's
`TransactionClient`'s), the send-adjacent lock check, and address/hash
validation. Each is reasoned
out in #3663's Scope, with the residuals stated.

## Rebased onto #3678, and one behavioural change

Master's #3736 landed while this was open and changed the service's own
message contract:
`intent_refund` now routes to withdraw instead of being dropped.
Resolving textually would have
reinstated the drop and regressed a production fix, so master's
behaviour wins and this branch's
shape wins — the classification passes through untranslated and the
handler diverts on
`!== "correct_transfer"`. `assertActionableClassification` is deleted:
with all three
classifications actionable it had no work left.

Note #3651 also landed, which appends behind in-flight nonces instead of
colliding on `"latest"`.
That materially narrows the nonce race described in #3663's Scope 5 —
I'll correct that entry.

## Verification

`yarn tsc --build --force`, `yarn lint`, **164 passing** across the
service and `TransactionClient`
suites. `DepositAddressService.deposit.ts` drives the real Express
boundary, faking only the chain,
quote-api and submission client — and its fake invokes `onBroadcast`
where the real client does.
Several tests were verified to fail without their fix rather than merely
pass with it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: droplet-rl <284132418+droplet-rl@users.noreply.github.com>
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.

3 participants