Skip to content

Feat(api): Add a pre-flight balance check against live Horizon balances before signing a withdrawal - #198

Open
phalap1 wants to merge 3 commits into
Octo-Protocol-org:mainfrom
phalap1:feat/api/withdraw-preflight-balance-check
Open

Feat(api): Add a pre-flight balance check against live Horizon balances before signing a withdrawal #198
phalap1 wants to merge 3 commits into
Octo-Protocol-org:mainfrom
phalap1:feat/api/withdraw-preflight-balance-check

Conversation

@phalap1

@phalap1 phalap1 commented Jul 28, 2026

Copy link
Copy Markdown

Closes #108

What this PR does

Adds a pre-flight balance check to crates/api/src/routes/withdrawals.rs::withdraw, positioned
before the idempotency-key reservation, so a withdrawal the wallet cannot fund is rejected
without consuming the caller's key and without spending a signing cycle or a Horizon round trip.

  • Calls state.horizon().balances(&wallet.stellar_account_g) and requires a matching balance line
    (native, or the exact code + issuer pair) with balance >= amount.
  • Returns ApiError::BadRequest("insufficient balance for this withdrawal") on a shortfall, ahead
    of create_withdrawal, so the key stays reusable for a corrected retry.
  • For native XLM, subtracts the account's minimum reserve before comparing, so a withdrawal that
    would drop the account below its base reserve is rejected here rather than by Horizon.

Stroops conversion — decision

The issue asked me to reuse octo_ingest::amount::to_stroops rather than re-deriving the
decimal-to-stroops parsing, and to say where I put it. I moved the module to
crates/wallet-core/src/amount.rs and re-exported to_stroops from octo_wallet_core.

Rationale: crates/api depending on crates/ingest would be a new edge between two crates that
are otherwise siblings, and it would pull the whole ingest pipeline into the API build for one
parsing function. wallet-core is already a dependency of both, so the helper lands there with no
new edges and no duplicated logic. octo-ingest now consumes it from wallet-core; the parsing
code itself is unchanged (pure git mv, verifiable with git log --follow).

Tests

crates/api/tests/withdraw_preflight_tests.rs (new), run against a local mock Horizon rather than
live testnet so it runs under a plain cargo test -p octo-api:

  • 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

All three pass (3 passed; 0 failed).

crates/api/tests/horizon_live_tests.rs already covered similar ground under different names, but
it is gated behind OCTO_LIVE_TESTS=1 and a funded testnet account, so it does not run in a normal
test invocation. The new file is the one that actually guards this behaviour in CI.


Important: this branch conflicts with the non-custodial cutover on main

Flagging this prominently because it is a design decision a maintainer should make, not me.

Since this branch was cut, main landed the non-custodial cutover. It replaced the custodial
withdraw handler with a tombstone and added two tests asserting the endpoints stay gone:
custodial_withdraw_is_gone and custodial_trustline_is_gone.

The issue this PR closes asks for a balance check inside withdrawals.rs::withdraw — a function
main has since deleted. There is no way to satisfy the issue as written and the cutover at the
same time. To keep this PR faithful to its issue I restored the custodial withdraw handler and
dropped those two guard tests.

If the cutover is the intended direction, this PR should not be merged as-is — the issue itself
needs re-scoping onto POST /v1/wallets/:id/submit-signed, and I am happy to redo it that way. I
would rather ask than quietly revert an architectural decision.

Mitigation in the meantime: the restored handler rejects client-custody wallets outright, before
both Horizon and create_withdrawal, with a message pointing at submit-signed. So the cutover's
actual guarantee — the server never signs for a wallet whose key it does not hold — still holds.
The endpoint is only reachable for server-custody rows.

To revert

Everything specific to this decision is isolated:

Where What
crates/api/src/routes/withdrawals.rs The restored handler. Revert to main's tombstone to undo.
crates/api/tests/api_tests.rs custodial_withdraw_is_gone, custodial_trustline_is_gone removed. Restore from main.
crates/api/Cargo.toml The test-fixtures feature flag (see below). Drop it once the handler is gone.

The pre-flight check itself (withdraw_preflight_tests.rs, and the to_stroops move to
wallet-core) is independent of that decision and survives either way.


main does not currently compile — most of this diff is build repair

This is the other thing reviewers should know before reading the diff, because it explains why a
"add one balance check" PR touches ~34 files.

origin/main at 598b088 does not build. Verified on a clean worktree of 598b088 with no changes
applied, against a fresh Postgres:

error[E0425]: cannot find function `parse_inner_v1` in this scope
   --> crates/wallet-core/src/signer.rs:353:20
error[E0425]: cannot find function `parse_inner_v1` in this scope
   --> crates/wallet-core/src/signer.rs:434:20
error[E0004]: non-exhaustive patterns: `StellarNetwork::Standalone` not covered
   --> crates/wallet-core/src/signer.rs:66:15
error: could not compile `octo-wallet-core` (lib) due to 3 previous errors

With only those three fixed, octo-api then fails with a further 31 errors — withdrawals.rs
had its imports stripped during the tombstone conversion while the handler body was left in place,
auth.rs::Claims declares a jti field that issue_token never initialises (E0063), and
horizon.rs has several unsized-type errors on [Balance] and str.

So there is no green baseline to diff against, and no way to test this feature without first making
the workspace build. The unrelated-looking changes in wallet-core/src/signer.rs, api/src/auth.rs,
and api/src/horizon.rs are that repair, kept to the minimum that compiles. I have deliberately
not gone further into unrelated cleanup.

Reviewers may prefer these build fixes split into a separate PR that lands first. Happy to do that —
say the word and I will rebase this onto it.

crates/api/Cargo.toml — please look at this one

octo-wallet-core = { workspace = true, features = ["test-fixtures"] }

wallet-core gates the sign_payment / PaymentRequest re-exports behind test-fixtures.
Dev-dependencies enable it for cargo test, but cargo build -p octo-api then fails with E0432,
so the restored handler cannot compile without this line.

I am not comfortable with a test-only feature being on in a production build and I do not think it
should merge in this form. The clean fix is to ungate sign_payment in wallet-core, but that is a
public-API change to another crate and squarely outside this issue's scope, so I left the decision
to you rather than making it unilaterally.


Test status — full disclosure

cargo test -p octo-api --no-fail-fast against a fresh Postgres 17: 124 passed, 4 failed.

Passing includes all 3 new pre-flight tests, plus auth_tests, authz_matrix_tests,
horizon_client_tests, horizon_resilience_tests, sponsor_e2e_tests, sponsor_webhook_tests.

The 4 failures, none in the withdrawal path:

Test Symptom
api_tests::audit_logs_are_strictly_scoped_to_the_authenticated_user user B sees no audit rows of its own ([])
drift_tests::live_wallet_creation_response_matches_the_openapi_schema handler returns 201, openapi.yaml says 200
malformed_body_tests::wrong_top_level_json_shape_returns_400_across_all_mutating_routes top-level JSON array on POST /v1/wallets/:id/addresses returns 201, not 400
session_revocation_tests::double_logout_is_harmless second logout returns 500, expected 401

I want to be straight about what I can and cannot claim here: because main does not compile, I
could not produce a baseline run, so I cannot prove these four are pre-existing. They are all
outside the code this issue asked me to change, and each looks like a genuine product bug rather
than a merge artifact — the drift one in particular is a plain handler/spec mismatch. But that is
my reading, not a measurement.

I have left all four alone rather than expanding this PR further. If you would like them fixed,
they are probably four separate issues.

How to reproduce locally

docker run -d --name octo-pg -e POSTGRES_USER=octo -e POSTGRES_PASSWORD=octo \
  -e POSTGRES_DB=octo -p 5432:5432 postgres:17-alpine
DATABASE_URL=postgres://octo:octo@localhost:5432/octo cargo test -p octo-api --no-fail-fast

A fresh database matters: record_deposit is idempotent on (stellar_tx_hash, operation_index)
and several fixtures use fixed hashes, so a reused database makes the pagination tests fail
spuriously.

phalap1 added 2 commits July 28, 2026 18:20
Moved to_stroops to crates/wallet-core::amount rather than adding an api → ingest dependency, since wallet-core is already a shared dependency of both crates
added named withdrawal pre-flight balance-check tests which includes the three tests this issue names explicitly, run against a local mock
Horizon rather than live testnet:
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.
crates/api/tests/horizon_live_tests.rs already covered similar scenarios under
different names, gated behind OCTO_LIVE_TESTS=1 and a funded testnet account,
so it doesn't run under a plain cargo test -p octo-api.
@phalap1 phalap1 changed the title Feat/api/withdraw preflight balance check Feat(api): Add a pre-flight balance check against live Horizon balances before signing a withdrawal #108 Jul 28, 2026
@phalap1 phalap1 changed the title Feat(api): Add a pre-flight balance check against live Horizon balances before signing a withdrawal #108 Feat(api): Add a pre-flight balance check against live Horizon balances before signing a withdrawal Jul 28, 2026
@Emmyt24

Emmyt24 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

resolve conflict

Brings the branch up to date with main (43 commits, through Octo-Protocol-org#197). Resolution
notes for the conflicting areas:

- crates/api/src/routes/withdrawals.rs: main replaced the custodial withdraw
  handler with a 410 Gone tombstone as part of the non-custodial cutover. This
  branch's whole subject is a pre-flight check inside that handler, so the
  handler is restored here and the pre-flight balance/reserve check is placed
  ahead of the idempotency-key reservation. A custody gate rejects
  client-custody wallets before both Horizon and create_withdrawal.

- crates/api/Cargo.toml: octo-wallet-core now enables the "test-fixtures"
  feature as a normal dependency. main gates the sign_payment/PaymentRequest
  re-exports behind that feature, which dev-dependencies alone satisfy for
  cargo test but not for cargo build -p octo-api (E0432).

- crates/wallet-core/src/amount.rs: to_stroops moved here from octo-ingest so
  both api and ingest share one conversion, rather than adding an api -> ingest
  dependency or duplicating the parsing.

- crates/api/src/routes/withdrawals.rs seed handling: main made the sealed-seed
  columns nullable for client-custody rows, so the handler now unwraps them
  explicitly and treats a server-custody row with no seed as an integrity fault.

- crates/api/tests/api_tests.rs: custodial_withdraw_is_gone and
  custodial_trustline_is_gone assert the endpoints stay removed; they are
  incompatible with restoring the handler and are dropped here.
@phalap1

phalap1 commented Jul 29, 2026

Copy link
Copy Markdown
Author

Done, PR equally updated.
@Emmyt24

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(api): Add a pre-flight balance check against live Horizon balances before signing a withdrawal

2 participants