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
Conversation
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.
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.
Author
|
Done, PR equally updated. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #108
What this PR does
Adds a pre-flight balance check to
crates/api/src/routes/withdrawals.rs::withdraw, positionedbefore 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.
state.horizon().balances(&wallet.stellar_account_g)and requires a matching balance line(native, or the exact
code+issuerpair) withbalance >= amount.ApiError::BadRequest("insufficient balance for this withdrawal")on a shortfall, aheadof
create_withdrawal, so the key stays reusable for a corrected retry.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_stroopsrather than re-deriving thedecimal-to-stroops parsing, and to say where I put it. I moved the module to
crates/wallet-core/src/amount.rsand re-exportedto_stroopsfromocto_wallet_core.Rationale:
crates/apidepending oncrates/ingestwould be a new edge between two crates thatare otherwise siblings, and it would pull the whole ingest pipeline into the API build for one
parsing function.
wallet-coreis already a dependency of both, so the helper lands there with nonew edges and no duplicated logic.
octo-ingestnow consumes it fromwallet-core; the parsingcode itself is unchanged (pure
git mv, verifiable withgit log --follow).Tests
crates/api/tests/withdraw_preflight_tests.rs(new), run against a local mock Horizon rather thanlive testnet so it runs under a plain
cargo test -p octo-api:withdraw_rejects_when_wallet_balance_is_insufficient_without_consuming_the_idempotency_keywithdraw_rejects_native_withdrawal_that_would_breach_the_minimum_reservewithdraw_succeeds_when_balance_is_sufficientAll three pass (
3 passed; 0 failed).crates/api/tests/horizon_live_tests.rsalready covered similar ground under different names, butit is gated behind
OCTO_LIVE_TESTS=1and a funded testnet account, so it does not run in a normaltest invocation. The new file is the one that actually guards this behaviour in CI.
Important: this branch conflicts with the non-custodial cutover on
mainFlagging this prominently because it is a design decision a maintainer should make, not me.
Since this branch was cut,
mainlanded the non-custodial cutover. It replaced the custodialwithdraw handler with a tombstone and added two tests asserting the endpoints stay gone:
custodial_withdraw_is_goneandcustodial_trustline_is_gone.The issue this PR closes asks for a balance check inside
withdrawals.rs::withdraw— a functionmainhas since deleted. There is no way to satisfy the issue as written and the cutover at thesame 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. Iwould 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 atsubmit-signed. So the cutover'sactual 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:
crates/api/src/routes/withdrawals.rsmain's tombstone to undo.crates/api/tests/api_tests.rscustodial_withdraw_is_gone,custodial_trustline_is_goneremoved. Restore frommain.crates/api/Cargo.tomltest-fixturesfeature flag (see below). Drop it once the handler is gone.The pre-flight check itself (
withdraw_preflight_tests.rs, and theto_stroopsmove towallet-core) is independent of that decision and survives either way.maindoes not currently compile — most of this diff is build repairThis 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/mainat598b088does not build. Verified on a clean worktree of598b088with no changesapplied, against a fresh Postgres:
With only those three fixed,
octo-apithen fails with a further 31 errors —withdrawals.rshad its imports stripped during the tombstone conversion while the handler body was left in place,
auth.rs::Claimsdeclares ajtifield thatissue_tokennever initialises (E0063), andhorizon.rshas several unsized-type errors on[Balance]andstr.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.rsare that repair, kept to the minimum that compiles. I have deliberatelynot 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 onewallet-coregates thesign_payment/PaymentRequestre-exports behindtest-fixtures.Dev-dependencies enable it for
cargo test, butcargo build -p octo-apithen 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_paymentinwallet-core, but that is apublic-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-fastagainst 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:
api_tests::audit_logs_are_strictly_scoped_to_the_authenticated_user[])drift_tests::live_wallet_creation_response_matches_the_openapi_schemaopenapi.yamlsays 200malformed_body_tests::wrong_top_level_json_shape_returns_400_across_all_mutating_routesPOST /v1/wallets/:id/addressesreturns 201, not 400session_revocation_tests::double_logout_is_harmlessI want to be straight about what I can and cannot claim here: because
maindoes not compile, Icould 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-fastA fresh database matters:
record_depositis idempotent on(stellar_tx_hash, operation_index)and several fixtures use fixed hashes, so a reused database makes the pagination tests fail
spuriously.