diff --git a/.env.example b/.env.example
index bc883f8..fd3f088 100644
--- a/.env.example
+++ b/.env.example
@@ -13,3 +13,7 @@ ERC8004_IDENTITY_REGISTRY_ADDRESS=0x8004A818BFB912233c491871b3d84c89A494BD9e
ERC8004_REPUTATION_REGISTRY_ADDRESS=0x8004B663056A597Dffe9eCcC1965A193B7388713
ERC8004_VALIDATION_REGISTRY_ADDRESS=0x8004Cb1BF31DAf7788923b405b754f57acEB4272
ERC8004_AGENT_ID=
+
+# Server RPC for telemetry and independent payment-proof verification.
+# Must return Arc Testnet chain ID 5042002; other chains are rejected.
+ARC_TESTNET_RPC_URL=https://rpc.testnet.arc.network
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c2fdc34..e7e692d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -43,3 +43,9 @@ jobs:
- name: Build
run: npm run build
+
+ - name: Install browser
+ run: npx playwright install --with-deps chromium
+
+ - name: Checkout browser regression tests
+ run: npm run test:e2e
diff --git a/.gitignore b/.gitignore
index 27ac439..d777f80 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,3 +44,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+/.test-dist/
+/test-results/
+/playwright-report/
diff --git a/README.md b/README.md
index 5f99b74..05a4047 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ ArcPay is an open-source reference demo for agentic commerce on Arc. It combines
- Live Arc Testnet block, chain ID, block age, and RPC latency telemetry
- Arc-aware health endpoint for deployment and uptime monitoring
- Game-credit catalog and PUBG test checkout
-- Local order history for completed demo purchases
+- Recoverable local proof history, read-only status checks and downloadable verified receipts
- Responsive, glassmorphism-based Arc visual theme
## Architecture
@@ -98,7 +98,7 @@ The `/api/arc-network` server route reads Arc Testnet using `viem` and exposes t
### Arc health endpoint
-The `/api/health` route is designed for deployment probes and uptime checks. It verifies that Arc Testnet RPC is reachable, reads the latest block, measures RPC latency, and marks the service as degraded when the latest block is more than 120 seconds old. Healthy checks return HTTP 200; degraded or unavailable checks return HTTP 503.
+The `/api/health` route is designed for deployment probes and uptime checks. It verifies that Arc Testnet RPC is reachable, reads the latest block, measures RPC latency, checks the returned chain ID, and marks the service as degraded when the latest block is more than 120 seconds old. Healthy checks return HTTP 200; degraded or unavailable checks return HTTP 503.
### ERC-8004 agent identity
@@ -143,6 +143,17 @@ These values are server-only. Never add a `NEXT_PUBLIC_` prefix, paste them into
The current integration creates an EOA on `ARC-TESTNET`, reads its token balance, and links to the official Circle Faucet for manual test USDC funding. It does not transfer real USDC or deliver a product.
+## Verifiable payment proofs
+
+Checkout now waits for a successful Arc Testnet receipt and validates the sender,
+recipient and exact self-transfer amount through the server RPC. A timeout stays
+pending; checking again never creates another payment. Downloadable receipts show
+actual transferred USDC, the network fee and the block. Catalog prices are not charged.
+
+See [API, recovery and testing guide](docs/payment-proofs.md), the
+[read-only integration example](examples/verify-proof.mjs), and
+[mainnet readiness plan](docs/mainnet-readiness.md).
+
## Testnet disclaimer
ArcPay is demonstration software. Live Arc telemetry is read from Arc Testnet, while AI-agent analytics and other showcase metrics may still be illustrative unless explicitly connected to a live provider. Contract addresses, token details, and wallet prompts must be independently verified before signing. Never send production assets or real USDC to testnet contracts or addresses.
@@ -161,7 +172,8 @@ ArcPay is demonstration software. Live Arc telemetry is read from Arc Testnet, w
- [ ] Add onchain reputation and validation registry reads
- [ ] Implement Vyper payment-policy contracts
- [ ] Ship a configurable workflow builder and merchant SDK
-- [ ] Expand automated application tests and audited production safeguards
+- [x] Add verified self-transfer receipts, recovery and checkout regression tests
+- [ ] Add production merchant settlement and audited production safeguards
## License
diff --git a/docs/mainnet-readiness.md b/docs/mainnet-readiness.md
new file mode 100644
index 0000000..4392992
--- /dev/null
+++ b/docs/mainnet-readiness.md
@@ -0,0 +1,14 @@
+# Mainnet readiness
+
+This release keeps checkout, Circle wallet creation, bridge configuration and identity integration on **Arc Testnet**. It does not move funds or provision a production wallet. Changing a chain ID environment variable is not a mainnet migration: proof verification explicitly rejects other chains.
+
+Before enabling a production checkout:
+
+1. Verify current chain parameters and supported Circle Wallets / Bridge Kit network identifiers against [Arc documentation](https://docs.arc.io) and [Circle documentation](https://developers.circle.com). Validate each endpoint's returned chain ID. Keep testnet and mainnet profiles complete and separate.
+2. Define the merchant recipient, product delivery rules and refund process. The current demo self-transfers cannot settle a merchant purchase.
+3. Replace browser-only history with authenticated, durable server-side orders, unique transaction-to-order binding, replay protection and idempotent settlement. Add rate limits and authentication to public wallet-mutating endpoints; the current Circle proof route is an intentionally public, daily-capped testnet demo, not a production authorization model.
+4. Establish the production credential owner and wallet authorization policy. Independently review spending limits, keys, webhook authentication, retries and reconciliation. Do not reuse testnet credentials or enable an arbitrary public transfer endpoint.
+5. Complete security review and dependency remediation, then test the full flow in staging. Test amount precision, gas affordability, wallet/network changes, unavailable RPC, dropped or replaced transactions, restart recovery and reconciliation.
+6. Obtain explicit approval for the real recipient, amount limits and production release before any funded smoke test.
+
+Already delivered in this iteration: server-side proof verification, downloadable receipts, recovery without resending, error tests and chain/staleness-aware telemetry. Remaining items above require production architecture and operator configuration; no claim of production readiness is made.
diff --git a/docs/payment-proofs.md b/docs/payment-proofs.md
new file mode 100644
index 0000000..4eb10e2
--- /dev/null
+++ b/docs/payment-proofs.md
@@ -0,0 +1,71 @@
+# Verifiable ArcPay transaction proofs
+
+ArcPlay is the repository; ArcPay is the demo application name. This feature extends the existing application. It is not a merchant SDK or a production checkout.
+
+## What is verified
+
+Browser Wallet submits a **zero-value self-transfer** on Arc Testnet. Circle Wallet submits a daily-idempotent **0.01 test USDC self-transfer**. Neither pays the catalog price or delivers game credits.
+
+`POST /api/payment-proof` independently reads the server-configured RPC. It checks:
+
+- Arc Testnet chain ID `5042002`;
+- successful execution (`0x1`), matching transaction hash, and a consistent canonical block;
+- the expected sender, same-wallet recipient and exact proof amount;
+- an empty-input native transfer, or (Circle only) a matching USDC Transfer event from the canonical ERC-20 interface;
+- actual gas usage and gas price to calculate the network fee.
+
+Native USDC uses **18 decimals**; the ERC-20 interface uses **6 decimals**. The token interface address is `0x3600000000000000000000000000000000000000`. See [Circle's Arc USDC explanation](https://www.arc.io/blog/building-with-usdc-on-arc-one-token-two-interfaces).
+
+A submitted hash is not a successful payment. Missing receipts stay pending. RPC timeouts stay unavailable. Reverted and mismatched transactions are never shown as confirmed.
+
+## Read-only API
+
+```http
+POST /api/payment-proof
+Content-Type: application/json
+
+{"hash":"0x…64 hex characters…","sender":"0x…40 hex characters…","kind":"wallet"}
+```
+
+Kinds: `wallet` (0 USDC) and `circle` (0.01 USDC).
+
+Responses:
+
+| Status | Meaning |
+| --- | --- |
+| `confirmed` | Includes a receipt with parties, actual amount, block, hash, fee and verification time. |
+| `pending` | Receipt is not available or block reads are not yet consistent. |
+| `reverted` | Execution failed. |
+| `mismatch` | Wrong chain, sender, recipient, amount or transaction. |
+| `unavailable` | RPC failed or returned unusable data. HTTP 503; safe to retry the read. |
+
+Malformed requests return HTTP 400. The client supplies expectations, not trusted order authorization. Anyone can inspect public transaction data. This endpoint never signs or sends a transaction.
+
+Run the reusable example against a running local app:
+
+```bash
+node examples/verify-proof.mjs http://localhost:3000 "$TX_HASH" "$SENDER_ADDRESS" wallet
+```
+
+The example only checks an existing transaction; it needs no private key. It exits with 0 for confirmed, 2 for pending, and 1 otherwise.
+
+## Recovery and local history
+
+The UI saves the transaction hash immediately after wallet submission, or the Circle transaction ID immediately after Circle accepts creation. On timeout it displays **Check status · no new transaction**. History provides the same read-only action after a reload. Circle status lookups are restricted to the configured wallet's ArcPay test proofs.
+
+Repeated checks update the existing record instead of creating duplicate orders. Old records that only contain a hash are marked **Legacy · unverified**. Malformed browser storage is ignored; storage failures must not turn a submitted transaction into a failed payment. Download the proof while the page is open if storage is unavailable.
+
+Receipts are local, editable JSON records. They are not signed attestations, ownership proofs, unique order bindings, or authorizations to deliver goods. Recheck the chain when current evidence is needed. A production merchant must persist server-owned orders, bind each transaction to one order and enforce unique settlement in durable storage.
+
+## Tests
+
+```bash
+npm test
+npm run lint
+npm run typecheck
+npm run build
+npx playwright install chromium
+npm run test:e2e
+```
+
+Unit tests cover successful proofs, pending/reverted/malformed receipts, wrong networks, sender/recipient/value mismatches, both decimal representations, RPC failures, wallet cancellation, duplicate history and read-only retries. Browser tests use a simulated wallet and deterministic RPC fixtures: no funds move and no live Circle credentials are required.
diff --git a/examples/verify-proof.mjs b/examples/verify-proof.mjs
new file mode 100644
index 0000000..c3fcd63
--- /dev/null
+++ b/examples/verify-proof.mjs
@@ -0,0 +1,19 @@
+/** Read-only example: never requests a private key or sends a transaction. */
+const [baseUrl = "http://localhost:3000", hash, sender, kind = "wallet"] = process.argv.slice(2);
+if (!/^0x[\da-f]{64}$/i.test(hash ?? "") || !/^0x[\da-f]{40}$/i.test(sender ?? "") || !["wallet", "circle"].includes(kind)) {
+ console.error("Usage: node examples/verify-proof.mjs [wallet|circle]");
+ process.exitCode = 1;
+} else {
+ try {
+ const response = await fetch(new URL("/api/payment-proof", baseUrl), {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ hash, sender, kind }), signal: AbortSignal.timeout(30_000),
+ });
+ const result = await response.json();
+ console.log(JSON.stringify(result, null, 2));
+ process.exitCode = response.ok && result.status === "confirmed" ? 0 : result.status === "pending" ? 2 : 1;
+ } catch (error) {
+ console.error(`Verification unavailable: ${error.message}. Do not resend the transaction.`);
+ process.exitCode = 1;
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index b9aab5f..c68fff7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"viem": "^2.55.2"
},
"devDependencies": {
+ "@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
@@ -1986,6 +1987,22 @@
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -7092,6 +7109,35 @@
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
diff --git a/package.json b/package.json
index 2e11514..5c216c9 100644
--- a/package.json
+++ b/package.json
@@ -8,10 +8,11 @@
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
- "test": "rm -rf .test-dist && tsc src/lib/arc-health.ts src/lib/erc8004.ts --target ES2022 --module NodeNext --moduleResolution NodeNext --outDir .test-dist --skipLibCheck && node --test tests/arc-health.test.mjs tests/erc8004.test.mjs && rm -rf .test-dist",
+ "test": "rm -rf .test-dist && tsc src/lib/arc-health.ts src/lib/erc8004.ts src/lib/payment-proof.ts src/lib/demo-orders.ts src/lib/check-payment.ts --target ES2022 --module NodeNext --moduleResolution NodeNext --lib ES2022,DOM --outDir .test-dist --skipLibCheck && node --test tests/*.test.mjs && rm -rf .test-dist",
"circle:register-secret": "node --env-file=.env.local scripts/register-circle-entity-secret.mjs",
"circle:provision-wallet": "node --env-file=.env.local scripts/provision-circle-wallet.mjs",
- "circle:sync-wallet": "node scripts/sync-circle-wallet-env.mjs"
+ "circle:sync-wallet": "node scripts/sync-circle-wallet-env.mjs",
+ "test:e2e": "playwright test"
},
"dependencies": {
"@circle-fin/adapter-viem-v2": "^1.14.0",
@@ -23,6 +24,7 @@
"viem": "^2.55.2"
},
"devDependencies": {
+ "@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..3f8e90e
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig, devices } from "@playwright/test";
+export default defineConfig({
+ testDir: "./tests/e2e", testMatch: "**/*.spec.ts", fullyParallel: false, workers: 1,
+ timeout: 45_000, use: { baseURL: "http://127.0.0.1:4318", trace: "retain-on-failure" },
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
+ webServer: [
+ { command: "node tests/e2e/rpc-fixture.mjs", url: "http://127.0.0.1:4319", reuseExistingServer: false },
+ { command: "npm run start -- --port 4318 --hostname 127.0.0.1", url: "http://127.0.0.1:4318", reuseExistingServer: false, timeout: 120_000,
+ env: { ARC_TESTNET_RPC_URL: "http://127.0.0.1:4319", CIRCLE_API_KEY: "", CIRCLE_ENTITY_SECRET: "" } },
+ ],
+});
diff --git a/src/app/api/arc-network/route.ts b/src/app/api/arc-network/route.ts
index 4ded7cb..12c9236 100644
--- a/src/app/api/arc-network/route.ts
+++ b/src/app/api/arc-network/route.ts
@@ -1,3 +1,4 @@
+import { getArcHealthStatus } from "@/lib/arc-health";
import { NextResponse } from "next/server";
import { createPublicClient, http } from "viem";
import { arcTestnet } from "viem/chains";
@@ -17,6 +18,7 @@ export async function GET() {
client.getBlockNumber(),
client.getChainId(),
]);
+ if (chainId !== arcTestnet.id) throw new Error("RPC chain mismatch");
const block = await client.getBlock({ blockNumber });
const latencyMs = Date.now() - startedAt;
const blockTimestampMs = Number(block.timestamp) * 1000;
@@ -25,7 +27,7 @@ export async function GET() {
return NextResponse.json(
{
network: "Arc Testnet",
- status: "online",
+ status: getArcHealthStatus(blockAgeSeconds) === "ok" ? "online" : "degraded",
chainId,
latestBlock: blockNumber.toString(),
blockTimestamp: new Date(blockTimestampMs).toISOString(),
diff --git a/src/app/api/circle/payments/route.ts b/src/app/api/circle/payments/route.ts
index 9f8b50d..1b7302d 100644
--- a/src/app/api/circle/payments/route.ts
+++ b/src/app/api/circle/payments/route.ts
@@ -1,3 +1,4 @@
+import { USDC_INTERFACE } from "@/lib/payment-proof";
import { createHash } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import {
@@ -45,10 +46,15 @@ export async function GET(request: NextRequest) {
}
try {
+ const { walletId, walletAddress } = getCircleConfiguration();
+ if (!walletId || !walletAddress) return NextResponse.json({ error: "Circle wallet is not configured." }, { status: 503 });
const response = await getCircleClient().getTransaction({ id });
const transaction = response.data?.transaction;
if (!transaction) throw new Error("Circle transaction was not found.");
- return NextResponse.json({ transaction: publicTransaction(transaction) });
+ if (transaction.walletId !== walletId || transaction.blockchain !== CIRCLE_BLOCKCHAIN || transaction.destinationAddress?.toLowerCase() !== walletAddress.toLowerCase() || !transaction.refId?.startsWith("arcpay-daily-proof-")) {
+ return NextResponse.json({ error: "This transaction is not an ArcPay test proof." }, { status: 404 });
+ }
+ return NextResponse.json({ transaction: { ...publicTransaction(transaction), sender: walletAddress } }, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
console.error("Circle payment status lookup failed", error);
return NextResponse.json({ error: "Circle transaction status could not be loaded." }, { status: 502 });
@@ -61,6 +67,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Circle payment wallet is not configured." }, { status: 503 });
}
+ // Browser requests must originate from this application.
+ const origin = request.headers.get("origin");
+ if (origin && origin !== request.nextUrl.origin) return NextResponse.json({ error: "Cross-origin payment requests are not allowed." }, { status: 403 });
const body = await request.json().catch(() => null) as { confirmed?: boolean } | null;
if (body?.confirmed !== true) {
return NextResponse.json({ error: "Test payment confirmation is required." }, { status: 400 });
@@ -68,9 +77,14 @@ export async function POST(request: NextRequest) {
try {
const client = getCircleClient();
+ const walletResponse = await client.getWallet({ id: walletId });
+ const wallet = walletResponse.data?.wallet;
+ if (wallet?.blockchain !== CIRCLE_BLOCKCHAIN || wallet.address.toLowerCase() !== walletAddress.toLowerCase()) {
+ return NextResponse.json({ error: "Circle wallet must match the configured Arc Testnet address." }, { status: 409 });
+ }
const balanceResponse = await client.getWalletTokenBalance({ id: walletId, includeAll: true });
const usdc = (balanceResponse.data?.tokenBalances ?? [])
- .filter((balance) => balance.token?.symbol === "USDC" && balance.token?.id)
+ .filter((balance) => balance.token?.symbol === "USDC" && balance.token?.id && balance.token.blockchain === CIRCLE_BLOCKCHAIN && (balance.token.isNative || balance.token.tokenAddress?.toLowerCase() === USDC_INTERFACE))
.sort((left, right) => Number(right.amount) - Number(left.amount))[0];
if (!usdc?.token?.id || Number(usdc.amount) < Number(PROOF_AMOUNT)) {
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
index cae5bff..57a4dc7 100644
--- a/src/app/api/health/route.ts
+++ b/src/app/api/health/route.ts
@@ -14,7 +14,8 @@ export async function GET() {
const startedAt = Date.now();
try {
- const blockNumber = await client.getBlockNumber();
+ const [blockNumber, chainId] = await Promise.all([client.getBlockNumber(), client.getChainId()]);
+ if (chainId !== arcTestnet.id) throw new Error("RPC chain mismatch");
const block = await client.getBlock({ blockNumber });
const latencyMs = Date.now() - startedAt;
const blockTimestampMs = Number(block.timestamp) * 1000;
diff --git a/src/app/api/payment-proof/route.ts b/src/app/api/payment-proof/route.ts
new file mode 100644
index 0000000..d1e58f8
--- /dev/null
+++ b/src/app/api/payment-proof/route.ts
@@ -0,0 +1,19 @@
+import { NextResponse } from "next/server";
+import { isProofRequest, verifyPaymentProof, type RpcRequest } from "@/lib/payment-proof";
+
+const headers = { "Cache-Control": "no-store" };
+export async function POST(request: Request) {
+ const input: unknown = await request.json().catch(() => null);
+ if (!isProofRequest(input)) return NextResponse.json({ error: "Provide a transaction hash, sender address and wallet/circle kind." }, { status: 400, headers });
+ const rpc: RpcRequest = async (method: string, params: unknown[] = []) => {
+ const response = await fetch(process.env.ARC_TESTNET_RPC_URL || "https://rpc.testnet.arc.network", {
+ method: "POST", headers: { "Content-Type": "application/json" }, cache: "no-store",
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), signal: AbortSignal.timeout(8_000),
+ });
+ const data = await response.json();
+ if (!response.ok || data.error || !("result" in data)) throw new Error("RPC unavailable");
+ return data.result as T;
+ };
+ const result = await verifyPaymentProof(input, rpc);
+ return NextResponse.json(result, { status: result.status === "unavailable" ? 503 : 200, headers });
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 0d49261..bbb425b 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -19,6 +19,12 @@
}
}
+html,
+body {
+ max-width: 100%;
+ overflow-x: hidden;
+}
+
body {
background: var(--background);
color: var(--foreground);
diff --git a/src/components/ai-agent-dashboard.tsx b/src/components/ai-agent-dashboard.tsx
index bd41e67..d5a24d9 100644
--- a/src/components/ai-agent-dashboard.tsx
+++ b/src/components/ai-agent-dashboard.tsx
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react";
type ArcNetworkData = {
network: string;
- status: "online" | "unavailable";
+ status: "online" | "degraded" | "unavailable";
chainId: number;
latestBlock?: string;
blockAgeSeconds?: number;
@@ -59,7 +59,7 @@ export default function AIAgentDashboard() {
{
title: "Arc Network",
lines: [
- loading ? "Checking RPC…" : network?.status === "online" ? "Online" : "Unavailable",
+ loading ? "Checking RPC…" : network?.status === "online" ? "Online" : network?.status === "degraded" ? "Stale blocks" : "Unavailable",
network?.network || "Arc Testnet",
network?.latestBlock ? `Latest block: #${network.latestBlock}` : "Live block unavailable",
],
@@ -117,7 +117,7 @@ export default function AIAgentDashboard() {
Network values come from Arc Testnet RPC. Agent actions remain approval-first and testnet-only.
- {loading ? "Checking" : network?.status === "online" ? "Arc Online" : "RPC Unavailable"}
+ {loading ? "Checking" : network?.status === "online" ? "Arc Online" : network?.status === "degraded" ? "Stale blocks" : "RPC Unavailable"}
diff --git a/src/components/live-arc-network.tsx b/src/components/live-arc-network.tsx
index 840dbde..0d10948 100644
--- a/src/components/live-arc-network.tsx
+++ b/src/components/live-arc-network.tsx
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react";
type ArcTelemetry = {
network: string;
- status: "online" | "unavailable";
+ status: "online" | "degraded" | "unavailable";
chainId: number;
latestBlock?: string;
blockTimestamp?: string;
@@ -41,7 +41,7 @@ export default function LiveArcNetwork() {
setError(false);
}
} catch {
- if (active) setError(true);
+ if (active) { setError(true); setTelemetry(null); }
}
}
@@ -54,11 +54,11 @@ export default function LiveArcNetwork() {
}, []);
const cards = useMemo(() => {
- if (!telemetry) return fallbackCards;
+ if (!telemetry) return error ? fallbackCards.map((card) => ({ ...card, value: card.title === "Arc Testnet" ? "Unavailable" : "—", detail: "Live data unavailable" })) : fallbackCards;
return [
{
title: "Arc Testnet",
- value: telemetry.status === "online" ? "Online" : "Unavailable",
+ value: telemetry.status === "online" ? "Online" : telemetry.status === "degraded" ? "Stale blocks" : "Unavailable",
detail: telemetry.rpcHost || "Official Arc RPC",
accent: "from-emerald-500/20 to-cyan-500/10",
},
@@ -93,13 +93,13 @@ export default function LiveArcNetwork() {
accent: "from-cyan-500/20 to-fuchsia-500/10",
},
];
- }, [telemetry]);
+ }, [telemetry, error]);
const readiness = [
{ label: "Wallet connectivity", state: "Implemented" },
{ label: "Circle wallet backend", state: "Implemented" },
{ label: "USDC bridge flow", state: "Implemented" },
- { label: "Live Arc telemetry", state: telemetry?.status === "online" ? "Live" : "Connecting" },
+ { label: "Live Arc telemetry", state: error ? "Unavailable" : telemetry?.status === "degraded" ? "Stale" : telemetry?.status === "online" ? "Live" : "Connecting" },
];
return (
@@ -114,7 +114,7 @@ export default function LiveArcNetwork() {
- {error ? "RPC unavailable" : telemetry ? "Live • Testnet" : "Connecting…"}
+ {error ? "RPC unavailable" : telemetry?.status === "degraded" ? "Stale blocks • Testnet" : telemetry ? "Live • Testnet" : "Connecting…"}
diff --git a/src/components/order-history.tsx b/src/components/order-history.tsx
index a70574e..bf87e19 100644
--- a/src/components/order-history.tsx
+++ b/src/components/order-history.tsx
@@ -1,88 +1,49 @@
"use client";
-
-import { useEffect, useState } from "react";
-import { ARC_NETWORK } from "@/lib/arc-network";
-import {
- DEMO_ORDER_EVENT,
- type DemoOrder,
- readDemoOrders,
-} from "@/lib/demo-orders";
-
-function shortHash(hash: string) {
- return `${hash.slice(0, 8)}…${hash.slice(-6)}`;
-}
-
-function isTransactionHash(value: string) {
- return /^0x[0-9a-fA-F]{64}$/.test(value);
-}
+import { useEffect, useRef, useState } from "react";
+import { DEMO_ORDER_EVENT, type DemoOrder, readDemoOrders, saveDemoOrder } from "@/lib/demo-orders";
+import { checkPayment } from "@/lib/check-payment";
+import { isHash } from "@/lib/payment-proof";
+import PaymentReceiptCard from "@/components/payment-receipt";
export default function OrderHistory() {
const [orders, setOrders] = useState([]);
-
+ const [checking, setChecking] = useState(null);
+ const [message, setMessage] = useState("");
+ const busy = useRef(false);
useEffect(() => {
const refresh = () => setOrders(readDemoOrders());
- const initialRead = window.setTimeout(refresh, 0);
+ const timer = window.setTimeout(refresh, 0);
window.addEventListener(DEMO_ORDER_EVENT, refresh);
- return () => {
- window.clearTimeout(initialRead);
- window.removeEventListener(DEMO_ORDER_EVENT, refresh);
- };
+ window.addEventListener("storage", refresh);
+ return () => { window.clearTimeout(timer); window.removeEventListener(DEMO_ORDER_EVENT, refresh); window.removeEventListener("storage", refresh); };
}, []);
-
- return (
-
-
-
- Local history
-
-
Demo orders
-
- Stored only in this browser. No real game credits are delivered.
-
-
-
- {orders.length === 0 ? (
-
- Your successful ArcPlay test orders will appear here.
-
- ) : (
-
- {orders.map((order) => {
- const hasArcTransaction = isTransactionHash(order.transactionHash);
-
- return (
-
-
-
- {order.game || "PUBG Mobile"} · {order.product}
-
- {hasArcTransaction ? "Arc verified" : order.transactionHash.startsWith("Apple Pay") ? "Apple Pay DEMO" : "Google Pay TEST"}
-
-
-
- Player {order.playerId} · {new Date(order.createdAt).toLocaleString()}
-
-
- {hasArcTransaction ? (
-
- {shortHash(order.transactionHash)} ↗
-
- ) : (
- No Arc transaction
- )}
-
- );
- })}
+ async function check(order: DemoOrder) {
+ if (busy.current) return;
+ busy.current = true; setChecking(order.id); setMessage("");
+ try {
+ const result = await checkPayment(order);
+ const saved = saveDemoOrder(result.order);
+ setMessage((result.result.status === "confirmed" ? "Self-transfer confirmed. The receipt is ready." : result.result.message) + (saved ? "" : " Browser storage is unavailable."));
+ } finally { busy.current = false; setChecking(null); }
+ }
+ return
+ Local history
+ Demo orders & transaction proofs
+ Stored only in this browser. Saved receipts are local records, not merchant payment authorizations. Recheck to read the chain again.
+ {message ? {message}
: null}
+ {!orders.length ? Submitted proofs and demo orders will appear here.
: {orders.map((order) => {
+ const onchain = isHash(order.transactionHash) || Boolean(order.circleId);
+ const label = order.receipt ? "Confirmed · saved receipt" : order.status === "pending" ? "Awaiting confirmation" : order.status === "reverted" ? "Failed" : order.status === "mismatch" ? "Proof mismatch" : onchain ? "Legacy · unverified" : "Simulation · no payment";
+ return
+ {order.game} · {order.product}
{label}
+ Player {order.playerId} · {new Date(order.createdAt).toLocaleString()}
+
+ {isHash(order.transactionHash) ?
View transaction ↗ : null}
+ {order.kind && (order.sender || order.circleId) ?
: null}
- )}
-
- );
+ {order.circleId && !isHash(order.transactionHash) ? Circle reference: {order.circleId}
: null}
+ {order.receipt ? View receipt & download
: null}
+ ;
+ })}
}
+ ;
}
diff --git a/src/components/payment-receipt.tsx b/src/components/payment-receipt.tsx
new file mode 100644
index 0000000..6fc6c63
--- /dev/null
+++ b/src/components/payment-receipt.tsx
@@ -0,0 +1,26 @@
+"use client";
+import type { PaymentReceipt } from "@/lib/payment-proof";
+
+export default function PaymentReceiptCard({ receipt }: { receipt: PaymentReceipt }) {
+ function download() {
+ const url = URL.createObjectURL(new Blob([JSON.stringify(receipt, null, 2)], { type: "application/json" }));
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = `arc-proof-${receipt.transactionHash}.json`;
+ anchor.click();
+ URL.revokeObjectURL(url);
+ }
+ return
+
Confirmed testnet self-transfer
+
This is a transaction proof. No merchant was paid and no game credits were delivered.
+
+ - Transferred / network fee
- {receipt.amount} test USDC / {receipt.networkFee} test USDC
+ - Sender
- {receipt.sender}
+ - Recipient (same wallet)
- {receipt.recipient}
+ - Network / block
- {receipt.network} · {receipt.chainId} · #{receipt.blockNumber}
+ - Transaction
- {receipt.transactionHash}
+ - Last checked
- {new Date(receipt.verifiedAt).toLocaleString()}
+
+
+
;
+}
diff --git a/src/components/pubg-test-checkout.tsx b/src/components/pubg-test-checkout.tsx
index d044a04..53d0e1a 100644
--- a/src/components/pubg-test-checkout.tsx
+++ b/src/components/pubg-test-checkout.tsx
@@ -1,22 +1,17 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import Script from "next/script";
import ArcSuccessModal from "@/components/arc-success-modal";
import { ARC_NETWORK } from "@/lib/arc-network";
-import { saveDemoOrder } from "@/lib/demo-orders";
+import { saveDemoOrder, readDemoOrders, DEMO_ORDER_EVENT, type DemoOrder } from "@/lib/demo-orders";
+import { checkPayment } from "@/lib/check-payment";
+import { paymentErrorMessage, PROOF_CHAIN_ID } from "@/lib/payment-proof";
+import PaymentReceiptCard from "@/components/payment-receipt";
import { GAMES } from "@/lib/game-catalog";
type PaymentMethod = "apple" | "google" | "wallet" | "circle";
-type CheckoutStatus = "idle" | "confirming" | "pending" | "success" | "error";
-type TransactionReceipt = { status?: string };
-type CircleTransaction = {
- id: string;
- state: string;
- txHash: string | null;
- error?: string | null;
-};
-
+type CheckoutStatus = "idle" | "confirming" | "pending" | "submitted" | "success" | "error";
type GooglePaymentsClient = {
isReadyToPay(request: Record
): Promise<{ result: boolean }>;
loadPaymentData(request: Record): Promise;
@@ -40,15 +35,6 @@ const methods = [
];
function wait(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); }
-function errorMessage(error: unknown) {
- if (typeof error === "object" && error && "code" in error) {
- const code = Number(error.code);
- if (code === 4001) return "Transaction cancelled in your wallet.";
- if (code === -32002) return "A wallet request is already waiting for approval.";
- }
- return "The demo transaction could not be completed.";
-}
-
export default function PubgTestCheckout() {
const [gameId, setGameId] = useState(GAMES[0].id);
const [productId, setProductId] = useState(GAMES[0].products[0].id);
@@ -64,6 +50,49 @@ export default function PubgTestCheckout() {
const [showSuccess, setShowSuccess] = useState(false);
const [circleReady, setCircleReady] = useState(false);
const [circleConsent, setCircleConsent] = useState(false);
+ const [proofOrder, setProofOrder] = useState(null);
+ const [hasPendingProof, setHasPendingProof] = useState(false);
+ const submitting = useRef(false);
+
+ useEffect(() => {
+ const refresh = () => setHasPendingProof(readDemoOrders().some((order) => order.status === "pending"));
+ const timer = window.setTimeout(refresh, 0);
+ window.addEventListener(DEMO_ORDER_EVENT, refresh);
+ return () => { window.clearTimeout(timer); window.removeEventListener(DEMO_ORDER_EVENT, refresh); };
+ }, []);
+
+ async function verifyOrder(order: DemoOrder) {
+ setStatus("pending");
+ setMessage("Checking the transaction on Arc Testnet…");
+ let current = order;
+ for (let attempt = 0; attempt < 10; attempt += 1) {
+ const checked = await checkPayment(current);
+ current = checked.order;
+ setProofOrder(current);
+ setTransactionHash(current.transactionHash);
+ const saved = saveDemoOrder(current);
+ if (checked.result.status === "confirmed") {
+ setStatus("success");
+ setMessage(`Self-transfer confirmed on Arc Testnet. No product was delivered.${saved ? "" : " Browser storage is unavailable; download the proof now."}`);
+ setShowSuccess(true);
+ return;
+ }
+ if (checked.result.status !== "pending") {
+ setStatus(checked.result.status === "unavailable" ? "submitted" : "error");
+ setMessage(checked.result.message);
+ return;
+ }
+ if (attempt < 9) await wait(1_500);
+ }
+ setStatus("submitted");
+ setMessage("Confirmation is still pending. Your proof is saved when browser storage is available. Check again below; no new transaction is needed.");
+ }
+
+ async function recheckProof() {
+ if (!proofOrder || submitting.current) return;
+ submitting.current = true;
+ try { await verifyOrder(proofOrder); } finally { submitting.current = false; }
+ }
const game = GAMES.find((item) => item.id === gameId) ?? GAMES[0];
const product = game.products.find((item) => item.id === productId) ?? game.products[0];
@@ -98,7 +127,7 @@ export default function PubgTestCheckout() {
}
function selectMethod(next: PaymentMethod) {
- setMethod(next); setStatus("idle"); setMessage("");
+ setMethod(next); setStatus("idle"); setMessage(""); setTransactionHash(""); setProofOrder(null); setShowSuccess(false);
if (next === "apple") setDemoModal("Apple Pay");
}
@@ -237,37 +266,16 @@ export default function PubgTestCheckout() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirmed: true }),
});
- const data = await response.json() as { transaction?: CircleTransaction; error?: string };
+ const data = await response.json() as { transaction?: { id: string }; error?: string };
if (!response.ok || !data.transaction?.id) throw new Error(data.error || "Circle payment proof could not be created.");
-
- let transaction = data.transaction;
- setStatus("pending");
- setMessage("Circle accepted the proof. Waiting for its Arc transaction hash…");
-
- for (let attempt = 0; attempt < 30 && !transaction.txHash; attempt += 1) {
- await wait(1_500);
- const statusResponse = await fetch(`/api/circle/payments?id=${encodeURIComponent(transaction.id)}`, { cache: "no-store" });
- const statusData = await statusResponse.json() as { transaction?: CircleTransaction; error?: string };
- if (!statusResponse.ok || !statusData.transaction) throw new Error(statusData.error || "Circle payment status could not be loaded.");
- transaction = statusData.transaction;
- if (["FAILED", "DENIED", "CANCELLED", "STUCK"].includes(transaction.state)) {
- throw new Error(transaction.error || `Circle transaction ended in ${transaction.state}.`);
- }
- }
-
- if (!transaction.txHash) throw new Error("Circle accepted the transaction, but its Arc hash is not ready yet. Try again shortly.");
- setTransactionHash(transaction.txHash);
- saveDemoOrder({
- id: transaction.id,
- game: game.name,
- product: `${product.label} · Circle proof`,
- playerId: accountId.trim(),
- transactionHash: transaction.txHash,
- createdAt: new Date().toISOString(),
- });
- setStatus("success");
- setMessage("Circle Wallet proof verified on Arc Testnet. Only 0.01 test USDC moved back to the same wallet.");
- setShowSuccess(true);
+ const order: DemoOrder = {
+ id: data.transaction.id, circleId: data.transaction.id, kind: "circle", status: "pending",
+ game: game.name, product: `${product.label} · Circle proof`, playerId: accountId.trim(),
+ transactionHash: "", createdAt: new Date().toISOString(),
+ };
+ saveDemoOrder(order);
+ setProofOrder(order);
+ await verifyOrder(order);
} catch (error) {
setStatus("error");
setMessage(error instanceof Error ? error.message : "Circle payment proof could not be completed.");
@@ -275,32 +283,33 @@ export default function PubgTestCheckout() {
}
async function createTestOrder() {
- if (method === "apple") { setDemoModal("Apple Pay"); return; }
- if (method === "google") { await startGooglePayTest(); return; }
- if (method === "circle") { await createCircleProof(); return; }
- const provider = window.ethereum;
- if (!provider) { setStatus("error"); setMessage("Connect an EIP-1193 browser wallet first."); return; }
- if (!accountId.trim()) { setStatus("error"); setMessage(`Enter a demo ${game.accountLabel.toLowerCase()}.`); return; }
- setStatus("confirming"); setMessage(""); setTransactionHash("");
+ if (submitting.current) return;
+ submitting.current = true;
try {
+ if (method === "apple") { setDemoModal("Apple Pay"); return; }
+ if (method === "google") { await startGooglePayTest(); return; }
+ if (hasPendingProof || proofOrder?.status === "pending") {
+ setMessage("Check the existing proof below or in order history before starting another."); return;
+ }
+ if (ARC_NETWORK.chainId !== PROOF_CHAIN_ID) {
+ setStatus("error"); setMessage("This checkout supports Arc Testnet only."); return;
+ }
+ if (method === "circle") { await createCircleProof(); return; }
+ const provider = window.ethereum;
+ if (!provider) { setStatus("error"); setMessage("Connect a browser wallet first."); return; }
+ if (!accountId.trim()) { setStatus("error"); setMessage(`Enter a demo ${game.accountLabel.toLowerCase()}.`); return; }
+ setStatus("confirming"); setMessage(""); setTransactionHash(""); setProofOrder(null);
const accounts = await provider.request({ method: "eth_accounts" });
const address = accounts[0];
if (!address) throw new Error("Wallet is not connected");
const chainId = await provider.request({ method: "eth_chainId" });
- if (chainId.toLowerCase() !== ARC_NETWORK.chainIdHex.toLowerCase()) { setStatus("error"); setMessage(`Switch your wallet to ${ARC_NETWORK.chainName} first.`); return; }
- const hash = await provider.request({ method: "eth_sendTransaction", params: [{ from: address, to: address, value: "0x0" }] });
- setTransactionHash(hash); setStatus("pending");
- for (let attempt = 0; attempt < 30; attempt += 1) {
- const receipt = await provider.request({ method: "eth_getTransactionReceipt", params: [hash] });
- if (receipt) {
- if (receipt.status === "0x0") throw new Error("Transaction reverted");
- saveDemoOrder({ id: hash, game: game.name, product: product.label, playerId: accountId.trim(), transactionHash: hash, createdAt: new Date().toISOString() });
- setStatus("success"); setMessage("Demo order verified on Arc Testnet. No product was delivered."); setShowSuccess(true); return;
- }
- await wait(1_000);
- }
- setStatus("success"); setMessage("Transaction submitted. Open ArcScan to check its final status."); setShowSuccess(true);
- } catch (error) { setStatus("error"); setMessage(errorMessage(error)); }
+ if (BigInt(chainId) !== BigInt(PROOF_CHAIN_ID)) { setStatus("error"); setMessage("Switch your wallet to Arc Testnet first."); return; }
+ const hash = await provider.request({ method: "eth_sendTransaction", params: [{ from: address, to: address, value: "0x0", chainId: ARC_NETWORK.chainIdHex }] });
+ const order: DemoOrder = { id: hash, kind: "wallet", sender: address, status: "pending", game: game.name, product: product.label, playerId: accountId.trim(), transactionHash: hash, createdAt: new Date().toISOString() };
+ setTransactionHash(hash); setProofOrder(order); saveDemoOrder(order);
+ await verifyOrder(order);
+ } catch (error) { setStatus("error"); setMessage(paymentErrorMessage(error)); }
+ finally { submitting.current = false; }
}
return (
@@ -312,33 +321,36 @@ export default function PubgTestCheckout() {
- {GAMES.map((item) =>
)}
+ {GAMES.map((item) =>
)}
1. Choose package
-
{game.products.map((item) => )}
+
{game.products.map((item) => )}
-
setAccountId(event.target.value)} placeholder={game.accountPlaceholder} className="mt-2 w-full rounded-xl border border-slate-700 bg-slate-950/70 px-4 py-3 text-white outline-none transition placeholder:text-slate-600 focus:border-cyan-400" />
+
setAccountId(event.target.value)} placeholder={game.accountPlaceholder} className="mt-2 w-full rounded-xl border border-slate-700 bg-slate-950/70 px-4 py-3 text-white outline-none transition placeholder:text-slate-600 focus:border-cyan-400" />
3. Choose payment method
-
{methods.map((item) =>
)}
- {method === "circle" ?
: null}
+
{methods.map((item) =>
)}
+ {method === "circle" ?
: null}
{demoModal ? Demo only
{demoModal} preview
Demo payment flow — fiat-to-USDC settlement on Arc will be added later. No card sheet opens and no payment is collected.
Product{game.shortName} · {product.label}
Preview total{product.price} USDC
: null}
- setShowSuccess(false)} title={method === "wallet" || method === "circle" ? "Payment proof verified" : "Demo payment approved"} description={method === "circle" ? "Circle Wallet submitted a capped 0.01 test USDC self-transfer proof on Arc Testnet." : method === "wallet" ? "Your demo order proof was submitted on Arc Testnet." : `${method === "apple" ? "Apple Pay" : "Google Pay"} demo completed successfully. No card was charged and no USDC moved on Arc.`} detail={`${game.name} · ${product.label}`} explorerUrl={transactionHash ? `${ARC_NETWORK.explorerUrl}/tx/${transactionHash}` : undefined} />
+ setShowSuccess(false)} title={method === "wallet" || method === "circle" ? "Payment proof verified" : "Demo payment approved"} description={method === "circle" ? "Circle Wallet confirmed a capped 0.01 test USDC self-transfer proof on Arc Testnet." : method === "wallet" ? "Your zero-value self-transfer was confirmed on Arc Testnet. No merchant was paid." : `${method === "apple" ? "Apple Pay" : "Google Pay"} demo completed successfully. No card was charged and no USDC moved on Arc.`} detail={`${game.name} · ${product.label}`} explorerUrl={transactionHash ? `${ARC_NETWORK.explorerUrl}/tx/${transactionHash}` : undefined} />
);
}
diff --git a/src/lib/arc-health.ts b/src/lib/arc-health.ts
index 7a1eb6c..4773c61 100644
--- a/src/lib/arc-health.ts
+++ b/src/lib/arc-health.ts
@@ -7,7 +7,7 @@ export function getArcHealthStatus(
rpcReachable = true,
): ArcHealthStatus {
if (!rpcReachable) return "unavailable";
- return blockAgeSeconds <= STALE_BLOCK_THRESHOLD_SECONDS ? "ok" : "degraded";
+ return Number.isFinite(blockAgeSeconds) && blockAgeSeconds >= 0 && blockAgeSeconds <= STALE_BLOCK_THRESHOLD_SECONDS ? "ok" : "degraded";
}
export function isFreshArcBlock(blockAgeSeconds: number): boolean {
diff --git a/src/lib/check-payment.ts b/src/lib/check-payment.ts
new file mode 100644
index 0000000..c236479
--- /dev/null
+++ b/src/lib/check-payment.ts
@@ -0,0 +1,29 @@
+import type { DemoOrder } from "./demo-orders";
+import { isHash, type ProofResult } from "./payment-proof";
+
+/** Read-only retry: never calls a wallet send method or the Circle creation endpoint. */
+export async function checkPayment(order: DemoOrder): Promise<{ order: DemoOrder; result: ProofResult }> {
+ let current = order;
+ try {
+ if (order.kind === "circle" && order.circleId) {
+ const response = await fetch(`/api/circle/payments?id=${encodeURIComponent(order.circleId)}`, { cache: "no-store", signal: AbortSignal.timeout(12_000) });
+ const data = await response.json();
+ if (!response.ok || !data.transaction) throw new Error("Circle lookup unavailable");
+ if (["FAILED", "DENIED", "CANCELLED"].includes(data.transaction.state)) return { order: { ...order, status: "reverted", receipt: undefined }, result: { status: "reverted", message: "Circle reports that this transaction did not complete." } };
+ current = { ...order, transactionHash: data.transaction.txHash || order.transactionHash, sender: data.transaction.sender };
+ }
+ if (!isHash(current.transactionHash)) return { order: current, result: { status: "pending", message: "Circle has not published a transaction hash yet. Check again without creating another proof." } };
+ if (!current.sender || !current.kind) return { order: current, result: { status: "mismatch", message: "This older order has no saved proof details. Inspect the transaction in the explorer." } };
+ const response = await fetch("/api/payment-proof", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ hash: current.transactionHash, sender: current.sender, kind: current.kind }), signal: AbortSignal.timeout(30_000),
+ });
+ const result = await response.json() as ProofResult;
+ if (!response.ok && result.status !== "unavailable") throw new Error("Proof lookup unavailable");
+ if (result.status === "confirmed") current = { ...current, status: "confirmed", receipt: result.receipt };
+ else current = { ...current, status: result.status === "reverted" || result.status === "mismatch" ? result.status : "pending", receipt: undefined };
+ return { order: current, result };
+ } catch {
+ return { order: { ...current, status: "pending", receipt: undefined }, result: { status: "unavailable", message: "Verification is temporarily unavailable. Check this saved transaction again without resending it." } };
+ }
+}
diff --git a/src/lib/demo-orders.ts b/src/lib/demo-orders.ts
index 1edb731..e6ae3b0 100644
--- a/src/lib/demo-orders.ts
+++ b/src/lib/demo-orders.ts
@@ -1,28 +1,45 @@
+import { isAddress, isHash, type PaymentReceipt, type ProofKind } from "./payment-proof";
export const DEMO_ORDERS_KEY = "arcplay-demo-orders";
export const DEMO_ORDER_EVENT = "arcplay:demo-order-created";
export const BALANCE_REFRESH_EVENT = "arcplay:balance-refresh";
-
export type DemoOrder = {
- id: string;
- game: string;
- product: string;
- playerId: string;
- transactionHash: string;
- createdAt: string;
+ id: string; game: string; product: string; playerId: string; transactionHash: string; createdAt: string;
+ kind?: ProofKind; sender?: string; circleId?: string;
+ status?: "pending" | "confirmed" | "reverted" | "mismatch";
+ receipt?: PaymentReceipt;
};
-
-export function readDemoOrders(): DemoOrder[] {
+export function parseDemoOrders(stored: string | null): DemoOrder[] {
try {
- const stored = window.localStorage.getItem(DEMO_ORDERS_KEY);
- return stored ? (JSON.parse(stored) as DemoOrder[]) : [];
- } catch {
- return [];
- }
+ const data: unknown = JSON.parse(stored ?? "[]");
+ if (!Array.isArray(data)) return [];
+ return data.filter((v): v is DemoOrder => v && typeof v === "object" &&
+ ["id", "game", "product", "playerId", "transactionHash", "createdAt"].every((key) => typeof v[key] === "string") &&
+ (v.sender === undefined || isAddress(v.sender)) &&
+ (v.kind === undefined || v.kind === "wallet" || v.kind === "circle") &&
+ (v.circleId === undefined || (typeof v.circleId === "string" && /^[\da-f-]{36}$/i.test(v.circleId))))
+ .slice(0, 50).map((v) => ({ ...v, receipt: isStoredReceipt(v.receipt, v.transactionHash) ? v.receipt : undefined }));
+ } catch { return []; }
+}
+function isStoredReceipt(value: unknown, hash: string): value is PaymentReceipt {
+ if (!value || typeof value !== "object") return false;
+ const r = value as PaymentReceipt;
+ return r.version === 1 && r.chainId === 5042002 && isHash(hash) && r.transactionHash === hash &&
+ isAddress(r.sender) && isAddress(r.recipient) && typeof r.amount === "string" &&
+ typeof r.networkFee === "string" && typeof r.blockNumber === "string" &&
+ typeof r.verifiedAt === "string" && (r.kind === "wallet" || r.kind === "circle");
+}
+export function readDemoOrders(): DemoOrder[] {
+ try { return parseDemoOrders(window.localStorage.getItem(DEMO_ORDERS_KEY)); } catch { return []; }
+}
+export function upsertDemoOrder(orders: DemoOrder[], order: DemoOrder): DemoOrder[] {
+ return [order, ...orders.filter((old) => old.id !== order.id && (!isHash(order.transactionHash) || old.transactionHash !== order.transactionHash))].slice(0, 50);
}
-
-export function saveDemoOrder(order: DemoOrder) {
- const orders = [order, ...readDemoOrders()].slice(0, 10);
- window.localStorage.setItem(DEMO_ORDERS_KEY, JSON.stringify(orders));
- window.dispatchEvent(new Event(DEMO_ORDER_EVENT));
- window.dispatchEvent(new Event(BALANCE_REFRESH_EVENT));
+/** A storage failure must never turn a submitted transaction into a payment failure. */
+export function saveDemoOrder(order: DemoOrder): boolean {
+ try {
+ window.localStorage.setItem(DEMO_ORDERS_KEY, JSON.stringify(upsertDemoOrder(readDemoOrders(), order)));
+ window.dispatchEvent(new Event(DEMO_ORDER_EVENT));
+ window.dispatchEvent(new Event(BALANCE_REFRESH_EVENT));
+ return true;
+ } catch { return false; }
}
diff --git a/src/lib/payment-proof.ts b/src/lib/payment-proof.ts
new file mode 100644
index 0000000..b4b84b0
--- /dev/null
+++ b/src/lib/payment-proof.ts
@@ -0,0 +1,78 @@
+/** Read-only proof verification. A confirmed self-transfer is not a merchant payment. */
+export const PROOF_CHAIN_ID = 5042002;
+export const USDC_INTERFACE = "0x3600000000000000000000000000000000000000";
+const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
+export type ProofKind = "wallet" | "circle";
+export type ProofRequest = { hash: string; sender: string; kind: ProofKind };
+export type PaymentReceipt = {
+ version: 1; chainId: number; network: string; transactionHash: string;
+ sender: string; recipient: string; amount: string; currency: "USDC";
+ kind: ProofKind; blockNumber: string; blockHash: string; networkFee: string;
+ verifiedAt: string; explorerUrl: string;
+};
+export type ProofResult = {
+ status: "confirmed"; receipt: PaymentReceipt;
+} | { status: "pending" | "reverted" | "mismatch" | "unavailable"; message: string };
+export type RpcRequest = (method: string, params?: unknown[]) => Promise;
+export function isHash(value: unknown): value is string { return typeof value === "string" && /^0x[\da-f]{64}$/i.test(value); }
+export function isAddress(value: unknown): value is string { return typeof value === "string" && /^0x[\da-f]{40}$/i.test(value); }
+export function isProofRequest(value: unknown): value is ProofRequest {
+ if (!value || typeof value !== "object") return false;
+ const v = value as Partial;
+ return isHash(v.hash) && isAddress(v.sender) && (v.kind === "wallet" || v.kind === "circle");
+}
+function same(a: string | null | undefined, b: string) { return a?.toLowerCase() === b.toLowerCase(); }
+function units(value: bigint, decimals = 18) {
+ const scale = 10n ** BigInt(decimals);
+ const fraction = (value % scale).toString().padStart(decimals, "0").replace(/0+$/, "");
+ return `${value / scale}${fraction ? `.${fraction}` : ""}`;
+}
+type RpcReceipt = { status: string; transactionHash: string; blockHash: string; blockNumber: string; gasUsed: string; effectiveGasPrice: string; logs: { address: string; topics: string[]; data: string }[] };
+type RpcTransaction = { hash: string; from: string; to: string | null; value: string; input: string; blockHash: string | null };
+
+export async function verifyPaymentProof(input: ProofRequest, rpc: RpcRequest): Promise {
+ if (!isProofRequest(input)) return { status: "mismatch", message: "Invalid proof request." };
+ try {
+ const chainId = await rpc("eth_chainId");
+ if (BigInt(chainId) !== BigInt(PROOF_CHAIN_ID)) return { status: "mismatch", message: "RPC is not connected to Arc Testnet." };
+ const receipt = await rpc("eth_getTransactionReceipt", [input.hash]);
+ if (!receipt) return { status: "pending", message: "Awaiting an onchain receipt. Check again without sending another transaction." };
+ if (!same(receipt.transactionHash, input.hash) || !isHash(receipt.blockHash)) return { status: "mismatch", message: "Receipt does not match the requested transaction." };
+ if (receipt.status === "0x0") return { status: "reverted", message: "The transaction reverted. No proof was confirmed." };
+ if (receipt.status !== "0x1") return { status: "unavailable", message: "The receipt has no successful execution status." };
+ const [tx, block] = await Promise.all([
+ rpc("eth_getTransactionByHash", [input.hash]),
+ rpc<{ hash: string } | null>("eth_getBlockByNumber", [receipt.blockNumber, false]),
+ ]);
+ if (!tx || !block || !same(block.hash, receipt.blockHash) || !same(tx.blockHash, receipt.blockHash)) return { status: "pending", message: "Waiting for a consistent block and transaction receipt." };
+ if (!same(tx.hash, input.hash) || !same(tx.from, input.sender)) return { status: "mismatch", message: "Transaction sender does not match this proof." };
+ const amount = input.kind === "wallet" ? "0" : "0.01";
+ const expectedNative = input.kind === "wallet" ? 0n : 10n ** 16n;
+ const nativeMatch = same(tx.to, input.sender) && BigInt(tx.value) === expectedNative && tx.input === "0x";
+ // Circle may use either native USDC (18 decimals) or its ERC-20 interface (6).
+ const tokenMatch = input.kind === "circle" && same(tx.to, USDC_INTERFACE) && BigInt(tx.value) === 0n && receipt.logs.some((log) =>
+ same(log.address, USDC_INTERFACE) && log.topics.length === 3 && same(log.topics[0], TRANSFER_TOPIC) &&
+ same(log.topics[1], `0x${input.sender.slice(2).padStart(64, "0")}`) &&
+ same(log.topics[2], `0x${input.sender.slice(2).padStart(64, "0")}`) && BigInt(log.data) === 10_000n,
+ );
+ if (!nativeMatch && !tokenMatch) return { status: "mismatch", message: "Recipient or amount does not match the expected self-transfer proof." };
+ return { status: "confirmed", receipt: {
+ version: 1, chainId: PROOF_CHAIN_ID, network: "Arc Testnet", transactionHash: input.hash,
+ sender: tx.from, recipient: input.sender, amount, currency: "USDC", kind: input.kind,
+ blockNumber: BigInt(receipt.blockNumber).toString(), blockHash: receipt.blockHash,
+ networkFee: units(BigInt(receipt.gasUsed) * BigInt(receipt.effectiveGasPrice)),
+ verifiedAt: new Date().toISOString(), explorerUrl: `https://testnet.arcscan.app/tx/${input.hash}`,
+ } };
+ } catch {
+ return { status: "unavailable", message: "Arc verification is temporarily unavailable. Keep this transaction and check again; do not resend it." };
+ }
+}
+
+export function paymentErrorMessage(error: unknown) {
+ const e = error as { code?: number; message?: string; cause?: { code?: number } } | null;
+ const code = Number(e?.code ?? e?.cause?.code);
+ if (code === 4001) return "Transaction cancelled in your wallet. Nothing was submitted.";
+ if (code === -32002) return "A wallet request is already waiting for approval.";
+ if (/insufficient funds/i.test(e?.message ?? "")) return "Not enough test USDC to cover the network fee. Fund your wallet from the testnet faucet.";
+ return "The wallet request could not be completed. Check your wallet activity before trying again.";
+}
diff --git a/tests/arc-health.test.mjs b/tests/arc-health.test.mjs
index 222e2cb..2924f36 100644
--- a/tests/arc-health.test.mjs
+++ b/tests/arc-health.test.mjs
@@ -24,3 +24,7 @@ test("unreachable Arc RPC reports unavailable", () => {
assert.equal(getArcHealthStatus(0, false), "unavailable");
assert.equal(getArcHealthStatus(Number.POSITIVE_INFINITY, false), "unavailable");
});
+
+test("invalid block ages cannot produce healthy telemetry", () => {
+ for (const age of [NaN, Infinity, -1]) assert.equal(getArcHealthStatus(age), "degraded");
+});
diff --git a/tests/e2e/checkout.spec.ts b/tests/e2e/checkout.spec.ts
new file mode 100644
index 0000000..a0ac251
--- /dev/null
+++ b/tests/e2e/checkout.spec.ts
@@ -0,0 +1,80 @@
+import { test, expect, type Page } from "@playwright/test";
+const sender = `0x${"1".repeat(40)}`;
+async function setup(page: Page, mode = "success") {
+ await page.route("https://pay.google.com/**", (route) => route.abort());
+ await page.route("**/api/agent-trust", (route) => route.fulfill({ json: { configured: false } }));
+ await page.addInitScript(({ sender, mode }) => {
+ const target = window as unknown as { ethereum: unknown; sends: number };
+ target.sends = 0;
+ target.ethereum = {
+ on() {}, removeListener() {},
+ async request({ method }: { method: string }) {
+ if (method === "eth_accounts" || method === "eth_requestAccounts") return [sender];
+ if (method === "eth_chainId") return mode === "wrong-network" ? "0x1" : "0x4cef52";
+ if (method === "eth_getBalance") return "0xde0b6b3a7640000";
+ if (method === "eth_sendTransaction") {
+ if (mode === "cancel") throw { code: 4001 };
+ if (mode === "insufficient") throw { message: "insufficient funds for gas" };
+ target.sends++;
+ return `0x${(mode === "pending" ? "c" : mode === "reverted" ? "d" : "a").repeat(64)}`;
+ }
+ throw new Error(`Unsupported wallet method ${method}`);
+ },
+ };
+ }, { sender, mode });
+ await page.goto("/");
+ await page.locator("#account-id").fill("demo-player");
+}
+
+test("checkout reaches real server verifier, renders receipt and downloads JSON", async ({ page }) => {
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await setup(page);
+ await page.getByRole("button", { name: "Create test order", exact: true }).click();
+ await expect(page.getByText("Self-transfer confirmed on Arc Testnet. No product was delivered.")).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Payment proof verified" })).toBeVisible();
+ await page.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(page.getByText("0 test USDC / 0.000021 test USDC").first()).toBeVisible();
+ const download = page.waitForEvent("download");
+ await page.getByRole("button", { name: "Download proof JSON" }).first().click();
+ expect((await download).suggestedFilename()).toMatch(/^arc-proof-0x/);
+ expect(await page.evaluate(() => JSON.parse(localStorage.getItem("arcplay-demo-orders")!)[0].status)).toBe("confirmed");
+ expect(errors).toEqual([]);
+});
+
+test("pending checkout survives reload; recheck never resends or claims success", async ({ page }) => {
+ await setup(page, "pending");
+ await page.getByRole("button", { name: "Create test order", exact: true }).click();
+ await expect(page.getByText("Confirmation is still pending.", { exact: false })).toBeVisible({ timeout: 25_000 });
+ await expect(page.getByRole("heading", { name: "Payment proof verified" })).toHaveCount(0);
+ expect(await page.evaluate(() => (window as unknown as { sends: number }).sends)).toBe(1);
+ await page.reload();
+ await expect(page.getByText("Awaiting confirmation", { exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "Check status · no new transaction", exact: true }).click();
+ await expect(page.getByText("Awaiting an onchain receipt.", { exact: false })).toBeVisible();
+ expect(await page.evaluate(() => (window as unknown as { sends: number }).sends)).toBe(0);
+ expect(await page.evaluate(() => JSON.parse(localStorage.getItem("arcplay-demo-orders")!).length)).toBe(1);
+});
+
+for (const [mode, message] of [["cancel", "Transaction cancelled in your wallet."], ["insufficient", "Not enough test USDC"], ["reverted", "The transaction reverted."]]) {
+ test(`${mode} does not display a successful payment`, async ({ page }) => {
+ await setup(page, mode);
+ await page.getByRole("button", { name: "Create test order", exact: true }).click();
+ await expect(page.getByText(message, { exact: false })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Payment proof verified" })).toHaveCount(0);
+ });
+}
+
+test("wrong network disables checkout and mobile page fits the screen", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await setup(page, "wrong-network");
+ await expect(page.getByRole("button", { name: "Switch to Arc to Pay" })).toBeDisabled();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
+});
+
+test("API rejects malformed requests and live telemetry reads the configured RPC", async ({ request }) => {
+ const invalid = await request.post("/api/payment-proof", { data: { hash: "oops" } });
+ expect(invalid.status()).toBe(400);
+ const network = await request.get("/api/arc-network");
+ expect(await network.json()).toMatchObject({ chainId: 5042002, latestBlock: "100", status: "online" });
+});
diff --git a/tests/e2e/rpc-fixture.mjs b/tests/e2e/rpc-fixture.mjs
new file mode 100644
index 0000000..be908cb
--- /dev/null
+++ b/tests/e2e/rpc-fixture.mjs
@@ -0,0 +1,24 @@
+import { createServer } from "node:http";
+const sender = `0x${"1".repeat(40)}`;
+const blockHash = `0x${"b".repeat(64)}`;
+const quantity = (value) => `0x${BigInt(value).toString(16)}`;
+createServer(async (req, res) => {
+ if (req.method === "GET") { res.end("ready"); return; }
+ let raw = "";
+ for await (const chunk of req) raw += chunk;
+ try {
+ const { id, method, params = [] } = JSON.parse(raw);
+ let result;
+ if (method === "eth_chainId") result = "0x4cef52";
+ else if (method === "eth_blockNumber") result = "0x64";
+ else if (method === "eth_getBlockByNumber") result = { hash: blockHash, number: "0x64", timestamp: quantity(Math.floor(Date.now() / 1000)), gasLimit: "0x1c9c380", gasUsed: "0x5208", difficulty: "0x0", transactions: [] };
+ else if (method === "eth_getTransactionReceipt") result = params[0].startsWith("0xcccc") ? null : {
+ transactionHash: params[0], status: params[0].startsWith("0xdddd") ? "0x0" : "0x1",
+ blockHash, blockNumber: "0x64", gasUsed: "0x5208", effectiveGasPrice: "0x3b9aca00", logs: [],
+ };
+ else if (method === "eth_getTransactionByHash") result = { hash: params[0], from: sender, to: sender, value: "0x0", input: "0x", blockHash };
+ else throw new Error(`Unexpected method ${method}`);
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
+ } catch { res.statusCode = 400; res.end("bad fixture request"); }
+}).listen(4319, "127.0.0.1");
diff --git a/tests/payment-proof.test.mjs b/tests/payment-proof.test.mjs
new file mode 100644
index 0000000..07d2642
--- /dev/null
+++ b/tests/payment-proof.test.mjs
@@ -0,0 +1,108 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import proof from "../.test-dist/payment-proof.js";
+import orders from "../.test-dist/demo-orders.js";
+import recovery from "../.test-dist/check-payment.js";
+const { verifyPaymentProof, paymentErrorMessage, isProofRequest, USDC_INTERFACE } = proof;
+const hash = `0x${"a".repeat(64)}`, blockHash = `0x${"b".repeat(64)}`;
+const sender = `0x${"1".repeat(40)}`, other = `0x${"2".repeat(40)}`;
+const request = { hash, sender, kind: "wallet" };
+function fixture(overrides = {}) {
+ const receipt = { status: "0x1", transactionHash: hash, blockHash, blockNumber: "0x64", gasUsed: "0x5208", effectiveGasPrice: "0x3b9aca00", logs: [], ...overrides.receipt };
+ const tx = { hash, from: sender, to: sender, value: "0x0", input: "0x", blockHash, ...overrides.tx };
+ return async (method) => {
+ if (overrides.fail) throw new Error("network timeout");
+ if (method === "eth_chainId") return overrides.chainId ?? "0x4cef52";
+ if (method === "eth_getTransactionReceipt") return overrides.pending ? null : receipt;
+ if (method === "eth_getTransactionByHash") return tx;
+ if (method === "eth_getBlockByNumber") return { hash: overrides.canonicalHash ?? blockHash };
+ throw new Error(`Unexpected ${method}`);
+ };
+}
+test("confirmed proof includes independently checked amount, parties, block and actual fee", async () => {
+ const result = await verifyPaymentProof(request, fixture());
+ assert.equal(result.status, "confirmed");
+ assert.equal(result.receipt.amount, "0");
+ assert.equal(result.receipt.networkFee, "0.000021");
+ assert.equal(result.receipt.blockNumber, "100");
+ assert.equal(result.receipt.recipient, sender);
+});
+test("pending and RPC failure never become successful receipts", async () => {
+ assert.equal((await verifyPaymentProof(request, fixture({ pending: true }))).status, "pending");
+ assert.equal((await verifyPaymentProof(request, fixture({ fail: true }))).status, "unavailable");
+});
+test("wrong network, sender, recipient, amount, hash and calldata are rejected", async () => {
+ for (const overrides of [
+ { chainId: "0x1" }, { tx: { from: other } }, { tx: { to: other } },
+ { tx: { value: "0x1" } }, { tx: { hash: blockHash } }, { tx: { input: "0x1234" } },
+ { receipt: { transactionHash: blockHash } },
+ ]) assert.equal((await verifyPaymentProof(request, fixture(overrides))).status, "mismatch");
+});
+test("reverted and unknown receipt statuses cannot pass", async () => {
+ assert.equal((await verifyPaymentProof(request, fixture({ receipt: { status: "0x0" } }))).status, "reverted");
+ assert.equal((await verifyPaymentProof(request, fixture({ receipt: { status: undefined } }))).status, "unavailable");
+});
+test("a receipt on an inconsistent block stays pending", async () => {
+ assert.equal((await verifyPaymentProof(request, fixture({ canonicalHash: hash }))).status, "pending");
+});
+test("Circle native transfer must be exactly 0.01 USDC at 18 decimals", async () => {
+ const input = { ...request, kind: "circle" };
+ assert.equal((await verifyPaymentProof(input, fixture({ tx: { value: `0x${(10n ** 16n).toString(16)}` } }))).status, "confirmed");
+ assert.equal((await verifyPaymentProof(input, fixture({ tx: { value: "0x2710" } }))).status, "mismatch");
+});
+test("Circle ERC-20 transfer checks canonical contract, parties and six-decimal amount", async () => {
+ const log = { address: USDC_INTERFACE, topics: ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", `0x${sender.slice(2).padStart(64, "0")}`, `0x${sender.slice(2).padStart(64, "0")}`], data: "0x2710" };
+ const input = { ...request, kind: "circle" };
+ const run = (event) => verifyPaymentProof(input, fixture({ tx: { to: USDC_INTERFACE, input: "0xa9059cbb" }, receipt: { logs: [event] } }));
+ assert.equal((await run(log)).status, "confirmed");
+ assert.equal((await run({ ...log, address: other })).status, "mismatch");
+ assert.equal((await run({ ...log, data: "0x1" })).status, "mismatch");
+});
+test("wallet cancellation and insufficient funds have actionable messages", () => {
+ assert.match(paymentErrorMessage({ code: 4001 }), /cancelled/);
+ assert.match(paymentErrorMessage({ code: -32002 }), /already waiting/);
+ assert.match(paymentErrorMessage({ message: "insufficient funds for gas" }), /network fee/);
+});
+test("invalid proof inputs are rejected before an RPC request", async () => {
+ assert.equal(isProofRequest({ ...request, hash: "bad" }), false);
+ assert.equal((await verifyPaymentProof({}, () => { throw new Error("must not call"); })).status, "mismatch");
+});
+const order = { id: hash, game: "Game", product: "Pack", playerId: "demo", transactionHash: hash, createdAt: "2026-09-18T00:00:00Z", kind: "wallet", sender, status: "pending" };
+test("history upserts repeated proof checks instead of duplicating the same transaction", () => {
+ assert.equal(orders.upsertDemoOrder([order], { ...order, status: "confirmed" }).length, 1);
+ assert.equal(orders.upsertDemoOrder([order], { ...order, id: "another-id" }).length, 1);
+});
+test("malformed local history is ignored and legacy hashes are not certified", () => {
+ for (const value of ["{", "{}", "null", '[null,42,{}]']) assert.deepEqual(orders.parseDemoOrders(value), []);
+ const legacy = orders.parseDemoOrders(JSON.stringify([{ ...order, status: undefined, receipt: { version: 1 } }]));
+ assert.equal(legacy[0].receipt, undefined);
+});
+test("storage failure returns false without throwing after a successful send", () => {
+ const previous = globalThis.window;
+ globalThis.window = { localStorage: { getItem: () => null, setItem: () => { throw new Error("quota"); } } };
+ try { assert.equal(orders.saveDemoOrder(order), false); } finally { globalThis.window = previous; }
+});
+test("Circle recovery only queries existing ID and verifies its hash; never creates a payment", async () => {
+ const original = globalThis.fetch, calls = [];
+ globalThis.fetch = async (url, init) => {
+ calls.push([url, init?.method ?? "GET"]);
+ return Response.json(url.startsWith("/api/circle") ? { transaction: { state: "COMPLETE", txHash: hash, sender } } : { status: "pending", message: "Pending" });
+ };
+ try {
+ const result = await recovery.checkPayment({ ...order, kind: "circle", circleId: "11111111-1111-4111-8111-111111111111", transactionHash: "" });
+ assert.equal(result.result.status, "pending");
+ assert.deepEqual(calls.map((c) => c[1]), ["GET", "POST"]);
+ assert.equal(calls[1][0], "/api/payment-proof");
+ assert.equal(result.order.transactionHash, hash);
+ } finally { globalThis.fetch = original; }
+});
+test("a retry with unavailable RPC preserves the same hash and does not claim confirmation", async () => {
+ const original = globalThis.fetch;
+ globalThis.fetch = async () => { throw new Error("offline"); };
+ try {
+ const result = await recovery.checkPayment(order);
+ assert.equal(result.order.transactionHash, hash);
+ assert.equal(result.order.status, "pending");
+ assert.equal(result.result.status, "unavailable");
+ } finally { globalThis.fetch = original; }
+});