Skip to content

fix: verify payment server-side before redeeming pay codes (C1) - #92

Open
sbddesign wants to merge 5 commits into
mainfrom
worktree-fix-redeem-without-payment
Open

fix: verify payment server-side before redeeming pay codes (C1)#92
sbddesign wants to merge 5 commits into
mainfrom
worktree-fix-redeem-without-payment

Conversation

@sbddesign

@sbddesign sbddesign commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Security fix: C1 — Paid usernames redeemable without payment (CRITICAL)

redeemPayCode was a publicProcedure that flipped a PENDING pay code to ACTIVE and published its Cloudflare DNS record with no server-side payment verification. The only "control" was a comment claiming the client-side MDK hook useCheckoutSuccess() handled it — but that runs in the attacker's browser.

Exploit: call createPayCode (public, returns payCodeId), then call redeemPayCode({ 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).

  • createPayCode now 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 the checkoutUrl. If the MDK call fails it cleans up the just-created PENDING row; if the id can't be persisted it fails rather than handing back an unverifiable URL.
  • redeemPayCode keeps its { payCodeId } signature but now fetches the stored checkout from MDK and requires it to be paid (PAYMENT_RECEIVED and amountSatsReceived >= 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.
  • The PENDING → ACTIVE flip is now an atomic conditional updateMany — a concurrent redeem loses the row-lock race and gets CONFLICT, preventing a double DNS publish (also fixes H2).
  • The outer transaction .catch now preserves typed TRPCErrors instead of masking them as 500s.
  • Client (NewPayCodeForm.tsx) drops its own useCheckout() call and the hardcoded price, and just redirects to the server-returned checkoutUrl.
  • Added checkoutId String? @unique to PayCode; new src/server/mdk.ts server helper (createMdkCheckout / getMdkCheckout / a pure, unit-tested isCheckoutPaid predicate) mirroring the existing ws-bufferutil bundling guard; deleted the dead checkPayment procedure; updated README/AGENTS.

Files

  • src/server/mdk.ts — server-side MDK create/verify + pure paid predicate
  • src/server/api/routers/payCode.tscreatePayCode, redeemPayCode, delete checkPayment
  • prisma/schema.prismacheckoutId column
  • src/lib/util/constant.tsPAY_CODE_PRICE_SATS
  • src/app/features/NewPayCodeForm.tsx — redirect to server checkout URL
  • src/server/mdk.test.ts — predicate unit tests
  • package.json / pnpm-lock.yaml@moneydevkit/core promoted to a direct dependency (was transitive-only; getCheckout/createCheckout are only on its root export)
  • README.md, AGENTS.md — docs

Verification

  • tsc --noEmit — clean (validates new code against the regenerated Prisma client + MDK types)
  • next build — compiles, types valid, 14/14 pages generated, and /api/trpc bundles @moneydevkit/core with 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)
  • MDK API assumptions verified against the installed @moneydevkit/core@0.18.0 (getCheckout/createCheckout signatures, Result shape, invoice.amountSatsReceived; predicate matches MDK's own isCheckoutPaidStatus plus an amount floor)

Not verifiable in CI without secrets

  • Migration: run pnpm db:push to add the checkoutId column (Prisma Client already regenerated with the field).
  • Live exploit reproduction / happy path need real MDK_ACCESS_TOKEN/MDK_MNEMONIC + CF_TOKEN. After the fix, the direct redeemPayCode exploit returns PRECONDITION_FAILED and publishes no DNS record.

Note for reviewers

Legacy PENDING rows with a null checkoutId now 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 only ACTIVE rows 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 /record left an equivalent side door open. That route was public, unauthenticated, took a caller-supplied localPart + 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.

  • Deleted src/app/record/route.ts. Nothing in this app called it.
  • Dropped DOMAIN and CF_DOMAIN_ID from .env.sample — that route was their only consumer.
  • Docs updated. The AGENTS.md API reference documented a POST /v2/record path that never existed; it now describes the actual tRPC procedures.

Follow-up needed outside this repo: Zeus still POSTs to https://twelve.cash/record on master and will break when this deploys. Zeus can move to payCode.createRandomPayCode for a free name, or the paid create/redeem flow for a chosen one.

2. Make custom name reservations atomic

Only ACTIVE rows counted as taking a name, so two callers could each create a PENDING row 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.

  • 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 and is explicitly not treated as a lock; a P2002 from the index maps to the same CONFLICT.
  • Reservations are a lease, not a permanent hold. reservationExpiresAt starts as a short window covering the MDK call, then aligns to the checkout's own expiresAt plus 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.
  • createRandomPayCode honors live reservations and retries a P2002 with a fresh name instead of surfacing a 500.
  • redeemPayCode also 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:push manually"

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/migrations baseline.

pnpm db:manual-migrations   # run BEFORE deploying this code

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. See prisma/manual-migrations/README.md. Local dev needs it too — db:push alone 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:

./src/server/mdk.ts
43:5  Error: Definition for rule '@typescript-eslint/no-var-requires' was not found.

The lazy require() carried an eslint-disable-next-line for a rule this repo never loads — .eslintrc.json extends only next/core-web-vitals. ESLint errors on the disable comment itself and next build exits 1. Removed the comment.

Note for anyone verifying locally: next.config.mjs sets eslint: { ignoreDuringBuilds: !process.env.CI }, so a plain pnpm build skips linting entirely and cannot reproduce this. Use CI=1 pnpm build.

Verification (at 0d7c6c1, clean worktree)

  • jest — 39/39 pass
  • tsc --noEmit — clean
  • prisma validate — schema valid
  • CI=1 pnpm build — lint ran (warnings only), 13/13 pages. 13 not 14 because /record is gone.
  • git diff --check — clean

New 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 P2002 handling 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 PENDING row 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 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 reserved.
  • 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 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 checkoutId including null. Expiring on row id alone let a slow createPayCode finalize 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 PENDING with its original lease, so a caller never gets 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

Scenario Result
Genuine legacy row (both fields NULL) expired ✅
In-flight row (lease set, checkoutId NULL) untouched, first run and rerun ✅
Paid reservation untouched ✅
Case-insensitive collision on a live name rejected by PayCode_live_name_key
Same name after the live row is EXPIRED insert allowed ✅
prisma db push afterwards index preserved ✅

The 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/55
  • tsc --noEmit, git diff --check
  • CI=1 pnpm build — lint ran, 13/13 pages, only the four pre-existing React Hook warnings

Mutation-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.

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.
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
twelvecash Ready Ready Preview Aug 9, 2026 6:01pm

Request Review

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>
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.

1 participant