@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.
@@ -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
- // Wrap in transaction: if the address insert fails, next_index is not consumed.
23
- return db.transaction(async (tx) => {
24
- // Atomic increment: UPDATE ... SET next_index = next_index + 1 RETURNING *
25
- const [method] = await tx
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
- if (method.type === "native" && method.chain === "dogecoin") {
39
- address = deriveP2pkhAddress(method.xpub, index, "dogecoin");
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
- else if (method.type === "native" && (method.chain === "bitcoin" || method.chain === "litecoin")) {
42
- address = deriveAddress(method.xpub, index, "mainnet", method.chain);
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
- else {
45
- // EVM (all ERC20 + native ETH) same derivation
46
- address = deriveDepositAddress(method.xpub, index);
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
- // Record in immutable log (inside same transaction)
49
- await tx.insert(derivedAddresses).values({
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);
@@ -12,6 +12,7 @@ export interface PaymentMethodRecord {
12
12
  rpcUrl: string | null;
13
13
  oracleAddress: string | null;
14
14
  xpub: string | null;
15
+ addressType: string;
15
16
  confirmations: number;
16
17
  }
17
18
  export interface IPaymentMethodStore {
@@ -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";
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.49.2",
3
+ "version": "1.49.4",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
- // Wrap in transaction: if the address insert fails, next_index is not consumed.
43
- return (db as unknown as { transaction: (fn: (tx: DrizzleDb) => Promise<unknown>) => Promise<unknown> }).transaction(
44
- async (tx: DrizzleDb) => {
45
- // Atomic increment: UPDATE ... SET next_index = next_index + 1 RETURNING *
46
- const [method] = await tx
47
- .update(paymentMethods)
48
- .set({ nextIndex: sql`${paymentMethods.nextIndex} + 1` })
49
- .where(eq(paymentMethods.id, chainId))
50
- .returning();
51
-
52
- if (!method) throw new Error(`Chain not found: ${chainId}`);
53
- if (!method.xpub) throw new Error(`No xpub configured for chain: ${chainId}`);
54
-
55
- // The index we use is the value BEFORE increment (returned value - 1)
56
- const index = method.nextIndex - 1;
57
-
58
- // Route to the right derivation function
59
- let address: string;
60
- if (method.type === "native" && method.chain === "dogecoin") {
61
- address = deriveP2pkhAddress(method.xpub, index, "dogecoin");
62
- } else if (method.type === "native" && (method.chain === "bitcoin" || method.chain === "litecoin")) {
63
- address = deriveAddress(method.xpub, index, "mainnet", method.chain as "bitcoin" | "litecoin");
64
- } else {
65
- // EVM (all ERC20 + native ETH) — same derivation
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
- // Record in immutable log (inside same transaction)
70
- await tx.insert(derivedAddresses).values({
71
- chainId,
72
- derivationIndex: index,
73
- address: address.toLowerCase(),
74
- tenantId,
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
- ) as Promise<{ address: string; index: number; chain: string; token: string }>;
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
  }
@@ -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())`),