fix: verify payment server-side before redeeming pay codes (C1) - #92
Open
sbddesign wants to merge 5 commits into
Open
fix: verify payment server-side before redeeming pay codes (C1)#92sbddesign wants to merge 5 commits into
sbddesign wants to merge 5 commits into
Conversation
redeemPayCode was a public procedure that flipped a PENDING pay code to ACTIVE and published its DNS record with no server-side payment check — the only guard was a comment claiming the client-side MDK hook handled it. Anyone could call createPayCode then redeemPayCode directly and get a paid-tier username for free. Move checkout creation server-side and verify payment before activating: - createPayCode creates the MDK checkout with a server-owned price (PAY_CODE_PRICE_SATS) and stores its id on the pay code row, returning the checkout URL to the client. - redeemPayCode looks up the stored checkout, confirms with MDK that it was paid (PAYMENT_RECEIVED and >= 5000 sats received), and refuses with PRECONDITION_FAILED otherwise. The PENDING -> ACTIVE flip is now an atomic conditional updateMany, which also closes the concurrent-redeem race (H2). - The client form drops its own checkout creation and price literal and just redirects to the returned URL. - Add checkoutId (unique) to PayCode; add src/server/mdk.ts server helper with a pure, unit-tested isCheckoutPaid predicate; delete the dead checkPayment procedure; update README/AGENTS docs.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The lazy require() in src/server/mdk.ts carried an eslint-disable-next-line for @typescript-eslint/no-var-requires. This repo's .eslintrc.json extends only next/core-web-vitals, which never loads the @typescript-eslint plugin, so ESLint reported "Definition for rule ... was not found" as an error on the disable comment itself and next build exited 1. That is the failing Vercel check on this PR. next.config.mjs sets eslint.ignoreDuringBuilds to !process.env.CI, so a local pnpm build silently skips linting and never reproduced it. Verify with CI=1 pnpm build. Co-authored-by: Stephen DeLorme <stephen@d.elor.me> Signed-off-by: Stephen DeLorme <stephen@d.elor.me>
POST /record was public, unauthenticated, and performed no payment verification. It took a caller-supplied localPart and bolt12 offer and wrote the TXT record straight to Cloudflare. So while this PR closes the direct redeemPayCode exploit, /record left an equivalent side door open: anyone could still claim a chosen custom username for free, and the row never reached the database at all, so the name did not even register as taken. Zeus (ZeusLN/zeus, views/Settings/Bolt12Address.tsx) is the only known caller; nothing in this app used it. Per the product decision, we are retiring it rather than trying to preserve a wallet-specific carve-out — a shared secret shipped inside a public wallet binary is not a control. Zeus can move to payCode.createRandomPayCode for a free name, or the paid create/redeem flow for a chosen one. DOMAIN and CF_DOMAIN_ID existed only for this route and are dropped from .env.sample. Docs updated: the AGENTS.md API reference described a POST /v2/record path that never existed and is replaced with the actual tRPC procedures. BREAKING CHANGE: POST /record is removed. Clients must use the tRPC API. Co-authored-by: Stephen DeLorme <stephen@d.elor.me> Signed-off-by: Stephen DeLorme <stephen@d.elor.me>
Only ACTIVE rows counted as taking a name, so two callers could each
create a PENDING row for the same custom username, each pay their own
5,000-sat checkout, and only the first to redeem would get it. The
second buyer paid and got nothing.
Reserve the name at PENDING time and let the database arbitrate:
- A partial unique index on (lower(userName), lower(domain)) WHERE
status IN ('PENDING','ACTIVE') serializes racing inserts. The
preflight findFirst is kept only for a fast, friendly CONFLICT; it is
explicitly not treated as a lock, and a P2002 from the index is
translated to the same CONFLICT.
- Reservations are a lease, not a permanent hold: reservationExpiresAt
is seeded with a short window covering the MDK call, then aligned to
the checkout's own expiresAt plus a grace period for users who paid
but did not return to the success page immediately. A stale lease is
expired in the same transaction that claims the name, so an abandoned
checkout cannot keep a name hostage.
- createRandomPayCode honors live reservations and retries a P2002 with
a fresh name instead of surfacing a 500.
- redeemPayCode additionally checks that the checkout MDK returned is
the one bound to this row before trusting its paid status.
Prisma 5 cannot express a partial expression index, and this database
predates a checked-in migration history and is managed with db push, so
the index ships as an idempotent SQL expand migration
(pnpm db:manual-migrations) rather than an invented prisma/migrations
baseline. It must run before the code that reads the new columns. It
refuses to guess: it aborts if live rows already collide
case-insensitively, or if a partially deployed row has a checkout id but
no lease.
Tests move up to the router boundary — reservation races, checkout
binding, and unpaid redemption — rather than only the pure predicate.
Each was verified by mutation: disabling the payment check, the checkout
id check, or the P2002 handling each fails exactly one test.
Co-authored-by: Stephen DeLorme <stephen@d.elor.me>
Signed-off-by: Stephen DeLorme <stephen@d.elor.me>
Review of the previous commit found two ways a buyer could pay and get nothing, plus a migration that could break a request already in flight. An expired lease did not mean the name was free. The release path expired any PENDING row past its lease without asking whether its bound checkout had been paid, so a buyer who paid and returned after the grace window lost both the name and the 5,000 sats. The 24-hour grace made it worse in the other direction too: it was a free hold anyone could renew by calling the public createPayCode again. The grace period is gone. A lease now ends exactly at the checkout's own expiry, and releasing an expired reservation requires MDK to confirm the checkout is dead: - Release only on the terminal EXPIRED status with the full-payment predicate false. "Not paid yet" is not "cannot be paid" — UNCONFIRMED, CONFIRMED and PENDING_PAYMENT are all live states whose invoice may still settle, and our clock running ahead of MDK's is not authority to kill a checkout. Paid, live, or unverifiable all keep the name. - Verify the returned checkout id matches the row before trusting its status, the same binding rule redemption already applies. - MDK failure fails closed: the reservation stands and this caller is refused. Refusing a create is recoverable; reselling a paid name is not. The sweep reads outside the transaction, so each candidate carries the state it was judged on and the release write re-asserts all of it — status, lease, and the observed checkout id including null. Expiring on row id alone let a slow createPayCode finalize the row with a real checkout in the gap and then be handed a payable URL for a reservation somebody else had just expired. A zero-count release is not treated as success; the partial unique index arbitrates. Checkout finalization is likewise conditional on the row still being PENDING with its original lease, so a caller never receives a payable URL for a reservation that no longer exists. createRandomPayCode no longer releases anything. It cannot distinguish a paid reservation from an abandoned one without an MDK round trip and does not need to — it just generates another name. The migration's legacy sweep now requires both checkoutId and reservationExpiresAt to be NULL. Matching checkoutId alone also matched the live interim state of a createPayCode waiting on MDK, so a rerun against a live database expired an in-flight reservation. Verified against a disposable PostgreSQL 16: first run expires a genuine legacy row and leaves both an in-flight row and a paid reservation untouched, a rerun is a no-op, the partial index rejects a case-insensitive collision on a live name while allowing reuse of an EXPIRED one, and a later db push preserves the index. Co-authored-by: Stephen DeLorme <stephen@d.elor.me> Signed-off-by: Stephen DeLorme <stephen@d.elor.me>
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.
Security fix: C1 — Paid usernames redeemable without payment (CRITICAL)
redeemPayCodewas apublicProcedurethat flipped aPENDINGpay code toACTIVEand published its Cloudflare DNS record with no server-side payment verification. The only "control" was a comment claiming the client-side MDK hookuseCheckoutSuccess()handled it — but that runs in the attacker's browser.Exploit: call
createPayCode(public, returnspayCodeId), then callredeemPayCode({ payCodeId })directly. Result: a paid-tier username registered for free. Root cause: the MDK checkout was created client-side with a client-supplied amount, so the server never had a checkout id to verify.Approach
Move checkout creation to the server and verify payment before activating (server-created checkout).
createPayCodenow creates the MDK checkout server-side with a server-owned price (PAY_CODE_PRICE_SATS = 5000), stores the checkout id on the pay code row, and returns thecheckoutUrl. If the MDK call fails it cleans up the just-createdPENDINGrow; if the id can't be persisted it fails rather than handing back an unverifiable URL.redeemPayCodekeeps its{ payCodeId }signature but now fetches the stored checkout from MDK and requires it to be paid (PAYMENT_RECEIVEDandamountSatsReceived >= 5000) before the DNS transaction. Unpaid →PRECONDITION_FAILED, no DNS published. Verification runs before the transaction so the MDK network call can't blow the transaction timeout.PENDING → ACTIVEflip is now an atomic conditionalupdateMany— a concurrent redeem loses the row-lock race and getsCONFLICT, preventing a double DNS publish (also fixes H2)..catchnow preserves typedTRPCErrors instead of masking them as 500s.NewPayCodeForm.tsx) drops its ownuseCheckout()call and the hardcoded price, and just redirects to the server-returnedcheckoutUrl.checkoutId String? @uniquetoPayCode; newsrc/server/mdk.tsserver helper (createMdkCheckout/getMdkCheckout/ a pure, unit-testedisCheckoutPaidpredicate) mirroring the existingws-bufferutil bundling guard; deleted the deadcheckPaymentprocedure; updated README/AGENTS.Files
src/server/mdk.ts— server-side MDK create/verify + pure paid predicatesrc/server/api/routers/payCode.ts—createPayCode,redeemPayCode, deletecheckPaymentprisma/schema.prisma—checkoutIdcolumnsrc/lib/util/constant.ts—PAY_CODE_PRICE_SATSsrc/app/features/NewPayCodeForm.tsx— redirect to server checkout URLsrc/server/mdk.test.ts— predicate unit testspackage.json/pnpm-lock.yaml—@moneydevkit/corepromoted to a direct dependency (was transitive-only;getCheckout/createCheckoutare only on its root export)README.md,AGENTS.md— docsVerification
tsc --noEmit— clean (validates new code against the regenerated Prisma client + MDK types)next build— compiles, types valid, 14/14 pages generated, and/api/trpcbundles@moneydevkit/corewith no module-not-found or native-binary errors (confirms the bundling hazard is handled)jest— 32/32 pass (25 existing + 7 new predicate tests, incl. underpayment and fail-closed cases)@moneydevkit/core@0.18.0(getCheckout/createCheckoutsignatures,Resultshape,invoice.amountSatsReceived; predicate matches MDK's ownisCheckoutPaidStatusplus an amount floor)Not verifiable in CI without secrets
pnpm db:pushto add thecheckoutIdcolumn (Prisma Client already regenerated with the field).MDK_ACCESS_TOKEN/MDK_MNEMONIC+CF_TOKEN. After the fix, the directredeemPayCodeexploit returnsPRECONDITION_FAILEDand publishes no DNS record.Note for reviewers
Legacy
PENDINGrows with a nullcheckoutIdnow fail redemption with a "create it again" message (fail-closed — payment can't be proven). Re-creating is free and the name stays unclaimed, since onlyACTIVErows reserve a name.🤖 Generated with Claude Code
Review round 2 — changes after adversarial review
Three follow-up commits address the review. This now contains a breaking change.
1. Retire
POST /record(breaking)The review's main blocker: this PR locked the front door while
/recordleft an equivalent side door open. That route was public, unauthenticated, took a caller-suppliedlocalPart+bolt12, and wrote the TXT record straight to Cloudflare — no payment check, and no database row at all, so the name did not even register as taken.Per the product decision, it is removed rather than carved out for one wallet. A shared secret shipped inside a public wallet binary is not a control.
src/app/record/route.ts. Nothing in this app called it.DOMAINandCF_DOMAIN_IDfrom.env.sample— that route was their only consumer.AGENTS.mdAPI reference documented aPOST /v2/recordpath that never existed; it now describes the actual tRPC procedures.Follow-up needed outside this repo: Zeus still POSTs to
https://twelve.cash/recordon master and will break when this deploys. Zeus can move topayCode.createRandomPayCodefor a free name, or the paid create/redeem flow for a chosen one.2. Make custom name reservations atomic
Only
ACTIVErows counted as taking a name, so two callers could each create aPENDINGrow for the same username, each pay their own 5,000-sat checkout, and only the first to redeem would get it. The second buyer paid and got nothing.(lower(userName), lower(domain)) WHERE status IN ('PENDING','ACTIVE')serializes racing inserts. The preflightfindFirstis kept only for a fast, friendlyCONFLICTand is explicitly not treated as a lock; aP2002from the index maps to the sameCONFLICT.reservationExpiresAtstarts as a short window covering the MDK call, then aligns to the checkout's ownexpiresAtplus a grace period for users who paid but did not immediately return to the success page. A stale lease is expired in the same transaction that claims the name, so an abandoned checkout cannot hold a name hostage.createRandomPayCodehonors live reservations and retries aP2002with a fresh name instead of surfacing a 500.redeemPayCodealso checks that the checkout MDK returned is the one bound to this row before trusting its paid status.3. Explicit migration instead of "run
db:pushmanually"Prisma 5 cannot express a partial expression index, and this database predates a checked-in migration history, so the index ships as an idempotent SQL expand migration rather than an invented
prisma/migrationsbaseline.pnpm db:manual-migrations # run BEFORE deploying this codeIt refuses to guess: it aborts if live rows already collide case-insensitively, or if a partially deployed row has a checkout id but no lease. See
prisma/manual-migrations/README.md. Local dev needs it too —db:pushalone leaves the index missing and the reservation non-atomic.4. Fix the failing Vercel check
The red check was a real bug, not flaky infra:
The lazy
require()carried aneslint-disable-next-linefor a rule this repo never loads —.eslintrc.jsonextends onlynext/core-web-vitals. ESLint errors on the disable comment itself andnext buildexits 1. Removed the comment.Note for anyone verifying locally:
next.config.mjssetseslint: { ignoreDuringBuilds: !process.env.CI }, so a plainpnpm buildskips linting entirely and cannot reproduce this. UseCI=1 pnpm build.Verification (at
0d7c6c1, clean worktree)jest— 39/39 passtsc --noEmit— cleanprisma validate— schema validCI=1 pnpm build— lint ran (warnings only), 13/13 pages. 13 not 14 because/recordis gone.git diff --check— cleanNew tests sit at the router boundary — reservation races, checkout binding, unpaid redemption — not just the pure predicate. Each was checked by mutation: disabling the payment check, the checkout-id check, or the
P2002handling each fails exactly one test.Still not verifiable without secrets
Live exploit reproduction and the happy path need real
MDK_ACCESS_TOKEN/MDK_MNEMONIC+CF_TOKEN.Known, deliberately out of scope
Cloudflare writes still happen inside the database transaction, so Cloudflare can succeed while the commit fails. That is pre-existing and unchanged here, but paid fulfillment makes recovery matter more — worth its own issue.
Review round 3 — never resell a name whose checkout may still be paid
Round 2's reservation lease had two ways for a buyer to pay and get nothing.
An expired lease did not mean the name was free. The release path expired any
PENDINGrow past its lease without asking whether its bound checkout had been paid. A buyer who paid and came back after the grace window lost the name and the 5,000 sats. The 24-hour grace was also wrong in the other direction — a free hold anyone could renew by calling the publiccreatePayCodeagain.The grace period is gone. A lease now ends exactly at the checkout's own expiry, and releasing an expired reservation requires MDK to confirm the checkout is dead:
EXPIREDstatus with the full-payment predicate false. "Not paid yet" is not "cannot be paid" —UNCONFIRMED,CONFIRMEDandPENDING_PAYMENTare all live states whose invoice may still settle, and our clock running ahead of MDK's is not authority to kill a checkout. Paid, live, or unverifiable all keep the name reserved.The release write re-asserts everything it judged. The sweep runs outside the transaction (network calls must not hold one open), so each candidate carries its observed state and the release requires all of it — status, lease, and the observed
checkoutIdincluding null. Expiring on row id alone let a slowcreatePayCodefinalize the row with a real checkout in the gap and then receive a payable URL for a reservation someone else had just expired. A zero-count release is not treated as success; the partial unique index arbitrates.Checkout finalization is likewise conditional on the row still being
PENDINGwith its original lease, so a caller never gets a payable URL for a reservation that no longer exists.createRandomPayCodeno longer releases anything. It cannot distinguish a paid reservation from an abandoned one without an MDK round trip and does not need to — it just generates another name.The migration's legacy sweep now requires both
checkoutIdandreservationExpiresAtto be NULL. MatchingcheckoutIdalone also matched the live interim state of acreatePayCodewaiting on MDK, so a rerun against a live database expired an in-flight reservation.Verified against a disposable PostgreSQL 16
checkoutIdNULL)PayCode_live_name_key✅EXPIREDprisma db pushafterwardsThe old predicate was confirmed broken on the same database before the fix — it selected both in-flight rows.
Verification at
afd639d(clean worktree)jest— 55/55tsc --noEmit,git diff --checkCI=1 pnpm build— lint ran, 13/13 pages, only the four pre-existing React Hook warningsMutation-checked: a status-only paid predicate, releasing on merely-not-paid, releasing without re-asserting observed state, and unconditional finalization each fail tests. Note the paid-reservation path fails closed through more than one branch, so removing any single one still refuses — the predicate's own guard is pinned by a unit test rather than the router test.
Known and deliberately out of scope
A caller can still hold a name for free for the checkout's own lifetime. That is inherent to any reserve-then-pay flow; the real fix is rate-limiting the public unauthenticated
createPayCode, which is separate work. Likewise, a checkout that never reaches a terminal state holds its name until it does — the safe direction, but it argues for a reconciliation job.