feat(cashout): route Cashout V1 wallets via cutover guard (ENG-357)#395
Merged
islandbitcoin merged 3 commits intoJun 10, 2026
Merged
Conversation
Cashout V1 always debited the legacy USD wallet and credited the bank-owner USD wallet. Post-cutover the user's funds live in an ETH-USDT cash wallet, so the offer must debit USDT and credit the bank-owner's USDT wallet. The Flash bank-owner account holds both a USD and a USDT wallet, so the route simply selects the matching pair on both sides — no cross-currency swap. - Add resolveCashoutWalletSelection: reads the cutover config + per-account migration and runs evaluateCashWalletCutoverGuard to pick the route. Source and destination wallets are resolved server-side from the guard, NOT from the client-supplied walletId (trusted only for wallet-level auth). The guard blocks the cashout while a migration is in-flight or has failed. - CashoutManager.createOffer builds a USD or USDT invoice per route; the USD/JMD payout math is unchanged (1 USDT = 1 USD). - executeCashout authorizes by account, since an old client may still present the zeroed legacy USD walletId while the offer settles in USDT, instead of an exact wallet-id match. - CashoutValidator and CashoutDetails.payment.amount are currency-aware; the USD path stays byte-identical. ErpNext.draftCashout records the USDT amount. Adds unit coverage for the routing decision tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Routes Cashout V1 settlement wallets through the cash-wallet cutover guard so post-cutover offers debit/credit USDT wallets (no cross-currency swap), while preserving the pre-cutover legacy USD wallet behavior.
Changes:
- Add
resolveCashoutWalletSelectionto choose legacy USD vs USDT wallet pair based on cutover config + migration state. - Update cashout offer creation/execution, validation, and ERP draft payloads to support
USDTAmountalongsideUSDAmount. - Refactor Redis offer SerDe into
OffersSerdeand add unit tests + Bruno smoke flow for the cutover scenario.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/flash/unit/app/offers/storage/redis-serde.spec.ts | Adds a unit test asserting USDT amounts round-trip through offer SerDe. |
| test/flash/unit/app/cash-wallet-cutover/cashout-routing.spec.ts | Adds unit tests for routing decisions across cutover states and error cases. |
| src/services/frappe/ErpNext.ts | Records USDT “user_pays” correctly when the offer settles in USDT. |
| src/app/offers/Validator.ts | Makes cashout wallet/amount validation currency-aware (USD or USDT). |
| src/app/offers/types.ts | Expands CashoutDetails.payment.amount to `USDAmount |
| src/app/offers/storage/Redis.ts | Switches Redis persistence to use the extracted OffersSerde. |
| src/app/offers/storage/OffersSerde.ts | Introduces custom offer SerDe with USDT support. |
| src/app/offers/CashoutManager.ts | Routes offer settlement wallets via cutover guard; authorizes execution by account. |
| src/app/cash-wallet-cutover/cashout-routing.ts | Implements cutover-guard-based selection of user + bank-owner settlement wallets. |
| dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/folder.bru | Adds a documented Bruno flow for local cutover smoke testing. |
| dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/01 discover legacy cashout wallet.bru | Step 01: discover legacy USD wallet without capability header. |
| dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/02 discover USDT cashout inputs.bru | Step 02: discover USDT wallet + bank account with capability header. |
| dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/03 request cashout offer.bru | Step 03: request offer using legacy wallet input but expect USDT-settled offer. |
| dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/04 initiate cashout offer.bru | Step 04: initiate Redis-loaded offer while authenticating with legacy wallet id. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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
Cashout V1 always debited the user's legacy USD wallet and credited the Flash bank-owner's USD wallet. Post-cutover the user's funds live in an ETH-USDT cash wallet, so the offer must debit USDT and credit the bank-owner's USDT wallet. The bank-owner account holds both a USD and a USDT wallet, so the route selects the matching pair on each side — no cross-currency swap.
How
resolveCashoutWalletSelection(new,src/app/cash-wallet-cutover/cashout-routing.ts) reads the cutover config + the per-account migration record and runs the existingevaluateCashWalletCutoverGuardto pick the route:legacy_usd→ user's USD wallet + bank-owner USD wallet (unchanged).usdt→ the account's USDT wallet + the bank-owner's USDT wallet.walletId(trusted only for wallet-level auth) — this protects old clients that still send the zeroed legacy USD walletId after migration.CashoutManager.createOfferbuilds a USD or USDT invoice per route; the USD/JMD payout math is unchanged (1 USDT = 1 USD).executeCashoutnow authorizes by account (provided walletId and the offer's settlement wallet must share an account) instead of an exact wallet-id match, so an old client presenting the legacy USD walletId can still execute a USDT-settled offer.CashoutValidatorandCashoutDetails.payment.amountare currency-aware (USDAmount | USDTAmount); the USD branches stay byte-identical.ErpNext.draftCashoutrecords the USDT amount (asNumber, sinceUSDTAmounthas noasDollars);wallet_id/flash_walletalready capture the USDT source/destination wallets for audit.Acceptance criteria
Builds on
tmp/bridge-rebase-pr-ready(the cutover machinery lives there). blockedBy ENG-345 (Done).Testing
tsc --noEmit: no new type errors (my files type-clean).cashout-routing.spec.tscovering pre→legacy, complete→usdt, mid-migration→legacy, failed→blocked, and missing-USDT-wallet→error. (Note: the unit suite does not complete on my local host — a ts-jest cold-compile hang that also affects existing specs — so the spec is type-checked here and will run in CI.)🤖 Generated with Claude Code