You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(api): manual RI exchange approval can race auto reservation and exceed daily cap #663
The manual-approval RI exchange path reads daily spend without the advisory lock used by ReserveRIExchange, so it can race an auto reservation and exceed the daily cap.
Current behaviour
internal/api/handler_ri_exchange.go (~2561-2566, 2612-2645) reads GetRIExchangeDailySpend without a lock and executes up to min(perCap, dailyCap - spend). Its ledger row holds only the quote. A concurrent auto reservation (platform PR #662, go#41) can take the same headroom.
Steps to verify the gap
Cap 1000, manual quote 100: manual reads spend 100, gets ceiling 900; auto reserves 900 under the lock; worst case 1800 spent against a 1000 cap.
Expected behaviour
Manual approval reserves its execution ceiling through the same lock-and-reserve step before Execute.
Proposed fix
Route manual approval through ReserveRIExchange (or equivalent). Also consider AND status='processing' guards in CompleteRIExchangeWithPayment / FailRIExchange (internal/config/store_postgres.go ~3087, ~3129).
Closing: this was fixed by #690 (cf0b1a0, merged 2026-10-09). That PR closed #656, which describes the same race, so it never referenced this issue.
Checked on current main:
The manual approve path (executeApprovedExchange, handler_ri_exchange.go around line 2686) now calls ReserveApprovedRIExchange before Execute and passes the reserved ceiling as MaxPaymentDueUSD. Nothing in internal/api reads GetRIExchangeDailySpend any more.
Manual (ReserveApprovedRIExchange) and auto (ReserveRIExchange) both go through reserveRIExchangeCeiling. That function takes pg_advisory_xact_lock(riExchangeDailySpendLockKey), reads spend excluding the row itself, computes min(per-exchange cap, daily cap − spend), and writes it as payment_due, all in one transaction. A request that doesn't fit is refused with ErrRIExchangeDailyCapExceeded. This holds across Lambda instances.
Tests:
go test -tags integration ./internal/config -run 'PostgresStoreDB_(Reserve|DailySpend)' passed all 8 tests against real Postgres.
With the advisory lock swapped for a plain SELECT, two tests failed in 5 of 5 runs: ReserveApprovedRIExchange_ConcurrentApprovalsStayWithinCap (manual path) and ReserveRIExchange_SerializesIndependentStores (auto path, same lock key).
Left for you; neither is needed to hold the cap today:
The optional AND status='processing' guards from this issue.
On FailRIExchange: safe. It stops a completed row from being flipped to failed and dropping out of the spend total.
On CompleteRIExchangeWithPayment: could refuse the ledger write after money has moved, which would loosen cap accounting.
No current path calls Fail after Complete on the same row.
TestPostgresStoreDB_ReserveApprovedRIExchange_AutoAndApprovalShareHeadroom still passes with the lock removed. It could hold the lock from a third transaction, as the concurrent-approvals test does, to make it deterministic. That would be a small test-only follow-up.
Activity
IAmJSD commentedon Oct 16, 2026
Released: already fixed by #690 (closing, details below)
IAmJSD commentedon Oct 16, 2026
Closing: this was fixed by #690 (cf0b1a0, merged 2026-10-09). That PR closed #656, which describes the same race, so it never referenced this issue.
Checked on current main:
executeApprovedExchange,handler_ri_exchange.goaround line 2686) now callsReserveApprovedRIExchangebefore Execute and passes the reserved ceiling asMaxPaymentDueUSD. Nothing ininternal/apireadsGetRIExchangeDailySpendany more.ReserveApprovedRIExchange) and auto (ReserveRIExchange) both go throughreserveRIExchangeCeiling. That function takespg_advisory_xact_lock(riExchangeDailySpendLockKey), reads spend excluding the row itself, computes min(per-exchange cap, daily cap − spend), and writes it aspayment_due, all in one transaction. A request that doesn't fit is refused withErrRIExchangeDailyCapExceeded. This holds across Lambda instances.Tests:
go test -tags integration ./internal/config -run 'PostgresStoreDB_(Reserve|DailySpend)'passed all 8 tests against real Postgres.SELECT, two tests failed in 5 of 5 runs:ReserveApprovedRIExchange_ConcurrentApprovalsStayWithinCap(manual path) andReserveRIExchange_SerializesIndependentStores(auto path, same lock key).Left for you; neither is needed to hold the cap today:
The optional
AND status='processing'guards from this issue.FailRIExchange: safe. It stops a completed row from being flipped to failed and dropping out of the spend total.CompleteRIExchangeWithPayment: could refuse the ledger write after money has moved, which would loosen cap accounting.No current path calls Fail after Complete on the same row.
TestPostgresStoreDB_ReserveApprovedRIExchange_AutoAndApprovalShareHeadroomstill passes with the lock removed. It could hold the lock from a third transaction, as the concurrent-approvals test does, to make it deterministic. That would be a small test-only follow-up.