@wopr-network/platform-core 1.63.1 → 1.64.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/__tests__/address-gen.test.js +191 -90
- package/dist/billing/crypto/__tests__/key-server.test.js +3 -0
- package/dist/billing/crypto/address-gen.js +32 -0
- package/dist/billing/crypto/evm/eth-watcher.js +52 -41
- package/dist/billing/crypto/evm/watcher.js +5 -11
- package/dist/billing/crypto/key-server-entry.js +8 -1
- package/dist/billing/crypto/key-server.js +19 -14
- package/dist/billing/crypto/oracle/coingecko.js +3 -0
- package/dist/billing/crypto/payment-method-store.d.ts +2 -0
- package/dist/billing/crypto/payment-method-store.js +5 -0
- package/dist/billing/crypto/tron/address-convert.js +15 -5
- package/dist/billing/crypto/watcher-service.js +9 -9
- package/dist/db/schema/crypto.d.ts +34 -0
- package/dist/db/schema/crypto.js +3 -1
- package/docs/superpowers/plans/2026-03-24-crypto-plugin-phase1.md +697 -0
- package/docs/superpowers/specs/2026-03-24-crypto-plugin-architecture-design.md +309 -0
- package/drizzle/migrations/0022_oracle_asset_id_column.sql +23 -0
- package/drizzle/migrations/0022_rpc_headers_column.sql +1 -0
- package/drizzle/migrations/meta/_journal.json +14 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/address-gen.test.ts +207 -88
- package/src/billing/crypto/__tests__/key-server.test.ts +3 -0
- package/src/billing/crypto/address-gen.ts +31 -0
- package/src/billing/crypto/evm/eth-watcher.ts +64 -47
- package/src/billing/crypto/evm/watcher.ts +8 -9
- package/src/billing/crypto/key-server-entry.ts +7 -1
- package/src/billing/crypto/key-server.ts +26 -19
- package/src/billing/crypto/oracle/coingecko.ts +3 -0
- package/src/billing/crypto/payment-method-store.ts +7 -0
- package/src/billing/crypto/tron/address-convert.ts +13 -4
- package/src/billing/crypto/watcher-service.ts +12 -11
- package/src/db/schema/crypto.ts +3 -1
|
@@ -1,113 +1,214 @@
|
|
|
1
1
|
import { HDKey } from "@scure/bip32";
|
|
2
|
+
import * as bip39 from "@scure/bip39";
|
|
3
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
2
4
|
import { describe, expect, it } from "vitest";
|
|
3
5
|
import { deriveAddress, deriveTreasury, isValidXpub } from "../address-gen.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Well-known BIP-39 test mnemonic.
|
|
8
|
+
* DO NOT use in production — this is public and widely known.
|
|
9
|
+
*/
|
|
10
|
+
const TEST_MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
|
11
|
+
const TEST_SEED = bip39.mnemonicToSeedSync(TEST_MNEMONIC);
|
|
12
|
+
/** Derive xpub at a BIP-44 path from the test mnemonic. */
|
|
13
|
+
function xpubAt(path) {
|
|
14
|
+
return HDKey.fromMasterSeed(TEST_SEED).derive(path).publicExtendedKey;
|
|
9
15
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
16
|
+
/** Derive private key at xpub / chainIndex / addressIndex from the test mnemonic. */
|
|
17
|
+
function privKeyAt(path, chainIndex, addressIndex) {
|
|
18
|
+
const child = HDKey.fromMasterSeed(TEST_SEED).derive(path).deriveChild(chainIndex).deriveChild(addressIndex);
|
|
19
|
+
if (!child.privateKey)
|
|
20
|
+
throw new Error("No private key");
|
|
21
|
+
return child.privateKey;
|
|
22
|
+
}
|
|
23
|
+
function privKeyHex(key) {
|
|
24
|
+
return `0x${Array.from(key, (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
25
|
+
}
|
|
26
|
+
// =============================================================
|
|
27
|
+
// DB chain configs — must match production payment_methods rows
|
|
28
|
+
// =============================================================
|
|
29
|
+
const CHAIN_CONFIGS = [
|
|
30
|
+
{ name: "bitcoin", coinType: 0, addressType: "bech32", params: { hrp: "bc" }, addrRegex: /^bc1q[a-z0-9]+$/ },
|
|
31
|
+
{ name: "litecoin", coinType: 2, addressType: "bech32", params: { hrp: "ltc" }, addrRegex: /^ltc1q[a-z0-9]+$/ },
|
|
32
|
+
{
|
|
33
|
+
name: "dogecoin",
|
|
34
|
+
coinType: 3,
|
|
35
|
+
addressType: "p2pkh",
|
|
36
|
+
params: { version: "0x1e" },
|
|
37
|
+
addrRegex: /^D[a-km-zA-HJ-NP-Z1-9]+$/,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "tron",
|
|
41
|
+
coinType: 195,
|
|
42
|
+
addressType: "keccak-b58check",
|
|
43
|
+
params: { version: "0x41" },
|
|
44
|
+
addrRegex: /^T[a-km-zA-HJ-NP-Z1-9]+$/,
|
|
45
|
+
},
|
|
46
|
+
{ name: "ethereum", coinType: 60, addressType: "evm", params: {}, addrRegex: /^0x[0-9a-fA-F]{40}$/ },
|
|
47
|
+
];
|
|
48
|
+
// =============================================================
|
|
49
|
+
// Core property: xpub-derived address == mnemonic-derived address
|
|
50
|
+
// This guarantees sweep scripts can sign for pay server addresses.
|
|
51
|
+
// =============================================================
|
|
52
|
+
describe("address derivation — sweep key parity", () => {
|
|
53
|
+
for (const cfg of CHAIN_CONFIGS) {
|
|
54
|
+
const path = `m/44'/${cfg.coinType}'/0'`;
|
|
55
|
+
const xpub = xpubAt(path);
|
|
56
|
+
describe(cfg.name, () => {
|
|
57
|
+
it("xpub address matches format", () => {
|
|
58
|
+
const addr = deriveAddress(xpub, 0, cfg.addressType, cfg.params);
|
|
59
|
+
expect(addr).toMatch(cfg.addrRegex);
|
|
60
|
+
});
|
|
61
|
+
it("derives different addresses at different indices", () => {
|
|
62
|
+
const a = deriveAddress(xpub, 0, cfg.addressType, cfg.params);
|
|
63
|
+
const b = deriveAddress(xpub, 1, cfg.addressType, cfg.params);
|
|
64
|
+
expect(a).not.toBe(b);
|
|
65
|
+
});
|
|
66
|
+
it("is deterministic", () => {
|
|
67
|
+
const a = deriveAddress(xpub, 7, cfg.addressType, cfg.params);
|
|
68
|
+
const b = deriveAddress(xpub, 7, cfg.addressType, cfg.params);
|
|
69
|
+
expect(a).toBe(b);
|
|
70
|
+
});
|
|
71
|
+
it("treasury differs from deposit index 0", () => {
|
|
72
|
+
const deposit = deriveAddress(xpub, 0, cfg.addressType, cfg.params);
|
|
73
|
+
const treasury = deriveTreasury(xpub, cfg.addressType, cfg.params);
|
|
74
|
+
expect(deposit).not.toBe(treasury);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
37
78
|
});
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
79
|
+
// =============================================================
|
|
80
|
+
// Sweep key parity for EVERY chain config.
|
|
81
|
+
// Proves: mnemonic → private key → public key → address == xpub → address
|
|
82
|
+
// This guarantees sweep scripts can sign for pay server addresses.
|
|
83
|
+
// =============================================================
|
|
84
|
+
describe("sweep key parity — privkey derives same address as xpub", () => {
|
|
85
|
+
for (const cfg of CHAIN_CONFIGS) {
|
|
86
|
+
const path = `m/44'/${cfg.coinType}'/0'`;
|
|
87
|
+
const xpub = xpubAt(path);
|
|
88
|
+
describe(cfg.name, () => {
|
|
89
|
+
it("deposit addresses match at indices 0-4", () => {
|
|
90
|
+
for (let i = 0; i < 5; i++) {
|
|
91
|
+
const fromXpub = deriveAddress(xpub, i, cfg.addressType, cfg.params);
|
|
92
|
+
// Derive from privkey: get the child's public key and re-derive the address
|
|
93
|
+
const child = HDKey.fromMasterSeed(TEST_SEED).derive(path).deriveChild(0).deriveChild(i);
|
|
94
|
+
const fromPriv = deriveAddress(
|
|
95
|
+
// Use the child's public extended key — but HDKey.deriveChild on a full key
|
|
96
|
+
// produces a full key. Extract just the public key and re-derive via xpub at same index.
|
|
97
|
+
// Simpler: verify the public keys match, then the address must match.
|
|
98
|
+
xpub, i, cfg.addressType, cfg.params);
|
|
99
|
+
// This is tautological — we need to verify from the private key side.
|
|
100
|
+
// For EVM we can use viem. For others, verify pubkey identity.
|
|
101
|
+
expect(child.publicKey).toBeDefined();
|
|
102
|
+
// The xpub's child pubkey at index i must equal the full-key's child pubkey at index i
|
|
103
|
+
const xpubChild = HDKey.fromExtendedKey(xpub).deriveChild(0).deriveChild(i);
|
|
104
|
+
const childPk = child.publicKey;
|
|
105
|
+
const xpubPk = xpubChild.publicKey;
|
|
106
|
+
expect(Buffer.from(childPk).toString("hex")).toBe(Buffer.from(xpubPk).toString("hex"));
|
|
107
|
+
// And the address from xpub must match
|
|
108
|
+
expect(fromXpub).toBe(fromPriv);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
it("treasury pubkey matches", () => {
|
|
112
|
+
const fullKey = HDKey.fromMasterSeed(TEST_SEED).derive(path).deriveChild(1).deriveChild(0);
|
|
113
|
+
const xpubKey = HDKey.fromExtendedKey(xpub).deriveChild(1).deriveChild(0);
|
|
114
|
+
const fullPk = fullKey.publicKey;
|
|
115
|
+
const xpubPk = xpubKey.publicKey;
|
|
116
|
+
expect(Buffer.from(fullPk).toString("hex")).toBe(Buffer.from(xpubPk).toString("hex"));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
44
120
|
});
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
121
|
+
// =============================================================
|
|
122
|
+
// EVM: private key → viem account → address must match.
|
|
123
|
+
// Strongest proof: viem can sign transactions from this key
|
|
124
|
+
// and the address matches what the pay server derived.
|
|
125
|
+
// =============================================================
|
|
126
|
+
describe("EVM sweep key parity — viem account matches derived address", () => {
|
|
127
|
+
const EVM_PATH = "m/44'/60'/0'";
|
|
128
|
+
const xpub = xpubAt(EVM_PATH);
|
|
129
|
+
// All EVM chains share coin type 60
|
|
130
|
+
it("deposit privkey account matches at indices 0-9", () => {
|
|
131
|
+
for (let i = 0; i < 10; i++) {
|
|
132
|
+
const derivedAddr = deriveAddress(xpub, i, "evm");
|
|
133
|
+
const privKey = privKeyAt(EVM_PATH, 0, i);
|
|
134
|
+
const account = privateKeyToAccount(privKeyHex(privKey));
|
|
135
|
+
expect(account.address.toLowerCase()).toBe(derivedAddr.toLowerCase());
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
it("treasury privkey account matches", () => {
|
|
139
|
+
const treasuryAddr = deriveTreasury(xpub, "evm");
|
|
140
|
+
const privKey = privKeyAt(EVM_PATH, 1, 0);
|
|
141
|
+
const account = privateKeyToAccount(privKeyHex(privKey));
|
|
142
|
+
expect(account.address.toLowerCase()).toBe(treasuryAddr.toLowerCase());
|
|
58
143
|
});
|
|
59
144
|
});
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
145
|
+
// =============================================================
|
|
146
|
+
// Tron-specific: keccak-b58check produces known test vectors
|
|
147
|
+
// =============================================================
|
|
148
|
+
describe("Tron keccak-b58check — known test vectors", () => {
|
|
149
|
+
const TRON_XPUB = xpubAt("m/44'/195'/0'");
|
|
150
|
+
it("index 0 produces known address", () => {
|
|
151
|
+
const addr = deriveAddress(TRON_XPUB, 0, "keccak-b58check", { version: "0x41" });
|
|
152
|
+
// Verified against TronWeb / TronLink derivation from test mnemonic
|
|
153
|
+
expect(addr).toBe("TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH");
|
|
154
|
+
});
|
|
155
|
+
it("index 1 produces known address", () => {
|
|
156
|
+
const addr = deriveAddress(TRON_XPUB, 1, "keccak-b58check", { version: "0x41" });
|
|
157
|
+
expect(addr).toBe("TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK");
|
|
158
|
+
});
|
|
159
|
+
it("treasury produces known address", () => {
|
|
160
|
+
const addr = deriveTreasury(TRON_XPUB, "keccak-b58check", { version: "0x41" });
|
|
161
|
+
expect(addr).toMatch(/^T[a-km-zA-HJ-NP-Z1-9]{33}$/);
|
|
162
|
+
});
|
|
163
|
+
it("address has correct length (34 chars)", () => {
|
|
164
|
+
for (let i = 0; i < 10; i++) {
|
|
165
|
+
const addr = deriveAddress(TRON_XPUB, i, "keccak-b58check", { version: "0x41" });
|
|
166
|
+
expect(addr.length).toBe(34);
|
|
167
|
+
}
|
|
70
168
|
});
|
|
71
169
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
170
|
+
// =============================================================
|
|
171
|
+
// Bitcoin — known test vectors from BIP-84 test mnemonic
|
|
172
|
+
// =============================================================
|
|
173
|
+
describe("Bitcoin bech32 — known test vectors", () => {
|
|
174
|
+
const BTC_XPUB = xpubAt("m/44'/0'/0'");
|
|
175
|
+
it("index 0 produces valid bc1q address", () => {
|
|
176
|
+
const addr = deriveAddress(BTC_XPUB, 0, "bech32", { hrp: "bc" });
|
|
177
|
+
expect(addr).toMatch(/^bc1q[a-z0-9]{38,42}$/);
|
|
76
178
|
});
|
|
77
|
-
it("
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
expect(a).not.toBe(b);
|
|
179
|
+
it("testnet uses tb prefix", () => {
|
|
180
|
+
const addr = deriveAddress(BTC_XPUB, 0, "bech32", { hrp: "tb" });
|
|
181
|
+
expect(addr).toMatch(/^tb1q[a-z0-9]+$/);
|
|
81
182
|
});
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
183
|
+
});
|
|
184
|
+
// =============================================================
|
|
185
|
+
// Error handling
|
|
186
|
+
// =============================================================
|
|
187
|
+
describe("error handling", () => {
|
|
188
|
+
const ETH_XPUB = xpubAt("m/44'/60'/0'");
|
|
189
|
+
const BTC_XPUB = xpubAt("m/44'/0'/0'");
|
|
190
|
+
it("rejects negative index", () => {
|
|
191
|
+
expect(() => deriveAddress(ETH_XPUB, -1, "evm")).toThrow("Invalid");
|
|
86
192
|
});
|
|
87
|
-
it("
|
|
88
|
-
|
|
89
|
-
expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
|
193
|
+
it("rejects unknown address type", () => {
|
|
194
|
+
expect(() => deriveAddress(ETH_XPUB, 0, "foo")).toThrow("Unknown address type");
|
|
90
195
|
});
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
it("throws for unknown address type", () => {
|
|
94
|
-
expect(() => deriveAddress(BTC_XPUB, 0, "foo")).toThrow("Unknown address type");
|
|
196
|
+
it("bech32 throws without hrp", () => {
|
|
197
|
+
expect(() => deriveAddress(BTC_XPUB, 0, "bech32", {})).toThrow("hrp");
|
|
95
198
|
});
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
it("derives a valid bech32 treasury address", () => {
|
|
99
|
-
const addr = deriveTreasury(BTC_XPUB, "bech32", { hrp: "bc" });
|
|
100
|
-
expect(addr).toMatch(/^bc1q[a-z0-9]+$/);
|
|
199
|
+
it("p2pkh throws without version", () => {
|
|
200
|
+
expect(() => deriveAddress(BTC_XPUB, 0, "p2pkh", {})).toThrow("version");
|
|
101
201
|
});
|
|
102
|
-
it("
|
|
103
|
-
|
|
104
|
-
const treasury = deriveTreasury(BTC_XPUB, "bech32", { hrp: "bc" });
|
|
105
|
-
expect(deposit).not.toBe(treasury);
|
|
202
|
+
it("keccak-b58check throws without version", () => {
|
|
203
|
+
expect(() => deriveAddress(BTC_XPUB, 0, "keccak-b58check", {})).toThrow("version");
|
|
106
204
|
});
|
|
107
205
|
});
|
|
206
|
+
// =============================================================
|
|
207
|
+
// isValidXpub
|
|
208
|
+
// =============================================================
|
|
108
209
|
describe("isValidXpub", () => {
|
|
109
210
|
it("accepts valid xpub", () => {
|
|
110
|
-
expect(isValidXpub(
|
|
211
|
+
expect(isValidXpub(xpubAt("m/44'/0'/0'"))).toBe(true);
|
|
111
212
|
});
|
|
112
213
|
it("rejects garbage", () => {
|
|
113
214
|
expect(isValidXpub("not-an-xpub")).toBe(false);
|
|
@@ -13,6 +13,7 @@ function createMockDb() {
|
|
|
13
13
|
addressType: "bech32",
|
|
14
14
|
encodingParams: '{"hrp":"bc"}',
|
|
15
15
|
watcherType: "utxo",
|
|
16
|
+
oracleAssetId: "bitcoin",
|
|
16
17
|
confirmations: 6,
|
|
17
18
|
};
|
|
18
19
|
const db = {
|
|
@@ -182,6 +183,7 @@ describe("key-server routes", () => {
|
|
|
182
183
|
addressType: "evm",
|
|
183
184
|
encodingParams: "{}",
|
|
184
185
|
watcherType: "evm",
|
|
186
|
+
oracleAssetId: "ethereum",
|
|
185
187
|
confirmations: 1,
|
|
186
188
|
};
|
|
187
189
|
const db = {
|
|
@@ -242,6 +244,7 @@ describe("key-server routes", () => {
|
|
|
242
244
|
addressType: "evm",
|
|
243
245
|
encodingParams: "{}",
|
|
244
246
|
watcherType: "evm",
|
|
247
|
+
oracleAssetId: "ethereum",
|
|
245
248
|
confirmations: 1,
|
|
246
249
|
};
|
|
247
250
|
const db = {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { secp256k1 } from "@noble/curves/secp256k1.js";
|
|
14
14
|
import { ripemd160 } from "@noble/hashes/legacy.js";
|
|
15
15
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
16
|
+
import { keccak_256 } from "@noble/hashes/sha3.js";
|
|
16
17
|
import { bech32 } from "@scure/base";
|
|
17
18
|
import { HDKey } from "@scure/bip32";
|
|
18
19
|
import { publicKeyToAddress } from "viem/accounts";
|
|
@@ -63,6 +64,21 @@ function encodeEvm(pubkey) {
|
|
|
63
64
|
const hexPubKey = `0x${Array.from(uncompressed, (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
64
65
|
return publicKeyToAddress(hexPubKey);
|
|
65
66
|
}
|
|
67
|
+
/** Keccak256-based Base58Check (Tron-style): keccak256(uncompressed[1:]) → last 20 bytes → version + checksum → Base58 */
|
|
68
|
+
function encodeKeccakB58(pubkey, versionByte) {
|
|
69
|
+
const hexKey = Array.from(pubkey, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
70
|
+
const uncompressed = secp256k1.Point.fromHex(hexKey).toBytes(false);
|
|
71
|
+
const hash = keccak_256(uncompressed.slice(1));
|
|
72
|
+
const addressBytes = hash.slice(-20);
|
|
73
|
+
const payload = new Uint8Array(21);
|
|
74
|
+
payload[0] = versionByte;
|
|
75
|
+
payload.set(addressBytes, 1);
|
|
76
|
+
const checksum = sha256(sha256(payload));
|
|
77
|
+
const full = new Uint8Array(25);
|
|
78
|
+
full.set(payload);
|
|
79
|
+
full.set(checksum.slice(0, 4), 21);
|
|
80
|
+
return base58encode(full);
|
|
81
|
+
}
|
|
66
82
|
// ---------- public API ----------
|
|
67
83
|
/**
|
|
68
84
|
* Derive a deposit address from an xpub at a given BIP-44 index.
|
|
@@ -95,6 +111,14 @@ export function deriveAddress(xpub, index, addressType, params = {}) {
|
|
|
95
111
|
throw new Error(`Invalid p2pkh version byte: ${params.version}`);
|
|
96
112
|
return encodeP2pkh(child.publicKey, versionByte);
|
|
97
113
|
}
|
|
114
|
+
case "keccak-b58check": {
|
|
115
|
+
if (!params.version)
|
|
116
|
+
throw new Error("keccak-b58check encoding requires 'version' param");
|
|
117
|
+
const versionByte = Number(params.version);
|
|
118
|
+
if (!Number.isInteger(versionByte) || versionByte < 0 || versionByte > 255)
|
|
119
|
+
throw new Error(`Invalid keccak-b58check version byte: ${params.version}`);
|
|
120
|
+
return encodeKeccakB58(child.publicKey, versionByte);
|
|
121
|
+
}
|
|
98
122
|
case "evm":
|
|
99
123
|
return encodeEvm(child.publicKey);
|
|
100
124
|
default:
|
|
@@ -124,6 +148,14 @@ export function deriveTreasury(xpub, addressType, params = {}) {
|
|
|
124
148
|
throw new Error(`Invalid p2pkh version byte: ${params.version}`);
|
|
125
149
|
return encodeP2pkh(child.publicKey, versionByte);
|
|
126
150
|
}
|
|
151
|
+
case "keccak-b58check": {
|
|
152
|
+
if (!params.version)
|
|
153
|
+
throw new Error("keccak-b58check encoding requires 'version' param");
|
|
154
|
+
const versionByte = Number(params.version);
|
|
155
|
+
if (!Number.isInteger(versionByte) || versionByte < 0 || versionByte > 255)
|
|
156
|
+
throw new Error(`Invalid keccak-b58check version byte: ${params.version}`);
|
|
157
|
+
return encodeKeccakB58(child.publicKey, versionByte);
|
|
158
|
+
}
|
|
127
159
|
case "evm":
|
|
128
160
|
return encodeEvm(child.publicKey);
|
|
129
161
|
default:
|
|
@@ -60,51 +60,62 @@ export class EthWatcher {
|
|
|
60
60
|
if (latest < this._cursor)
|
|
61
61
|
return;
|
|
62
62
|
const { priceMicros } = await this.oracle.getPrice("ETH");
|
|
63
|
-
// Scan up to latest (not just confirmed) to detect pending txs
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (this.cursorStore) {
|
|
80
|
-
const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, tx.hash);
|
|
81
|
-
if (lastConf !== null && confs <= lastConf)
|
|
63
|
+
// Scan up to latest (not just confirmed) to detect pending txs.
|
|
64
|
+
// Fetch blocks in batches to avoid bursting RPC rate limits on fast chains (e.g. Tron 3s blocks).
|
|
65
|
+
const BATCH_SIZE = 5;
|
|
66
|
+
for (let batchStart = this._cursor; batchStart <= latest; batchStart += BATCH_SIZE) {
|
|
67
|
+
const batchEnd = Math.min(batchStart + BATCH_SIZE - 1, latest);
|
|
68
|
+
const blockNums = Array.from({ length: batchEnd - batchStart + 1 }, (_, i) => batchStart + i);
|
|
69
|
+
const blocks = await Promise.all(blockNums.map((bn) => this.rpc("eth_getBlockByNumber", [`0x${bn.toString(16)}`, true]).then((b) => ({ blockNum: bn, block: b, error: null }), (err) => ({ blockNum: bn, block: null, error: err }))));
|
|
70
|
+
// Stop processing at the first failed block so the cursor doesn't advance past it.
|
|
71
|
+
const firstFailIdx = blocks.findIndex((b) => b.error !== null || !b.block);
|
|
72
|
+
const safeBlocks = firstFailIdx === -1 ? blocks : blocks.slice(0, firstFailIdx);
|
|
73
|
+
for (const { blockNum, block } of safeBlocks) {
|
|
74
|
+
if (!block)
|
|
75
|
+
break;
|
|
76
|
+
const confs = latest - blockNum;
|
|
77
|
+
for (const tx of block.transactions) {
|
|
78
|
+
if (!tx.to)
|
|
82
79
|
continue;
|
|
80
|
+
const to = tx.to.toLowerCase();
|
|
81
|
+
if (!this._watchedAddresses.has(to))
|
|
82
|
+
continue;
|
|
83
|
+
const valueWei = BigInt(tx.value);
|
|
84
|
+
if (valueWei === 0n)
|
|
85
|
+
continue;
|
|
86
|
+
// Skip if we already emitted at this confirmation count
|
|
87
|
+
if (this.cursorStore) {
|
|
88
|
+
const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, tx.hash);
|
|
89
|
+
if (lastConf !== null && confs <= lastConf)
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const amountUsdCents = nativeToCents(valueWei, priceMicros, 18);
|
|
93
|
+
const event = {
|
|
94
|
+
chain: this.chain,
|
|
95
|
+
from: tx.from.toLowerCase(),
|
|
96
|
+
to,
|
|
97
|
+
valueWei: valueWei.toString(),
|
|
98
|
+
amountUsdCents,
|
|
99
|
+
txHash: tx.hash,
|
|
100
|
+
blockNumber: blockNum,
|
|
101
|
+
confirmations: confs,
|
|
102
|
+
confirmationsRequired: this.confirmations,
|
|
103
|
+
};
|
|
104
|
+
await this.onPayment(event);
|
|
105
|
+
if (this.cursorStore) {
|
|
106
|
+
await this.cursorStore.saveConfirmationCount(this.watcherId, tx.hash, confs);
|
|
107
|
+
}
|
|
83
108
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
amountUsdCents,
|
|
91
|
-
txHash: tx.hash,
|
|
92
|
-
blockNumber: blockNum,
|
|
93
|
-
confirmations: confs,
|
|
94
|
-
confirmationsRequired: this.confirmations,
|
|
95
|
-
};
|
|
96
|
-
await this.onPayment(event);
|
|
97
|
-
if (this.cursorStore) {
|
|
98
|
-
await this.cursorStore.saveConfirmationCount(this.watcherId, tx.hash, confs);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
// Only advance cursor past fully-confirmed blocks
|
|
102
|
-
if (blockNum <= confirmed) {
|
|
103
|
-
this._cursor = blockNum + 1;
|
|
104
|
-
if (this.cursorStore) {
|
|
105
|
-
await this.cursorStore.save(this.watcherId, this._cursor);
|
|
109
|
+
// Only advance cursor past fully-confirmed blocks
|
|
110
|
+
if (blockNum <= confirmed) {
|
|
111
|
+
this._cursor = blockNum + 1;
|
|
112
|
+
if (this.cursorStore) {
|
|
113
|
+
await this.cursorStore.save(this.watcherId, this._cursor);
|
|
114
|
+
}
|
|
106
115
|
}
|
|
107
116
|
}
|
|
117
|
+
if (firstFailIdx !== -1)
|
|
118
|
+
break;
|
|
108
119
|
}
|
|
109
120
|
}
|
|
110
121
|
}
|
|
@@ -135,25 +135,19 @@ export class EvmWatcher {
|
|
|
135
135
|
/** Create an RPC caller for a given URL (plain JSON-RPC over fetch). */
|
|
136
136
|
export function createRpcCaller(rpcUrl, extraHeaders) {
|
|
137
137
|
let id = 0;
|
|
138
|
-
// Extract apikey query param and pass as TRON-PRO-API-KEY header (TronGrid JSON-RPC ignores query params)
|
|
139
138
|
const headers = { "Content-Type": "application/json", ...extraHeaders };
|
|
140
|
-
try {
|
|
141
|
-
const url = new URL(rpcUrl);
|
|
142
|
-
const apiKey = url.searchParams.get("apikey");
|
|
143
|
-
if (apiKey)
|
|
144
|
-
headers["TRON-PRO-API-KEY"] = apiKey;
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
// Not a valid URL — proceed without extra headers
|
|
148
|
-
}
|
|
149
139
|
return async (method, params) => {
|
|
150
140
|
const res = await fetch(rpcUrl, {
|
|
151
141
|
method: "POST",
|
|
152
142
|
headers,
|
|
153
143
|
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
|
|
154
144
|
});
|
|
155
|
-
if (!res.ok)
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
const body = await res.text().catch(() => "");
|
|
147
|
+
const hasApiKey = "TRON-PRO-API-KEY" in headers;
|
|
148
|
+
console.error(`[rpc] ${method} ${res.status} auth=${hasApiKey} url=${rpcUrl.replace(/apikey=[^&]+/, "apikey=***")} body=${body.slice(0, 200)}`);
|
|
156
149
|
throw new Error(`RPC ${method} failed: ${res.status}`);
|
|
150
|
+
}
|
|
157
151
|
const data = (await res.json());
|
|
158
152
|
if (data.error)
|
|
159
153
|
throw new Error(`RPC ${method} error: ${data.error.message}`);
|
|
@@ -48,7 +48,14 @@ async function main() {
|
|
|
48
48
|
const chainlink = BASE_RPC_URL
|
|
49
49
|
? new ChainlinkOracle({ rpcCall: createRpcCaller(BASE_RPC_URL) })
|
|
50
50
|
: new FixedPriceOracle();
|
|
51
|
-
|
|
51
|
+
// Build token→CoinGecko ID map from DB (zero-deploy chain additions)
|
|
52
|
+
const allMethods = await methodStore.listAll();
|
|
53
|
+
const dbTokenIds = {};
|
|
54
|
+
for (const m of allMethods) {
|
|
55
|
+
if (m.oracleAssetId)
|
|
56
|
+
dbTokenIds[m.token] = m.oracleAssetId;
|
|
57
|
+
}
|
|
58
|
+
const coingecko = new CoinGeckoOracle({ tokenIds: dbTokenIds });
|
|
52
59
|
const oracle = new CompositeOracle(chainlink, coingecko);
|
|
53
60
|
const app = createKeyServerApp({
|
|
54
61
|
db,
|
|
@@ -257,19 +257,6 @@ export function createKeyServerApp(deps) {
|
|
|
257
257
|
if (!body.id || !body.xpub || !body.token) {
|
|
258
258
|
return c.json({ error: "id, xpub, and token are required" }, 400);
|
|
259
259
|
}
|
|
260
|
-
// Record the path allocation (idempotent — ignore if already exists)
|
|
261
|
-
const inserted = (await deps.db
|
|
262
|
-
.insert(pathAllocations)
|
|
263
|
-
.values({
|
|
264
|
-
coinType: body.coin_type,
|
|
265
|
-
accountIndex: body.account_index,
|
|
266
|
-
chainId: body.id,
|
|
267
|
-
xpub: body.xpub,
|
|
268
|
-
})
|
|
269
|
-
.onConflictDoNothing());
|
|
270
|
-
if (inserted.rowCount === 0) {
|
|
271
|
-
return c.json({ error: "Path allocation already exists", path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 409);
|
|
272
|
-
}
|
|
273
260
|
// Validate encoding_params match address_type requirements
|
|
274
261
|
const addrType = body.address_type ?? "evm";
|
|
275
262
|
const encParams = body.encoding_params ?? {};
|
|
@@ -279,7 +266,7 @@ export function createKeyServerApp(deps) {
|
|
|
279
266
|
if (addrType === "p2pkh" && !encParams.version) {
|
|
280
267
|
return c.json({ error: "p2pkh address_type requires encoding_params.version" }, 400);
|
|
281
268
|
}
|
|
282
|
-
// Upsert
|
|
269
|
+
// Upsert payment method FIRST (path_allocations has FK to payment_methods.id)
|
|
283
270
|
await deps.methodStore.upsert({
|
|
284
271
|
id: body.id,
|
|
285
272
|
type: body.type ?? "native",
|
|
@@ -292,13 +279,31 @@ export function createKeyServerApp(deps) {
|
|
|
292
279
|
displayOrder: body.display_order ?? 0,
|
|
293
280
|
iconUrl: body.icon_url ?? null,
|
|
294
281
|
rpcUrl: body.rpc_url,
|
|
282
|
+
rpcHeaders: JSON.stringify(body.rpc_headers ?? {}),
|
|
295
283
|
oracleAddress: body.oracle_address ?? null,
|
|
296
284
|
xpub: body.xpub,
|
|
297
285
|
addressType: body.address_type ?? "evm",
|
|
298
286
|
encodingParams: JSON.stringify(body.encoding_params ?? {}),
|
|
299
287
|
watcherType: body.watcher_type ?? "evm",
|
|
288
|
+
oracleAssetId: body.oracle_asset_id ?? null,
|
|
300
289
|
confirmations: body.confirmations ?? 6,
|
|
301
290
|
});
|
|
291
|
+
// Record the path allocation (idempotent — ignore if already exists)
|
|
292
|
+
const inserted = (await deps.db
|
|
293
|
+
.insert(pathAllocations)
|
|
294
|
+
.values({
|
|
295
|
+
coinType: body.coin_type,
|
|
296
|
+
accountIndex: body.account_index,
|
|
297
|
+
chainId: body.id,
|
|
298
|
+
xpub: body.xpub,
|
|
299
|
+
})
|
|
300
|
+
.onConflictDoNothing());
|
|
301
|
+
if (inserted.rowCount === 0) {
|
|
302
|
+
return c.json({
|
|
303
|
+
message: "Path allocation already exists, payment method updated",
|
|
304
|
+
path: `m/44'/${body.coin_type}'/${body.account_index}'`,
|
|
305
|
+
}, 200);
|
|
306
|
+
}
|
|
302
307
|
return c.json({ id: body.id, path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 201);
|
|
303
308
|
});
|
|
304
309
|
/** PATCH /admin/chains/:id — update metadata (icon_url, display_order, display_name) */
|
|
@@ -13,6 +13,9 @@ const COINGECKO_IDS = {
|
|
|
13
13
|
UNI: "uniswap",
|
|
14
14
|
AERO: "aerodrome-finance",
|
|
15
15
|
TRX: "tron",
|
|
16
|
+
BNB: "binancecoin",
|
|
17
|
+
POL: "matic-network",
|
|
18
|
+
AVAX: "avalanche-2",
|
|
16
19
|
};
|
|
17
20
|
/** Default cache TTL: 60 seconds. CoinGecko free tier allows 10-30 req/min. */
|
|
18
21
|
const DEFAULT_CACHE_TTL_MS = 60_000;
|
|
@@ -11,11 +11,13 @@ export interface PaymentMethodRecord {
|
|
|
11
11
|
displayOrder: number;
|
|
12
12
|
iconUrl: string | null;
|
|
13
13
|
rpcUrl: string | null;
|
|
14
|
+
rpcHeaders: string;
|
|
14
15
|
oracleAddress: string | null;
|
|
15
16
|
xpub: string | null;
|
|
16
17
|
addressType: string;
|
|
17
18
|
encodingParams: string;
|
|
18
19
|
watcherType: string;
|
|
20
|
+
oracleAssetId: string | null;
|
|
19
21
|
confirmations: number;
|
|
20
22
|
}
|
|
21
23
|
export interface IPaymentMethodStore {
|