Skip to content

fix(web): make the CoinPay settled-status guard atomic - #235

Merged
ralyodio merged 1 commit into
masterfrom
fix/coinpay-atomic-status-guard
Sep 25, 2026
Merged

ralyodio merged 1 commit into
masterfrom
fix/coinpay-atomic-status-guard

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What

The CoinPay webhook's TC-10 guard (#213), which stops a late payment.expired / payment.failed from regressing a settled funding_payments, credit_deposits or license_purchases row, is now atomic. The /api/funding/status poll also writes funding_payments.status, and it had no guard at all. It now uses the same guard.

Why

The guard read the row's status and wrote later (isStatusRegression). If two deliveries for the same payment arrived together, both could read pending. The expired/failed request's UPDATE then waited on the confirm's row lock and overwrote it after the commit, which left the settled payment expired. For a license purchase the route even answered { received: true, status: "expired" }.

/api/funding/status re-polls CoinPay for any row that is not forwarded/failed/expired, including confirmed. It then writes the live status unconditionally. getCoinpayPaymentStatus falls back to 'pending' when the response has no status. So one lagging or empty poll response could send a confirmed contribution back to pending, and this endpoint needs no authentication.

How

  • New apps/web/src/lib/payment-status.ts holds the settled set (confirmed, forwarded) and the PostgREST list literal.
  • Any write whose next status is not settled carries .not('status', 'in', '("confirmed","forwarded")') in the UPDATE itself. supabase-js sends this as status=not.in.("confirmed","forwarded"), and PostgREST runs it as ... AND NOT status = ANY('{confirmed,forwarded}').
  • The webhook adds .select('id') to that write. If zero rows are updated, it answers { received: true, ignored: "stale event" }, the same response as before. The read-then-write check (isStatusRegression and the status column in the lookups) is removed. The lookups still exist, but only to decide which table owns the payment id.
  • Settled → settled (confirmed → forwarded, or a repeated confirm) is written without the condition and still applies. Unsettled → unsettled (pending → expired) still applies.
  • The license-purchase status write now returns 500 when the database reports an error. Before, the error was ignored and the license was still activated.
  • No migration. All three status columns are NOT NULL, so NOT (status = ANY(...)) never evaluates to NULL and wrongly skips a row.

Double-credit audit (settled side)

No double-credit path was found, so nothing else changed:

  • Credit balance: /api/usage works it out as SUM(amount_usd) over credit_deposits rows with status confirmed/forwarded, minus usage. No stored balance, trigger or RPC adds to anything. A repeated confirm, or confirm → forwarded, still counts the row once.
  • License: sets user_profiles.license_status = 'active', a constant, so replaying it has no further effect.
  • Funding total and contributors: sums over rows, so repeats don't count twice.
  • Waitlist: writes paid = true and the referrer's amount_usd = 399, both constant values. Its entry.paid pre-check is still read-then-write, but two concurrent confirms produce the same final row. No code pays out referrals: total_referral_earnings_usd is never incremented.

Verification

  • Route tests: the mocked Supabase now keeps the row's status at write time and applies .not(... in ...) filters the way PostgREST does. New tests cover a funding_payments, credit_deposits or license_purchases row settled by a concurrent delivery after the lookup: the route answers stale-event, the row stays confirmed, and no license is activated. Existing tests check that confirmed → forwarded still applies and that pending → expired still applies. The TC-10 tests now check the stored status instead of whether update was called.

    • Against the old route, the three race tests fail. Against the new route, all 19 pass.
    • New funding/status test: a lagging pending poll does not regress a confirmed row. It fails before the fix and passes after, as do the forwarded and expired cases.
  • Web suite: 52 files, 448 tests passed.

  • Real database (throwaway, not committed; containers removed afterwards): postgres:17-alpine with the repo's three migrations applied (the auth.users FK dropped), and postgrest/postgrest:v12.2.8 in front of it. The real route handlers talked to PostgREST through supabase-js. Session A ran BEGIN; UPDATE … status='confirmed'; pg_sleep(1.5); COMMIT, and while A held the lock the webhook was sent payment.expired:

    route table response blocked final status
    old funding_payments {received:true} 1.5 s expired
    old credit_deposits {received:true} 1.4 s expired
    old license_purchases {received:true,status:"expired"} 1.5 s expired
    new funding_payments stale event 1.4 s confirmed
    new credit_deposits stale event 1.5 s confirmed
    new license_purchases stale event 1.4 s confirmed
    new /api/funding/status (live pending) funding_payments — 1.5 s confirmed

    With the new route, confirmed → forwarded gave forwarded and pending → expired gave expired. The Postgres statement log showed the SQL PostgREST ran: UPDATE "public"."credit_deposits" SET … WHERE "coinpay_payment_id" = $2 AND NOT "status" = ANY ($3) RETURNING "id", with $3 = '{confirmed,forwarded}'. So the quoted list is parsed correctly. The same race with plain psql: the guarded UPDATE gave UPDATE 0 and the row stayed confirmed; the unguarded one gave UPDATE 1 and the row became expired.

Operator-visible change

  • A late or concurrent expired/failed webhook for a settled payment is refused even when it races the confirm. The response is { received: true, ignored: "stale event" }, and the warning now names the table.
  • /api/funding/status no longer writes an unsettled live status over a settled row. The response body is unchanged: it still returns the live status.
  • If the license-purchase status write fails, the webhook now returns 500, so CoinPay retries, instead of activating the license anyway.

Residual risk

  • Writers outside these two routes (manual SQL, the Supabase dashboard) are not guarded. A BEFORE UPDATE trigger would cover them, but it needs a migration and a deploy step. The app routes are the only code that writes these statuses.
  • confirmed → forwarded still re-stamps paid_at / confirmed_at (existing behaviour, not a credit issue).

Two deliveries for the same payment could both read an unsettled row,
and the expired/failed write then overwrote the confirm that committed in
between. The settled check now lives in the UPDATE's WHERE clause
(status not in confirmed/forwarded) and zero matched rows answers
'stale event'. The funding status poll, which could write a lagging or
defaulted 'pending' over a confirmed row, uses the same guard.
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

12 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 6 | LOW: 5

Severity Rule Location
HIGH secret-aws-access-key prd/0003-detect-hardcoded-secrets-before-they-are-committed-or-served.md:126
MEDIUM js-open-redirect apps/web/src/app/auth/login/page.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/hire/page.tsx:104
MEDIUM js-unescaped-html-sink apps/web/src/app/hire/page.tsx:108
MEDIUM js-open-redirect apps/web/src/components/funding/FundingClient.tsx:97
MEDIUM js-unescaped-html-sink apps/web/src/components/GuideReader.tsx:265
MEDIUM js-uninitialized-buffer packages/scan/src/node-rules.ts:456
LOW secret-generic-credential PRD.md:269
LOW tls-verification-disabled prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md:121
LOW tls-verification-disabled prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md:122
LOW sh-remote-script-execution scripts/smoke-test.sh:47
LOW secret-aws-access-key scripts/smoke-test.sh:112

Snippets are redacted; ThreatCrush never prints matched credential material.

if (error) return { applied: false, error };
if (!data || data.length === 0) {
console.warn(
`[coinpay webhook] ignoring out-of-order ${nextStatus} for already-settled ${table} row ${logSafe(paymentId)}`,
@ralyodio
ralyodio merged commit 8ec4a61 into master Sep 25, 2026
11 checks passed
@ralyodio
ralyodio deleted the fix/coinpay-atomic-status-guard branch September 25, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants