Skip to content

Broadcast-before-persist double-spend: PayIn/Send-EVM and DEX-Purchase (follow-ups to #4181) #4192

Description

@TaprootFreak

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions