diff --git a/CHANGELOG.md b/CHANGELOG.md index caa5a00..847f6fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Arc Receipts MVP with invoice memos, receipt matching, in-memory ledger, and signed webhook helpers. +- Arc Testnet watcher for memo-wrapped USDC payments, plus memo payment request helpers and official Arc Testnet contract constants. ## [0.2.0-alpha] - 2026-06-02 diff --git a/README.md b/README.md index e934d38..e5abde9 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ We turn these primitives from docs and quickstarts into **drop-in middleware and | **Usage Metering** | Track consumption per buyer, per endpoint | ✅ Ready | | **Gateway Client** | Unified balance monitoring, deposit tracking | ✅ Ready | | **Arc Receipts** | Invoices, transaction memos, receipts, and signed webhooks | ✅ Ready | +| **Arc Testnet Watcher** | Watches memo-wrapped USDC payments and creates receipts locally | ✅ Ready | | **Demo App** | Next.js app with live paywalled API endpoints | ✅ Ready | | **Dashboard** | Real-time analytics and revenue tracking | 🔜 Phase 2 | | **Multi-Framework** | Fastify, Hono, Python, Go adapters | 🔜 Phase 3 | @@ -296,12 +297,14 @@ console.log(`Balance: ${balance.available} USDC`); ### Receipts (`@arc-nano-kit/sdk/receipts`) -Invoice, transaction memo, receipt, and signed webhook helpers for Arc payment workflows: +Invoice, transaction memo, watcher, receipt, and signed webhook helpers for Arc payment workflows: ```typescript import { + ArcReceiptWatcher, ReceiptLedger, createInvoiceMemo, + createMemoPaymentRequest, verifyWebhookSignature, } from '@arc-nano-kit/sdk/receipts'; ``` @@ -371,7 +374,7 @@ arc-nano-kit leverages the full Circle stack: | Phase | Timeline | Key Deliverables | Status | |-------|----------|------------------|--------| | **Foundation** | Weeks 1-2 | SDK middleware, buyer client, billing engine, demo app | ✅ In Progress | -| **Production** | Weeks 3-4 | Arc Receipts, usage dashboard, Gateway helpers, CLI scaffolding | 🔜 Next | +| **Production** | Weeks 3-4 | Arc Receipts, Arc Testnet watcher, usage dashboard, Gateway helpers, CLI scaffolding | 🔜 Next | | **Ecosystem** | Months 2-3 | Multi-framework, agent commerce, Arc Mainnet | 📋 Planned | See [ROADMAP.md](ROADMAP.md) for the detailed feature roadmap. diff --git a/ROADMAP.md b/ROADMAP.md index f8ea006..1774e1d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,6 +20,7 @@ arc-nano-kit is under active development. This roadmap outlines our planned mile - [x] Per-job (batch) pricing model - [x] Usage metering with in-memory store - [x] Arc Receipts MVP (invoice memos, receipts, signed webhooks) +- [x] Arc Testnet watcher for memo-wrapped USDC payments - [ ] Persistent usage store (Supabase/Postgres adapter) ### Demo @@ -35,7 +36,8 @@ arc-nano-kit is under active development. This roadmap outlines our planned mile - [x] Invoice and transaction memo helpers - [x] Receipt matching and in-memory ledger - [x] HMAC-signed webhook events -- [ ] Arc testnet payment watcher +- [x] Arc Testnet payment watcher +- [ ] Persistent watcher cursor - [ ] SQLite/Postgres receipt store - [ ] Next.js webhook route helpers - [ ] Refund/counter-payment tracking diff --git a/docs/receipts.md b/docs/receipts.md index d054e12..20a8306 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -1,6 +1,6 @@ # Arc Receipts -Arc Receipts is the payment operations layer for arc-nano-kit. It gives Arc builders a small, typed workflow for invoices, transaction memos, signed webhooks, receipts, refunds, and reconciliation. +Arc Receipts is the payment operations layer for arc-nano-kit. It gives Arc builders a small, typed workflow for invoices, transaction memos, signed webhooks, receipts, refunds, watcher-based payment observation, and reconciliation. The goal is not to replace x402 or Circle App Kits. The goal is to make the application layer around Arc payments easier to ship. @@ -39,9 +39,6 @@ const invoice = ledger.createInvoice({ console.log(invoice.memo); // arc-nano-kit:invoice:v1:inv_pro_plan_123 -console.log(invoice.paymentUri); -// arc://pay?to=0x...&amount=19.00¤cy=USDC&network=arc-testnet&memo=... - const receipt = ledger.recordPayment(invoice.id, { from: '0x2222222222222222222222222222222222222222', to: invoice.payTo, @@ -60,24 +57,66 @@ console.log(signature.header); // t=...,v1=... ``` +## Arc Testnet Watcher + +Use the watcher when you want receipts to come from real Arc Testnet transactions instead of manually calling `recordPayment()`. + +```typescript +import { + ArcReceiptWatcher, + ReceiptLedger, + createMemoPaymentRequest, +} from '@arc-nano-kit/sdk/receipts'; + +const ledger = new ReceiptLedger(); + +const invoice = ledger.createInvoice({ + id: 'inv_pro_plan_123', + amount: '19.00', + payTo: '0x1111111111111111111111111111111111111111', +}); + +const paymentRequest = createMemoPaymentRequest(invoice); + +console.log(paymentRequest.memoContract); +console.log(paymentRequest.target); // Arc Testnet USDC ERC-20 interface +console.log(paymentRequest.memoId); // Indexed memo id for querying logs +console.log(paymentRequest.txData); // Data for Memo.memo(...) + +const watcher = new ArcReceiptWatcher({ + ledger, + onReceipt(receipt) { + console.log('paid', receipt.txHash); + }, +}); + +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. + +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. + ## What ships in the MVP -- `createInvoice()` for invoice ids, stablecoin minor units, Arc payment URIs, and invoice memos. -- `createInvoiceMemo()` and `parseInvoiceMemo()` for memo correlation. -- `matchPaymentToInvoice()` to validate amount, recipient, currency, network, memo, and expiry. -- `createReceipt()` to turn an observed payment into a durable receipt object. -- `ReceiptLedger` for an in-memory invoice/receipt/event store. +- `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. +- `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. - `signWebhookEvent()` and `verifyWebhookSignature()` for HMAC-signed webhooks. ## Current Limits -This module does not yet watch Arc blocks or Circle Gateway events by itself. Today, callers pass observed payments into `recordPayment()`. A chain watcher and persistent stores are planned next. +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. ## Planned Next Steps -- Arc testnet transfer watcher. - SQLite/Postgres receipt store. +- Persistent watcher cursor. - Next.js webhook route helpers. -- Refund receipts and counter-payment tracking. +- Refund receipts and partial refund accounting. - Unified Balance readiness states. -- Demo dashboard for invoice state transitions. +- Demo dashboard for invoice state transitions. \ No newline at end of file diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 0f92eb8..546679a 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -4,7 +4,7 @@ This is the core SDK package of [arc-nano-kit](https://github.com/horn111/arc-nano-kit). See the root README for full documentation. -It includes paid API middleware, buyer SDK helpers, usage billing, invoices, receipts, and signed webhooks for Arc payment workflows. +It includes paid API middleware, buyer SDK helpers, usage billing, invoices, receipt watchers, receipts, and signed webhooks for Arc payment workflows. ## Installation @@ -63,7 +63,7 @@ const receipt = ledger.recordPayment(invoice.id, { | Client | `@arc-nano-kit/sdk/client` | Buyer SDK for automated x402 payments | | Billing | `@arc-nano-kit/sdk/billing` | Usage metering & billing plans | | Gateway | `@arc-nano-kit/sdk/gateway` | Circle Gateway balance management | -| Receipts | `@arc-nano-kit/sdk/receipts` | Invoices, memos, receipts, signed webhooks | +| Receipts | `@arc-nano-kit/sdk/receipts` | Invoices, memos, Arc Testnet watcher, receipts, signed webhooks | ## License diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index 74de32b..aac281b 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -19,12 +19,22 @@ export const PAYMENT_REQUIRED_HEADER = 'x-payment-requirements'; /** HTTP 402 status code */ export const HTTP_402 = 402; +/** Arc Testnet predeployed contract addresses */ +export const ARC_TESTNET_CONTRACTS = { + /** Optional ERC-20 interface for Arc's native USDC balance (6 decimals) */ + usdc: '0x3600000000000000000000000000000000000000', + /** Predeployed transaction memo contract */ + memo: '0x5294E9927c3306DcBaDb03fe70b92e01cCede505', + /** Native USDC system event emitter (18 decimals) */ + nativeUsdcSystemEmitter: '0xfffffffffffffffffffffffffffffffffffffffe', +} as const; + /** Arc Testnet configuration */ export const ARC_TESTNET: NetworkConfig = { chainId: 5042002, rpcUrl: 'https://rpc.testnet.arc.network', name: 'Arc Testnet', - usdcAddress: '0x0000000000000000000000000000000000000001', // Native USDC + usdcAddress: ARC_TESTNET_CONTRACTS.usdc, explorerUrl: 'https://testnet.arcscan.app', cctpDomainId: 26, } as const; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6649305..fcf7e9a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -31,20 +31,24 @@ export { export { GatewayClient, type GatewayClientConfig } from './gateway/client.js'; export { + ArcReceiptWatcher, ReceiptLedger, createInvoice, createInvoiceMemo, + createMemoPaymentRequest, createReceipt, signWebhookEvent, verifyWebhookSignature, type ArcInvoice, type ArcReceipt, + type ArcReceiptWatcherConfig, type CreateInvoiceInput, + type MemoPaymentRequest, type ObservedPayment, type WebhookEvent, } from './receipts/index.js'; -export { ARC_TESTNET, ARC_MAINNET, USDC_DECIMALS } from './constants.js'; +export { ARC_TESTNET, ARC_TESTNET_CONTRACTS, ARC_MAINNET, USDC_DECIMALS } from './constants.js'; export type { PaymentRequirements, diff --git a/packages/sdk/src/receipts/index.ts b/packages/sdk/src/receipts/index.ts index 48dd79b..00fd7cb 100644 --- a/packages/sdk/src/receipts/index.ts +++ b/packages/sdk/src/receipts/index.ts @@ -17,7 +17,20 @@ export { createInvoiceMemo, parseInvoiceMemo, type ParsedInvoiceMemo, + createInvoiceMemoData, + createInvoiceMemoId, } from './memo.js'; +export { + ARC_MEMO_ABI, + ERC20_TRANSFER_ABI, + createMemoPaymentRequest, + type MemoPaymentRequestOptions, +} from './memo-payment.js'; +export { + ArcReceiptWatcher, + type ArcReceiptWatcherConfig, + type ArcReceiptWatcherLifecycleEvent, +} from './watcher.js'; export { createWebhookEvent, serializeWebhookPayload, @@ -30,6 +43,7 @@ export type { ArcReceipt, CreateInvoiceInput, InvoiceStatus, + MemoPaymentRequest, ObservedPayment, PaymentMatchResult, ReceiptStatus, diff --git a/packages/sdk/src/receipts/invoice.ts b/packages/sdk/src/receipts/invoice.ts index 616bede..ad1274e 100644 --- a/packages/sdk/src/receipts/invoice.ts +++ b/packages/sdk/src/receipts/invoice.ts @@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto'; import { DEFAULTS } from '../constants.js'; import { isAmountAtLeast, toStablecoinUnits } from './amount.js'; -import { createInvoiceMemo } from './memo.js'; +import { createInvoiceMemo, createInvoiceMemoData, createInvoiceMemoId } from './memo.js'; import type { ArcInvoice, ArcReceipt, @@ -22,6 +22,8 @@ export function createInvoice(input: CreateInvoiceInput): ArcInvoice { const network = input.network ?? DEFAULTS.network; const createdAt = input.createdAt ?? Date.now(); const memo = createInvoiceMemo(id); + const memoId = createInvoiceMemoId(id); + const memoData = createInvoiceMemoData(memo); const amountUnits = toStablecoinUnits(input.amount).toString(); return { @@ -33,6 +35,8 @@ export function createInvoice(input: CreateInvoiceInput): ArcInvoice { payTo: input.payTo, network, memo, + memoId, + memoData, paymentUri: createPaymentUri({ payTo: input.payTo, amount: input.amount, @@ -96,6 +100,14 @@ export function matchPaymentToInvoice( return { success: false, reason: 'wrong_memo' }; } + if ( + payment.memoId !== undefined + && invoice.memoId !== undefined + && payment.memoId.toLowerCase() !== invoice.memoId.toLowerCase() + ) { + return { success: false, reason: 'wrong_memo_id' }; + } + if (!isAmountAtLeast(payment.amount, invoice.amount)) { return { success: false, reason: 'insufficient_amount' }; } diff --git a/packages/sdk/src/receipts/ledger.test.ts b/packages/sdk/src/receipts/ledger.test.ts index fcc8c21..c71643d 100644 --- a/packages/sdk/src/receipts/ledger.test.ts +++ b/packages/sdk/src/receipts/ledger.test.ts @@ -54,4 +54,26 @@ describe('ReceiptLedger', () => { expect(refund.status).toBe('refunded'); expect(ledger.getInvoice(invoice.id)?.status).toBe('refunded'); }); + it('returns the existing receipt for duplicate invoice tx records', () => { + const ledger = new ReceiptLedger(); + const invoice = ledger.createInvoice({ id: 'inv_dupe', amount: '1', payTo: seller }); + const payment = { + txHash: '0xabc' as `0x${string}`, + from: buyer, + to: seller, + amount: '1', + memo: invoice.memo, + }; + + const first = ledger.recordPayment(invoice.id, payment); + const second = ledger.recordPayment(invoice.id, payment); + + expect(second).toBe(first); + expect(ledger.listReceipts()).toHaveLength(1); + expect(ledger.listWebhookEvents().map((event) => event.type)).toEqual([ + 'invoice.created', + 'invoice.observed', + 'invoice.paid', + ]); + }); }); diff --git a/packages/sdk/src/receipts/ledger.ts b/packages/sdk/src/receipts/ledger.ts index f5fcad8..ea6975c 100644 --- a/packages/sdk/src/receipts/ledger.ts +++ b/packages/sdk/src/receipts/ledger.ts @@ -57,6 +57,13 @@ export class ReceiptLedger { } recordPayment(invoiceId: string, payment: ObservedPayment): ArcReceipt { + const existingReceipt = payment.txHash + ? this.getReceiptByTxHash(payment.txHash, invoiceId) + : undefined; + if (existingReceipt) { + return existingReceipt; + } + const invoice = this.requireInvoice(invoiceId); const observedInvoice = this.updateInvoice(invoice.id, { status: 'observed' }); this.events.push(createWebhookEvent('invoice.observed', { invoice: observedInvoice, payment })); @@ -109,6 +116,13 @@ export class ReceiptLedger { return this.receipts.get(id); } + getReceiptByTxHash(txHash: `0x${string}`, invoiceId?: string): ArcReceipt | undefined { + return [...this.receipts.values()].find((receipt) => ( + receipt.txHash?.toLowerCase() === txHash.toLowerCase() + && (invoiceId === undefined || receipt.invoiceId === invoiceId) + )); + } + listReceipts(): ArcReceipt[] { return [...this.receipts.values()]; } diff --git a/packages/sdk/src/receipts/memo-payment.test.ts b/packages/sdk/src/receipts/memo-payment.test.ts new file mode 100644 index 0000000..142d8d1 --- /dev/null +++ b/packages/sdk/src/receipts/memo-payment.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { decodeFunctionData, keccak256 } from 'viem'; +import { ARC_TESTNET_CONTRACTS } from '../constants.js'; +import { createInvoice } from './invoice.js'; +import { ARC_MEMO_ABI, createMemoPaymentRequest, ERC20_TRANSFER_ABI } from './memo-payment.js'; + +const seller = '0x1111111111111111111111111111111111111111' as const; + +describe('memo payment requests', () => { + it('builds a Memo.memo call around an ERC-20 USDC transfer', () => { + const invoice = createInvoice({ id: 'inv_memo', amount: '19.00', payTo: seller }); + const request = createMemoPaymentRequest(invoice); + + expect(request.memoContract).toBe(ARC_TESTNET_CONTRACTS.memo); + expect(request.target).toBe(ARC_TESTNET_CONTRACTS.usdc); + expect(request.memoId).toBe(invoice.memoId); + expect(request.memoData).toBe(invoice.memoData); + expect(request.callDataHash).toBe(keccak256(request.data)); + + const transferCall = decodeFunctionData({ + abi: ERC20_TRANSFER_ABI, + data: request.data, + }); + expect(transferCall.functionName).toBe('transfer'); + expect(transferCall.args).toEqual([seller, 19_000_000n]); + + const memoCall = decodeFunctionData({ + abi: ARC_MEMO_ABI, + data: request.txData, + }); + expect(memoCall.functionName).toBe('memo'); + expect(memoCall.args).toEqual([ + ARC_TESTNET_CONTRACTS.usdc, + request.data, + request.memoId, + request.memoData, + ]); + }); +}); \ No newline at end of file diff --git a/packages/sdk/src/receipts/memo-payment.ts b/packages/sdk/src/receipts/memo-payment.ts new file mode 100644 index 0000000..368b853 --- /dev/null +++ b/packages/sdk/src/receipts/memo-payment.ts @@ -0,0 +1,109 @@ +/** + * Helpers for Arc Memo-wrapped USDC invoice payments. + */ + +import { + encodeFunctionData, + keccak256, + type Abi, +} from 'viem'; +import { ARC_TESTNET_CONTRACTS } from '../constants.js'; +import { createInvoiceMemoData, createInvoiceMemoId } from './memo.js'; +import type { ArcInvoice, MemoPaymentRequest } from './types.js'; + +export const ERC20_TRANSFER_ABI = [ + { + type: 'function', + name: 'transfer', + stateMutability: 'nonpayable', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + }, + { + type: 'event', + name: 'Transfer', + anonymous: false, + inputs: [ + { name: 'from', type: 'address', indexed: true }, + { name: 'to', type: 'address', indexed: true }, + { name: 'value', type: 'uint256', indexed: false }, + ], + }, +] as const satisfies Abi; + +export const ARC_MEMO_ABI = [ + { + type: 'function', + name: 'memo', + stateMutability: 'nonpayable', + inputs: [ + { name: 'target', type: 'address' }, + { name: 'data', type: 'bytes' }, + { name: 'memoId', type: 'bytes32' }, + { name: 'memoData', type: 'bytes' }, + ], + outputs: [], + }, + { + type: 'event', + name: 'BeforeMemo', + anonymous: false, + inputs: [{ name: 'memoIndex', type: 'uint256', indexed: true }], + }, + { + type: 'event', + name: 'Memo', + anonymous: false, + inputs: [ + { name: 'sender', type: 'address', indexed: true }, + { name: 'target', type: 'address', indexed: true }, + { name: 'callDataHash', type: 'bytes32', indexed: false }, + { name: 'memoId', type: 'bytes32', indexed: true }, + { name: 'memo', type: 'bytes', indexed: false }, + { name: 'memoIndex', type: 'uint256', indexed: false }, + ], + }, +] as const satisfies Abi; + +export interface MemoPaymentRequestOptions { + usdcAddress?: `0x${string}`; + memoContract?: `0x${string}`; +} + +export function createMemoPaymentRequest( + invoice: ArcInvoice, + options: MemoPaymentRequestOptions = {}, +): MemoPaymentRequest { + const target = options.usdcAddress ?? ARC_TESTNET_CONTRACTS.usdc; + const memoContract = options.memoContract ?? ARC_TESTNET_CONTRACTS.memo; + const memoId = invoice.memoId ?? createInvoiceMemoId(invoice.id); + const memoData = invoice.memoData ?? createInvoiceMemoData(invoice.memo); + + const data = encodeFunctionData({ + abi: ERC20_TRANSFER_ABI, + functionName: 'transfer', + args: [invoice.payTo, BigInt(invoice.amountUnits)], + }); + const callDataHash = keccak256(data); + const txData = encodeFunctionData({ + abi: ARC_MEMO_ABI, + functionName: 'memo', + args: [target, data, memoId, memoData], + }); + + return { + invoiceId: invoice.id, + memoContract, + target, + data, + txData, + memoId, + memoData, + callDataHash, + amountUnits: invoice.amountUnits, + payTo: invoice.payTo, + }; +} \ No newline at end of file diff --git a/packages/sdk/src/receipts/memo.ts b/packages/sdk/src/receipts/memo.ts index 5ff96f0..d19c86f 100644 --- a/packages/sdk/src/receipts/memo.ts +++ b/packages/sdk/src/receipts/memo.ts @@ -2,6 +2,8 @@ * Arc transaction memo helpers for invoice correlation. */ +import { keccak256, stringToHex } from 'viem'; + export interface ParsedInvoiceMemo { namespace: string; version: 'v1'; @@ -26,6 +28,14 @@ export function createInvoiceMemo(invoiceId: string, namespace = 'arc-nano-kit') return `${cleanNamespace}:invoice:v1:${cleanInvoiceId}`; } +export function createInvoiceMemoId(invoiceId: string): `0x${string}` { + return keccak256(stringToHex(invoiceId)); +} + +export function createInvoiceMemoData(memo: string): `0x${string}` { + return stringToHex(memo); +} + export function parseInvoiceMemo(memo: string): ParsedInvoiceMemo | null { const [namespace, kind, version, invoiceId, ...extra] = memo.split(':'); diff --git a/packages/sdk/src/receipts/types.ts b/packages/sdk/src/receipts/types.ts index 9b9cb50..93eb2a6 100644 --- a/packages/sdk/src/receipts/types.ts +++ b/packages/sdk/src/receipts/types.ts @@ -46,6 +46,8 @@ export interface ArcInvoice { payTo: `0x${string}`; network: string; memo: string; + memoId?: `0x${string}`; + memoData?: `0x${string}`; paymentUri: string; createdAt: number; expiresAt?: number; @@ -62,11 +64,26 @@ export interface ObservedPayment { currency?: StablecoinSymbol; network?: string; memo?: string; + memoId?: `0x${string}`; + callDataHash?: `0x${string}`; observedAt?: number; blockNumber?: bigint; metadata?: Record; } +export interface MemoPaymentRequest { + invoiceId: string; + memoContract: `0x${string}`; + target: `0x${string}`; + data: `0x${string}`; + txData: `0x${string}`; + memoId: `0x${string}`; + memoData: `0x${string}`; + callDataHash: `0x${string}`; + amountUnits: string; + payTo: `0x${string}`; +} + export interface PaymentMatchResult { success: boolean; reason?: string; diff --git a/packages/sdk/src/receipts/watcher.test.ts b/packages/sdk/src/receipts/watcher.test.ts new file mode 100644 index 0000000..a6c5be3 --- /dev/null +++ b/packages/sdk/src/receipts/watcher.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + encodeAbiParameters, + encodeEventTopics, + parseAbiItem, +} from 'viem'; +import { ARC_TESTNET_CONTRACTS } from '../constants.js'; +import { ReceiptLedger } from './ledger.js'; +import { createMemoPaymentRequest } from './memo-payment.js'; +import { ArcReceiptWatcher } from './watcher.js'; + +const seller = '0x1111111111111111111111111111111111111111' as const; +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)'); + +describe('ArcReceiptWatcher', () => { + it('creates a receipt when it sees matching Memo and ERC-20 Transfer logs', async () => { + const ledger = new ReceiptLedger(); + const invoice = ledger.createInvoice({ id: 'inv_watch', amount: '19.00', payTo: seller }); + const request = createMemoPaymentRequest(invoice); + const onReceipt = vi.fn(); + const publicClient = createMockClient({ + memoLogs: [memoLog(request)], + receiptLogs: [transferLog({ value: 19_000_000n })], + }); + const watcher = new ArcReceiptWatcher({ ledger, publicClient, onReceipt }); + + watcher.watchInvoice(invoice, { fromBlock: 10n }); + const receipts = await watcher.pollOnce(); + + expect(receipts).toHaveLength(1); + expect(receipts[0]?.invoiceId).toBe(invoice.id); + expect(receipts[0]?.txHash).toBe(txHash); + expect(receipts[0]?.amountUnits).toBe('19000000'); + expect(ledger.getInvoice(invoice.id)?.status).toBe('paid'); + expect(onReceipt).toHaveBeenCalledOnce(); + }); + + it('ignores native USDC system Transfer logs to avoid double-counting', async () => { + const ledger = new ReceiptLedger(); + const invoice = ledger.createInvoice({ id: 'inv_native_only', amount: '19.00', payTo: seller }); + const request = createMemoPaymentRequest(invoice); + const publicClient = createMockClient({ + memoLogs: [memoLog(request)], + receiptLogs: [transferLog({ + address: ARC_TESTNET_CONTRACTS.nativeUsdcSystemEmitter, + value: 19_000_000_000_000_000_000n, + })], + }); + const watcher = new ArcReceiptWatcher({ ledger, publicClient }); + + watcher.watchInvoice(invoice, { fromBlock: 10n }); + const receipts = await watcher.pollOnce(); + + expect(receipts).toHaveLength(0); + expect(ledger.getInvoice(invoice.id)?.status).toBe('open'); + }); + + it('ignores memo logs with the wrong calldata hash', async () => { + const ledger = new ReceiptLedger(); + const invoice = ledger.createInvoice({ id: 'inv_wrong_hash', amount: '19.00', payTo: seller }); + const request = createMemoPaymentRequest(invoice); + const publicClient = createMockClient({ + memoLogs: [memoLog({ + ...request, + callDataHash: '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as `0x${string}`, + })], + receiptLogs: [transferLog({ value: 19_000_000n })], + }); + const watcher = new ArcReceiptWatcher({ ledger, publicClient }); + + watcher.watchInvoice(invoice, { fromBlock: 10n }); + const receipts = await watcher.pollOnce(); + + expect(receipts).toHaveLength(0); + }); +}); + +function createMockClient(params: { memoLogs: unknown[]; receiptLogs: unknown[] }) { + return { + getBlockNumber: vi.fn().mockResolvedValue(12n), + getLogs: vi.fn().mockResolvedValue(params.memoLogs), + getTransactionReceipt: vi.fn().mockResolvedValue({ + status: 'success', + blockNumber: 10n, + logs: params.receiptLogs, + }), + } as any; +} + +function memoLog(request: { + target: `0x${string}`; + memoId: `0x${string}`; + memoData: `0x${string}`; + callDataHash: `0x${string}`; +}) { + return { + address: ARC_TESTNET_CONTRACTS.memo, + transactionHash: txHash, + blockNumber: 10n, + args: { + sender: buyer, + target: request.target, + callDataHash: request.callDataHash, + memoId: request.memoId, + memo: request.memoData, + memoIndex: 1n, + }, + }; +} + +function transferLog(params: { address?: `0x${string}`; value: bigint }) { + const topics = encodeEventTopics({ + abi: [transferEvent], + eventName: 'Transfer', + args: { from: buyer, to: seller }, + }); + + return { + address: params.address ?? ARC_TESTNET_CONTRACTS.usdc, + topics, + data: encodeAbiParameters([{ type: 'uint256' }], [params.value]), + blockNumber: 10n, + blockHash, + transactionHash: txHash, + transactionIndex: 0, + 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 new file mode 100644 index 0000000..18fbc7e --- /dev/null +++ b/packages/sdk/src/receipts/watcher.ts @@ -0,0 +1,310 @@ +/** + * Arc Testnet receipt watcher for Memo-wrapped USDC invoice payments. + */ + +import { + createPublicClient, + formatUnits, + getAddress, + http, + parseAbiItem, + parseEventLogs, + type Log, + type PublicClient, +} from 'viem'; +import { ARC_TESTNET, USDC_DECIMALS } from '../constants.js'; +import { createMemoPaymentRequest, ERC20_TRANSFER_ABI } from './memo-payment.js'; +import type { ReceiptLedger } from './ledger.js'; +import type { ArcInvoice, ArcReceipt, MemoPaymentRequest, ObservedPayment } from './types.js'; + +const MEMO_EVENT = parseAbiItem( + 'event Memo(address indexed sender,address indexed target,bytes32 callDataHash,bytes32 indexed memoId,bytes memo,uint256 memoIndex)', +); + +type ReceiptWatcherClient = Pick< + PublicClient, + 'getBlockNumber' | 'getLogs' | 'getTransactionReceipt' +>; + +type MemoLog = { + transactionHash?: `0x${string}` | null; + blockNumber?: bigint | null; + args?: { + sender?: `0x${string}`; + target?: `0x${string}`; + callDataHash?: `0x${string}`; + memoId?: `0x${string}`; + memo?: `0x${string}`; + memoIndex?: bigint; + }; +}; + +export type ArcReceiptWatcherLifecycleEvent = + | { type: 'watcher.started'; invoiceCount: number } + | { type: 'watcher.stopped' } + | { type: 'watcher.poll'; fromBlock: bigint; toBlock: bigint; invoiceCount: number } + | { type: 'watcher.memo_seen'; invoiceId: string; txHash: `0x${string}`; blockNumber?: bigint } + | { type: 'watcher.receipt_created'; invoiceId: string; receipt: ArcReceipt }; + +export interface ArcReceiptWatcherConfig { + ledger: ReceiptLedger; + rpcUrl?: string; + publicClient?: ReceiptWatcherClient; + fromBlock?: bigint; + confirmations?: number; + pollIntervalMs?: number; + onReceipt?: (receipt: ArcReceipt, invoice: ArcInvoice) => void | Promise; + onEvent?: (event: ArcReceiptWatcherLifecycleEvent) => void | Promise; + onError?: (error: unknown) => void; +} + +export class ArcReceiptWatcher { + private readonly ledger: ReceiptLedger; + private readonly client: ReceiptWatcherClient; + private readonly confirmations: bigint; + private readonly pollIntervalMs: number; + private readonly fromBlock?: bigint; + private readonly onReceipt?: ArcReceiptWatcherConfig['onReceipt']; + private readonly onEvent?: ArcReceiptWatcherConfig['onEvent']; + private readonly onError?: ArcReceiptWatcherConfig['onError']; + private readonly invoices = new Map(); + private readonly cursors = new Map(); + private timer?: ReturnType; + + constructor(config: ArcReceiptWatcherConfig) { + this.ledger = config.ledger; + this.client = config.publicClient ?? createPublicClient({ + transport: http(config.rpcUrl ?? ARC_TESTNET.rpcUrl), + }); + this.confirmations = BigInt(config.confirmations ?? 1); + this.pollIntervalMs = config.pollIntervalMs ?? 5_000; + this.fromBlock = config.fromBlock; + this.onReceipt = config.onReceipt; + this.onEvent = config.onEvent; + this.onError = config.onError; + } + + watchInvoice(invoice: ArcInvoice, options: { fromBlock?: bigint } = {}): void { + this.invoices.set(invoice.id, invoice); + if (options.fromBlock !== undefined) { + this.cursors.set(invoice.id, options.fromBlock); + } + } + + unwatchInvoice(invoiceId: string): void { + this.invoices.delete(invoiceId); + this.cursors.delete(invoiceId); + } + + async pollOnce(): Promise { + if (this.invoices.size === 0) { + return []; + } + + const latestBlock = await this.client.getBlockNumber(); + const toBlock = latestBlock > this.confirmations + ? latestBlock - this.confirmations + : 0n; + const receipts: ArcReceipt[] = []; + + for (const invoice of this.invoices.values()) { + const fromBlock = this.cursors.get(invoice.id) ?? this.fromBlock ?? toBlock; + if (fromBlock > toBlock) { + continue; + } + + await this.emit({ + type: 'watcher.poll', + fromBlock, + toBlock, + invoiceCount: this.invoices.size, + }); + + const invoiceReceipts = await this.pollInvoice(invoice, fromBlock, toBlock); + receipts.push(...invoiceReceipts); + this.cursors.set(invoice.id, toBlock + 1n); + } + + return receipts; + } + + start(): void { + if (this.timer) { + return; + } + + void this.emit({ type: 'watcher.started', invoiceCount: this.invoices.size }); + void this.pollOnce().catch((error) => this.handleError(error)); + this.timer = setInterval(() => { + void this.pollOnce().catch((error) => this.handleError(error)); + }, this.pollIntervalMs); + } + + stop(): void { + if (!this.timer) { + return; + } + + clearInterval(this.timer); + this.timer = undefined; + void this.emit({ type: 'watcher.stopped' }); + } + + private async pollInvoice( + invoice: ArcInvoice, + fromBlock: bigint, + toBlock: bigint, + ): Promise { + const request = createMemoPaymentRequest(invoice); + const memoLogs = await this.client.getLogs({ + address: request.memoContract, + event: MEMO_EVENT, + args: { memoId: request.memoId }, + fromBlock, + toBlock, + }) as MemoLog[]; + + const receipts: ArcReceipt[] = []; + + for (const memoLog of memoLogs) { + const receipt = await this.processMemoLog(invoice, request, memoLog); + if (receipt) { + receipts.push(receipt); + } + } + + return receipts; + } + + private async processMemoLog( + invoice: ArcInvoice, + request: MemoPaymentRequest, + memoLog: MemoLog, + ): Promise { + const txHash = memoLog.transactionHash; + const args = memoLog.args; + + if (!txHash || !args?.sender || !args.target || !args.callDataHash || !args.memoId) { + return null; + } + + if (!sameAddress(args.target, request.target)) { + return null; + } + + if (args.memoId.toLowerCase() !== request.memoId.toLowerCase()) { + return null; + } + + if (args.callDataHash.toLowerCase() !== request.callDataHash.toLowerCase()) { + return null; + } + + if (this.ledger.getReceiptByTxHash(txHash, invoice.id)) { + return null; + } + + await this.emit({ + type: 'watcher.memo_seen', + invoiceId: invoice.id, + txHash, + blockNumber: memoLog.blockNumber ?? undefined, + }); + + const txReceipt = await this.client.getTransactionReceipt({ hash: txHash }); + if (txReceipt.status !== 'success') { + return null; + } + + const transfer = extractMatchingTransfer(txReceipt.logs, request, args.sender); + if (!transfer) { + return null; + } + + const observed: ObservedPayment = { + txHash, + from: transfer.from, + to: transfer.to, + amount: formatUnits(transfer.value, USDC_DECIMALS), + currency: invoice.currency, + network: invoice.network, + memo: invoice.memo, + memoId: request.memoId, + callDataHash: request.callDataHash, + blockNumber: txReceipt.blockNumber, + observedAt: Date.now(), + metadata: { + source: 'arc-testnet-watcher', + memoIndex: args.memoIndex?.toString(), + }, + }; + + const receipt = this.ledger.recordPayment(invoice.id, observed); + await this.onReceipt?.(receipt, invoice); + await this.emit({ type: 'watcher.receipt_created', invoiceId: invoice.id, receipt }); + return receipt; + } + + private async emit(event: ArcReceiptWatcherLifecycleEvent): Promise { + await this.onEvent?.(event); + } + + private handleError(error: unknown): void { + if (this.onError) { + this.onError(error); + return; + } + + throw error; + } +} + +function extractMatchingTransfer( + logs: readonly Log[], + request: MemoPaymentRequest, + expectedFrom: `0x${string}`, +): { from: `0x${string}`; to: `0x${string}`; value: bigint } | null { + const erc20Logs = logs.filter((log) => sameAddress(log.address, request.target)); + const parsedLogs = parseEventLogs({ + abi: ERC20_TRANSFER_ABI, + eventName: 'Transfer', + logs: erc20Logs, + strict: false, + }); + + for (const parsedLog of parsedLogs) { + const args = parsedLog.args as { + from?: `0x${string}`; + to?: `0x${string}`; + value?: bigint; + }; + + if (!args.from || !args.to || args.value === undefined) { + continue; + } + + if (!sameAddress(args.from, expectedFrom)) { + continue; + } + + if (!sameAddress(args.to, request.payTo)) { + continue; + } + + if (args.value < BigInt(request.amountUnits)) { + continue; + } + + return { + from: args.from, + to: args.to, + value: args.value, + }; + } + + return null; +} + +function sameAddress(a: `0x${string}`, b: `0x${string}`): boolean { + return getAddress(a) === getAddress(b); +} \ No newline at end of file