Skip to content

feat(deposit-address-service): announce settled withdrawals - #3711

Open
amateima wants to merge 2 commits into
masterfrom
feat/deposit-address-service-withdraw-publishing
Open

feat(deposit-address-service): announce settled withdrawals#3711
amateima wants to merge 2 commits into
masterfrom
feat/deposit-address-service-withdraw-publishing

Conversation

@amateima

Copy link
Copy Markdown
Contributor

Part of #3663 — PR 6 of the standalone deposit-address service. Stacked on #3709.

A withdrawal leaves no on-chain provenance event, so unlike a deposit it has to be announced: the
Pub/Sub withdraw_executed message is the only way the indexer learns the refund settled. PR 5 records
withdraw_executed and announces nothing, so every refund it makes today leaves the indexer row pending.

buildWithdrawExecutedPayload and the { type, data } envelope locked by
DepositAddressWithdrawConsumer are reused verbatim. buildDepositExecutedPayload is not — the service
never publishes deposits.

The structural change

The announcement is durable state, not a step of the request that made it
withdrawLifecyclePublishedAt on the withdraw_executed record. That is what makes a dropped one
recoverable, and it is why the terminal short-circuit is pierced in both places (the pre-lock read in
createDepositHandler and the post-lock re-read in processUnderLock): a settled-but-unannounced
withdrawal takes the lock and publishes instead of acknowledging. Piercing one alone would ACK before the
retry could happen.

Recovery keys on the recorded state, never on the message's classification — a correct_transfer
refunded below the minimum owes the same announcement — and it re-fetches the receipt, because the
payload's logIndex comes from scanning receipt.logs for the settlement log and cannot be rebuilt from
the record.

Ordering

Publish, then stamp. The reverse order loses the announcement for good on any failure between the
two; this order can at worst announce twice, which at-least-once delivery already implies. A fresh
withdrawal publishes from what TransferStore durably holds rather than from what the request believes
it just did, so the happy path runs the same code a redelivery does — the recovery path being the one
that cannot be exercised in production.

Outcome Disposition
Published stamp the record, ACK
Publish threw preserve withdraw_executed unstamped, NACK; the redelivery retries the publication only
Receipt carries no settlement log ACK + warn, left unstamped — the funds moved correctly and no redelivery can conjure a log that is not there

No path here re-executes the withdrawal. The polling bot instead catches, logs at error and never
throws, so a dropped publish is never replayed; closing that is the point of this change, so its swallow
is deliberately not copied.

Config

Mirrors the polling bot, reusing its variable names so both can run during migration:
ENABLE_DEPOSIT_ADDRESS_WITHDRAW_PUBLISHER, PUBSUB_GCP_PROJECT_ID,
PUBSUB_DEPOSIT_ADDRESS_WITHDRAW_TOPIC. The gate on with either of the other two empty fails startup,
as does a gate with no publisher behind it — announcing nothing is otherwise invisible until a refund
goes unannounced. ENABLE_DEPOSIT_ADDRESS_DEPOSIT_PUBLISHER stays dead config. The publisher is injected
like Redis, since getGcpPubSubPublisher answers undefined under RELAYER_TEST.

Testing

DepositAddressService.deposit.ts gains the publisher in its default harness, so every pre-existing
withdraw test now exercises the announcement rather than passing because publishing was off. New cases
cover: the envelope and the stamp; publish throws ⇒ state preserved unstamped + NACK; redelivery of a
published withdrawal ACKs without republishing; redelivery of an unpublished one republishes and never
re-withdraws; the same for a below-minimum correct_transfer; missing settlement log ⇒ ACK + warn; a
failed terminal write announces nothing; no publisher configured; and a deposit is never announced.

Each new branch was verified red-first — the bug reintroduced and the test confirmed failing — for the
stamp/publish swap, both short-circuit pierces individually, gating recovery on classification, stamping
on the missing-log path, and announcing before the durable terminal write.

yarn tsc --build --force && yarn lint
RELAYER_TEST=true yarn hardhat test test/DepositAddressService.*.ts test/TransactionClient.ts   # 200 passing

Review focus: the envelope still matches the locked consumer contract; a publication retry can never
re-withdraw.

🤖 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: 73aa4955bc

ℹ️ 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/depositHandler.ts
@amateima

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: b006f388da

ℹ️ 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".

@amateima
amateima force-pushed the feat/deposit-address-service-withdraw-publishing branch from b006f38 to ddf03a3 Compare August 24, 2026 11:50
@amateima
amateima force-pushed the feat/deposit-address-service-withdraw-publishing branch from ddf03a3 to e82a19a Compare August 27, 2026 17:19
@amateima
amateima force-pushed the feat/deposit-address-service-withdraw-publishing branch from e82a19a to 48edf93 Compare September 3, 2026 22:19
pxrl
pxrl previously approved these changes Sep 4, 2026
@amateima
amateima disabled the stack merge September 7, 2026 13:21
@amateima
amateima force-pushed the feat/deposit-address-service-withdraw-publishing branch from 48edf93 to cb93520 Compare September 7, 2026 13:21
@amateima
amateima force-pushed the feat/deposit-address-service-withdraw-publishing branch from cb93520 to d26c4d0 Compare September 7, 2026 15:10
Base automatically changed from feat/deposit-address-service-v3-withdrawal to master September 8, 2026 08:37
amateima and others added 2 commits September 8, 2026 11:40
A withdrawal leaves no on-chain provenance event, so unlike a deposit it has to be
announced: the Pub/Sub `withdraw_executed` message is the only way the indexer learns
the refund settled. Reuses `buildWithdrawExecutedPayload` and the locked `{type, data}`
envelope verbatim.

The announcement is durable state rather than a step of the request that made it —
`withdrawLifecyclePublishedAt` on the `withdraw_executed` record. That is what makes a
dropped one recoverable, and it is why the terminal short-circuit is pierced in both
places: a settled-but-unannounced withdrawal takes the lock and publishes instead of
acknowledging. Piercing one alone would ACK before the retry could happen.

Publish, then stamp. The reverse order loses the announcement for good on any failure
between the two. A fresh withdrawal publishes from what `TransferStore` durably holds
rather than from what the request believes it just did, so the happy path runs the same
code a redelivery does — the recovery path being the one that cannot be exercised in
production. Recovery keys on the recorded state, never on the message's classification,
since a `correct_transfer` refunded below the minimum owes the same announcement; and it
re-fetches the receipt, because the payload's `logIndex` comes from scanning
`receipt.logs` and cannot be rebuilt from the record.

A publish failure preserves `withdraw_executed` unstamped and NACKs, so the redelivery
retries the publication alone and no path re-withdraws. A receipt with no settlement log
is ACK + warn: the funds moved correctly and no redelivery can conjure a log that is not
there. The polling bot instead catches, logs at `error` and never throws, so a dropped
publish is never replayed; closing that is the point of this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ending record

A redelivery that found `broadcast_pending` for a withdrawal resolved it, wrote
`withdraw_executed` and ACKed without announcing — the exact permanent loss this change
exists to prevent, reached from the one route that writes the terminal state rather than
finds it. That is the case where the original request broadcast the refund and died
before its receipt landed, so this delivery is both the first to observe the settlement
and the last that could ever announce it.

Resolution and announcement are now one function and no caller invokes the resolver
directly, so a fourth call site cannot reintroduce the split. The deposit path pays only
an extra read for that: `deposit_executed` never awaits an announcement.

Reported in review of the pending-record branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@amateima
amateima force-pushed the feat/deposit-address-service-withdraw-publishing branch from d26c4d0 to 57b50e4 Compare September 8, 2026 08:41
@amateima
amateima requested a review from pxrl September 8, 2026 09:30
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.

2 participants