@wopr-network/platform-core 1.57.0 → 1.58.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 (41) hide show
  1. package/dist/billing/crypto/__tests__/address-gen.test.js +118 -0
  2. package/dist/billing/crypto/__tests__/key-server.test.js +3 -0
  3. package/dist/billing/crypto/address-gen.d.ts +24 -0
  4. package/dist/billing/crypto/address-gen.js +137 -0
  5. package/dist/billing/crypto/btc/checkout.js +5 -2
  6. package/dist/billing/crypto/btc/index.d.ts +2 -2
  7. package/dist/billing/crypto/btc/index.js +1 -1
  8. package/dist/billing/crypto/evm/checkout.js +2 -2
  9. package/dist/billing/crypto/evm/eth-checkout.js +2 -2
  10. package/dist/billing/crypto/evm/index.d.ts +2 -1
  11. package/dist/billing/crypto/evm/index.js +1 -1
  12. package/dist/billing/crypto/key-server.js +26 -19
  13. package/dist/billing/crypto/payment-method-store.d.ts +1 -0
  14. package/dist/billing/crypto/payment-method-store.js +3 -0
  15. package/dist/db/schema/crypto.d.ts +17 -0
  16. package/dist/db/schema/crypto.js +1 -0
  17. package/drizzle/migrations/0020_encoding_params_column.sql +7 -0
  18. package/package.json +1 -1
  19. package/src/billing/crypto/__tests__/address-gen.test.ts +145 -0
  20. package/src/billing/crypto/__tests__/key-server.test.ts +3 -0
  21. package/src/billing/crypto/address-gen.ts +146 -0
  22. package/src/billing/crypto/btc/checkout.ts +5 -2
  23. package/src/billing/crypto/btc/index.ts +2 -2
  24. package/src/billing/crypto/evm/checkout.ts +2 -2
  25. package/src/billing/crypto/evm/eth-checkout.ts +2 -2
  26. package/src/billing/crypto/evm/index.ts +2 -1
  27. package/src/billing/crypto/key-server.ts +28 -24
  28. package/src/billing/crypto/payment-method-store.ts +4 -0
  29. package/src/db/schema/crypto.ts +1 -0
  30. package/dist/billing/crypto/btc/__tests__/address-gen.test.js +0 -44
  31. package/dist/billing/crypto/btc/address-gen.d.ts +0 -23
  32. package/dist/billing/crypto/btc/address-gen.js +0 -101
  33. package/dist/billing/crypto/evm/__tests__/address-gen.test.d.ts +0 -1
  34. package/dist/billing/crypto/evm/__tests__/address-gen.test.js +0 -54
  35. package/dist/billing/crypto/evm/address-gen.d.ts +0 -8
  36. package/dist/billing/crypto/evm/address-gen.js +0 -29
  37. package/src/billing/crypto/btc/__tests__/address-gen.test.ts +0 -53
  38. package/src/billing/crypto/btc/address-gen.ts +0 -122
  39. package/src/billing/crypto/evm/__tests__/address-gen.test.ts +0 -63
  40. package/src/billing/crypto/evm/address-gen.ts +0 -29
  41. /package/dist/billing/crypto/{btc/__tests__ → __tests__}/address-gen.test.d.ts +0 -0
@@ -0,0 +1,145 @@
1
+ import { HDKey } from "@scure/bip32";
2
+ import { describe, expect, it } from "vitest";
3
+ import { deriveAddress, deriveTreasury, isValidXpub } from "../address-gen.js";
4
+
5
+ function makeTestXpub(path: string): string {
6
+ const seed = new Uint8Array(32);
7
+ seed[0] = 1;
8
+ const master = HDKey.fromMasterSeed(seed);
9
+ return master.derive(path).publicExtendedKey;
10
+ }
11
+
12
+ const BTC_XPUB = makeTestXpub("m/44'/0'/0'");
13
+ const ETH_XPUB = makeTestXpub("m/44'/60'/0'");
14
+
15
+ describe("deriveAddress — bech32 (BTC)", () => {
16
+ it("derives a valid bc1q address", () => {
17
+ const addr = deriveAddress(BTC_XPUB, 0, "bech32", { hrp: "bc" });
18
+ expect(addr).toMatch(/^bc1q[a-z0-9]+$/);
19
+ });
20
+
21
+ it("derives different addresses for different indices", () => {
22
+ const a = deriveAddress(BTC_XPUB, 0, "bech32", { hrp: "bc" });
23
+ const b = deriveAddress(BTC_XPUB, 1, "bech32", { hrp: "bc" });
24
+ expect(a).not.toBe(b);
25
+ });
26
+
27
+ it("is deterministic", () => {
28
+ const a = deriveAddress(BTC_XPUB, 42, "bech32", { hrp: "bc" });
29
+ const b = deriveAddress(BTC_XPUB, 42, "bech32", { hrp: "bc" });
30
+ expect(a).toBe(b);
31
+ });
32
+
33
+ it("uses tb prefix for testnet", () => {
34
+ const addr = deriveAddress(BTC_XPUB, 0, "bech32", { hrp: "tb" });
35
+ expect(addr).toMatch(/^tb1q[a-z0-9]+$/);
36
+ });
37
+
38
+ it("rejects negative index", () => {
39
+ expect(() => deriveAddress(BTC_XPUB, -1, "bech32", { hrp: "bc" })).toThrow("Invalid");
40
+ });
41
+
42
+ it("throws without hrp param", () => {
43
+ expect(() => deriveAddress(BTC_XPUB, 0, "bech32", {})).toThrow("hrp");
44
+ });
45
+ });
46
+
47
+ describe("deriveAddress — bech32 (LTC)", () => {
48
+ const LTC_XPUB = makeTestXpub("m/44'/2'/0'");
49
+
50
+ it("derives a valid ltc1q address", () => {
51
+ const addr = deriveAddress(LTC_XPUB, 0, "bech32", { hrp: "ltc" });
52
+ expect(addr).toMatch(/^ltc1q[a-z0-9]+$/);
53
+ });
54
+ });
55
+
56
+ describe("deriveAddress — p2pkh (DOGE)", () => {
57
+ const DOGE_XPUB = makeTestXpub("m/44'/3'/0'");
58
+
59
+ it("derives a valid D... address", () => {
60
+ const addr = deriveAddress(DOGE_XPUB, 0, "p2pkh", { version: "0x1e" });
61
+ expect(addr).toMatch(/^D[a-km-zA-HJ-NP-Z1-9]+$/);
62
+ });
63
+
64
+ it("derives different addresses for different indices", () => {
65
+ const a = deriveAddress(DOGE_XPUB, 0, "p2pkh", { version: "0x1e" });
66
+ const b = deriveAddress(DOGE_XPUB, 1, "p2pkh", { version: "0x1e" });
67
+ expect(a).not.toBe(b);
68
+ });
69
+
70
+ it("throws without version param", () => {
71
+ expect(() => deriveAddress(DOGE_XPUB, 0, "p2pkh", {})).toThrow("version");
72
+ });
73
+ });
74
+
75
+ describe("deriveAddress — p2pkh (TRON)", () => {
76
+ const TRON_XPUB = makeTestXpub("m/44'/195'/0'");
77
+
78
+ it("derives a valid T... address", () => {
79
+ const addr = deriveAddress(TRON_XPUB, 0, "p2pkh", { version: "0x41" });
80
+ expect(addr).toMatch(/^T[a-km-zA-HJ-NP-Z1-9]+$/);
81
+ });
82
+
83
+ it("is deterministic", () => {
84
+ const a = deriveAddress(TRON_XPUB, 5, "p2pkh", { version: "0x41" });
85
+ const b = deriveAddress(TRON_XPUB, 5, "p2pkh", { version: "0x41" });
86
+ expect(a).toBe(b);
87
+ });
88
+ });
89
+
90
+ describe("deriveAddress — evm (ETH)", () => {
91
+ it("derives a valid Ethereum address", () => {
92
+ const addr = deriveAddress(ETH_XPUB, 0, "evm");
93
+ expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/);
94
+ });
95
+
96
+ it("derives different addresses for different indices", () => {
97
+ const a = deriveAddress(ETH_XPUB, 0, "evm");
98
+ const b = deriveAddress(ETH_XPUB, 1, "evm");
99
+ expect(a).not.toBe(b);
100
+ });
101
+
102
+ it("is deterministic", () => {
103
+ const a = deriveAddress(ETH_XPUB, 42, "evm");
104
+ const b = deriveAddress(ETH_XPUB, 42, "evm");
105
+ expect(a).toBe(b);
106
+ });
107
+
108
+ it("returns checksummed address", () => {
109
+ const addr = deriveAddress(ETH_XPUB, 0, "evm");
110
+ expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/);
111
+ });
112
+ });
113
+
114
+ describe("deriveAddress — unknown type", () => {
115
+ it("throws for unknown address type", () => {
116
+ expect(() => deriveAddress(BTC_XPUB, 0, "foo")).toThrow("Unknown address type");
117
+ });
118
+ });
119
+
120
+ describe("deriveTreasury", () => {
121
+ it("derives a valid bech32 treasury address", () => {
122
+ const addr = deriveTreasury(BTC_XPUB, "bech32", { hrp: "bc" });
123
+ expect(addr).toMatch(/^bc1q[a-z0-9]+$/);
124
+ });
125
+
126
+ it("differs from deposit address at index 0", () => {
127
+ const deposit = deriveAddress(BTC_XPUB, 0, "bech32", { hrp: "bc" });
128
+ const treasury = deriveTreasury(BTC_XPUB, "bech32", { hrp: "bc" });
129
+ expect(deposit).not.toBe(treasury);
130
+ });
131
+ });
132
+
133
+ describe("isValidXpub", () => {
134
+ it("accepts valid xpub", () => {
135
+ expect(isValidXpub(BTC_XPUB)).toBe(true);
136
+ });
137
+
138
+ it("rejects garbage", () => {
139
+ expect(isValidXpub("not-an-xpub")).toBe(false);
140
+ });
141
+
142
+ it("rejects empty string", () => {
143
+ expect(isValidXpub("")).toBe(false);
144
+ });
145
+ });
@@ -15,6 +15,7 @@ function createMockDb() {
15
15
  nextIndex: 1,
16
16
  decimals: 8,
17
17
  addressType: "bech32",
18
+ encodingParams: '{"hrp":"bc"}',
18
19
  confirmations: 6,
19
20
  };
20
21
 
@@ -195,6 +196,7 @@ describe("key-server routes", () => {
195
196
  nextIndex: 0,
196
197
  decimals: 18,
197
198
  addressType: "evm",
199
+ encodingParams: "{}",
198
200
  confirmations: 1,
199
201
  };
200
202
 
@@ -258,6 +260,7 @@ describe("key-server routes", () => {
258
260
  nextIndex: 0,
259
261
  decimals: 18,
260
262
  addressType: "evm",
263
+ encodingParams: "{}",
261
264
  confirmations: 1,
262
265
  };
263
266
 
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Universal address derivation — one function for all secp256k1 chains.
3
+ *
4
+ * Encoding type and chain-specific params (HRP, version byte) are read from
5
+ * the paymentMethods DB row, not hardcoded per chain. Adding a new chain
6
+ * is a DB INSERT, not a code change.
7
+ *
8
+ * Supported encodings:
9
+ * - bech32: BTC (bc1q...), LTC (ltc1q...) — params: { hrp }
10
+ * - p2pkh: DOGE (D...), TRON (T...) — params: { version }
11
+ * - evm: ETH, ERC-20 (0x...) — params: {}
12
+ */
13
+ import { ripemd160 } from "@noble/hashes/legacy.js";
14
+ import { sha256 } from "@noble/hashes/sha2.js";
15
+ import { bech32 } from "@scure/base";
16
+ import { HDKey } from "@scure/bip32";
17
+ import { publicKeyToAddress } from "viem/accounts";
18
+
19
+ export interface EncodingParams {
20
+ /** Bech32 human-readable prefix (e.g. "bc", "ltc", "tb"). */
21
+ hrp?: string;
22
+ /** Base58Check version byte as hex string (e.g. "0x1e" for DOGE, "0x41" for TRON). */
23
+ version?: string;
24
+ }
25
+
26
+ // ---------- encoding helpers ----------
27
+
28
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
29
+ function base58encode(data: Uint8Array): string {
30
+ let num = 0n;
31
+ for (const byte of data) num = num * 256n + BigInt(byte);
32
+ let encoded = "";
33
+ while (num > 0n) {
34
+ encoded = BASE58_ALPHABET[Number(num % 58n)] + encoded;
35
+ num = num / 58n;
36
+ }
37
+ for (const byte of data) {
38
+ if (byte !== 0) break;
39
+ encoded = `1${encoded}`;
40
+ }
41
+ return encoded;
42
+ }
43
+
44
+ function hash160(pubkey: Uint8Array): Uint8Array {
45
+ return ripemd160(sha256(pubkey));
46
+ }
47
+
48
+ function encodeBech32(pubkey: Uint8Array, hrp: string): string {
49
+ const h = hash160(pubkey);
50
+ const words = bech32.toWords(h);
51
+ return bech32.encode(hrp, [0, ...words]);
52
+ }
53
+
54
+ function encodeP2pkh(pubkey: Uint8Array, versionByte: number): string {
55
+ const h = hash160(pubkey);
56
+ const payload = new Uint8Array(21);
57
+ payload[0] = versionByte;
58
+ payload.set(h, 1);
59
+ const checksum = sha256(sha256(payload));
60
+ const full = new Uint8Array(25);
61
+ full.set(payload);
62
+ full.set(checksum.slice(0, 4), 21);
63
+ return base58encode(full);
64
+ }
65
+
66
+ function encodeEvm(pubkey: Uint8Array): string {
67
+ const hexPubKey = `0x${Array.from(pubkey, (b) => b.toString(16).padStart(2, "0")).join("")}` as `0x${string}`;
68
+ return publicKeyToAddress(hexPubKey);
69
+ }
70
+
71
+ // ---------- public API ----------
72
+
73
+ /**
74
+ * Derive a deposit address from an xpub at a given BIP-44 index.
75
+ * Path: xpub / 0 / index (external chain).
76
+ * No private keys involved.
77
+ *
78
+ * @param xpub - Extended public key
79
+ * @param index - Derivation index (0, 1, 2, ...)
80
+ * @param addressType - Encoding type: "bech32", "p2pkh", "evm"
81
+ * @param params - Chain-specific encoding params from DB (parsed JSON)
82
+ */
83
+ export function deriveAddress(xpub: string, index: number, addressType: string, params: EncodingParams = {}): string {
84
+ if (!Number.isInteger(index) || index < 0) throw new Error(`Invalid derivation index: ${index}`);
85
+
86
+ const master = HDKey.fromExtendedKey(xpub);
87
+ const child = master.deriveChild(0).deriveChild(index);
88
+ if (!child.publicKey) throw new Error("Failed to derive public key");
89
+
90
+ switch (addressType) {
91
+ case "bech32": {
92
+ if (!params.hrp) throw new Error("bech32 encoding requires 'hrp' param");
93
+ return encodeBech32(child.publicKey, params.hrp);
94
+ }
95
+ case "p2pkh": {
96
+ if (!params.version) throw new Error("p2pkh encoding requires 'version' param");
97
+ const versionByte = Number(params.version);
98
+ if (!Number.isInteger(versionByte) || versionByte < 0 || versionByte > 255)
99
+ throw new Error(`Invalid p2pkh version byte: ${params.version}`);
100
+ return encodeP2pkh(child.publicKey, versionByte);
101
+ }
102
+ case "evm":
103
+ return encodeEvm(child.publicKey);
104
+ default:
105
+ throw new Error(`Unknown address type: ${addressType}`);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Derive the treasury address (internal chain, index 0).
111
+ * Used for sweep destinations.
112
+ */
113
+ export function deriveTreasury(xpub: string, addressType: string, params: EncodingParams = {}): string {
114
+ const master = HDKey.fromExtendedKey(xpub);
115
+ const child = master.deriveChild(1).deriveChild(0); // internal chain
116
+ if (!child.publicKey) throw new Error("Failed to derive public key");
117
+
118
+ switch (addressType) {
119
+ case "bech32": {
120
+ if (!params.hrp) throw new Error("bech32 encoding requires 'hrp' param");
121
+ return encodeBech32(child.publicKey, params.hrp);
122
+ }
123
+ case "p2pkh": {
124
+ if (!params.version) throw new Error("p2pkh encoding requires 'version' param");
125
+ const versionByte = Number(params.version);
126
+ if (!Number.isInteger(versionByte) || versionByte < 0 || versionByte > 255)
127
+ throw new Error(`Invalid p2pkh version byte: ${params.version}`);
128
+ return encodeP2pkh(child.publicKey, versionByte);
129
+ }
130
+ case "evm":
131
+ return encodeEvm(child.publicKey);
132
+ default:
133
+ throw new Error(`Unknown address type: ${addressType}`);
134
+ }
135
+ }
136
+
137
+ /** Validate that a string is an xpub (not xprv). */
138
+ export function isValidXpub(key: string): boolean {
139
+ if (!key.startsWith("xpub")) return false;
140
+ try {
141
+ HDKey.fromExtendedKey(key);
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
@@ -1,6 +1,6 @@
1
1
  import { Credit } from "../../../credits/credit.js";
2
+ import { deriveAddress } from "../address-gen.js";
2
3
  import type { ICryptoChargeRepository } from "../charge-store.js";
3
- import { deriveBtcAddress } from "./address-gen.js";
4
4
  import type { BtcCheckoutOpts } from "./types.js";
5
5
 
6
6
  export const MIN_BTC_USD = 10;
@@ -35,7 +35,10 @@ export async function createBtcCheckout(deps: BtcCheckoutDeps, opts: BtcCheckout
35
35
 
36
36
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
37
37
  const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
38
- const depositAddress = deriveBtcAddress(deps.xpub, derivationIndex, network);
38
+ const hrpMap = { mainnet: "bc", testnet: "tb", regtest: "bcrt" } as const;
39
+ const depositAddress = deriveAddress(deps.xpub, derivationIndex, "bech32", {
40
+ hrp: hrpMap[network],
41
+ });
39
42
  const referenceId = `btc:${depositAddress}`;
40
43
 
41
44
  try {
@@ -1,5 +1,5 @@
1
- export type { UtxoChain, UtxoNetwork } from "./address-gen.js";
2
- export { deriveAddress, deriveBtcAddress, deriveBtcTreasury, deriveTreasury } from "./address-gen.js";
1
+ export type { EncodingParams } from "../address-gen.js";
2
+ export { deriveAddress, deriveTreasury, isValidXpub } from "../address-gen.js";
3
3
  export type { BtcCheckoutDeps, BtcCheckoutResult } from "./checkout.js";
4
4
  export { createBtcCheckout, MIN_BTC_USD } from "./checkout.js";
5
5
  export { centsToSats, loadBitcoindConfig, satsToCents } from "./config.js";
@@ -1,6 +1,6 @@
1
1
  import { Credit } from "../../../credits/credit.js";
2
+ import { deriveAddress } from "../address-gen.js";
2
3
  import type { ICryptoChargeRepository } from "../charge-store.js";
3
- import { deriveDepositAddress } from "./address-gen.js";
4
4
  import { getTokenConfig, tokenAmountFromCents } from "./config.js";
5
5
  import type { StablecoinCheckoutOpts } from "./types.js";
6
6
 
@@ -47,7 +47,7 @@ export async function createStablecoinCheckout(
47
47
  const maxRetries = 3;
48
48
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
49
49
  const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
50
- const depositAddress = deriveDepositAddress(deps.xpub, derivationIndex);
50
+ const depositAddress = deriveAddress(deps.xpub, derivationIndex, "evm");
51
51
  const referenceId = `sc:${opts.chain}:${opts.token.toLowerCase()}:${depositAddress.toLowerCase()}`;
52
52
 
53
53
  try {
@@ -1,8 +1,8 @@
1
1
  import { Credit } from "../../../credits/credit.js";
2
+ import { deriveAddress } from "../address-gen.js";
2
3
  import type { ICryptoChargeRepository } from "../charge-store.js";
3
4
  import { centsToNative } from "../oracle/convert.js";
4
5
  import type { IPriceOracle } from "../oracle/types.js";
5
- import { deriveDepositAddress } from "./address-gen.js";
6
6
  import type { EvmChain } from "./types.js";
7
7
 
8
8
  export const MIN_ETH_USD = 10;
@@ -51,7 +51,7 @@ export async function createEthCheckout(deps: EthCheckoutDeps, opts: EthCheckout
51
51
 
52
52
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
53
53
  const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
54
- const depositAddress = deriveDepositAddress(deps.xpub, derivationIndex);
54
+ const depositAddress = deriveAddress(deps.xpub, derivationIndex, "evm") as `0x${string}`;
55
55
  const referenceId = `eth:${opts.chain}:${depositAddress}`;
56
56
 
57
57
  try {
@@ -1,4 +1,5 @@
1
- export { deriveDepositAddress, isValidXpub } from "./address-gen.js";
1
+ export type { EncodingParams } from "../address-gen.js";
2
+ export { deriveAddress, isValidXpub } from "../address-gen.js";
2
3
  export type { StablecoinCheckoutDeps, StablecoinCheckoutResult } from "./checkout.js";
3
4
  export { createStablecoinCheckout, MIN_STABLECOIN_USD } from "./checkout.js";
4
5
  export { centsFromTokenAmount, getChainConfig, getTokenConfig, tokenAmountFromCents } from "./config.js";
@@ -11,9 +11,9 @@ import { eq, sql } from "drizzle-orm";
11
11
  import { Hono } from "hono";
12
12
  import type { DrizzleDb } from "../../db/index.js";
13
13
  import { derivedAddresses, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
14
- import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
14
+ import type { EncodingParams } from "./address-gen.js";
15
+ import { deriveAddress } from "./address-gen.js";
15
16
  import type { ICryptoChargeRepository } from "./charge-store.js";
16
- import { deriveDepositAddress } from "./evm/address-gen.js";
17
17
  import { centsToNative } from "./oracle/convert.js";
18
18
  import type { IPriceOracle } from "./oracle/types.js";
19
19
  import { AssetNotSupportedError } from "./oracle/types.js";
@@ -60,26 +60,15 @@ async function deriveNextAddress(
60
60
 
61
61
  const index = method.nextIndex - 1;
62
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":
78
- address = deriveDepositAddress(method.xpub, index);
79
- break;
80
- default:
81
- throw new Error(`Unknown address type: ${method.addressType}`);
63
+ // Universal address derivation encoding type + params are DB-driven.
64
+ // Adding a new chain is a DB INSERT, not a code change.
65
+ let encodingParams: EncodingParams = {};
66
+ try {
67
+ encodingParams = JSON.parse(method.encodingParams ?? "{}");
68
+ } catch {
69
+ throw new Error(`Invalid encoding_params JSON for chain ${chainId}: ${method.encodingParams}`);
82
70
  }
71
+ const address = deriveAddress(method.xpub, index, method.addressType, encodingParams);
83
72
 
84
73
  // Step 2: Record in immutable log. If this address was already derived by a
85
74
  // sibling chain (shared xpub), the unique constraint fires and we retry
@@ -218,9 +207,12 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
218
207
  expectedAmount: expectedAmount.toString(),
219
208
  });
220
209
 
221
- // Format display amount for the client
222
- const divisor = 10 ** method.decimals;
223
- const displayAmount = `${(Number(expectedAmount) / divisor).toFixed(Math.min(method.decimals, 8))} ${token}`;
210
+ // Format display amount for the client (BigInt-safe, no Number overflow)
211
+ const divisor = 10n ** BigInt(method.decimals);
212
+ const whole = expectedAmount / divisor;
213
+ const frac = expectedAmount % divisor;
214
+ const fracStr = frac.toString().padStart(method.decimals, "0").slice(0, 8).replace(/0+$/, "");
215
+ const displayAmount = `${whole}${fracStr ? `.${fracStr}` : ""} ${token}`;
224
216
 
225
217
  return c.json(
226
218
  {
@@ -329,6 +321,7 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
329
321
  display_name?: string;
330
322
  oracle_address?: string;
331
323
  address_type?: string;
324
+ encoding_params?: Record<string, string>;
332
325
  icon_url?: string;
333
326
  display_order?: number;
334
327
  }>();
@@ -355,6 +348,16 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
355
348
  );
356
349
  }
357
350
 
351
+ // Validate encoding_params match address_type requirements
352
+ const addrType = body.address_type ?? "evm";
353
+ const encParams = body.encoding_params ?? {};
354
+ if (addrType === "bech32" && !encParams.hrp) {
355
+ return c.json({ error: "bech32 address_type requires encoding_params.hrp" }, 400);
356
+ }
357
+ if (addrType === "p2pkh" && !encParams.version) {
358
+ return c.json({ error: "p2pkh address_type requires encoding_params.version" }, 400);
359
+ }
360
+
358
361
  // Upsert the payment method
359
362
  await deps.methodStore.upsert({
360
363
  id: body.id,
@@ -371,6 +374,7 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
371
374
  oracleAddress: body.oracle_address ?? null,
372
375
  xpub: body.xpub,
373
376
  addressType: body.address_type ?? "evm",
377
+ encodingParams: JSON.stringify(body.encoding_params ?? {}),
374
378
  confirmations: body.confirmations ?? 6,
375
379
  });
376
380
 
@@ -17,6 +17,7 @@ export interface PaymentMethodRecord {
17
17
  oracleAddress: string | null;
18
18
  xpub: string | null;
19
19
  addressType: string;
20
+ encodingParams: string;
20
21
  confirmations: number;
21
22
  }
22
23
 
@@ -89,6 +90,7 @@ export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
89
90
  oracleAddress: method.oracleAddress,
90
91
  xpub: method.xpub,
91
92
  addressType: method.addressType,
93
+ encodingParams: method.encodingParams,
92
94
  confirmations: method.confirmations,
93
95
  })
94
96
  .onConflictDoUpdate({
@@ -107,6 +109,7 @@ export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
107
109
  oracleAddress: method.oracleAddress,
108
110
  xpub: method.xpub,
109
111
  addressType: method.addressType,
112
+ encodingParams: method.encodingParams,
110
113
  confirmations: method.confirmations,
111
114
  },
112
115
  });
@@ -148,6 +151,7 @@ function toRecord(row: typeof paymentMethods.$inferSelect): PaymentMethodRecord
148
151
  oracleAddress: row.oracleAddress,
149
152
  xpub: row.xpub,
150
153
  addressType: row.addressType,
154
+ encodingParams: row.encodingParams,
151
155
  confirmations: row.confirmations,
152
156
  };
153
157
  }
@@ -83,6 +83,7 @@ export const paymentMethods = pgTable("payment_methods", {
83
83
  oracleAddress: text("oracle_address"), // Chainlink feed address for price (null = 1:1 stablecoin)
84
84
  xpub: text("xpub"), // HD wallet extended public key for deposit address derivation
85
85
  addressType: text("address_type").notNull().default("evm"), // "bech32" (BTC/LTC), "p2pkh" (DOGE), "evm" (ETH/ERC20)
86
+ encodingParams: text("encoding_params").notNull().default("{}"), // JSON: {"hrp":"bc"}, {"version":"0x1e"}, etc.
86
87
  confirmations: integer("confirmations").notNull().default(1),
87
88
  nextIndex: integer("next_index").notNull().default(0), // atomic derivation counter, never reuses
88
89
  createdAt: text("created_at").notNull().default(sql`(now())`),
@@ -1,44 +0,0 @@
1
- import { HDKey } from "@scure/bip32";
2
- import { describe, expect, it } from "vitest";
3
- import { deriveBtcAddress, deriveBtcTreasury } from "../address-gen.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'/0'/0'").publicExtendedKey;
9
- }
10
- const TEST_XPUB = makeTestXpub();
11
- describe("deriveBtcAddress", () => {
12
- it("derives a valid bech32 address", () => {
13
- const addr = deriveBtcAddress(TEST_XPUB, 0);
14
- expect(addr).toMatch(/^bc1q[a-z0-9]+$/);
15
- });
16
- it("derives different addresses for different indices", () => {
17
- const a = deriveBtcAddress(TEST_XPUB, 0);
18
- const b = deriveBtcAddress(TEST_XPUB, 1);
19
- expect(a).not.toBe(b);
20
- });
21
- it("is deterministic", () => {
22
- const a = deriveBtcAddress(TEST_XPUB, 42);
23
- const b = deriveBtcAddress(TEST_XPUB, 42);
24
- expect(a).toBe(b);
25
- });
26
- it("uses tb prefix for testnet/regtest", () => {
27
- const addr = deriveBtcAddress(TEST_XPUB, 0, "testnet");
28
- expect(addr).toMatch(/^tb1q[a-z0-9]+$/);
29
- });
30
- it("rejects negative index", () => {
31
- expect(() => deriveBtcAddress(TEST_XPUB, -1)).toThrow("Invalid");
32
- });
33
- });
34
- describe("deriveBtcTreasury", () => {
35
- it("derives a valid bech32 address", () => {
36
- const addr = deriveBtcTreasury(TEST_XPUB);
37
- expect(addr).toMatch(/^bc1q[a-z0-9]+$/);
38
- });
39
- it("differs from deposit address at index 0", () => {
40
- const deposit = deriveBtcAddress(TEST_XPUB, 0);
41
- const treasury = deriveBtcTreasury(TEST_XPUB);
42
- expect(deposit).not.toBe(treasury);
43
- });
44
- });
@@ -1,23 +0,0 @@
1
- /** Supported UTXO chains for bech32 address derivation. */
2
- export type UtxoChain = "bitcoin" | "litecoin";
3
- /** Supported network types. */
4
- export type UtxoNetwork = "mainnet" | "testnet" | "regtest";
5
- /**
6
- * Derive a native segwit (bech32) deposit address from an xpub at a given index.
7
- * Works for BTC (bc1q...) and LTC (ltc1q...) — same HASH160 + bech32 encoding.
8
- * Path: xpub / 0 / index (external chain).
9
- * No private keys involved.
10
- */
11
- export declare function deriveAddress(xpub: string, index: number, network?: UtxoNetwork, chain?: UtxoChain): string;
12
- /** Derive the treasury address (internal chain, index 0). */
13
- export declare function deriveTreasury(xpub: string, network?: UtxoNetwork, chain?: UtxoChain): string;
14
- /**
15
- * Derive a P2PKH (Base58Check) address for chains without bech32 (e.g., DOGE → D...).
16
- */
17
- export declare function deriveP2pkhAddress(xpub: string, index: number, chain: string, network?: "mainnet" | "testnet"): string;
18
- /** @deprecated Use `deriveAddress` instead. */
19
- export declare const deriveBtcAddress: typeof deriveAddress;
20
- /** @deprecated Use `deriveTreasury` instead. */
21
- export declare const deriveBtcTreasury: typeof deriveTreasury;
22
- /** Validate that a string is an xpub (not xprv). */
23
- export declare function isValidXpub(key: string): boolean;