@wopr-network/platform-core 1.15.0 → 1.17.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/dist/auth/tenant-access.test.js +2 -0
- package/dist/billing/crypto/charge-store.d.ts +23 -0
- package/dist/billing/crypto/charge-store.js +34 -0
- package/dist/billing/crypto/charge-store.test.js +56 -0
- package/dist/billing/crypto/evm/__tests__/address-gen.test.d.ts +1 -0
- package/dist/billing/crypto/evm/__tests__/address-gen.test.js +54 -0
- package/dist/billing/crypto/evm/__tests__/checkout.test.d.ts +1 -0
- package/dist/billing/crypto/evm/__tests__/checkout.test.js +54 -0
- package/dist/billing/crypto/evm/__tests__/config.test.d.ts +1 -0
- package/dist/billing/crypto/evm/__tests__/config.test.js +52 -0
- package/dist/billing/crypto/evm/__tests__/settler.test.d.ts +1 -0
- package/dist/billing/crypto/evm/__tests__/settler.test.js +196 -0
- package/dist/billing/crypto/evm/__tests__/watcher.test.d.ts +1 -0
- package/dist/billing/crypto/evm/__tests__/watcher.test.js +109 -0
- package/dist/billing/crypto/evm/address-gen.d.ts +8 -0
- package/dist/billing/crypto/evm/address-gen.js +29 -0
- package/dist/billing/crypto/evm/checkout.d.ts +26 -0
- package/dist/billing/crypto/evm/checkout.js +57 -0
- package/dist/billing/crypto/evm/config.d.ts +13 -0
- package/dist/billing/crypto/evm/config.js +46 -0
- package/dist/billing/crypto/evm/index.d.ts +9 -0
- package/dist/billing/crypto/evm/index.js +5 -0
- package/dist/billing/crypto/evm/settler.d.ts +23 -0
- package/dist/billing/crypto/evm/settler.js +60 -0
- package/dist/billing/crypto/evm/types.d.ts +40 -0
- package/dist/billing/crypto/evm/types.js +1 -0
- package/dist/billing/crypto/evm/watcher.d.ts +31 -0
- package/dist/billing/crypto/evm/watcher.js +91 -0
- package/dist/billing/crypto/index.d.ts +2 -1
- package/dist/billing/crypto/index.js +1 -0
- package/dist/db/schema/crypto.d.ts +68 -0
- package/dist/db/schema/crypto.js +7 -0
- package/dist/db/schema/organization-members.d.ts +34 -0
- package/dist/db/schema/organization-members.js +2 -0
- package/dist/tenancy/org-member-repository.d.ts +12 -0
- package/dist/tenancy/org-member-repository.js +14 -0
- package/dist/tenancy/org-service.d.ts +10 -1
- package/dist/tenancy/org-service.js +39 -0
- package/dist/tenancy/org-service.test.js +219 -0
- package/dist/trpc/index.d.ts +1 -1
- package/dist/trpc/index.js +1 -1
- package/dist/trpc/init.d.ts +7 -0
- package/dist/trpc/init.js +16 -1
- package/dist/trpc/init.test.js +39 -1
- package/docs/superpowers/plans/2026-03-14-stablecoin-phase1.md +1413 -0
- package/drizzle/migrations/0005_stablecoin_columns.sql +7 -0
- package/drizzle/migrations/0006_invite_acceptance.sql +2 -0
- package/drizzle/migrations/meta/_journal.json +14 -0
- package/package.json +4 -1
- package/src/auth/tenant-access.test.ts +2 -0
- package/src/billing/crypto/charge-store.test.ts +61 -0
- package/src/billing/crypto/charge-store.ts +54 -0
- package/src/billing/crypto/evm/__tests__/address-gen.test.ts +63 -0
- package/src/billing/crypto/evm/__tests__/checkout.test.ts +83 -0
- package/src/billing/crypto/evm/__tests__/config.test.ts +63 -0
- package/src/billing/crypto/evm/__tests__/settler.test.ts +218 -0
- package/src/billing/crypto/evm/__tests__/watcher.test.ts +128 -0
- package/src/billing/crypto/evm/address-gen.ts +29 -0
- package/src/billing/crypto/evm/checkout.ts +82 -0
- package/src/billing/crypto/evm/config.ts +50 -0
- package/src/billing/crypto/evm/index.ts +16 -0
- package/src/billing/crypto/evm/settler.ts +79 -0
- package/src/billing/crypto/evm/types.ts +45 -0
- package/src/billing/crypto/evm/watcher.ts +126 -0
- package/src/billing/crypto/index.ts +2 -1
- package/src/db/schema/crypto.ts +7 -0
- package/src/db/schema/organization-members.ts +2 -0
- package/src/tenancy/org-member-repository.ts +20 -0
- package/src/tenancy/org-service.test.ts +260 -0
- package/src/tenancy/org-service.ts +42 -1
- package/src/trpc/index.ts +1 -0
- package/src/trpc/init.test.ts +48 -0
- package/src/trpc/init.ts +18 -1
|
@@ -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.
|