@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,7 @@
1
+ ALTER TABLE "crypto_charges" ADD COLUMN "chain" text;--> statement-breakpoint
2
+ ALTER TABLE "crypto_charges" ADD COLUMN "token" text;--> statement-breakpoint
3
+ ALTER TABLE "crypto_charges" ADD COLUMN "deposit_address" text;--> statement-breakpoint
4
+ ALTER TABLE "crypto_charges" ADD COLUMN "derivation_index" integer;--> statement-breakpoint
5
+ CREATE INDEX "idx_crypto_charges_deposit_address" ON "crypto_charges" USING btree ("deposit_address");--> statement-breakpoint
6
+ CREATE UNIQUE INDEX "uq_crypto_charges_deposit_address" ON "crypto_charges" ("deposit_address") WHERE "deposit_address" IS NOT NULL;--> statement-breakpoint
7
+ CREATE UNIQUE INDEX "uq_crypto_charges_derivation_index" ON "crypto_charges" ("derivation_index") WHERE "derivation_index" IS NOT NULL;
@@ -36,6 +36,13 @@
36
36
  "when": 1741968000000,
37
37
  "tag": "0004_crypto_charges",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1742054400000,
44
+ "tag": "0005_stablecoin_columns",
45
+ "breakpoints": true
39
46
  }
40
47
  ]
41
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -128,7 +128,10 @@
128
128
  },
129
129
  "packageManager": "pnpm@10.31.0",
130
130
  "dependencies": {
131
+ "@scure/base": "^2.0.0",
132
+ "@scure/bip32": "^2.0.1",
131
133
  "js-yaml": "^4.1.1",
134
+ "viem": "^2.47.4",
132
135
  "yaml": "^2.8.2"
133
136
  }
134
137
  }
@@ -78,4 +78,65 @@ describe("CryptoChargeRepository", () => {
78
78
  await store.markCredited("inv-006");
79
79
  expect(await store.isCredited("inv-006")).toBe(true);
80
80
  });
81
+
82
+ describe("stablecoin charges", () => {
83
+ it("creates a stablecoin charge with chain/token/address", async () => {
84
+ await store.createStablecoinCharge({
85
+ referenceId: "sc:base:usdc:0x123",
86
+ tenantId: "tenant-1",
87
+ amountUsdCents: 1000,
88
+ chain: "base",
89
+ token: "USDC",
90
+ depositAddress: "0xabc123",
91
+ derivationIndex: 42,
92
+ });
93
+ const charge = await store.getByReferenceId("sc:base:usdc:0x123");
94
+ expect(charge).not.toBeNull();
95
+ expect(charge?.chain).toBe("base");
96
+ expect(charge?.token).toBe("USDC");
97
+ expect(charge?.depositAddress).toBe("0xabc123");
98
+ expect(charge?.derivationIndex).toBe(42);
99
+ expect(charge?.amountUsdCents).toBe(1000);
100
+ });
101
+
102
+ it("looks up charge by deposit address", async () => {
103
+ await store.createStablecoinCharge({
104
+ referenceId: "sc:base:usdc:0x456",
105
+ tenantId: "tenant-2",
106
+ amountUsdCents: 5000,
107
+ chain: "base",
108
+ token: "USDC",
109
+ depositAddress: "0xdef456",
110
+ derivationIndex: 43,
111
+ });
112
+ const charge = await store.getByDepositAddress("0xdef456");
113
+ expect(charge).not.toBeNull();
114
+ expect(charge?.tenantId).toBe("tenant-2");
115
+ expect(charge?.amountUsdCents).toBe(5000);
116
+ });
117
+
118
+ it("returns null for unknown deposit address", async () => {
119
+ const charge = await store.getByDepositAddress("0xnonexistent");
120
+ expect(charge).toBeNull();
121
+ });
122
+
123
+ it("gets next derivation index (0 when empty)", async () => {
124
+ const idx = await store.getNextDerivationIndex();
125
+ expect(idx).toBe(0);
126
+ });
127
+
128
+ it("gets next derivation index (max + 1)", async () => {
129
+ await store.createStablecoinCharge({
130
+ referenceId: "sc:idx-test",
131
+ tenantId: "t",
132
+ amountUsdCents: 100,
133
+ chain: "base",
134
+ token: "USDC",
135
+ depositAddress: "0xidxtest",
136
+ derivationIndex: 5,
137
+ });
138
+ const idx = await store.getNextDerivationIndex();
139
+ expect(idx).toBe(6);
140
+ });
141
+ });
81
142
  });
@@ -13,6 +13,20 @@ export interface CryptoChargeRecord {
13
13
  creditedAt: string | null;
14
14
  createdAt: string;
15
15
  updatedAt: string;
16
+ chain: string | null;
17
+ token: string | null;
18
+ depositAddress: string | null;
19
+ derivationIndex: number | null;
20
+ }
21
+
22
+ export interface StablecoinChargeInput {
23
+ referenceId: string;
24
+ tenantId: string;
25
+ amountUsdCents: number;
26
+ chain: string;
27
+ token: string;
28
+ depositAddress: string;
29
+ derivationIndex: number;
16
30
  }
17
31
 
18
32
  export interface ICryptoChargeRepository {
@@ -26,6 +40,9 @@ export interface ICryptoChargeRepository {
26
40
  ): Promise<void>;
27
41
  markCredited(referenceId: string): Promise<void>;
28
42
  isCredited(referenceId: string): Promise<boolean>;
43
+ createStablecoinCharge(input: StablecoinChargeInput): Promise<void>;
44
+ getByDepositAddress(address: string): Promise<CryptoChargeRecord | null>;
45
+ getNextDerivationIndex(): Promise<number>;
29
46
  }
30
47
 
31
48
  /**
@@ -55,6 +72,10 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
55
72
  async getByReferenceId(referenceId: string): Promise<CryptoChargeRecord | null> {
56
73
  const row = (await this.db.select().from(cryptoCharges).where(eq(cryptoCharges.referenceId, referenceId)))[0];
57
74
  if (!row) return null;
75
+ return this.toRecord(row);
76
+ }
77
+
78
+ private toRecord(row: typeof cryptoCharges.$inferSelect): CryptoChargeRecord {
58
79
  return {
59
80
  referenceId: row.referenceId,
60
81
  tenantId: row.tenantId,
@@ -65,6 +86,10 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
65
86
  creditedAt: row.creditedAt ?? null,
66
87
  createdAt: row.createdAt,
67
88
  updatedAt: row.updatedAt,
89
+ chain: row.chain ?? null,
90
+ token: row.token ?? null,
91
+ depositAddress: row.depositAddress ?? null,
92
+ derivationIndex: row.derivationIndex ?? null,
68
93
  };
69
94
  }
70
95
 
@@ -107,6 +132,35 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
107
132
  )[0];
108
133
  return row?.creditedAt != null;
109
134
  }
135
+
136
+ /** Create a stablecoin charge with chain/token/deposit address. */
137
+ async createStablecoinCharge(input: StablecoinChargeInput): Promise<void> {
138
+ await this.db.insert(cryptoCharges).values({
139
+ referenceId: input.referenceId,
140
+ tenantId: input.tenantId,
141
+ amountUsdCents: input.amountUsdCents,
142
+ status: "New",
143
+ chain: input.chain,
144
+ token: input.token,
145
+ depositAddress: input.depositAddress,
146
+ derivationIndex: input.derivationIndex,
147
+ });
148
+ }
149
+
150
+ /** Look up a charge by its deposit address. */
151
+ async getByDepositAddress(address: string): Promise<CryptoChargeRecord | null> {
152
+ const row = (await this.db.select().from(cryptoCharges).where(eq(cryptoCharges.depositAddress, address)))[0];
153
+ if (!row) return null;
154
+ return this.toRecord(row);
155
+ }
156
+
157
+ /** Get the next available HD derivation index (max + 1, or 0 if empty). */
158
+ async getNextDerivationIndex(): Promise<number> {
159
+ const result = await this.db
160
+ .select({ maxIdx: sql<number>`coalesce(max(${cryptoCharges.derivationIndex}), -1)` })
161
+ .from(cryptoCharges);
162
+ return (result[0]?.maxIdx ?? -1) + 1;
163
+ }
110
164
  }
111
165
 
112
166
  export { DrizzleCryptoChargeRepository as CryptoChargeRepository };
@@ -0,0 +1,63 @@
1
+ import { HDKey } from "@scure/bip32";
2
+ import { describe, expect, it } from "vitest";
3
+ import { deriveDepositAddress, isValidXpub } from "../address-gen.js";
4
+
5
+ // Generate a test xpub deterministically
6
+ function makeTestXpub(): string {
7
+ const seed = new Uint8Array(32);
8
+ seed[0] = 1; // deterministic seed
9
+ const master = HDKey.fromMasterSeed(seed);
10
+ // Derive to m/44'/60'/0' (Ethereum BIP-44 path)
11
+ const account = master.derive("m/44'/60'/0'");
12
+ return account.publicExtendedKey;
13
+ }
14
+
15
+ const TEST_XPUB = makeTestXpub();
16
+
17
+ describe("deriveDepositAddress", () => {
18
+ it("derives a valid Ethereum address", () => {
19
+ const addr = deriveDepositAddress(TEST_XPUB, 0);
20
+ expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/);
21
+ });
22
+
23
+ it("derives different addresses for different indices", () => {
24
+ const addr0 = deriveDepositAddress(TEST_XPUB, 0);
25
+ const addr1 = deriveDepositAddress(TEST_XPUB, 1);
26
+ expect(addr0).not.toBe(addr1);
27
+ });
28
+
29
+ it("is deterministic — same xpub + index = same address", () => {
30
+ const a = deriveDepositAddress(TEST_XPUB, 42);
31
+ const b = deriveDepositAddress(TEST_XPUB, 42);
32
+ expect(a).toBe(b);
33
+ });
34
+
35
+ it("returns checksummed address", () => {
36
+ const addr = deriveDepositAddress(TEST_XPUB, 0);
37
+ // Must be a valid 0x-prefixed address
38
+ expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/);
39
+ // viem's publicKeyToAddress always returns EIP-55 checksummed
40
+ // Verify it's not all-lowercase (checksummed addresses have mixed case)
41
+ const hexPart = addr.slice(2);
42
+ const hasUpperCase = hexPart !== hexPart.toLowerCase();
43
+ const hasLowerCase = hexPart !== hexPart.toUpperCase();
44
+ // At least one of these should be true for a checksummed address
45
+ // (unless the address happens to be all digits, which is extremely rare)
46
+ expect(hasUpperCase || !hexPart.match(/[a-f]/i)).toBe(true);
47
+ expect(hasLowerCase || !hexPart.match(/[a-f]/i)).toBe(true);
48
+ });
49
+ });
50
+
51
+ describe("isValidXpub", () => {
52
+ it("accepts valid xpub", () => {
53
+ expect(isValidXpub(TEST_XPUB)).toBe(true);
54
+ });
55
+
56
+ it("rejects garbage", () => {
57
+ expect(isValidXpub("not-an-xpub")).toBe(false);
58
+ });
59
+
60
+ it("rejects empty string", () => {
61
+ expect(isValidXpub("")).toBe(false);
62
+ });
63
+ });
@@ -0,0 +1,83 @@
1
+ import { HDKey } from "@scure/bip32";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createStablecoinCheckout } from "../checkout.js";
4
+
5
+ function makeTestXpub(): string {
6
+ const seed = new Uint8Array(32);
7
+ seed[0] = 1;
8
+ const master = HDKey.fromMasterSeed(seed);
9
+ return master.derive("m/44'/60'/0'").publicExtendedKey;
10
+ }
11
+
12
+ const TEST_XPUB = makeTestXpub();
13
+
14
+ describe("createStablecoinCheckout", () => {
15
+ it("derives address and creates charge", async () => {
16
+ const mockChargeStore = {
17
+ getNextDerivationIndex: vi.fn().mockResolvedValue(42),
18
+ createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
19
+ };
20
+
21
+ const result = await createStablecoinCheckout(
22
+ { chargeStore: mockChargeStore as never, xpub: TEST_XPUB },
23
+ { tenant: "t1", amountUsd: 10, chain: "base", token: "USDC" },
24
+ );
25
+
26
+ expect(result.depositAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
27
+ expect(result.amountRaw).toBe("10000000"); // 10 USDC = 10 * 10^6
28
+ expect(result.chain).toBe("base");
29
+ expect(result.token).toBe("USDC");
30
+ expect(mockChargeStore.createStablecoinCharge).toHaveBeenCalledOnce();
31
+
32
+ // Verify charge was created with integer cents, not floating point
33
+ const chargeInput = mockChargeStore.createStablecoinCharge.mock.calls[0][0];
34
+ expect(chargeInput.amountUsdCents).toBe(1000); // $10 = 1000 cents
35
+ expect(Number.isInteger(chargeInput.amountUsdCents)).toBe(true);
36
+ });
37
+
38
+ it("rejects below minimum", async () => {
39
+ const mockChargeStore = {
40
+ getNextDerivationIndex: vi.fn().mockResolvedValue(0),
41
+ createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
42
+ };
43
+
44
+ await expect(
45
+ createStablecoinCheckout(
46
+ { chargeStore: mockChargeStore as never, xpub: TEST_XPUB },
47
+ { tenant: "t1", amountUsd: 5, chain: "base", token: "USDC" },
48
+ ),
49
+ ).rejects.toThrow("Minimum");
50
+ });
51
+
52
+ it("stores deposit address in lowercase", async () => {
53
+ const mockChargeStore = {
54
+ getNextDerivationIndex: vi.fn().mockResolvedValue(0),
55
+ createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
56
+ };
57
+
58
+ await createStablecoinCheckout(
59
+ { chargeStore: mockChargeStore as never, xpub: TEST_XPUB },
60
+ { tenant: "t1", amountUsd: 10, chain: "base", token: "USDC" },
61
+ );
62
+
63
+ const chargeInput = mockChargeStore.createStablecoinCharge.mock.calls[0][0];
64
+ expect(chargeInput.depositAddress).toBe(chargeInput.depositAddress.toLowerCase());
65
+ });
66
+
67
+ it("converts $25 correctly to raw USDC amount", async () => {
68
+ const mockChargeStore = {
69
+ getNextDerivationIndex: vi.fn().mockResolvedValue(0),
70
+ createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
71
+ };
72
+
73
+ const result = await createStablecoinCheckout(
74
+ { chargeStore: mockChargeStore as never, xpub: TEST_XPUB },
75
+ { tenant: "t1", amountUsd: 25, chain: "base", token: "USDC" },
76
+ );
77
+
78
+ expect(result.amountRaw).toBe("25000000"); // 25 * 10^6
79
+
80
+ const chargeInput = mockChargeStore.createStablecoinCharge.mock.calls[0][0];
81
+ expect(chargeInput.amountUsdCents).toBe(2500);
82
+ });
83
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { centsFromTokenAmount, getChainConfig, getTokenConfig, tokenAmountFromCents } from "../config.js";
3
+
4
+ describe("getChainConfig", () => {
5
+ it("returns Base config", () => {
6
+ const cfg = getChainConfig("base");
7
+ expect(cfg.chainId).toBe(8453);
8
+ expect(cfg.confirmations).toBe(1);
9
+ expect(cfg.blockTimeMs).toBe(2000);
10
+ });
11
+
12
+ it("throws on unknown chain", () => {
13
+ // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
14
+ expect(() => getChainConfig("solana" as any)).toThrow("Unsupported chain");
15
+ });
16
+ });
17
+
18
+ describe("getTokenConfig", () => {
19
+ it("returns USDC on Base", () => {
20
+ const cfg = getTokenConfig("USDC", "base");
21
+ expect(cfg.decimals).toBe(6);
22
+ expect(cfg.contractAddress).toMatch(/^0x/);
23
+ expect(cfg.contractAddress).toBe("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913");
24
+ });
25
+
26
+ it("throws on unsupported token/chain combo", () => {
27
+ // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
28
+ expect(() => getTokenConfig("USDC" as any, "ethereum" as any)).toThrow("Unsupported token");
29
+ });
30
+ });
31
+
32
+ describe("tokenAmountFromCents", () => {
33
+ it("converts 1000 cents ($10) to USDC raw amount (6 decimals)", () => {
34
+ expect(tokenAmountFromCents(1000, 6)).toBe(10_000_000n);
35
+ });
36
+
37
+ it("converts 100 cents ($1) to DAI raw amount (18 decimals)", () => {
38
+ expect(tokenAmountFromCents(100, 18)).toBe(1_000_000_000_000_000_000n);
39
+ });
40
+
41
+ it("converts 1 cent to USDC", () => {
42
+ expect(tokenAmountFromCents(1, 6)).toBe(10_000n);
43
+ });
44
+
45
+ it("rejects non-integer cents", () => {
46
+ expect(() => tokenAmountFromCents(10.5, 6)).toThrow("integer");
47
+ });
48
+ });
49
+
50
+ describe("centsFromTokenAmount", () => {
51
+ it("converts 10 USDC raw to 1000 cents", () => {
52
+ expect(centsFromTokenAmount(10_000_000n, 6)).toBe(1000);
53
+ });
54
+
55
+ it("converts 1 DAI raw to 100 cents", () => {
56
+ expect(centsFromTokenAmount(1_000_000_000_000_000_000n, 18)).toBe(100);
57
+ });
58
+
59
+ it("truncates fractional cents", () => {
60
+ // 0.005 USDC = 5000 raw units = 0.5 cents -> truncates to 0
61
+ expect(centsFromTokenAmount(5000n, 6)).toBe(0);
62
+ });
63
+ });
@@ -0,0 +1,218 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { settleEvmPayment } from "../settler.js";
3
+ import type { EvmPaymentEvent } from "../types.js";
4
+
5
+ const mockEvent: EvmPaymentEvent = {
6
+ chain: "base",
7
+ token: "USDC",
8
+ from: "0xsender",
9
+ to: "0xdeposit",
10
+ rawAmount: "10000000", // 10 USDC
11
+ amountUsdCents: 1000,
12
+ txHash: "0xtx123",
13
+ blockNumber: 100,
14
+ logIndex: 0,
15
+ };
16
+
17
+ describe("settleEvmPayment", () => {
18
+ it("credits ledger when charge found and not yet credited", async () => {
19
+ const deps = {
20
+ chargeStore: {
21
+ getByDepositAddress: vi.fn().mockResolvedValue({
22
+ referenceId: "sc:base:usdc:abc",
23
+ tenantId: "tenant-1",
24
+ amountUsdCents: 1000,
25
+ status: "New",
26
+ creditedAt: null,
27
+ }),
28
+ updateStatus: vi.fn().mockResolvedValue(undefined),
29
+ markCredited: vi.fn().mockResolvedValue(undefined),
30
+ },
31
+ creditLedger: {
32
+ hasReferenceId: vi.fn().mockResolvedValue(false),
33
+ credit: vi.fn().mockResolvedValue({}),
34
+ },
35
+ onCreditsPurchased: vi.fn().mockResolvedValue([]),
36
+ };
37
+
38
+ const result = await settleEvmPayment(deps as never, mockEvent);
39
+
40
+ expect(result.handled).toBe(true);
41
+ expect(result.creditedCents).toBe(1000);
42
+ expect(deps.creditLedger.credit).toHaveBeenCalledOnce();
43
+ expect(deps.chargeStore.markCredited).toHaveBeenCalledOnce();
44
+
45
+ // Verify Credit.fromCents was used (credit is called with a Credit object, not raw cents)
46
+ const creditArg = deps.creditLedger.credit.mock.calls[0][1];
47
+ expect(creditArg.toCentsRounded()).toBe(1000);
48
+ });
49
+
50
+ it("skips crediting when already credited (idempotent)", async () => {
51
+ const deps = {
52
+ chargeStore: {
53
+ getByDepositAddress: vi.fn().mockResolvedValue({
54
+ referenceId: "sc:base:usdc:abc",
55
+ tenantId: "tenant-1",
56
+ amountUsdCents: 1000,
57
+ status: "Settled",
58
+ creditedAt: "2026-01-01",
59
+ }),
60
+ updateStatus: vi.fn().mockResolvedValue(undefined),
61
+ markCredited: vi.fn().mockResolvedValue(undefined),
62
+ },
63
+ creditLedger: {
64
+ hasReferenceId: vi.fn().mockResolvedValue(true),
65
+ credit: vi.fn().mockResolvedValue({}),
66
+ },
67
+ };
68
+
69
+ const result = await settleEvmPayment(deps as never, mockEvent);
70
+
71
+ expect(result.handled).toBe(true);
72
+ expect(result.creditedCents).toBe(0);
73
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("returns handled:false when no charge found for deposit address", async () => {
77
+ const deps = {
78
+ chargeStore: {
79
+ getByDepositAddress: vi.fn().mockResolvedValue(null),
80
+ updateStatus: vi.fn(),
81
+ markCredited: vi.fn(),
82
+ },
83
+ creditLedger: { hasReferenceId: vi.fn(), credit: vi.fn() },
84
+ };
85
+
86
+ const result = await settleEvmPayment(deps as never, mockEvent);
87
+ expect(result.handled).toBe(false);
88
+ });
89
+
90
+ it("credits the charge amount, not the transfer amount (overpayment safe)", async () => {
91
+ const overpaidEvent = { ...mockEvent, amountUsdCents: 2000 }; // sent $20
92
+ const deps = {
93
+ chargeStore: {
94
+ getByDepositAddress: vi.fn().mockResolvedValue({
95
+ referenceId: "sc:x",
96
+ tenantId: "t",
97
+ amountUsdCents: 1000, // charge was for $10
98
+ status: "New",
99
+ creditedAt: null,
100
+ }),
101
+ updateStatus: vi.fn().mockResolvedValue(undefined),
102
+ markCredited: vi.fn().mockResolvedValue(undefined),
103
+ },
104
+ creditLedger: {
105
+ hasReferenceId: vi.fn().mockResolvedValue(false),
106
+ credit: vi.fn().mockResolvedValue({}),
107
+ },
108
+ onCreditsPurchased: vi.fn().mockResolvedValue([]),
109
+ };
110
+
111
+ const result = await settleEvmPayment(deps as never, overpaidEvent);
112
+ expect(result.creditedCents).toBe(1000); // charge amount, NOT transfer amount
113
+ });
114
+
115
+ it("rejects underpayment — does not credit if transfer < charge", async () => {
116
+ const underpaidEvent = { ...mockEvent, amountUsdCents: 500 }; // sent $5
117
+ const deps = {
118
+ chargeStore: {
119
+ getByDepositAddress: vi.fn().mockResolvedValue({
120
+ referenceId: "sc:x",
121
+ tenantId: "t",
122
+ amountUsdCents: 1000, // charge was for $10
123
+ status: "New",
124
+ creditedAt: null,
125
+ }),
126
+ updateStatus: vi.fn().mockResolvedValue(undefined),
127
+ markCredited: vi.fn().mockResolvedValue(undefined),
128
+ },
129
+ creditLedger: {
130
+ hasReferenceId: vi.fn().mockResolvedValue(false),
131
+ credit: vi.fn().mockResolvedValue({}),
132
+ },
133
+ };
134
+
135
+ const result = await settleEvmPayment(deps as never, underpaidEvent);
136
+ expect(result.creditedCents).toBe(0);
137
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("uses correct ledger referenceId format", async () => {
141
+ const deps = {
142
+ chargeStore: {
143
+ getByDepositAddress: vi.fn().mockResolvedValue({
144
+ referenceId: "sc:ref",
145
+ tenantId: "t",
146
+ amountUsdCents: 500,
147
+ status: "New",
148
+ creditedAt: null,
149
+ }),
150
+ updateStatus: vi.fn().mockResolvedValue(undefined),
151
+ markCredited: vi.fn().mockResolvedValue(undefined),
152
+ },
153
+ creditLedger: {
154
+ hasReferenceId: vi.fn().mockResolvedValue(false),
155
+ credit: vi.fn().mockResolvedValue({}),
156
+ },
157
+ onCreditsPurchased: vi.fn().mockResolvedValue([]),
158
+ };
159
+
160
+ await settleEvmPayment(deps as never, mockEvent);
161
+
162
+ const creditOpts = deps.creditLedger.credit.mock.calls[0][3];
163
+ expect(creditOpts.referenceId).toBe("evm:base:0xtx123:0");
164
+ expect(creditOpts.fundingSource).toBe("crypto");
165
+ });
166
+
167
+ it("calls onCreditsPurchased when provided", async () => {
168
+ const onPurchased = vi.fn().mockResolvedValue(["bot-1", "bot-2"]);
169
+ const deps = {
170
+ chargeStore: {
171
+ getByDepositAddress: vi.fn().mockResolvedValue({
172
+ referenceId: "sc:ref",
173
+ tenantId: "t",
174
+ amountUsdCents: 500,
175
+ status: "New",
176
+ creditedAt: null,
177
+ }),
178
+ updateStatus: vi.fn().mockResolvedValue(undefined),
179
+ markCredited: vi.fn().mockResolvedValue(undefined),
180
+ },
181
+ creditLedger: {
182
+ hasReferenceId: vi.fn().mockResolvedValue(false),
183
+ credit: vi.fn().mockResolvedValue({}),
184
+ },
185
+ onCreditsPurchased: onPurchased,
186
+ };
187
+
188
+ const result = await settleEvmPayment(deps as never, mockEvent);
189
+ expect(onPurchased).toHaveBeenCalledOnce();
190
+ expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
191
+ });
192
+
193
+ it("rejects second transfer to already-credited charge (no double-credit)", async () => {
194
+ const secondTxEvent = { ...mockEvent, txHash: "0xsecondtx", logIndex: 0 };
195
+ const deps = {
196
+ chargeStore: {
197
+ getByDepositAddress: vi.fn().mockResolvedValue({
198
+ referenceId: "sc:base:usdc:abc",
199
+ tenantId: "tenant-1",
200
+ amountUsdCents: 1000,
201
+ status: "Settled",
202
+ creditedAt: "2026-01-01T00:00:00Z", // already credited by first tx
203
+ }),
204
+ updateStatus: vi.fn().mockResolvedValue(undefined),
205
+ markCredited: vi.fn().mockResolvedValue(undefined),
206
+ },
207
+ creditLedger: {
208
+ hasReferenceId: vi.fn().mockResolvedValue(false), // new txHash, so this returns false
209
+ credit: vi.fn().mockResolvedValue({}),
210
+ },
211
+ };
212
+
213
+ const result = await settleEvmPayment(deps as never, secondTxEvent);
214
+ expect(result.handled).toBe(true);
215
+ expect(result.creditedCents).toBe(0);
216
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled(); // must NOT double-credit
217
+ });
218
+ });