Description
crates/api/src/routes/withdrawals.rs::withdraw currently reserves the idempotency key, fetches the sequence number, signs, and submits to Horizon — with no check that the master wallet actually holds enough of the requested asset to cover the withdrawal beforehand. A withdrawal that Horizon will obviously reject for insufficient balance still consumes a signing cycle and (per current behavior) permanently occupies that idempotency key with a "failed" status, meaning the caller cannot simply retry the exact same logical request with a corrected amount using the same key — they'd need a new key even though nothing about their intent changed except that the wallet needed topping up.
Requirements and Context
- Before signing, call
state.horizon().balances(&wallet.stellar_account_g) (already exists) and confirm a balance line matching the requested asset (native or the specific code+issuer) with balance >= amount (converted to the same stroops precision as the rest of the codebase — reuse octo_ingest::amount::to_stroops or a similar conversion helper rather than re-deriving decimal-to-stroops math from scratch; consider whether that helper should move to a shared location both crates/ingest and crates/api can depend on, and note your decision in the PR description).
- On insufficient balance, return
ApiError::BadRequest("insufficient balance for this withdrawal") before calling create_withdrawal (so the idempotency key is never consumed by a request that was never going to succeed) — this changes the current ordering of operations in withdraw, so re-read the whole function carefully before restructuring it.
- Account for the base reserve (a Stellar account must always retain a minimum XLM reserve) when checking native XLM withdrawals specifically, so a withdrawal that would bring the account below its minimum reserve is also rejected here rather than by Horizon.
Suggested Execution
Branch: feat/api/withdraw-preflight-balance-check
Implement Changes
- Add the balance check to
crates/api/src/routes/withdrawals.rs::withdraw, positioned before the idempotency-key reservation.
- If the stroops-conversion helper is shared with
crates/ingest, extract it to a small shared location (or crates/wallet-core if that fits better) rather than duplicating the digit-by-digit parsing logic.
Test and Commit
withdraw_rejects_when_wallet_balance_is_insufficient_without_consuming_the_idempotency_key.
withdraw_rejects_native_withdrawal_that_would_breach_the_minimum_reserve.
withdraw_succeeds_when_balance_is_sufficient (regression check against the existing happy-path test).
- Run
cargo test -p octo-api locally before committing.
Example Commit Message
feat(api): pre-flight-check wallet balance before signing a withdrawal
Withdrawals with insufficient balance previously reached Horizon before
failing, consuming a signing cycle and permanently occupying the caller's
idempotency key on a request that could never succeed. Adds a pre-flight
balance (and minimum-reserve) check ahead of the idempotency-key reservation,
sharing the existing stroops-conversion logic rather than duplicating it.
Guidelines
- This changes the order of operations in
withdraw — read the whole function carefully (idempotency reservation, sequence fetch, signing, submission, status update, audit, webhook) before restructuring, and make sure the balance check genuinely happens first.
- This is one of the tracked hard/complex issues in its fuller cross-crate form (see the corresponding hard-issue ticket) — this medium ticket covers the core API-layer check; the hard ticket covers the full design writeup and edge-case matrix.
- Reference this issue with
Closes #<issue-number> in the PR description.
Description
crates/api/src/routes/withdrawals.rs::withdrawcurrently reserves the idempotency key, fetches the sequence number, signs, and submits to Horizon — with no check that the master wallet actually holds enough of the requested asset to cover the withdrawal beforehand. A withdrawal that Horizon will obviously reject for insufficient balance still consumes a signing cycle and (per current behavior) permanently occupies that idempotency key with a"failed"status, meaning the caller cannot simply retry the exact same logical request with a corrected amount using the same key — they'd need a new key even though nothing about their intent changed except that the wallet needed topping up.Requirements and Context
state.horizon().balances(&wallet.stellar_account_g)(already exists) and confirm a balance line matching the requested asset (native or the specific code+issuer) withbalance >= amount(converted to the same stroops precision as the rest of the codebase — reuseocto_ingest::amount::to_stroopsor a similar conversion helper rather than re-deriving decimal-to-stroops math from scratch; consider whether that helper should move to a shared location bothcrates/ingestandcrates/apican depend on, and note your decision in the PR description).ApiError::BadRequest("insufficient balance for this withdrawal")before callingcreate_withdrawal(so the idempotency key is never consumed by a request that was never going to succeed) — this changes the current ordering of operations inwithdraw, so re-read the whole function carefully before restructuring it.Suggested Execution
Branch:
feat/api/withdraw-preflight-balance-checkImplement Changes
crates/api/src/routes/withdrawals.rs::withdraw, positioned before the idempotency-key reservation.crates/ingest, extract it to a small shared location (orcrates/wallet-coreif that fits better) rather than duplicating the digit-by-digit parsing logic.Test and Commit
withdraw_rejects_when_wallet_balance_is_insufficient_without_consuming_the_idempotency_key.withdraw_rejects_native_withdrawal_that_would_breach_the_minimum_reserve.withdraw_succeeds_when_balance_is_sufficient(regression check against the existing happy-path test).cargo test -p octo-apilocally before committing.Example Commit Message
Guidelines
withdraw— read the whole function carefully (idempotency reservation, sequence fetch, signing, submission, status update, audit, webhook) before restructuring, and make sure the balance check genuinely happens first.Closes #<issue-number>in the PR description.