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
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tally-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Accounting, tax, and compliance layer for the agent economy",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"x402",
|
|
21
|
+
"agent",
|
|
22
|
+
"accounting",
|
|
23
|
+
"crypto"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"better-sqlite3": "^11.0.0",
|
|
28
|
+
"uuid": "^11.1.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
32
|
+
"@types/uuid": "^10.0.0",
|
|
33
|
+
"typescript": "^5.5.0",
|
|
34
|
+
"vitest": "^2.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/hook.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import type { PaymentPayload, Store } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export interface HookOptions {
|
|
5
|
+
facilitator: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function extractPayload(body: unknown, headers: Headers, options: HookOptions): PaymentPayload {
|
|
9
|
+
const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
id: (b.id as string) ?? uuidv4(),
|
|
14
|
+
facilitator: options.facilitator,
|
|
15
|
+
requestId: (b.requestId as string) ?? headers.get("x-request-id") ?? "",
|
|
16
|
+
fromAddress: (b.fromAddress as string) ?? "",
|
|
17
|
+
toAddress: (b.toAddress as string) ?? headers.get("x-payment-address") ?? "",
|
|
18
|
+
amount: (b.amount as string) ?? headers.get("x-payment-amount") ?? "0",
|
|
19
|
+
asset: (b.asset as string) ?? headers.get("x-payment-asset") ?? "USDC",
|
|
20
|
+
chainId: (b.chainId as string) ?? "eip155:1",
|
|
21
|
+
txHash: (b.txHash as string) ?? "",
|
|
22
|
+
timestamp: (b.timestamp as number) ?? now,
|
|
23
|
+
memo: (b.memo as string) ?? undefined,
|
|
24
|
+
metadata: b.metadata as Record<string, unknown> ?? undefined,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createTallyFetch(
|
|
29
|
+
fetchFn: typeof fetch,
|
|
30
|
+
store: Store,
|
|
31
|
+
options?: HookOptions
|
|
32
|
+
): typeof fetch {
|
|
33
|
+
return async (input, init) => {
|
|
34
|
+
const response = await fetchFn(input, init);
|
|
35
|
+
|
|
36
|
+
if (options && (response.headers.has("x-facilitator") || response.headers.has("x-request-id"))) {
|
|
37
|
+
try {
|
|
38
|
+
const cloned = response.clone();
|
|
39
|
+
const body = await cloned.json();
|
|
40
|
+
const payload = extractPayload(body, cloned.headers, options);
|
|
41
|
+
await store.insert(payload);
|
|
42
|
+
} catch {
|
|
43
|
+
// swallow — never break the agent request
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return response;
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { SqliteStore } from "./store.js";
|
|
2
|
+
import { createTallyFetch } from "./hook.js";
|
|
3
|
+
import type { Store, TallyClient, TallyConfig } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type { PaymentPayload, Store, TallyConfig, TallyClient } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export function createTally(config: TallyConfig): TallyClient {
|
|
8
|
+
const store: Store = config.store ?? new SqliteStore("./tally.db");
|
|
9
|
+
const originalFetch = globalThis.fetch;
|
|
10
|
+
const wrappedFetch = createTallyFetch(
|
|
11
|
+
originalFetch.bind(globalThis),
|
|
12
|
+
store,
|
|
13
|
+
{ facilitator: config.facilitator }
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
fetch: wrappedFetch,
|
|
18
|
+
wrap() {
|
|
19
|
+
globalThis.fetch = wrappedFetch;
|
|
20
|
+
},
|
|
21
|
+
unwrap() {
|
|
22
|
+
globalThis.fetch = originalFetch;
|
|
23
|
+
},
|
|
24
|
+
store,
|
|
25
|
+
async close() {
|
|
26
|
+
await store.close();
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import type { PaymentPayload, Store } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export class SqliteStore implements Store {
|
|
5
|
+
private db: Database.Database;
|
|
6
|
+
|
|
7
|
+
constructor(path: string) {
|
|
8
|
+
this.db = new Database(path);
|
|
9
|
+
this.db.exec(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS payments (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
facilitator TEXT NOT NULL,
|
|
13
|
+
request_id TEXT NOT NULL,
|
|
14
|
+
from_addr TEXT NOT NULL,
|
|
15
|
+
to_addr TEXT NOT NULL,
|
|
16
|
+
amount TEXT NOT NULL,
|
|
17
|
+
asset TEXT NOT NULL DEFAULT 'USDC',
|
|
18
|
+
chain_id TEXT NOT NULL,
|
|
19
|
+
tx_hash TEXT NOT NULL,
|
|
20
|
+
memo TEXT,
|
|
21
|
+
metadata TEXT,
|
|
22
|
+
created_at INTEGER NOT NULL
|
|
23
|
+
);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_payments_facilitator ON payments(facilitator);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at);
|
|
26
|
+
`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async insert(payload: PaymentPayload): Promise<void> {
|
|
30
|
+
const stmt = this.db.prepare(`
|
|
31
|
+
INSERT OR IGNORE INTO payments
|
|
32
|
+
(id, facilitator, request_id, from_addr, to_addr, amount, asset, chain_id, tx_hash, memo, metadata, created_at)
|
|
33
|
+
VALUES
|
|
34
|
+
(@id, @facilitator, @requestId, @fromAddress, @toAddress, @amount, @asset, @chainId, @txHash, @memo, @metadata, @createdAt)
|
|
35
|
+
`);
|
|
36
|
+
stmt.run({
|
|
37
|
+
id: payload.id,
|
|
38
|
+
facilitator: payload.facilitator,
|
|
39
|
+
requestId: payload.requestId,
|
|
40
|
+
fromAddress: payload.fromAddress,
|
|
41
|
+
toAddress: payload.toAddress,
|
|
42
|
+
amount: payload.amount,
|
|
43
|
+
asset: payload.asset,
|
|
44
|
+
chainId: payload.chainId,
|
|
45
|
+
txHash: payload.txHash,
|
|
46
|
+
memo: payload.memo ?? null,
|
|
47
|
+
metadata: payload.metadata ? JSON.stringify(payload.metadata) : null,
|
|
48
|
+
createdAt: payload.timestamp,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async list(opts?: { facilitator?: string; limit?: number; offset?: number }): Promise<PaymentPayload[]> {
|
|
53
|
+
const conditions: string[] = [];
|
|
54
|
+
const params: Record<string, unknown> = {};
|
|
55
|
+
|
|
56
|
+
if (opts?.facilitator) {
|
|
57
|
+
conditions.push("facilitator = @facilitator");
|
|
58
|
+
params.facilitator = opts.facilitator;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
62
|
+
const limit = opts?.limit ?? 50;
|
|
63
|
+
const offset = opts?.offset ?? 0;
|
|
64
|
+
|
|
65
|
+
const rows = this.db.prepare(
|
|
66
|
+
`SELECT * FROM payments ${where} ORDER BY created_at DESC LIMIT @limit OFFSET @offset`
|
|
67
|
+
).all({ ...params, limit, offset }) as Record<string, unknown>[];
|
|
68
|
+
|
|
69
|
+
return rows.map(rowToPayload);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async get(id: string): Promise<PaymentPayload | null> {
|
|
73
|
+
const row = this.db.prepare("SELECT * FROM payments WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
|
74
|
+
return row ? rowToPayload(row) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async close(): Promise<void> {
|
|
78
|
+
this.db.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function rowToPayload(row: Record<string, unknown>): PaymentPayload {
|
|
83
|
+
return {
|
|
84
|
+
id: row.id as string,
|
|
85
|
+
facilitator: row.facilitator as string,
|
|
86
|
+
requestId: row.request_id as string,
|
|
87
|
+
fromAddress: row.from_addr as string,
|
|
88
|
+
toAddress: row.to_addr as string,
|
|
89
|
+
amount: row.amount as string,
|
|
90
|
+
asset: row.asset as string,
|
|
91
|
+
chainId: row.chain_id as string,
|
|
92
|
+
txHash: row.tx_hash as string,
|
|
93
|
+
memo: (row.memo as string) ?? undefined,
|
|
94
|
+
metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined,
|
|
95
|
+
timestamp: row.created_at as number,
|
|
96
|
+
};
|
|
97
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface PaymentPayload {
|
|
2
|
+
id: string
|
|
3
|
+
facilitator: string
|
|
4
|
+
requestId: string
|
|
5
|
+
fromAddress: string
|
|
6
|
+
toAddress: string
|
|
7
|
+
amount: string
|
|
8
|
+
asset: string
|
|
9
|
+
chainId: string
|
|
10
|
+
txHash: string
|
|
11
|
+
timestamp: number
|
|
12
|
+
memo?: string
|
|
13
|
+
metadata?: Record<string, unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Store {
|
|
17
|
+
insert(payload: PaymentPayload): Promise<void>
|
|
18
|
+
list(opts?: { facilitator?: string; limit?: number; offset?: number }): Promise<PaymentPayload[]>
|
|
19
|
+
get(id: string): Promise<PaymentPayload | null>
|
|
20
|
+
close(): Promise<void>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TallyConfig {
|
|
24
|
+
facilitator: string
|
|
25
|
+
store?: Store
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TallyClient {
|
|
29
|
+
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>
|
|
30
|
+
wrap(): void
|
|
31
|
+
unwrap(): void
|
|
32
|
+
store: Store
|
|
33
|
+
close(): Promise<void>
|
|
34
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createTallyFetch } from "../src/hook.js";
|
|
3
|
+
import type { Store, PaymentPayload } from "../src/types.js";
|
|
4
|
+
|
|
5
|
+
function createMockStore(): Store {
|
|
6
|
+
const inserts: PaymentPayload[] = [];
|
|
7
|
+
return {
|
|
8
|
+
insert: vi.fn(async (p: PaymentPayload) => { inserts.push(p); }),
|
|
9
|
+
list: vi.fn(async () => []),
|
|
10
|
+
get: vi.fn(async () => null),
|
|
11
|
+
close: vi.fn(async () => {}),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("createTallyFetch", () => {
|
|
16
|
+
let store: Store;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
store = createMockStore();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("passes through non-payment responses", async () => {
|
|
23
|
+
const mockFetch = vi.fn(async () => new Response("ok", { status: 200 }));
|
|
24
|
+
const tallyFetch = createTallyFetch(mockFetch, store);
|
|
25
|
+
|
|
26
|
+
const res = await tallyFetch("https://api.example.com/data");
|
|
27
|
+
expect(res.status).toBe(200);
|
|
28
|
+
expect(store.insert).not.toHaveBeenCalled();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("extracts PaymentPayload from response with x-facilitator header", async () => {
|
|
32
|
+
const paymentPayload = {
|
|
33
|
+
requestId: "req-1",
|
|
34
|
+
fromAddress: "0xsender",
|
|
35
|
+
toAddress: "0xreceiver",
|
|
36
|
+
amount: "1.50",
|
|
37
|
+
asset: "USDC",
|
|
38
|
+
chainId: "eip155:8453",
|
|
39
|
+
txHash: "0xtx",
|
|
40
|
+
timestamp: Date.now(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const mockFetch = vi.fn(async () =>
|
|
44
|
+
Response.json(paymentPayload, {
|
|
45
|
+
status: 200,
|
|
46
|
+
headers: { "x-facilitator": "dexter", "x-request-id": "req-1" },
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const tallyFetch = createTallyFetch(mockFetch, store, { facilitator: "dexter" });
|
|
51
|
+
await tallyFetch("https://api.example.com/pay");
|
|
52
|
+
|
|
53
|
+
expect(store.insert).toHaveBeenCalledTimes(1);
|
|
54
|
+
const inserted = (store.insert as ReturnType<typeof vi.fn>).mock.calls[0][0] as PaymentPayload;
|
|
55
|
+
expect(inserted.facilitator).toBe("dexter");
|
|
56
|
+
expect(inserted.amount).toBe("1.50");
|
|
57
|
+
expect(inserted.chainId).toBe("eip155:8453");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("generates an id when none is in the response", async () => {
|
|
61
|
+
const mockFetch = vi.fn(async () =>
|
|
62
|
+
Response.json({ amount: "5.00" }, {
|
|
63
|
+
status: 200,
|
|
64
|
+
headers: { "x-facilitator": "dexter", "x-request-id": "req-2" },
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const tallyFetch = createTallyFetch(mockFetch, store, { facilitator: "dexter" });
|
|
69
|
+
await tallyFetch("https://api.example.com/pay");
|
|
70
|
+
|
|
71
|
+
expect(store.insert).toHaveBeenCalledTimes(1);
|
|
72
|
+
const inserted = (store.insert as ReturnType<typeof vi.fn>).mock.calls[0][0] as PaymentPayload;
|
|
73
|
+
expect(inserted.id).toBeDefined();
|
|
74
|
+
expect(inserted.id.length).toBeGreaterThan(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not store on error responses", async () => {
|
|
78
|
+
const mockFetch = vi.fn(async () =>
|
|
79
|
+
Response.json({ error: "not found" }, { status: 404 })
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const tallyFetch = createTallyFetch(mockFetch, store);
|
|
83
|
+
const res = await tallyFetch("https://api.example.com/nope");
|
|
84
|
+
expect(res.status).toBe(404);
|
|
85
|
+
expect(store.insert).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createTally } from "../src/index.js";
|
|
3
|
+
|
|
4
|
+
describe("createTally", () => {
|
|
5
|
+
it("returns a TallyClient with a store", () => {
|
|
6
|
+
const tally = createTally({ facilitator: "dexter" });
|
|
7
|
+
expect(tally.fetch).toBeDefined();
|
|
8
|
+
expect(tally.wrap).toBeDefined();
|
|
9
|
+
expect(tally.unwrap).toBeDefined();
|
|
10
|
+
expect(tally.store).toBeDefined();
|
|
11
|
+
expect(tally.close).toBeDefined();
|
|
12
|
+
tally.close();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("accepts a custom store", () => {
|
|
16
|
+
const customStore = {
|
|
17
|
+
insert: async () => {},
|
|
18
|
+
list: async () => [],
|
|
19
|
+
get: async () => null,
|
|
20
|
+
close: async () => {},
|
|
21
|
+
};
|
|
22
|
+
const tally = createTally({ facilitator: "dexter", store: customStore });
|
|
23
|
+
expect(tally.store).toBe(customStore);
|
|
24
|
+
tally.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("wrap and unwrap monkey-patches and restores globalThis.fetch", () => {
|
|
28
|
+
const originalFetch = globalThis.fetch;
|
|
29
|
+
const tally = createTally({ facilitator: "dexter" });
|
|
30
|
+
tally.wrap();
|
|
31
|
+
expect(globalThis.fetch).not.toBe(originalFetch);
|
|
32
|
+
tally.unwrap();
|
|
33
|
+
expect(globalThis.fetch).toBe(originalFetch);
|
|
34
|
+
tally.close();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { createTally } from "../src/index.js";
|
|
3
|
+
import { SqliteStore } from "../src/store.js";
|
|
4
|
+
import type { Store } from "../src/types.js";
|
|
5
|
+
|
|
6
|
+
describe("integration", () => {
|
|
7
|
+
let store: Store;
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
await store.close();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("captures a payment end-to-end", async () => {
|
|
14
|
+
const tally = createTally({ facilitator: "dexter" });
|
|
15
|
+
store = tally.store;
|
|
16
|
+
|
|
17
|
+
// Verify store is ready
|
|
18
|
+
const list = await tally.store.list();
|
|
19
|
+
expect(list).toEqual([]);
|
|
20
|
+
|
|
21
|
+
tally.close();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("manual pipeline: hook captures, store persists", async () => {
|
|
25
|
+
const customStore = new SqliteStore(":memory:");
|
|
26
|
+
store = customStore;
|
|
27
|
+
|
|
28
|
+
const fetchSpy = async () =>
|
|
29
|
+
Response.json(
|
|
30
|
+
{ requestId: "req-int", amount: "3.00", asset: "USDC", chainId: "eip155:1", txHash: "0xint" },
|
|
31
|
+
{ status: 200, headers: { "x-facilitator": "dexter", "x-request-id": "req-int" } }
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const { createTallyFetch } = await import("../src/hook.js");
|
|
35
|
+
const tallyFetch = createTallyFetch(fetchSpy, customStore, { facilitator: "dexter" });
|
|
36
|
+
|
|
37
|
+
await tallyFetch("https://api.example.com/pay");
|
|
38
|
+
|
|
39
|
+
const all = await customStore.list();
|
|
40
|
+
expect(all).toHaveLength(1);
|
|
41
|
+
expect(all[0].amount).toBe("3.00");
|
|
42
|
+
expect(all[0].facilitator).toBe("dexter");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { SqliteStore } from "../src/store.js";
|
|
3
|
+
import type { PaymentPayload } from "../src/types.js";
|
|
4
|
+
|
|
5
|
+
function makePayload(overrides?: Partial<PaymentPayload>): PaymentPayload {
|
|
6
|
+
return {
|
|
7
|
+
id: overrides?.id ?? "test-1",
|
|
8
|
+
facilitator: overrides?.facilitator ?? "dexter",
|
|
9
|
+
requestId: "req-1",
|
|
10
|
+
fromAddress: "0xsender",
|
|
11
|
+
toAddress: "0xreceiver",
|
|
12
|
+
amount: "10.00",
|
|
13
|
+
asset: "USDC",
|
|
14
|
+
chainId: "eip155:1",
|
|
15
|
+
txHash: "0xtx",
|
|
16
|
+
timestamp: Date.now(),
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("SqliteStore", () => {
|
|
22
|
+
let store: SqliteStore;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
store = new SqliteStore(":memory:");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
store.close();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("inserts and retrieves a payment", async () => {
|
|
33
|
+
const payload = makePayload();
|
|
34
|
+
await store.insert(payload);
|
|
35
|
+
const result = await store.get(payload.id);
|
|
36
|
+
expect(result).not.toBeNull();
|
|
37
|
+
expect(result!.id).toBe(payload.id);
|
|
38
|
+
expect(result!.amount).toBe("10.00");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns null for unknown id", async () => {
|
|
42
|
+
const result = await store.get("nonexistent");
|
|
43
|
+
expect(result).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("lists payments ordered by created_at desc", async () => {
|
|
47
|
+
const p1 = makePayload({ id: "1", timestamp: 1000 });
|
|
48
|
+
const p2 = makePayload({ id: "2", timestamp: 2000 });
|
|
49
|
+
await store.insert(p1);
|
|
50
|
+
await store.insert(p2);
|
|
51
|
+
const all = await store.list();
|
|
52
|
+
expect(all).toHaveLength(2);
|
|
53
|
+
expect(all[0].id).toBe("2"); // newest first
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("filters by facilitator", async () => {
|
|
57
|
+
await store.insert(makePayload({ id: "1", facilitator: "dexter" }));
|
|
58
|
+
await store.insert(makePayload({ id: "2", facilitator: "coinbase-cdp" }));
|
|
59
|
+
const filtered = await store.list({ facilitator: "dexter" });
|
|
60
|
+
expect(filtered).toHaveLength(1);
|
|
61
|
+
expect(filtered[0].facilitator).toBe("dexter");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("paginates with limit and offset", async () => {
|
|
65
|
+
for (let i = 0; i < 10; i++) {
|
|
66
|
+
await store.insert(makePayload({ id: `p${i}`, timestamp: i }));
|
|
67
|
+
}
|
|
68
|
+
const page = await store.list({ limit: 3, offset: 0 });
|
|
69
|
+
expect(page).toHaveLength(3);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("handles metadata serialization", async () => {
|
|
73
|
+
const payload = makePayload({ metadata: { foo: "bar", num: 42 } });
|
|
74
|
+
await store.insert(payload);
|
|
75
|
+
const result = await store.get(payload.id);
|
|
76
|
+
expect(result!.metadata).toEqual({ foo: "bar", num: 42 });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("ignores duplicate inserts (idempotency)", async () => {
|
|
80
|
+
const payload = makePayload({ id: "dup-1" });
|
|
81
|
+
await store.insert(payload);
|
|
82
|
+
await store.insert(payload);
|
|
83
|
+
const result = await store.get("dup-1");
|
|
84
|
+
expect(result).not.toBeNull();
|
|
85
|
+
const all = await store.list();
|
|
86
|
+
expect(all).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { PaymentPayload, Store, TallyConfig, TallyClient } from "../src/types.js";
|
|
3
|
+
|
|
4
|
+
describe("types", () => {
|
|
5
|
+
it("PaymentPayload can be constructed", () => {
|
|
6
|
+
const payload: PaymentPayload = {
|
|
7
|
+
id: "abc-123",
|
|
8
|
+
facilitator: "dexter",
|
|
9
|
+
requestId: "req-1",
|
|
10
|
+
fromAddress: "0xsender",
|
|
11
|
+
toAddress: "0xreceiver",
|
|
12
|
+
amount: "10.00",
|
|
13
|
+
asset: "USDC",
|
|
14
|
+
chainId: "eip155:1",
|
|
15
|
+
txHash: "0xtx",
|
|
16
|
+
timestamp: Date.now(),
|
|
17
|
+
};
|
|
18
|
+
expect(payload.amount).toBe("10.00");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("Store interface is structurally typed", () => {
|
|
22
|
+
const store: Store = {
|
|
23
|
+
insert: async () => {},
|
|
24
|
+
list: async () => [],
|
|
25
|
+
get: async () => null,
|
|
26
|
+
close: async () => {},
|
|
27
|
+
};
|
|
28
|
+
expect(store).toBeDefined();
|
|
29
|
+
});
|
|
30
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"outDir": "dist",
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts", "test/**/*.ts"]
|
|
16
|
+
}
|