Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
7 changes: 6 additions & 1 deletion app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 17 additions & 7 deletions app/api/checkout/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
}
4 changes: 3 additions & 1 deletion lib/checkout/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ type SignTypedData = (

async function readError(res: Response, fallback: string): Promise<string> {
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;
}
Expand Down
8 changes: 4 additions & 4 deletions lib/orders/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down