@wopr-network/platform-core 1.49.2 → 1.49.4
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/__tests__/key-server.test.js +110 -0
- package/dist/billing/crypto/key-server.js +48 -22
- package/dist/billing/crypto/payment-method-store.d.ts +1 -0
- package/dist/billing/crypto/payment-method-store.js +3 -0
- package/dist/db/schema/crypto.d.ts +17 -0
- package/dist/db/schema/crypto.js +1 -0
- package/drizzle/migrations/0018_address_type_column.sql +12 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +120 -0
- package/src/billing/crypto/key-server.ts +64 -34
- package/src/billing/crypto/payment-method-store.ts +4 -0
- package/src/db/schema/crypto.ts +1 -0
|
@@ -10,6 +10,7 @@ function createMockDb() {
|
|
|
10
10
|
xpub: "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqYh2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz",
|
|
11
11
|
nextIndex: 1,
|
|
12
12
|
decimals: 8,
|
|
13
|
+
addressType: "bech32",
|
|
13
14
|
confirmations: 6,
|
|
14
15
|
};
|
|
15
16
|
const db = {
|
|
@@ -161,6 +162,115 @@ describe("key-server routes", () => {
|
|
|
161
162
|
});
|
|
162
163
|
expect(res.status).toBe(400);
|
|
163
164
|
});
|
|
165
|
+
it("POST /address retries on shared-xpub address collision", async () => {
|
|
166
|
+
const collision = Object.assign(new Error("unique_violation"), { code: "23505" });
|
|
167
|
+
let callCount = 0;
|
|
168
|
+
const mockMethod = {
|
|
169
|
+
id: "eth",
|
|
170
|
+
type: "native",
|
|
171
|
+
token: "ETH",
|
|
172
|
+
chain: "base",
|
|
173
|
+
xpub: "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqYh2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz",
|
|
174
|
+
nextIndex: 0,
|
|
175
|
+
decimals: 18,
|
|
176
|
+
addressType: "evm",
|
|
177
|
+
confirmations: 1,
|
|
178
|
+
};
|
|
179
|
+
const db = {
|
|
180
|
+
// Each update call increments nextIndex
|
|
181
|
+
update: vi.fn().mockImplementation(() => ({
|
|
182
|
+
set: vi.fn().mockReturnValue({
|
|
183
|
+
where: vi.fn().mockReturnValue({
|
|
184
|
+
returning: vi.fn().mockImplementation(() => {
|
|
185
|
+
callCount++;
|
|
186
|
+
return Promise.resolve([{ ...mockMethod, nextIndex: callCount }]);
|
|
187
|
+
}),
|
|
188
|
+
}),
|
|
189
|
+
}),
|
|
190
|
+
})),
|
|
191
|
+
insert: vi.fn().mockImplementation(() => ({
|
|
192
|
+
values: vi.fn().mockImplementation(() => {
|
|
193
|
+
// First insert collides, second succeeds
|
|
194
|
+
if (callCount <= 1)
|
|
195
|
+
throw collision;
|
|
196
|
+
return { onConflictDoNothing: vi.fn().mockResolvedValue({ rowCount: 1 }) };
|
|
197
|
+
}),
|
|
198
|
+
})),
|
|
199
|
+
select: vi.fn().mockReturnValue({
|
|
200
|
+
from: vi.fn().mockReturnValue({
|
|
201
|
+
where: vi.fn().mockResolvedValue([]),
|
|
202
|
+
}),
|
|
203
|
+
}),
|
|
204
|
+
transaction: vi.fn().mockImplementation(async (fn) => fn(db)),
|
|
205
|
+
};
|
|
206
|
+
const deps = mockDeps();
|
|
207
|
+
deps.db = db;
|
|
208
|
+
const app = createKeyServerApp(deps);
|
|
209
|
+
const res = await app.request("/address", {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: { "Content-Type": "application/json" },
|
|
212
|
+
body: JSON.stringify({ chain: "eth" }),
|
|
213
|
+
});
|
|
214
|
+
expect(res.status).toBe(201);
|
|
215
|
+
const body = await res.json();
|
|
216
|
+
expect(body.address).toMatch(/^0x/);
|
|
217
|
+
// Should have called update twice (first collision, then success)
|
|
218
|
+
expect(callCount).toBe(2);
|
|
219
|
+
expect(body.index).toBe(1); // skipped index 0
|
|
220
|
+
});
|
|
221
|
+
it("POST /address retries on Drizzle-wrapped collision error (cause.code)", async () => {
|
|
222
|
+
// Drizzle wraps PG errors: err.code is undefined, err.cause.code has "23505"
|
|
223
|
+
const pgError = Object.assign(new Error("unique_violation"), { code: "23505" });
|
|
224
|
+
const drizzleError = Object.assign(new Error("DrizzleQueryError"), { cause: pgError });
|
|
225
|
+
let callCount = 0;
|
|
226
|
+
const mockMethod = {
|
|
227
|
+
id: "eth",
|
|
228
|
+
type: "native",
|
|
229
|
+
token: "ETH",
|
|
230
|
+
chain: "base",
|
|
231
|
+
xpub: "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqYh2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz",
|
|
232
|
+
nextIndex: 0,
|
|
233
|
+
decimals: 18,
|
|
234
|
+
addressType: "evm",
|
|
235
|
+
confirmations: 1,
|
|
236
|
+
};
|
|
237
|
+
const db = {
|
|
238
|
+
update: vi.fn().mockImplementation(() => ({
|
|
239
|
+
set: vi.fn().mockReturnValue({
|
|
240
|
+
where: vi.fn().mockReturnValue({
|
|
241
|
+
returning: vi.fn().mockImplementation(() => {
|
|
242
|
+
callCount++;
|
|
243
|
+
return Promise.resolve([{ ...mockMethod, nextIndex: callCount }]);
|
|
244
|
+
}),
|
|
245
|
+
}),
|
|
246
|
+
}),
|
|
247
|
+
})),
|
|
248
|
+
insert: vi.fn().mockImplementation(() => ({
|
|
249
|
+
values: vi.fn().mockImplementation(() => {
|
|
250
|
+
if (callCount <= 1)
|
|
251
|
+
throw drizzleError;
|
|
252
|
+
return { onConflictDoNothing: vi.fn().mockResolvedValue({ rowCount: 1 }) };
|
|
253
|
+
}),
|
|
254
|
+
})),
|
|
255
|
+
select: vi.fn().mockReturnValue({
|
|
256
|
+
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
|
257
|
+
}),
|
|
258
|
+
transaction: vi.fn().mockImplementation(async (fn) => fn(db)),
|
|
259
|
+
};
|
|
260
|
+
const deps = mockDeps();
|
|
261
|
+
deps.db = db;
|
|
262
|
+
const app = createKeyServerApp(deps);
|
|
263
|
+
const res = await app.request("/address", {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: { "Content-Type": "application/json" },
|
|
266
|
+
body: JSON.stringify({ chain: "eth" }),
|
|
267
|
+
});
|
|
268
|
+
expect(res.status).toBe(201);
|
|
269
|
+
const body = await res.json();
|
|
270
|
+
expect(body.address).toMatch(/^0x/);
|
|
271
|
+
expect(callCount).toBe(2);
|
|
272
|
+
expect(body.index).toBe(1);
|
|
273
|
+
});
|
|
164
274
|
it("POST /charges creates a charge", async () => {
|
|
165
275
|
const app = createKeyServerApp(mockDeps());
|
|
166
276
|
const res = await app.request("/charges", {
|
|
@@ -17,12 +17,18 @@ import { AssetNotSupportedError } from "./oracle/types.js";
|
|
|
17
17
|
/**
|
|
18
18
|
* Derive the next unused address for a chain.
|
|
19
19
|
* Atomically increments next_index and records address in a single transaction.
|
|
20
|
+
*
|
|
21
|
+
* EVM chains share an xpub (coin type 60), so ETH index 0 = USDC index 0 = same
|
|
22
|
+
* address. The unique constraint on derived_addresses.address prevents reuse.
|
|
23
|
+
* On collision, we skip the index and retry (up to maxRetries).
|
|
20
24
|
*/
|
|
21
25
|
async function deriveNextAddress(db, chainId, tenantId) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
const maxRetries = 10;
|
|
27
|
+
const dbWithTx = db;
|
|
28
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
29
|
+
// Step 1: Atomically claim the next index OUTSIDE the transaction.
|
|
30
|
+
// This survives even if the transaction below rolls back on address collision.
|
|
31
|
+
const [method] = await db
|
|
26
32
|
.update(paymentMethods)
|
|
27
33
|
.set({ nextIndex: sql `${paymentMethods.nextIndex} + 1` })
|
|
28
34
|
.where(eq(paymentMethods.id, chainId))
|
|
@@ -31,29 +37,48 @@ async function deriveNextAddress(db, chainId, tenantId) {
|
|
|
31
37
|
throw new Error(`Chain not found: ${chainId}`);
|
|
32
38
|
if (!method.xpub)
|
|
33
39
|
throw new Error(`No xpub configured for chain: ${chainId}`);
|
|
34
|
-
// The index we use is the value BEFORE increment (returned value - 1)
|
|
35
40
|
const index = method.nextIndex - 1;
|
|
36
|
-
// Route to the right derivation function
|
|
41
|
+
// Route to the right derivation function via address_type (DB-driven, no hardcoded chains)
|
|
37
42
|
let address;
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
switch (method.addressType) {
|
|
44
|
+
case "bech32":
|
|
45
|
+
address = deriveAddress(method.xpub, index, (method.network ?? "mainnet"), method.chain);
|
|
46
|
+
break;
|
|
47
|
+
case "p2pkh":
|
|
48
|
+
address = deriveP2pkhAddress(method.xpub, index, method.chain);
|
|
49
|
+
break;
|
|
50
|
+
case "evm":
|
|
51
|
+
address = deriveDepositAddress(method.xpub, index);
|
|
52
|
+
break;
|
|
53
|
+
default:
|
|
54
|
+
throw new Error(`Unknown address type: ${method.addressType}`);
|
|
40
55
|
}
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
// Step 2: Record in immutable log. If this address was already derived by a
|
|
57
|
+
// sibling chain (shared xpub), the unique constraint fires and we retry
|
|
58
|
+
// with the next index (which is already incremented above).
|
|
59
|
+
try {
|
|
60
|
+
await dbWithTx.transaction(async (tx) => {
|
|
61
|
+
// bech32/evm addresses are case-insensitive (lowercase by spec).
|
|
62
|
+
// p2pkh (Base58Check) addresses are case-sensitive — do NOT lowercase.
|
|
63
|
+
const normalizedAddress = method.addressType === "p2pkh" ? address : address.toLowerCase();
|
|
64
|
+
await tx.insert(derivedAddresses).values({
|
|
65
|
+
chainId,
|
|
66
|
+
derivationIndex: index,
|
|
67
|
+
address: normalizedAddress,
|
|
68
|
+
tenantId,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
return { address, index, chain: method.chain, token: method.token };
|
|
43
72
|
}
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
|
|
73
|
+
catch (err) {
|
|
74
|
+
// Drizzle wraps PG errors — check both top-level and cause for the constraint violation code
|
|
75
|
+
const code = err.code ?? err.cause?.code;
|
|
76
|
+
if (code === "23505" && attempt < maxRetries)
|
|
77
|
+
continue; // collision — index already advanced, retry
|
|
78
|
+
throw err;
|
|
47
79
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
chainId,
|
|
51
|
-
derivationIndex: index,
|
|
52
|
-
address: address.toLowerCase(),
|
|
53
|
-
tenantId,
|
|
54
|
-
});
|
|
55
|
-
return { address, index, chain: method.chain, token: method.token };
|
|
56
|
-
});
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Failed to derive unique address for ${chainId} after ${maxRetries} retries`);
|
|
57
82
|
}
|
|
58
83
|
/** Validate Bearer token from Authorization header. */
|
|
59
84
|
function requireAuth(header, expected) {
|
|
@@ -261,6 +286,7 @@ export function createKeyServerApp(deps) {
|
|
|
261
286
|
rpcUrl: body.rpc_url,
|
|
262
287
|
oracleAddress: body.oracle_address ?? null,
|
|
263
288
|
xpub: body.xpub,
|
|
289
|
+
addressType: body.address_type ?? "evm",
|
|
264
290
|
confirmations: body.confirmations ?? 6,
|
|
265
291
|
});
|
|
266
292
|
return c.json({ id: body.id, path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 201);
|
|
@@ -45,6 +45,7 @@ export class DrizzlePaymentMethodStore {
|
|
|
45
45
|
rpcUrl: method.rpcUrl,
|
|
46
46
|
oracleAddress: method.oracleAddress,
|
|
47
47
|
xpub: method.xpub,
|
|
48
|
+
addressType: method.addressType,
|
|
48
49
|
confirmations: method.confirmations,
|
|
49
50
|
})
|
|
50
51
|
.onConflictDoUpdate({
|
|
@@ -61,6 +62,7 @@ export class DrizzlePaymentMethodStore {
|
|
|
61
62
|
rpcUrl: method.rpcUrl,
|
|
62
63
|
oracleAddress: method.oracleAddress,
|
|
63
64
|
xpub: method.xpub,
|
|
65
|
+
addressType: method.addressType,
|
|
64
66
|
confirmations: method.confirmations,
|
|
65
67
|
},
|
|
66
68
|
});
|
|
@@ -83,6 +85,7 @@ function toRecord(row) {
|
|
|
83
85
|
rpcUrl: row.rpcUrl,
|
|
84
86
|
oracleAddress: row.oracleAddress,
|
|
85
87
|
xpub: row.xpub,
|
|
88
|
+
addressType: row.addressType,
|
|
86
89
|
confirmations: row.confirmations,
|
|
87
90
|
};
|
|
88
91
|
}
|
|
@@ -648,6 +648,23 @@ export declare const paymentMethods: import("drizzle-orm/pg-core").PgTableWithCo
|
|
|
648
648
|
identity: undefined;
|
|
649
649
|
generated: undefined;
|
|
650
650
|
}, {}, {}>;
|
|
651
|
+
addressType: import("drizzle-orm/pg-core").PgColumn<{
|
|
652
|
+
name: "address_type";
|
|
653
|
+
tableName: "payment_methods";
|
|
654
|
+
dataType: "string";
|
|
655
|
+
columnType: "PgText";
|
|
656
|
+
data: string;
|
|
657
|
+
driverParam: string;
|
|
658
|
+
notNull: true;
|
|
659
|
+
hasDefault: true;
|
|
660
|
+
isPrimaryKey: false;
|
|
661
|
+
isAutoincrement: false;
|
|
662
|
+
hasRuntimeDefault: false;
|
|
663
|
+
enumValues: [string, ...string[]];
|
|
664
|
+
baseColumn: never;
|
|
665
|
+
identity: undefined;
|
|
666
|
+
generated: undefined;
|
|
667
|
+
}, {}, {}>;
|
|
651
668
|
confirmations: import("drizzle-orm/pg-core").PgColumn<{
|
|
652
669
|
name: "confirmations";
|
|
653
670
|
tableName: "payment_methods";
|
package/dist/db/schema/crypto.js
CHANGED
|
@@ -74,6 +74,7 @@ export const paymentMethods = pgTable("payment_methods", {
|
|
|
74
74
|
rpcUrl: text("rpc_url"), // chain node RPC endpoint
|
|
75
75
|
oracleAddress: text("oracle_address"), // Chainlink feed address for price (null = 1:1 stablecoin)
|
|
76
76
|
xpub: text("xpub"), // HD wallet extended public key for deposit address derivation
|
|
77
|
+
addressType: text("address_type").notNull().default("evm"), // "bech32" (BTC/LTC), "p2pkh" (DOGE), "evm" (ETH/ERC20)
|
|
77
78
|
confirmations: integer("confirmations").notNull().default(1),
|
|
78
79
|
nextIndex: integer("next_index").notNull().default(0), // atomic derivation counter, never reuses
|
|
79
80
|
createdAt: text("created_at").notNull().default(sql `(now())`),
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Add address_type column to payment_methods.
|
|
2
|
+
-- Drives derivation routing: "bech32" (BTC/LTC), "p2pkh" (DOGE), "evm" (ETH/ERC20).
|
|
3
|
+
-- Eliminates hardcoded chain names in deriveNextAddress.
|
|
4
|
+
|
|
5
|
+
ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "address_type" text NOT NULL DEFAULT 'evm';
|
|
6
|
+
--> statement-breakpoint
|
|
7
|
+
|
|
8
|
+
-- Set correct values for existing chains
|
|
9
|
+
UPDATE "payment_methods" SET "address_type" = 'bech32' WHERE "chain" IN ('bitcoin', 'litecoin');
|
|
10
|
+
--> statement-breakpoint
|
|
11
|
+
|
|
12
|
+
UPDATE "payment_methods" SET "address_type" = 'p2pkh' WHERE "chain" = 'dogecoin';
|
|
@@ -127,6 +127,13 @@
|
|
|
127
127
|
"when": 1743091200000,
|
|
128
128
|
"tag": "0017_fix_derivation_index_constraint",
|
|
129
129
|
"breakpoints": true
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"idx": 18,
|
|
133
|
+
"version": "7",
|
|
134
|
+
"when": 1743177600000,
|
|
135
|
+
"tag": "0018_address_type_column",
|
|
136
|
+
"breakpoints": true
|
|
130
137
|
}
|
|
131
138
|
]
|
|
132
139
|
}
|
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@ function createMockDb() {
|
|
|
14
14
|
xpub: "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqYh2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz",
|
|
15
15
|
nextIndex: 1,
|
|
16
16
|
decimals: 8,
|
|
17
|
+
addressType: "bech32",
|
|
17
18
|
confirmations: 6,
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -177,6 +178,125 @@ describe("key-server routes", () => {
|
|
|
177
178
|
expect(res.status).toBe(400);
|
|
178
179
|
});
|
|
179
180
|
|
|
181
|
+
it("POST /address retries on shared-xpub address collision", async () => {
|
|
182
|
+
const collision = Object.assign(new Error("unique_violation"), { code: "23505" });
|
|
183
|
+
let callCount = 0;
|
|
184
|
+
|
|
185
|
+
const mockMethod = {
|
|
186
|
+
id: "eth",
|
|
187
|
+
type: "native",
|
|
188
|
+
token: "ETH",
|
|
189
|
+
chain: "base",
|
|
190
|
+
xpub: "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqYh2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz",
|
|
191
|
+
nextIndex: 0,
|
|
192
|
+
decimals: 18,
|
|
193
|
+
addressType: "evm",
|
|
194
|
+
confirmations: 1,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const db = {
|
|
198
|
+
// Each update call increments nextIndex
|
|
199
|
+
update: vi.fn().mockImplementation(() => ({
|
|
200
|
+
set: vi.fn().mockReturnValue({
|
|
201
|
+
where: vi.fn().mockReturnValue({
|
|
202
|
+
returning: vi.fn().mockImplementation(() => {
|
|
203
|
+
callCount++;
|
|
204
|
+
return Promise.resolve([{ ...mockMethod, nextIndex: callCount }]);
|
|
205
|
+
}),
|
|
206
|
+
}),
|
|
207
|
+
}),
|
|
208
|
+
})),
|
|
209
|
+
insert: vi.fn().mockImplementation(() => ({
|
|
210
|
+
values: vi.fn().mockImplementation(() => {
|
|
211
|
+
// First insert collides, second succeeds
|
|
212
|
+
if (callCount <= 1) throw collision;
|
|
213
|
+
return { onConflictDoNothing: vi.fn().mockResolvedValue({ rowCount: 1 }) };
|
|
214
|
+
}),
|
|
215
|
+
})),
|
|
216
|
+
select: vi.fn().mockReturnValue({
|
|
217
|
+
from: vi.fn().mockReturnValue({
|
|
218
|
+
where: vi.fn().mockResolvedValue([]),
|
|
219
|
+
}),
|
|
220
|
+
}),
|
|
221
|
+
transaction: vi.fn().mockImplementation(async (fn: (tx: unknown) => unknown) => fn(db)),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const deps = mockDeps();
|
|
225
|
+
(deps as unknown as { db: unknown }).db = db;
|
|
226
|
+
const app = createKeyServerApp(deps);
|
|
227
|
+
|
|
228
|
+
const res = await app.request("/address", {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "Content-Type": "application/json" },
|
|
231
|
+
body: JSON.stringify({ chain: "eth" }),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(res.status).toBe(201);
|
|
235
|
+
const body = await res.json();
|
|
236
|
+
expect(body.address).toMatch(/^0x/);
|
|
237
|
+
// Should have called update twice (first collision, then success)
|
|
238
|
+
expect(callCount).toBe(2);
|
|
239
|
+
expect(body.index).toBe(1); // skipped index 0
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("POST /address retries on Drizzle-wrapped collision error (cause.code)", async () => {
|
|
243
|
+
// Drizzle wraps PG errors: err.code is undefined, err.cause.code has "23505"
|
|
244
|
+
const pgError = Object.assign(new Error("unique_violation"), { code: "23505" });
|
|
245
|
+
const drizzleError = Object.assign(new Error("DrizzleQueryError"), { cause: pgError });
|
|
246
|
+
let callCount = 0;
|
|
247
|
+
|
|
248
|
+
const mockMethod = {
|
|
249
|
+
id: "eth",
|
|
250
|
+
type: "native",
|
|
251
|
+
token: "ETH",
|
|
252
|
+
chain: "base",
|
|
253
|
+
xpub: "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqYh2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz",
|
|
254
|
+
nextIndex: 0,
|
|
255
|
+
decimals: 18,
|
|
256
|
+
addressType: "evm",
|
|
257
|
+
confirmations: 1,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const db = {
|
|
261
|
+
update: vi.fn().mockImplementation(() => ({
|
|
262
|
+
set: vi.fn().mockReturnValue({
|
|
263
|
+
where: vi.fn().mockReturnValue({
|
|
264
|
+
returning: vi.fn().mockImplementation(() => {
|
|
265
|
+
callCount++;
|
|
266
|
+
return Promise.resolve([{ ...mockMethod, nextIndex: callCount }]);
|
|
267
|
+
}),
|
|
268
|
+
}),
|
|
269
|
+
}),
|
|
270
|
+
})),
|
|
271
|
+
insert: vi.fn().mockImplementation(() => ({
|
|
272
|
+
values: vi.fn().mockImplementation(() => {
|
|
273
|
+
if (callCount <= 1) throw drizzleError;
|
|
274
|
+
return { onConflictDoNothing: vi.fn().mockResolvedValue({ rowCount: 1 }) };
|
|
275
|
+
}),
|
|
276
|
+
})),
|
|
277
|
+
select: vi.fn().mockReturnValue({
|
|
278
|
+
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
|
279
|
+
}),
|
|
280
|
+
transaction: vi.fn().mockImplementation(async (fn: (tx: unknown) => unknown) => fn(db)),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const deps = mockDeps();
|
|
284
|
+
(deps as unknown as { db: unknown }).db = db;
|
|
285
|
+
const app = createKeyServerApp(deps);
|
|
286
|
+
|
|
287
|
+
const res = await app.request("/address", {
|
|
288
|
+
method: "POST",
|
|
289
|
+
headers: { "Content-Type": "application/json" },
|
|
290
|
+
body: JSON.stringify({ chain: "eth" }),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(res.status).toBe(201);
|
|
294
|
+
const body = await res.json();
|
|
295
|
+
expect(body.address).toMatch(/^0x/);
|
|
296
|
+
expect(callCount).toBe(2);
|
|
297
|
+
expect(body.index).toBe(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
180
300
|
it("POST /charges creates a charge", async () => {
|
|
181
301
|
const app = createKeyServerApp(mockDeps());
|
|
182
302
|
const res = await app.request("/charges", {
|
|
@@ -33,50 +33,78 @@ export interface KeyServerDeps {
|
|
|
33
33
|
/**
|
|
34
34
|
* Derive the next unused address for a chain.
|
|
35
35
|
* Atomically increments next_index and records address in a single transaction.
|
|
36
|
+
*
|
|
37
|
+
* EVM chains share an xpub (coin type 60), so ETH index 0 = USDC index 0 = same
|
|
38
|
+
* address. The unique constraint on derived_addresses.address prevents reuse.
|
|
39
|
+
* On collision, we skip the index and retry (up to maxRetries).
|
|
36
40
|
*/
|
|
37
41
|
async function deriveNextAddress(
|
|
38
42
|
db: DrizzleDb,
|
|
39
43
|
chainId: string,
|
|
40
44
|
tenantId?: string,
|
|
41
45
|
): Promise<{ address: string; index: number; chain: string; token: string }> {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
address = deriveAddress(
|
|
64
|
-
|
|
65
|
-
|
|
46
|
+
const maxRetries = 10;
|
|
47
|
+
const dbWithTx = db as unknown as { transaction: (fn: (tx: DrizzleDb) => Promise<unknown>) => Promise<unknown> };
|
|
48
|
+
|
|
49
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
50
|
+
// Step 1: Atomically claim the next index OUTSIDE the transaction.
|
|
51
|
+
// This survives even if the transaction below rolls back on address collision.
|
|
52
|
+
const [method] = await db
|
|
53
|
+
.update(paymentMethods)
|
|
54
|
+
.set({ nextIndex: sql`${paymentMethods.nextIndex} + 1` })
|
|
55
|
+
.where(eq(paymentMethods.id, chainId))
|
|
56
|
+
.returning();
|
|
57
|
+
|
|
58
|
+
if (!method) throw new Error(`Chain not found: ${chainId}`);
|
|
59
|
+
if (!method.xpub) throw new Error(`No xpub configured for chain: ${chainId}`);
|
|
60
|
+
|
|
61
|
+
const index = method.nextIndex - 1;
|
|
62
|
+
|
|
63
|
+
// Route to the right derivation function via address_type (DB-driven, no hardcoded chains)
|
|
64
|
+
let address: string;
|
|
65
|
+
switch (method.addressType) {
|
|
66
|
+
case "bech32":
|
|
67
|
+
address = deriveAddress(
|
|
68
|
+
method.xpub,
|
|
69
|
+
index,
|
|
70
|
+
(method.network ?? "mainnet") as "mainnet" | "testnet" | "regtest",
|
|
71
|
+
method.chain as "bitcoin" | "litecoin",
|
|
72
|
+
);
|
|
73
|
+
break;
|
|
74
|
+
case "p2pkh":
|
|
75
|
+
address = deriveP2pkhAddress(method.xpub, index, method.chain);
|
|
76
|
+
break;
|
|
77
|
+
case "evm":
|
|
66
78
|
address = deriveDepositAddress(method.xpub, index);
|
|
67
|
-
|
|
79
|
+
break;
|
|
80
|
+
default:
|
|
81
|
+
throw new Error(`Unknown address type: ${method.addressType}`);
|
|
82
|
+
}
|
|
68
83
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
// Step 2: Record in immutable log. If this address was already derived by a
|
|
85
|
+
// sibling chain (shared xpub), the unique constraint fires and we retry
|
|
86
|
+
// with the next index (which is already incremented above).
|
|
87
|
+
try {
|
|
88
|
+
await dbWithTx.transaction(async (tx: DrizzleDb) => {
|
|
89
|
+
// bech32/evm addresses are case-insensitive (lowercase by spec).
|
|
90
|
+
// p2pkh (Base58Check) addresses are case-sensitive — do NOT lowercase.
|
|
91
|
+
const normalizedAddress = method.addressType === "p2pkh" ? address : address.toLowerCase();
|
|
92
|
+
await tx.insert(derivedAddresses).values({
|
|
93
|
+
chainId,
|
|
94
|
+
derivationIndex: index,
|
|
95
|
+
address: normalizedAddress,
|
|
96
|
+
tenantId,
|
|
97
|
+
});
|
|
75
98
|
});
|
|
76
|
-
|
|
77
99
|
return { address, index, chain: method.chain, token: method.token };
|
|
78
|
-
}
|
|
79
|
-
|
|
100
|
+
} catch (err: unknown) {
|
|
101
|
+
// Drizzle wraps PG errors — check both top-level and cause for the constraint violation code
|
|
102
|
+
const code = (err as { code?: string }).code ?? (err as { cause?: { code?: string } }).cause?.code;
|
|
103
|
+
if (code === "23505" && attempt < maxRetries) continue; // collision — index already advanced, retry
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
throw new Error(`Failed to derive unique address for ${chainId} after ${maxRetries} retries`);
|
|
80
108
|
}
|
|
81
109
|
|
|
82
110
|
/** Validate Bearer token from Authorization header. */
|
|
@@ -299,6 +327,7 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
|
|
|
299
327
|
confirmations?: number;
|
|
300
328
|
display_name?: string;
|
|
301
329
|
oracle_address?: string;
|
|
330
|
+
address_type?: string;
|
|
302
331
|
}>();
|
|
303
332
|
|
|
304
333
|
if (!body.id || !body.xpub || !body.token) {
|
|
@@ -337,6 +366,7 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
|
|
|
337
366
|
rpcUrl: body.rpc_url,
|
|
338
367
|
oracleAddress: body.oracle_address ?? null,
|
|
339
368
|
xpub: body.xpub,
|
|
369
|
+
addressType: body.address_type ?? "evm",
|
|
340
370
|
confirmations: body.confirmations ?? 6,
|
|
341
371
|
});
|
|
342
372
|
|
|
@@ -15,6 +15,7 @@ export interface PaymentMethodRecord {
|
|
|
15
15
|
rpcUrl: string | null;
|
|
16
16
|
oracleAddress: string | null;
|
|
17
17
|
xpub: string | null;
|
|
18
|
+
addressType: string;
|
|
18
19
|
confirmations: number;
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -80,6 +81,7 @@ export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
|
|
|
80
81
|
rpcUrl: method.rpcUrl,
|
|
81
82
|
oracleAddress: method.oracleAddress,
|
|
82
83
|
xpub: method.xpub,
|
|
84
|
+
addressType: method.addressType,
|
|
83
85
|
confirmations: method.confirmations,
|
|
84
86
|
})
|
|
85
87
|
.onConflictDoUpdate({
|
|
@@ -96,6 +98,7 @@ export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
|
|
|
96
98
|
rpcUrl: method.rpcUrl,
|
|
97
99
|
oracleAddress: method.oracleAddress,
|
|
98
100
|
xpub: method.xpub,
|
|
101
|
+
addressType: method.addressType,
|
|
99
102
|
confirmations: method.confirmations,
|
|
100
103
|
},
|
|
101
104
|
});
|
|
@@ -120,6 +123,7 @@ function toRecord(row: typeof paymentMethods.$inferSelect): PaymentMethodRecord
|
|
|
120
123
|
rpcUrl: row.rpcUrl,
|
|
121
124
|
oracleAddress: row.oracleAddress,
|
|
122
125
|
xpub: row.xpub,
|
|
126
|
+
addressType: row.addressType,
|
|
123
127
|
confirmations: row.confirmations,
|
|
124
128
|
};
|
|
125
129
|
}
|
package/src/db/schema/crypto.ts
CHANGED
|
@@ -81,6 +81,7 @@ export const paymentMethods = pgTable("payment_methods", {
|
|
|
81
81
|
rpcUrl: text("rpc_url"), // chain node RPC endpoint
|
|
82
82
|
oracleAddress: text("oracle_address"), // Chainlink feed address for price (null = 1:1 stablecoin)
|
|
83
83
|
xpub: text("xpub"), // HD wallet extended public key for deposit address derivation
|
|
84
|
+
addressType: text("address_type").notNull().default("evm"), // "bech32" (BTC/LTC), "p2pkh" (DOGE), "evm" (ETH/ERC20)
|
|
84
85
|
confirmations: integer("confirmations").notNull().default(1),
|
|
85
86
|
nextIndex: integer("next_index").notNull().default(0), // atomic derivation counter, never reuses
|
|
86
87
|
createdAt: text("created_at").notNull().default(sql`(now())`),
|