Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

/.test-dist/
/test-results/
/playwright-report/
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
14 changes: 14 additions & 0 deletions docs/mainnet-readiness.md
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 71 additions & 0 deletions docs/payment-proofs.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions examples/verify-proof.mjs
Original file line number Diff line number Diff line change
@@ -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 <app-url> <transaction-hash> <sender-address> [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;
}
}
46 changes: 46 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -23,6 +24,7 @@
"viem": "^2.55.2"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
Expand Down
11 changes: 11 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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: "" } },
],
});
4 changes: 3 additions & 1 deletion src/app/api/arc-network/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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(),
Expand Down
18 changes: 16 additions & 2 deletions src/app/api/circle/payments/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { USDC_INTERFACE } from "@/lib/payment-proof";
import { createHash } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import {
Expand Down Expand Up @@ -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 });
Expand All @@ -61,16 +67,24 @@ 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 });
}

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)) {
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading