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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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';
```
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
65 changes: 52 additions & 13 deletions docs/receipts.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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&currency=USDC&network=arc-testnet&memo=...

const receipt = ledger.recordPayment(invoice.id, {
from: '0x2222222222222222222222222222222222222222',
to: invoice.payTo,
Expand All @@ -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.
4 changes: 2 additions & 2 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down
12 changes: 11 additions & 1 deletion packages/sdk/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions packages/sdk/src/receipts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,6 +43,7 @@ export type {
ArcReceipt,
CreateInvoiceInput,
InvoiceStatus,
MemoPaymentRequest,
ObservedPayment,
PaymentMatchResult,
ReceiptStatus,
Expand Down
14 changes: 13 additions & 1 deletion packages/sdk/src/receipts/invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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' };
}
Expand Down
22 changes: 22 additions & 0 deletions packages/sdk/src/receipts/ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});
});
14 changes: 14 additions & 0 deletions packages/sdk/src/receipts/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -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()];
}
Expand Down
39 changes: 39 additions & 0 deletions packages/sdk/src/receipts/memo-payment.test.ts
Original file line number Diff line number Diff line change
@@ -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,
]);
});
});
Loading
Loading