Repository navigation
fix(api,store): sponsorship budget semantics, fee-cap validation, OTP reissue invalidation, balances timeout - #374
Merged
Emmyt24 merged 2 commits intoSep 28, 2026
Conversation
… 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
|
@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! 🚀 |
…orship-otp-balances-hardening # Conflicts: # crates/store/src/lib.rs
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.
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-storeand 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 byput_config(400, already enforced there). The store also treats a negative value likeSome(0)if one ever gets in (fails closed).What the audit found:
put_configalready rejected negatives.NoneandSome(0)were stored as given.try_reserve_sponsored_transactionhandledSome(0)correctly only by accident:spent + fee <= 0fails only because the route guaranteesfee > 0. A zero-fee reservation would have got through a zero budget.sum_sponsored_fees_reserved_todaynever reads the budget, so it can't disagree with the rule.Changes:
GasSponsorshipConfig::daily_budget_stroopsstating the semantics.try_reserve_sponsored_transactionchecks 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_confignow rejectsper_tx_fee_cap_stroops > daily_budget_stroopswhen both are set. The400message 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.docs/api.md.#275 — Invalidate prior OTPs on reissue
create_otpruns 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.(user, purpose)pg_advisory_xact_lockstops two concurrentcreate_otpcalls from each leaving a live row. Under READ COMMITTED, one call's UPDATE can't see the other's uncommitted INSERT.#278 — Route timeout for
get_balancesApiError::GatewayTimeout→504in the standard{statusCode, message, data}envelope.axum::middleware::from_fntimeout (OUTBOUND_ROUTE_TIMEOUT = 10s) applies only toGET /v1/wallets/:id/balances. It is infallible, so it doesn't need theHandleErrorLayerthe router comments warn about. The client gets a clean504instead of a dropped connection.horizon.rs. Documented indocs/api.md.Security fix outside the issues' scope (AGENTS.md §4)
verify_and_consume_otpread the OTP, compared it in Rust, then ran an unconditionalUPDATE ... SET consumed_at = now() WHERE id = $1. Because of that gap between check and consume: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 withInvalidOtpunless 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_daydaily_budget_of_none_allows_unlimited_sponsorshipput_config_rejects_a_negative_daily_budgetput_config_rejects_a_per_tx_cap_larger_than_the_daily_budgetput_config_accepts_a_per_tx_cap_equal_to_the_daily_budgetput_config_accepts_either_field_left_unsetcreate_otp_invalidates_a_prior_unconsumed_otp_for_the_same_purposecreate_otp_does_not_affect_otps_for_a_different_purposeverify_and_consume_otp_rejects_an_otp_that_was_superseded_by_a_newer_one_even_if_queried_directlyget_balances_returns_a_clean_error_when_horizon_is_slower_than_the_route_timeoutget_balances_succeeds_normally_when_horizon_responds_promptly🤖 Generated with Claude Code