Skip to content

fix(api,store): sponsorship budget semantics, fee-cap validation, OTP reissue invalidation, balances timeout - #374

Merged
Emmyt24 merged 2 commits into
Octo-Protocol-org:dev-branchfrom
Lost-Z:fix/api/sponsorship-otp-balances-hardening
Sep 28, 2026
Merged

Emmyt24 merged 2 commits into
Octo-Protocol-org:dev-branchfrom
Lost-Z:fix/api/sponsorship-otp-balances-hardening

Conversation

@Lost-Z

@Lost-Z Lost-Z commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Warning

Implementation only — not tested. No tests were run and none of the test cases listed in the issues have been added. Nothing here has been compiled either (no Rust toolchain was available where this was written). Please run cargo test -p octo-api -p octo-store and add the requested tests before merging.

Closes #277
Closes #276
Closes #275
Closes #278

#277 — Null vs zero daily sponsorship budget

Chosen semantics (stated before any tests are written against them):

  • daily_budget_stroops = None → unlimited sponsorship.
  • daily_budget_stroops = Some(0) → sponsorship fully disabled for the day. Every reservation is refused (429).
  • daily_budget_stroops < 0 → rejected by put_config (400, already enforced there). The store also treats a negative value like Some(0) if one ever gets in (fails closed).

What the audit found:

  • put_config already rejected negatives. None and Some(0) were stored as given.
  • try_reserve_sponsored_transaction handled Some(0) correctly only by accident: spent + fee <= 0 fails only because the route guarantees fee > 0. A zero-fee reservation would have got through a zero budget.
  • sum_sponsored_fees_reserved_today never reads the budget, so it can't disagree with the rule.

Changes:

  • Doc comment on GasSponsorshipConfig::daily_budget_stroops stating the semantics.
  • try_reserve_sponsored_transaction checks the rule directly: a non-positive budget, or a non-positive fee (which could otherwise offset today's spend), is refused before the DB is touched.

#276 — Per-tx fee cap vs daily budget

  • put_config now rejects per_tx_fee_cap_stroops > daily_budget_stroops when both are set. The 400 message names both values, e.g. per_tx_fee_cap_stroops (500) must not exceed daily_budget_stroops (100); lower the per-transaction cap or raise the daily budget.
  • Equal values and either field left unset are still accepted. Validation of each field on its own is unchanged.
  • Documented in docs/api.md.

#275 — Invalidate prior OTPs on reissue

  • create_otp runs in one transaction. It marks every unconsumed OTP for the same (user_id, purpose) as consumed, then inserts the new one. The invariant (at most one live OTP per pair) is in the doc comment.
  • A per-(user, purpose) pg_advisory_xact_lock stops two concurrent create_otp calls from each leaving a live row. Under READ COMMITTED, one call's UPDATE can't see the other's uncommitted INSERT.
  • OTPs for other purposes are unaffected.

#278 — Route timeout for get_balances

  • New ApiError::GatewayTimeout → 504 in the standard {statusCode, message, data} envelope.
  • An axum::middleware::from_fn timeout (OUTBOUND_ROUTE_TIMEOUT = 10s) applies only to GET /v1/wallets/:id/balances. It is infallible, so it doesn't need the HandleErrorLayer the router comments warn about. The client gets a clean 504 instead of a dropped connection.
  • It is separate from the per-attempt Horizon client timeout and the retry policy in horizon.rs. Documented in docs/api.md.

Security fix outside the issues' scope (AGENTS.md §4)

verify_and_consume_otp read the OTP, compared it in Rust, then ran an unconditional UPDATE ... SET consumed_at = now() WHERE id = $1. Because of that gap between check and consume:

  • two concurrent requests with the correct code could both succeed (e.g. one withdrawal OTP authorising a double submit), and
  • concurrent wrong guesses could all read attempts < 5, so a correct guess could still be consumed after the attempt limit had been passed.

The consume is now a conditional UPDATE ... WHERE consumed_at IS NULL AND attempts < 5 AND expires_at >= now(), and it fails with InvalidOtp unless exactly one row changed. The common case behaves the same as before.

Tests requested by the issues (not added)

  • daily_budget_of_zero_blocks_all_sponsorship_for_the_day
  • daily_budget_of_none_allows_unlimited_sponsorship
  • put_config_rejects_a_negative_daily_budget
  • put_config_rejects_a_per_tx_cap_larger_than_the_daily_budget
  • put_config_accepts_a_per_tx_cap_equal_to_the_daily_budget
  • put_config_accepts_either_field_left_unset
  • create_otp_invalidates_a_prior_unconsumed_otp_for_the_same_purpose
  • create_otp_does_not_affect_otps_for_a_different_purpose
  • verify_and_consume_otp_rejects_an_otp_that_was_superseded_by_a_newer_one_even_if_queried_directly
  • get_balances_returns_a_clean_error_when_horizon_is_slower_than_the_route_timeout
  • get_balances_succeeds_normally_when_horizon_responds_promptly

🤖 Generated with Claude Code

… balances timeout

- store: document daily_budget_stroops semantics (None = unlimited, Some(0) = sponsorship
  disabled today) and enforce them explicitly in try_reserve_sponsored_transaction; a
  non-positive budget or fee is refused before touching the database (fail closed).
- api: reject a per_tx_fee_cap_stroops larger than daily_budget_stroops in put_config with a
  400 that names both values.
- store: create_otp now supersedes any prior unconsumed OTP for the same (user, purpose) in the
  same transaction, serialized per pair with an advisory lock, making the
  at-most-one-live-OTP invariant explicit.
- store: verify_and_consume_otp consumes with a conditional UPDATE so concurrent correct
  submissions can't both succeed and the attempt limit holds under racing guesses.
- api: scope a 10s caller-facing timeout to GET /v1/wallets/:id/balances, returning a 504
  envelope instead of holding the connection across slow Horizon retries.

Closes Octo-Protocol-org#277
Closes Octo-Protocol-org#276
Closes Octo-Protocol-org#275
Closes Octo-Protocol-org#278
@drips-wave

drips-wave Bot commented Sep 24, 2026

Copy link
Copy Markdown

@Lost-Z Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

…orship-otp-balances-hardening

# Conflicts:
#	crates/store/src/lib.rs
@Emmyt24
Emmyt24 merged commit edb6398 into Octo-Protocol-org:dev-branch Sep 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment