Let the aggregator borrow a token from a Choice CLMM pool via the pool's Flash {}
entry point, run a cycle (X → … → X) across other venues using the existing
multi-stage router, repay principal + flash_fee, and forward the surplus to the
initiator. This is an atomic, capital-free arbitrage primitive — not a user A→B
swap (a flash loan must be repaid in the same asset it was borrowed in).
The CLMM pool (choice_clmm_pool/src/actions/flash.rs):
Flash { recipient, amount0, amount1, data }lends the tokens (BankSend/ CW20Transfer) torecipient, then callsrecipientwithFlashCallbackMsg::FlashCallback { fee0, fee1, data }as areply_on_successsubmessage (pool's ownREPLY_FLASH = 100).- The borrower must leave
snapshot + feeof each borrowed token back in the pool before that callback's message tree returns. Repayment is verified by balance delta in the pool'sreply_flash. - Repayment must be a direct transfer (Bank
Send/ CW20Transfer) — never CW20Send, which would re-enter the reentrancy-locked pool. A reentrancy lock blocks every pool mutator (incl. the same pool's swap) for the whole callback.
Because the aggregator's whole route runs depth-first inside the FlashCallback
Execute, the existing reply-driven state machine (proceed_to_next_step) drives
the cycle unchanged. It only has to be kicked off from inside FlashCallback, and
end by repaying the pool instead of paying the user. The aggregator's
create_send_msg already uses Bank Send / CW20 Transfer — exactly the
repayment method the pool requires.
caller ──ExecuteMsg::FlashRoute{ flash_pool, flash_asset, flash_amount, stages, min_profit }──▶ aggregator
aggregator: validate + guard cycle + map flash_asset→token0/token1
save PENDING_FLASH ctx
emit WasmMsg::Execute(flash_pool, Flash{ recipient: self, amount0/amount1, data:"" })
pool: lend tokens to aggregator
SubMsg.reply_on_success(REPLY_FLASH) → ExecuteMsg::FlashCallback{ fee0, fee1, data } on aggregator
aggregator FlashCallback:
load+remove PENDING_FLASH (absent ⇒ forged call, reject)
assert info.sender == ctx.flash_pool
fee = ctx.flash_is_token0 ? fee0 : fee1 ; repay = principal + fee
build ExecutionState{ accumulated=[flash_asset, principal],
plan.flash_repayment=Some{pool, asset, repay, min_profit} }
proceed_to_next_step(...) ← existing engine runs the cycle
... reply chain ...
handle_final_stage / final-conversion → finalize_route():
assert total ≥ repay + min_profit (else FlashProfitNotMet ⇒ whole tx reverts)
send repay → flash_pool (direct transfer)
send surplus → initiator
pool reply_flash: balance ≥ snapshot+fee ✓, accrue fee, release lock
data is unused: both ends are controlled via PENDING_FLASH storage, so we pass
an empty Binary. Storage (an Item) is correct because the whole flow is one
atomic tx — only one flash is ever in flight.
clmmmodule: addClmmPoolFlashMsg::Flash { recipient, amount0, amount1, data }(wire-matcheschoice_clmm_common::pool::ExecuteMsg::Flash).ExecuteMsg: addFlashRoute { flash_pool, flash_asset, flash_amount, stages, min_profit }FlashCallback { fee0, fee1, data }— variant tagflash_callback, fields in the same order asFlashCallbackMsg::FlashCallback, so the pool's serialized callback decodes straight into it.
RoutePlan: addflash_repayment: Option<FlashRepayment>.FlashRepayment { pool: Addr, asset: amm::AssetInfo, repay_amount: Uint128, min_profit: Uint128 }.PendingFlashCtx { flash_pool, flash_asset, flash_is_token0, principal, stages, min_profit, initiator }PENDING_FLASH: Item<PendingFlashCtx>.
execute_flash_route(...): validate stages/percents; guard that noClmmSwapop routes throughflash_pool(reentrancy lock would revert the tx); query the pool'sGetConfig {}to mapflash_asset → token0/token1and setamount0/amount1; savePENDING_FLASH; emit theFlashmessage.execute_flash_callback(...): load+removePENDING_FLASH(absence ⇒NoPendingFlash); assertinfo.sender == ctx.flash_pool; pickfee; build theExecutionStatewithflash_repayment; callproceed_to_next_step.
- Extract
finalize_route(deps, reply_id, exec_state, total, asset_info):- flash branch: assert
total ≥ repay_amount + min_profit; repay pool; surplus → sender. - normal branch: existing
minimum_receivecheck; total → sender.
- flash branch: assert
handle_final_stage: force the normalization target toflash_repayment.assetwhen present; route both scenario A and the empty-accumulated case throughfinalize_route/ aFlashProfitNotMeterror.handle_final_conversion_reply: callfinalize_routeon completion.execute_aggregate_swaps_internal: setflash_repayment: None.
NoPendingFlash,FlashPoolInCycle,FlashAssetNotInPool,FlashProfitNotMet { required, actual }.
execute: dispatchFlashRoute/FlashCallback.
- Forged callback:
FlashCallbackonly runs ifPENDING_FLASHis set (by our ownFlashRoute) andinfo.sender == ctx.flash_pool. Without a real loan the borrowed funds aren't present and the route fails anyway, but the storage gate blocks the call up-front so no idle aggregator dust can be spent. - Reentrancy: the cycle must not touch
flash_pool; guarded atFlashRoute. - Same-asset close:
finalize_routeforces the normalization target to the borrowed asset and asserts the repayment+profit floor; a cycle that fails to return the borrowed asset can't repay → the pool'sreply_flashreverts the tx. - Fee is dynamic:
fee0/fee1come from the pool at callback time — never precomputed inFlashRoute.min_profitmust exceed the flash fee or every fire reverts. - Reply namespace: the pool's
REPLY_FLASH = 100lives in the pool's reply space; the aggregator seesFlashCallbackas a plain Execute, so it never collides with the aggregator'sREPLY_ID_COUNTER.
Embedding the real choice_clmm_pool.wasm was rejected: choice_exchange is
cosmwasm-std 2.x vs this workspace's 3.x (no type sharing), and it would drag in
the factory/manager deploy + tick-math liquidity seeding. Since the aggregator is
itself the borrower, no separate borrower mock is needed either. Instead a new
workspace member contracts/mock_clmm_flash faithfully mirrors the pool's
flash interface at the JSON wire level (lend → FlashCallback → balance-delta
repayment check + reentrancy lock + GetConfig). The happy-path test doubles as
the wire-format proof: the aggregator's ClmmPoolFlashMsg::Flash must serialize
into what the mock decodes, and the mock's FlashCallbackMsg::FlashCallback must
decode into the aggregator's ExecuteMsg::FlashCallback.
Four tests in tests/integration.rs (all green; 36/36 suite passes):
test_flash_route_happy_path— borrow 1000 USDT @30bps → 100 INJ → 1100 USDT; repay 1003, 97 USDT surplus to caller, pool net +3 (the fee).test_flash_route_below_min_profit_reverts— unreachablemin_profit→FlashProfitNotMet, whole tx reverts, nothing moves.test_flash_route_cycle_through_flash_pool_rejected— a cycle hop on the flash pool →FlashPoolInCycle, rejected pre-fire.test_flash_callback_without_pending_flash_rejected— directFlashCallbackwith no in-flight flash →NoPendingFlash.
Build/run notes: ./build_release.sh (docker, --locked) rebuilds all members
including mock_clmm_flash.wasm AND a fresh dex_aggregator.wasm — a stale
artifact lacks the flash code. Adding the member/dev-dep makes Cargo.lock stale;
run a local cargo build to refresh it before the optimizer (the serde_with
3.12.0 / darling 0.20.11 pins must hold). Don't over-fund test accounts with INJ —
gas fees make a full-balance bank.send fail.
Not covered by these (the pool's own logic, tested in choice_exchange): the
FlashNotRepaid shortfall path is unreachable via the aggregator, because
FlashProfitNotMet (required = repay + min_profit ≥ repay) fires first — the
aggregator never under-repays.