diff --git a/README.md b/README.md index a4f0b85..5b0b4f3 100644 --- a/README.md +++ b/README.md @@ -54,18 +54,25 @@ Running `npm run setup` provisions the operator and merchant wallets, deploys pi ``` Open `.env.local` and fill in your Supabase keys, your Circle API key and entity secret, and (optionally) your WalletConnect project ID. The Arc Testnet RPC URL is pre-filled. See [Environment Variables](#environment-variables) for the full list. The wallet IDs and the deployed contract addresses are written automatically by the setup script, so leave those blank. > **Register your entity secret first.** Circle's Developer-Controlled Wallets require a one-time entity secret registration before the SDK can sign anything. Follow [the registration guide](https://developers.circle.com/wallets/dev-controlled/register-entity-secret) and store the recovery file **outside** the repository. Set `CIRCLE_API_KEY` and `CIRCLE_ENTITY_SECRET` in `.env.local` afterward. -3. Set up the onchain side in one command: +3. Apply the Supabase schema before taking orders. For a hosted Supabase project, install the [Supabase CLI](https://supabase.com/docs/guides/cli), authenticate, and link this checkout to the project that is configured in `.env.local`: + ```bash + npx supabase login + npx supabase link --project-ref YOUR_PROJECT_REF + npm run db:remote-status # review local vs. linked migrations + npm run db:push # applies pending migrations to the linked project + ``` + `YOUR_PROJECT_REF` is the subdomain in `https://YOUR_PROJECT_REF.supabase.co`. The push is intentionally explicit because it changes the hosted database; `npm run setup` only provisions Circle wallets and contracts and never changes Supabase. The CLI may ask for the database password or access token. Review the migrations in `supabase/migrations/` before applying them, and do not commit credentials. +4. Set up the onchain side in one command: ```bash npm run setup ``` The setup script (`scripts/setup.ts`) is idempotent end to end: 1. Ensures the three Developer-Controlled wallets the store needs - **deployer**, **operator** (submits + sponsors gas), and **merchant** (funds receiver) - creating any that are missing and writing their ids/addresses to `.env.local` so reruns reuse them. 2. Deploys `AuthCaptureEscrow` (no constructor args, deploys its own `TokenStore`) and `ERC3009PaymentCollector` (escrow + Multicall3 addresses) via the Smart Contract Platform, then writes `NEXT_PUBLIC_ESCROW_ADDRESS` and `NEXT_PUBLIC_TOKEN_COLLECTOR_ADDRESS` back. Skipped if both are already set. - 3. Seeds the operator with a little Arc gas (USDC) from the deployer, since the operator pays for every checkout transaction. - cause Arc uses USDC as native gas, the deployer must hold some before contracts can deploy. If it is empty the script prints its address and the [faucet](https://faucet.circle.com) link, then exits - fund it and re-run. To deploy a fresh pair of contracts, force it: - `bash m run setup -- --force` - The protocol source is pinned to a specific commit and compiled with the Cancun EVM target. Arc Testnet runs a newer hardfork, so no source changes are needed. Provenance and the compatibility notes live in `[contracts/README.md](contracts/README.md)`. -4. Start the development server: + 3. Seeds the operator with a little Arc gas (USDC) from the deployer, since the operator pays for every checkout transaction. Because Arc uses USDC as native gas, the deployer must hold some before contracts can deploy. If it is empty the script prints its address and the [faucet](https://faucet.circle.com) link, then exits - fund it and re-run. To deploy a fresh pair of contracts, force it: + `npm run setup -- --force` + The protocol source is pinned to a specific commit and compiled with the Cancun EVM target. Arc Testnet runs a newer hardfork, so no source changes are needed. Provenance and the compatibility notes live in `[contracts/README.md](contracts/README.md)`. +5. Start the development server: ```bash npm run dev ``` @@ -217,8 +224,15 @@ npm run db:start # start the local stack npm run db:status # print local URLs and keys (use these in .env.local) npm run db:stop # stop the stack npm run db:reset # reset the local database +npm run db:migrate # apply local migrations to the local database ``` +`npm run db:migrate` is local-only (`supabase migration up`). It does not apply +anything to a hosted project. Use `npm run db:push` after `supabase link` for a +hosted project, and `npm run db:remote-status` to inspect the linked project's +migration history. Keep `.env.local` pointed at the same Supabase project whose +migrations you pushed before starting the app. + ## Project Structure diff --git a/app/account/page.tsx b/app/account/page.tsx index e4d8b2b..2b3f76f 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -52,13 +52,18 @@ export default async function AccountPage() { const { data: { user } } = await supabase.auth.getUser(); // RLS scopes this select to the signed-in shopper's own orders. - const { data: rows } = await supabase + const { data: rows, error } = await supabase .from("orders") .select( "id, created_at, status, currency, total, items, payment_info, lifecycle_events(operation, tx_hash, amount, note, created_at)", ) .order("created_at", { ascending: false }); + if (error) { + console.error("[account] order history query failed:", error.message); + throw new Error("Unable to load order history. Please try again later."); + } + // eslint-disable-next-line react-hooks/purity -- async server component, runs once on the server const nowSeconds = Math.floor(Date.now() / 1000); diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 0e4b857..af877d2 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -26,13 +26,18 @@ export default async function AdminPage() { const supabase = await createClient(); // RLS is verified in the layout; the is_admin() policy grants read-all here. - const { data: rows } = await supabase + const { data: rows, error } = await supabase .from("orders") .select( "id, created_at, status, currency, total, items, payer, operator_fee, net_amount, captured_amount, refunded_amount, payment_info, lifecycle_events(operation, tx_hash, amount, note, created_at)", ) .order("created_at", { ascending: false }); + if (error) { + console.error("[admin] order queue query failed:", error.message); + throw new Error("Unable to load the order queue. Please try again later."); + } + const orders: Order[] = (rows ?? []).map((row) => { const pi = row.payment_info as { authorizationExpiry?: number } | null; const currency = row.currency as Currency; diff --git a/app/api/checkout/authorize/route.ts b/app/api/checkout/authorize/route.ts index 2fac49f..6b2e3db 100644 --- a/app/api/checkout/authorize/route.ts +++ b/app/api/checkout/authorize/route.ts @@ -17,7 +17,6 @@ */ import { NextResponse } from "next/server"; -import { randomUUID } from "node:crypto"; import { isHex, type Hex } from "viem"; import { authorize } from "@/lib/operator/operations"; import { takeIntent } from "@/lib/payments/intent-store"; @@ -62,13 +61,24 @@ export async function POST(req: Request) { return NextResponse.json({ error: message }, { status: 502 }); } - // Payment is settled on-chain past this point. Persisting the order is - // best-effort - a DB failure must not surface as a checkout error, since the - // funds have already moved. recordOrder logs and returns null on any problem. - // The persisted row's uuid is the order id. If persistence failed (funds - // already moved), fall back to a generated uuid so the receipt still has one. + // Payment is settled on-chain past this point. A database failure must be + // reported explicitly: a generated UUID would not exist in Supabase and + // would make the receipt/history misleading. Return the transaction hash so + // support can reconcile the settled payment. const status = "Reserved"; - const orderId = (await recordOrder({ intent, status, txHash })) ?? randomUUID(); + const orderId = await recordOrder({ intent, status, txHash }); + + if (!orderId) { + return NextResponse.json( + { + error: + "Payment confirmed on-chain, but the order could not be recorded. Save the transaction hash and contact support.", + txHash, + orderPersisted: false, + }, + { status: 503 }, + ); + } return NextResponse.json({ txHash, orderId, status }); } diff --git a/lib/checkout/submit.ts b/lib/checkout/submit.ts index 5e355f1..d6bc768 100644 --- a/lib/checkout/submit.ts +++ b/lib/checkout/submit.ts @@ -47,7 +47,9 @@ type SignTypedData = ( async function readError(res: Response, fallback: string): Promise { try { - return (await res.json()).error ?? fallback; + const body = await res.json(); + const message = body.error ?? fallback; + return body.txHash ? `${message} Transaction hash: ${body.txHash}` : message; } catch { return fallback; } diff --git a/lib/orders/record.ts b/lib/orders/record.ts index 87c9175..2598883 100644 --- a/lib/orders/record.ts +++ b/lib/orders/record.ts @@ -27,10 +27,10 @@ import type { Intent } from "@/lib/payments/intent-store"; /** * Persist a completed checkout to the `orders` table. * - * Called only after the Authorize transaction is confirmed on-chain, so - * the funds have already moved. Persistence is therefore best-effort: it must - * never turn a settled payment into a user-facing failure. On any problem it - * logs and returns null, and the caller falls back to a placeholder reference. + * Called only after the Authorize transaction is confirmed on-chain, so the + * funds have already moved. If persistence fails, the caller reports that + * state explicitly; it must never replace the missing row with a fabricated + * order reference. * * Returns the database-assigned order id (uuid) on success. */ diff --git a/package.json b/package.json index 012b9e8..eee7507 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "db:reset": "supabase db reset", "db:migration": "supabase migration new", "db:migrate": "supabase migration up", + "db:push": "supabase db push --linked", + "db:remote-status": "supabase migration list", "db:types": "supabase gen types typescript --local > lib/database.types.ts" }, "dependencies": {