@wopr-network/platform-core 1.15.0 → 1.16.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.
Files changed (51) hide show
  1. package/dist/billing/crypto/charge-store.d.ts +23 -0
  2. package/dist/billing/crypto/charge-store.js +34 -0
  3. package/dist/billing/crypto/charge-store.test.js +56 -0
  4. package/dist/billing/crypto/evm/__tests__/address-gen.test.d.ts +1 -0
  5. package/dist/billing/crypto/evm/__tests__/address-gen.test.js +54 -0
  6. package/dist/billing/crypto/evm/__tests__/checkout.test.d.ts +1 -0
  7. package/dist/billing/crypto/evm/__tests__/checkout.test.js +54 -0
  8. package/dist/billing/crypto/evm/__tests__/config.test.d.ts +1 -0
  9. package/dist/billing/crypto/evm/__tests__/config.test.js +52 -0
  10. package/dist/billing/crypto/evm/__tests__/settler.test.d.ts +1 -0
  11. package/dist/billing/crypto/evm/__tests__/settler.test.js +196 -0
  12. package/dist/billing/crypto/evm/__tests__/watcher.test.d.ts +1 -0
  13. package/dist/billing/crypto/evm/__tests__/watcher.test.js +109 -0
  14. package/dist/billing/crypto/evm/address-gen.d.ts +8 -0
  15. package/dist/billing/crypto/evm/address-gen.js +29 -0
  16. package/dist/billing/crypto/evm/checkout.d.ts +26 -0
  17. package/dist/billing/crypto/evm/checkout.js +57 -0
  18. package/dist/billing/crypto/evm/config.d.ts +13 -0
  19. package/dist/billing/crypto/evm/config.js +46 -0
  20. package/dist/billing/crypto/evm/index.d.ts +9 -0
  21. package/dist/billing/crypto/evm/index.js +5 -0
  22. package/dist/billing/crypto/evm/settler.d.ts +23 -0
  23. package/dist/billing/crypto/evm/settler.js +60 -0
  24. package/dist/billing/crypto/evm/types.d.ts +40 -0
  25. package/dist/billing/crypto/evm/types.js +1 -0
  26. package/dist/billing/crypto/evm/watcher.d.ts +31 -0
  27. package/dist/billing/crypto/evm/watcher.js +91 -0
  28. package/dist/billing/crypto/index.d.ts +2 -1
  29. package/dist/billing/crypto/index.js +1 -0
  30. package/dist/db/schema/crypto.d.ts +68 -0
  31. package/dist/db/schema/crypto.js +7 -0
  32. package/docs/superpowers/plans/2026-03-14-stablecoin-phase1.md +1413 -0
  33. package/drizzle/migrations/0005_stablecoin_columns.sql +7 -0
  34. package/drizzle/migrations/meta/_journal.json +7 -0
  35. package/package.json +4 -1
  36. package/src/billing/crypto/charge-store.test.ts +61 -0
  37. package/src/billing/crypto/charge-store.ts +54 -0
  38. package/src/billing/crypto/evm/__tests__/address-gen.test.ts +63 -0
  39. package/src/billing/crypto/evm/__tests__/checkout.test.ts +83 -0
  40. package/src/billing/crypto/evm/__tests__/config.test.ts +63 -0
  41. package/src/billing/crypto/evm/__tests__/settler.test.ts +218 -0
  42. package/src/billing/crypto/evm/__tests__/watcher.test.ts +128 -0
  43. package/src/billing/crypto/evm/address-gen.ts +29 -0
  44. package/src/billing/crypto/evm/checkout.ts +82 -0
  45. package/src/billing/crypto/evm/config.ts +50 -0
  46. package/src/billing/crypto/evm/index.ts +16 -0
  47. package/src/billing/crypto/evm/settler.ts +79 -0
  48. package/src/billing/crypto/evm/types.ts +45 -0
  49. package/src/billing/crypto/evm/watcher.ts +126 -0
  50. package/src/billing/crypto/index.ts +2 -1
  51. package/src/db/schema/crypto.ts +7 -0
@@ -0,0 +1,1413 @@
1
+ # Stablecoin Phase 1: USDC on Base — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Accept USDC payments on Base via a self-hosted node, crediting the double-entry ledger with the same invariants as BTCPay.
6
+
7
+ **Architecture:** An EVM watcher polls a self-hosted Base node (`op-geth`) for ERC-20 `Transfer` events on the USDC contract. Each invoice gets a unique deposit address derived from a master xpub via BIP-44 HD derivation. When a Transfer to a watched address is confirmed, the watcher credits the ledger through the existing `ICryptoChargeRepository` + `ILedger` pattern — identical to the BTCPay webhook handler.
8
+
9
+ **Tech Stack:** `viem` (EVM library, ABI encoding, log parsing), `@scure/bip32` + `@scure/base` (HD wallet derivation), existing Drizzle schema + double-entry ledger.
10
+
11
+ **Spec:** `docs/specs/stablecoin-payments.md` (on `docs/stablecoin-spec` branch)
12
+
13
+ ---
14
+
15
+ ## File Map
16
+
17
+ ### New files (platform-core)
18
+
19
+ | File | Responsibility |
20
+ |------|---------------|
21
+ | `src/billing/crypto/evm/types.ts` | `ChainConfig`, `TokenConfig`, `EvmPaymentEvent`, `StablecoinCheckoutOpts` |
22
+ | `src/billing/crypto/evm/config.ts` | Base chain config, USDC contract address, confirmation depth, token decimals |
23
+ | `src/billing/crypto/evm/address-gen.ts` | `deriveDepositAddress(xpub, index)` — BIP-44 HD derivation, no private keys |
24
+ | `src/billing/crypto/evm/watcher.ts` | `EvmWatcher` class — polls `eth_getLogs` for Transfer events, tracks cursor, emits settlement |
25
+ | `src/billing/crypto/evm/settler.ts` | `settleEvmPayment(deps, event)` — look up charge by deposit address, credit ledger, mark credited |
26
+ | `src/billing/crypto/evm/checkout.ts` | `createStablecoinCheckout(deps, opts)` — derive address, store charge, return address + amount |
27
+ | `src/billing/crypto/evm/index.ts` | Barrel exports |
28
+ | `src/billing/crypto/evm/__tests__/config.test.ts` | Chain/token config tests |
29
+ | `src/billing/crypto/evm/__tests__/address-gen.test.ts` | HD derivation tests (known xpub → known addresses) |
30
+ | `src/billing/crypto/evm/__tests__/watcher.test.ts` | Mock RPC responses, Transfer event parsing, confirmation counting |
31
+ | `src/billing/crypto/evm/__tests__/settler.test.ts` | Settlement logic — idempotency, ledger credit, mark credited |
32
+ | `src/billing/crypto/evm/__tests__/checkout.test.ts` | Checkout flow — address derivation, charge creation, min amount |
33
+
34
+ ### Modified files (platform-core)
35
+
36
+ | File | Change |
37
+ |------|--------|
38
+ | `src/db/schema/crypto.ts` | Add `chain`, `token`, `deposit_address`, `derivation_index` columns (nullable for BTCPay backward compat) |
39
+ | `src/billing/crypto/charge-store.ts` | Add `createStablecoinCharge()`, `getByDepositAddress()`, `getNextDerivationIndex()` to interface + impl |
40
+ | `src/billing/crypto/index.ts` | Re-export `./evm/index.js` |
41
+ | `drizzle/migrations/0005_stablecoin_columns.sql` | ALTER TABLE add columns + index on deposit_address |
42
+ | `package.json` | Add `viem`, `@scure/bip32`, `@scure/base` dependencies |
43
+
44
+ ### New files (wopr-ops)
45
+
46
+ | File | Change |
47
+ |------|--------|
48
+ | `docker-compose.local.yml` | Add `op-geth` + `op-node` services (or separate compose file) |
49
+ | `RUNBOOK.md` | Base node section: sync, monitoring, troubleshooting |
50
+
51
+ ---
52
+
53
+ ## Chunk 1: Dependencies + Schema Migration
54
+
55
+ ### Task 1: Add npm dependencies
56
+
57
+ **Files:**
58
+ - Modify: `package.json`
59
+
60
+ - [ ] **Step 1: Install viem and scure libraries**
61
+
62
+ ```bash
63
+ cd /home/tsavo/platform-core
64
+ pnpm add viem @scure/bip32 @scure/base
65
+ ```
66
+
67
+ - [ ] **Step 2: Verify imports resolve**
68
+
69
+ ```bash
70
+ node -e "require('viem'); require('@scure/bip32'); require('@scure/base'); console.log('OK')"
71
+ ```
72
+
73
+ Expected: `OK`
74
+
75
+ - [ ] **Step 3: Commit**
76
+
77
+ ```bash
78
+ git add package.json pnpm-lock.yaml
79
+ git commit -m "deps: add viem, @scure/bip32, @scure/base for stablecoin payments"
80
+ ```
81
+
82
+ ### Task 2: Schema migration — add stablecoin columns to crypto_charges
83
+
84
+ **Files:**
85
+ - Modify: `src/db/schema/crypto.ts`
86
+ - Create: `drizzle/migrations/0005_stablecoin_columns.sql`
87
+
88
+ - [ ] **Step 1: Add columns to Drizzle schema**
89
+
90
+ In `src/db/schema/crypto.ts`, add four nullable columns after `filledAmount`:
91
+
92
+ ```typescript
93
+ chain: text("chain"), // e.g. "base", "ethereum"
94
+ token: text("token"), // e.g. "USDC", "USDT", "DAI"
95
+ depositAddress: text("deposit_address"), // HD-derived address for this charge
96
+ derivationIndex: integer("derivation_index"), // HD derivation path index
97
+ ```
98
+
99
+ And add an index in the table's index array:
100
+
101
+ ```typescript
102
+ index("idx_crypto_charges_deposit_address").on(table.depositAddress),
103
+ ```
104
+
105
+ These are nullable because existing BTCPay charges don't have them.
106
+
107
+ - [ ] **Step 2: Generate the migration**
108
+
109
+ ```bash
110
+ npx drizzle-kit generate
111
+ ```
112
+
113
+ Verify it creates `drizzle/migrations/0005_stablecoin_columns.sql` with ALTER TABLE statements adding the four columns and the index.
114
+
115
+ - [ ] **Step 3: Verify migration has statement-breakpoint separators**
116
+
117
+ Read the generated SQL. Each statement (ALTER TABLE, CREATE INDEX) must be separated by `--\> statement-breakpoint`. PGlite (unit tests) requires this.
118
+
119
+ - [ ] **Step 4: Run tests to verify migration applies cleanly**
120
+
121
+ ```bash
122
+ npx vitest run src/billing/crypto/charge-store.test.ts
123
+ ```
124
+
125
+ Expected: existing tests still pass (new columns are nullable, no breaking change).
126
+
127
+ - [ ] **Step 5: Commit**
128
+
129
+ ```bash
130
+ git add src/db/schema/crypto.ts drizzle/
131
+ git commit -m "schema: add chain, token, deposit_address, derivation_index to crypto_charges"
132
+ ```
133
+
134
+ ### Task 3: Extend ICryptoChargeRepository for stablecoin charges
135
+
136
+ **Files:**
137
+ - Modify: `src/billing/crypto/charge-store.ts`
138
+ - Modify: `src/billing/crypto/charge-store.test.ts`
139
+
140
+ - [ ] **Step 1: Write failing tests for new methods**
141
+
142
+ Add tests to `charge-store.test.ts`:
143
+
144
+ ```typescript
145
+ describe("stablecoin charges", () => {
146
+ it("creates a stablecoin charge with chain/token/address", async () => {
147
+ await repo.createStablecoinCharge({
148
+ referenceId: "sc:base:usdc:0x123",
149
+ tenantId: "tenant-1",
150
+ amountUsdCents: 1000,
151
+ chain: "base",
152
+ token: "USDC",
153
+ depositAddress: "0xabc123",
154
+ derivationIndex: 42,
155
+ });
156
+ const charge = await repo.getByReferenceId("sc:base:usdc:0x123");
157
+ expect(charge).not.toBeNull();
158
+ expect(charge!.chain).toBe("base");
159
+ expect(charge!.token).toBe("USDC");
160
+ expect(charge!.depositAddress).toBe("0xabc123");
161
+ expect(charge!.derivationIndex).toBe(42);
162
+ });
163
+
164
+ it("looks up charge by deposit address", async () => {
165
+ await repo.createStablecoinCharge({
166
+ referenceId: "sc:base:usdc:0x456",
167
+ tenantId: "tenant-2",
168
+ amountUsdCents: 5000,
169
+ chain: "base",
170
+ token: "USDC",
171
+ depositAddress: "0xdef456",
172
+ derivationIndex: 43,
173
+ });
174
+ const charge = await repo.getByDepositAddress("0xdef456");
175
+ expect(charge).not.toBeNull();
176
+ expect(charge!.tenantId).toBe("tenant-2");
177
+ expect(charge!.amountUsdCents).toBe(5000);
178
+ });
179
+
180
+ it("returns null for unknown deposit address", async () => {
181
+ const charge = await repo.getByDepositAddress("0xnonexistent");
182
+ expect(charge).toBeNull();
183
+ });
184
+
185
+ it("gets next derivation index (0 when empty)", async () => {
186
+ const idx = await repo.getNextDerivationIndex();
187
+ expect(idx).toBe(0);
188
+ });
189
+
190
+ it("gets next derivation index (max + 1)", async () => {
191
+ await repo.createStablecoinCharge({
192
+ referenceId: "sc:1",
193
+ tenantId: "t",
194
+ amountUsdCents: 100,
195
+ chain: "base",
196
+ token: "USDC",
197
+ depositAddress: "0xa",
198
+ derivationIndex: 5,
199
+ });
200
+ const idx = await repo.getNextDerivationIndex();
201
+ expect(idx).toBe(6);
202
+ });
203
+ });
204
+ ```
205
+
206
+ - [ ] **Step 2: Run tests to verify they fail**
207
+
208
+ ```bash
209
+ npx vitest run src/billing/crypto/charge-store.test.ts
210
+ ```
211
+
212
+ Expected: FAIL — methods don't exist yet.
213
+
214
+ - [ ] **Step 3: Add new fields to CryptoChargeRecord type**
215
+
216
+ ```typescript
217
+ export interface CryptoChargeRecord {
218
+ // ... existing fields ...
219
+ chain: string | null;
220
+ token: string | null;
221
+ depositAddress: string | null;
222
+ derivationIndex: number | null;
223
+ }
224
+ ```
225
+
226
+ - [ ] **Step 4: Add new methods to ICryptoChargeRepository interface**
227
+
228
+ ```typescript
229
+ export interface StablecoinChargeInput {
230
+ referenceId: string;
231
+ tenantId: string;
232
+ amountUsdCents: number;
233
+ chain: string;
234
+ token: string;
235
+ depositAddress: string;
236
+ derivationIndex: number;
237
+ }
238
+
239
+ export interface ICryptoChargeRepository {
240
+ // ... existing methods ...
241
+ createStablecoinCharge(input: StablecoinChargeInput): Promise<void>;
242
+ getByDepositAddress(address: string): Promise<CryptoChargeRecord | null>;
243
+ getNextDerivationIndex(): Promise<number>;
244
+ }
245
+ ```
246
+
247
+ - [ ] **Step 5: Implement methods in DrizzleCryptoChargeRepository**
248
+
249
+ ```typescript
250
+ async createStablecoinCharge(input: StablecoinChargeInput): Promise<void> {
251
+ await this.db.insert(cryptoCharges).values({
252
+ referenceId: input.referenceId,
253
+ tenantId: input.tenantId,
254
+ amountUsdCents: input.amountUsdCents,
255
+ status: "New",
256
+ chain: input.chain,
257
+ token: input.token,
258
+ depositAddress: input.depositAddress,
259
+ derivationIndex: input.derivationIndex,
260
+ });
261
+ }
262
+
263
+ async getByDepositAddress(address: string): Promise<CryptoChargeRecord | null> {
264
+ const row = (
265
+ await this.db
266
+ .select()
267
+ .from(cryptoCharges)
268
+ .where(eq(cryptoCharges.depositAddress, address))
269
+ )[0];
270
+ if (!row) return null;
271
+ return this.toRecord(row);
272
+ }
273
+
274
+ async getNextDerivationIndex(): Promise<number> {
275
+ const result = await this.db
276
+ .select({ maxIdx: sql<number>`coalesce(max(${cryptoCharges.derivationIndex}), -1)` })
277
+ .from(cryptoCharges);
278
+ return (result[0]?.maxIdx ?? -1) + 1;
279
+ }
280
+ ```
281
+
282
+ Also update `getByReferenceId` and `toRecord()` helper to return the new fields.
283
+
284
+ - [ ] **Step 6: Run tests to verify they pass**
285
+
286
+ ```bash
287
+ npx vitest run src/billing/crypto/charge-store.test.ts
288
+ ```
289
+
290
+ Expected: ALL pass.
291
+
292
+ - [ ] **Step 7: Commit**
293
+
294
+ ```bash
295
+ git add src/billing/crypto/charge-store.ts src/billing/crypto/charge-store.test.ts
296
+ git commit -m "feat: add stablecoin charge methods to ICryptoChargeRepository"
297
+ ```
298
+
299
+ ---
300
+
301
+ ## Chunk 2: EVM Core — Config, Address Generation, Types
302
+
303
+ ### Task 4: EVM types
304
+
305
+ **Files:**
306
+ - Create: `src/billing/crypto/evm/types.ts`
307
+
308
+ - [ ] **Step 1: Create types file**
309
+
310
+ ```typescript
311
+ /** Supported EVM chains. */
312
+ export type EvmChain = "base";
313
+
314
+ /** Supported stablecoin tokens. */
315
+ export type StablecoinToken = "USDC";
316
+
317
+ /** Chain configuration. */
318
+ export interface ChainConfig {
319
+ readonly chain: EvmChain;
320
+ readonly rpcUrl: string;
321
+ readonly confirmations: number;
322
+ readonly blockTimeMs: number;
323
+ readonly chainId: number;
324
+ }
325
+
326
+ /** Token configuration on a specific chain. */
327
+ export interface TokenConfig {
328
+ readonly token: StablecoinToken;
329
+ readonly chain: EvmChain;
330
+ readonly contractAddress: `0x${string}`;
331
+ readonly decimals: number;
332
+ }
333
+
334
+ /** Event emitted when a Transfer is detected and confirmed. */
335
+ export interface EvmPaymentEvent {
336
+ readonly chain: EvmChain;
337
+ readonly token: StablecoinToken;
338
+ readonly from: string;
339
+ readonly to: string;
340
+ /** Raw token amount (BigInt as string for serialization). */
341
+ readonly rawAmount: string;
342
+ /** USD cents equivalent (integer). */
343
+ readonly amountUsdCents: number;
344
+ readonly txHash: string;
345
+ readonly blockNumber: number;
346
+ readonly logIndex: number;
347
+ }
348
+
349
+ /** Options for creating a stablecoin checkout. */
350
+ export interface StablecoinCheckoutOpts {
351
+ tenant: string;
352
+ amountUsd: number;
353
+ chain: EvmChain;
354
+ token: StablecoinToken;
355
+ }
356
+ ```
357
+
358
+ - [ ] **Step 2: Commit**
359
+
360
+ ```bash
361
+ git add src/billing/crypto/evm/types.ts
362
+ git commit -m "feat(evm): add stablecoin type definitions"
363
+ ```
364
+
365
+ ### Task 5: Chain and token configuration
366
+
367
+ **Files:**
368
+ - Create: `src/billing/crypto/evm/config.ts`
369
+ - Create: `src/billing/crypto/evm/__tests__/config.test.ts`
370
+
371
+ - [ ] **Step 1: Write failing tests**
372
+
373
+ ```typescript
374
+ import { describe, expect, it } from "vitest";
375
+ import { getChainConfig, getTokenConfig, tokenAmountFromCents } from "../config.js";
376
+
377
+ describe("getChainConfig", () => {
378
+ it("returns Base config", () => {
379
+ const cfg = getChainConfig("base");
380
+ expect(cfg.chainId).toBe(8453);
381
+ expect(cfg.confirmations).toBe(1);
382
+ });
383
+
384
+ it("throws on unknown chain", () => {
385
+ expect(() => getChainConfig("solana" as any)).toThrow("Unsupported chain");
386
+ });
387
+ });
388
+
389
+ describe("getTokenConfig", () => {
390
+ it("returns USDC on Base", () => {
391
+ const cfg = getTokenConfig("USDC", "base");
392
+ expect(cfg.decimals).toBe(6);
393
+ expect(cfg.contractAddress).toMatch(/^0x/);
394
+ });
395
+ });
396
+
397
+ describe("tokenAmountFromCents", () => {
398
+ it("converts 1000 cents ($10) to USDC raw amount", () => {
399
+ const raw = tokenAmountFromCents(1000, 6);
400
+ expect(raw).toBe(10_000_000n); // $10 × 10^6
401
+ });
402
+
403
+ it("converts 100 cents ($1) to DAI raw amount (18 decimals)", () => {
404
+ const raw = tokenAmountFromCents(100, 18);
405
+ expect(raw).toBe(1_000_000_000_000_000_000n); // $1 × 10^18
406
+ });
407
+
408
+ it("rejects non-integer cents", () => {
409
+ expect(() => tokenAmountFromCents(10.5, 6)).toThrow("integer");
410
+ });
411
+ });
412
+ ```
413
+
414
+ - [ ] **Step 2: Run to verify failure**
415
+
416
+ ```bash
417
+ npx vitest run src/billing/crypto/evm/__tests__/config.test.ts
418
+ ```
419
+
420
+ - [ ] **Step 3: Implement config**
421
+
422
+ ```typescript
423
+ import type { ChainConfig, EvmChain, StablecoinToken, TokenConfig } from "./types.js";
424
+
425
+ const CHAINS: Record<EvmChain, ChainConfig> = {
426
+ base: {
427
+ chain: "base",
428
+ rpcUrl: process.env.EVM_RPC_BASE ?? "http://op-geth:8545",
429
+ confirmations: 1,
430
+ blockTimeMs: 2000,
431
+ chainId: 8453,
432
+ },
433
+ };
434
+
435
+ /** USDC on Base (Circle-issued, bridged). */
436
+ const TOKENS: Record<`${StablecoinToken}:${EvmChain}`, TokenConfig> = {
437
+ "USDC:base": {
438
+ token: "USDC",
439
+ chain: "base",
440
+ contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
441
+ decimals: 6,
442
+ },
443
+ };
444
+
445
+ export function getChainConfig(chain: EvmChain): ChainConfig {
446
+ const cfg = CHAINS[chain];
447
+ if (!cfg) throw new Error(`Unsupported chain: ${chain}`);
448
+ return cfg;
449
+ }
450
+
451
+ export function getTokenConfig(token: StablecoinToken, chain: EvmChain): TokenConfig {
452
+ const key = `${token}:${chain}` as const;
453
+ const cfg = TOKENS[key];
454
+ if (!cfg) throw new Error(`Unsupported token ${token} on ${chain}`);
455
+ return cfg;
456
+ }
457
+
458
+ /**
459
+ * Convert USD cents (integer) to token raw amount (BigInt).
460
+ * Stablecoins are 1:1 USD, so $10.00 = 1000 cents = 10 × 10^decimals raw.
461
+ */
462
+ export function tokenAmountFromCents(cents: number, decimals: number): bigint {
463
+ if (!Number.isInteger(cents)) throw new Error("cents must be an integer");
464
+ // cents / 100 = dollars, dollars × 10^decimals = raw
465
+ // To avoid floating point: cents × 10^decimals / 100
466
+ return (BigInt(cents) * 10n ** BigInt(decimals)) / 100n;
467
+ }
468
+
469
+ /**
470
+ * Convert token raw amount (BigInt) to USD cents (integer).
471
+ * Inverse of tokenAmountFromCents. Truncates fractional cents.
472
+ */
473
+ export function centsFromTokenAmount(rawAmount: bigint, decimals: number): number {
474
+ // raw / 10^decimals = dollars, dollars × 100 = cents
475
+ // To avoid floating point: raw × 100 / 10^decimals
476
+ return Number((rawAmount * 100n) / 10n ** BigInt(decimals));
477
+ }
478
+ ```
479
+
480
+ - [ ] **Step 4: Run tests**
481
+
482
+ ```bash
483
+ npx vitest run src/billing/crypto/evm/__tests__/config.test.ts
484
+ ```
485
+
486
+ Expected: ALL pass.
487
+
488
+ - [ ] **Step 5: Commit**
489
+
490
+ ```bash
491
+ git add src/billing/crypto/evm/config.ts src/billing/crypto/evm/__tests__/config.test.ts
492
+ git commit -m "feat(evm): chain and token config for Base + USDC"
493
+ ```
494
+
495
+ ### Task 6: HD wallet address derivation
496
+
497
+ **Files:**
498
+ - Create: `src/billing/crypto/evm/address-gen.ts`
499
+ - Create: `src/billing/crypto/evm/__tests__/address-gen.test.ts`
500
+
501
+ - [ ] **Step 1: Write failing tests**
502
+
503
+ Use a known test xpub and verify deterministic address derivation:
504
+
505
+ ```typescript
506
+ import { describe, expect, it } from "vitest";
507
+ import { deriveDepositAddress, isValidXpub } from "../address-gen.js";
508
+
509
+ // BIP-44 test vector xpub (Ethereum path m/44'/60'/0')
510
+ // We'll use a well-known test xpub for deterministic tests.
511
+ const TEST_XPUB = "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqYh2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz";
512
+
513
+ describe("deriveDepositAddress", () => {
514
+ it("derives a valid Ethereum address", () => {
515
+ const addr = deriveDepositAddress(TEST_XPUB, 0);
516
+ expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/);
517
+ });
518
+
519
+ it("derives different addresses for different indices", () => {
520
+ const addr0 = deriveDepositAddress(TEST_XPUB, 0);
521
+ const addr1 = deriveDepositAddress(TEST_XPUB, 1);
522
+ expect(addr0).not.toBe(addr1);
523
+ });
524
+
525
+ it("is deterministic — same xpub + index = same address", () => {
526
+ const a = deriveDepositAddress(TEST_XPUB, 42);
527
+ const b = deriveDepositAddress(TEST_XPUB, 42);
528
+ expect(a).toBe(b);
529
+ });
530
+
531
+ it("returns checksummed address", () => {
532
+ const addr = deriveDepositAddress(TEST_XPUB, 0);
533
+ // Checksummed addresses have mixed case
534
+ expect(addr).not.toBe(addr.toLowerCase());
535
+ });
536
+ });
537
+
538
+ describe("isValidXpub", () => {
539
+ it("accepts valid xpub", () => {
540
+ expect(isValidXpub(TEST_XPUB)).toBe(true);
541
+ });
542
+
543
+ it("rejects garbage", () => {
544
+ expect(isValidXpub("not-an-xpub")).toBe(false);
545
+ });
546
+
547
+ it("rejects xprv (private key)", () => {
548
+ expect(isValidXpub("xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi")).toBe(false);
549
+ });
550
+ });
551
+ ```
552
+
553
+ - [ ] **Step 2: Run to verify failure**
554
+
555
+ ```bash
556
+ npx vitest run src/billing/crypto/evm/__tests__/address-gen.test.ts
557
+ ```
558
+
559
+ - [ ] **Step 3: Implement address derivation**
560
+
561
+ ```typescript
562
+ import { HDKey } from "@scure/bip32";
563
+ import { getAddress, keccak256 } from "viem";
564
+
565
+ /**
566
+ * Derive a deposit address from an xpub at a given BIP-44 index.
567
+ *
568
+ * Path: xpub / 0 / index (external chain / address index).
569
+ * Returns a checksummed Ethereum address. No private keys involved.
570
+ */
571
+ export function deriveDepositAddress(xpub: string, index: number): `0x${string}` {
572
+ const master = HDKey.fromExtendedKey(xpub);
573
+ const child = master.deriveChild(0).deriveChild(index);
574
+ if (!child.publicKey) throw new Error("Failed to derive public key");
575
+
576
+ // Ethereum address = last 20 bytes of keccak256(uncompressed pubkey without 04 prefix)
577
+ // viem's publicKeyToAddress handles this, but we need raw uncompressed key
578
+ const uncompressed = uncompressPublicKey(child.publicKey);
579
+ const hash = keccak256(uncompressed.slice(1) as `0x${string}`);
580
+ const addr = `0x${hash.slice(-40)}` as `0x${string}`;
581
+ return getAddress(addr); // checksummed
582
+ }
583
+
584
+ /** Decompress a 33-byte compressed secp256k1 public key to 65-byte uncompressed. */
585
+ function uncompressPublicKey(compressed: Uint8Array): Uint8Array {
586
+ // Use @scure/bip32's HDKey which already provides the compressed key.
587
+ // viem can convert compressed → uncompressed via secp256k1.
588
+ // For simplicity, use viem's built-in utility.
589
+ const { secp256k1 } = require("@noble/curves/secp256k1");
590
+ const point = secp256k1.ProjectivePoint.fromHex(compressed);
591
+ return point.toRawBytes(false); // uncompressed (65 bytes)
592
+ }
593
+
594
+ /** Validate that a string is an xpub (not xprv). */
595
+ export function isValidXpub(key: string): boolean {
596
+ if (!key.startsWith("xpub")) return false;
597
+ try {
598
+ HDKey.fromExtendedKey(key);
599
+ return true;
600
+ } catch {
601
+ return false;
602
+ }
603
+ }
604
+ ```
605
+
606
+ Note: `@noble/curves` is a transitive dependency of `@scure/bip32` — no extra install needed. If the `require()` is problematic for ESM, use `import { secp256k1 } from "@noble/curves/secp256k1"` at the top of the file instead. Adjust during implementation based on what the test runner accepts.
607
+
608
+ - [ ] **Step 4: Run tests**
609
+
610
+ ```bash
611
+ npx vitest run src/billing/crypto/evm/__tests__/address-gen.test.ts
612
+ ```
613
+
614
+ Expected: ALL pass. If the test xpub doesn't work (wrong derivation path depth), generate a fresh test xpub using `@scure/bip32` in the test setup.
615
+
616
+ - [ ] **Step 5: Commit**
617
+
618
+ ```bash
619
+ git add src/billing/crypto/evm/address-gen.ts src/billing/crypto/evm/__tests__/address-gen.test.ts
620
+ git commit -m "feat(evm): HD wallet address derivation from xpub"
621
+ ```
622
+
623
+ ---
624
+
625
+ ## Chunk 3: EVM Watcher + Settler
626
+
627
+ ### Task 7: EVM watcher — polls for Transfer events
628
+
629
+ **Files:**
630
+ - Create: `src/billing/crypto/evm/watcher.ts`
631
+ - Create: `src/billing/crypto/evm/__tests__/watcher.test.ts`
632
+
633
+ - [ ] **Step 1: Write failing tests**
634
+
635
+ Test the watcher with a mock RPC transport. Focus on:
636
+ - Parsing ERC-20 Transfer event logs
637
+ - Tracking block cursor (last processed block)
638
+ - Skipping already-processed blocks on restart
639
+ - Confirmation counting (waits for N confirmations)
640
+ - Extracting `from`, `to`, `value` from log topics/data
641
+
642
+ ```typescript
643
+ import { describe, expect, it, vi } from "vitest";
644
+ import { EvmWatcher } from "../watcher.js";
645
+
646
+ // ERC-20 Transfer event signature
647
+ const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
648
+
649
+ // Mock eth_getLogs response for a USDC Transfer
650
+ function mockTransferLog(to: string, amount: bigint, blockNumber: number) {
651
+ return {
652
+ address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
653
+ topics: [
654
+ TRANSFER_TOPIC,
655
+ `0x000000000000000000000000${"ab".repeat(20)}`, // from (padded)
656
+ `0x000000000000000000000000${to.slice(2).toLowerCase()}`, // to (padded)
657
+ ],
658
+ data: `0x${amount.toString(16).padStart(64, "0")}`,
659
+ blockNumber: `0x${blockNumber.toString(16)}`,
660
+ transactionHash: "0x" + "ff".repeat(32),
661
+ logIndex: "0x0",
662
+ };
663
+ }
664
+
665
+ describe("EvmWatcher", () => {
666
+ it("parses Transfer log into EvmPaymentEvent", async () => {
667
+ const events: any[] = [];
668
+ const mockRpc = vi.fn()
669
+ .mockResolvedValueOnce(`0x${(100).toString(16)}`) // eth_blockNumber: block 100
670
+ .mockResolvedValueOnce([mockTransferLog("0x" + "cc".repeat(20), 10_000_000n, 99)]); // eth_getLogs
671
+
672
+ const watcher = new EvmWatcher({
673
+ chain: "base",
674
+ token: "USDC",
675
+ rpcCall: mockRpc,
676
+ fromBlock: 99,
677
+ onPayment: (evt) => { events.push(evt); },
678
+ });
679
+
680
+ await watcher.poll();
681
+
682
+ expect(events).toHaveLength(1);
683
+ expect(events[0].amountUsdCents).toBe(1000); // 10 USDC = $10 = 1000 cents
684
+ expect(events[0].to).toMatch(/^0x/);
685
+ });
686
+
687
+ it("advances cursor after processing", async () => {
688
+ const mockRpc = vi.fn()
689
+ .mockResolvedValueOnce(`0x${(200).toString(16)}`) // block 200
690
+ .mockResolvedValueOnce([]); // no logs
691
+
692
+ const watcher = new EvmWatcher({
693
+ chain: "base",
694
+ token: "USDC",
695
+ rpcCall: mockRpc,
696
+ fromBlock: 100,
697
+ onPayment: vi.fn(),
698
+ });
699
+
700
+ await watcher.poll();
701
+ expect(watcher.cursor).toBeGreaterThan(100);
702
+ });
703
+
704
+ it("skips blocks not yet confirmed", async () => {
705
+ const events: any[] = [];
706
+ const mockRpc = vi.fn()
707
+ .mockResolvedValueOnce(`0x${(50).toString(16)}`) // current block: 50
708
+ .mockResolvedValueOnce([mockTransferLog("0x" + "dd".repeat(20), 5_000_000n, 50)]); // log at block 50
709
+
710
+ // Base needs 1 confirmation, so block 50 is confirmed when current is 51+
711
+ const watcher = new EvmWatcher({
712
+ chain: "base",
713
+ token: "USDC",
714
+ rpcCall: mockRpc,
715
+ fromBlock: 49,
716
+ onPayment: (evt) => { events.push(evt); },
717
+ });
718
+
719
+ await watcher.poll();
720
+ // Block 50 with current block 50: needs 1 confirmation → confirmed block = 50 - 1 = 49
721
+ // So block 50 should NOT be processed yet
722
+ expect(events).toHaveLength(0);
723
+ });
724
+ });
725
+ ```
726
+
727
+ - [ ] **Step 2: Run to verify failure**
728
+
729
+ ```bash
730
+ npx vitest run src/billing/crypto/evm/__tests__/watcher.test.ts
731
+ ```
732
+
733
+ - [ ] **Step 3: Implement watcher**
734
+
735
+ ```typescript
736
+ import { getChainConfig, getTokenConfig, centsFromTokenAmount } from "./config.js";
737
+ import type { EvmChain, EvmPaymentEvent, StablecoinToken } from "./types.js";
738
+
739
+ const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
740
+
741
+ type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
742
+
743
+ export interface EvmWatcherOpts {
744
+ chain: EvmChain;
745
+ token: StablecoinToken;
746
+ rpcCall: RpcCall;
747
+ fromBlock: number;
748
+ onPayment: (event: EvmPaymentEvent) => void | Promise<void>;
749
+ }
750
+
751
+ export class EvmWatcher {
752
+ private _cursor: number;
753
+ private readonly chain: EvmChain;
754
+ private readonly token: StablecoinToken;
755
+ private readonly rpc: RpcCall;
756
+ private readonly onPayment: EvmWatcherOpts["onPayment"];
757
+ private readonly confirmations: number;
758
+ private readonly contractAddress: string;
759
+ private readonly decimals: number;
760
+
761
+ constructor(opts: EvmWatcherOpts) {
762
+ this.chain = opts.chain;
763
+ this.token = opts.token;
764
+ this.rpc = opts.rpcCall;
765
+ this._cursor = opts.fromBlock;
766
+ this.onPayment = opts.onPayment;
767
+
768
+ const chainCfg = getChainConfig(opts.chain);
769
+ const tokenCfg = getTokenConfig(opts.token, opts.chain);
770
+ this.confirmations = chainCfg.confirmations;
771
+ this.contractAddress = tokenCfg.contractAddress.toLowerCase();
772
+ this.decimals = tokenCfg.decimals;
773
+ }
774
+
775
+ get cursor(): number {
776
+ return this._cursor;
777
+ }
778
+
779
+ /** Poll for new Transfer events. Call on an interval. */
780
+ async poll(): Promise<void> {
781
+ const latestHex = (await this.rpc("eth_blockNumber", [])) as string;
782
+ const latest = parseInt(latestHex, 16);
783
+ const confirmed = latest - this.confirmations;
784
+
785
+ if (confirmed < this._cursor) return; // nothing new
786
+
787
+ const logs = (await this.rpc("eth_getLogs", [
788
+ {
789
+ address: this.contractAddress,
790
+ topics: [TRANSFER_TOPIC],
791
+ fromBlock: `0x${this._cursor.toString(16)}`,
792
+ toBlock: `0x${confirmed.toString(16)}`,
793
+ },
794
+ ])) as Array<{
795
+ address: string;
796
+ topics: string[];
797
+ data: string;
798
+ blockNumber: string;
799
+ transactionHash: string;
800
+ logIndex: string;
801
+ }>;
802
+
803
+ for (const log of logs) {
804
+ const to = "0x" + log.topics[2].slice(26);
805
+ const from = "0x" + log.topics[1].slice(26);
806
+ const rawAmount = BigInt(log.data);
807
+ const amountUsdCents = centsFromTokenAmount(rawAmount, this.decimals);
808
+
809
+ const event: EvmPaymentEvent = {
810
+ chain: this.chain,
811
+ token: this.token,
812
+ from,
813
+ to,
814
+ rawAmount: rawAmount.toString(),
815
+ amountUsdCents,
816
+ txHash: log.transactionHash,
817
+ blockNumber: parseInt(log.blockNumber, 16),
818
+ logIndex: parseInt(log.logIndex, 16),
819
+ };
820
+
821
+ await this.onPayment(event);
822
+ }
823
+
824
+ this._cursor = confirmed + 1;
825
+ }
826
+ }
827
+
828
+ /** Create an RPC caller for a given URL (plain JSON-RPC over fetch). */
829
+ export function createRpcCaller(rpcUrl: string): RpcCall {
830
+ let id = 0;
831
+ return async (method: string, params: unknown[]): Promise<unknown> => {
832
+ const res = await fetch(rpcUrl, {
833
+ method: "POST",
834
+ headers: { "Content-Type": "application/json" },
835
+ body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
836
+ });
837
+ if (!res.ok) throw new Error(`RPC ${method} failed: ${res.status}`);
838
+ const data = (await res.json()) as { result?: unknown; error?: { message: string } };
839
+ if (data.error) throw new Error(`RPC ${method} error: ${data.error.message}`);
840
+ return data.result;
841
+ };
842
+ }
843
+ ```
844
+
845
+ - [ ] **Step 4: Run tests**
846
+
847
+ ```bash
848
+ npx vitest run src/billing/crypto/evm/__tests__/watcher.test.ts
849
+ ```
850
+
851
+ Expected: ALL pass.
852
+
853
+ - [ ] **Step 5: Commit**
854
+
855
+ ```bash
856
+ git add src/billing/crypto/evm/watcher.ts src/billing/crypto/evm/__tests__/watcher.test.ts
857
+ git commit -m "feat(evm): Transfer event watcher with confirmation counting"
858
+ ```
859
+
860
+ ### Task 8: Settler — credits ledger on confirmed payment
861
+
862
+ **Files:**
863
+ - Create: `src/billing/crypto/evm/settler.ts`
864
+ - Create: `src/billing/crypto/evm/__tests__/settler.test.ts`
865
+
866
+ - [ ] **Step 1: Write failing tests**
867
+
868
+ ```typescript
869
+ import { describe, expect, it, vi } from "vitest";
870
+ import type { EvmPaymentEvent } from "../types.js";
871
+ import { settleEvmPayment } from "../settler.js";
872
+
873
+ const mockEvent: EvmPaymentEvent = {
874
+ chain: "base",
875
+ token: "USDC",
876
+ from: "0xsender",
877
+ to: "0xdeposit",
878
+ rawAmount: "10000000", // 10 USDC
879
+ amountUsdCents: 1000,
880
+ txHash: "0xtx",
881
+ blockNumber: 100,
882
+ logIndex: 0,
883
+ };
884
+
885
+ describe("settleEvmPayment", () => {
886
+ it("credits ledger when charge found and not yet credited", async () => {
887
+ const deps = {
888
+ chargeStore: {
889
+ getByDepositAddress: vi.fn().mockResolvedValue({
890
+ referenceId: "sc:base:usdc:abc",
891
+ tenantId: "tenant-1",
892
+ amountUsdCents: 1000,
893
+ status: "New",
894
+ creditedAt: null,
895
+ }),
896
+ updateStatus: vi.fn().mockResolvedValue(undefined),
897
+ markCredited: vi.fn().mockResolvedValue(undefined),
898
+ },
899
+ creditLedger: {
900
+ hasReferenceId: vi.fn().mockResolvedValue(false),
901
+ credit: vi.fn().mockResolvedValue({}),
902
+ },
903
+ onCreditsPurchased: vi.fn().mockResolvedValue([]),
904
+ };
905
+
906
+ const result = await settleEvmPayment(deps as any, mockEvent);
907
+
908
+ expect(result.handled).toBe(true);
909
+ expect(result.creditedCents).toBe(1000);
910
+ expect(deps.creditLedger.credit).toHaveBeenCalledOnce();
911
+ expect(deps.chargeStore.markCredited).toHaveBeenCalledOnce();
912
+ });
913
+
914
+ it("skips crediting when already credited (idempotent)", async () => {
915
+ const deps = {
916
+ chargeStore: {
917
+ getByDepositAddress: vi.fn().mockResolvedValue({
918
+ referenceId: "sc:base:usdc:abc",
919
+ tenantId: "tenant-1",
920
+ amountUsdCents: 1000,
921
+ status: "Settled",
922
+ creditedAt: "2026-01-01",
923
+ }),
924
+ updateStatus: vi.fn().mockResolvedValue(undefined),
925
+ markCredited: vi.fn().mockResolvedValue(undefined),
926
+ },
927
+ creditLedger: {
928
+ hasReferenceId: vi.fn().mockResolvedValue(true),
929
+ credit: vi.fn().mockResolvedValue({}),
930
+ },
931
+ };
932
+
933
+ const result = await settleEvmPayment(deps as any, mockEvent);
934
+
935
+ expect(result.handled).toBe(true);
936
+ expect(result.creditedCents).toBe(0);
937
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
938
+ });
939
+
940
+ it("returns handled:false when no charge found for deposit address", async () => {
941
+ const deps = {
942
+ chargeStore: {
943
+ getByDepositAddress: vi.fn().mockResolvedValue(null),
944
+ },
945
+ creditLedger: { hasReferenceId: vi.fn(), credit: vi.fn() },
946
+ };
947
+
948
+ const result = await settleEvmPayment(deps as any, mockEvent);
949
+ expect(result.handled).toBe(false);
950
+ });
951
+
952
+ it("credits the charge amount, not the transfer amount (overpayment safe)", async () => {
953
+ const overpaidEvent = { ...mockEvent, amountUsdCents: 2000 }; // sent $20
954
+ const deps = {
955
+ chargeStore: {
956
+ getByDepositAddress: vi.fn().mockResolvedValue({
957
+ referenceId: "sc:x",
958
+ tenantId: "t",
959
+ amountUsdCents: 1000, // charge was for $10
960
+ status: "New",
961
+ creditedAt: null,
962
+ }),
963
+ updateStatus: vi.fn().mockResolvedValue(undefined),
964
+ markCredited: vi.fn().mockResolvedValue(undefined),
965
+ },
966
+ creditLedger: {
967
+ hasReferenceId: vi.fn().mockResolvedValue(false),
968
+ credit: vi.fn().mockResolvedValue({}),
969
+ },
970
+ onCreditsPurchased: vi.fn().mockResolvedValue([]),
971
+ };
972
+
973
+ const result = await settleEvmPayment(deps as any, overpaidEvent);
974
+ expect(result.creditedCents).toBe(1000); // charged amount, not transfer amount
975
+ });
976
+ });
977
+ ```
978
+
979
+ - [ ] **Step 2: Run to verify failure**
980
+
981
+ ```bash
982
+ npx vitest run src/billing/crypto/evm/__tests__/settler.test.ts
983
+ ```
984
+
985
+ - [ ] **Step 3: Implement settler**
986
+
987
+ ```typescript
988
+ import { Credit } from "../../../credits/credit.js";
989
+ import type { ILedger } from "../../../credits/ledger.js";
990
+ import type { ICryptoChargeRepository } from "../charge-store.js";
991
+ import type { CryptoWebhookResult } from "../types.js";
992
+ import type { EvmPaymentEvent } from "./types.js";
993
+
994
+ export interface EvmSettlerDeps {
995
+ chargeStore: Pick<ICryptoChargeRepository, "getByDepositAddress" | "updateStatus" | "markCredited">;
996
+ creditLedger: Pick<ILedger, "credit" | "hasReferenceId">;
997
+ onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
998
+ }
999
+
1000
+ /**
1001
+ * Settle an EVM payment event — look up charge by deposit address, credit ledger.
1002
+ *
1003
+ * Same idempotency pattern as handleCryptoWebhook():
1004
+ * Primary: creditLedger.hasReferenceId() — atomic in ledger transaction
1005
+ * Secondary: chargeStore.markCredited() — advisory
1006
+ *
1007
+ * Credits the CHARGE amount (not the transfer amount) for overpayment safety.
1008
+ */
1009
+ export async function settleEvmPayment(
1010
+ deps: EvmSettlerDeps,
1011
+ event: EvmPaymentEvent,
1012
+ ): Promise<CryptoWebhookResult> {
1013
+ const { chargeStore, creditLedger } = deps;
1014
+
1015
+ const charge = await chargeStore.getByDepositAddress(event.to);
1016
+ if (!charge) {
1017
+ return { handled: false, status: "Settled" };
1018
+ }
1019
+
1020
+ // Update charge status to Settled.
1021
+ await chargeStore.updateStatus(charge.referenceId, "Settled");
1022
+
1023
+ // Idempotency: check if ledger already has this reference.
1024
+ const creditRef = `evm:${event.chain}:${event.txHash}:${event.logIndex}`;
1025
+ if (await creditLedger.hasReferenceId(creditRef)) {
1026
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
1027
+ }
1028
+
1029
+ // Credit the charge amount (NOT the transfer amount — overpayment stays in wallet).
1030
+ const creditCents = charge.amountUsdCents;
1031
+ await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
1032
+ description: `Stablecoin credit purchase (${event.token} on ${event.chain}, tx: ${event.txHash})`,
1033
+ referenceId: creditRef,
1034
+ fundingSource: "crypto",
1035
+ });
1036
+
1037
+ await chargeStore.markCredited(charge.referenceId);
1038
+
1039
+ let reactivatedBots: string[] | undefined;
1040
+ if (deps.onCreditsPurchased) {
1041
+ reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger as ILedger);
1042
+ if (reactivatedBots.length === 0) reactivatedBots = undefined;
1043
+ }
1044
+
1045
+ return {
1046
+ handled: true,
1047
+ status: "Settled",
1048
+ tenant: charge.tenantId,
1049
+ creditedCents: creditCents,
1050
+ reactivatedBots,
1051
+ };
1052
+ }
1053
+ ```
1054
+
1055
+ - [ ] **Step 4: Run tests**
1056
+
1057
+ ```bash
1058
+ npx vitest run src/billing/crypto/evm/__tests__/settler.test.ts
1059
+ ```
1060
+
1061
+ Expected: ALL pass.
1062
+
1063
+ - [ ] **Step 5: Commit**
1064
+
1065
+ ```bash
1066
+ git add src/billing/crypto/evm/settler.ts src/billing/crypto/evm/__tests__/settler.test.ts
1067
+ git commit -m "feat(evm): settler — credits ledger on confirmed stablecoin payment"
1068
+ ```
1069
+
1070
+ ---
1071
+
1072
+ ## Chunk 4: Stablecoin Checkout + Barrel Exports
1073
+
1074
+ ### Task 9: Stablecoin checkout flow
1075
+
1076
+ **Files:**
1077
+ - Create: `src/billing/crypto/evm/checkout.ts`
1078
+ - Create: `src/billing/crypto/evm/__tests__/checkout.test.ts`
1079
+
1080
+ - [ ] **Step 1: Write failing tests**
1081
+
1082
+ ```typescript
1083
+ import { describe, expect, it, vi } from "vitest";
1084
+ import { createStablecoinCheckout, MIN_STABLECOIN_USD } from "../checkout.js";
1085
+
1086
+ describe("createStablecoinCheckout", () => {
1087
+ const mockChargeStore = {
1088
+ getNextDerivationIndex: vi.fn().mockResolvedValue(42),
1089
+ createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
1090
+ };
1091
+
1092
+ it("derives address and creates charge", async () => {
1093
+ const result = await createStablecoinCheckout(
1094
+ { chargeStore: mockChargeStore as any, xpub: "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqYh2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz" },
1095
+ { tenant: "t1", amountUsd: 10, chain: "base", token: "USDC" },
1096
+ );
1097
+
1098
+ expect(result.depositAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
1099
+ expect(result.amountRaw).toBe("10000000"); // 10 USDC in raw
1100
+ expect(result.chain).toBe("base");
1101
+ expect(result.token).toBe("USDC");
1102
+ expect(mockChargeStore.createStablecoinCharge).toHaveBeenCalledOnce();
1103
+ });
1104
+
1105
+ it("rejects below minimum", async () => {
1106
+ await expect(
1107
+ createStablecoinCheckout(
1108
+ { chargeStore: mockChargeStore as any, xpub: "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqYh2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz" },
1109
+ { tenant: "t1", amountUsd: 5, chain: "base", token: "USDC" },
1110
+ ),
1111
+ ).rejects.toThrow("Minimum");
1112
+ });
1113
+ });
1114
+ ```
1115
+
1116
+ - [ ] **Step 2: Implement checkout**
1117
+
1118
+ ```typescript
1119
+ import { Credit } from "../../../credits/credit.js";
1120
+ import type { ICryptoChargeRepository } from "../charge-store.js";
1121
+ import { deriveDepositAddress } from "./address-gen.js";
1122
+ import { getTokenConfig, tokenAmountFromCents } from "./config.js";
1123
+ import type { StablecoinCheckoutOpts } from "./types.js";
1124
+
1125
+ export const MIN_STABLECOIN_USD = 10;
1126
+
1127
+ export interface StablecoinCheckoutDeps {
1128
+ chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
1129
+ xpub: string;
1130
+ }
1131
+
1132
+ export interface StablecoinCheckoutResult {
1133
+ depositAddress: string;
1134
+ amountRaw: string;
1135
+ amountUsd: number;
1136
+ chain: string;
1137
+ token: string;
1138
+ referenceId: string;
1139
+ }
1140
+
1141
+ export async function createStablecoinCheckout(
1142
+ deps: StablecoinCheckoutDeps,
1143
+ opts: StablecoinCheckoutOpts,
1144
+ ): Promise<StablecoinCheckoutResult> {
1145
+ if (opts.amountUsd < MIN_STABLECOIN_USD) {
1146
+ throw new Error(`Minimum payment amount is $${MIN_STABLECOIN_USD}`);
1147
+ }
1148
+
1149
+ const tokenCfg = getTokenConfig(opts.token, opts.chain);
1150
+ const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
1151
+ const rawAmount = tokenAmountFromCents(amountUsdCents, tokenCfg.decimals);
1152
+
1153
+ const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
1154
+ const depositAddress = deriveDepositAddress(deps.xpub, derivationIndex);
1155
+
1156
+ const referenceId = `sc:${opts.chain}:${opts.token.toLowerCase()}:${depositAddress.toLowerCase()}`;
1157
+
1158
+ await deps.chargeStore.createStablecoinCharge({
1159
+ referenceId,
1160
+ tenantId: opts.tenant,
1161
+ amountUsdCents,
1162
+ chain: opts.chain,
1163
+ token: opts.token,
1164
+ depositAddress: depositAddress.toLowerCase(),
1165
+ derivationIndex,
1166
+ });
1167
+
1168
+ return {
1169
+ depositAddress,
1170
+ amountRaw: rawAmount.toString(),
1171
+ amountUsd: opts.amountUsd,
1172
+ chain: opts.chain,
1173
+ token: opts.token,
1174
+ referenceId,
1175
+ };
1176
+ }
1177
+ ```
1178
+
1179
+ - [ ] **Step 3: Run tests**
1180
+
1181
+ ```bash
1182
+ npx vitest run src/billing/crypto/evm/__tests__/checkout.test.ts
1183
+ ```
1184
+
1185
+ - [ ] **Step 4: Commit**
1186
+
1187
+ ```bash
1188
+ git add src/billing/crypto/evm/checkout.ts src/billing/crypto/evm/__tests__/checkout.test.ts
1189
+ git commit -m "feat(evm): stablecoin checkout — derive address, create charge"
1190
+ ```
1191
+
1192
+ ### Task 10: Barrel exports
1193
+
1194
+ **Files:**
1195
+ - Create: `src/billing/crypto/evm/index.ts`
1196
+ - Modify: `src/billing/crypto/index.ts`
1197
+
1198
+ - [ ] **Step 1: Create EVM barrel**
1199
+
1200
+ ```typescript
1201
+ export { getChainConfig, getTokenConfig, tokenAmountFromCents, centsFromTokenAmount } from "./config.js";
1202
+ export { deriveDepositAddress, isValidXpub } from "./address-gen.js";
1203
+ export { EvmWatcher, createRpcCaller } from "./watcher.js";
1204
+ export type { EvmWatcherOpts } from "./watcher.js";
1205
+ export { settleEvmPayment } from "./settler.js";
1206
+ export type { EvmSettlerDeps } from "./settler.js";
1207
+ export { createStablecoinCheckout, MIN_STABLECOIN_USD } from "./checkout.js";
1208
+ export type { StablecoinCheckoutDeps, StablecoinCheckoutResult } from "./checkout.js";
1209
+ export type {
1210
+ ChainConfig,
1211
+ EvmChain,
1212
+ EvmPaymentEvent,
1213
+ StablecoinCheckoutOpts,
1214
+ StablecoinToken,
1215
+ TokenConfig,
1216
+ } from "./types.js";
1217
+ ```
1218
+
1219
+ - [ ] **Step 2: Add re-export to main crypto barrel**
1220
+
1221
+ In `src/billing/crypto/index.ts`, add at the end:
1222
+
1223
+ ```typescript
1224
+ export * from "./evm/index.js";
1225
+ ```
1226
+
1227
+ - [ ] **Step 3: Verify build compiles**
1228
+
1229
+ ```bash
1230
+ npx tsc --noEmit
1231
+ ```
1232
+
1233
+ - [ ] **Step 4: Commit**
1234
+
1235
+ ```bash
1236
+ git add src/billing/crypto/evm/index.ts src/billing/crypto/index.ts
1237
+ git commit -m "feat(evm): barrel exports for stablecoin module"
1238
+ ```
1239
+
1240
+ ---
1241
+
1242
+ ## Chunk 5: Infrastructure — Docker + RUNBOOK
1243
+
1244
+ ### Task 11: Base node Docker services (wopr-ops)
1245
+
1246
+ **Files:**
1247
+ - Modify: `~/wopr-ops/docker-compose.local.yml` (or create a separate `docker-compose.base-node.yml`)
1248
+ - Modify: `~/wopr-ops/RUNBOOK.md`
1249
+
1250
+ - [ ] **Step 1: Add op-geth + op-node services to docker-compose**
1251
+
1252
+ Add to wopr-ops docker-compose (or create overlay):
1253
+
1254
+ ```yaml
1255
+ op-geth:
1256
+ image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-geth:latest
1257
+ volumes:
1258
+ - base-geth-data:/data
1259
+ ports:
1260
+ - "8545:8545"
1261
+ - "8546:8546"
1262
+ command: >
1263
+ --datadir=/data
1264
+ --http --http.addr=0.0.0.0 --http.port=8545
1265
+ --http.api=eth,net,web3
1266
+ --ws --ws.addr=0.0.0.0 --ws.port=8546
1267
+ --ws.api=eth,net,web3
1268
+ --rollup.sequencerhttp=https://mainnet-sequencer.base.org
1269
+ --rollup.historicalrpc=https://mainnet.base.org
1270
+ --syncmode=snap
1271
+ restart: unless-stopped
1272
+
1273
+ op-node:
1274
+ image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-node:latest
1275
+ depends_on: [op-geth]
1276
+ command: >
1277
+ --l1=ws://geth:8546
1278
+ --l2=http://op-geth:8551
1279
+ --network=base-mainnet
1280
+ --rpc.addr=0.0.0.0 --rpc.port=9545
1281
+ restart: unless-stopped
1282
+ ```
1283
+
1284
+ And add volume:
1285
+
1286
+ ```yaml
1287
+ volumes:
1288
+ base-geth-data:
1289
+ ```
1290
+
1291
+ Note: the `--l1` endpoint needs an Ethereum L1 node or a provider for the derivation pipe. For production, this should be our own geth instance. For initial setup, can use a public L1 endpoint temporarily. Document this trade-off in RUNBOOK.
1292
+
1293
+ - [ ] **Step 2: Add RUNBOOK section for Base node**
1294
+
1295
+ Add to `~/wopr-ops/RUNBOOK.md` under a new `### Self-hosted Base node (stablecoin payments)` heading:
1296
+
1297
+ Document:
1298
+ - What it does (L2 node for stablecoin payment detection)
1299
+ - Disk requirements (~50GB, grows slowly)
1300
+ - Sync time (initial: 2-6 hours, then real-time)
1301
+ - How to check sync status: `curl -s http://localhost:8545 -X POST -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"eth_syncing","id":1}'`
1302
+ - L1 dependency (op-node needs L1 RPC for derivation)
1303
+ - Monitoring: compare local block number vs Base block explorer
1304
+ - Troubleshooting: if op-geth falls behind, restart; if disk full, prune
1305
+
1306
+ - [ ] **Step 3: Commit in wopr-ops**
1307
+
1308
+ ```bash
1309
+ cd ~/wopr-ops
1310
+ jj new && jj describe "feat: add Base node (op-geth + op-node) for stablecoin payments"
1311
+ # ... add files ...
1312
+ jj commit
1313
+ ```
1314
+
1315
+ ### Task 12: Base node in paperclip-platform local dev compose
1316
+
1317
+ **Files:**
1318
+ - Modify: `~/paperclip-platform/docker-compose.local.yml`
1319
+
1320
+ - [ ] **Step 1: Add op-geth service for local dev**
1321
+
1322
+ For local dev, use Anvil (Foundry's local node) instead of a real Base node. Anvil is lighter and can fork Base mainnet:
1323
+
1324
+ ```yaml
1325
+ anvil:
1326
+ image: ghcr.io/foundry-rs/foundry:latest
1327
+ entrypoint: ["anvil"]
1328
+ command: >
1329
+ --fork-url https://mainnet.base.org
1330
+ --host 0.0.0.0
1331
+ --port 8545
1332
+ ports:
1333
+ - "8545:8545"
1334
+ ```
1335
+
1336
+ Or if we want to test against a real Base node locally, add the full op-geth stack. For Phase 1, Anvil fork is sufficient for integration tests.
1337
+
1338
+ Add `EVM_RPC_BASE=http://anvil:8545` to platform environment.
1339
+ Add `EVM_XPUB` to `.env.local.example`.
1340
+
1341
+ - [ ] **Step 2: Commit**
1342
+
1343
+ ---
1344
+
1345
+ ## Chunk 6: Integration Testing
1346
+
1347
+ ### Task 13: End-to-end stablecoin flow test
1348
+
1349
+ **Files:**
1350
+ - Create: `src/billing/crypto/evm/__tests__/e2e-flow.test.ts`
1351
+
1352
+ - [ ] **Step 1: Write integration test**
1353
+
1354
+ Test the full flow with mocked RPC:
1355
+ 1. `createStablecoinCheckout()` → get deposit address
1356
+ 2. Simulate Transfer event to that address
1357
+ 3. `settleEvmPayment()` → credits ledger
1358
+ 4. Verify charge is marked credited
1359
+ 5. Verify ledger balance increased
1360
+
1361
+ This test uses real charge-store (PGlite) + real ledger, mocked RPC only.
1362
+
1363
+ - [ ] **Step 2: Run full test suite**
1364
+
1365
+ ```bash
1366
+ npx vitest run src/billing/crypto/
1367
+ ```
1368
+
1369
+ Expected: ALL pass (existing BTCPay tests + new EVM tests).
1370
+
1371
+ - [ ] **Step 3: Commit**
1372
+
1373
+ ```bash
1374
+ git add src/billing/crypto/evm/__tests__/e2e-flow.test.ts
1375
+ git commit -m "test(evm): end-to-end stablecoin checkout → watcher → settlement flow"
1376
+ ```
1377
+
1378
+ ### Task 14: Run CI gate
1379
+
1380
+ - [ ] **Step 1: Full CI gate**
1381
+
1382
+ ```bash
1383
+ pnpm lint && pnpm format && pnpm build && pnpm test
1384
+ ```
1385
+
1386
+ (Skip `swiftformat` and `pnpm protocol:gen` — no Swift or protocol changes.)
1387
+
1388
+ All must pass. Fix any issues found.
1389
+
1390
+ - [ ] **Step 2: Final commit if lint/format made changes**
1391
+
1392
+ ---
1393
+
1394
+ ## Execution Order Summary
1395
+
1396
+ | Task | What | Where | Depends on |
1397
+ |------|------|-------|-----------|
1398
+ | 1 | npm deps | platform-core | — |
1399
+ | 2 | Schema migration | platform-core | 1 |
1400
+ | 3 | Charge store methods | platform-core | 2 |
1401
+ | 4 | EVM types | platform-core | — |
1402
+ | 5 | Chain/token config | platform-core | 4 |
1403
+ | 6 | Address derivation | platform-core | 1 |
1404
+ | 7 | EVM watcher | platform-core | 5 |
1405
+ | 8 | Settler | platform-core | 3, 5 |
1406
+ | 9 | Checkout flow | platform-core | 3, 5, 6 |
1407
+ | 10 | Barrel exports | platform-core | 4-9 |
1408
+ | 11 | Docker services | wopr-ops | — (independent) |
1409
+ | 12 | Local dev compose | paperclip-platform | 11 |
1410
+ | 13 | E2E test | platform-core | all above |
1411
+ | 14 | CI gate | platform-core | 13 |
1412
+
1413
+ Tasks 1, 4, 11 can run in parallel. Tasks 5, 6 can run in parallel after 4. Task 7, 8, 9 can run in parallel after their deps.