tally-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opencode/commands/opsx-apply.md +152 -0
- package/.opencode/commands/opsx-archive.md +157 -0
- package/.opencode/commands/opsx-explore.md +169 -0
- package/.opencode/commands/opsx-propose.md +104 -0
- package/.opencode/commands/opsx-sync.md +140 -0
- package/.opencode/skills/openspec-apply-change/SKILL.md +159 -0
- package/.opencode/skills/openspec-archive-change/SKILL.md +117 -0
- package/.opencode/skills/openspec-explore/SKILL.md +287 -0
- package/.opencode/skills/openspec-propose/SKILL.md +111 -0
- package/.opencode/skills/openspec-sync-specs/SKILL.md +147 -0
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/docs/design-hook-store.md +122 -0
- package/docs/plan-hook-store.md +752 -0
- package/package.json +36 -0
- package/src/hook.ts +49 -0
- package/src/index.ts +29 -0
- package/src/store.ts +97 -0
- package/src/types.ts +34 -0
- package/test/hook.test.ts +87 -0
- package/test/index.test.ts +36 -0
- package/test/integration.test.ts +44 -0
- package/test/store.test.ts +88 -0
- package/test/types.test.ts +30 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
# Hook + SQLite Store Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build the first `tally-sdk` publish — client-side x402 V2 hook that captures PaymentPayload and persists to SQLite.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Agent calls `createTally()` → gets a `TallyClient` with a wrapped `fetch()` that intercepts x402 payment responses, extracts metadata, and stores to a local SQLite DB. The store implements a `Store` interface for future swap to TimescaleDB.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript (strict, ES2022, NodeNext module), better-sqlite3, uuid, vitest
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: Project scaffolding
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Create: `package.json`
|
|
17
|
+
- Create: `tsconfig.json`
|
|
18
|
+
- Create: `src/index.ts` (placeholder export)
|
|
19
|
+
|
|
20
|
+
- [ ] **Step 1: Create package.json**
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"name": "tally-sdk",
|
|
25
|
+
"version": "0.1.0",
|
|
26
|
+
"description": "Accounting, tax, and compliance layer for the agent economy",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "./src/index.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./src/index.ts"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"test:watch": "vitest"
|
|
35
|
+
},
|
|
36
|
+
"keywords": ["x402", "agent", "accounting", "crypto"],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"better-sqlite3": "^11.0.0",
|
|
40
|
+
"uuid": "^10.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
44
|
+
"@types/uuid": "^10.0.0",
|
|
45
|
+
"typescript": "^5.5.0",
|
|
46
|
+
"vitest": "^2.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- [ ] **Step 2: Create tsconfig.json**
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"compilerOptions": {
|
|
56
|
+
"target": "ES2022",
|
|
57
|
+
"module": "NodeNext",
|
|
58
|
+
"moduleResolution": "NodeNext",
|
|
59
|
+
"strict": true,
|
|
60
|
+
"esModuleInterop": true,
|
|
61
|
+
"skipLibCheck": true,
|
|
62
|
+
"forceConsistentCasingInFileNames": true,
|
|
63
|
+
"outDir": "dist",
|
|
64
|
+
"declaration": true,
|
|
65
|
+
"declarationMap": true,
|
|
66
|
+
"sourceMap": true
|
|
67
|
+
},
|
|
68
|
+
"include": ["src/**/*.ts", "test/**/*.ts"]
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- [ ] **Step 3: Create placeholder src/index.ts**
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
export const VERSION = "0.1.0";
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- [ ] **Step 4: Install dependencies and verify TypeScript compiles**
|
|
79
|
+
|
|
80
|
+
Run: `cd ~/Projects/tally && npm install`
|
|
81
|
+
|
|
82
|
+
Run: `cd ~/Projects/tally && npx tsc --noEmit`
|
|
83
|
+
|
|
84
|
+
Expected: No errors, exit code 0.
|
|
85
|
+
|
|
86
|
+
- [ ] **Step 5: Commit**
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
cd ~/Projects/tally && git add package.json tsconfig.json src/index.ts package-lock.json && git commit -m "chore: scaffold project"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
### Task 2: Core types (PaymentPayload, Store, TallyConfig, TallyClient)
|
|
95
|
+
|
|
96
|
+
**Files:**
|
|
97
|
+
- Create: `src/types.ts`
|
|
98
|
+
- Test: `test/types.test.ts`
|
|
99
|
+
|
|
100
|
+
- [ ] **Step 1: Write src/types.ts**
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
export interface PaymentPayload {
|
|
104
|
+
id: string
|
|
105
|
+
facilitator: string
|
|
106
|
+
requestId: string
|
|
107
|
+
fromAddress: string
|
|
108
|
+
toAddress: string
|
|
109
|
+
amount: string
|
|
110
|
+
asset: string
|
|
111
|
+
chainId: string
|
|
112
|
+
txHash: string
|
|
113
|
+
timestamp: number
|
|
114
|
+
memo?: string
|
|
115
|
+
metadata?: Record<string, unknown>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface Store {
|
|
119
|
+
insert(payload: PaymentPayload): Promise<void>
|
|
120
|
+
list(opts?: { facilitator?: string; limit?: number; offset?: number }): Promise<PaymentPayload[]>
|
|
121
|
+
get(id: string): Promise<PaymentPayload | null>
|
|
122
|
+
close(): Promise<void>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface TallyConfig {
|
|
126
|
+
facilitator: string
|
|
127
|
+
store?: Store
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface TallyClient {
|
|
131
|
+
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>
|
|
132
|
+
wrap(): void
|
|
133
|
+
store: Store
|
|
134
|
+
close(): Promise<void>
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- [ ] **Step 2: Write test/types.test.ts**
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
import { describe, it, expect } from "vitest";
|
|
142
|
+
import type { PaymentPayload, Store, TallyConfig, TallyClient } from "../src/types.js";
|
|
143
|
+
|
|
144
|
+
describe("types", () => {
|
|
145
|
+
it("PaymentPayload can be constructed", () => {
|
|
146
|
+
const payload: PaymentPayload = {
|
|
147
|
+
id: "abc-123",
|
|
148
|
+
facilitator: "dexter",
|
|
149
|
+
requestId: "req-1",
|
|
150
|
+
fromAddress: "0xsender",
|
|
151
|
+
toAddress: "0xreceiver",
|
|
152
|
+
amount: "10.00",
|
|
153
|
+
asset: "USDC",
|
|
154
|
+
chainId: "eip155:1",
|
|
155
|
+
txHash: "0xtx",
|
|
156
|
+
timestamp: Date.now(),
|
|
157
|
+
};
|
|
158
|
+
expect(payload.amount).toBe("10.00");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("Store interface is structurally typed", () => {
|
|
162
|
+
const store: Store = {
|
|
163
|
+
insert: async () => {},
|
|
164
|
+
list: async () => [],
|
|
165
|
+
get: async () => null,
|
|
166
|
+
close: async () => {},
|
|
167
|
+
};
|
|
168
|
+
expect(store).toBeDefined();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- [ ] **Step 3: Run tests to confirm they pass**
|
|
174
|
+
|
|
175
|
+
Run: `cd ~/Projects/tally && npx vitest run test/types.test.ts`
|
|
176
|
+
Expected: 2 passed
|
|
177
|
+
|
|
178
|
+
- [ ] **Step 4: Commit**
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
cd ~/Projects/tally && git add src/types.ts test/types.test.ts && git commit -m "feat: add core types"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
### Task 3: SQLite store
|
|
187
|
+
|
|
188
|
+
**Files:**
|
|
189
|
+
- Create: `src/store.ts`
|
|
190
|
+
- Test: `test/store.test.ts`
|
|
191
|
+
|
|
192
|
+
- [ ] **Step 1: Write the store test (TDD)**
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
196
|
+
import { SqliteStore } from "../src/store.js";
|
|
197
|
+
import type { PaymentPayload } from "../src/types.js";
|
|
198
|
+
|
|
199
|
+
function makePayload(overrides?: Partial<PaymentPayload>): PaymentPayload {
|
|
200
|
+
return {
|
|
201
|
+
id: overrides?.id ?? "test-1",
|
|
202
|
+
facilitator: overrides?.facilitator ?? "dexter",
|
|
203
|
+
requestId: "req-1",
|
|
204
|
+
fromAddress: "0xsender",
|
|
205
|
+
toAddress: "0xreceiver",
|
|
206
|
+
amount: "10.00",
|
|
207
|
+
asset: "USDC",
|
|
208
|
+
chainId: "eip155:1",
|
|
209
|
+
txHash: "0xtx",
|
|
210
|
+
timestamp: Date.now(),
|
|
211
|
+
...overrides,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
describe("SqliteStore", () => {
|
|
216
|
+
let store: SqliteStore;
|
|
217
|
+
|
|
218
|
+
beforeEach(() => {
|
|
219
|
+
store = new SqliteStore(":memory:");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
afterEach(() => {
|
|
223
|
+
store.close();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("inserts and retrieves a payment", async () => {
|
|
227
|
+
const payload = makePayload();
|
|
228
|
+
await store.insert(payload);
|
|
229
|
+
const result = await store.get(payload.id);
|
|
230
|
+
expect(result).not.toBeNull();
|
|
231
|
+
expect(result!.id).toBe(payload.id);
|
|
232
|
+
expect(result!.amount).toBe("10.00");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("returns null for unknown id", async () => {
|
|
236
|
+
const result = await store.get("nonexistent");
|
|
237
|
+
expect(result).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("lists payments ordered by created_at desc", async () => {
|
|
241
|
+
const p1 = makePayload({ id: "1", timestamp: 1000 });
|
|
242
|
+
const p2 = makePayload({ id: "2", timestamp: 2000 });
|
|
243
|
+
await store.insert(p1);
|
|
244
|
+
await store.insert(p2);
|
|
245
|
+
const all = await store.list();
|
|
246
|
+
expect(all).toHaveLength(2);
|
|
247
|
+
expect(all[0].id).toBe("2"); // newest first
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("filters by facilitator", async () => {
|
|
251
|
+
await store.insert(makePayload({ id: "1", facilitator: "dexter" }));
|
|
252
|
+
await store.insert(makePayload({ id: "2", facilitator: "coinbase-cdp" }));
|
|
253
|
+
const filtered = await store.list({ facilitator: "dexter" });
|
|
254
|
+
expect(filtered).toHaveLength(1);
|
|
255
|
+
expect(filtered[0].facilitator).toBe("dexter");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("paginates with limit and offset", async () => {
|
|
259
|
+
for (let i = 0; i < 10; i++) {
|
|
260
|
+
await store.insert(makePayload({ id: `p${i}`, timestamp: i }));
|
|
261
|
+
}
|
|
262
|
+
const page = await store.list({ limit: 3, offset: 0 });
|
|
263
|
+
expect(page).toHaveLength(3);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("handles metadata serialization", async () => {
|
|
267
|
+
const payload = makePayload({ metadata: { foo: "bar", num: 42 } });
|
|
268
|
+
await store.insert(payload);
|
|
269
|
+
const result = await store.get(payload.id);
|
|
270
|
+
expect(result!.metadata).toEqual({ foo: "bar", num: 42 });
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
- [ ] **Step 2: Run tests — should fail (no store yet)**
|
|
276
|
+
|
|
277
|
+
Run: `cd ~/Projects/tally && npx vitest run test/store.test.ts`
|
|
278
|
+
Expected: 0 passed, ImportError (module not found)
|
|
279
|
+
|
|
280
|
+
- [ ] **Step 3: Implement src/store.ts**
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
import Database from "better-sqlite3";
|
|
284
|
+
import type { PaymentPayload, Store } from "./types.js";
|
|
285
|
+
|
|
286
|
+
export class SqliteStore implements Store {
|
|
287
|
+
private db: Database.Database;
|
|
288
|
+
|
|
289
|
+
constructor(path: string) {
|
|
290
|
+
this.db = new Database(path);
|
|
291
|
+
this.db.exec(`
|
|
292
|
+
CREATE TABLE IF NOT EXISTS payments (
|
|
293
|
+
id TEXT PRIMARY KEY,
|
|
294
|
+
facilitator TEXT NOT NULL,
|
|
295
|
+
request_id TEXT NOT NULL,
|
|
296
|
+
from_addr TEXT NOT NULL,
|
|
297
|
+
to_addr TEXT NOT NULL,
|
|
298
|
+
amount TEXT NOT NULL,
|
|
299
|
+
asset TEXT NOT NULL DEFAULT 'USDC',
|
|
300
|
+
chain_id TEXT NOT NULL,
|
|
301
|
+
tx_hash TEXT NOT NULL,
|
|
302
|
+
memo TEXT,
|
|
303
|
+
metadata TEXT,
|
|
304
|
+
created_at INTEGER NOT NULL
|
|
305
|
+
);
|
|
306
|
+
CREATE INDEX IF NOT EXISTS idx_payments_facilitator ON payments(facilitator);
|
|
307
|
+
CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at);
|
|
308
|
+
`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async insert(payload: PaymentPayload): Promise<void> {
|
|
312
|
+
const stmt = this.db.prepare(`
|
|
313
|
+
INSERT OR IGNORE INTO payments
|
|
314
|
+
(id, facilitator, request_id, from_addr, to_addr, amount, asset, chain_id, tx_hash, memo, metadata, created_at)
|
|
315
|
+
VALUES
|
|
316
|
+
(@id, @facilitator, @requestId, @fromAddress, @toAddress, @amount, @asset, @chainId, @txHash, @memo, @metadata, @createdAt)
|
|
317
|
+
`);
|
|
318
|
+
stmt.run({
|
|
319
|
+
id: payload.id,
|
|
320
|
+
facilitator: payload.facilitator,
|
|
321
|
+
requestId: payload.requestId,
|
|
322
|
+
fromAddress: payload.fromAddress,
|
|
323
|
+
toAddress: payload.toAddress,
|
|
324
|
+
amount: payload.amount,
|
|
325
|
+
asset: payload.asset,
|
|
326
|
+
chainId: payload.chainId,
|
|
327
|
+
txHash: payload.txHash,
|
|
328
|
+
memo: payload.memo ?? null,
|
|
329
|
+
metadata: payload.metadata ? JSON.stringify(payload.metadata) : null,
|
|
330
|
+
createdAt: payload.timestamp,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async list(opts?: { facilitator?: string; limit?: number; offset?: number }): Promise<PaymentPayload[]> {
|
|
335
|
+
const conditions: string[] = [];
|
|
336
|
+
const params: Record<string, unknown> = {};
|
|
337
|
+
|
|
338
|
+
if (opts?.facilitator) {
|
|
339
|
+
conditions.push("facilitator = @facilitator");
|
|
340
|
+
params.facilitator = opts.facilitator;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
344
|
+
const limit = opts?.limit ?? 50;
|
|
345
|
+
const offset = opts?.offset ?? 0;
|
|
346
|
+
|
|
347
|
+
const rows = this.db.prepare(
|
|
348
|
+
`SELECT * FROM payments ${where} ORDER BY created_at DESC LIMIT @limit OFFSET @offset`
|
|
349
|
+
).all({ ...params, limit, offset }) as Record<string, unknown>[];
|
|
350
|
+
|
|
351
|
+
return rows.map(rowToPayload);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async get(id: string): Promise<PaymentPayload | null> {
|
|
355
|
+
const row = this.db.prepare("SELECT * FROM payments WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
|
356
|
+
return row ? rowToPayload(row) : null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async close(): Promise<void> {
|
|
360
|
+
this.db.close();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function rowToPayload(row: Record<string, unknown>): PaymentPayload {
|
|
365
|
+
return {
|
|
366
|
+
id: row.id as string,
|
|
367
|
+
facilitator: row.facilitator as string,
|
|
368
|
+
requestId: row.request_id as string,
|
|
369
|
+
fromAddress: row.from_addr as string,
|
|
370
|
+
toAddress: row.to_addr as string,
|
|
371
|
+
amount: row.amount as string,
|
|
372
|
+
asset: row.asset as string,
|
|
373
|
+
chainId: row.chain_id as string,
|
|
374
|
+
txHash: row.tx_hash as string,
|
|
375
|
+
memo: (row.memo as string) ?? undefined,
|
|
376
|
+
metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined,
|
|
377
|
+
timestamp: row.created_at as number,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
- [ ] **Step 4: Run tests — should pass**
|
|
383
|
+
|
|
384
|
+
Run: `cd ~/Projects/tally && npx vitest run test/store.test.ts`
|
|
385
|
+
Expected: 5 passed
|
|
386
|
+
|
|
387
|
+
- [ ] **Step 5: Commit**
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
cd ~/Projects/tally && git add src/store.ts test/store.test.ts && git commit -m "feat: add SQLite store"
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
### Task 4: x402 V2 hook
|
|
396
|
+
|
|
397
|
+
**Files:**
|
|
398
|
+
- Create: `src/hook.ts`
|
|
399
|
+
- Test: `test/hook.test.ts`
|
|
400
|
+
|
|
401
|
+
- [ ] **Step 1: Write the hook test (TDD)**
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
405
|
+
import { createTallyFetch } from "../src/hook.js";
|
|
406
|
+
import type { Store, PaymentPayload } from "../src/types.js";
|
|
407
|
+
|
|
408
|
+
function createMockStore(): Store {
|
|
409
|
+
const inserts: PaymentPayload[] = [];
|
|
410
|
+
return {
|
|
411
|
+
insert: vi.fn(async (p: PaymentPayload) => { inserts.push(p); }),
|
|
412
|
+
list: vi.fn(async () => []),
|
|
413
|
+
get: vi.fn(async () => null),
|
|
414
|
+
close: vi.fn(async () => {}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
describe("createTallyFetch", () => {
|
|
419
|
+
let store: Store;
|
|
420
|
+
|
|
421
|
+
beforeEach(() => {
|
|
422
|
+
store = createMockStore();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("passes through non-402 responses", async () => {
|
|
426
|
+
const mockFetch = vi.fn(async () => new Response("ok", { status: 200 }));
|
|
427
|
+
const tallyFetch = createTallyFetch(mockFetch, store);
|
|
428
|
+
|
|
429
|
+
const res = await tallyFetch("https://api.example.com/data");
|
|
430
|
+
expect(res.status).toBe(200);
|
|
431
|
+
expect(store.insert).not.toHaveBeenCalled();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("extracts PaymentPayload from 402 response body", async () => {
|
|
435
|
+
const paymentPayload = {
|
|
436
|
+
requestId: "req-1",
|
|
437
|
+
fromAddress: "0xsender",
|
|
438
|
+
toAddress: "0xreceiver",
|
|
439
|
+
amount: "1.50",
|
|
440
|
+
asset: "USDC",
|
|
441
|
+
chainId: "eip155:8453",
|
|
442
|
+
txHash: "0xtx",
|
|
443
|
+
timestamp: Date.now(),
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const mockFetch = vi.fn(async () =>
|
|
447
|
+
Response.json(paymentPayload, {
|
|
448
|
+
status: 200,
|
|
449
|
+
headers: { "x-facilitator": "dexter", "x-request-id": "req-1" },
|
|
450
|
+
})
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const tallyFetch = createTallyFetch(mockFetch, store, { facilitator: "dexter" });
|
|
454
|
+
await tallyFetch("https://api.example.com/pay");
|
|
455
|
+
|
|
456
|
+
expect(store.insert).toHaveBeenCalledTimes(1);
|
|
457
|
+
const inserted = (store.insert as ReturnType<typeof vi.fn>).mock.calls[0][0] as PaymentPayload;
|
|
458
|
+
expect(inserted.facilitator).toBe("dexter");
|
|
459
|
+
expect(inserted.amount).toBe("1.50");
|
|
460
|
+
expect(inserted.chainId).toBe("eip155:8453");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("generates an id when none is in the response", async () => {
|
|
464
|
+
const mockFetch = vi.fn(async () =>
|
|
465
|
+
Response.json({ amount: "5.00" }, {
|
|
466
|
+
status: 200,
|
|
467
|
+
headers: { "x-facilitator": "dexter", "x-request-id": "req-2" },
|
|
468
|
+
})
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const tallyFetch = createTallyFetch(mockFetch, store, { facilitator: "dexter" });
|
|
472
|
+
await tallyFetch("https://api.example.com/pay");
|
|
473
|
+
|
|
474
|
+
expect(store.insert).toHaveBeenCalledTimes(1);
|
|
475
|
+
const inserted = (store.insert as ReturnType<typeof vi.fn>).mock.calls[0][0] as PaymentPayload;
|
|
476
|
+
expect(inserted.id).toBeDefined();
|
|
477
|
+
expect(inserted.id.length).toBeGreaterThan(0);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("does not store if the response is not a payment", async () => {
|
|
481
|
+
const mockFetch = vi.fn(async () =>
|
|
482
|
+
Response.json({ error: "not found" }, { status: 404 })
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const tallyFetch = createTallyFetch(mockFetch, store);
|
|
486
|
+
const res = await tallyFetch("https://api.example.com/nope");
|
|
487
|
+
expect(res.status).toBe(404);
|
|
488
|
+
expect(store.insert).not.toHaveBeenCalled();
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
- [ ] **Step 2: Run tests — should fail**
|
|
494
|
+
|
|
495
|
+
Run: `cd ~/Projects/tally && npx vitest run test/hook.test.ts`
|
|
496
|
+
Expected: 0 passed, ImportError
|
|
497
|
+
|
|
498
|
+
- [ ] **Step 3: Implement src/hook.ts**
|
|
499
|
+
|
|
500
|
+
```typescript
|
|
501
|
+
import { v4 as uuidv4 } from "uuid";
|
|
502
|
+
import type { PaymentPayload, Store } from "./types.js";
|
|
503
|
+
|
|
504
|
+
export interface HookOptions {
|
|
505
|
+
facilitator: string;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function isPaymentResponse(body: unknown, headers: Headers): boolean {
|
|
509
|
+
if (headers.has("x-facilitator") || headers.has("x-request-id")) return true;
|
|
510
|
+
if (body && typeof body === "object" && "requestId" in (body as Record<string, unknown>)) return true;
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function extractPayload(body: unknown, headers: Headers, options: HookOptions): PaymentPayload {
|
|
515
|
+
const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
|
|
516
|
+
|
|
517
|
+
const now = Date.now();
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
id: (b.id as string) ?? uuidv4(),
|
|
521
|
+
facilitator: options.facilitator,
|
|
522
|
+
requestId: (b.requestId as string) ?? headers.get("x-request-id") ?? "",
|
|
523
|
+
fromAddress: (b.fromAddress as string) ?? "",
|
|
524
|
+
toAddress: (b.toAddress as string) ?? headers.get("x-payment-address") ?? "",
|
|
525
|
+
amount: (b.amount as string) ?? headers.get("x-payment-amount") ?? "0",
|
|
526
|
+
asset: (b.asset as string) ?? headers.get("x-payment-asset") ?? "USDC",
|
|
527
|
+
chainId: (b.chainId as string) ?? "eip155:1",
|
|
528
|
+
txHash: (b.txHash as string) ?? "",
|
|
529
|
+
timestamp: (b.timestamp as number) ?? now,
|
|
530
|
+
memo: (b.memo as string) ?? undefined,
|
|
531
|
+
metadata: b.metadata as Record<string, unknown> ?? undefined,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function createTallyFetch(
|
|
536
|
+
fetchFn: typeof fetch,
|
|
537
|
+
store: Store,
|
|
538
|
+
options?: HookOptions
|
|
539
|
+
): typeof fetch {
|
|
540
|
+
return async (input, init) => {
|
|
541
|
+
const response = await fetchFn(input, init);
|
|
542
|
+
const cloned = response.clone();
|
|
543
|
+
|
|
544
|
+
if (options && isPaymentResponse(cloned, cloned.headers)) {
|
|
545
|
+
try {
|
|
546
|
+
const body = await cloned.json();
|
|
547
|
+
const payload = extractPayload(body, cloned.headers, options);
|
|
548
|
+
await store.insert(payload);
|
|
549
|
+
} catch {
|
|
550
|
+
// swallow — never break the agent request
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return response;
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
**Design note:** The hook uses a best-effort extraction approach. It looks at response headers and body for x402 payment metadata. If extraction fails (e.g., malformed body), it silently swallows the error so the agent flow is never broken. Facilitator-specific adapters can provide richer extraction in future iterations.
|
|
560
|
+
|
|
561
|
+
**Flagged gaps (from design review, tracked for follow-up):**
|
|
562
|
+
1. Error handling — store failures are swallowed. Future: add configurable error handler.
|
|
563
|
+
2. `timestamp` vs `created_at` — payload.timestamp = when payment happened, store.created_at = when persisted. Current code maps both to `timestamp` for simplicity.
|
|
564
|
+
3. `close()` lifecycle — TallyClient.close() to be called on shutdown. Documented at API level.
|
|
565
|
+
|
|
566
|
+
- [ ] **Step 4: Run tests — should pass**
|
|
567
|
+
|
|
568
|
+
Run: `cd ~/Projects/tally && npx vitest run test/hook.test.ts`
|
|
569
|
+
Expected: 4 passed
|
|
570
|
+
|
|
571
|
+
- [ ] **Step 5: Commit**
|
|
572
|
+
|
|
573
|
+
```bash
|
|
574
|
+
cd ~/Projects/tally && git add src/hook.ts test/hook.test.ts && git commit -m "feat: add x402 V2 hook"
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
---
|
|
578
|
+
|
|
579
|
+
### Task 5: Public API (createTally)
|
|
580
|
+
|
|
581
|
+
**Files:**
|
|
582
|
+
- Modify: `src/index.ts`
|
|
583
|
+
- Modify: `src/types.ts` (add TallyClient interface already done in Task 2)
|
|
584
|
+
- Test: `test/index.test.ts`
|
|
585
|
+
|
|
586
|
+
- [ ] **Step 1: Write public API test**
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
import { describe, it, expect } from "vitest";
|
|
590
|
+
import { createTally } from "../src/index.js";
|
|
591
|
+
|
|
592
|
+
describe("createTally", () => {
|
|
593
|
+
it("returns a TallyClient with a store", () => {
|
|
594
|
+
const tally = createTally({ facilitator: "dexter" });
|
|
595
|
+
expect(tally.fetch).toBeDefined();
|
|
596
|
+
expect(tally.wrap).toBeDefined();
|
|
597
|
+
expect(tally.store).toBeDefined();
|
|
598
|
+
expect(tally.close).toBeDefined();
|
|
599
|
+
tally.close();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("accepts a custom store", () => {
|
|
603
|
+
const customStore = {
|
|
604
|
+
insert: async () => {},
|
|
605
|
+
list: async () => [],
|
|
606
|
+
get: async () => null,
|
|
607
|
+
close: async () => {},
|
|
608
|
+
};
|
|
609
|
+
const tally = createTally({ facilitator: "dexter", store: customStore });
|
|
610
|
+
expect(tally.store).toBe(customStore);
|
|
611
|
+
tally.close();
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
- [ ] **Step 2: Run test — should fail**
|
|
617
|
+
|
|
618
|
+
Run: `cd ~/Projects/tally && npx vitest run test/index.test.ts`
|
|
619
|
+
Expected: ImportError (createTally not exported yet)
|
|
620
|
+
|
|
621
|
+
- [ ] **Step 3: Implement src/index.ts (replace placeholder)**
|
|
622
|
+
|
|
623
|
+
```typescript
|
|
624
|
+
import { SqliteStore } from "./store.js";
|
|
625
|
+
import { createTallyFetch } from "./hook.js";
|
|
626
|
+
import type { Store, TallyClient, TallyConfig } from "./types.js";
|
|
627
|
+
|
|
628
|
+
export type { PaymentPayload, Store, TallyConfig, TallyClient } from "./types.js";
|
|
629
|
+
|
|
630
|
+
export function createTally(config: TallyConfig): TallyClient {
|
|
631
|
+
const store: Store = config.store ?? new SqliteStore("./tally.db");
|
|
632
|
+
const wrappedFetch = createTallyFetch(
|
|
633
|
+
globalThis.fetch.bind(globalThis),
|
|
634
|
+
store,
|
|
635
|
+
{ facilitator: config.facilitator }
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
return {
|
|
639
|
+
fetch: wrappedFetch,
|
|
640
|
+
wrap() {
|
|
641
|
+
const original = globalThis.fetch.bind(globalThis);
|
|
642
|
+
globalThis.fetch = wrappedFetch;
|
|
643
|
+
},
|
|
644
|
+
store,
|
|
645
|
+
async close() {
|
|
646
|
+
await store.close();
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
- [ ] **Step 4: Run tests — should pass**
|
|
653
|
+
|
|
654
|
+
Run: `cd ~/Projects/tally && npx vitest run test/index.test.ts`
|
|
655
|
+
Expected: 2 passed
|
|
656
|
+
|
|
657
|
+
- [ ] **Step 5: Commit**
|
|
658
|
+
|
|
659
|
+
```bash
|
|
660
|
+
cd ~/Projects/tally && git add src/index.ts test/index.test.ts && git commit -m "feat: add public API (createTally)"
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
---
|
|
664
|
+
|
|
665
|
+
### Task 6: Integration test — full pipeline
|
|
666
|
+
|
|
667
|
+
**Files:**
|
|
668
|
+
- Create: `test/integration.test.ts`
|
|
669
|
+
|
|
670
|
+
- [ ] **Step 1: Write integration test**
|
|
671
|
+
|
|
672
|
+
```typescript
|
|
673
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
674
|
+
import { createTally } from "../src/index.js";
|
|
675
|
+
import { SqliteStore } from "../src/store.js";
|
|
676
|
+
import type { Store } from "../src/types.js";
|
|
677
|
+
|
|
678
|
+
describe("integration", () => {
|
|
679
|
+
let store: Store;
|
|
680
|
+
|
|
681
|
+
afterEach(async () => {
|
|
682
|
+
await store.close();
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("captures a payment end-to-end", async () => {
|
|
686
|
+
const tally = createTally({ facilitator: "dexter" });
|
|
687
|
+
store = tally.store;
|
|
688
|
+
|
|
689
|
+
// Verify store is ready
|
|
690
|
+
const list = await tally.store.list();
|
|
691
|
+
expect(list).toEqual([]);
|
|
692
|
+
|
|
693
|
+
tally.close();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("manual pipeline: hook captures, store persists", async () => {
|
|
697
|
+
const customStore = new SqliteStore(":memory:");
|
|
698
|
+
store = customStore;
|
|
699
|
+
|
|
700
|
+
const fetchSpy = async () =>
|
|
701
|
+
Response.json(
|
|
702
|
+
{ requestId: "req-int", amount: "3.00", asset: "USDC", chainId: "eip155:1", txHash: "0xint" },
|
|
703
|
+
{ status: 200, headers: { "x-facilitator": "dexter", "x-request-id": "req-int" } }
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
const { createTallyFetch } = await import("../src/hook.js");
|
|
707
|
+
const tallyFetch = createTallyFetch(fetchSpy, customStore, { facilitator: "dexter" });
|
|
708
|
+
|
|
709
|
+
await tallyFetch("https://api.example.com/pay");
|
|
710
|
+
|
|
711
|
+
const all = await customStore.list();
|
|
712
|
+
expect(all).toHaveLength(1);
|
|
713
|
+
expect(all[0].amount).toBe("3.00");
|
|
714
|
+
expect(all[0].facilitator).toBe("dexter");
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
- [ ] **Step 2: Run tests**
|
|
720
|
+
|
|
721
|
+
Run: `cd ~/Projects/tally && npx vitest run test/integration.test.ts`
|
|
722
|
+
Expected: 2 passed
|
|
723
|
+
|
|
724
|
+
- [ ] **Step 3: Run full test suite — all 13 tests should pass**
|
|
725
|
+
|
|
726
|
+
Run: `cd ~/Projects/tally && npx vitest run`
|
|
727
|
+
Expected: All tests pass, exit code 0
|
|
728
|
+
|
|
729
|
+
- [ ] **Step 4: Commit**
|
|
730
|
+
|
|
731
|
+
```bash
|
|
732
|
+
cd ~/Projects/tally && git add test/integration.test.ts && git commit -m "test: add end-to-end integration test"
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
---
|
|
736
|
+
|
|
737
|
+
### Task 7: Final verification
|
|
738
|
+
|
|
739
|
+
- [ ] **Step 1: TypeScript check**
|
|
740
|
+
|
|
741
|
+
Run: `cd ~/Projects/tally && npx tsc --noEmit`
|
|
742
|
+
Expected: No errors
|
|
743
|
+
|
|
744
|
+
- [ ] **Step 2: Full test suite**
|
|
745
|
+
|
|
746
|
+
Run: `cd ~/Projects/tally && npx vitest run`
|
|
747
|
+
Expected: All 13+ tests pass
|
|
748
|
+
|
|
749
|
+
- [ ] **Step 3: Verify git status is clean**
|
|
750
|
+
|
|
751
|
+
Run: `cd ~/Projects/tally && git status`
|
|
752
|
+
Expected: nothing to commit, working tree clean
|