Context
#4181 fixes the payout instance of a broadcast-before-persist double-spend class: an on-chain send is issued before the entity state that guards re-selection is persisted, so a process death (reboot / SIGKILL / VM panic) in the window between broadcast and save lets a status-based cron re-select the entry and broadcast a second time.
Two other funds flows carry the same class but were deliberately left out of #4181 because they need a DB migration (a new in-flight status / a unique index). Patching them into the payout PR would have meant an untested migration in funds code, so they are tracked here as separate follow-ups.
The class & the safe pattern
Persist the guarding state before the broadcast, not after. The reference is the payout fix (#4181): the order is written to an in-flight status (PAYOUT_DESIGNATED) before dispatch; the cron only re-selects the pre-broadcast status, so a crash cannot re-broadcast; a recovery cron (processFailedOrders) escalates stranded in-flight entries fail-closed.
1. PayIn / Send-EVM — double-send
payin/strategies/send/impl/base/evm.strategy.ts#dispatch() (as of develop, ~L197):
const outTxId = await this.dispatchSend(...); // broadcast
const updated = await this.updatePayInsWithSendData(group, outTxId, type);
await this.saveUpdatedPayIns(updated); // persist — AFTER the broadcast
CryptoInput (PayInStatus, crypto-input.entity.ts) has no in-flight status between "ready to send" and "outTxId set", and there is no processFailedOrders equivalent. Re-selection keys on status + outTxId IS NULL, so a crash between dispatchSend and saveUpdatedPayIns leaves outTxId null → the pay-in is re-selected → second broadcast → double-send. The RETURN path and EIP-7702 delegation (evm.token.strategy.ts) share the same dispatch.
Needs: a new pre-broadcast PayInStatus (e.g. SENDING) that the send cron does not re-select, a recovery cron (analogue of processFailedOrders), and a migration.
2. DEX purchase — double-swap
dex/strategies/purchase-liquidity/impl/base/purchase.strategy.ts#purchaseLiquidity() (as of develop):
const order = createPurchaseOrder(...); // in-memory only
await this.bookLiquiditySwap(order); // -> dexService.swap(...) = broadcast
await this.estimateTargetAmount(order);
await this.liquidityOrderRepo.save(order); // first persist — AFTER the swap
The LiquidityOrder is only saved after the swap has already been sent (the swap() call lives inside bookLiquiditySwap, which runs before the first save). A crash in that window loses the order entirely, and the upstream trigger then re-issues the request → second swap. LiquidityOrder has no status column (only isReady/isComplete, both false at creation) and its (context, correlationId) index is NOT unique — on PayoutOrder it is.
Needs: a persisted pre-broadcast status on LiquidityOrder written before swap(), a unique index on (context, correlationId), and a migration.
Suggested approach
Mirror #4181 per flow, one PR + migration each: write an in-flight status before the send; make the selection cron pick only the pre-broadcast status; add a fail-closed recovery path for stranded in-flight entries; add the unique constraint where missing.
Note
The broadcast-before-persist pattern likely exists in further send paths (non-EVM PayIn send strategies, custody / liquidity-management transfers). A systematic sweep is worth more than fixing only these two — line references above are the analysis state on develop and should be re-verified.
Context
#4181 fixes the payout instance of a broadcast-before-persist double-spend class: an on-chain send is issued before the entity state that guards re-selection is persisted, so a process death (reboot / SIGKILL / VM panic) in the window between broadcast and save lets a status-based cron re-select the entry and broadcast a second time.
Two other funds flows carry the same class but were deliberately left out of #4181 because they need a DB migration (a new in-flight status / a unique index). Patching them into the payout PR would have meant an untested migration in funds code, so they are tracked here as separate follow-ups.
The class & the safe pattern
Persist the guarding state before the broadcast, not after. The reference is the payout fix (#4181): the order is written to an in-flight status (
PAYOUT_DESIGNATED) before dispatch; the cron only re-selects the pre-broadcast status, so a crash cannot re-broadcast; a recovery cron (processFailedOrders) escalates stranded in-flight entries fail-closed.1. PayIn / Send-EVM — double-send
payin/strategies/send/impl/base/evm.strategy.ts#dispatch()(as of develop, ~L197):CryptoInput(PayInStatus,crypto-input.entity.ts) has no in-flight status between "ready to send" and "outTxId set", and there is noprocessFailedOrdersequivalent. Re-selection keys on status +outTxId IS NULL, so a crash betweendispatchSendandsaveUpdatedPayInsleavesoutTxIdnull → the pay-in is re-selected → second broadcast → double-send. The RETURN path and EIP-7702 delegation (evm.token.strategy.ts) share the same dispatch.Needs: a new pre-broadcast
PayInStatus(e.g.SENDING) that the send cron does not re-select, a recovery cron (analogue ofprocessFailedOrders), and a migration.2. DEX purchase — double-swap
dex/strategies/purchase-liquidity/impl/base/purchase.strategy.ts#purchaseLiquidity()(as of develop):The
LiquidityOrderis only saved after the swap has already been sent (theswap()call lives insidebookLiquiditySwap, which runs before the firstsave). A crash in that window loses the order entirely, and the upstream trigger then re-issues the request → second swap.LiquidityOrderhas no status column (onlyisReady/isComplete, both false at creation) and its(context, correlationId)index is NOT unique — onPayoutOrderit is.Needs: a persisted pre-broadcast status on
LiquidityOrderwritten beforeswap(), a unique index on(context, correlationId), and a migration.Suggested approach
Mirror #4181 per flow, one PR + migration each: write an in-flight status before the send; make the selection cron pick only the pre-broadcast status; add a fail-closed recovery path for stranded in-flight entries; add the unique constraint where missing.
Note
The broadcast-before-persist pattern likely exists in further send paths (non-EVM PayIn send strategies, custody / liquidity-management transfers). A systematic sweep is worth more than fixing only these two — line references above are the analysis state on develop and should be re-verified.