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
Found while implementing #528 (PR #537), 2026-07-25. Verified against a local database.
Problem
#528 closed the two impacts that depended on an untrusted caller producing a successful transaction: the INSERT policy on transactions now pins status = 'pending', and table-level UPDATE is revoked from authenticated in favour of a three-column grant.
That invariant is "an untrusted caller may open a transaction, but may not declare it paid". It deliberately says nothing about what a pending row claims it is owed — and the Solana settlement columns are exactly that:
settlement_currency text -- 'sol' | 'usdc'
settlement_base bigint -- amount in lamports / USDC base units
settlement_mint text
settlement_sol_usd numeric(20,8)
All four are still writable by authenticated on INSERT, because app/api/payments/checkout/route.ts:233 legitimately writes them on the user-scoped client and the fix in #528 did not narrow INSERT columns.
Impact
The on-chain payment is verified against the row's ownsettlement_base, in lib/payments/solana-reconcile.ts:103-106:
vTotalBase becomes the totalBase handed to verifySplitTransfer. Both callers reach it — app/api/payments/solana/verify/route.ts:27 (reconcileSolanaOneTimeTransaction) and the /api/cron/solana-reconcile backstop. A student can therefore skip the checkout route and POST /rest/v1/transactions directly:
The row is pending, so it passes the #528 policy cleanly. They then pay 1 lamport, the verify endpoint compares the on-chain transfer against settlement_base: 1, finds it satisfied, and flips the row to successful on the service-role client — which fires trigger_manage_transactions and grants the entitlement. Full course access for a fraction of a cent, through the legitimate payment path.
amount is caller-controlled at INSERT for the same reason, which is the same shape of problem for any provider that reconciles against the stored amount rather than against the product's price.
#528's two filed impacts (free enrolment via a self-inserted successful row, and fabricated payout liability) are both gated on the row reaching successful, and both are closed by pinning status. This one is reached through a legitimately pending row, so it needs a different control, and mixing the two would have made that PR unreviewable.
Possible directions
The straightforward fixes for status do not transfer here, because these columns must hold caller-supplied values at insert time:
Column-level INSERT grants don't help on their own: the checkout route needs to write these columns, so they'd stay in the allow-list.
A BEFORE INSERT trigger that recomputes the values — the pattern Split snapshot is written at a single call site with no DB backstop (#496 follow-up) #512 used for school_percentage_snapshot — can't fully work: settlement_sol_usd comes from a live SOL/USD quote fetched off-platform (getSolUsdPrice()), which a trigger cannot obtain. A trigger could still recompute amount from products.price / plans.price and reject a settlement_base inconsistent with it, which would close most of the gap.
Move transaction creation behind a SECURITY DEFINER RPC (direction 3 in transactions RLS restricts rows but not columns — a student can self-insert a 'successful' sale #528) and revoke the direct INSERT grant. The RPC would take product_id/plan_id plus the quoted rate and derive amount, currency and the settlement figures server-side. This is the thorough fix and probably the right one, but it is a real change to the checkout path.
Or verify against the product price rather than the row in solana/verify — narrower, and it leaves the column writable for anything else that reads it.
Worth confirming which of settlement_* and amount are actually load-bearing in verification before choosing, rather than hardening all of them by reflex.
Found while implementing #528 (PR #537), 2026-07-25. Verified against a local database.
Problem
#528 closed the two impacts that depended on an untrusted caller producing a
successfultransaction: the INSERT policy ontransactionsnow pinsstatus = 'pending', and table-levelUPDATEis revoked fromauthenticatedin favour of a three-column grant.That invariant is "an untrusted caller may open a transaction, but may not declare it paid". It deliberately says nothing about what a pending row claims it is owed — and the Solana settlement columns are exactly that:
All four are still writable by
authenticatedon INSERT, becauseapp/api/payments/checkout/route.ts:233legitimately writes them on the user-scoped client and the fix in #528 did not narrow INSERT columns.Impact
The on-chain payment is verified against the row's own
settlement_base, inlib/payments/solana-reconcile.ts:103-106:vTotalBasebecomes thetotalBasehanded toverifySplitTransfer. Both callers reach it —app/api/payments/solana/verify/route.ts:27(reconcileSolanaOneTimeTransaction) and the/api/cron/solana-reconcilebackstop. A student can therefore skip the checkout route andPOST /rest/v1/transactionsdirectly:{ "user_id": "<self>", "tenant_id": "<own tenant>", "product_id": <a $100 course>, "amount": 100, "currency": "usd", "status": "pending", "payment_provider": "solana", "settlement_currency": "sol", "settlement_base": 1 }The row is
pending, so it passes the #528 policy cleanly. They then pay 1 lamport, the verify endpoint compares the on-chain transfer againstsettlement_base: 1, finds it satisfied, and flips the row tosuccessfulon the service-role client — which firestrigger_manage_transactionsand grants the entitlement. Full course access for a fraction of a cent, through the legitimate payment path.amountis caller-controlled at INSERT for the same reason, which is the same shape of problem for any provider that reconciles against the stored amount rather than against the product's price.Why it wasn't folded into #528
#528's two filed impacts (free enrolment via a self-inserted
successfulrow, and fabricated payout liability) are both gated on the row reachingsuccessful, and both are closed by pinningstatus. This one is reached through a legitimately pending row, so it needs a different control, and mixing the two would have made that PR unreviewable.Possible directions
The straightforward fixes for
statusdo not transfer here, because these columns must hold caller-supplied values at insert time:BEFORE INSERTtrigger that recomputes the values — the pattern Split snapshot is written at a single call site with no DB backstop (#496 follow-up) #512 used forschool_percentage_snapshot— can't fully work:settlement_sol_usdcomes from a live SOL/USD quote fetched off-platform (getSolUsdPrice()), which a trigger cannot obtain. A trigger could still recomputeamountfromproducts.price/plans.priceand reject asettlement_baseinconsistent with it, which would close most of the gap.SECURITY DEFINERRPC (direction 3 in transactions RLS restricts rows but not columns — a student can self-insert a 'successful' sale #528) and revoke the direct INSERT grant. The RPC would takeproduct_id/plan_idplus the quoted rate and deriveamount,currencyand the settlement figures server-side. This is the thorough fix and probably the right one, but it is a real change to the checkout path.solana/verify— narrower, and it leaves the column writable for anything else that reads it.Worth confirming which of
settlement_*andamountare actually load-bearing in verification before choosing, rather than hardening all of them by reflex.