@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.
- 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/docs/superpowers/plans/2026-03-14-stablecoin-phase1.md +1413 -0
- package/drizzle/migrations/0005_stablecoin_columns.sql +7 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +4 -1
- 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
|
@@ -10,6 +10,19 @@ export interface CryptoChargeRecord {
|
|
|
10
10
|
creditedAt: string | null;
|
|
11
11
|
createdAt: string;
|
|
12
12
|
updatedAt: string;
|
|
13
|
+
chain: string | null;
|
|
14
|
+
token: string | null;
|
|
15
|
+
depositAddress: string | null;
|
|
16
|
+
derivationIndex: number | null;
|
|
17
|
+
}
|
|
18
|
+
export interface StablecoinChargeInput {
|
|
19
|
+
referenceId: string;
|
|
20
|
+
tenantId: string;
|
|
21
|
+
amountUsdCents: number;
|
|
22
|
+
chain: string;
|
|
23
|
+
token: string;
|
|
24
|
+
depositAddress: string;
|
|
25
|
+
derivationIndex: number;
|
|
13
26
|
}
|
|
14
27
|
export interface ICryptoChargeRepository {
|
|
15
28
|
create(referenceId: string, tenantId: string, amountUsdCents: number): Promise<void>;
|
|
@@ -17,6 +30,9 @@ export interface ICryptoChargeRepository {
|
|
|
17
30
|
updateStatus(referenceId: string, status: CryptoPaymentState, currency?: string, filledAmount?: string): Promise<void>;
|
|
18
31
|
markCredited(referenceId: string): Promise<void>;
|
|
19
32
|
isCredited(referenceId: string): Promise<boolean>;
|
|
33
|
+
createStablecoinCharge(input: StablecoinChargeInput): Promise<void>;
|
|
34
|
+
getByDepositAddress(address: string): Promise<CryptoChargeRecord | null>;
|
|
35
|
+
getNextDerivationIndex(): Promise<number>;
|
|
20
36
|
}
|
|
21
37
|
/**
|
|
22
38
|
* Manages crypto charge records in PostgreSQL.
|
|
@@ -35,11 +51,18 @@ export declare class DrizzleCryptoChargeRepository implements ICryptoChargeRepos
|
|
|
35
51
|
create(referenceId: string, tenantId: string, amountUsdCents: number): Promise<void>;
|
|
36
52
|
/** Get a charge by reference ID. Returns null if not found. */
|
|
37
53
|
getByReferenceId(referenceId: string): Promise<CryptoChargeRecord | null>;
|
|
54
|
+
private toRecord;
|
|
38
55
|
/** Update charge status and payment details from webhook. */
|
|
39
56
|
updateStatus(referenceId: string, status: CryptoPaymentState, currency?: string, filledAmount?: string): Promise<void>;
|
|
40
57
|
/** Mark a charge as credited (idempotency flag). */
|
|
41
58
|
markCredited(referenceId: string): Promise<void>;
|
|
42
59
|
/** Check if a charge has already been credited (for idempotency). */
|
|
43
60
|
isCredited(referenceId: string): Promise<boolean>;
|
|
61
|
+
/** Create a stablecoin charge with chain/token/deposit address. */
|
|
62
|
+
createStablecoinCharge(input: StablecoinChargeInput): Promise<void>;
|
|
63
|
+
/** Look up a charge by its deposit address. */
|
|
64
|
+
getByDepositAddress(address: string): Promise<CryptoChargeRecord | null>;
|
|
65
|
+
/** Get the next available HD derivation index (max + 1, or 0 if empty). */
|
|
66
|
+
getNextDerivationIndex(): Promise<number>;
|
|
44
67
|
}
|
|
45
68
|
export { DrizzleCryptoChargeRepository as CryptoChargeRepository };
|
|
@@ -29,6 +29,9 @@ export class DrizzleCryptoChargeRepository {
|
|
|
29
29
|
const row = (await this.db.select().from(cryptoCharges).where(eq(cryptoCharges.referenceId, referenceId)))[0];
|
|
30
30
|
if (!row)
|
|
31
31
|
return null;
|
|
32
|
+
return this.toRecord(row);
|
|
33
|
+
}
|
|
34
|
+
toRecord(row) {
|
|
32
35
|
return {
|
|
33
36
|
referenceId: row.referenceId,
|
|
34
37
|
tenantId: row.tenantId,
|
|
@@ -39,6 +42,10 @@ export class DrizzleCryptoChargeRepository {
|
|
|
39
42
|
creditedAt: row.creditedAt ?? null,
|
|
40
43
|
createdAt: row.createdAt,
|
|
41
44
|
updatedAt: row.updatedAt,
|
|
45
|
+
chain: row.chain ?? null,
|
|
46
|
+
token: row.token ?? null,
|
|
47
|
+
depositAddress: row.depositAddress ?? null,
|
|
48
|
+
derivationIndex: row.derivationIndex ?? null,
|
|
42
49
|
};
|
|
43
50
|
}
|
|
44
51
|
/** Update charge status and payment details from webhook. */
|
|
@@ -71,5 +78,32 @@ export class DrizzleCryptoChargeRepository {
|
|
|
71
78
|
.where(eq(cryptoCharges.referenceId, referenceId)))[0];
|
|
72
79
|
return row?.creditedAt != null;
|
|
73
80
|
}
|
|
81
|
+
/** Create a stablecoin charge with chain/token/deposit address. */
|
|
82
|
+
async createStablecoinCharge(input) {
|
|
83
|
+
await this.db.insert(cryptoCharges).values({
|
|
84
|
+
referenceId: input.referenceId,
|
|
85
|
+
tenantId: input.tenantId,
|
|
86
|
+
amountUsdCents: input.amountUsdCents,
|
|
87
|
+
status: "New",
|
|
88
|
+
chain: input.chain,
|
|
89
|
+
token: input.token,
|
|
90
|
+
depositAddress: input.depositAddress,
|
|
91
|
+
derivationIndex: input.derivationIndex,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** Look up a charge by its deposit address. */
|
|
95
|
+
async getByDepositAddress(address) {
|
|
96
|
+
const row = (await this.db.select().from(cryptoCharges).where(eq(cryptoCharges.depositAddress, address)))[0];
|
|
97
|
+
if (!row)
|
|
98
|
+
return null;
|
|
99
|
+
return this.toRecord(row);
|
|
100
|
+
}
|
|
101
|
+
/** Get the next available HD derivation index (max + 1, or 0 if empty). */
|
|
102
|
+
async getNextDerivationIndex() {
|
|
103
|
+
const result = await this.db
|
|
104
|
+
.select({ maxIdx: sql `coalesce(max(${cryptoCharges.derivationIndex}), -1)` })
|
|
105
|
+
.from(cryptoCharges);
|
|
106
|
+
return (result[0]?.maxIdx ?? -1) + 1;
|
|
107
|
+
}
|
|
74
108
|
}
|
|
75
109
|
export { DrizzleCryptoChargeRepository as CryptoChargeRepository };
|
|
@@ -61,4 +61,60 @@ describe("CryptoChargeRepository", () => {
|
|
|
61
61
|
await store.markCredited("inv-006");
|
|
62
62
|
expect(await store.isCredited("inv-006")).toBe(true);
|
|
63
63
|
});
|
|
64
|
+
describe("stablecoin charges", () => {
|
|
65
|
+
it("creates a stablecoin charge with chain/token/address", async () => {
|
|
66
|
+
await store.createStablecoinCharge({
|
|
67
|
+
referenceId: "sc:base:usdc:0x123",
|
|
68
|
+
tenantId: "tenant-1",
|
|
69
|
+
amountUsdCents: 1000,
|
|
70
|
+
chain: "base",
|
|
71
|
+
token: "USDC",
|
|
72
|
+
depositAddress: "0xabc123",
|
|
73
|
+
derivationIndex: 42,
|
|
74
|
+
});
|
|
75
|
+
const charge = await store.getByReferenceId("sc:base:usdc:0x123");
|
|
76
|
+
expect(charge).not.toBeNull();
|
|
77
|
+
expect(charge?.chain).toBe("base");
|
|
78
|
+
expect(charge?.token).toBe("USDC");
|
|
79
|
+
expect(charge?.depositAddress).toBe("0xabc123");
|
|
80
|
+
expect(charge?.derivationIndex).toBe(42);
|
|
81
|
+
expect(charge?.amountUsdCents).toBe(1000);
|
|
82
|
+
});
|
|
83
|
+
it("looks up charge by deposit address", async () => {
|
|
84
|
+
await store.createStablecoinCharge({
|
|
85
|
+
referenceId: "sc:base:usdc:0x456",
|
|
86
|
+
tenantId: "tenant-2",
|
|
87
|
+
amountUsdCents: 5000,
|
|
88
|
+
chain: "base",
|
|
89
|
+
token: "USDC",
|
|
90
|
+
depositAddress: "0xdef456",
|
|
91
|
+
derivationIndex: 43,
|
|
92
|
+
});
|
|
93
|
+
const charge = await store.getByDepositAddress("0xdef456");
|
|
94
|
+
expect(charge).not.toBeNull();
|
|
95
|
+
expect(charge?.tenantId).toBe("tenant-2");
|
|
96
|
+
expect(charge?.amountUsdCents).toBe(5000);
|
|
97
|
+
});
|
|
98
|
+
it("returns null for unknown deposit address", async () => {
|
|
99
|
+
const charge = await store.getByDepositAddress("0xnonexistent");
|
|
100
|
+
expect(charge).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
it("gets next derivation index (0 when empty)", async () => {
|
|
103
|
+
const idx = await store.getNextDerivationIndex();
|
|
104
|
+
expect(idx).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
it("gets next derivation index (max + 1)", async () => {
|
|
107
|
+
await store.createStablecoinCharge({
|
|
108
|
+
referenceId: "sc:idx-test",
|
|
109
|
+
tenantId: "t",
|
|
110
|
+
amountUsdCents: 100,
|
|
111
|
+
chain: "base",
|
|
112
|
+
token: "USDC",
|
|
113
|
+
depositAddress: "0xidxtest",
|
|
114
|
+
derivationIndex: 5,
|
|
115
|
+
});
|
|
116
|
+
const idx = await store.getNextDerivationIndex();
|
|
117
|
+
expect(idx).toBe(6);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
64
120
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { HDKey } from "@scure/bip32";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { deriveDepositAddress, isValidXpub } from "../address-gen.js";
|
|
4
|
+
// Generate a test xpub deterministically
|
|
5
|
+
function makeTestXpub() {
|
|
6
|
+
const seed = new Uint8Array(32);
|
|
7
|
+
seed[0] = 1; // deterministic seed
|
|
8
|
+
const master = HDKey.fromMasterSeed(seed);
|
|
9
|
+
// Derive to m/44'/60'/0' (Ethereum BIP-44 path)
|
|
10
|
+
const account = master.derive("m/44'/60'/0'");
|
|
11
|
+
return account.publicExtendedKey;
|
|
12
|
+
}
|
|
13
|
+
const TEST_XPUB = makeTestXpub();
|
|
14
|
+
describe("deriveDepositAddress", () => {
|
|
15
|
+
it("derives a valid Ethereum address", () => {
|
|
16
|
+
const addr = deriveDepositAddress(TEST_XPUB, 0);
|
|
17
|
+
expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
|
18
|
+
});
|
|
19
|
+
it("derives different addresses for different indices", () => {
|
|
20
|
+
const addr0 = deriveDepositAddress(TEST_XPUB, 0);
|
|
21
|
+
const addr1 = deriveDepositAddress(TEST_XPUB, 1);
|
|
22
|
+
expect(addr0).not.toBe(addr1);
|
|
23
|
+
});
|
|
24
|
+
it("is deterministic — same xpub + index = same address", () => {
|
|
25
|
+
const a = deriveDepositAddress(TEST_XPUB, 42);
|
|
26
|
+
const b = deriveDepositAddress(TEST_XPUB, 42);
|
|
27
|
+
expect(a).toBe(b);
|
|
28
|
+
});
|
|
29
|
+
it("returns checksummed address", () => {
|
|
30
|
+
const addr = deriveDepositAddress(TEST_XPUB, 0);
|
|
31
|
+
// Must be a valid 0x-prefixed address
|
|
32
|
+
expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
|
33
|
+
// viem's publicKeyToAddress always returns EIP-55 checksummed
|
|
34
|
+
// Verify it's not all-lowercase (checksummed addresses have mixed case)
|
|
35
|
+
const hexPart = addr.slice(2);
|
|
36
|
+
const hasUpperCase = hexPart !== hexPart.toLowerCase();
|
|
37
|
+
const hasLowerCase = hexPart !== hexPart.toUpperCase();
|
|
38
|
+
// At least one of these should be true for a checksummed address
|
|
39
|
+
// (unless the address happens to be all digits, which is extremely rare)
|
|
40
|
+
expect(hasUpperCase || !hexPart.match(/[a-f]/i)).toBe(true);
|
|
41
|
+
expect(hasLowerCase || !hexPart.match(/[a-f]/i)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("isValidXpub", () => {
|
|
45
|
+
it("accepts valid xpub", () => {
|
|
46
|
+
expect(isValidXpub(TEST_XPUB)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it("rejects garbage", () => {
|
|
49
|
+
expect(isValidXpub("not-an-xpub")).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
it("rejects empty string", () => {
|
|
52
|
+
expect(isValidXpub("")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { HDKey } from "@scure/bip32";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createStablecoinCheckout } from "../checkout.js";
|
|
4
|
+
function makeTestXpub() {
|
|
5
|
+
const seed = new Uint8Array(32);
|
|
6
|
+
seed[0] = 1;
|
|
7
|
+
const master = HDKey.fromMasterSeed(seed);
|
|
8
|
+
return master.derive("m/44'/60'/0'").publicExtendedKey;
|
|
9
|
+
}
|
|
10
|
+
const TEST_XPUB = makeTestXpub();
|
|
11
|
+
describe("createStablecoinCheckout", () => {
|
|
12
|
+
it("derives address and creates charge", async () => {
|
|
13
|
+
const mockChargeStore = {
|
|
14
|
+
getNextDerivationIndex: vi.fn().mockResolvedValue(42),
|
|
15
|
+
createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
};
|
|
17
|
+
const result = await createStablecoinCheckout({ chargeStore: mockChargeStore, xpub: TEST_XPUB }, { tenant: "t1", amountUsd: 10, chain: "base", token: "USDC" });
|
|
18
|
+
expect(result.depositAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
|
19
|
+
expect(result.amountRaw).toBe("10000000"); // 10 USDC = 10 * 10^6
|
|
20
|
+
expect(result.chain).toBe("base");
|
|
21
|
+
expect(result.token).toBe("USDC");
|
|
22
|
+
expect(mockChargeStore.createStablecoinCharge).toHaveBeenCalledOnce();
|
|
23
|
+
// Verify charge was created with integer cents, not floating point
|
|
24
|
+
const chargeInput = mockChargeStore.createStablecoinCharge.mock.calls[0][0];
|
|
25
|
+
expect(chargeInput.amountUsdCents).toBe(1000); // $10 = 1000 cents
|
|
26
|
+
expect(Number.isInteger(chargeInput.amountUsdCents)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it("rejects below minimum", async () => {
|
|
29
|
+
const mockChargeStore = {
|
|
30
|
+
getNextDerivationIndex: vi.fn().mockResolvedValue(0),
|
|
31
|
+
createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
};
|
|
33
|
+
await expect(createStablecoinCheckout({ chargeStore: mockChargeStore, xpub: TEST_XPUB }, { tenant: "t1", amountUsd: 5, chain: "base", token: "USDC" })).rejects.toThrow("Minimum");
|
|
34
|
+
});
|
|
35
|
+
it("stores deposit address in lowercase", async () => {
|
|
36
|
+
const mockChargeStore = {
|
|
37
|
+
getNextDerivationIndex: vi.fn().mockResolvedValue(0),
|
|
38
|
+
createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
};
|
|
40
|
+
await createStablecoinCheckout({ chargeStore: mockChargeStore, xpub: TEST_XPUB }, { tenant: "t1", amountUsd: 10, chain: "base", token: "USDC" });
|
|
41
|
+
const chargeInput = mockChargeStore.createStablecoinCharge.mock.calls[0][0];
|
|
42
|
+
expect(chargeInput.depositAddress).toBe(chargeInput.depositAddress.toLowerCase());
|
|
43
|
+
});
|
|
44
|
+
it("converts $25 correctly to raw USDC amount", async () => {
|
|
45
|
+
const mockChargeStore = {
|
|
46
|
+
getNextDerivationIndex: vi.fn().mockResolvedValue(0),
|
|
47
|
+
createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
|
|
48
|
+
};
|
|
49
|
+
const result = await createStablecoinCheckout({ chargeStore: mockChargeStore, xpub: TEST_XPUB }, { tenant: "t1", amountUsd: 25, chain: "base", token: "USDC" });
|
|
50
|
+
expect(result.amountRaw).toBe("25000000"); // 25 * 10^6
|
|
51
|
+
const chargeInput = mockChargeStore.createStablecoinCharge.mock.calls[0][0];
|
|
52
|
+
expect(chargeInput.amountUsdCents).toBe(2500);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { centsFromTokenAmount, getChainConfig, getTokenConfig, tokenAmountFromCents } from "../config.js";
|
|
3
|
+
describe("getChainConfig", () => {
|
|
4
|
+
it("returns Base config", () => {
|
|
5
|
+
const cfg = getChainConfig("base");
|
|
6
|
+
expect(cfg.chainId).toBe(8453);
|
|
7
|
+
expect(cfg.confirmations).toBe(1);
|
|
8
|
+
expect(cfg.blockTimeMs).toBe(2000);
|
|
9
|
+
});
|
|
10
|
+
it("throws on unknown chain", () => {
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
12
|
+
expect(() => getChainConfig("solana")).toThrow("Unsupported chain");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe("getTokenConfig", () => {
|
|
16
|
+
it("returns USDC on Base", () => {
|
|
17
|
+
const cfg = getTokenConfig("USDC", "base");
|
|
18
|
+
expect(cfg.decimals).toBe(6);
|
|
19
|
+
expect(cfg.contractAddress).toMatch(/^0x/);
|
|
20
|
+
expect(cfg.contractAddress).toBe("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913");
|
|
21
|
+
});
|
|
22
|
+
it("throws on unsupported token/chain combo", () => {
|
|
23
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
24
|
+
expect(() => getTokenConfig("USDC", "ethereum")).toThrow("Unsupported token");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe("tokenAmountFromCents", () => {
|
|
28
|
+
it("converts 1000 cents ($10) to USDC raw amount (6 decimals)", () => {
|
|
29
|
+
expect(tokenAmountFromCents(1000, 6)).toBe(10000000n);
|
|
30
|
+
});
|
|
31
|
+
it("converts 100 cents ($1) to DAI raw amount (18 decimals)", () => {
|
|
32
|
+
expect(tokenAmountFromCents(100, 18)).toBe(1000000000000000000n);
|
|
33
|
+
});
|
|
34
|
+
it("converts 1 cent to USDC", () => {
|
|
35
|
+
expect(tokenAmountFromCents(1, 6)).toBe(10000n);
|
|
36
|
+
});
|
|
37
|
+
it("rejects non-integer cents", () => {
|
|
38
|
+
expect(() => tokenAmountFromCents(10.5, 6)).toThrow("integer");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe("centsFromTokenAmount", () => {
|
|
42
|
+
it("converts 10 USDC raw to 1000 cents", () => {
|
|
43
|
+
expect(centsFromTokenAmount(10000000n, 6)).toBe(1000);
|
|
44
|
+
});
|
|
45
|
+
it("converts 1 DAI raw to 100 cents", () => {
|
|
46
|
+
expect(centsFromTokenAmount(1000000000000000000n, 18)).toBe(100);
|
|
47
|
+
});
|
|
48
|
+
it("truncates fractional cents", () => {
|
|
49
|
+
// 0.005 USDC = 5000 raw units = 0.5 cents -> truncates to 0
|
|
50
|
+
expect(centsFromTokenAmount(5000n, 6)).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { settleEvmPayment } from "../settler.js";
|
|
3
|
+
const mockEvent = {
|
|
4
|
+
chain: "base",
|
|
5
|
+
token: "USDC",
|
|
6
|
+
from: "0xsender",
|
|
7
|
+
to: "0xdeposit",
|
|
8
|
+
rawAmount: "10000000", // 10 USDC
|
|
9
|
+
amountUsdCents: 1000,
|
|
10
|
+
txHash: "0xtx123",
|
|
11
|
+
blockNumber: 100,
|
|
12
|
+
logIndex: 0,
|
|
13
|
+
};
|
|
14
|
+
describe("settleEvmPayment", () => {
|
|
15
|
+
it("credits ledger when charge found and not yet credited", async () => {
|
|
16
|
+
const deps = {
|
|
17
|
+
chargeStore: {
|
|
18
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
19
|
+
referenceId: "sc:base:usdc:abc",
|
|
20
|
+
tenantId: "tenant-1",
|
|
21
|
+
amountUsdCents: 1000,
|
|
22
|
+
status: "New",
|
|
23
|
+
creditedAt: null,
|
|
24
|
+
}),
|
|
25
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
},
|
|
28
|
+
creditLedger: {
|
|
29
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
30
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
31
|
+
},
|
|
32
|
+
onCreditsPurchased: vi.fn().mockResolvedValue([]),
|
|
33
|
+
};
|
|
34
|
+
const result = await settleEvmPayment(deps, mockEvent);
|
|
35
|
+
expect(result.handled).toBe(true);
|
|
36
|
+
expect(result.creditedCents).toBe(1000);
|
|
37
|
+
expect(deps.creditLedger.credit).toHaveBeenCalledOnce();
|
|
38
|
+
expect(deps.chargeStore.markCredited).toHaveBeenCalledOnce();
|
|
39
|
+
// Verify Credit.fromCents was used (credit is called with a Credit object, not raw cents)
|
|
40
|
+
const creditArg = deps.creditLedger.credit.mock.calls[0][1];
|
|
41
|
+
expect(creditArg.toCentsRounded()).toBe(1000);
|
|
42
|
+
});
|
|
43
|
+
it("skips crediting when already credited (idempotent)", async () => {
|
|
44
|
+
const deps = {
|
|
45
|
+
chargeStore: {
|
|
46
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
47
|
+
referenceId: "sc:base:usdc:abc",
|
|
48
|
+
tenantId: "tenant-1",
|
|
49
|
+
amountUsdCents: 1000,
|
|
50
|
+
status: "Settled",
|
|
51
|
+
creditedAt: "2026-01-01",
|
|
52
|
+
}),
|
|
53
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
55
|
+
},
|
|
56
|
+
creditLedger: {
|
|
57
|
+
hasReferenceId: vi.fn().mockResolvedValue(true),
|
|
58
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const result = await settleEvmPayment(deps, mockEvent);
|
|
62
|
+
expect(result.handled).toBe(true);
|
|
63
|
+
expect(result.creditedCents).toBe(0);
|
|
64
|
+
expect(deps.creditLedger.credit).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
it("returns handled:false when no charge found for deposit address", async () => {
|
|
67
|
+
const deps = {
|
|
68
|
+
chargeStore: {
|
|
69
|
+
getByDepositAddress: vi.fn().mockResolvedValue(null),
|
|
70
|
+
updateStatus: vi.fn(),
|
|
71
|
+
markCredited: vi.fn(),
|
|
72
|
+
},
|
|
73
|
+
creditLedger: { hasReferenceId: vi.fn(), credit: vi.fn() },
|
|
74
|
+
};
|
|
75
|
+
const result = await settleEvmPayment(deps, mockEvent);
|
|
76
|
+
expect(result.handled).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
it("credits the charge amount, not the transfer amount (overpayment safe)", async () => {
|
|
79
|
+
const overpaidEvent = { ...mockEvent, amountUsdCents: 2000 }; // sent $20
|
|
80
|
+
const deps = {
|
|
81
|
+
chargeStore: {
|
|
82
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
83
|
+
referenceId: "sc:x",
|
|
84
|
+
tenantId: "t",
|
|
85
|
+
amountUsdCents: 1000, // charge was for $10
|
|
86
|
+
status: "New",
|
|
87
|
+
creditedAt: null,
|
|
88
|
+
}),
|
|
89
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
90
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
91
|
+
},
|
|
92
|
+
creditLedger: {
|
|
93
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
94
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
95
|
+
},
|
|
96
|
+
onCreditsPurchased: vi.fn().mockResolvedValue([]),
|
|
97
|
+
};
|
|
98
|
+
const result = await settleEvmPayment(deps, overpaidEvent);
|
|
99
|
+
expect(result.creditedCents).toBe(1000); // charge amount, NOT transfer amount
|
|
100
|
+
});
|
|
101
|
+
it("rejects underpayment — does not credit if transfer < charge", async () => {
|
|
102
|
+
const underpaidEvent = { ...mockEvent, amountUsdCents: 500 }; // sent $5
|
|
103
|
+
const deps = {
|
|
104
|
+
chargeStore: {
|
|
105
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
106
|
+
referenceId: "sc:x",
|
|
107
|
+
tenantId: "t",
|
|
108
|
+
amountUsdCents: 1000, // charge was for $10
|
|
109
|
+
status: "New",
|
|
110
|
+
creditedAt: null,
|
|
111
|
+
}),
|
|
112
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
113
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
114
|
+
},
|
|
115
|
+
creditLedger: {
|
|
116
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
117
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
const result = await settleEvmPayment(deps, underpaidEvent);
|
|
121
|
+
expect(result.creditedCents).toBe(0);
|
|
122
|
+
expect(deps.creditLedger.credit).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
it("uses correct ledger referenceId format", async () => {
|
|
125
|
+
const deps = {
|
|
126
|
+
chargeStore: {
|
|
127
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
128
|
+
referenceId: "sc:ref",
|
|
129
|
+
tenantId: "t",
|
|
130
|
+
amountUsdCents: 500,
|
|
131
|
+
status: "New",
|
|
132
|
+
creditedAt: null,
|
|
133
|
+
}),
|
|
134
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
135
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
136
|
+
},
|
|
137
|
+
creditLedger: {
|
|
138
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
139
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
140
|
+
},
|
|
141
|
+
onCreditsPurchased: vi.fn().mockResolvedValue([]),
|
|
142
|
+
};
|
|
143
|
+
await settleEvmPayment(deps, mockEvent);
|
|
144
|
+
const creditOpts = deps.creditLedger.credit.mock.calls[0][3];
|
|
145
|
+
expect(creditOpts.referenceId).toBe("evm:base:0xtx123:0");
|
|
146
|
+
expect(creditOpts.fundingSource).toBe("crypto");
|
|
147
|
+
});
|
|
148
|
+
it("calls onCreditsPurchased when provided", async () => {
|
|
149
|
+
const onPurchased = vi.fn().mockResolvedValue(["bot-1", "bot-2"]);
|
|
150
|
+
const deps = {
|
|
151
|
+
chargeStore: {
|
|
152
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
153
|
+
referenceId: "sc:ref",
|
|
154
|
+
tenantId: "t",
|
|
155
|
+
amountUsdCents: 500,
|
|
156
|
+
status: "New",
|
|
157
|
+
creditedAt: null,
|
|
158
|
+
}),
|
|
159
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
160
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
161
|
+
},
|
|
162
|
+
creditLedger: {
|
|
163
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
164
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
165
|
+
},
|
|
166
|
+
onCreditsPurchased: onPurchased,
|
|
167
|
+
};
|
|
168
|
+
const result = await settleEvmPayment(deps, mockEvent);
|
|
169
|
+
expect(onPurchased).toHaveBeenCalledOnce();
|
|
170
|
+
expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
|
|
171
|
+
});
|
|
172
|
+
it("rejects second transfer to already-credited charge (no double-credit)", async () => {
|
|
173
|
+
const secondTxEvent = { ...mockEvent, txHash: "0xsecondtx", logIndex: 0 };
|
|
174
|
+
const deps = {
|
|
175
|
+
chargeStore: {
|
|
176
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
177
|
+
referenceId: "sc:base:usdc:abc",
|
|
178
|
+
tenantId: "tenant-1",
|
|
179
|
+
amountUsdCents: 1000,
|
|
180
|
+
status: "Settled",
|
|
181
|
+
creditedAt: "2026-01-01T00:00:00Z", // already credited by first tx
|
|
182
|
+
}),
|
|
183
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
184
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
185
|
+
},
|
|
186
|
+
creditLedger: {
|
|
187
|
+
hasReferenceId: vi.fn().mockResolvedValue(false), // new txHash, so this returns false
|
|
188
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
const result = await settleEvmPayment(deps, secondTxEvent);
|
|
192
|
+
expect(result.handled).toBe(true);
|
|
193
|
+
expect(result.creditedCents).toBe(0);
|
|
194
|
+
expect(deps.creditLedger.credit).not.toHaveBeenCalled(); // must NOT double-credit
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { EvmWatcher } from "../watcher.js";
|
|
3
|
+
const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
4
|
+
function mockTransferLog(to, amount, blockNumber) {
|
|
5
|
+
return {
|
|
6
|
+
address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
7
|
+
topics: [
|
|
8
|
+
TRANSFER_TOPIC,
|
|
9
|
+
`0x${"00".repeat(12)}${"ab".repeat(20)}`, // from (padded)
|
|
10
|
+
`0x${"00".repeat(12)}${to.slice(2).toLowerCase()}`, // to (padded)
|
|
11
|
+
],
|
|
12
|
+
data: `0x${amount.toString(16).padStart(64, "0")}`,
|
|
13
|
+
blockNumber: `0x${blockNumber.toString(16)}`,
|
|
14
|
+
transactionHash: `0x${"ff".repeat(32)}`,
|
|
15
|
+
logIndex: "0x0",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe("EvmWatcher", () => {
|
|
19
|
+
it("parses Transfer log into EvmPaymentEvent", async () => {
|
|
20
|
+
const events = [];
|
|
21
|
+
const mockRpc = vi
|
|
22
|
+
.fn()
|
|
23
|
+
.mockResolvedValueOnce(`0x${(102).toString(16)}`) // eth_blockNumber: block 102
|
|
24
|
+
.mockResolvedValueOnce([mockTransferLog(`0x${"cc".repeat(20)}`, 10000000n, 99)]); // eth_getLogs
|
|
25
|
+
const watcher = new EvmWatcher({
|
|
26
|
+
chain: "base",
|
|
27
|
+
token: "USDC",
|
|
28
|
+
rpcCall: mockRpc,
|
|
29
|
+
fromBlock: 99,
|
|
30
|
+
onPayment: (evt) => {
|
|
31
|
+
events.push(evt);
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
await watcher.poll();
|
|
35
|
+
expect(events).toHaveLength(1);
|
|
36
|
+
expect(events[0].amountUsdCents).toBe(1000); // 10 USDC = $10 = 1000 cents
|
|
37
|
+
expect(events[0].to).toMatch(/^0x/);
|
|
38
|
+
});
|
|
39
|
+
it("advances cursor after processing", async () => {
|
|
40
|
+
const mockRpc = vi
|
|
41
|
+
.fn()
|
|
42
|
+
.mockResolvedValueOnce(`0x${(200).toString(16)}`) // block 200
|
|
43
|
+
.mockResolvedValueOnce([]); // no logs
|
|
44
|
+
const watcher = new EvmWatcher({
|
|
45
|
+
chain: "base",
|
|
46
|
+
token: "USDC",
|
|
47
|
+
rpcCall: mockRpc,
|
|
48
|
+
fromBlock: 100,
|
|
49
|
+
onPayment: vi.fn(),
|
|
50
|
+
});
|
|
51
|
+
await watcher.poll();
|
|
52
|
+
expect(watcher.cursor).toBeGreaterThan(100);
|
|
53
|
+
});
|
|
54
|
+
it("skips blocks not yet confirmed", async () => {
|
|
55
|
+
const events = [];
|
|
56
|
+
const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`); // current block: 50
|
|
57
|
+
// Base needs 1 confirmation, so confirmed = 50 - 1 = 49
|
|
58
|
+
// cursor starts at 50, so confirmed (49) < cursor (50) → no poll
|
|
59
|
+
const watcher = new EvmWatcher({
|
|
60
|
+
chain: "base",
|
|
61
|
+
token: "USDC",
|
|
62
|
+
rpcCall: mockRpc,
|
|
63
|
+
fromBlock: 50,
|
|
64
|
+
onPayment: (evt) => {
|
|
65
|
+
events.push(evt);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
await watcher.poll();
|
|
69
|
+
expect(events).toHaveLength(0);
|
|
70
|
+
// eth_getLogs should not even be called
|
|
71
|
+
expect(mockRpc).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
it("processes multiple logs in one poll", async () => {
|
|
74
|
+
const events = [];
|
|
75
|
+
const mockRpc = vi
|
|
76
|
+
.fn()
|
|
77
|
+
.mockResolvedValueOnce(`0x${(110).toString(16)}`) // block 110
|
|
78
|
+
.mockResolvedValueOnce([
|
|
79
|
+
mockTransferLog(`0x${"aa".repeat(20)}`, 5000000n, 105), // $5
|
|
80
|
+
mockTransferLog(`0x${"bb".repeat(20)}`, 20000000n, 107), // $20
|
|
81
|
+
]);
|
|
82
|
+
const watcher = new EvmWatcher({
|
|
83
|
+
chain: "base",
|
|
84
|
+
token: "USDC",
|
|
85
|
+
rpcCall: mockRpc,
|
|
86
|
+
fromBlock: 100,
|
|
87
|
+
onPayment: (evt) => {
|
|
88
|
+
events.push(evt);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
await watcher.poll();
|
|
92
|
+
expect(events).toHaveLength(2);
|
|
93
|
+
expect(events[0].amountUsdCents).toBe(500);
|
|
94
|
+
expect(events[1].amountUsdCents).toBe(2000);
|
|
95
|
+
});
|
|
96
|
+
it("does nothing when no new blocks", async () => {
|
|
97
|
+
const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(99).toString(16)}`); // block 99, confirmed = 98
|
|
98
|
+
const watcher = new EvmWatcher({
|
|
99
|
+
chain: "base",
|
|
100
|
+
token: "USDC",
|
|
101
|
+
rpcCall: mockRpc,
|
|
102
|
+
fromBlock: 100,
|
|
103
|
+
onPayment: vi.fn(),
|
|
104
|
+
});
|
|
105
|
+
await watcher.poll();
|
|
106
|
+
expect(watcher.cursor).toBe(100); // unchanged
|
|
107
|
+
expect(mockRpc).toHaveBeenCalledTimes(1); // only eth_blockNumber
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a deposit address from an xpub at a given BIP-44 index.
|
|
3
|
+
* Path: xpub / 0 / index (external chain / address index).
|
|
4
|
+
* Returns a checksummed Ethereum address. No private keys involved.
|
|
5
|
+
*/
|
|
6
|
+
export declare function deriveDepositAddress(xpub: string, index: number): `0x${string}`;
|
|
7
|
+
/** Validate that a string is an xpub (not xprv). */
|
|
8
|
+
export declare function isValidXpub(key: string): boolean;
|