From 0b799df62166b89ae9ca07ecb18916e7a8b83d5b Mon Sep 17 00:00:00 2001 From: horn111 Date: Wed, 1 Jul 2026 20:36:22 +0300 Subject: [PATCH] Add Arc Testnet proof mode --- README.md | 6 +- ROADMAP.md | 2 + apps/demo/README.md | 13 +- apps/demo/src/app/api/receipts/proof/route.ts | 97 +++++++ apps/demo/src/app/page.tsx | 234 +++++++++++++++- docs/architecture.md | 7 +- docs/demo-script.md | 39 ++- docs/getting-started.md | 9 +- docs/grant.md | 7 +- docs/onchain-proof.md | 92 +++++++ docs/receipts.md | 29 +- packages/sdk/README.md | 15 +- packages/sdk/src/index.ts | 4 + packages/sdk/src/receipts/index.ts | 8 + packages/sdk/src/receipts/invoice.ts | 1 + packages/sdk/src/receipts/proof.test.ts | 229 ++++++++++++++++ packages/sdk/src/receipts/proof.ts | 256 ++++++++++++++++++ packages/sdk/src/receipts/types.ts | 21 ++ packages/sdk/src/receipts/watcher.test.ts | 56 +++- packages/sdk/src/receipts/watcher.ts | 11 +- 20 files changed, 1114 insertions(+), 22 deletions(-) create mode 100644 apps/demo/src/app/api/receipts/proof/route.ts create mode 100644 docs/onchain-proof.md create mode 100644 packages/sdk/src/receipts/proof.test.ts create mode 100644 packages/sdk/src/receipts/proof.ts diff --git a/README.md b/README.md index f5ba623..f66e12f 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,9 @@ This is not a hosted dashboard or production queue yet. It is a developer-facing | CLI scaffolder | Ready in repo | `packages/create-arc-nano-kit` generates Express or Next.js paid API starters. | | Arc Receipts | Ready | Invoices, memos, receipts, signed webhook events, and in-memory ledger. | | Arc Testnet watcher | Ready | Watches memo-wrapped USDC payments and records matching receipts locally. | +| Arc Testnet proof mode | Ready | Verifies a pasted tx hash against a memo payment request and returns block/log proof. | | Webhook Inbox + Replay | Ready | Verifies signed webhook delivery attempts and replays events locally. | -| Demo app | Ready locally | Next.js demo with paid endpoints, watcher flow, inbox verification, and replay. | +| Demo app | Ready locally | Next.js demo with paid endpoints, watcher flow, onchain proof, inbox verification, and replay. | | Persistent receipt store | Planned | SQLite/Postgres adapter is a next-step production feature. | | Hosted dashboard | Planned | Analytics UI and managed ops surface are not part of the current MVP. | | Fastify/Hono/Python/Go adapters | Planned | Current framework adapters are Express and Next.js. | @@ -116,6 +117,7 @@ sequenceDiagram App->>Arc: send USDC with memo SDK->>Arc: watcher polls Memo events Arc-->>SDK: matching memo + transfer + SDK->>Arc: verify tx receipt proof SDK->>SDK: generate receipt SDK->>SDK: sign invoice.paid webhook SDK->>Inbox: deliver signed payload @@ -129,6 +131,7 @@ sequenceDiagram The demo does not stop at "webhook ready". It shows verified delivery: - `receipt.generated` +- optional `onchainProof` with tx hash, block, log index, and Arcscan link - raw webhook payload - `x-arc-signature` - SDK verification @@ -349,6 +352,7 @@ import { createInvoiceMemo, createMemoPaymentRequest, signWebhookEvent, + verifyMemoPaymentProof, verifyWebhookSignature, } from '@arc-nano-kit/sdk/receipts'; ``` diff --git a/ROADMAP.md b/ROADMAP.md index 82c690a..1823e2a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,6 +31,7 @@ - [x] In-memory receipt ledger - [x] HMAC-signed webhook events - [x] Arc Testnet watcher for memo-wrapped USDC payments +- [x] Read-only Arc Testnet tx proof for memo payment requests - [x] Local webhook inbox - [x] Webhook replay attempts with fresh signature timestamps @@ -39,6 +40,7 @@ - [x] Local Next.js demo app - [x] Interactive watcher flow in the demo - [x] Webhook Inbox + Replay demo flow +- [x] Optional Onchain Proof demo panel - [x] Repo-local `create-arc-nano-kit` scaffolder - [x] Grant snapshot and demo script docs diff --git a/apps/demo/README.md b/apps/demo/README.md index 1b779eb..5871cf3 100644 --- a/apps/demo/README.md +++ b/apps/demo/README.md @@ -9,6 +9,7 @@ invoice -> transaction memo payment request -> watcher flow -> generated receipt +-> optional Arc Testnet tx proof -> signed webhook -> local inbox verification -> replayed delivery attempt @@ -29,10 +30,12 @@ Open [http://localhost:3000](http://localhost:3000). 1. Click `Run Watcher Flow`. 2. Inspect `Memo Payment Data`. 3. Inspect `Generated Receipt`. -4. Inspect `Webhook Inbox`. -5. Confirm `Received`, `Verified`, and `Signature OK`. -6. Click `Replay Webhook`. -7. Confirm `Delivery attempt #2` appears with a fresh signature timestamp. +4. Inspect `Onchain Proof`. +5. Optionally paste a real Arc Testnet Memo transaction hash and verify it. +6. Inspect `Webhook Inbox`. +7. Confirm `Received`, `Verified`, and `Signature OK`. +8. Click `Replay Webhook`. +9. Confirm `Delivery attempt #2` appears with a fresh signature timestamp. ## Demo Routes @@ -41,6 +44,7 @@ Open [http://localhost:3000](http://localhost:3000). | `GET /api/joke` | Paywalled joke endpoint probe | | `GET /api/weather?city=NYC` | Paywalled weather endpoint probe | | `GET /api/receipts` | Generates the local receipt/watcher demo payload | +| `POST /api/receipts/proof` | Verifies a pasted Arc Testnet tx hash against the memo payment request | | `POST /api/webhook-inbox` | Receives raw signed webhook payload and verifies it | | `POST /api/webhook-inbox/replay` | Replays a webhook event with a fresh signature timestamp | @@ -52,6 +56,7 @@ See [../../docs/demo-script.md](../../docs/demo-script.md) for the full grant-re - The inbox is in-memory. - The receipt ledger is in-memory. +- Onchain proof mode is read-only and never asks for a private key. - The watcher flow is a local developer demo. - Hosted dashboard and persistent storage are planned, not shipped. diff --git a/apps/demo/src/app/api/receipts/proof/route.ts b/apps/demo/src/app/api/receipts/proof/route.ts new file mode 100644 index 0000000..606849a --- /dev/null +++ b/apps/demo/src/app/api/receipts/proof/route.ts @@ -0,0 +1,97 @@ +import { + MemoPaymentProofError, + createReceipt, + stablecoinUnitsToString, + verifyMemoPaymentProof, + type ArcInvoice, + type MemoPaymentRequest, +} from '@arc-nano-kit/sdk/receipts'; + +export const dynamic = 'force-dynamic'; + +interface ProofRequest { + txHash?: string; + invoice?: ArcInvoice; + paymentRequest?: MemoPaymentRequest; +} + +export async function POST(request: Request) { + const body = (await request.json()) as ProofRequest; + + if (!body.txHash || !isTxHash(body.txHash)) { + return Response.json( + { error: 'Missing or invalid Arc Testnet tx hash', reason: 'invalid_tx_hash' }, + { status: 400 }, + ); + } + + if (!body.invoice || !body.paymentRequest) { + return Response.json( + { error: 'Missing invoice or payment request', reason: 'missing_payment_request' }, + { status: 400 }, + ); + } + + try { + const proof = await verifyMemoPaymentProof({ + txHash: body.txHash, + paymentRequest: body.paymentRequest, + }); + const receipt = createReceipt(body.invoice, { + txHash: proof.txHash, + from: proof.payer, + to: proof.payTo, + amount: stablecoinUnitsToString(BigInt(proof.amountUnits)), + currency: body.invoice.currency, + network: proof.network, + memo: body.invoice.memo, + memoId: proof.memoId, + callDataHash: proof.callDataHash, + blockNumber: proof.blockNumber, + onchainProof: proof, + metadata: { + source: 'arc-testnet-proof', + memoContract: proof.memoContract, + memoIndex: proof.memoIndex, + explorerUrl: proof.explorerUrl, + }, + }); + + return Response.json(toJsonSafe({ proof, receipt })); + } catch (error) { + if (error instanceof MemoPaymentProofError) { + return Response.json( + { error: error.message, reason: error.reason }, + { status: 422 }, + ); + } + + const message = error instanceof Error ? error.message : 'Unknown proof verification error'; + return Response.json( + { error: message, reason: 'proof_verification_failed' }, + { status: 500 }, + ); + } +} + +function isTxHash(value: string): value is `0x${string}` { + return /^0x[0-9a-fA-F]{64}$/.test(value); +} + +function toJsonSafe(value: unknown): unknown { + if (typeof value === 'bigint') { + return value.toString(); + } + + if (Array.isArray(value)) { + return value.map((item) => toJsonSafe(item)); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [key, toJsonSafe(nested)]), + ); + } + + return value; +} diff --git a/apps/demo/src/app/page.tsx b/apps/demo/src/app/page.tsx index 272d69c..52113b7 100644 --- a/apps/demo/src/app/page.tsx +++ b/apps/demo/src/app/page.tsx @@ -65,6 +65,7 @@ type ReceiptDemo = { txHash?: string; memo: string; createdAt: number; + onchainProof?: ArcReceiptOnchainProof; metadata?: Record; }; webhook: { @@ -77,6 +78,30 @@ type ReceiptDemo = { timeline: TimelineItem[]; }; +type ArcReceiptOnchainProof = { + chainId: number; + network: string; + txHash: string; + blockNumber: string; + transactionIndex?: number; + logIndex?: number; + memoContract: string; + memoIndex?: string; + memoId: string; + callDataHash: string; + payer: string; + payTo: string; + target: string; + amountUnits: string; + explorerUrl: string; + verifiedAt: number; +}; + +type OnchainProofResult = { + proof: ArcReceiptOnchainProof; + receipt: ReceiptDemo['receipt']; +}; + type WebhookDemoEvent = { id: string; type: string; @@ -153,6 +178,10 @@ export default function HomePage() { const [webhookDeliveries, setWebhookDeliveries] = useState([]); const [webhookInboxError, setWebhookInboxError] = useState(null); const [replayLoading, setReplayLoading] = useState(false); + const [proofTxHash, setProofTxHash] = useState(''); + const [onchainProof, setOnchainProof] = useState(null); + const [proofLoading, setProofLoading] = useState(false); + const [proofError, setProofError] = useState(null); const terminalState = receiptLoading ? 'Watching flow' @@ -188,6 +217,11 @@ export default function HomePage() { detail: 'fresh signature timestamp', done: webhookDeliveries.some((delivery) => delivery.attempt >= 2), }, + { + label: 'Arc Testnet proof', + detail: 'optional tx/log/explorer proof', + done: Boolean(onchainProof?.proof), + }, ]; const runReceiptDemo = async () => { @@ -199,6 +233,9 @@ export default function HomePage() { setSelectedBlock('invoice'); setWebhookDeliveries([]); setWebhookInboxError(null); + setProofTxHash(''); + setOnchainProof(null); + setProofError(null); try { const response = await fetch('/api/receipts', { cache: 'no-store' }); @@ -331,6 +368,45 @@ export default function HomePage() { } }; + const verifyOnchainProof = async () => { + if (!receiptDemo) { + return; + } + + setProofLoading(true); + setProofError(null); + setOnchainProof(null); + + try { + const response = await fetch('/api/receipts/proof', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + txHash: proofTxHash.trim(), + invoice: receiptDemo.invoice, + paymentRequest: receiptDemo.paymentRequest, + }), + }); + const result = (await response.json()) as { + proof?: ArcReceiptOnchainProof; + receipt?: ReceiptDemo['receipt']; + error?: string; + reason?: string; + }; + + if (!response.ok || !result.proof || !result.receipt) { + throw new Error(result.reason ? `${result.reason}: ${result.error}` : result.error ?? 'Proof failed'); + } + + setOnchainProof({ proof: result.proof, receipt: result.receipt }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown proof verification error'; + setProofError(message); + } finally { + setProofLoading(false); + } + }; + return (
@@ -520,6 +596,68 @@ export default function HomePage() {
+
+
+
+

Arc Testnet

+

Onchain Proof

+
+
+ +
+ +
+ setProofTxHash(event.target.value)} + placeholder="0x..." + spellCheck={false} + /> + +
+

+ Read-only proof. Paste a Memo-wrapped Arc Testnet USDC tx for the generated payment request. +

+
+ +
+
+ Chain ID + {onchainProof?.proof.chainId ?? 'pending'} +
+
+ Block + {onchainProof?.proof.blockNumber ?? 'pending'} +
+
+ Memo Index + {onchainProof?.proof.memoIndex ?? 'pending'} +
+
+ Log Index + {onchainProof?.proof.logIndex ?? 'pending'} +
+
+ + {proofError ?

{proofError}

: null} + {onchainProof ? ( + + View on Arcscan + + ) : null} + +
+
@@ -875,7 +1013,7 @@ export default function HomePage() { .proof-grid { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } @@ -1427,6 +1565,73 @@ export default function HomePage() { margin-bottom: 16px; } + .proof-verifier { + margin-bottom: 16px; + } + + .proof-input-label { + display: block; + margin-bottom: 8px; + color: var(--text-muted); + font-family: JetBrains Mono, Geist Mono, SFMono-Regular, Consolas, monospace; + font-size: 11px; + text-transform: uppercase; + } + + .proof-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: stretch; + } + + .proof-input { + min-width: 0; + min-height: 38px; + padding: 0 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-canvas); + color: var(--text-main); + font-family: JetBrains Mono, Geist Mono, SFMono-Regular, Consolas, monospace; + font-size: 13px; + outline: none; + } + + .proof-input:focus { + border-color: rgba(138, 180, 248, 0.54); + } + + .proof-button { + white-space: nowrap; + } + + .proof-note { + margin-top: 9px; + color: var(--text-muted); + font-size: 12px; + line-height: 1.45; + } + + .proof-status-grid { + margin-bottom: 16px; + } + + .proof-link { + display: inline-flex; + align-items: center; + min-height: 34px; + margin-bottom: 16px; + padding: 0 12px; + border: 1px solid rgba(138, 180, 248, 0.32); + border-radius: 8px; + background: var(--pale-blue); + color: var(--pale-blue-text); + font-size: 13px; + font-weight: 600; + text-decoration: none; + } + .delivery-attempt { display: grid; gap: 8px; @@ -1669,6 +1874,10 @@ export default function HomePage() { grid-template-columns: 1fr; } + .proof-input-row { + grid-template-columns: 1fr; + } + .btn-endpoint { align-items: flex-start; flex-direction: column; @@ -1723,6 +1932,25 @@ function getReceiptPayload(data: ReceiptDemo | null) { txHash: data.receipt.txHash, payer: data.receipt.payer, memo: data.receipt.memo, + onchainProof: data.receipt.onchainProof ?? null, + }; +} + +function getOnchainProofPayload(result: OnchainProofResult | null) { + if (!result) { + return { + status: 'pending', + proof: 'paste an Arc Testnet tx hash for this payment request', + }; + } + + return { + proof: result.proof, + receipt: { + id: result.receipt.id, + txHash: result.receipt.txHash, + blockNumber: result.receipt.onchainProof?.blockNumber, + }, }; } @@ -1794,6 +2022,10 @@ function formatSignatureTimestamp(header: string) { return timestamp ?? 't=pending'; } +function isTxHash(value: string) { + return /^0x[0-9a-fA-F]{64}$/.test(value); +} + function delay(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); diff --git a/docs/architecture.md b/docs/architecture.md index acd3bc5..694d3e7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,6 +26,7 @@ Developer app | - transaction memos | - receipt matching | - Arc Testnet watcher + | - read-only Arc Testnet tx proof | - signed webhook events | - local webhook inbox | - replayable delivery attempts @@ -58,6 +59,7 @@ sequenceDiagram Buyer->>Arc: send USDC with memo SDK->>Arc: watcher polls memo events Arc-->>SDK: matching memo + transfer + SDK->>Arc: verify tx receipt proof SDK->>SDK: generate receipt SDK->>SDK: sign invoice.paid webhook SDK->>Inbox: deliver signed payload @@ -108,6 +110,7 @@ Receipts are the strongest current module. They cover: - memo construction; - memo payment request data; - matching observed payments to invoices; +- read-only Arc Testnet tx proof; - local receipt generation; - signed webhook events; - local webhook inbox verification; @@ -115,13 +118,14 @@ Receipts are the strongest current module. They cover: ### Watcher -`ArcReceiptWatcher` is local-first and polling-based. It watches Arc Testnet memo-wrapped USDC payment shape, matches observed payments to invoices, and records receipts in the local ledger. +`ArcReceiptWatcher` is local-first and polling-based. It watches Arc Testnet memo-wrapped USDC payment shape, matches observed payments to invoices, attaches onchain proof data, and records receipts in the local ledger. Current limits: - no persistent watcher cursor; - no hosted indexer; - no database-backed receipt store; +- no transaction broadcasting in proof mode; - no refund state in the current watcher flow. ### Gateway / Balance Helpers @@ -136,6 +140,7 @@ The local Next.js demo contains: - `/api/joke` and `/api/weather` paywalled endpoint probes; - `/api/receipts` for the receipt and watcher demo payload; +- `/api/receipts/proof` for read-only Arc Testnet tx proof verification; - `/api/webhook-inbox` for raw signed webhook delivery verification; - `/api/webhook-inbox/replay` for replaying the same webhook event with a fresh signature timestamp; - `page.tsx` for the interactive local payment ops walkthrough. diff --git a/docs/demo-script.md b/docs/demo-script.md index a7882d1..efda1be 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -28,18 +28,47 @@ Open [http://localhost:3000](http://localhost:3000). - Expected fields: invoice id, amount, memo contract, memo id, call data hash. 5. Inspect `Generated Receipt`. - Expected proof: paid receipt JSON with tx hash, payer, and invoice memo. -6. Inspect `Webhook Inbox`. +6. Inspect `Onchain Proof`. + - Expected status before a live tx hash: pending. + - Expected fields: transaction hash input, chain id, block, memo index, log index, and proof JSON. +7. Inspect `Webhook Inbox`. - Expected status: `Received` is `yes`. - Expected status: `Verified` is `yes`. - Expected status: `Signature` is `OK`. - Expected event: `Delivery attempt #1`. -7. Click `Replay Webhook`. -8. Confirm the inbox now shows: +8. Click `Replay Webhook`. +9. Confirm the inbox now shows: - `Attempts` is `2`; - `Delivery attempt #2`; - `Signature OK`; - a fresh `t=` in the signature header. +## Optional Arc Testnet Proof + +This step is optional and read-only from the demo. The demo never asks for a private key and never broadcasts a transaction. + +1. Run the local demo. +2. Click `Run Watcher Flow`. +3. Inspect `Memo Payment Data`. +4. From your own funded Arc Testnet EOA, send USDC through the Arc Memo contract using the generated: + - memo contract; + - target; + - memo id; + - memo data; + - tx data. +5. Copy the resulting Arc Testnet transaction hash. +6. Paste it into `Onchain Proof`. +7. Click `Verify Arc Testnet Tx`. + +Expected proof points: + +- `Chain ID` is `5042002`; +- `Block` is populated; +- `Memo Index` is populated; +- `Log Index` is populated; +- `View on Arcscan` opens the verified transaction; +- proof JSON includes `txHash`, `memoId`, `callDataHash`, `payer`, `payTo`, and `explorerUrl`. + ## What The Demo Proves The shipped flow is: @@ -49,6 +78,7 @@ create invoice build memo payment request watch Arc Testnet payment shape generate receipt +optionally verify a real Arc Testnet tx proof sign invoice.paid webhook deliver signed payload verify signature locally @@ -58,6 +88,8 @@ replay webhook with fresh timestamp The important proof is not only that a receipt object exists. The demo proves verified webhook delivery and replay, which is the operational layer apps need after payment. +The optional onchain proof path connects the same receipt shape to a concrete Arc Testnet transaction, block, Memo event, and Arcscan link. + ## API Fallback Check If the browser UI is unavailable, run the flow through local API routes after `npm run dev`. @@ -121,5 +153,6 @@ replayOf: ## Notes For Grant Reviewers - The inbox is intentionally in-memory for the current milestone. +- Onchain proof mode is read-only and does not send transactions. - Persistence, watcher cursors, webhook route helpers, and refund states are planned grant milestones. - The current demo is designed to prove the local developer workflow, not to replace a production payment processor or hosted dashboard. diff --git a/docs/getting-started.md b/docs/getting-started.md index 456fa87..f8563a4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -95,6 +95,7 @@ The demo currently shows: - invoice and memo payment data; - Arc Testnet watcher flow; - generated receipt JSON; +- optional read-only Arc Testnet tx proof; - signed webhook payload; - local Webhook Inbox verification; - replayed webhook delivery attempt. @@ -105,9 +106,10 @@ In the browser: 1. Click `Run Watcher Flow`. 2. Confirm `Generated Receipt` shows a paid receipt payload. -3. Confirm `Webhook Inbox` shows `Received`, `Verified`, and `Signature OK`. -4. Click `Replay Webhook`. -5. Confirm `Delivery attempt #2` appears with a fresh `t=` signature value. +3. Optionally paste a real Arc Testnet Memo transaction hash into `Onchain Proof`. +4. Confirm `Webhook Inbox` shows `Received`, `Verified`, and `Signature OK`. +5. Click `Replay Webhook`. +6. Confirm `Delivery attempt #2` appears with a fresh `t=` signature value. For a detailed reviewer script, see [demo-script.md](demo-script.md). @@ -127,5 +129,6 @@ The scaffolder creates an Express or Next.js starter with a paid API route and e - [Grant Snapshot](grant.md) - [Demo Script](demo-script.md) - [Architecture](architecture.md) +- [Onchain Proof](onchain-proof.md) - [Arc Receipts](receipts.md) - [Why Arc?](why-arc.md) diff --git a/docs/grant.md b/docs/grant.md index 9d43c86..8551992 100644 --- a/docs/grant.md +++ b/docs/grant.md @@ -8,7 +8,7 @@ It helps developers move beyond "a payment happened" and into application-level ## One-Liner -Open-source payment operations toolkit for Arc builders: paid APIs, invoices, memo-based receipts, watcher flows, signed webhooks, and local delivery replay. +Open-source payment operations toolkit for Arc builders: paid APIs, invoices, memo-based receipts, watcher flows, optional Arc Testnet tx proof, signed webhooks, and local delivery replay. ## Problem @@ -36,6 +36,7 @@ The current repo includes: - Billing helpers for per-request, per-second, and per-job pricing. - `ReceiptLedger` for in-memory invoices, receipts, and webhook events. - `ArcReceiptWatcher` for memo-wrapped Arc Testnet USDC payments. +- `verifyMemoPaymentProof` for read-only Arc Testnet tx/log proof against a memo payment request. - `WebhookInbox` for local signed webhook verification and replay. - `create-arc-nano-kit` scaffolder for Express or Next.js paid API starters. - A local Next.js demo that shows the payment ops flow end to end. @@ -49,6 +50,7 @@ create invoice build Arc transaction memo payment request watch Arc Testnet memo-wrapped USDC payment generate receipt +optionally verify the receipt against a real Arc Testnet tx create signed invoice.paid webhook deliver webhook into local inbox verify SDK signature @@ -58,6 +60,7 @@ replay webhook with a fresh timestamp The demo proves the chain does not stop at "webhook ready". It shows verified delivery attempts: - `receipt.generated` +- optional `onchainProof` with tx hash, block, memo index, log index, and Arcscan link - raw webhook payload - `x-arc-signature` - `Signature OK` @@ -87,6 +90,7 @@ Current limits are explicit: - receipt storage is in-memory; - watcher cursors are not persisted; - webhook delivery attempts are local/in-memory; +- onchain proof mode is read-only and does not broadcast transactions; - default middleware verification is not a production Gateway verification path unless the app provides a custom verifier; - hosted dashboard and analytics are planned, not shipped; - Fastify, Hono, Python, and Go adapters are planned, not shipped; @@ -118,6 +122,7 @@ Use [demo-script.md](demo-script.md) to reproduce the local demo flow. Expected proof points: - `Run Watcher Flow` produces invoice, memo, watcher, receipt, and webhook data. +- Optional `Onchain Proof` verifies a real Arc Testnet transaction hash against the generated memo payment request. - `Webhook Inbox` shows `Received`, `Verified`, and `Signature OK`. - `Replay Webhook` creates `Delivery attempt #2`. - The replayed attempt has a fresh signature timestamp. diff --git a/docs/onchain-proof.md b/docs/onchain-proof.md new file mode 100644 index 0000000..fa5472f --- /dev/null +++ b/docs/onchain-proof.md @@ -0,0 +1,92 @@ +# Arc Testnet Onchain Proof + +Arc Testnet Proof Mode adds a read-only verification step to Arc Receipts. + +It lets a builder paste an Arc Testnet transaction hash and prove that a receipt is tied to a real Memo-wrapped USDC payment: + +```text +tx hash +-> Arc Testnet receipt +-> Memo event +-> USDC Transfer +-> receipt onchainProof +-> Arcscan link +``` + +## What It Verifies + +`verifyMemoPaymentProof()` checks the transaction receipt against a `MemoPaymentRequest`: + +- the transaction exists on Arc Testnet; +- the transaction succeeded; +- the expected Arc Memo contract emitted a `Memo` event; +- `memoId` matches the invoice; +- `target` is the Arc USDC ERC-20 interface; +- `callDataHash` matches the generated transfer calldata; +- a USDC `Transfer` exists from the memo sender; +- recipient and amount match the invoice payment request. + +The returned proof includes: + +- `chainId`; +- `txHash`; +- `blockNumber`; +- `transactionIndex`; +- `logIndex`; +- `memoContract`; +- `memoIndex`; +- `memoId`; +- `callDataHash`; +- `payer`; +- `payTo`; +- `target`; +- `amountUnits`; +- `explorerUrl`. + +## SDK Usage + +```ts +import { + createInvoice, + createMemoPaymentRequest, + verifyMemoPaymentProof, +} from '@arc-nano-kit/sdk/receipts'; + +const invoice = createInvoice({ + id: 'inv_arc_demo', + amount: '19.00', + payTo: '0x1111111111111111111111111111111111111111', +}); + +const paymentRequest = createMemoPaymentRequest(invoice); + +const proof = await verifyMemoPaymentProof({ + txHash: '0x...', + paymentRequest, +}); + +console.log(proof.explorerUrl); +``` + +## Demo Flow + +The demo does not send transactions or ask for a private key. + +To use the optional live proof path: + +1. Run the local demo. +2. Click `Run Watcher Flow`. +3. Copy the generated memo payment request values. +4. From a funded Arc Testnet EOA, send USDC through Arc `Memo.memo(...)`. +5. Paste the resulting transaction hash into `Onchain Proof`. +6. Click `Verify Arc Testnet Tx`. + +On success, the demo shows block/log proof and a `View on Arcscan` link. + +## Current Limits + +- Proof mode is read-only. +- It does not broadcast transactions. +- It does not store private keys. +- It does not persist proofs in a database. +- It does not replace a hosted indexer or production receipt store. diff --git a/docs/receipts.md b/docs/receipts.md index 681d958..f2bcd30 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -94,10 +94,34 @@ watcher.watchInvoice(invoice); watcher.start(); ``` -The watcher polls Arc Testnet `Memo` events, fetches the transaction receipt, verifies the paired ERC-20 USDC `Transfer`, then writes a receipt and emits signed-webhook-ready ledger events. +The watcher polls Arc Testnet `Memo` events, fetches the transaction receipt, verifies the paired ERC-20 USDC `Transfer`, writes an `onchainProof` onto the receipt, then emits signed-webhook-ready ledger events. It intentionally watches the ERC-20 USDC interface at `0x3600000000000000000000000000000000000000` and ignores the native USDC system event emitter at `0xfffffffffffffffffffffffffffffffffffffffe` to avoid double-counting the same ERC-20 transfer. +## Arc Testnet Proof + +Use `verifyMemoPaymentProof()` when you already have a transaction hash and want to prove it matches a generated memo payment request. + +```typescript +import { + createMemoPaymentRequest, + verifyMemoPaymentProof, +} from '@arc-nano-kit/sdk/receipts'; + +const paymentRequest = createMemoPaymentRequest(invoice); + +const proof = await verifyMemoPaymentProof({ + txHash: '0x...' as `0x${string}`, + paymentRequest, +}); + +console.log(proof.blockNumber); +console.log(proof.memoIndex); +console.log(proof.explorerUrl); +``` + +The proof path is read-only. It does not send a transaction, store a private key, or replace a production indexer. + ## Webhook Inbox and Replay Use `WebhookInbox` when you want a local app to receive, verify, store, and replay Arc Receipts webhook deliveries. @@ -137,6 +161,7 @@ The inbox is intentionally in-memory for local payment-ops workflows. A producti - `createInvoice()` for invoice ids, stablecoin minor units, Arc payment URIs, memo ids, and invoice memos. - `createMemoPaymentRequest()` for Arc `Memo.memo(...)` call data around an ERC-20 USDC transfer. - `ArcReceiptWatcher` for polling Arc Testnet memo events and creating receipts from matching payments. +- `verifyMemoPaymentProof()` for read-only tx/log proof against a memo payment request. - `createInvoiceMemo()`, `createInvoiceMemoId()`, `createInvoiceMemoData()`, and `parseInvoiceMemo()` for memo correlation. - `matchPaymentToInvoice()` to validate amount, recipient, currency, network, memo, memo id, and expiry. - `ReceiptLedger` for an in-memory invoice/receipt/event store with duplicate tx protection. @@ -145,7 +170,7 @@ The inbox is intentionally in-memory for local payment-ops workflows. A producti ## Current Limits -The watcher is intentionally local-first and polling-based. It does not yet persist scan cursors, run a hosted indexer, use Circle event monitors, watch Gateway settlement, or store receipts in a database. +The watcher is intentionally local-first and polling-based. Proof mode is read-only. The module does not yet persist scan cursors, run a hosted indexer, use Circle event monitors, watch Gateway settlement, or store receipts in a database. ## Planned Next Steps diff --git a/packages/sdk/README.md b/packages/sdk/README.md index f94ffed..b14bc62 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -2,7 +2,7 @@ Core SDK package for `arc-nano-kit`. -It includes paid API middleware, buyer SDK helpers, billing helpers, Arc Receipts, watcher logic, signed webhooks, and local webhook inbox replay for Arc payment workflows. +It includes paid API middleware, buyer SDK helpers, billing helpers, Arc Receipts, watcher logic, read-only Arc Testnet proof verification, signed webhooks, and local webhook inbox replay for Arc payment workflows. ## Installation @@ -53,8 +53,10 @@ console.log(response.data); import { ReceiptLedger, WebhookInbox, + createMemoPaymentRequest, serializeWebhookPayload, signWebhookEvent, + verifyMemoPaymentProof, } from '@arc-nano-kit/sdk/receipts'; const ledger = new ReceiptLedger(); @@ -73,6 +75,12 @@ const receipt = ledger.recordPayment(invoice.id, { txHash: '0xabc' as `0x${string}`, }); +const paymentRequest = createMemoPaymentRequest(invoice); +const proof = await verifyMemoPaymentProof({ + txHash: '0x...' as `0x${string}`, + paymentRequest, +}); + const event = ledger.listWebhookEvents().at(-1)!; const signed = signWebhookEvent(event, 'secret'); @@ -90,6 +98,7 @@ const replay = inbox.replay({ }); console.log(receipt.status); // paid +console.log(proof.explorerUrl); console.log(delivery.status); // verified console.log(replay.attempt); // 2 ``` @@ -102,11 +111,12 @@ console.log(replay.attempt); // 2 | Client | `@arc-nano-kit/sdk/client` | Buyer SDK for `402 -> sign -> retry` flows | | Billing | `@arc-nano-kit/sdk/billing` | Usage metering and billing plans | | Gateway | `@arc-nano-kit/sdk/gateway` | Small Arc Testnet balance helper | -| Receipts | `@arc-nano-kit/sdk/receipts` | Invoices, memos, watcher, receipts, signed webhooks, inbox replay | +| Receipts | `@arc-nano-kit/sdk/receipts` | Invoices, memos, watcher, onchain proof, receipts, signed webhooks, inbox replay | ## Current Limits - Receipt storage is in-memory. +- Onchain proof mode is read-only and does not send transactions. - Webhook delivery attempts are in-memory. - Watcher cursors are not persisted yet. - Gateway helpers do not yet include deposit tracking or pending settlement state. @@ -116,6 +126,7 @@ console.log(replay.attempt); // 2 - [Root README](../../README.md) - [Grant Snapshot](../../docs/grant.md) - [Demo Script](../../docs/demo-script.md) +- [Onchain Proof](../../docs/onchain-proof.md) - [Arc Receipts](../../docs/receipts.md) ## License diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 388658a..dbf7d3c 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -39,13 +39,17 @@ export { createMemoPaymentRequest, createReceipt, signWebhookEvent, + verifyMemoPaymentProof, verifyWebhookSignature, type ArcInvoice, type ArcReceipt, + type ArcReceiptOnchainProof, type ArcReceiptWatcherConfig, type CreateInvoiceInput, type MemoPaymentRequest, type ObservedPayment, + type VerifyMemoPaymentProofInput, + type VerifyMemoPaymentProofResult, type WebhookDeliveryAttempt, type WebhookDeliveryStatus, type WebhookEvent, diff --git a/packages/sdk/src/receipts/index.ts b/packages/sdk/src/receipts/index.ts index 7489195..d62db10 100644 --- a/packages/sdk/src/receipts/index.ts +++ b/packages/sdk/src/receipts/index.ts @@ -31,6 +31,13 @@ export { type ArcReceiptWatcherConfig, type ArcReceiptWatcherLifecycleEvent, } from './watcher.js'; +export { + MemoPaymentProofError, + verifyMemoPaymentProof, + type VerifyMemoPaymentProofFailureReason, + type VerifyMemoPaymentProofInput, + type VerifyMemoPaymentProofResult, +} from './proof.js'; export { createWebhookEvent, serializeWebhookPayload, @@ -47,6 +54,7 @@ export { export type { ArcInvoice, ArcReceipt, + ArcReceiptOnchainProof, CreateInvoiceInput, InvoiceStatus, MemoPaymentRequest, diff --git a/packages/sdk/src/receipts/invoice.ts b/packages/sdk/src/receipts/invoice.ts index ad1274e..8f43b33 100644 --- a/packages/sdk/src/receipts/invoice.ts +++ b/packages/sdk/src/receipts/invoice.ts @@ -141,6 +141,7 @@ export function createReceipt( txHash: payment.txHash, createdAt, blockNumber: payment.blockNumber, + onchainProof: payment.onchainProof, metadata: { ...invoice.metadata, ...payment.metadata, diff --git a/packages/sdk/src/receipts/proof.test.ts b/packages/sdk/src/receipts/proof.test.ts new file mode 100644 index 0000000..57675f9 --- /dev/null +++ b/packages/sdk/src/receipts/proof.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + encodeAbiParameters, + encodeEventTopics, + parseAbiItem, +} from 'viem'; +import { ARC_TESTNET, ARC_TESTNET_CONTRACTS } from '../constants.js'; +import { createInvoice } from './invoice.js'; +import { ARC_MEMO_ABI, createMemoPaymentRequest } from './memo-payment.js'; +import { + MemoPaymentProofError, + verifyMemoPaymentProof, + type VerifyMemoPaymentProofFailureReason, +} from './proof.js'; + +const seller = '0x1111111111111111111111111111111111111111' as const; +const buyer = '0x2222222222222222222222222222222222222222' as const; +const other = '0x3333333333333333333333333333333333333333' as const; +const txHash = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const; +const blockHash = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as const; +const transferEvent = parseAbiItem('event Transfer(address indexed from,address indexed to,uint256 value)'); +const memoEvent = ARC_MEMO_ABI.find((item) => item.type === 'event' && item.name === 'Memo'); + +describe('verifyMemoPaymentProof', () => { + it('returns an onchain proof for a valid Arc Memo USDC payment tx', async () => { + const invoice = createInvoice({ id: 'inv_proof', amount: '19.00', payTo: seller }); + const request = createMemoPaymentRequest(invoice); + const client = createMockClient({ + logs: [ + transferLog({ to: seller, value: 19_000_000n }), + memoReceiptLog(request), + ], + }); + + const proof = await verifyMemoPaymentProof({ + txHash, + paymentRequest: request, + publicClient: client, + verifiedAt: 1_700_000_000_000, + }); + + expect(proof).toMatchObject({ + chainId: ARC_TESTNET.chainId, + network: 'arc-testnet', + txHash, + blockNumber: 10n, + transactionIndex: 0, + logIndex: 1, + memoContract: request.memoContract, + memoIndex: '42', + memoId: request.memoId, + callDataHash: request.callDataHash, + payer: buyer, + payTo: seller, + target: request.target, + amountUnits: '19000000', + explorerUrl: `${ARC_TESTNET.explorerUrl}/tx/${txHash}`, + verifiedAt: 1_700_000_000_000, + }); + }); + + it('fails when the tx reverted', async () => { + const invoice = createInvoice({ id: 'inv_reverted', amount: '19.00', payTo: seller }); + const request = createMemoPaymentRequest(invoice); + const client = createMockClient({ + status: 'reverted', + logs: [memoReceiptLog(request), transferLog({ to: seller, value: 19_000_000n })], + }); + + await expectProofFailure( + verifyMemoPaymentProof({ txHash, paymentRequest: request, publicClient: client }), + 'tx_reverted', + ); + }); + + it('fails when the Memo event has the wrong memo id', async () => { + const invoice = createInvoice({ id: 'inv_wrong_memo', amount: '19.00', payTo: seller }); + const request = createMemoPaymentRequest(invoice); + const client = createMockClient({ + logs: [ + memoReceiptLog({ + ...request, + memoId: '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + }), + transferLog({ to: seller, value: 19_000_000n }), + ], + }); + + await expectProofFailure( + verifyMemoPaymentProof({ txHash, paymentRequest: request, publicClient: client }), + 'memo_id_mismatch', + ); + }); + + it('fails when the Memo event has the wrong calldata hash', async () => { + const invoice = createInvoice({ id: 'inv_wrong_hash', amount: '19.00', payTo: seller }); + const request = createMemoPaymentRequest(invoice); + const client = createMockClient({ + logs: [ + memoReceiptLog({ + ...request, + callDataHash: '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + }), + transferLog({ to: seller, value: 19_000_000n }), + ], + }); + + await expectProofFailure( + verifyMemoPaymentProof({ txHash, paymentRequest: request, publicClient: client }), + 'call_data_hash_mismatch', + ); + }); + + it('fails when no USDC transfer is present', async () => { + const invoice = createInvoice({ id: 'inv_missing_transfer', amount: '19.00', payTo: seller }); + const request = createMemoPaymentRequest(invoice); + const client = createMockClient({ logs: [memoReceiptLog(request)] }); + + await expectProofFailure( + verifyMemoPaymentProof({ txHash, paymentRequest: request, publicClient: client }), + 'missing_transfer', + ); + }); + + it('fails when the transfer recipient is wrong', async () => { + const invoice = createInvoice({ id: 'inv_wrong_recipient', amount: '19.00', payTo: seller }); + const request = createMemoPaymentRequest(invoice); + const client = createMockClient({ + logs: [ + memoReceiptLog(request), + transferLog({ to: other, value: 19_000_000n }), + ], + }); + + await expectProofFailure( + verifyMemoPaymentProof({ txHash, paymentRequest: request, publicClient: client }), + 'recipient_mismatch', + ); + }); + + it('fails when the transfer amount is wrong', async () => { + const invoice = createInvoice({ id: 'inv_wrong_amount', amount: '19.00', payTo: seller }); + const request = createMemoPaymentRequest(invoice); + const client = createMockClient({ + logs: [ + memoReceiptLog(request), + transferLog({ to: seller, value: 18_000_000n }), + ], + }); + + await expectProofFailure( + verifyMemoPaymentProof({ txHash, paymentRequest: request, publicClient: client }), + 'amount_mismatch', + ); + }); +}); + +function createMockClient(params: { status?: string; logs: unknown[] }) { + return { + getTransactionReceipt: vi.fn().mockResolvedValue({ + status: params.status ?? 'success', + blockNumber: 10n, + transactionIndex: 0, + logs: params.logs, + }), + } as any; +} + +function memoReceiptLog(request: { + target: `0x${string}`; + memoId: `0x${string}`; + memoData: `0x${string}`; + callDataHash: `0x${string}`; +}) { + if (!memoEvent) { + throw new Error('Memo event ABI missing'); + } + + const topics = encodeEventTopics({ + abi: [memoEvent], + eventName: 'Memo', + args: { sender: buyer, target: request.target, memoId: request.memoId }, + }); + + return { + address: ARC_TESTNET_CONTRACTS.memo, + topics, + data: encodeAbiParameters( + [{ type: 'bytes32' }, { type: 'bytes' }, { type: 'uint256' }], + [request.callDataHash, request.memoData, 42n], + ), + blockNumber: 10n, + blockHash, + transactionHash: txHash, + transactionIndex: 0, + logIndex: 1, + removed: false, + }; +} + +function transferLog(params: { to: `0x${string}`; value: bigint }) { + const topics = encodeEventTopics({ + abi: [transferEvent], + eventName: 'Transfer', + args: { from: buyer, to: params.to }, + }); + + return { + address: ARC_TESTNET_CONTRACTS.usdc, + topics, + data: encodeAbiParameters([{ type: 'uint256' }], [params.value]), + blockNumber: 10n, + blockHash, + transactionHash: txHash, + transactionIndex: 0, + logIndex: 0, + removed: false, + }; +} + +async function expectProofFailure( + promise: Promise, + reason: VerifyMemoPaymentProofFailureReason, +) { + await expect(promise).rejects.toMatchObject({ + name: 'MemoPaymentProofError', + reason, + } satisfies Partial); +} diff --git a/packages/sdk/src/receipts/proof.ts b/packages/sdk/src/receipts/proof.ts new file mode 100644 index 0000000..6a614d5 --- /dev/null +++ b/packages/sdk/src/receipts/proof.ts @@ -0,0 +1,256 @@ +/** + * Read-only Arc Testnet proof verification for Memo-wrapped receipt payments. + */ + +import { + createPublicClient, + getAddress, + http, + parseEventLogs, + type Log, +} from 'viem'; +import { ARC_TESTNET } from '../constants.js'; +import { ARC_MEMO_ABI, ERC20_TRANSFER_ABI } from './memo-payment.js'; +import type { ArcReceiptOnchainProof, MemoPaymentRequest } from './types.js'; + +type ProofClient = { + getTransactionReceipt: (params: { hash: `0x${string}` }) => Promise; +}; + +type ProofTransactionReceipt = { + status: string; + blockNumber: bigint; + transactionIndex?: number; + logs: readonly Log[]; +}; + +export type VerifyMemoPaymentProofFailureReason = + | 'tx_not_found' + | 'tx_reverted' + | 'wrong_memo_contract' + | 'missing_memo_event' + | 'memo_id_mismatch' + | 'target_mismatch' + | 'call_data_hash_mismatch' + | 'missing_transfer' + | 'recipient_mismatch' + | 'amount_mismatch'; + +export class MemoPaymentProofError extends Error { + readonly reason: VerifyMemoPaymentProofFailureReason; + + constructor(reason: VerifyMemoPaymentProofFailureReason, message: string) { + super(message); + this.name = 'MemoPaymentProofError'; + this.reason = reason; + } +} + +export interface VerifyMemoPaymentProofInput { + txHash: `0x${string}`; + paymentRequest: MemoPaymentRequest; + rpcUrl?: string; + publicClient?: ProofClient; + verifiedAt?: number; +} + +export interface VerifyMemoPaymentProofResult { + proof: ArcReceiptOnchainProof; +} + +export async function verifyMemoPaymentProof( + input: VerifyMemoPaymentProofInput, +): Promise { + const client = input.publicClient ?? createPublicClient({ + transport: http(input.rpcUrl ?? ARC_TESTNET.rpcUrl), + }); + + let txReceipt: ProofTransactionReceipt | null; + try { + txReceipt = await client.getTransactionReceipt({ hash: input.txHash }); + } catch { + throw new MemoPaymentProofError( + 'tx_not_found', + `Transaction ${input.txHash} was not found on Arc Testnet`, + ); + } + + if (!txReceipt) { + throw new MemoPaymentProofError( + 'tx_not_found', + `Transaction ${input.txHash} was not found on Arc Testnet`, + ); + } + + return createMemoPaymentProofFromReceipt({ + txHash: input.txHash, + paymentRequest: input.paymentRequest, + txReceipt, + verifiedAt: input.verifiedAt, + }); +} + +export function createMemoPaymentProofFromReceipt(params: { + txHash: `0x${string}`; + paymentRequest: MemoPaymentRequest; + txReceipt: ProofTransactionReceipt; + verifiedAt?: number; +}): ArcReceiptOnchainProof { + const { paymentRequest, txHash, txReceipt } = params; + + if (txReceipt.status !== 'success') { + throw new MemoPaymentProofError( + 'tx_reverted', + `Transaction ${txHash} did not succeed`, + ); + } + + const memoLogs = txReceipt.logs.filter((log) => sameAddress(log.address, paymentRequest.memoContract)); + if (memoLogs.length === 0) { + throw new MemoPaymentProofError( + 'wrong_memo_contract', + `Transaction ${txHash} has no logs from Memo contract ${paymentRequest.memoContract}`, + ); + } + + const parsedMemoLogs = parseEventLogs({ + abi: ARC_MEMO_ABI, + eventName: 'Memo', + logs: memoLogs, + strict: false, + }); + + if (parsedMemoLogs.length === 0) { + throw new MemoPaymentProofError( + 'missing_memo_event', + `Transaction ${txHash} has no Memo event`, + ); + } + + const memoById = parsedMemoLogs.find((log) => { + const args = log.args as { + memoId?: `0x${string}`; + }; + return args.memoId?.toLowerCase() === paymentRequest.memoId.toLowerCase(); + }); + + if (!memoById) { + throw new MemoPaymentProofError( + 'memo_id_mismatch', + `Transaction ${txHash} does not contain memoId ${paymentRequest.memoId}`, + ); + } + + const memoArgs = memoById.args as { + sender?: `0x${string}`; + target?: `0x${string}`; + callDataHash?: `0x${string}`; + memoId?: `0x${string}`; + memoIndex?: bigint; + }; + + if (!memoArgs.sender || !memoArgs.target || !memoArgs.callDataHash || !memoArgs.memoId) { + throw new MemoPaymentProofError( + 'missing_memo_event', + `Transaction ${txHash} has an incomplete Memo event`, + ); + } + + if (!sameAddress(memoArgs.target, paymentRequest.target)) { + throw new MemoPaymentProofError( + 'target_mismatch', + `Memo target ${memoArgs.target} does not match ${paymentRequest.target}`, + ); + } + + if (memoArgs.callDataHash.toLowerCase() !== paymentRequest.callDataHash.toLowerCase()) { + throw new MemoPaymentProofError( + 'call_data_hash_mismatch', + `Memo callDataHash ${memoArgs.callDataHash} does not match ${paymentRequest.callDataHash}`, + ); + } + + const transferLogs = txReceipt.logs.filter((log) => sameAddress(log.address, paymentRequest.target)); + const parsedTransferLogs = parseEventLogs({ + abi: ERC20_TRANSFER_ABI, + eventName: 'Transfer', + logs: transferLogs, + strict: false, + }); + + if (parsedTransferLogs.length === 0) { + throw new MemoPaymentProofError( + 'missing_transfer', + `Transaction ${txHash} has no USDC Transfer event`, + ); + } + + const payerTransfers = parsedTransferLogs.filter((log) => { + const args = log.args as { + from?: `0x${string}`; + }; + return args.from !== undefined && sameAddress(args.from, memoArgs.sender as `0x${string}`); + }); + + if (payerTransfers.length === 0) { + throw new MemoPaymentProofError( + 'missing_transfer', + `Transaction ${txHash} has no USDC Transfer from memo sender ${memoArgs.sender}`, + ); + } + + const recipientTransfers = payerTransfers.filter((log) => { + const args = log.args as { + to?: `0x${string}`; + }; + return args.to !== undefined && sameAddress(args.to, paymentRequest.payTo); + }); + + if (recipientTransfers.length === 0) { + throw new MemoPaymentProofError( + 'recipient_mismatch', + `Transaction ${txHash} has no USDC Transfer to ${paymentRequest.payTo}`, + ); + } + + const amountTransfer = recipientTransfers.find((log) => { + const args = log.args as { + value?: bigint; + }; + return args.value?.toString() === paymentRequest.amountUnits; + }); + + if (!amountTransfer) { + throw new MemoPaymentProofError( + 'amount_mismatch', + `Transaction ${txHash} has no USDC Transfer for ${paymentRequest.amountUnits} units`, + ); + } + + return { + chainId: ARC_TESTNET.chainId, + network: 'arc-testnet', + txHash, + blockNumber: txReceipt.blockNumber, + transactionIndex: txReceipt.transactionIndex ?? numberOrUndefined(memoById.transactionIndex), + logIndex: numberOrUndefined(memoById.logIndex), + memoContract: paymentRequest.memoContract, + memoIndex: memoArgs.memoIndex?.toString(), + memoId: paymentRequest.memoId, + callDataHash: paymentRequest.callDataHash, + payer: getAddress(memoArgs.sender) as `0x${string}`, + payTo: getAddress(paymentRequest.payTo) as `0x${string}`, + target: getAddress(paymentRequest.target) as `0x${string}`, + amountUnits: paymentRequest.amountUnits, + explorerUrl: `${ARC_TESTNET.explorerUrl}/tx/${txHash}`, + verifiedAt: params.verifiedAt ?? Date.now(), + }; +} + +function sameAddress(left: `0x${string}` | string, right: `0x${string}` | string): boolean { + return getAddress(left as `0x${string}`) === getAddress(right as `0x${string}`); +} + +function numberOrUndefined(value: unknown): number | undefined { + return typeof value === 'number' ? value : undefined; +} diff --git a/packages/sdk/src/receipts/types.ts b/packages/sdk/src/receipts/types.ts index 4f0ee1e..c5adf09 100644 --- a/packages/sdk/src/receipts/types.ts +++ b/packages/sdk/src/receipts/types.ts @@ -68,6 +68,7 @@ export interface ObservedPayment { callDataHash?: `0x${string}`; observedAt?: number; blockNumber?: bigint; + onchainProof?: ArcReceiptOnchainProof; metadata?: Record; } @@ -103,9 +104,29 @@ export interface ArcReceipt { txHash?: `0x${string}`; createdAt: number; blockNumber?: bigint; + onchainProof?: ArcReceiptOnchainProof; metadata?: Record; } +export interface ArcReceiptOnchainProof { + chainId: number; + network: string; + txHash: `0x${string}`; + blockNumber: bigint; + transactionIndex?: number; + logIndex?: number; + memoContract: `0x${string}`; + memoIndex?: string; + memoId: `0x${string}`; + callDataHash: `0x${string}`; + payer: `0x${string}`; + payTo: `0x${string}`; + target: `0x${string}`; + amountUnits: string; + explorerUrl: string; + verifiedAt: number; +} + export type WebhookEventType = | 'invoice.created' | 'invoice.observed' diff --git a/packages/sdk/src/receipts/watcher.test.ts b/packages/sdk/src/receipts/watcher.test.ts index a6c5be3..dcdad84 100644 --- a/packages/sdk/src/receipts/watcher.test.ts +++ b/packages/sdk/src/receipts/watcher.test.ts @@ -6,7 +6,7 @@ import { } from 'viem'; import { ARC_TESTNET_CONTRACTS } from '../constants.js'; import { ReceiptLedger } from './ledger.js'; -import { createMemoPaymentRequest } from './memo-payment.js'; +import { ARC_MEMO_ABI, createMemoPaymentRequest } from './memo-payment.js'; import { ArcReceiptWatcher } from './watcher.js'; const seller = '0x1111111111111111111111111111111111111111' as const; @@ -14,6 +14,7 @@ const buyer = '0x2222222222222222222222222222222222222222' as const; const txHash = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const; const blockHash = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as const; const transferEvent = parseAbiItem('event Transfer(address indexed from,address indexed to,uint256 value)'); +const memoEvent = ARC_MEMO_ABI.find((item) => item.type === 'event' && item.name === 'Memo'); describe('ArcReceiptWatcher', () => { it('creates a receipt when it sees matching Memo and ERC-20 Transfer logs', async () => { @@ -34,6 +35,18 @@ describe('ArcReceiptWatcher', () => { expect(receipts[0]?.invoiceId).toBe(invoice.id); expect(receipts[0]?.txHash).toBe(txHash); expect(receipts[0]?.amountUnits).toBe('19000000'); + expect(receipts[0]?.onchainProof).toMatchObject({ + chainId: 5042002, + txHash, + memoContract: request.memoContract, + memoIndex: '1', + memoId: request.memoId, + callDataHash: request.callDataHash, + payer: buyer, + payTo: seller, + target: request.target, + amountUnits: '19000000', + }); expect(ledger.getInvoice(invoice.id)?.status).toBe('paid'); expect(onReceipt).toHaveBeenCalledOnce(); }); @@ -85,7 +98,11 @@ function createMockClient(params: { memoLogs: unknown[]; receiptLogs: unknown[] getTransactionReceipt: vi.fn().mockResolvedValue({ status: 'success', blockNumber: 10n, - logs: params.receiptLogs, + transactionIndex: 0, + logs: [ + ...params.memoLogs.map((log) => memoReceiptLog((log as ReturnType).args)), + ...params.receiptLogs, + ], }), } as any; } @@ -111,6 +128,39 @@ function memoLog(request: { }; } +function memoReceiptLog(args: { + target?: `0x${string}`; + memoId?: `0x${string}`; + memo?: `0x${string}`; + callDataHash?: `0x${string}`; + memoIndex?: bigint; +}) { + if (!memoEvent || !args.target || !args.memoId || !args.memo || !args.callDataHash) { + throw new Error('Invalid memo log fixture'); + } + + const topics = encodeEventTopics({ + abi: [memoEvent], + eventName: 'Memo', + args: { sender: buyer, target: args.target, memoId: args.memoId }, + }); + + return { + address: ARC_TESTNET_CONTRACTS.memo, + topics, + data: encodeAbiParameters( + [{ type: 'bytes32' }, { type: 'bytes' }, { type: 'uint256' }], + [args.callDataHash, args.memo, args.memoIndex ?? 1n], + ), + blockNumber: 10n, + blockHash, + transactionHash: txHash, + transactionIndex: 0, + logIndex: 1, + removed: false, + }; +} + function transferLog(params: { address?: `0x${string}`; value: bigint }) { const topics = encodeEventTopics({ abi: [transferEvent], @@ -129,4 +179,4 @@ function transferLog(params: { address?: `0x${string}`; value: bigint }) { logIndex: 0, removed: false, }; -} \ No newline at end of file +} diff --git a/packages/sdk/src/receipts/watcher.ts b/packages/sdk/src/receipts/watcher.ts index 18fbc7e..e10105b 100644 --- a/packages/sdk/src/receipts/watcher.ts +++ b/packages/sdk/src/receipts/watcher.ts @@ -14,6 +14,7 @@ import { } from 'viem'; import { ARC_TESTNET, USDC_DECIMALS } from '../constants.js'; import { createMemoPaymentRequest, ERC20_TRANSFER_ABI } from './memo-payment.js'; +import { createMemoPaymentProofFromReceipt } from './proof.js'; import type { ReceiptLedger } from './ledger.js'; import type { ArcInvoice, ArcReceipt, MemoPaymentRequest, ObservedPayment } from './types.js'; @@ -221,6 +222,12 @@ export class ArcReceiptWatcher { return null; } + const onchainProof = createMemoPaymentProofFromReceipt({ + txHash, + paymentRequest: request, + txReceipt, + }); + const observed: ObservedPayment = { txHash, from: transfer.from, @@ -232,10 +239,12 @@ export class ArcReceiptWatcher { memoId: request.memoId, callDataHash: request.callDataHash, blockNumber: txReceipt.blockNumber, + onchainProof, observedAt: Date.now(), metadata: { source: 'arc-testnet-watcher', memoIndex: args.memoIndex?.toString(), + explorerUrl: onchainProof.explorerUrl, }, }; @@ -307,4 +316,4 @@ function extractMatchingTransfer( function sameAddress(a: `0x${string}`, b: `0x${string}`): boolean { return getAddress(a) === getAddress(b); -} \ No newline at end of file +}