@qntx/wallet-engine 0.4.0 → 0.5.2

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/index.mjs CHANGED
@@ -2,11 +2,25 @@ import { createStore } from "zustand/vanilla";
2
2
  import { SparkInputError, bitcoinAddressScript, createSparkClient, decodeBolt11, decodeSparkAddress, encodeSparkAddress, parseSparkInvoice, uuidv7 } from "@qntx/spark";
3
3
  import { nostr } from "@qntx/wallet/nostr";
4
4
  import { spark } from "@qntx/wallet/spark";
5
+ import { createPublicClient, decodeFunctionResult, encodeFunctionData, fallback, http, isAddress, isHex, numberToHex } from "viem";
6
+ import { bitcoinAddressFromXpub, bitcoinNetwork, bitcoinPubkeyFromXpub, decodeBitcoinPsbt } from "@qntx/wallet-chains/bitcoin";
7
+ import { chains } from "@qntx/wallet/chains";
8
+ import { sha256 } from "@noble/hashes/sha2.js";
9
+ import { Address, OutScript, Transaction } from "@scure/btc-signer";
10
+ import { checksumEvmAddress, decodeEvm } from "@qntx/wallet-chains/evm";
11
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
5
12
  import { parseBackupHeader } from "@qntx/wallet";
6
13
  import { signedMatchesUnsigned, verifyEvent } from "@qntx/nostr";
14
+ import { base64, hex } from "@scure/base";
15
+ import { p2wpkh } from "@scure/btc-signer/payment.js";
16
+ import { selectUTXO } from "@scure/btc-signer/utxo.js";
7
17
  import { decodeLnurl, encodeLnurl, lightningAddressUrl, registerLightningAddress, requestInvoice, resolveLnurl, submitWithdraw, unregisterLightningAddress } from "@qntx/spark/lnurl";
8
18
  import { makeZapRequest } from "@qntx/nostr/nips/nip57";
9
- import { base64 } from "@scure/base";
19
+ import { PROGRAM_IDS, decodeSolana, netAfterTransferFee, parseMintExtensions } from "@qntx/wallet-chains/solana";
20
+ import { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction } from "@solana-program/compute-budget";
21
+ import { getTransferSolInstruction } from "@solana-program/system";
22
+ import { TOKEN_PROGRAM_ADDRESS, findAssociatedTokenPda, getCreateAssociatedTokenIdempotentInstruction, getTransferCheckedInstruction } from "@solana-program/token";
23
+ import { address, appendTransactionMessageInstructions, blockhash, compileTransactionMessage, createNoopSigner, createTransactionMessage, getCompiledTransactionMessageEncoder, setTransactionMessageFeePayer, setTransactionMessageLifetimeUsingBlockhash } from "@solana/kit";
10
24
  //#region src/controller.ts
11
25
  /**
12
26
  * Controller base: a zustand vanilla store plus lifecycle. `update` applies a recipe that must
@@ -119,6 +133,185 @@ function createMemoryEngineStore(backing) {
119
133
  };
120
134
  }
121
135
  //#endregion
136
+ //#region src/chains/bitcoin-scan.ts
137
+ /**
138
+ * Gap-20 BIP-44/86 account scanning over Esplora — derives /0/* and /1/* from the account xpub,
139
+ * walks each chain in windows until 20 consecutive unused addresses, persists the discovered
140
+ * addresses/txs/UTXOs in EngineStore (rebuildable from a rescan), and exposes the next unused
141
+ * receive/change address.
142
+ */
143
+ const GAP = 20;
144
+ const isObject$10 = (v) => typeof v === "object" && v !== null;
145
+ const isUnknownArray = (v) => Array.isArray(v);
146
+ const asArray = (v) => isUnknownArray(v) ? v : [];
147
+ /** The scriptPubKey bytes of a bech32/base58 address on `network`. */
148
+ const scriptPubKey = (address, network) => OutScript.encode(Address(network).decode(address));
149
+ /** Esplora scripthash: sha256 of the scriptPubKey, digest hex as-is (verified vs electrs REST). */
150
+ const scripthash = (address, network) => [...sha256(OutScript.encode(Address(network).decode(address)))].map((b) => b.toString(16).padStart(2, "0")).join("");
151
+ const windowUsed = async (es, params, addrs) => Promise.all(addrs.map(async (a) => {
152
+ const txs = await es.get(`/scripthash/${scripthash(a.address, params)}/txs`);
153
+ return isUnknownArray(txs) && txs.length > 0;
154
+ }));
155
+ const scanChain = async (es, xpub, template, network, params, chain, start = 0) => {
156
+ const batch = Array.from({ length: GAP }, (_, i) => {
157
+ const index = start + i;
158
+ return {
159
+ chain,
160
+ index,
161
+ address: bitcoinAddressFromXpub(xpub, template, network, chain, index),
162
+ used: false
163
+ };
164
+ });
165
+ const used = await windowUsed(es, params, batch);
166
+ const marked = batch.map((a, i) => ({
167
+ chain: a.chain,
168
+ index: a.index,
169
+ address: a.address,
170
+ used: used[i] === true
171
+ }));
172
+ if (!marked.some((a) => a.used)) return marked;
173
+ const rest = await scanChain(es, xpub, template, network, params, chain, start + GAP);
174
+ return [...marked, ...rest];
175
+ };
176
+ const scanAddressUtxos = async (es, a, params) => {
177
+ const utxos = await es.get(`/scripthash/${scripthash(a.address, params)}/utxo`);
178
+ return asArray(utxos).flatMap((u) => {
179
+ const o = isObject$10(u) ? u : {};
180
+ const status = isObject$10(o["status"]) ? o["status"] : {};
181
+ const txid = typeof o["txid"] === "string" ? o["txid"] : "";
182
+ if (txid === "") return [];
183
+ return [{
184
+ txid,
185
+ vout: typeof o["vout"] === "number" ? o["vout"] : 0,
186
+ value: typeof o["value"] === "number" ? o["value"] : 0,
187
+ address: a.address,
188
+ height: typeof status["block_height"] === "number" ? status["block_height"] : 0
189
+ }];
190
+ });
191
+ };
192
+ const scanUtxos = async (es, addresses, params) => {
193
+ const candidates = addresses.filter((a) => a.used || a.chain === 1 || a.index < GAP);
194
+ return Promise.all(candidates.map(async (a) => scanAddressUtxos(es, a, params))).then((r) => r.flat());
195
+ };
196
+ const str$1 = (v) => typeof v === "string" ? v : void 0;
197
+ const num = (v) => typeof v === "number" ? v : void 0;
198
+ const toScanUtxo = (v) => {
199
+ if (!isObject$10(v)) return [];
200
+ const txid = str$1(v["txid"]);
201
+ const vout = num(v["vout"]);
202
+ const value = num(v["value"]);
203
+ const address = str$1(v["address"]);
204
+ const height = num(v["height"]);
205
+ return txid !== void 0 && vout !== void 0 && value !== void 0 && address !== void 0 ? [{
206
+ txid,
207
+ vout,
208
+ value,
209
+ address,
210
+ height: height ?? 0
211
+ }] : [];
212
+ };
213
+ const toLockedUtxo = (v) => {
214
+ const [base] = toScanUtxo(v);
215
+ if (base === void 0 || !isObject$10(v)) return;
216
+ const status = str$1(v["status"]);
217
+ const txId = str$1(v["txId"]);
218
+ if (status !== "confirmed" && status !== "locked" && status !== "frozen") return;
219
+ return txId === void 0 ? {
220
+ txid: base.txid,
221
+ vout: base.vout,
222
+ value: base.value,
223
+ address: base.address,
224
+ height: base.height,
225
+ status
226
+ } : {
227
+ txid: base.txid,
228
+ vout: base.vout,
229
+ value: base.value,
230
+ address: base.address,
231
+ height: base.height,
232
+ status,
233
+ txId
234
+ };
235
+ };
236
+ var BtcUtxoStore = class BtcUtxoStore {
237
+ engineStore;
238
+ utxos = /* @__PURE__ */ new Map();
239
+ async unlock(store) {
240
+ this.engineStore = store;
241
+ const raw = await store.collection("btc-utxos").get("state");
242
+ this.utxos = raw === void 0 ? /* @__PURE__ */ new Map() : new Map(Object.entries((() => {
243
+ const rawParsed = JSON.parse(new TextDecoder().decode(raw));
244
+ const parsed = isObject$10(rawParsed) ? rawParsed : {};
245
+ const pairs = Object.entries(parsed).flatMap(([k, v]) => {
246
+ const u = toLockedUtxo(v);
247
+ return u === void 0 ? [] : [[k, u]];
248
+ });
249
+ return Object.fromEntries(pairs);
250
+ })()));
251
+ }
252
+ async persist() {
253
+ const store = this.engineStore;
254
+ if (store !== void 0) await store.collection("btc-utxos").put("state", new TextEncoder().encode(JSON.stringify(Object.fromEntries(this.utxos))));
255
+ }
256
+ static key(u) {
257
+ return `${u.txid}:${u.vout}`;
258
+ }
259
+ replace(utxos) {
260
+ const locked = [...this.utxos.entries()].filter(([, u]) => u.status === "locked" || u.status === "frozen");
261
+ this.utxos = new Map(utxos.map((u) => [BtcUtxoStore.key(u), {
262
+ txid: u.txid,
263
+ vout: u.vout,
264
+ value: u.value,
265
+ address: u.address,
266
+ height: u.height,
267
+ status: "confirmed"
268
+ }]));
269
+ for (const [k, u] of locked) this.utxos.set(k, u);
270
+ }
271
+ lock(utxos, txId) {
272
+ for (const u of utxos) this.utxos.set(BtcUtxoStore.key(u), {
273
+ txid: u.txid,
274
+ vout: u.vout,
275
+ value: u.value,
276
+ address: u.address,
277
+ height: u.height,
278
+ status: "locked",
279
+ txId
280
+ });
281
+ this.persist();
282
+ }
283
+ /** Released on failed/rejected/dropped; spent-removed on confirmed. */
284
+ endTx(txId, final) {
285
+ if (final === "confirmed") {
286
+ for (const [k, u] of this.utxos) if (u.txId === txId) this.utxos.delete(k);
287
+ } else for (const [k, u] of this.utxos) if (u.txId === txId && u.status === "locked") this.utxos.set(k, {
288
+ txid: u.txid,
289
+ vout: u.vout,
290
+ value: u.value,
291
+ address: u.address,
292
+ height: u.height,
293
+ status: "confirmed"
294
+ });
295
+ this.persist();
296
+ }
297
+ spendable() {
298
+ return [...this.utxos.values()].filter((u) => u.status === "confirmed" && !(u.value === 546 || u.value === 330));
299
+ }
300
+ frozen() {
301
+ return [...this.utxos.values()].filter((u) => u.status === "frozen" || u.value === 546 || u.value === 330);
302
+ }
303
+ all() {
304
+ return [...this.utxos.values()];
305
+ }
306
+ lockStore() {
307
+ this.engineStore = void 0;
308
+ }
309
+ };
310
+ //#endregion
311
+ //#region src/transactions/route.ts
312
+ const newId = (random) => `tx-${[...random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
313
+ const errorMessage = (error) => error instanceof Error ? error.message : String(error);
314
+ //#endregion
122
315
  //#region src/accounts/errors.ts
123
316
  var AccountTreeError = class extends Error {
124
317
  category;
@@ -262,6 +455,18 @@ function encodeTree(tree) {
262
455
  * selected again). The active account's `balanceSats` feeds `totals`, debounced 1 s before
263
456
  * persist.
264
457
  */
458
+ const check = async (family, run) => {
459
+ try {
460
+ return [family, { used: await run() }];
461
+ } catch (error) {
462
+ return [family, {
463
+ used: false,
464
+ error: errorMessage(error)
465
+ }];
466
+ }
467
+ };
468
+ const evmAddress = (v) => typeof v === "string" && isAddress(v) ? v : void 0;
469
+ const addressOf = (v) => typeof v === "string" ? v : void 0;
265
470
  var AccountTreeController = class extends Controller {
266
471
  options;
267
472
  col;
@@ -646,6 +851,125 @@ var AccountTreeController = class extends Controller {
646
851
  }
647
852
  return found;
648
853
  }
854
+ /**
855
+ * Per-group chain-family discovery — same stop rule as `discoverSpark`: group 0 always, stop
856
+ * after two consecutive fully-unused groups. A family error is reported on the group and does NOT
857
+ * count as unused (the group stays in `found` — it can't be proven empty).
858
+ */
859
+ async discoverGroups(walletId) {
860
+ this.mustWallet(walletId);
861
+ const apis = this.options.chains ?? chains(this.options.wallet);
862
+ const nets = this.options.networks;
863
+ const found = [0];
864
+ let index = 0;
865
+ let unused = 0;
866
+ while (index < 65) {
867
+ const groupIndex = index;
868
+ const evm = async () => {
869
+ if (nets === void 0) return false;
870
+ return (await Promise.all(nets.enabled("evm").map(async (network) => {
871
+ const acct = await apis.account({
872
+ walletId,
873
+ groupIndex,
874
+ template: "evm.bip44",
875
+ network
876
+ });
877
+ const address = "address" in acct ? evmAddress(acct.address) : void 0;
878
+ if (address === void 0) return false;
879
+ const client = nets.client(network);
880
+ const [nonce, balance] = await Promise.all([client.getTransactionCount({
881
+ address,
882
+ blockTag: "latest"
883
+ }), client.getBalance({ address })]);
884
+ return nonce > 0 || balance > 0n;
885
+ }))).some(Boolean);
886
+ };
887
+ const solana = async () => {
888
+ if (nets === void 0) return false;
889
+ return (await Promise.all(nets.enabled("solana").map(async (network) => {
890
+ const acct = await apis.account({
891
+ walletId,
892
+ groupIndex,
893
+ template: "solana.bip44-change",
894
+ network
895
+ });
896
+ const address = "address" in acct ? addressOf(acct.address) : void 0;
897
+ if (address === void 0) return false;
898
+ const sigs = await nets.solanaRpc(network).call("getSignaturesForAddress", [address, { limit: 1 }]);
899
+ return Array.isArray(sigs) && sigs.length > 0;
900
+ }))).some(Boolean);
901
+ };
902
+ const bitcoin = async () => {
903
+ if (nets === void 0) return false;
904
+ const combos = nets.enabled("bitcoin").flatMap((network) => [
905
+ "btc.bip44",
906
+ "btc.bip49",
907
+ "btc.bip84",
908
+ "btc.bip86"
909
+ ].map((template) => [network, template]));
910
+ return (await Promise.all(combos.map(async ([network, template]) => {
911
+ const net = bitcoinNetwork(network);
912
+ const es = nets.esplora(network);
913
+ const acct = await apis.account({
914
+ walletId,
915
+ groupIndex,
916
+ template,
917
+ network
918
+ });
919
+ const xpub = "xpub" in acct ? acct.xpub : void 0;
920
+ if (xpub === void 0) return false;
921
+ const [receive, change] = await Promise.all([scanChain(es, xpub, template, network, net.params, 0), scanChain(es, xpub, template, network, net.params, 1)]);
922
+ return receive.some((a) => a.used) || change.some((a) => a.used);
923
+ }))).some(Boolean);
924
+ };
925
+ const spark = async () => {
926
+ const ref = {
927
+ walletId,
928
+ groupIndex,
929
+ network: this.options.sparkNetwork
930
+ };
931
+ const { used } = await this.options.spark.inspect(ref);
932
+ return used;
933
+ };
934
+ const results = await Promise.all([
935
+ check("spark", spark),
936
+ check("evm", evm),
937
+ check("solana", solana),
938
+ check("bitcoin", bitcoin)
939
+ ]);
940
+ const usedMap = Object.fromEntries(results.map(([k, v]) => [k, v.used]));
941
+ const errorMap = Object.fromEntries(results.flatMap(([k, v]) => v.error === void 0 ? [] : [[k, v.error]]));
942
+ const anyUsed = results.some(([, v]) => v.used);
943
+ const anyError = results.some(([, v]) => v.error !== void 0);
944
+ this.mutateGroups(walletId, (list) => {
945
+ const have = list.some((g) => g.index === groupIndex);
946
+ const updatedAt = this.options.now();
947
+ if (!have) return [...list, {
948
+ walletId,
949
+ index: groupIndex,
950
+ hidden: false,
951
+ used: usedMap,
952
+ errors: errorMap,
953
+ updatedAt
954
+ }].sort((a, b) => a.index - b.index);
955
+ return list.map((g) => g.index === groupIndex ? {
956
+ ...g,
957
+ used: usedMap,
958
+ errors: errorMap,
959
+ updatedAt
960
+ } : g);
961
+ });
962
+ if (anyUsed || anyError) {
963
+ if (index !== 0 && anyUsed) found.push(index);
964
+ unused = 0;
965
+ } else {
966
+ unused += 1;
967
+ if (index !== 0 && unused >= 2) break;
968
+ }
969
+ index += 1;
970
+ }
971
+ return found;
972
+ }
649
973
  /** Refresh `totals` for every other visible group; per-group failures are logged and skipped. */
650
974
  async refreshTotals() {
651
975
  const state = this.getState();
@@ -815,635 +1139,589 @@ function persistedToState(tree) {
815
1139
  };
816
1140
  }
817
1141
  //#endregion
818
- //#region ../../node_modules/.bun/@noble+hashes@2.4.0/node_modules/@noble/hashes/_u64.js
819
- const fromNumH = (n) => n / 2 ** 32 | 0;
820
- const fromNumL = (n) => n >>> 0;
821
- function setU64FromNum(view, byteOffset, n, isLE) {
822
- const h = fromNumH(n);
823
- const l = fromNumL(n);
824
- view.setUint32(byteOffset, isLE ? l : h, isLE);
825
- view.setUint32(byteOffset + 4, isLE ? h : l, isLE);
826
- }
827
- //#endregion
828
- //#region ../../node_modules/.bun/@noble+hashes@2.4.0/node_modules/@noble/hashes/utils.js
829
- /**
830
- * Checks if something is Uint8Array. Be careful: nodejs Buffer will return true.
831
- * @param a - value to test
832
- * @returns `true` when the value is a Uint8Array-compatible view.
833
- * @example
834
- * Check whether a value is a Uint8Array-compatible view.
835
- * ```ts
836
- * isBytes(new Uint8Array([1, 2, 3]));
837
- * ```
838
- */
839
- function isBytes(a) {
840
- return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array" && "BYTES_PER_ELEMENT" in a && a.BYTES_PER_ELEMENT === 1;
841
- }
842
- const atitle = (title) => title ? `"${title}" ` : "";
843
- /**
844
- * Asserts something is a non-negative integer.
845
- * @param n - number to validate
846
- * @param title - label included in thrown errors
847
- * @returns The validated number.
848
- * @throws On wrong argument types. {@link TypeError}
849
- * @throws On wrong argument ranges or values. {@link RangeError}
850
- * @example
851
- * Validate a non-negative integer option.
852
- * ```ts
853
- * anumber(32, 'length');
854
- * ```
855
- */
856
- function anumber(n, title = "") {
857
- if (typeof n !== "number") throw new TypeError(atitle(title) + "expected number, got " + typeof n);
858
- if (!Number.isSafeInteger(n) || n < 0) throw new RangeError(atitle(title) + "expected integer >= 0, got " + n);
859
- return n;
860
- }
1142
+ //#region src/chains/evm.ts
861
1143
  /**
862
- * Asserts something is Uint8Array.
863
- * @param value - value to validate
864
- * @param length - optional exact length constraint
865
- * @param title - label included in thrown errors
866
- * @returns The validated byte array.
867
- * @throws On wrong argument types. {@link TypeError}
868
- * @throws On wrong argument ranges or values. {@link RangeError}
869
- * @example
870
- * Validate that a value is a byte array.
871
- * ```ts
872
- * abytes(new Uint8Array([1, 2, 3]));
873
- * ```
1144
+ * EVM chain adapter: native transfers through a viem PublicClient injected by NetworkController.
1145
+ * Fee tiers come from eth_feeHistory (20 blocks, p10/p50/p90 → slow/normal/fast, maxFee = 2×baseFee +
1146
+ * priority); networks that cannot answer feeHistory fall back to eth_gasPrice. gas = estimateGas ×
1147
+ * 1.2 (ceil). Nonces are allocated on a per-(network, from) serial queue as max(pending count, last
1148
+ * submitted + 1). Finalization uses the `finalized` block tag; receipts with status 0 fail.
874
1149
  */
875
- function abytes(value, length, title = "") {
876
- if (isBytes(value) && (length === void 0 || value.length === length)) return value;
877
- if (length !== void 0) anumber(length, "length");
878
- const bytes = isBytes(value);
879
- const ofLen = length !== void 0 ? ` of length ${length}` : "";
880
- const got = bytes ? `length=${value.length}` : `type=${typeof value}`;
881
- const message = atitle(title) + "expected Uint8Array" + ofLen + ", got " + got;
882
- if (!bytes) throw new TypeError(message);
883
- throw new RangeError(message);
884
- }
885
- const aobject = (value, label) => {
886
- if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError((label === "object" ? "" : `"${label}" `) + "expected object, got type=" + typeof value);
1150
+ const ERC20_ABI = [
1151
+ {
1152
+ type: "function",
1153
+ name: "transfer",
1154
+ inputs: [{
1155
+ name: "to",
1156
+ type: "address"
1157
+ }, {
1158
+ name: "value",
1159
+ type: "uint256"
1160
+ }],
1161
+ outputs: [{
1162
+ name: "",
1163
+ type: "bool"
1164
+ }],
1165
+ stateMutability: "nonpayable"
1166
+ },
1167
+ {
1168
+ type: "function",
1169
+ name: "balanceOf",
1170
+ inputs: [{
1171
+ name: "account",
1172
+ type: "address"
1173
+ }],
1174
+ outputs: [{
1175
+ name: "",
1176
+ type: "uint256"
1177
+ }],
1178
+ stateMutability: "view"
1179
+ },
1180
+ {
1181
+ type: "function",
1182
+ name: "symbol",
1183
+ inputs: [],
1184
+ outputs: [{
1185
+ name: "",
1186
+ type: "string"
1187
+ }],
1188
+ stateMutability: "view"
1189
+ },
1190
+ {
1191
+ type: "function",
1192
+ name: "decimals",
1193
+ inputs: [],
1194
+ outputs: [{
1195
+ name: "",
1196
+ type: "uint8"
1197
+ }],
1198
+ stateMutability: "view"
1199
+ }
1200
+ ];
1201
+ const checkedAddress = (v) => {
1202
+ if (!isAddress(v)) throw new TypeError(`malformed evm address: ${v}`);
1203
+ return v;
887
1204
  };
888
- const aopts = (value, label) => {
889
- aobject(value, label);
890
- const proto = Object.getPrototypeOf(value);
891
- if (proto !== Object.prototype && proto !== null) throw new TypeError(`"${label}" expected plain object`);
892
- if (Object.hasOwn(value, "__proto__")) throw new TypeError(`"${label}.__proto__" is not allowed`);
893
- };
894
- /**
895
- * Asserts a hash instance has not been destroyed or finished.
896
- * @param instance - hash instance to validate
897
- * @param checkFinished - whether to reject finalized instances
898
- * @throws If the hash instance has already been destroyed or finalized. {@link Error}
899
- * @example
900
- * Validate that a hash instance is still usable.
901
- * ```ts
902
- * import { aexists } from '@noble/hashes/utils.js';
903
- * import { sha256 } from '@noble/hashes/sha2.js';
904
- * const hash = sha256.create();
905
- * aexists(hash);
906
- * ```
907
- */
908
- function aexists(instance, checkFinished = true) {
909
- if (instance.destroyed) throw new Error("hash was destroyed");
910
- if (checkFinished && instance.finished) throw new Error("digest() was already called");
911
- }
912
- /**
913
- * Asserts output is a sufficiently-sized byte array.
914
- * @param out - destination buffer
915
- * @param instance - hash instance providing output length
916
- * Oversized buffers are allowed; downstream code only promises to fill the first `outputLen` bytes.
917
- * @throws On wrong argument types. {@link TypeError}
918
- * @throws On wrong argument ranges or values. {@link RangeError}
919
- * @example
920
- * Validate a caller-provided digest buffer.
921
- * ```ts
922
- * import { aoutput } from '@noble/hashes/utils.js';
923
- * import { sha256 } from '@noble/hashes/sha2.js';
924
- * const hash = sha256.create();
925
- * aoutput(new Uint8Array(hash.outputLen), hash);
926
- * ```
927
- */
928
- function aoutput(out, instance) {
929
- abytes(out, void 0, "output");
930
- const min = instance.outputLen;
931
- if (!(out.length >= min)) throw new RangeError("\"output\" expected length >= " + min);
932
- }
933
- /**
934
- * Zeroizes typed arrays in place. Warning: JS provides no guarantees.
935
- * @param arrays - arrays to overwrite with zeros
936
- * @example
937
- * Zeroize sensitive buffers in place.
938
- * ```ts
939
- * clean(new Uint8Array([1, 2, 3]));
940
- * ```
941
- */
942
- function clean(...arrays) {
943
- for (let i = 0; i < arrays.length; i++) arrays[i].fill(0);
944
- }
945
- /**
946
- * Creates a DataView for byte-level manipulation.
947
- * @param arr - source typed array
948
- * @returns DataView over the same buffer region.
949
- * @example
950
- * Create a DataView over an existing buffer.
951
- * ```ts
952
- * createView(new Uint8Array(4));
953
- * ```
954
- */
955
- function createView(arr) {
956
- return new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
957
- }
958
- /**
959
- * Rotate-right operation for uint32 values.
960
- * @param word - source word
961
- * @param shift - shift amount in bits
962
- * @returns Rotated word.
963
- * @example
964
- * Rotate a 32-bit word to the right.
965
- * ```ts
966
- * rotr(0x12345678, 8);
967
- * ```
968
- */
969
- function rotr(word, shift) {
970
- return word << 32 - shift | word >>> shift;
971
- }
972
- const hasHexBuiltin = /* @__PURE__ */ (() => typeof Uint8Array.from([]).toHex === "function" && typeof Uint8Array.fromHex === "function")();
973
- const hexes = /* @__PURE__ */ Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
974
- /**
975
- * Convert byte array to hex string.
976
- * Uses the built-in function when available and assumes it matches the tested
977
- * fallback semantics.
978
- * @param bytes - bytes to encode
979
- * @returns Lowercase hexadecimal string.
980
- * @throws On wrong argument types. {@link TypeError}
981
- * @example
982
- * Convert bytes to lowercase hexadecimal.
983
- * ```ts
984
- * bytesToHex(Uint8Array.from([0xca, 0xfe, 0x01, 0x23])); // 'cafe0123'
985
- * ```
986
- */
987
- function bytesToHex(bytes) {
988
- abytes(bytes);
989
- if (hasHexBuiltin) return bytes.toHex();
990
- let hex = "";
991
- for (let i = 0; i < bytes.length; i++) hex += hexes[bytes[i]];
992
- return hex;
993
- }
994
- /**
995
- * Converts string to bytes using UTF8 encoding.
996
- * Built-in doesn't validate input to be string: we do the check.
997
- * Non-ASCII details are delegated to the platform `TextEncoder`.
998
- * @param str - string to encode
999
- * @returns UTF-8 encoded bytes.
1000
- * @throws On wrong argument types. {@link TypeError}
1001
- * @example
1002
- * Encode a string as UTF-8 bytes.
1003
- * ```ts
1004
- * utf8ToBytes('abc'); // Uint8Array.from([97, 98, 99])
1005
- * ```
1006
- */
1007
- function utf8ToBytes(str) {
1008
- if (typeof str !== "string") throw new TypeError("string expected");
1009
- const encoded = new TextEncoder().encode(str);
1010
- try {
1011
- return new Uint8Array(encoded);
1012
- } finally {
1013
- clean(encoded);
1205
+ const hex$1 = (v) => numberToHex(v);
1206
+ var EvmChainAdapter = class {
1207
+ family = "evm";
1208
+ nonces = /* @__PURE__ */ new Map();
1209
+ lastNonce = /* @__PURE__ */ new Map();
1210
+ async serial(key, run) {
1211
+ const prior = this.nonces.get(key) ?? Promise.resolve();
1212
+ const next = (async () => {
1213
+ try {
1214
+ await prior;
1215
+ } catch {}
1216
+ return run();
1217
+ })();
1218
+ this.nonces.set(key, next);
1219
+ return next;
1014
1220
  }
1015
- }
1016
- /**
1017
- * Merges default options and passed options.
1018
- * @param defaults - base option object
1019
- * @param opts - user overrides
1020
- * @param title - label included in thrown override errors
1021
- * @returns Fresh merged option object with a null prototype.
1022
- * @throws On wrong argument types. {@link TypeError}
1023
- * @example
1024
- * Merge user overrides onto default options.
1025
- * ```ts
1026
- * checkOpts({ dkLen: 32 }, { asyncTick: 10 });
1027
- * ```
1028
- */
1029
- function checkOpts(defaults, opts, title = "opts") {
1030
- aopts(defaults, "defaults");
1031
- if (opts !== void 0) aopts(opts, title);
1032
- return Object.assign(Object.create(null), defaults, opts);
1033
- }
1034
- /**
1035
- * Creates a callable hash function from a stateful class constructor.
1036
- * @param hashCons - hash constructor or factory
1037
- * @param info - optional metadata such as DER OID
1038
- * @returns Frozen callable hash wrapper with `.create()`.
1039
- * Wrapper construction eagerly calls `hashCons(undefined)` once to read
1040
- * `outputLen` / `blockLen`, so constructor side effects happen at module
1041
- * init time.
1042
- * @throws On wrong argument types. {@link TypeError}
1043
- * @example
1044
- * Wrap a stateful hash constructor into a callable helper.
1045
- * ```ts
1046
- * import { createHasher } from '@noble/hashes/utils.js';
1047
- * import { sha256 } from '@noble/hashes/sha2.js';
1048
- * const wrapped = createHasher(sha256.create, { oid: sha256.oid });
1049
- * wrapped(new Uint8Array([1]));
1050
- * ```
1051
- */
1052
- function createHasher(hashCons, info = {}) {
1053
- if (typeof hashCons !== "function") throw new TypeError("\"hashCons\" expected function, got type=" + typeof hashCons);
1054
- info = checkOpts({}, info, "info");
1055
- const hashC = (msg, opts) => hashCons(opts).update(msg).digest();
1056
- const tmp = hashCons(void 0);
1057
- hashC.outputLen = tmp.outputLen;
1058
- hashC.blockLen = tmp.blockLen;
1059
- hashC.canXOF = tmp.canXOF;
1060
- hashC.create = (opts) => hashCons(opts);
1061
- Object.assign(hashC, info);
1062
- return Object.freeze(hashC);
1063
- }
1064
- /**
1065
- * Creates OID metadata for NIST hashes with prefix `06 09 60 86 48 01 65 03 04 02`.
1066
- * @param suffix - final OID byte for the selected hash.
1067
- * The helper accepts any byte even though only the documented NIST hash
1068
- * suffixes are meaningful downstream.
1069
- * @returns Object containing the DER-encoded OID.
1070
- * @example
1071
- * Build OID metadata for a NIST hash.
1072
- * ```ts
1073
- * oidNist(0x01);
1074
- * ```
1075
- */
1076
- const oidNist = (suffix) => ({ oid: Uint8Array.from([
1077
- 6,
1078
- 9,
1079
- 96,
1080
- 134,
1081
- 72,
1082
- 1,
1083
- 101,
1084
- 3,
1085
- 4,
1086
- 2,
1087
- suffix
1088
- ]) });
1221
+ async rpc(client, method, params) {
1222
+ return client.transport.request({
1223
+ method,
1224
+ params
1225
+ });
1226
+ }
1227
+ async rpcHex(client, method, params) {
1228
+ const result = await this.rpc(client, method, params);
1229
+ if (typeof result !== "string") throw new TypeError(`${method} returned a malformed result`);
1230
+ return result;
1231
+ }
1232
+ /** Allocate the next nonce for (network, from) — serial per key. */
1233
+ async nonce(client, network, from) {
1234
+ const key = `${network}:${from}`;
1235
+ return this.serial(key, async () => {
1236
+ const pending = Number(await this.rpcHex(client, "eth_getTransactionCount", [from, "pending"]));
1237
+ const local = this.lastNonce.get(key);
1238
+ const nonce = Math.max(pending, local === void 0 ? 0 : local + 1);
1239
+ this.lastNonce.set(key, nonce);
1240
+ return nonce;
1241
+ });
1242
+ }
1243
+ /** ERC-20 gas is buffered ×1.1 instead of the native ×1.2 (chains.mdx "EVM"). */
1244
+ async build(client, network, from, intent) {
1245
+ const nonce = await this.nonce(client, network, from);
1246
+ const erc20 = intent.kind === "erc20";
1247
+ const to = checksumEvmAddress(erc20 ? intent.token : intent.to);
1248
+ const value = erc20 ? 0n : intent.value;
1249
+ const data = erc20 ? encodeFunctionData({
1250
+ abi: ERC20_ABI,
1251
+ functionName: "transfer",
1252
+ args: [checkedAddress(intent.to), intent.value]
1253
+ }) : "0x";
1254
+ const estimate = BigInt(await this.rpcHex(client, "eth_estimateGas", [{
1255
+ from,
1256
+ to,
1257
+ value: hex$1(value),
1258
+ data
1259
+ }]));
1260
+ const scale = erc20 ? 11n : 12n;
1261
+ return {
1262
+ family: "evm",
1263
+ network,
1264
+ from,
1265
+ to,
1266
+ value,
1267
+ data,
1268
+ nonce,
1269
+ gas: estimate * scale / 10n + (estimate * scale % 10n === 0n ? 0n : 1n)
1270
+ };
1271
+ }
1272
+ async estimateFee(client) {
1273
+ try {
1274
+ const history = await this.rpc(client, "eth_feeHistory", [
1275
+ "0x14",
1276
+ "latest",
1277
+ [
1278
+ 10,
1279
+ 50,
1280
+ 90
1281
+ ]
1282
+ ]);
1283
+ if (typeof history !== "object" || history === null || !("baseFeePerGas" in history) || !Array.isArray(history.baseFeePerGas) || !("reward" in history) || !Array.isArray(history.reward)) throw new TypeError("eth_feeHistory returned a malformed result");
1284
+ const { baseFeePerGas, reward } = history;
1285
+ if (!Array.isArray(baseFeePerGas) || !baseFeePerGas.every((v) => typeof v === "string") || !Array.isArray(reward) || !reward.every((r) => Array.isArray(r) && r.every((v) => typeof v === "string"))) throw new TypeError("eth_feeHistory returned a malformed result");
1286
+ const baseFee = BigInt(baseFeePerGas.at(-1) ?? "0x0");
1287
+ const tier = (i, name) => {
1288
+ const last = reward.at(-1);
1289
+ const priority = last === void 0 ? 0n : BigInt(last[i] ?? "0x0");
1290
+ const maxFeePerGas = baseFee * 2n + priority;
1291
+ return {
1292
+ tier: name,
1293
+ wei: maxFeePerGas,
1294
+ maxFeePerGas,
1295
+ maxPriorityFeePerGas: priority
1296
+ };
1297
+ };
1298
+ return [
1299
+ tier(0, "slow"),
1300
+ tier(1, "normal"),
1301
+ tier(2, "fast")
1302
+ ];
1303
+ } catch {
1304
+ const price = BigInt(await this.rpcHex(client, "eth_gasPrice", []));
1305
+ return [
1306
+ "slow",
1307
+ "normal",
1308
+ "fast"
1309
+ ].map((name) => ({
1310
+ tier: name,
1311
+ wei: price,
1312
+ maxFeePerGas: price,
1313
+ maxPriorityFeePerGas: void 0
1314
+ }));
1315
+ }
1316
+ }
1317
+ applyFee(tx, fee) {
1318
+ if (fee.maxPriorityFeePerGas === void 0) return {
1319
+ ...tx,
1320
+ gasPrice: fee.maxFeePerGas
1321
+ };
1322
+ return {
1323
+ ...tx,
1324
+ maxFeePerGas: fee.maxFeePerGas,
1325
+ maxPriorityFeePerGas: fee.maxPriorityFeePerGas
1326
+ };
1327
+ }
1328
+ toSignRequest(tx, account, chainId) {
1329
+ const base = {
1330
+ chainId,
1331
+ nonce: tx.nonce,
1332
+ gas: hex$1(tx.gas),
1333
+ to: tx.to,
1334
+ value: hex$1(tx.value),
1335
+ data: tx.data
1336
+ };
1337
+ const transaction = tx.gasPrice === void 0 ? {
1338
+ ...base,
1339
+ type: "eip1559",
1340
+ accessList: [],
1341
+ maxFeePerGas: hex$1(tx.maxFeePerGas ?? 0n),
1342
+ maxPriorityFeePerGas: hex$1(tx.maxPriorityFeePerGas ?? 0n)
1343
+ } : {
1344
+ ...base,
1345
+ type: "legacy",
1346
+ gasPrice: hex$1(tx.gasPrice)
1347
+ };
1348
+ return {
1349
+ kind: "evm.transaction",
1350
+ network: tx.network,
1351
+ account,
1352
+ tx: transaction
1353
+ };
1354
+ }
1355
+ finalize(_tx, signed) {
1356
+ const bytes = signed.signedTransaction;
1357
+ if (signed.kind !== "evm.transaction" || bytes === void 0) throw new Error(`unexpected sign result ${signed.kind}`);
1358
+ return `0x${[...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
1359
+ }
1360
+ async broadcast(client, raw) {
1361
+ return this.rpcHex(client, "eth_sendRawTransaction", [raw]);
1362
+ }
1363
+ async status(client, hash) {
1364
+ const receipt = await this.rpc(client, "eth_getTransactionReceipt", [hash]);
1365
+ if (receipt === null || typeof receipt !== "object") return "pending";
1366
+ if ("status" in receipt && typeof receipt.status === "string" && BigInt(receipt.status) === 0n) return "failed";
1367
+ try {
1368
+ const finalized = await this.rpc(client, "eth_getBlockByNumber", ["finalized", false]);
1369
+ if (finalized !== null && typeof finalized === "object" && "number" in finalized && typeof finalized.number === "string" && "blockNumber" in receipt && typeof receipt.blockNumber === "string" && BigInt(receipt.blockNumber) <= BigInt(finalized.number)) return "finalized";
1370
+ } catch {}
1371
+ return "confirmed";
1372
+ }
1373
+ };
1089
1374
  //#endregion
1090
- //#region ../../node_modules/.bun/@noble+hashes@2.4.0/node_modules/@noble/hashes/_md.js
1091
- /**
1092
- * Internal Merkle-Damgard hash utils.
1093
- * @module
1094
- */
1095
- /**
1096
- * Shared 32-bit conditional boolean primitive reused by SHA-256, SHA-1, and MD5 `F`.
1097
- * Returns bits from `b` when `a` is set, otherwise from `c`.
1098
- * The XOR form is equivalent to MD5's `F(X,Y,Z) = XY v not(X)Z` because the masked terms never
1099
- * set the same bit.
1100
- * @param a - selector word
1101
- * @param b - word chosen when selector bit is set
1102
- * @param c - word chosen when selector bit is clear
1103
- * @returns Mixed 32-bit word.
1104
- * @example
1105
- * Combine three words with the shared 32-bit choice primitive.
1106
- * ```ts
1107
- * Chi(0xffffffff, 0x12345678, 0x87654321);
1108
- * ```
1109
- */
1110
- function Chi(a, b, c) {
1111
- return a & b ^ ~a & c;
1112
- }
1113
- /**
1114
- * Shared 32-bit majority primitive reused by SHA-256 and SHA-1.
1115
- * Returns bits shared by at least two inputs.
1116
- * @param a - first input word
1117
- * @param b - second input word
1118
- * @param c - third input word
1119
- * @returns Mixed 32-bit word.
1120
- * @example
1121
- * Combine three words with the shared 32-bit majority primitive.
1122
- * ```ts
1123
- * Maj(0xffffffff, 0x12345678, 0x87654321);
1124
- * ```
1125
- */
1126
- function Maj(a, b, c) {
1127
- return a & b ^ a & c ^ b & c;
1128
- }
1375
+ //#region src/assets/controller.ts
1129
1376
  /**
1130
- * Merkle-Damgard hash construction base class.
1131
- * Could be used to create MD5, RIPEMD, SHA1, SHA2.
1132
- * Accepts only byte-aligned `Uint8Array` input, even when the underlying spec describes bit
1133
- * strings with partial-byte tails.
1134
- * @param blockLen - internal block size in bytes
1135
- * @param outputLen - digest size in bytes
1136
- * @param padOffset - trailing length field size in bytes
1137
- * @param isLE - whether length and state words are encoded in little-endian
1138
- * @example
1139
- * Use a concrete subclass to get the shared Merkle-Damgard update/digest flow.
1140
- * ```ts
1141
- * import { _SHA1 } from '@noble/hashes/legacy.js';
1142
- * const hash = new _SHA1();
1143
- * hash.update(new Uint8Array([97, 98, 99]));
1144
- * hash.digest();
1145
- * ```
1377
+ * EVM balances per (account address, network). Native via eth_getBalance; token balanceOf calls go
1378
+ * through the network's Multicall3 (one aggregate eth_call) when the registry declares it,
1379
+ * otherwise one eth_call per token. Custom tokens persist in the EngineStore as unverified until a
1380
+ * built-in-table merge marks them otherwise. State values are display-only: transaction build
1381
+ * always re-reads the chain.
1146
1382
  */
1147
- var HashMD = class {
1148
- blockLen;
1149
- outputLen;
1150
- canXOF = false;
1151
- padOffset;
1152
- isLE;
1153
- buffer;
1154
- view;
1155
- finished = false;
1156
- length = 0;
1157
- pos = 0;
1158
- destroyed = false;
1159
- constructor(blockLen, outputLen, padOffset, isLE) {
1160
- this.blockLen = blockLen;
1161
- this.outputLen = outputLen;
1162
- this.padOffset = padOffset;
1163
- this.isLE = isLE;
1164
- this.buffer = new Uint8Array(blockLen);
1165
- this.view = createView(this.buffer);
1166
- }
1167
- update(data) {
1168
- aexists(this);
1169
- abytes(data);
1170
- const { view, buffer, blockLen } = this;
1171
- const len = data.length;
1172
- let processed = false;
1173
- for (let pos = 0; pos < len;) {
1174
- const take = Math.min(blockLen - this.pos, len - pos);
1175
- if (take === blockLen) {
1176
- const dataView = createView(data);
1177
- for (; blockLen <= len - pos; pos += blockLen) this.process(dataView, pos);
1178
- processed = true;
1179
- continue;
1383
+ const obj$2 = (v) => isObject$9(v) ? v : void 0;
1384
+ const arr$1 = (v) => Array.isArray(v);
1385
+ const COLLECTION$1 = "assets-custom-tokens";
1386
+ const POLL_MS = 6e4;
1387
+ const isObject$9 = (v) => typeof v === "object" && v !== null;
1388
+ const checked = (v) => {
1389
+ if (!isAddress(v)) throw new TypeError(`malformed evm address: ${v}`);
1390
+ return v;
1391
+ };
1392
+ const key$1 = (network, address) => `${network}:${address}`;
1393
+ const MULTICALL3_ABI = [{
1394
+ type: "function",
1395
+ name: "aggregate3",
1396
+ inputs: [{
1397
+ name: "calls",
1398
+ type: "tuple[]",
1399
+ components: [
1400
+ {
1401
+ name: "target",
1402
+ type: "address"
1403
+ },
1404
+ {
1405
+ name: "allowFailure",
1406
+ type: "bool"
1407
+ },
1408
+ {
1409
+ name: "callData",
1410
+ type: "bytes"
1180
1411
  }
1181
- buffer.set(pos === 0 && take === len ? data : data.subarray(pos, pos + take), this.pos);
1182
- this.pos += take;
1183
- pos += take;
1184
- if (this.pos === blockLen) {
1185
- this.process(view, 0);
1186
- this.pos = 0;
1187
- processed = true;
1412
+ ]
1413
+ }],
1414
+ outputs: [{
1415
+ name: "returnData",
1416
+ type: "tuple[]",
1417
+ components: [{
1418
+ name: "success",
1419
+ type: "bool"
1420
+ }, {
1421
+ name: "returnData",
1422
+ type: "bytes"
1423
+ }]
1424
+ }],
1425
+ stateMutability: "payable"
1426
+ }];
1427
+ var AssetsController = class extends Controller {
1428
+ options;
1429
+ engineStore;
1430
+ bitcoinUtxos = new BtcUtxoStore();
1431
+ visible = false;
1432
+ cancelPoll;
1433
+ customTokens = /* @__PURE__ */ new Map();
1434
+ constructor(options) {
1435
+ super({
1436
+ name: "assets",
1437
+ defaultState: () => ({ balances: {} })
1438
+ });
1439
+ this.options = options;
1440
+ }
1441
+ async init() {
1442
+ this.engineStore = await this.options.openStore();
1443
+ const listed = await this.engineStore.collection(COLLECTION$1).get("customTokens");
1444
+ if (listed !== void 0) {
1445
+ const parsed = JSON.parse(new TextDecoder().decode(listed));
1446
+ if (isObject$9(parsed)) {
1447
+ for (const [network, arr] of Object.entries(parsed)) if (Array.isArray(arr)) {
1448
+ const tokens = arr.flatMap((t) => isObject$9(t) && typeof t["address"] === "string" && typeof t["verified"] === "boolean" ? [{
1449
+ address: t["address"],
1450
+ verified: t["verified"]
1451
+ }] : []);
1452
+ this.customTokens.set(network, tokens);
1453
+ }
1188
1454
  }
1189
1455
  }
1190
- this.length += data.length;
1191
- if (processed) this.roundClean();
1192
- return this;
1193
- }
1194
- digestInto(out) {
1195
- aexists(this);
1196
- aoutput(out, this);
1197
- this.finished = true;
1198
- const { buffer, view, blockLen, isLE } = this;
1199
- let { pos } = this;
1200
- buffer[pos++] = 128;
1201
- buffer.fill(0, pos);
1202
- if (this.padOffset > blockLen - pos) {
1203
- this.process(view, 0);
1204
- buffer.fill(0);
1205
- }
1206
- setU64FromNum(view, blockLen - 8, this.length * 8, isLE);
1207
- this.process(view, 0);
1208
- this.roundClean();
1209
- const oview = out === buffer ? view : createView(out);
1210
- const len = this.outputLen;
1211
- const outLen = len / 4;
1212
- const state = this.get();
1213
- if (len % 4 || outLen > state.length) throw new Error("invalid outputLen");
1214
- for (let i = 0; i < outLen; i++) oview.setUint32(4 * i, state[i], isLE);
1215
- }
1216
- digest() {
1217
- const { buffer, outputLen } = this;
1218
- this.digestInto(buffer);
1219
- const res = buffer.slice(0, outputLen);
1220
- this.destroy();
1221
- return res;
1222
- }
1223
- _cloneIntoMeta(to) {
1224
- const { buffer, length, finished, destroyed, pos } = this;
1225
- to.destroyed = destroyed;
1226
- to.finished = finished;
1227
- to.length = length;
1228
- to.pos = pos;
1229
- if (pos) to.buffer.set(buffer);
1230
- return to;
1231
- }
1232
- clone() {
1233
- return this._cloneInto();
1456
+ if (this.visible) this.armPoll();
1234
1457
  }
1235
- };
1236
- /**
1237
- * Initial SHA-2 state: fractional parts of square roots of first 16 primes 2..53.
1238
- * Check out `test/misc/sha2-gen-iv.js` for recomputation guide.
1239
- */
1240
- /** Initial SHA256 state from RFC 6234 §6.1: the first 32 bits of the fractional parts of the
1241
- * square roots of the first eight prime numbers. Exported as a shared table; callers must treat
1242
- * it as read-only because constructors copy words from it by index. */
1243
- const SHA256_IV = /* @__PURE__ */ Uint32Array.from([
1244
- 1779033703,
1245
- 3144134277,
1246
- 1013904242,
1247
- 2773480762,
1248
- 1359893119,
1249
- 2600822924,
1250
- 528734635,
1251
- 1541459225
1252
- ]);
1253
- //#endregion
1254
- //#region ../../node_modules/.bun/@noble+hashes@2.4.0/node_modules/@noble/hashes/sha2.js
1255
- /**
1256
- * SHA2 hash function. A.k.a. sha256, sha384, sha512, sha512_224, sha512_256.
1257
- * SHA256 is the fastest hash implementable in JS, even faster than Blake3.
1258
- * Check out {@link https://www.rfc-editor.org/rfc/rfc4634 | RFC 4634} and
1259
- * {@link https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf | FIPS 180-4}.
1260
- * @module
1261
- */
1262
- /**
1263
- * SHA-224 / SHA-256 round constants from RFC 6234 §5.1: the first 32 bits
1264
- * of the cube roots of the first 64 primes (2..311).
1265
- */
1266
- const SHA256_K = /* @__PURE__ */ Uint32Array.from([
1267
- 1116352408,
1268
- 1899447441,
1269
- 3049323471,
1270
- 3921009573,
1271
- 961987163,
1272
- 1508970993,
1273
- 2453635748,
1274
- 2870763221,
1275
- 3624381080,
1276
- 310598401,
1277
- 607225278,
1278
- 1426881987,
1279
- 1925078388,
1280
- 2162078206,
1281
- 2614888103,
1282
- 3248222580,
1283
- 3835390401,
1284
- 4022224774,
1285
- 264347078,
1286
- 604807628,
1287
- 770255983,
1288
- 1249150122,
1289
- 1555081692,
1290
- 1996064986,
1291
- 2554220882,
1292
- 2821834349,
1293
- 2952996808,
1294
- 3210313671,
1295
- 3336571891,
1296
- 3584528711,
1297
- 113926993,
1298
- 338241895,
1299
- 666307205,
1300
- 773529912,
1301
- 1294757372,
1302
- 1396182291,
1303
- 1695183700,
1304
- 1986661051,
1305
- 2177026350,
1306
- 2456956037,
1307
- 2730485921,
1308
- 2820302411,
1309
- 3259730800,
1310
- 3345764771,
1311
- 3516065817,
1312
- 3600352804,
1313
- 4094571909,
1314
- 275423344,
1315
- 430227734,
1316
- 506948616,
1317
- 659060556,
1318
- 883997877,
1319
- 958139571,
1320
- 1322822218,
1321
- 1537002063,
1322
- 1747873779,
1323
- 1955562222,
1324
- 2024104815,
1325
- 2227730452,
1326
- 2361852424,
1327
- 2428436474,
1328
- 2756734187,
1329
- 3204031479,
1330
- 3329325298
1331
- ]);
1332
- /** Reusable SHA-224 / SHA-256 message schedule buffer `W_t` from RFC 6234 §6.2 step 1. */
1333
- const SHA256_W = /* @__PURE__ */ new Uint32Array(64);
1334
- /** Internal SHA-224 / SHA-256 compression engine from RFC 6234 §6.2. */
1335
- var SHA2_32B = class extends HashMD {
1336
- A = 0;
1337
- B = 0;
1338
- C = 0;
1339
- D = 0;
1340
- E = 0;
1341
- F = 0;
1342
- G = 0;
1343
- H = 0;
1344
- constructor(outputLen, IV) {
1345
- super(64, outputLen, 8, false);
1346
- this.A = IV[0] | 0;
1347
- this.B = IV[1] | 0;
1348
- this.C = IV[2] | 0;
1349
- this.D = IV[3] | 0;
1350
- this.E = IV[4] | 0;
1351
- this.F = IV[5] | 0;
1352
- this.G = IV[6] | 0;
1353
- this.H = IV[7] | 0;
1354
- }
1355
- get() {
1356
- const { A, B, C, D, E, F, G, H } = this;
1357
- return [
1358
- A,
1359
- B,
1360
- C,
1361
- D,
1362
- E,
1363
- F,
1364
- G,
1365
- H
1366
- ];
1458
+ lock() {
1459
+ this.cancelPoll?.();
1460
+ this.cancelPoll = void 0;
1461
+ this.customTokens.clear();
1462
+ this.engineStore = void 0;
1463
+ this.update(() => ({ balances: {} }));
1367
1464
  }
1368
- set(A, B, C, D, E, F, G, H) {
1369
- this.A = A | 0;
1370
- this.B = B | 0;
1371
- this.C = C | 0;
1372
- this.D = D | 0;
1373
- this.E = E | 0;
1374
- this.F = F | 0;
1375
- this.G = G | 0;
1376
- this.H = H | 0;
1377
- }
1378
- _cloneInto(to) {
1379
- (to ||= new this.constructor()).set(...this.get());
1380
- return this._cloneIntoMeta(to);
1381
- }
1382
- process(view, offset) {
1383
- for (let i = 0; i < 16; i++, offset += 4) SHA256_W[i] = view.getUint32(offset, false);
1384
- for (let i = 16; i < 64; i++) {
1385
- const W15 = SHA256_W[i - 15];
1386
- const W2 = SHA256_W[i - 2];
1387
- const s0 = rotr(W15, 7) ^ rotr(W15, 18) ^ W15 >>> 3;
1388
- const s1 = rotr(W2, 17) ^ rotr(W2, 19) ^ W2 >>> 10;
1389
- SHA256_W[i] = s1 + SHA256_W[i - 7] + s0 + SHA256_W[i - 16] | 0;
1390
- }
1391
- let { A, B, C, D, E, F, G, H } = this;
1392
- for (let i = 0; i < 64; i++) {
1393
- const sigma1 = rotr(E, 6) ^ rotr(E, 11) ^ rotr(E, 25);
1394
- const T1 = H + sigma1 + Chi(E, F, G) + SHA256_K[i] + SHA256_W[i] | 0;
1395
- const T2 = (rotr(A, 2) ^ rotr(A, 13) ^ rotr(A, 22)) + Maj(A, B, C) | 0;
1396
- H = G;
1397
- G = F;
1398
- F = E;
1399
- E = D + T1 | 0;
1400
- D = C;
1401
- C = B;
1402
- B = A;
1403
- A = T1 + T2 | 0;
1404
- }
1405
- A = A + this.A | 0;
1406
- B = B + this.B | 0;
1407
- C = C + this.C | 0;
1408
- D = D + this.D | 0;
1409
- E = E + this.E | 0;
1410
- F = F + this.F | 0;
1411
- G = G + this.G | 0;
1412
- H = H + this.H | 0;
1413
- this.set(A, B, C, D, E, F, G, H);
1414
- }
1415
- roundClean() {
1416
- clean(SHA256_W);
1417
- }
1418
- destroy() {
1419
- this.destroyed = true;
1420
- this.set(0, 0, 0, 0, 0, 0, 0, 0);
1421
- clean(this.buffer);
1465
+ async destroy() {
1466
+ this.lock();
1467
+ await Promise.resolve();
1422
1468
  }
1423
- };
1424
- /** Internal SHA-256 hash class grounded in RFC 6234 §6.2. */
1425
- var _SHA256 = class extends SHA2_32B {
1426
- constructor() {
1427
- super(32, SHA256_IV);
1469
+ setVisible(visible) {
1470
+ this.visible = visible;
1471
+ if (!visible) {
1472
+ this.cancelPoll?.();
1473
+ this.cancelPoll = void 0;
1474
+ return;
1475
+ }
1476
+ this.armPoll();
1477
+ }
1478
+ armPoll() {
1479
+ if (this.cancelPoll !== void 0) return;
1480
+ this.cancelPoll = this.options.setTimer(POLL_MS, () => {
1481
+ (async () => {
1482
+ try {
1483
+ await this.refreshAll();
1484
+ } catch (error) {
1485
+ this.options.logger.warn("assets refresh failed", error);
1486
+ }
1487
+ })();
1488
+ });
1489
+ }
1490
+ async refreshAll() {
1491
+ await Promise.all(Object.keys(this.getState().balances).flatMap((entry) => {
1492
+ const [network, address] = entry.split(":");
1493
+ return network === void 0 || address === void 0 ? [] : [this.refresh(address, network)];
1494
+ }));
1495
+ }
1496
+ async refresh(address, network) {
1497
+ const info = this.options.networks.info(network);
1498
+ if (info.family === "solana") {
1499
+ await this.refreshSolana(address, network);
1500
+ return;
1501
+ }
1502
+ if (info.family === "bitcoin") {
1503
+ await this.refreshBitcoin(address, network);
1504
+ return;
1505
+ }
1506
+ const client = this.options.networks.client(network);
1507
+ const native = BigInt(await this.rpcHex(client, "eth_getBalance", [address, "latest"])).toString();
1508
+ const custom = this.customTokens.get(network) ?? [];
1509
+ const tokens = await this.readTokens(client, info.multicall3, address, custom.map((t) => ({
1510
+ address: t.address,
1511
+ verified: t.verified
1512
+ })));
1513
+ this.update((s) => ({ balances: {
1514
+ ...s.balances,
1515
+ [key$1(network, address)]: {
1516
+ native,
1517
+ tokens
1518
+ }
1519
+ } }));
1520
+ }
1521
+ /** SOL via getBalance; SPL via getTokenAccountsByOwner (jsonParsed) for known mints. */
1522
+ async refreshSolana(owner, network) {
1523
+ const rpc = this.options.networks.solanaRpc(network);
1524
+ const balValue = obj$2(await rpc.call("getBalance", [owner, { commitment: "confirmed" }]))?.["value"];
1525
+ const native = typeof balValue === "string" ? balValue : typeof balValue === "number" ? String(balValue) : "0";
1526
+ const custom = this.customTokens.get(network) ?? [];
1527
+ const tokens = await Promise.all(custom.map(async (t) => {
1528
+ const accounts = obj$2(await rpc.call("getTokenAccountsByOwner", [
1529
+ owner,
1530
+ { mint: t.address },
1531
+ { encoding: "jsonParsed" }
1532
+ ]));
1533
+ const first = arr$1(accounts?.["value"]) ? accounts?.["value"][0] : void 0;
1534
+ const parsed = obj$2(obj$2(obj$2(obj$2(obj$2(first)?.["account"])?.["data"])?.["parsed"])?.["info"]);
1535
+ const amount = obj$2(parsed?.["tokenAmount"]);
1536
+ const mintInfo = obj$2(await rpc.call("getAccountInfo", [t.address, { encoding: "jsonParsed" }]));
1537
+ const mintParsed = obj$2(obj$2(obj$2(obj$2(mintInfo?.["value"])?.["data"])?.["parsed"])?.["info"]);
1538
+ return {
1539
+ address: t.address,
1540
+ symbol: "?",
1541
+ decimals: typeof mintParsed?.["decimals"] === "number" ? mintParsed["decimals"] : 9,
1542
+ balance: typeof amount?.["amount"] === "string" ? amount["amount"] : "0",
1543
+ verified: t.verified
1544
+ };
1545
+ }));
1546
+ this.update((s) => ({ balances: {
1547
+ ...s.balances,
1548
+ [key$1(network, owner)]: {
1549
+ native,
1550
+ tokens
1551
+ }
1552
+ } }));
1553
+ }
1554
+ async readTokens(client, multicall3, owner, tokens) {
1555
+ if (tokens.length === 0) return [];
1556
+ const balances = multicall3 === void 0 ? await Promise.all(tokens.map(async (t) => this.balanceOf(client, t.address, owner))) : await this.balanceOfBatch(client, multicall3, owner, tokens.map((t) => t.address));
1557
+ return Promise.all(tokens.map(async (t, i) => ({
1558
+ address: t.address,
1559
+ symbol: await this.callString(client, t.address, "symbol") ?? "?",
1560
+ decimals: await this.callNumber(client, t.address, "decimals") ?? 18,
1561
+ balance: balances[i]?.toString() ?? "0",
1562
+ verified: t.verified
1563
+ })));
1564
+ }
1565
+ async balanceOfBatch(client, multicall3, owner, tokens) {
1566
+ const data = encodeFunctionData({
1567
+ abi: MULTICALL3_ABI,
1568
+ functionName: "aggregate3",
1569
+ args: [tokens.map((token) => ({
1570
+ target: checked(token),
1571
+ allowFailure: true,
1572
+ callData: encodeFunctionData({
1573
+ abi: ERC20_ABI,
1574
+ functionName: "balanceOf",
1575
+ args: [checked(owner)]
1576
+ })
1577
+ }))]
1578
+ });
1579
+ const result = await this.rpcHex(client, "eth_call", [{
1580
+ to: multicall3,
1581
+ data
1582
+ }, "latest"]);
1583
+ if (!isHex(result)) throw new TypeError("eth_call returned malformed data");
1584
+ return decodeFunctionResult({
1585
+ abi: MULTICALL3_ABI,
1586
+ functionName: "aggregate3",
1587
+ data: result
1588
+ }).map((r) => r.success ? decodeFunctionResult({
1589
+ abi: ERC20_ABI,
1590
+ functionName: "balanceOf",
1591
+ data: r.returnData
1592
+ }) : void 0);
1593
+ }
1594
+ async balanceOf(client, token, owner) {
1595
+ const data = encodeFunctionData({
1596
+ abi: ERC20_ABI,
1597
+ functionName: "balanceOf",
1598
+ args: [checked(owner)]
1599
+ });
1600
+ const result = await this.rpcHex(client, "eth_call", [{
1601
+ to: token,
1602
+ data
1603
+ }, "latest"]);
1604
+ if (!isHex(result)) throw new TypeError("eth_call returned malformed data");
1605
+ return decodeFunctionResult({
1606
+ abi: ERC20_ABI,
1607
+ functionName: "balanceOf",
1608
+ data: result
1609
+ });
1610
+ }
1611
+ async callString(client, token, fn) {
1612
+ const data = encodeFunctionData({
1613
+ abi: ERC20_ABI,
1614
+ functionName: fn
1615
+ });
1616
+ const result = await this.rpcHex(client, "eth_call", [{
1617
+ to: token,
1618
+ data
1619
+ }, "latest"]);
1620
+ if (!isHex(result)) return;
1621
+ try {
1622
+ return decodeFunctionResult({
1623
+ abi: ERC20_ABI,
1624
+ functionName: fn,
1625
+ data: result
1626
+ });
1627
+ } catch {
1628
+ return;
1629
+ }
1630
+ }
1631
+ async callNumber(client, token, fn) {
1632
+ const data = encodeFunctionData({
1633
+ abi: ERC20_ABI,
1634
+ functionName: fn
1635
+ });
1636
+ const result = await this.rpcHex(client, "eth_call", [{
1637
+ to: token,
1638
+ data
1639
+ }, "latest"]);
1640
+ if (!isHex(result)) return;
1641
+ try {
1642
+ return decodeFunctionResult({
1643
+ abi: ERC20_ABI,
1644
+ functionName: fn,
1645
+ data: result
1646
+ });
1647
+ } catch {
1648
+ return;
1649
+ }
1650
+ }
1651
+ /** Reads symbol/decimals on-chain, marks unverified, persists; throws when the calls fail. */
1652
+ async addToken(network, address) {
1653
+ if (this.options.networks.info(network).family === "solana") {
1654
+ const rpc = this.options.networks.solanaRpc(network);
1655
+ const mint = obj$2(await rpc.call("getAccountInfo", [address, { encoding: "jsonParsed" }]));
1656
+ const decimals = obj$2(obj$2(obj$2(mint?.["value"])?.["data"])?.["parsed"])?.["info"];
1657
+ if (typeof obj$2(decimals)?.["decimals"] !== "number") throw new TypeError(`${address} is not a readable SPL mint on ${network}`);
1658
+ await this.persistToken(network, address);
1659
+ return;
1660
+ }
1661
+ const client = this.options.networks.client(network);
1662
+ await this.callString(client, address, "symbol");
1663
+ await this.callNumber(client, address, "decimals");
1664
+ await this.persistToken(network, address);
1665
+ }
1666
+ async persistToken(network, address) {
1667
+ const existing = this.customTokens.get(network) ?? [];
1668
+ if (existing.some((t) => t.address === address)) return;
1669
+ const next = [...existing, {
1670
+ address,
1671
+ verified: false
1672
+ }];
1673
+ this.customTokens.set(network, next);
1674
+ if (this.engineStore !== void 0) {
1675
+ const merged = { [network]: next };
1676
+ for (const [n, list] of this.customTokens) if (n !== network) merged[n] = list;
1677
+ await this.engineStore.collection(COLLECTION$1).put("customTokens", new TextEncoder().encode(JSON.stringify(merged)));
1678
+ }
1679
+ }
1680
+ async rpc(client, method, params) {
1681
+ return client.transport.request({
1682
+ method,
1683
+ params
1684
+ });
1685
+ }
1686
+ async rpcHex(client, method, params) {
1687
+ const result = await this.rpc(client, method, params);
1688
+ if (typeof result !== "string") throw new TypeError(`${method} returned a malformed result`);
1689
+ return result;
1690
+ }
1691
+ /** BTC balance = confirmed spendable + own unconfirmed change; frozen 546/330 shown separately. */
1692
+ async refreshBitcoin(xpub, network) {
1693
+ const es = this.options.networks.esplora(network);
1694
+ const net = bitcoinNetwork(network);
1695
+ const template = "btc.bip84";
1696
+ const receive = await scanChain(es, xpub, template, network, net.params, 0);
1697
+ const change = await scanChain(es, xpub, template, network, net.params, 1);
1698
+ const addresses = [...receive, ...change];
1699
+ const scanned = await scanUtxos(es, addresses, net.params);
1700
+ this.bitcoinUtxos.replace(scanned);
1701
+ const changeSet = new Set(change.map((a) => a.address));
1702
+ let confirmed = 0n;
1703
+ let ownUnconfirmed = 0n;
1704
+ let frozen = 0n;
1705
+ for (const u of this.bitcoinUtxos.all()) {
1706
+ const v = BigInt(u.value);
1707
+ if (u.status === "frozen" || u.value === 546 || u.value === 330) frozen += v;
1708
+ else if (u.height > 0) confirmed += v;
1709
+ else if (changeSet.has(u.address)) ownUnconfirmed += v;
1710
+ }
1711
+ this.update((s) => ({
1712
+ ...s,
1713
+ balances: {
1714
+ ...s.balances,
1715
+ [`${xpub}:${network}:BTC`]: {
1716
+ native: confirmed.toString(),
1717
+ tokens: [],
1718
+ unconfirmed: ownUnconfirmed.toString(),
1719
+ frozen: frozen.toString()
1720
+ }
1721
+ }
1722
+ }));
1428
1723
  }
1429
1724
  };
1430
- /**
1431
- * SHA2-256 hash function from RFC 4634. In JS it's the fastest: even faster than Blake3. Some info:
1432
- *
1433
- * - Trying 2^128 hashes would get 50% chance of collision, using birthday attack.
1434
- * - BTC network is doing 2^70 hashes/sec (2^95 hashes/year) as per 2025.
1435
- * - Each sha256 hash is executing 2^18 bit operations.
1436
- * - Good 2024 ASICs can do 200Th/sec with 3500 watts of power, corresponding to 2^36 hashes/joule.
1437
- * @param msg - message bytes to hash
1438
- * @param opts - Reserved hash options.
1439
- * @returns Digest bytes.
1440
- * @example
1441
- * Hash a message with SHA2-256.
1442
- * ```ts
1443
- * sha256(new Uint8Array([97, 98, 99]));
1444
- * ```
1445
- */
1446
- const sha256 = /* @__PURE__ */ createHasher(() => new _SHA256(), /* @__PURE__ */ oidNist(1));
1447
1725
  //#endregion
1448
1726
  //#region src/backup/errors.ts
1449
1727
  var BackupControllerError = class extends Error {
@@ -1481,14 +1759,14 @@ function sorted(values) {
1481
1759
  function sameSet(a, b) {
1482
1760
  return a.length === b.length && a.every((v, i) => v === b[i]);
1483
1761
  }
1484
- const isObject$2 = (v) => typeof v === "object" && v !== null;
1762
+ const isObject$8 = (v) => typeof v === "object" && v !== null;
1485
1763
  const isStringArray = (v) => Array.isArray(v) && v.every((e) => typeof e === "string");
1486
1764
  /** Strict record validation — anything malformed reads as absent (and is logged). */
1487
1765
  function parseRecord(bytes) {
1488
1766
  if (bytes === void 0) return;
1489
1767
  try {
1490
1768
  const value = JSON.parse(new TextDecoder().decode(bytes));
1491
- if (!isObject$2(value)) return;
1769
+ if (!isObject$8(value)) return;
1492
1770
  const o = value;
1493
1771
  const { id } = o;
1494
1772
  const { digest } = o;
@@ -1817,17 +2095,17 @@ const KNOWN_GROUP_KEYS = /* @__PURE__ */ new Set([
1817
2095
  "boundPubkey",
1818
2096
  "updatedAt"
1819
2097
  ]);
1820
- const isObject$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2098
+ const isObject$7 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1821
2099
  const isNonNegativeInt = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
1822
2100
  const validName = (v) => v === void 0 || typeof v === "string" && v.trim().length > 0 && v.trim().length <= NAME_MAX;
1823
2101
  const trimmedName = (v) => typeof v === "string" ? v.trim() : void 0;
1824
2102
  /** Strict decode — any malformed shape, unknown key or duplicate index returns `undefined`. */
1825
2103
  function decodeMetaDoc(value) {
1826
- if (!isObject$1(value)) return;
2104
+ if (!isObject$7(value)) return;
1827
2105
  for (const k of Object.keys(value)) if (!KNOWN_DOC_KEYS.has(k)) return;
1828
2106
  if (value["v"] !== 1) return;
1829
2107
  const { wallet } = value;
1830
- if (!isObject$1(wallet)) return;
2108
+ if (!isObject$7(wallet)) return;
1831
2109
  for (const k of Object.keys(wallet)) if (!KNOWN_WALLET_KEYS.has(k)) return;
1832
2110
  if (!validName(wallet["name"]) || !isNonNegativeInt(wallet["updatedAt"])) return;
1833
2111
  const { groups } = value;
@@ -1835,7 +2113,7 @@ function decodeMetaDoc(value) {
1835
2113
  const seen = /* @__PURE__ */ new Set();
1836
2114
  const out = [];
1837
2115
  for (const item of groups) {
1838
- if (!isObject$1(item)) return;
2116
+ if (!isObject$7(item)) return;
1839
2117
  for (const k of Object.keys(item)) if (!KNOWN_GROUP_KEYS.has(k)) return;
1840
2118
  const { index } = item;
1841
2119
  if (!isNonNegativeInt(index) || seen.has(index)) return;
@@ -2137,32 +2415,512 @@ function isUnsupportedDoc(plaintext) {
2137
2415
  }
2138
2416
  }
2139
2417
  //#endregion
2140
- //#region src/payments/parse.ts
2141
- /** Invoices with less than this much life left are treated as already expired. */
2142
- const BOLT11_EXPIRY_GRACE_MS = 6e4;
2143
- const invalid = (reason) => ({
2144
- kind: "invalid",
2145
- reason
2146
- });
2147
- /** BTC decimal `amount` → sats. At most 8 decimal places; must fit a safe integer. */
2148
- function btcAmountSats(text) {
2149
- const match = /^(\d+)(?:\.(\d{1,8}))?$/.exec(text);
2150
- if (match === null) return;
2151
- const [, whole] = match;
2152
- if (whole === void 0) return;
2153
- const sats = BigInt(whole) * 100000000n + BigInt((match[2] ?? "").padEnd(8, "0") || "0");
2154
- return sats <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(sats) : void 0;
2155
- }
2156
- /**
2157
- * BOLT11 → fields, network- and expiry-checked. `undefined` = not a bolt11 string; `"network"` /
2158
- * `"expired"` / `"malformed"` are the failure modes.
2159
- */
2160
- function bolt11Fields(text, options) {
2161
- let decoded;
2162
- try {
2163
- decoded = decodeBolt11(text);
2164
- } catch {
2165
- return;
2418
+ //#region src/chains/bitcoin.ts
2419
+ const DUST = {
2420
+ p2wpkh: 294,
2421
+ p2tr: 330,
2422
+ p2pkh: 546
2423
+ };
2424
+ const FROZEN_AMOUNTS = /* @__PURE__ */ new Set([546, 330]);
2425
+ const isObject$6 = (v) => typeof v === "object" && v !== null;
2426
+ var EsploraError = class extends Error {
2427
+ /** True for request timeout/abort, network failure, 429-after-retries and 5xx — safe to retry. */
2428
+ transient;
2429
+ constructor(message, transient) {
2430
+ super(message);
2431
+ this.name = "EsploraError";
2432
+ this.transient = transient;
2433
+ }
2434
+ };
2435
+ const MAX_IN_FLIGHT = 4;
2436
+ const MAX_RETRIES = 3;
2437
+ const MAX_BACKOFF_MS = 2e3;
2438
+ const REQUEST_TIMEOUT_MS = 1e4;
2439
+ /** Bounded-concurrency + 429-aware ordered failover over the endpoint list. */
2440
+ const esplora = (endpoints, fetchFn, sleep, timeoutMs = REQUEST_TIMEOUT_MS) => {
2441
+ let inFlight = 0;
2442
+ const waiters = [];
2443
+ const acquire = async () => {
2444
+ if (inFlight >= MAX_IN_FLIGHT) await new Promise((resolve) => {
2445
+ waiters.push(resolve);
2446
+ });
2447
+ inFlight += 1;
2448
+ };
2449
+ const release = () => {
2450
+ inFlight -= 1;
2451
+ waiters.shift()?.();
2452
+ };
2453
+ const backoff = async (headers, retry) => {
2454
+ const ra = headers.get("retry-after");
2455
+ const retryAfterMs = ra !== null && ra !== "" && !Number.isNaN(Number(ra)) ? Number(ra) * 1e3 : void 0;
2456
+ const jitter = Math.floor(Math.random() * 100);
2457
+ await sleep(retryAfterMs ?? Math.min(100 * 2 ** retry + jitter, MAX_BACKOFF_MS));
2458
+ };
2459
+ const request = async (base, path, body) => {
2460
+ await acquire();
2461
+ try {
2462
+ let res;
2463
+ try {
2464
+ res = await fetchFn(`${base.replace(/\/$/, "")}${path}`, {
2465
+ ...body === void 0 ? { method: "GET" } : {
2466
+ method: "POST",
2467
+ headers: { "content-type": "text/plain" },
2468
+ body
2469
+ },
2470
+ signal: AbortSignal.timeout(timeoutMs)
2471
+ });
2472
+ } catch (error) {
2473
+ throw new EsploraError(`esplora ${base}${path}: request failed (${error instanceof Error ? error.name : "unknown"})`, true);
2474
+ }
2475
+ if (res.status === 429) return { throttled: res.headers };
2476
+ if (!res.ok) throw new EsploraError(`esplora ${base}${path}: ${res.status}`, res.status >= 500);
2477
+ const text = await res.text();
2478
+ try {
2479
+ return { value: JSON.parse(text) };
2480
+ } catch {
2481
+ return { value: text };
2482
+ }
2483
+ } finally {
2484
+ release();
2485
+ }
2486
+ };
2487
+ const call = async (base, path, body, retry) => {
2488
+ const outcome = await request(base, path, body);
2489
+ if ("value" in outcome) return outcome.value;
2490
+ if (retry >= MAX_RETRIES) throw new EsploraError(`esplora ${base}${path}: 429 after ${MAX_RETRIES} retries`, true);
2491
+ await backoff(outcome.throttled, retry);
2492
+ return call(base, path, body, retry + 1);
2493
+ };
2494
+ const attempt = async (rest, path, body, last) => {
2495
+ const [base, ...next] = rest;
2496
+ if (base === void 0) throw last instanceof Error ? last : /* @__PURE__ */ new Error("all esplora endpoints failed");
2497
+ try {
2498
+ return await call(base, path, body, 0);
2499
+ } catch (error) {
2500
+ return attempt(next, path, body, error);
2501
+ }
2502
+ };
2503
+ return {
2504
+ get: async (path) => attempt(endpoints, path, void 0, void 0),
2505
+ post: async (path, body) => attempt(endpoints, path, body, void 0)
2506
+ };
2507
+ };
2508
+ const feeTiers = async (es) => {
2509
+ const res = await es.get("/fee-estimates");
2510
+ const estimates = isObject$6(res) ? res : {};
2511
+ const pick = (target) => Math.max(1, Math.ceil(Number(estimates[target] ?? 1)));
2512
+ return [
2513
+ {
2514
+ tier: "fast",
2515
+ satPerByte: pick("1")
2516
+ },
2517
+ {
2518
+ tier: "normal",
2519
+ satPerByte: pick("3")
2520
+ },
2521
+ {
2522
+ tier: "slow",
2523
+ satPerByte: pick("6")
2524
+ },
2525
+ {
2526
+ tier: "economy",
2527
+ satPerByte: pick("144")
2528
+ }
2529
+ ];
2530
+ };
2531
+ const dustFor = (scriptType) => DUST[scriptType] ?? 546;
2532
+ const isFrozen = (value) => FROZEN_AMOUNTS.has(value);
2533
+ var BitcoinChainAdapter = class {
2534
+ /** Inputs for selectUTXO — the same TransactionInputUpdate objects added to the PSBT. */
2535
+ toInputs(utxos, fingerprint, pathOf, pubkeyOf, scriptOf, template, rawTxOf) {
2536
+ return utxos.filter((u) => u.status === "confirmed" && !isFrozen(u.value)).map((u) => {
2537
+ const pub = pubkeyOf(u);
2538
+ const entry = {
2539
+ txid: u.txid,
2540
+ index: u.vout,
2541
+ sequence: 4294967293
2542
+ };
2543
+ if (template !== "btc.bip44") entry.witnessUtxo = {
2544
+ amount: BigInt(u.value),
2545
+ script: scriptOf(u)
2546
+ };
2547
+ if (template === "btc.bip44") {
2548
+ const raw = rawTxOf === void 0 ? void 0 : rawTxOf(u);
2549
+ if (raw === void 0) throw new Error(`no raw transaction for bip44 input ${u.txid}`);
2550
+ entry.nonWitnessUtxo = raw;
2551
+ }
2552
+ if (template === "btc.bip86") {
2553
+ entry.tapInternalKey = pub.subarray(1);
2554
+ entry.tapBip32Derivation = [[pub.subarray(1), {
2555
+ hashes: [],
2556
+ der: {
2557
+ fingerprint,
2558
+ path: pathOf(u)
2559
+ }
2560
+ }]];
2561
+ } else {
2562
+ entry.bip32Derivation = [[pub, {
2563
+ fingerprint,
2564
+ path: pathOf(u)
2565
+ }]];
2566
+ if (template === "btc.bip49") entry.redeemScript = p2wpkh(pub).script;
2567
+ }
2568
+ return entry;
2569
+ });
2570
+ }
2571
+ buildPsbt(inputs, to, amount, feePerByte, changeAddress, network) {
2572
+ const selection = selectUTXO([...inputs], [{
2573
+ address: to,
2574
+ amount
2575
+ }], "default", {
2576
+ feePerByte,
2577
+ changeAddress,
2578
+ bip69: true,
2579
+ dust: BigInt(dustFor("p2wpkh")),
2580
+ network,
2581
+ createTx: true
2582
+ });
2583
+ if (selection === void 0 || selection.tx === void 0) throw new Error("insufficient confirmed utxo for the amount + fee");
2584
+ const wanted = new Set((selection.inputs ?? []).flatMap((sel) => {
2585
+ const raw = sel.txid;
2586
+ if (!(raw instanceof Uint8Array)) return [];
2587
+ const forward = hex.encode(raw);
2588
+ const reverse = hex.encode(Uint8Array.from(raw).reverse());
2589
+ return [`${forward}:${String(sel.index ?? -1)}`, `${reverse}:${String(sel.index ?? -1)}`];
2590
+ }));
2591
+ const chosen = inputs.filter((i) => wanted.has(`${String(i.txid)}:${String(i.index)}`));
2592
+ return {
2593
+ psbt: selection.tx.toPSBT(),
2594
+ inputs: chosen
2595
+ };
2596
+ }
2597
+ /** Fixed-input/output PSBT — the RBF/CPFP path, which chooses nothing itself. */
2598
+ buildExactPsbt(inputs, outputs, network) {
2599
+ const tx = new Transaction();
2600
+ for (const input of inputs) tx.addInput(input);
2601
+ for (const out of outputs) tx.addOutputAddress(out.address, out.amount, network);
2602
+ const tap = inputs.every((i) => i.tapInternalKey !== void 0);
2603
+ const vsize = Math.ceil(10.5 + inputs.length * (tap ? 57.5 : 68) + outputs.length * (tap ? 43 : 31));
2604
+ return {
2605
+ psbt: tx.toPSBT(),
2606
+ vsize
2607
+ };
2608
+ }
2609
+ };
2610
+ //#endregion
2611
+ //#region src/networks/registry.ts
2612
+ const entry = (chainId, name, symbol, testnet, _source) => ({
2613
+ id: `eip155:${chainId}`,
2614
+ chainId,
2615
+ name,
2616
+ symbol,
2617
+ decimals: 18,
2618
+ testnet,
2619
+ family: "evm",
2620
+ multicall3: "0xcA11bde05977b3631167028862bE2a173976CA11"
2621
+ });
2622
+ const NETWORKS = [
2623
+ entry(1, "Ethereum", "ETH", false, "https://ethereum.org/en/developers/docs/networks/"),
2624
+ entry(8453, "Base", "ETH", false, "https://docs.base.org/docs/network-information"),
2625
+ entry(42161, "Arbitrum One", "ETH", false, "https://docs.arbitrum.io/for-devs/dev-tools-and-resources/chain-info"),
2626
+ entry(10, "OP Mainnet", "ETH", false, "https://docs.optimism.io/chain/networks"),
2627
+ entry(137, "Polygon", "POL", false, "https://docs.polygon.technology/pos/reference/rpc-endpoints/"),
2628
+ entry(56, "BNB Smart Chain", "BNB", false, "https://docs.bnbchain.org/bnb-smart-chain/developers/json_rpc/"),
2629
+ entry(999, "HyperEVM", "HYPE", false, "https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/hyperevm"),
2630
+ entry(43114, "Avalanche C-Chain", "AVAX", false, "https://build.avax.network/docs/tooling/rpc-providers"),
2631
+ entry(11155111, "Sepolia", "ETH", true, "https://ethereum.org/en/developers/docs/networks/"),
2632
+ entry(84532, "Base Sepolia", "ETH", true, "https://docs.base.org/docs/network-information"),
2633
+ entry(421614, "Arbitrum Sepolia", "ETH", true, "https://docs.arbitrum.io/for-devs/dev-tools-and-resources/chain-info"),
2634
+ entry(11155420, "OP Sepolia", "ETH", true, "https://docs.optimism.io/chain/networks"),
2635
+ entry(80002, "Polygon Amoy", "POL", true, "https://docs.polygon.technology/pos/reference/rpc-endpoints/"),
2636
+ entry(97, "BNB Smart Chain Testnet", "tBNB", true, "https://docs.bnbchain.org/bnb-smart-chain/developers/json_rpc/"),
2637
+ entry(998, "HyperEVM Testnet", "HYPE", true, "https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/hyperevm"),
2638
+ entry(43113, "Avalanche Fuji", "AVAX", true, "https://build.avax.network/docs/tooling/rpc-providers")
2639
+ ];
2640
+ const networkById = (id) => [
2641
+ ...NETWORKS,
2642
+ ...SOLANA_NETWORKS,
2643
+ ...BITCOIN_NETWORKS
2644
+ ].find((n) => n.id === id);
2645
+ const BITCOIN_NETWORKS = [
2646
+ {
2647
+ id: "bip122:000000000019d6689c085ae165831e93",
2648
+ chainId: 0,
2649
+ name: "Bitcoin",
2650
+ symbol: "BTC",
2651
+ decimals: 8,
2652
+ testnet: false,
2653
+ family: "bitcoin",
2654
+ genesisHash: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
2655
+ },
2656
+ {
2657
+ id: "bip122:00000000da84f2bafbbc53dee25a72ae",
2658
+ chainId: 0,
2659
+ name: "Bitcoin Testnet4",
2660
+ symbol: "tBTC",
2661
+ decimals: 8,
2662
+ testnet: true,
2663
+ family: "bitcoin",
2664
+ genesisHash: "00000000da84f2bafbbc53dee25a72ae507ff4914b867c565be350b0da8bf043"
2665
+ },
2666
+ {
2667
+ id: "bip122:00000008819873e925422c1ff0f99f7c",
2668
+ chainId: 0,
2669
+ name: "Bitcoin Signet",
2670
+ symbol: "sBTC",
2671
+ decimals: 8,
2672
+ testnet: true,
2673
+ family: "bitcoin",
2674
+ genesisHash: "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6"
2675
+ },
2676
+ {
2677
+ id: "bip122:0f9188f13cb7b2c71f2a335e3a4fc328",
2678
+ chainId: 0,
2679
+ name: "Bitcoin Regtest",
2680
+ symbol: "rBTC",
2681
+ decimals: 8,
2682
+ testnet: true,
2683
+ family: "bitcoin",
2684
+ genesisHash: "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206"
2685
+ }
2686
+ ];
2687
+ const SOLANA_NETWORKS = [{
2688
+ id: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
2689
+ chainId: 0,
2690
+ name: "Solana",
2691
+ symbol: "SOL",
2692
+ decimals: 9,
2693
+ testnet: false,
2694
+ family: "solana",
2695
+ genesisHash: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpWvDVGok8cUQPCbq9AzdTQvpNbNvyq4qDvMNTrDDezsFapSihepu2eQEdje"
2696
+ }, {
2697
+ id: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
2698
+ chainId: 0,
2699
+ name: "Solana Devnet",
2700
+ symbol: "SOL",
2701
+ decimals: 9,
2702
+ testnet: true,
2703
+ family: "solana",
2704
+ genesisHash: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBGGSWgX4GwCJRmgdcvRaRmFbXfw3nDZBTqsr"
2705
+ }];
2706
+ //#endregion
2707
+ //#region src/networks/controller.ts
2708
+ /**
2709
+ * EVM network controller: the built-in registry plus host-provided and user-added RPC endpoints.
2710
+ * Each network gets a lazily-created viem PublicClient on a `fallback` transport — endpoints are
2711
+ * tried in order (custom → host), each with the injected fetch, so a dead endpoint fails over
2712
+ * without code here. Custom RPC URLs are validated against the registry chain id with `eth_chainId`
2713
+ * and persisted in the EngineStore.
2714
+ */
2715
+ const esploraSleep = async (ms) => new Promise((resolve) => {
2716
+ setTimeout(resolve, ms);
2717
+ });
2718
+ const isObject$5 = (v) => typeof v === "object" && v !== null;
2719
+ var NetworkController = class extends Controller {
2720
+ options;
2721
+ engineStore;
2722
+ clients = /* @__PURE__ */ new Map();
2723
+ constructor(options) {
2724
+ super({
2725
+ name: "networks",
2726
+ defaultState: () => ({ customRpc: {} })
2727
+ });
2728
+ this.options = options;
2729
+ }
2730
+ async init() {
2731
+ this.engineStore = await this.options.openStore();
2732
+ const raw = await this.engineStore.collection("networks").get("customRpc");
2733
+ if (raw === void 0) return;
2734
+ const parsed = JSON.parse(new TextDecoder().decode(raw));
2735
+ if (typeof parsed === "object" && parsed !== null) {
2736
+ const map = {};
2737
+ for (const [k, v] of Object.entries(parsed)) if (Array.isArray(v) && v.every((u) => typeof u === "string")) map[k] = v;
2738
+ this.update((s) => ({
2739
+ ...s,
2740
+ customRpc: map
2741
+ }));
2742
+ }
2743
+ }
2744
+ async destroy() {
2745
+ this.lock();
2746
+ await Promise.resolve();
2747
+ }
2748
+ lock() {
2749
+ this.clients.clear();
2750
+ this.engineStore = void 0;
2751
+ }
2752
+ endpoints(network) {
2753
+ return [...this.getState().customRpc[network] ?? [], ...this.options.rpc[network] ?? []];
2754
+ }
2755
+ info(network) {
2756
+ const info = networkById(network);
2757
+ if (info === void 0) throw new Error(`unknown network ${network}`);
2758
+ return info;
2759
+ }
2760
+ client(network) {
2761
+ const info = this.info(network);
2762
+ const cached = this.clients.get(network);
2763
+ if (cached !== void 0) return cached;
2764
+ const urls = this.endpoints(network);
2765
+ if (urls.length === 0) throw new Error(`no rpc endpoint for ${network}`);
2766
+ const client = createPublicClient({
2767
+ chain: {
2768
+ id: info.chainId,
2769
+ name: info.name,
2770
+ nativeCurrency: {
2771
+ name: info.symbol,
2772
+ symbol: info.symbol,
2773
+ decimals: info.decimals
2774
+ },
2775
+ rpcUrls: { default: { http: urls } }
2776
+ },
2777
+ transport: fallback(urls.map((url) => http(url, { fetchFn: this.options.fetch }))),
2778
+ batch: { multicall: true }
2779
+ });
2780
+ this.clients.set(network, client);
2781
+ return client;
2782
+ }
2783
+ /** Validate a custom endpoint's reported chain id, then persist it first in the list. */
2784
+ /** Solana JSON-RPC over the ordered endpoint list — kit's transport is single-endpoint. */
2785
+ /** Networks with at least one configured endpoint, filtered by family. */
2786
+ enabled(family) {
2787
+ return [.../* @__PURE__ */ new Set([...Object.keys(this.options.rpc), ...Object.keys(this.getState().customRpc)])].filter((id) => {
2788
+ const info = networkById(id);
2789
+ return info !== void 0 && info.family === family;
2790
+ });
2791
+ }
2792
+ solanaRpc(network) {
2793
+ const urls = this.endpoints(network);
2794
+ if (urls.length === 0) throw new Error(`no rpc endpoint for ${network}`);
2795
+ return { call: async (method, params) => {
2796
+ const attempt = async (rest, last) => {
2797
+ const [url, ...next] = rest;
2798
+ if (url === void 0) throw last instanceof Error ? last : new Error(typeof last === "string" ? last : method);
2799
+ try {
2800
+ const body = await (await this.options.fetch(url, {
2801
+ method: "POST",
2802
+ headers: { "content-type": "application/json" },
2803
+ body: JSON.stringify({
2804
+ jsonrpc: "2.0",
2805
+ id: 1,
2806
+ method,
2807
+ params
2808
+ })
2809
+ })).json();
2810
+ if (!isObject$5(body)) return await attempt(next, /* @__PURE__ */ new Error(`malformed response from ${url}`));
2811
+ if (body["error"] !== void 0) {
2812
+ const msg = isObject$5(body["error"]) ? body["error"]["message"] : void 0;
2813
+ return await attempt(next, new Error(typeof msg === "string" ? msg : method));
2814
+ }
2815
+ return body["result"];
2816
+ } catch (error) {
2817
+ return attempt(next, error);
2818
+ }
2819
+ };
2820
+ return attempt(urls, void 0);
2821
+ } };
2822
+ }
2823
+ /** Bounded-concurrency + 429-aware ordered-failover Esplora client. */
2824
+ esplora(network) {
2825
+ const urls = this.endpoints(network);
2826
+ if (urls.length === 0) throw new Error(`no esplora endpoint for ${network}`);
2827
+ return esplora(urls, async (url, init) => {
2828
+ const r = await this.options.fetch(url, init);
2829
+ return {
2830
+ ok: r.ok,
2831
+ status: r.status,
2832
+ headers: { get: (h) => r.headers.get(h) ?? void 0 },
2833
+ json: async () => r.json(),
2834
+ text: async () => r.text()
2835
+ };
2836
+ }, esploraSleep);
2837
+ }
2838
+ async addRpc(network, url) {
2839
+ const info = this.info(network);
2840
+ if (info.family === "bitcoin") {
2841
+ const genesis = (await (await this.options.fetch(`${url.replace(/\/$/, "")}/block-height/0`)).text()).trim();
2842
+ if (genesis !== info.genesisHash) throw new Error(`${url} reports genesis ${genesis}, expected ${info.genesisHash ?? ""}`);
2843
+ } else if (info.family === "solana") {
2844
+ const body = await (await this.options.fetch(url, {
2845
+ method: "POST",
2846
+ headers: { "content-type": "application/json" },
2847
+ body: JSON.stringify({
2848
+ jsonrpc: "2.0",
2849
+ id: 1,
2850
+ method: "getGenesisHash",
2851
+ params: []
2852
+ })
2853
+ })).json();
2854
+ const genesis = isObject$5(body) ? body["result"] : void 0;
2855
+ if (genesis !== info.genesisHash) throw new Error(`${url} reports genesis hash ${String(genesis)}, expected ${info.genesisHash ?? ""}`);
2856
+ const current = this.getState().customRpc[network] ?? [];
2857
+ const customRpc = {
2858
+ ...this.getState().customRpc,
2859
+ [network]: [url, ...current]
2860
+ };
2861
+ this.update((s) => ({
2862
+ ...s,
2863
+ customRpc
2864
+ }));
2865
+ const store = this.engineStore;
2866
+ if (store !== void 0) await store.collection("networks").put("customRpc", new TextEncoder().encode(JSON.stringify(customRpc)));
2867
+ return;
2868
+ }
2869
+ const body = await (await this.options.fetch(url, {
2870
+ method: "POST",
2871
+ headers: { "content-type": "application/json" },
2872
+ body: JSON.stringify({
2873
+ jsonrpc: "2.0",
2874
+ id: 1,
2875
+ method: "eth_chainId",
2876
+ params: []
2877
+ })
2878
+ })).json();
2879
+ const result = typeof body === "object" && body !== null && "result" in body ? body.result : void 0;
2880
+ if (typeof result !== "string") throw new TypeError(`eth_chainId returned a malformed response from ${url}`);
2881
+ const chainId = Number.parseInt(result, 16);
2882
+ if (chainId !== info.chainId) throw new Error(`${url} reports chain id ${chainId}, expected ${info.chainId}`);
2883
+ const current = this.getState().customRpc[network] ?? [];
2884
+ const customRpc = {
2885
+ ...this.getState().customRpc,
2886
+ [network]: [url, ...current]
2887
+ };
2888
+ this.update((s) => ({
2889
+ ...s,
2890
+ customRpc
2891
+ }));
2892
+ this.clients.delete(network);
2893
+ const store = this.engineStore;
2894
+ if (store !== void 0) await store.collection("networks").put("customRpc", new TextEncoder().encode(JSON.stringify(customRpc)));
2895
+ }
2896
+ };
2897
+ //#endregion
2898
+ //#region src/payments/parse.ts
2899
+ /** Invoices with less than this much life left are treated as already expired. */
2900
+ const BOLT11_EXPIRY_GRACE_MS = 6e4;
2901
+ const invalid = (reason) => ({
2902
+ kind: "invalid",
2903
+ reason
2904
+ });
2905
+ /** BTC decimal `amount` → sats. At most 8 decimal places; must fit a safe integer. */
2906
+ function btcAmountSats(text) {
2907
+ const match = /^(\d+)(?:\.(\d{1,8}))?$/.exec(text);
2908
+ if (match === null) return;
2909
+ const [, whole] = match;
2910
+ if (whole === void 0) return;
2911
+ const sats = BigInt(whole) * 100000000n + BigInt((match[2] ?? "").padEnd(8, "0") || "0");
2912
+ return sats <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(sats) : void 0;
2913
+ }
2914
+ /**
2915
+ * BOLT11 → fields, network- and expiry-checked. `undefined` = not a bolt11 string; `"network"` /
2916
+ * `"expired"` / `"malformed"` are the failure modes.
2917
+ */
2918
+ function bolt11Fields(text, options) {
2919
+ let decoded;
2920
+ try {
2921
+ decoded = decodeBolt11(text);
2922
+ } catch {
2923
+ return;
2166
2924
  }
2167
2925
  const network = decoded.network === "mainnet" ? "mainnet" : decoded.network === "regtest" ? "regtest" : void 0;
2168
2926
  if (network === void 0) return "network";
@@ -2681,7 +3439,7 @@ function categorizePayment(error) {
2681
3439
  //#endregion
2682
3440
  //#region src/spark/notes.ts
2683
3441
  const HEX64 = /^[0-9a-f]{64}$/;
2684
- const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3442
+ const isObject$4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2685
3443
  const NOTE_FIELDS = /* @__PURE__ */ new Set([
2686
3444
  "kind",
2687
3445
  "recipientPubkey",
@@ -2691,7 +3449,7 @@ const NOTE_FIELDS = /* @__PURE__ */ new Set([
2691
3449
  ]);
2692
3450
  /** Strict read-side validation: anything off-shape is `undefined`, never a throw. */
2693
3451
  function parsePaymentNote(value) {
2694
- if (!isObject(value)) return;
3452
+ if (!isObject$4(value)) return;
2695
3453
  for (const field of Object.keys(value)) if (!NOTE_FIELDS.has(field)) return;
2696
3454
  const { kind, recipientPubkey, eventId, comment, lightning } = value;
2697
3455
  if (kind !== "zap") return;
@@ -3249,6 +4007,1161 @@ var SparkController = class extends Controller {
3249
4007
  }
3250
4008
  };
3251
4009
  //#endregion
4010
+ //#region src/transactions/bitcoin.ts
4011
+ /** Bitcoin route: scan → coin-select → PSBT → sign → broadcast, plus esplora status poll. */
4012
+ const isObject$3 = (v) => typeof v === "object" && v !== null;
4013
+ const fetchRawTxes = async (es, utxos) => {
4014
+ const out = /* @__PURE__ */ new Map();
4015
+ await Promise.all(utxos.map(async (u) => {
4016
+ const raw = await es.get(`/tx/${u.txid}/hex`);
4017
+ if (typeof raw !== "string") throw new TypeError(`esplora /tx/${u.txid}/hex: not a string`);
4018
+ const bytes = hex.decode(raw);
4019
+ if (Transaction.fromRaw(bytes, { allowUnknownInputs: true }).id !== u.txid) throw new Error(`esplora /tx/${u.txid}/hex: txid mismatch`);
4020
+ out.set(u.txid, bytes);
4021
+ }));
4022
+ return out;
4023
+ };
4024
+ /** The account-relative full path `purpose'/coin'/group'/change/index` for a PSBT entry. */
4025
+ const accountPath = (accountPath) => accountPath.replace(/^m\//, "").split("/").map((p) => {
4026
+ const hardened = p.endsWith("'");
4027
+ const n = Number(hardened ? p.slice(0, -1) : p);
4028
+ return hardened ? n + 2147483648 : n;
4029
+ });
4030
+ const poll = async (ctx, tx) => {
4031
+ if (tx.hash === void 0) return "failed";
4032
+ const status = await ctx.options.networks.esplora(tx.network).get(`/tx/${tx.hash}/status`);
4033
+ if (!(isObject$3(status) && status["confirmed"] === true)) return;
4034
+ return "confirmed";
4035
+ };
4036
+ /** Per-controller route — the UTXO store and the build stash are engine state. */
4037
+ const createBitcoinRoute = () => {
4038
+ const bitcoin = new BitcoinChainAdapter();
4039
+ const bitcoinUtxos = new BtcUtxoStore();
4040
+ const pending = /* @__PURE__ */ new Map();
4041
+ const send = async (ctx, params) => {
4042
+ const { account, network, to, amount, fee, verify } = params;
4043
+ if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
4044
+ const es = ctx.options.networks.esplora(network);
4045
+ const apis = ctx.options.chains ?? chains(ctx.options.wallet);
4046
+ const template = [
4047
+ "btc.bip44",
4048
+ "btc.bip49",
4049
+ "btc.bip84",
4050
+ "btc.bip86"
4051
+ ].find((x) => x === account.template) ?? "btc.bip84";
4052
+ const btcAccount = await apis.account({
4053
+ walletId: account.walletId,
4054
+ groupIndex: account.groupIndex,
4055
+ template,
4056
+ network
4057
+ });
4058
+ if (!("xpub" in btcAccount) || btcAccount.xpub === void 0) throw new Error("bitcoin account carries no xpub");
4059
+ const { xpub, fingerprint, path } = btcAccount;
4060
+ const fp = Number.parseInt(fingerprint, 16);
4061
+ const net = bitcoinNetwork(network);
4062
+ const tap = template === "btc.bip86";
4063
+ const rawTxOf = (u) => rawTxes.get(u.txid);
4064
+ const receive = await scanChain(es, xpub, template, network, net.params, 0);
4065
+ const change = await scanChain(es, xpub, template, network, net.params, 1);
4066
+ const addresses = [...receive, ...change];
4067
+ const utxos = (await scanUtxos(es, addresses, net.params)).map((u) => ({
4068
+ txid: u.txid,
4069
+ vout: u.vout,
4070
+ value: u.value,
4071
+ address: u.address,
4072
+ status: "confirmed",
4073
+ height: u.height
4074
+ }));
4075
+ const byAddr = new Map(addresses.map((a) => [a.address, a]));
4076
+ const base = accountPath(path);
4077
+ const rawTxes = template === "btc.bip44" ? await fetchRawTxes(es, utxos) : /* @__PURE__ */ new Map();
4078
+ const inputs = bitcoin.toInputs(utxos, fp, (u) => {
4079
+ const a = byAddr.get(u.address);
4080
+ return a === void 0 ? [] : [
4081
+ ...base,
4082
+ a.chain,
4083
+ a.index
4084
+ ];
4085
+ }, (u) => {
4086
+ const a = byAddr.get(u.address);
4087
+ if (a === void 0) throw new Error(`utxo ${u.txid} on an unknown address`);
4088
+ return bitcoinPubkeyFromXpub(xpub, template, network, a.chain, a.index);
4089
+ }, (u) => scriptPubKey(u.address, net.params), template, template === "btc.bip44" ? rawTxOf : void 0);
4090
+ let changeAddr = change.find((a) => !a.used)?.address;
4091
+ if (template === "btc.bip49" || template === "btc.bip44") {
4092
+ const acct84 = await apis.account({
4093
+ walletId: account.walletId,
4094
+ groupIndex: account.groupIndex,
4095
+ template: "btc.bip84",
4096
+ network
4097
+ });
4098
+ const xpub84 = "xpub" in acct84 ? acct84.xpub : void 0;
4099
+ if (xpub84 === void 0) throw new Error("no bip84 account for the legacy-template change chain");
4100
+ changeAddr = (await scanChain(es, xpub84, "btc.bip84", network, net.params, 1)).find((a) => !a.used)?.address;
4101
+ }
4102
+ if (changeAddr === void 0) throw new Error("no unused change address");
4103
+ const quotes = await feeTiers(es);
4104
+ const quote = quotes.find((q) => q.tier === (fee === "fast" ? "fast" : fee === "slow" ? "slow" : "normal")) ?? quotes[1];
4105
+ if (quote === void 0) throw new Error("no fee quote");
4106
+ const { psbt, inputs: selected } = bitcoin.buildPsbt(inputs, to, amount, BigInt(quote.satPerByte), changeAddr, net.params);
4107
+ const selectedKeys = new Set(selected.map((i) => `${String(i.txid ?? "")}:${String(i.index ?? 0)}`));
4108
+ const selectedUtxos = utxos.filter((u) => selectedKeys.has(`${u.txid}:${u.vout}`));
4109
+ const txP = Transaction.fromPSBT(psbt);
4110
+ const ownScripts = new Set(change.filter((a) => !a.used).map((a) => hex.encode(scriptPubKey(a.address, net.params))));
4111
+ const request = {
4112
+ kind: "bitcoin.psbt",
4113
+ network,
4114
+ account,
4115
+ psbt,
4116
+ sign: Array.from({ length: txP.inputsLength }, (_, i) => i)
4117
+ };
4118
+ const intent = decodeBitcoinPsbt(request, ownScripts);
4119
+ const vsize = Math.ceil(10.5 + txP.inputsLength * (tap ? 57.5 : 68) + txP.outputsLength * (tap ? 43 : 31));
4120
+ const outValues = Array.from({ length: txP.outputsLength }, (_, i) => {
4121
+ const o = txP.getOutput(i);
4122
+ const scriptHex = "script" in o && o.script instanceof Uint8Array ? o.script : /* @__PURE__ */ new Uint8Array();
4123
+ return {
4124
+ address: scriptHex.length > 0 ? Address(net.params).encode(OutScript.decode(scriptHex)) : "",
4125
+ value: Number(o.amount ?? 0n),
4126
+ own: false
4127
+ };
4128
+ });
4129
+ for (const o of outValues) if (o.address !== "" && ownScripts.has(hex.encode(scriptPubKey(o.address, net.params)))) o.own = true;
4130
+ const inTotal = selectedUtxos.reduce((a, u) => a + u.value, 0);
4131
+ const outTotal = outValues.reduce((a, o) => a + o.value, 0);
4132
+ const id0 = newId(ctx.options.random);
4133
+ pending.set(id0, {
4134
+ inputs,
4135
+ allUtxos: utxos,
4136
+ pathOf: (u) => {
4137
+ const a = byAddr.get(u.address);
4138
+ return a === void 0 ? [] : [
4139
+ ...base,
4140
+ a.chain,
4141
+ a.index
4142
+ ];
4143
+ },
4144
+ pubkeyOf: (u) => {
4145
+ const a = byAddr.get(u.address);
4146
+ if (a === void 0) throw new Error(`utxo ${String(u.txid)} on an unknown address`);
4147
+ return bitcoinPubkeyFromXpub(xpub, template, network, a.chain, a.index);
4148
+ },
4149
+ scriptOf: (u) => scriptPubKey(u.address, net.params),
4150
+ changeAddresses: change.filter((a) => !a.used).map((a) => a.address),
4151
+ fp,
4152
+ tier: fee === "fast" ? "fast" : fee === "slow" ? "slow" : "normal",
4153
+ template,
4154
+ network
4155
+ });
4156
+ let tx = {
4157
+ id: id0,
4158
+ family: "bitcoin",
4159
+ network,
4160
+ account: account.walletId,
4161
+ hash: void 0,
4162
+ state: "draft",
4163
+ intent,
4164
+ nonce: 0,
4165
+ btc: {
4166
+ inputs: selectedUtxos.map((u) => ({
4167
+ txid: u.txid,
4168
+ vout: u.vout,
4169
+ value: u.value,
4170
+ address: u.address
4171
+ })),
4172
+ outputs: outValues,
4173
+ vsize,
4174
+ fee: inTotal - outTotal,
4175
+ feeRate: Math.ceil((inTotal - outTotal) / vsize)
4176
+ },
4177
+ submittedAt: ctx.options.now(),
4178
+ error: void 0
4179
+ };
4180
+ tx = await ctx.transition(tx, {});
4181
+ try {
4182
+ const result = await apis.sign({
4183
+ request,
4184
+ verify
4185
+ });
4186
+ tx = await ctx.transition(tx, { state: "signed" });
4187
+ if (result.kind !== "bitcoin.psbt" || !(result.signedPsbt instanceof Uint8Array)) throw new Error("unexpected bitcoin sign result");
4188
+ bitcoinUtxos.lock(selectedUtxos, tx.id);
4189
+ const signed = Transaction.fromPSBT(result.signedPsbt);
4190
+ signed.finalize();
4191
+ const raw = signed.extract();
4192
+ const hash = String(await es.post("/tx", hex.encode(raw)));
4193
+ tx = await ctx.transition(tx, {
4194
+ state: "submitted",
4195
+ hash,
4196
+ submittedAt: ctx.options.now()
4197
+ });
4198
+ ctx.track(tx.id, hash);
4199
+ return tx;
4200
+ } catch (error) {
4201
+ const failed = tx.state === "signed" ? "failed" : "rejected";
4202
+ await ctx.transition(tx, {
4203
+ state: failed,
4204
+ error: errorMessage(error)
4205
+ });
4206
+ throw error;
4207
+ }
4208
+ };
4209
+ /** Esplora `/tx/:txid/status` → terminal state or `undefined` to keep polling. */
4210
+ const finish = async (ctx, stash, es, account, old, psbt, record, link, verify) => {
4211
+ const apis = ctx.options.chains ?? chains(ctx.options.wallet);
4212
+ const txP = Transaction.fromPSBT(psbt);
4213
+ const request = {
4214
+ kind: "bitcoin.psbt",
4215
+ network: old.network,
4216
+ account,
4217
+ psbt,
4218
+ sign: Array.from({ length: txP.inputsLength }, (_, i) => i)
4219
+ };
4220
+ const ownScripts = new Set(stash.changeAddresses.map((a) => hex.encode(scriptPubKey(a, bitcoinNetwork(stash.network).params))));
4221
+ const intent = decodeBitcoinPsbt(request, ownScripts);
4222
+ let tx = {
4223
+ id: newId(ctx.options.random),
4224
+ family: "bitcoin",
4225
+ network: old.network,
4226
+ account: old.account,
4227
+ hash: void 0,
4228
+ state: "draft",
4229
+ intent,
4230
+ nonce: 0,
4231
+ btc: record,
4232
+ submittedAt: ctx.options.now(),
4233
+ error: void 0,
4234
+ ...link
4235
+ };
4236
+ tx = await ctx.transition(tx, {});
4237
+ try {
4238
+ const result = await apis.sign({
4239
+ request,
4240
+ verify
4241
+ });
4242
+ tx = await ctx.transition(tx, { state: "signed" });
4243
+ if (result.kind !== "bitcoin.psbt" || !(result.signedPsbt instanceof Uint8Array)) throw new Error("unexpected bitcoin sign result");
4244
+ bitcoinUtxos.endTx(old.id, "dropped");
4245
+ const signed = Transaction.fromPSBT(result.signedPsbt);
4246
+ signed.finalize();
4247
+ const raw = signed.extract();
4248
+ const hash = String(await es.post("/tx", hex.encode(raw)));
4249
+ bitcoinUtxos.lock(record.inputs.map((i) => ({
4250
+ ...i,
4251
+ status: "confirmed",
4252
+ height: 0
4253
+ })), tx.id);
4254
+ tx = await ctx.transition(tx, {
4255
+ state: "submitted",
4256
+ hash,
4257
+ submittedAt: ctx.options.now()
4258
+ });
4259
+ ctx.track(tx.id, hash);
4260
+ return tx;
4261
+ } catch (error) {
4262
+ const failed = tx.state === "signed" ? "failed" : "rejected";
4263
+ await ctx.transition(tx, {
4264
+ state: failed,
4265
+ error: errorMessage(error)
4266
+ });
4267
+ throw error;
4268
+ }
4269
+ };
4270
+ const replace = async (ctx, old, mode, verify) => {
4271
+ const stash = pending.get(old.id);
4272
+ const { btc } = old;
4273
+ if (stash === void 0 || btc === void 0) throw new Error("the original build context is gone — cannot replace after restart");
4274
+ const es = ctx.options.networks.esplora(old.network);
4275
+ const apis = ctx.options.chains ?? chains(ctx.options.wallet);
4276
+ const { template } = stash;
4277
+ if (!("xpub" in await apis.account({
4278
+ walletId: old.account,
4279
+ groupIndex: 0,
4280
+ template,
4281
+ network: old.network
4282
+ }))) throw new Error("bitcoin account carries no xpub");
4283
+ const account = {
4284
+ kind: "hd",
4285
+ walletId: old.account,
4286
+ groupIndex: 0,
4287
+ template
4288
+ };
4289
+ const tiers = await feeTiers(es);
4290
+ const tierQuote = tiers.find((t) => t.tier === stash.tier) ?? tiers[1];
4291
+ const rate = tierQuote === void 0 ? 1 : tierQuote.satPerByte;
4292
+ const newFee = Math.max(btc.fee + btc.vsize, Math.ceil(btc.vsize * rate));
4293
+ const [changeAddr] = stash.changeAddresses;
4294
+ if (changeAddr === void 0) throw new Error("no unused change address");
4295
+ const net = bitcoinNetwork(old.network);
4296
+ const inTotal = btc.inputs.reduce((a, i) => a + i.value, 0);
4297
+ const dust = 546;
4298
+ const payee = btc.outputs.filter((o) => !o.own).map((o) => ({
4299
+ address: o.address,
4300
+ amount: BigInt(o.value)
4301
+ }));
4302
+ const usedKeys = new Set(btc.inputs.map((i) => `${i.txid}:${i.vout}`));
4303
+ const spare = stash.allUtxos.filter((u) => !usedKeys.has(`${u.txid}:${u.vout}`));
4304
+ const extraInputs = [];
4305
+ const extraUtxos = [];
4306
+ const feeNeeded = (o) => BigInt(newFee) - (BigInt(inTotal) + extraUtxos.reduce((a, u) => a + BigInt(u.value), 0n) - o.reduce((a, x) => a + x.amount, 0n));
4307
+ let outputs;
4308
+ if (mode === "cancel") outputs = [{
4309
+ address: changeAddr,
4310
+ amount: BigInt(inTotal - newFee)
4311
+ }];
4312
+ else {
4313
+ const shrunk = btc.outputs.find((o) => o.own);
4314
+ const changeAfter = shrunk === void 0 ? 0 : shrunk.value - (newFee - btc.fee);
4315
+ outputs = changeAfter >= dust ? [...payee, {
4316
+ address: changeAddr,
4317
+ amount: BigInt(changeAfter)
4318
+ }] : payee;
4319
+ while (feeNeeded(outputs) > 0n && spare.length > extraUtxos.length) {
4320
+ const next = spare[extraUtxos.length];
4321
+ if (next === void 0) break;
4322
+ extraUtxos.push(next);
4323
+ extraInputs.push(...bitcoin.toInputs([{
4324
+ ...next,
4325
+ status: "confirmed"
4326
+ }], stash.fp, stash.pathOf, stash.pubkeyOf, stash.scriptOf, stash.template));
4327
+ }
4328
+ if (feeNeeded(outputs) > 0n) {
4329
+ const keepChange = [...outputs, {
4330
+ address: changeAddr,
4331
+ amount: BigInt(dust)
4332
+ }];
4333
+ while (feeNeeded(keepChange) > 0n && spare.length > extraUtxos.length) {
4334
+ const next = spare[extraUtxos.length];
4335
+ if (next === void 0) break;
4336
+ extraUtxos.push(next);
4337
+ extraInputs.push(...bitcoin.toInputs([{
4338
+ ...next,
4339
+ status: "confirmed"
4340
+ }], 0, stash.pathOf, stash.pubkeyOf, stash.scriptOf, stash.template));
4341
+ }
4342
+ if (feeNeeded(keepChange) > 0n) throw new Error("insufficient confirmed utxo to fund the replacement fee");
4343
+ const changeValue = inTotal + extraUtxos.reduce((a, u) => a + u.value, 0) - payee.reduce((a, o) => a + Number(o.amount), 0) - newFee;
4344
+ outputs = [...payee, {
4345
+ address: changeAddr,
4346
+ amount: BigInt(changeValue)
4347
+ }];
4348
+ } else {
4349
+ const changeValue = inTotal + extraUtxos.reduce((a, u) => a + u.value, 0) - payee.reduce((a, o) => a + Number(o.amount), 0) - newFee;
4350
+ if (changeValue >= dust) outputs = [...payee, {
4351
+ address: changeAddr,
4352
+ amount: BigInt(changeValue)
4353
+ }];
4354
+ }
4355
+ }
4356
+ const allInputs = [...stash.inputs, ...extraInputs];
4357
+ const { psbt, vsize } = bitcoin.buildExactPsbt(allInputs, outputs, net.params);
4358
+ const record = {
4359
+ inputs: [...btc.inputs, ...extraUtxos.map((u) => ({
4360
+ txid: u.txid,
4361
+ vout: u.vout,
4362
+ value: u.value,
4363
+ address: u.address
4364
+ }))],
4365
+ outputs: outputs.map((o) => ({
4366
+ address: o.address,
4367
+ value: Number(o.amount),
4368
+ own: o.address === changeAddr
4369
+ })),
4370
+ vsize,
4371
+ fee: newFee,
4372
+ feeRate: Math.ceil(newFee / vsize)
4373
+ };
4374
+ return finish(ctx, stash, es, account, old, psbt, record, { replaces: old.id }, verify);
4375
+ };
4376
+ const bumpChild = async (ctx, old, targetRate, verify) => {
4377
+ const stash = pending.get(old.id);
4378
+ const { btc } = old;
4379
+ if (stash === void 0 || btc === void 0) throw new Error("the parent build context is gone — cannot bump after restart");
4380
+ const es = ctx.options.networks.esplora(old.network);
4381
+ const { template } = stash;
4382
+ const account = {
4383
+ kind: "hd",
4384
+ walletId: old.account,
4385
+ groupIndex: 0,
4386
+ template
4387
+ };
4388
+ const own = btc.outputs.flatMap((o, vout) => o.own ? [{
4389
+ ...o,
4390
+ vout
4391
+ }] : []);
4392
+ if (own.length === 0) throw new Error("parent has no own outputs to bump");
4393
+ const childInputs = bitcoin.toInputs(own.map((o) => ({
4394
+ txid: old.hash ?? "",
4395
+ vout: o.vout,
4396
+ value: o.value,
4397
+ address: o.address,
4398
+ status: "confirmed",
4399
+ height: 0
4400
+ })), stash.fp, stash.pathOf, stash.pubkeyOf, stash.scriptOf, stash.template);
4401
+ const [changeAddr] = stash.changeAddresses;
4402
+ if (changeAddr === void 0) throw new Error("no unused change address");
4403
+ const net = bitcoinNetwork(old.network);
4404
+ const inTotal = own.reduce((a, o) => a + o.value, 0);
4405
+ const probe = bitcoin.buildExactPsbt(childInputs, [{
4406
+ address: changeAddr,
4407
+ amount: BigInt(inTotal)
4408
+ }], net.params);
4409
+ const childFee = Math.max(Math.ceil(targetRate * (btc.vsize + probe.vsize)) - btc.fee, probe.vsize);
4410
+ const { psbt, vsize } = bitcoin.buildExactPsbt(childInputs, [{
4411
+ address: changeAddr,
4412
+ amount: BigInt(inTotal - childFee)
4413
+ }], net.params);
4414
+ const record = {
4415
+ inputs: own.map((o, i) => ({
4416
+ txid: old.hash ?? "",
4417
+ vout: i,
4418
+ value: o.value,
4419
+ address: o.address
4420
+ })),
4421
+ outputs: [{
4422
+ address: changeAddr,
4423
+ value: inTotal - childFee,
4424
+ own: true
4425
+ }],
4426
+ vsize,
4427
+ fee: childFee,
4428
+ feeRate: Math.ceil(childFee / vsize)
4429
+ };
4430
+ return finish(ctx, stash, es, account, old, psbt, record, { parentId: old.id }, verify);
4431
+ };
4432
+ return {
4433
+ send,
4434
+ poll,
4435
+ replace,
4436
+ bumpChild
4437
+ };
4438
+ };
4439
+ //#endregion
4440
+ //#region src/transactions/evm.ts
4441
+ /** EVM route: native/ERC-20 send build → fee → decode → sign → broadcast, plus status poll. */
4442
+ const bump = (v) => (v * 11n + 9n) / 10n;
4443
+ /** Per-controller route — the adapter's nonce queue and the replace stash are engine state. */
4444
+ const createEvmRoute = () => {
4445
+ const evm = new EvmChainAdapter();
4446
+ /** In-memory build context keyed by tx id — the inputs a replace rebuilds from. */
4447
+ const pending = /* @__PURE__ */ new Map();
4448
+ const send = async (ctx, params) => {
4449
+ const { account, network, to, amount, fee, verify } = params;
4450
+ const { networks } = ctx.options;
4451
+ if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
4452
+ const info = networks.info(network);
4453
+ const client = networks.client(network);
4454
+ const apis = ctx.options.chains ?? chains(ctx.options.wallet);
4455
+ const evmAccount = await apis.account({
4456
+ walletId: account.walletId,
4457
+ groupIndex: account.groupIndex,
4458
+ template: "evm.bip44",
4459
+ network
4460
+ });
4461
+ const from = "address" in evmAccount ? evmAccount.address : "";
4462
+ const unsigned = await evm.build(client, network, from, params.token === void 0 ? {
4463
+ kind: "native",
4464
+ to,
4465
+ value: amount
4466
+ } : {
4467
+ kind: "erc20",
4468
+ token: params.token,
4469
+ to,
4470
+ value: amount
4471
+ });
4472
+ const quotes = await evm.estimateFee(client);
4473
+ const quote = quotes.find((q) => q.tier === fee) ?? quotes[1];
4474
+ if (quote === void 0) throw new Error("no fee quote");
4475
+ const feed = evm.applyFee(unsigned, quote);
4476
+ const request = evm.toSignRequest(feed, account, info.chainId);
4477
+ const intent = decodeEvm(request);
4478
+ const id = newId(ctx.options.random);
4479
+ pending.set(id, {
4480
+ unsigned: feed,
4481
+ request,
4482
+ quote,
4483
+ chainId: info.chainId
4484
+ });
4485
+ let tx = {
4486
+ id,
4487
+ family: "evm",
4488
+ network,
4489
+ account: account.walletId,
4490
+ hash: void 0,
4491
+ state: "draft",
4492
+ intent,
4493
+ nonce: unsigned.nonce,
4494
+ submittedAt: ctx.options.now(),
4495
+ error: void 0
4496
+ };
4497
+ tx = await ctx.transition(tx, {});
4498
+ let signed;
4499
+ try {
4500
+ const result = await apis.sign({
4501
+ request,
4502
+ verify
4503
+ });
4504
+ tx = await ctx.transition(tx, { state: "signed" });
4505
+ signed = result;
4506
+ } catch (error) {
4507
+ await ctx.transition(tx, {
4508
+ state: "rejected",
4509
+ error: errorMessage(error)
4510
+ });
4511
+ throw error;
4512
+ }
4513
+ try {
4514
+ const raw = evm.finalize(feed, signed);
4515
+ const hash = await evm.broadcast(client, raw);
4516
+ tx = await ctx.transition(tx, {
4517
+ state: "submitted",
4518
+ hash,
4519
+ submittedAt: ctx.options.now()
4520
+ });
4521
+ ctx.track(tx.id, hash);
4522
+ return tx;
4523
+ } catch (error) {
4524
+ await ctx.transition(tx, {
4525
+ state: "failed",
4526
+ error: errorMessage(error)
4527
+ });
4528
+ throw error;
4529
+ }
4530
+ };
4531
+ /**
4532
+ * EVM speed-up/cancel — replaces a pending tx at the same nonce with a bumped fee (cancel = a
4533
+ * 0-value transfer to self). The new tx carries `replaces` and needs a fresh signature; a
4534
+ * rejected one never reaches the network and leaves the original.
4535
+ */
4536
+ const replace = async (ctx, old, mode, verify) => {
4537
+ const stash = pending.get(old.id);
4538
+ if (stash === void 0) throw new Error("the original build context is gone — cannot replace after restart");
4539
+ const { networks } = ctx.options;
4540
+ const client = networks.client(old.network);
4541
+ const apis = ctx.options.chains ?? chains(ctx.options.wallet);
4542
+ const { tier } = stash.quote;
4543
+ const estimates = await evm.estimateFee(client);
4544
+ const estimate = estimates.find((q) => q.tier === tier) ?? estimates[1];
4545
+ if (estimate === void 0) throw new Error("no fee quote");
4546
+ const oldFee = stash.unsigned;
4547
+ const bumpedFee = bump(oldFee.maxFeePerGas ?? oldFee.gasPrice ?? 0n);
4548
+ const maxFeePerGas = bumpedFee > estimate.maxFeePerGas ? bumpedFee : estimate.maxFeePerGas;
4549
+ const priorityBump = oldFee.maxPriorityFeePerGas === void 0 && estimate.maxPriorityFeePerGas === void 0 ? void 0 : bump(oldFee.maxPriorityFeePerGas ?? 0n) > (estimate.maxPriorityFeePerGas ?? 0n) ? bump(oldFee.maxPriorityFeePerGas ?? 0n) : estimate.maxPriorityFeePerGas ?? 0n;
4550
+ const feeFields = priorityBump === void 0 ? { maxFeePerGas } : {
4551
+ maxFeePerGas,
4552
+ maxPriorityFeePerGas: priorityBump
4553
+ };
4554
+ const unsigned = mode === "cancel" ? {
4555
+ ...stash.unsigned,
4556
+ to: stash.unsigned.from,
4557
+ value: 0n,
4558
+ data: "0x",
4559
+ gas: 21000n,
4560
+ ...feeFields
4561
+ } : {
4562
+ ...stash.unsigned,
4563
+ ...feeFields
4564
+ };
4565
+ const request = evm.toSignRequest(unsigned, stash.request.account, stash.chainId);
4566
+ const intent = decodeEvm(request);
4567
+ const id = newId(ctx.options.random);
4568
+ pending.set(id, {
4569
+ unsigned,
4570
+ request,
4571
+ quote: stash.quote,
4572
+ chainId: stash.chainId
4573
+ });
4574
+ let tx = {
4575
+ id,
4576
+ family: "evm",
4577
+ network: old.network,
4578
+ account: old.account,
4579
+ hash: void 0,
4580
+ state: "draft",
4581
+ intent,
4582
+ nonce: old.nonce,
4583
+ replaces: old.id,
4584
+ submittedAt: ctx.options.now(),
4585
+ error: void 0
4586
+ };
4587
+ tx = await ctx.transition(tx, {});
4588
+ let signed;
4589
+ try {
4590
+ const result = await apis.sign({
4591
+ request,
4592
+ verify
4593
+ });
4594
+ tx = await ctx.transition(tx, { state: "signed" });
4595
+ signed = result;
4596
+ } catch (error) {
4597
+ await ctx.transition(tx, {
4598
+ state: "rejected",
4599
+ error: errorMessage(error)
4600
+ });
4601
+ throw error;
4602
+ }
4603
+ try {
4604
+ const raw = evm.finalize(unsigned, signed);
4605
+ const hash = await evm.broadcast(client, raw);
4606
+ tx = await ctx.transition(tx, {
4607
+ state: "submitted",
4608
+ hash,
4609
+ submittedAt: ctx.options.now()
4610
+ });
4611
+ ctx.track(tx.id, hash);
4612
+ return tx;
4613
+ } catch (error) {
4614
+ await ctx.transition(tx, {
4615
+ state: "failed",
4616
+ error: errorMessage(error)
4617
+ });
4618
+ throw error;
4619
+ }
4620
+ };
4621
+ /** Returns the terminal state once the receipt lands, or `undefined` to keep polling. */
4622
+ const poll = async (ctx, tx) => {
4623
+ if (tx.hash === void 0) return "failed";
4624
+ const state = await evm.status(ctx.options.networks.client(tx.network), tx.hash);
4625
+ if (state === "pending") return;
4626
+ pending.delete(tx.id);
4627
+ return state === "finalized" ? "confirmed" : state;
4628
+ };
4629
+ return {
4630
+ send,
4631
+ poll,
4632
+ replace
4633
+ };
4634
+ };
4635
+ PROGRAM_IDS.token2022;
4636
+ const MAX_MESSAGE_BYTES = 1232;
4637
+ const CU_SIMULATE_LIMIT = 14e5;
4638
+ const b64 = (bytes) => base64.encode(bytes);
4639
+ const isObject$2 = (v) => typeof v === "object" && v !== null;
4640
+ const obj$1 = (v) => isObject$2(v) ? v : void 0;
4641
+ const str = (v, fallback = "0") => {
4642
+ if (typeof v === "string") return v;
4643
+ return typeof v === "number" || typeof v === "bigint" ? String(v) : fallback;
4644
+ };
4645
+ var SolanaChainAdapter = class {
4646
+ async rpc(rpc, method, params) {
4647
+ return rpc.call(method, params);
4648
+ }
4649
+ async instructions(rpc, from, intent) {
4650
+ if (intent.kind === "sol") return [getTransferSolInstruction({
4651
+ source: createNoopSigner(address(from)),
4652
+ destination: address(intent.to),
4653
+ amount: intent.lamports
4654
+ })];
4655
+ const tokenProgram = intent.tokenProgram === void 0 ? TOKEN_PROGRAM_ADDRESS : address(intent.tokenProgram);
4656
+ const [sourceAta] = await findAssociatedTokenPda({
4657
+ mint: address(intent.mint),
4658
+ owner: address(from),
4659
+ tokenProgram
4660
+ });
4661
+ const [destAta] = await findAssociatedTokenPda({
4662
+ mint: address(intent.mint),
4663
+ owner: address(intent.to),
4664
+ tokenProgram
4665
+ });
4666
+ const ixs = [];
4667
+ const destInfo = await this.rpc(rpc, "getAccountInfo", [destAta, {
4668
+ encoding: "base64",
4669
+ commitment: "confirmed"
4670
+ }]);
4671
+ if (obj$1(obj$1(destInfo)?.["value"]) === void 0) ixs.push(getCreateAssociatedTokenIdempotentInstruction({
4672
+ ata: destAta,
4673
+ mint: address(intent.mint),
4674
+ owner: address(intent.to),
4675
+ payer: createNoopSigner(address(from)),
4676
+ tokenProgram
4677
+ }));
4678
+ ixs.push(getTransferCheckedInstruction({
4679
+ source: sourceAta,
4680
+ mint: address(intent.mint),
4681
+ destination: destAta,
4682
+ authority: address(from),
4683
+ amount: intent.amount,
4684
+ decimals: intent.decimals
4685
+ }, { programAddress: tokenProgram }));
4686
+ return ixs;
4687
+ }
4688
+ /** Instruction set, simulated compute units, and a fresh blockhash — fee fields come later. */
4689
+ async build(rpc, network, from, intent) {
4690
+ const instructions = await this.instructions(rpc, from, intent);
4691
+ const hashResult = obj$1(await this.rpc(rpc, "getLatestBlockhash", [{ commitment: "confirmed" }]));
4692
+ const value = obj$1(hashResult?.["value"]);
4693
+ const blockhashValue = typeof value?.["blockhash"] === "string" ? value["blockhash"] : "";
4694
+ const lastValidBlockHeight = BigInt(str(value?.["lastValidBlockHeight"], "0"));
4695
+ const compiled = this.compile(from, instructions, blockhashValue, lastValidBlockHeight);
4696
+ const units = await this.simulate(rpc, compiled.messageBytes);
4697
+ return {
4698
+ family: "solana",
4699
+ network,
4700
+ from,
4701
+ instructions,
4702
+ writable: this.writableAccounts(instructions),
4703
+ unitsConsumed: units,
4704
+ blockhash: blockhashValue,
4705
+ lastValidBlockHeight
4706
+ };
4707
+ }
4708
+ writableAccounts(instructions) {
4709
+ const out = /* @__PURE__ */ new Set();
4710
+ for (const ix of instructions) {
4711
+ const accounts = obj$1(ix)?.["accounts"];
4712
+ if (!Array.isArray(accounts)) continue;
4713
+ for (const a of accounts) {
4714
+ const record = obj$1(a);
4715
+ const role = Number(record?.["role"] ?? 0);
4716
+ if (typeof record?.["address"] === "string" && role >= 2) out.add(record["address"]);
4717
+ }
4718
+ }
4719
+ return [...out];
4720
+ }
4721
+ /** Unsigned wire bytes: shortvec(1) sig count + 64-byte zero sig + message (for simulate). */
4722
+ wireBytes(messageBytes) {
4723
+ const out = new Uint8Array(65 + messageBytes.length);
4724
+ out[0] = 1;
4725
+ out.set(messageBytes, 65);
4726
+ return out;
4727
+ }
4728
+ async simulate(rpc, messageBytes) {
4729
+ const result = obj$1(await this.rpc(rpc, "simulateTransaction", [b64(this.wireBytes(messageBytes)), {
4730
+ encoding: "base64",
4731
+ sigVerify: false,
4732
+ replaceRecentBlockhash: false
4733
+ }]));
4734
+ const value = obj$1(result?.["value"]);
4735
+ const units = Number(value?.["unitsConsumed"] ?? 0);
4736
+ return units > 0 ? units : CU_SIMULATE_LIMIT;
4737
+ }
4738
+ compile(from, instructions, blockhashValue, lastValidBlockHeight) {
4739
+ const message = createTransactionMessage({ version: 0 });
4740
+ const withPayer = setTransactionMessageFeePayer(address(from), message);
4741
+ const withLifetime = setTransactionMessageLifetimeUsingBlockhash({
4742
+ blockhash: blockhash(blockhashValue),
4743
+ lastValidBlockHeight
4744
+ }, withPayer);
4745
+ const withIxs = appendTransactionMessageInstructions([...instructions], withLifetime);
4746
+ const compiled = compileTransactionMessage(withIxs);
4747
+ const messageBytes = new Uint8Array(getCompiledTransactionMessageEncoder().encode(compiled));
4748
+ if (messageBytes.length > MAX_MESSAGE_BYTES) throw new Error(`compiled message is ${messageBytes.length} bytes (max ${MAX_MESSAGE_BYTES})`);
4749
+ return {
4750
+ messageBytes,
4751
+ instructions
4752
+ };
4753
+ }
4754
+ /** Percentile-tiered prioritization fees over the writable accounts of this transaction. */
4755
+ async estimateFee(rpc, writable) {
4756
+ const result = await this.rpc(rpc, "getRecentPrioritizationFees", [[...writable]]);
4757
+ const fees = (Array.isArray(result) ? result : []).flatMap((r) => {
4758
+ const fee = obj$1(r)?.["prioritizationFee"];
4759
+ return fee === void 0 ? [] : [Number(fee)];
4760
+ }).sort((a, b) => a - b);
4761
+ const pick = (p) => BigInt(fees[Math.min(fees.length - 1, Math.floor(fees.length * p / 100))] ?? 0);
4762
+ return [
4763
+ {
4764
+ tier: "slow",
4765
+ microLamportsPerCu: pick(50)
4766
+ },
4767
+ {
4768
+ tier: "normal",
4769
+ microLamportsPerCu: pick(75)
4770
+ },
4771
+ {
4772
+ tier: "fast",
4773
+ microLamportsPerCu: pick(90)
4774
+ }
4775
+ ];
4776
+ }
4777
+ /** Prepend CU-limit + CU-price budget instructions; returns message bytes for the sign request. */
4778
+ applyFee(tx, fee) {
4779
+ const units = Math.ceil((tx.unitsConsumed === 0 ? CU_SIMULATE_LIMIT : tx.unitsConsumed) * 1.1);
4780
+ return this.compile(tx.from, [
4781
+ getSetComputeUnitLimitInstruction({ units }),
4782
+ getSetComputeUnitPriceInstruction({ microLamports: fee.microLamportsPerCu }),
4783
+ ...tx.instructions
4784
+ ], tx.blockhash, tx.lastValidBlockHeight).messageBytes;
4785
+ }
4786
+ /** Wire transaction bytes — sigCount shortvec + each 64-byte sig + message. */
4787
+ finalize(messageBytes, signatures) {
4788
+ for (const sig of signatures) if (sig.length !== 64) throw new Error(`solana signature must be 64 bytes, got ${sig.length}`);
4789
+ const out = new Uint8Array(1 + 64 * signatures.length + messageBytes.length);
4790
+ out[0] = signatures.length;
4791
+ for (const [i, sig] of signatures.entries()) out.set(sig, 1 + 64 * i);
4792
+ out.set(messageBytes, 1 + 64 * signatures.length);
4793
+ return out;
4794
+ }
4795
+ async broadcast(rpc, wireBytes) {
4796
+ const result = await this.rpc(rpc, "sendTransaction", [b64(wireBytes), {
4797
+ encoding: "base64",
4798
+ skipPreflight: false,
4799
+ maxRetries: 0,
4800
+ preflightCommitment: "confirmed"
4801
+ }]);
4802
+ if (typeof result !== "string") throw new TypeError("sendTransaction returned a malformed signature");
4803
+ return result;
4804
+ }
4805
+ /** Processed → confirmed → finalized; an error on the status means the tx failed on-chain. */
4806
+ async status(rpc, signature) {
4807
+ const result = obj$1(await this.rpc(rpc, "getSignatureStatuses", [[signature], { searchTransactionHistory: true }]));
4808
+ const entry = Array.isArray(result?.["value"]) ? obj$1(result["value"][0]) : void 0;
4809
+ if (entry === void 0) return "pending";
4810
+ if (entry["err"] !== null && entry["err"] !== void 0) return "failed";
4811
+ const c = entry["confirmationStatus"];
4812
+ return c === "finalized" ? "finalized" : c === "confirmed" ? "confirmed" : "pending";
4813
+ }
4814
+ async blockHeight(rpc) {
4815
+ const result = await this.rpc(rpc, "getBlockHeight", [{ commitment: "confirmed" }]);
4816
+ return BigInt(str(result, "0"));
4817
+ }
4818
+ };
4819
+ //#endregion
4820
+ //#region src/transactions/solana.ts
4821
+ /** Solana route: SOL/SPL send → sign → broadcast, plus status poll with rebroadcast/drop. */
4822
+ const isObject$1 = (v) => typeof v === "object" && v !== null;
4823
+ const obj = (v) => isObject$1(v) ? v : void 0;
4824
+ const arr = (v) => Array.isArray(v) ? v : void 0;
4825
+ const solana = new SolanaChainAdapter();
4826
+ /** Signed wire transactions kept for re-broadcast until the tx confirms or drops. */
4827
+ const wires = /* @__PURE__ */ new Map();
4828
+ const sendSolana = async (ctx, params) => {
4829
+ const { account, network, to, amount, fee, verify } = params;
4830
+ if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
4831
+ const rpc = ctx.options.networks.solanaRpc(network);
4832
+ const apis = ctx.options.chains ?? chains(ctx.options.wallet);
4833
+ const solAccount = await apis.account({
4834
+ walletId: account.walletId,
4835
+ groupIndex: account.groupIndex,
4836
+ template: "solana.bip44-change",
4837
+ network
4838
+ });
4839
+ const from = "address" in solAccount ? solAccount.address : "";
4840
+ let tokenProgram;
4841
+ let ext;
4842
+ if (params.token !== void 0) {
4843
+ const mintInfo = await rpc.call("getAccountInfo", [params.token, {
4844
+ encoding: "base64",
4845
+ commitment: "confirmed"
4846
+ }]);
4847
+ const owner = obj(mintInfo)?.["value"];
4848
+ if (obj(owner)?.["owner"] === PROGRAM_IDS.token2022) {
4849
+ tokenProgram = PROGRAM_IDS.token2022;
4850
+ const mintBytes = arr(obj(owner)?.["data"])?.[0];
4851
+ const raw = typeof mintBytes === "string" ? base64.decode(mintBytes) : void 0;
4852
+ if (raw !== void 0 && raw.length > 166) {
4853
+ const epoch = obj(await rpc.call("getEpochInfo", []))?.["epoch"];
4854
+ ext = parseMintExtensions(raw, BigInt(typeof epoch === "number" ? epoch : 0));
4855
+ if (ext.nonTransferable === true) throw new Error("token is non-transferable");
4856
+ if (ext.confidential === true) throw new Error("confidential transfer extensions are not supported");
4857
+ }
4858
+ }
4859
+ }
4860
+ const unsigned = await solana.build(rpc, network, from, params.token === void 0 ? {
4861
+ kind: "sol",
4862
+ to,
4863
+ lamports: amount
4864
+ } : {
4865
+ kind: "spl",
4866
+ mint: params.token,
4867
+ to,
4868
+ amount,
4869
+ decimals: params.decimals ?? 0,
4870
+ ...tokenProgram === void 0 ? {} : { tokenProgram }
4871
+ });
4872
+ const quotes = await solana.estimateFee(rpc, unsigned.writable);
4873
+ const quote = quotes.find((q) => q.tier === fee) ?? quotes[1];
4874
+ if (quote === void 0) throw new Error("no fee quote");
4875
+ const messageBytes = solana.applyFee(unsigned, quote);
4876
+ const request = {
4877
+ kind: "solana.transaction",
4878
+ network,
4879
+ account,
4880
+ message: messageBytes
4881
+ };
4882
+ const intent = ext === void 0 || params.token === void 0 ? decodeSolana(request, from) : token2022Intent(decodeSolana(request, from), ext, params.token, amount);
4883
+ const id = newId(ctx.options.random);
4884
+ let tx = {
4885
+ id,
4886
+ family: "solana",
4887
+ network,
4888
+ account: account.walletId,
4889
+ hash: void 0,
4890
+ state: "draft",
4891
+ intent,
4892
+ nonce: 0,
4893
+ lastValidBlockHeight: Number(unsigned.lastValidBlockHeight),
4894
+ submittedAt: ctx.options.now(),
4895
+ error: void 0
4896
+ };
4897
+ tx = await ctx.transition(tx, {});
4898
+ try {
4899
+ const result = await apis.sign({
4900
+ request,
4901
+ verify
4902
+ });
4903
+ tx = await ctx.transition(tx, { state: "signed" });
4904
+ if (result.kind !== "solana.transaction" || !(result.signature instanceof Uint8Array)) throw new Error("unexpected solana sign result");
4905
+ const wire = solana.finalize(messageBytes, [result.signature]);
4906
+ const hash = await solana.broadcast(rpc, wire);
4907
+ wires.set(id, wire);
4908
+ tx = await ctx.transition(tx, {
4909
+ state: "submitted",
4910
+ hash,
4911
+ submittedAt: ctx.options.now()
4912
+ });
4913
+ ctx.track(tx.id, hash);
4914
+ return tx;
4915
+ } catch (error) {
4916
+ const failed = tx.state === "signed" ? "failed" : "rejected";
4917
+ await ctx.transition(tx, {
4918
+ state: failed,
4919
+ error: errorMessage(error)
4920
+ });
4921
+ throw error;
4922
+ }
4923
+ };
4924
+ /** Returns the terminal state, `dropped` on blockhash expiry, or `undefined` to keep polling. */
4925
+ const pollSolana = async (ctx, tx) => {
4926
+ if (tx.hash === void 0) return "failed";
4927
+ const rpc = ctx.options.networks.solanaRpc(tx.network);
4928
+ const state = await solana.status(rpc, tx.hash);
4929
+ if (state !== "pending") {
4930
+ wires.delete(tx.id);
4931
+ return state === "finalized" ? "confirmed" : state;
4932
+ }
4933
+ if (tx.lastValidBlockHeight !== void 0 && await solana.blockHeight(rpc) > BigInt(tx.lastValidBlockHeight)) {
4934
+ wires.delete(tx.id);
4935
+ return "dropped";
4936
+ }
4937
+ const wire = wires.get(tx.id);
4938
+ if (wire !== void 0) await solana.broadcast(rpc, wire).catch(() => "");
4939
+ };
4940
+ /**
4941
+ * Folds Token-2022 mint extensions into the decoded intent: a TransferFee mint shows the
4942
+ * recipient's net amount (amount − min(amount×bps/10000, maximum)); a TransferHook or
4943
+ * PermanentDelegate marks the intent `partial` with a warning. The mint's extension data is not
4944
+ * part of the message, so the route attaches what it fetched — decode alone cannot see it.
4945
+ */
4946
+ const token2022Intent = (intent, ext, mint, amount) => {
4947
+ const warnings = [...intent.warnings];
4948
+ let { outflows } = intent;
4949
+ if (ext.transferFee !== void 0) {
4950
+ const net = netAfterTransferFee(amount, ext.transferFee.basisPoints, ext.transferFee.maximumFee);
4951
+ const withheld = amount - net;
4952
+ warnings.push({
4953
+ kind: "token-2022-transfer-fee",
4954
+ message: `transfer fee withheld ${withheld.toString(10)} base units`
4955
+ });
4956
+ outflows = outflows.map((o) => o.amount.asset === mint ? {
4957
+ ...o,
4958
+ amount: {
4959
+ ...o.amount,
4960
+ amount: net.toString(10)
4961
+ }
4962
+ } : o);
4963
+ }
4964
+ if (ext.transferHook !== void 0 || ext.permanentDelegate !== void 0) {
4965
+ warnings.push({
4966
+ kind: "token-2022-extension",
4967
+ message: "mint carries a transfer-hook or permanent-delegate extension"
4968
+ });
4969
+ return {
4970
+ ...intent,
4971
+ outflows,
4972
+ warnings,
4973
+ verifiability: "partial"
4974
+ };
4975
+ }
4976
+ return {
4977
+ ...intent,
4978
+ outflows,
4979
+ warnings
4980
+ };
4981
+ };
4982
+ //#endregion
4983
+ //#region src/transactions/controller.ts
4984
+ /**
4985
+ * Transaction controller: orchestrates lifecycle, persistence and polling; each chain family routes
4986
+ * through its own module (`evm.ts`, `solana.ts`, `bitcoin.ts`). A rejected signature (wrong
4987
+ * password, cancelled presence) never broadcasts.
4988
+ */
4989
+ const encode = (tx) => new TextEncoder().encode(JSON.stringify(tx));
4990
+ const isObject = (v) => typeof v === "object" && v !== null;
4991
+ const TX_STATES = /* @__PURE__ */ new Set([
4992
+ "draft",
4993
+ "signed",
4994
+ "submitted",
4995
+ "pending",
4996
+ "confirmed",
4997
+ "failed",
4998
+ "rejected",
4999
+ "dropped",
5000
+ "replaced"
5001
+ ]);
5002
+ const isTxState = (v) => typeof v === "string" && TX_STATES.has(v);
5003
+ const isBtcRecord = (v) => {
5004
+ if (!isObject(v)) return false;
5005
+ const { inputs, outputs, vsize, fee, feeRate } = v;
5006
+ return Array.isArray(inputs) && Array.isArray(outputs) && typeof vsize === "number" && typeof fee === "number" && typeof feeRate === "number" && inputs.every((i) => isObject(i) && typeof i["txid"] === "string" && typeof i["vout"] === "number" && typeof i["value"] === "number" && typeof i["address"] === "string") && outputs.every((o) => isObject(o) && typeof o["address"] === "string" && typeof o["value"] === "number" && typeof o["own"] === "boolean");
5007
+ };
5008
+ const isIntent = (v) => isObject(v) && typeof v["summary"] === "string" && Array.isArray(v["outflows"]) && typeof v["verifiability"] === "string";
5009
+ const decode = (raw) => {
5010
+ const parsed = JSON.parse(new TextDecoder().decode(raw));
5011
+ if (!isObject(parsed)) return;
5012
+ const { id, family, network, account, hash, state, intent, nonce, replaces, parentId, btc, submittedAt, error } = parsed;
5013
+ if (typeof id !== "string" || family !== "evm" && family !== "solana" && family !== "bitcoin" || typeof network !== "string" || typeof account !== "string" || !isTxState(state) || typeof nonce !== "number" || typeof submittedAt !== "number") return;
5014
+ return {
5015
+ id,
5016
+ family,
5017
+ network,
5018
+ account,
5019
+ hash: typeof hash === "string" ? hash : void 0,
5020
+ state,
5021
+ intent: isIntent(intent) ? intent : void 0,
5022
+ nonce,
5023
+ ...typeof replaces === "string" ? { replaces } : {},
5024
+ ...typeof parentId === "string" ? { parentId } : {},
5025
+ ...isBtcRecord(btc) ? { btc } : {},
5026
+ submittedAt,
5027
+ error: typeof error === "string" ? error : void 0
5028
+ };
5029
+ };
5030
+ var TransactionController = class extends Controller {
5031
+ options;
5032
+ engineStore;
5033
+ timers = /* @__PURE__ */ new Map();
5034
+ evmRoute = createEvmRoute();
5035
+ bitcoinRoute = createBitcoinRoute();
5036
+ locked = true;
5037
+ constructor(options) {
5038
+ super({
5039
+ name: "transactions",
5040
+ defaultState: () => ({ txs: {} })
5041
+ });
5042
+ this.options = options;
5043
+ }
5044
+ async init() {
5045
+ this.locked = false;
5046
+ this.engineStore = await this.options.openStore();
5047
+ const collection = this.engineStore.collection("transactions");
5048
+ const keys = await collection.list();
5049
+ const rows = await Promise.all(keys.map(async (key) => collection.get(key)));
5050
+ for (const raw of rows) {
5051
+ const tx = raw === void 0 ? void 0 : decode(raw);
5052
+ if (tx === void 0) continue;
5053
+ this.update((s) => ({
5054
+ ...s,
5055
+ txs: {
5056
+ ...s.txs,
5057
+ [tx.id]: tx
5058
+ }
5059
+ }));
5060
+ if ((tx.state === "submitted" || tx.state === "pending") && tx.hash !== void 0) this.track(tx.id, tx.hash);
5061
+ }
5062
+ }
5063
+ async destroy() {
5064
+ this.lock();
5065
+ await Promise.resolve();
5066
+ }
5067
+ lock() {
5068
+ this.locked = true;
5069
+ for (const stop of this.timers.values()) stop();
5070
+ this.timers.clear();
5071
+ this.engineStore = void 0;
5072
+ this.evmRoute = createEvmRoute();
5073
+ }
5074
+ async persist(tx) {
5075
+ const store = this.engineStore;
5076
+ if (store !== void 0) await store.collection("transactions").put(tx.id, encode(tx));
5077
+ }
5078
+ async transition(tx, next) {
5079
+ const updated = {
5080
+ ...tx,
5081
+ ...next
5082
+ };
5083
+ this.update((s) => ({
5084
+ ...s,
5085
+ txs: {
5086
+ ...s.txs,
5087
+ [tx.id]: updated
5088
+ }
5089
+ }));
5090
+ await this.persist(updated);
5091
+ return updated;
5092
+ }
5093
+ routeCtx() {
5094
+ return {
5095
+ options: this.options,
5096
+ transition: async (tx, next) => this.transition(tx, next),
5097
+ track: (id, hash) => this.track(id, hash),
5098
+ newId: () => newId(this.options.random)
5099
+ };
5100
+ }
5101
+ async send(params) {
5102
+ if (this.locked) throw new Error("transactions require an unlocked vault");
5103
+ const { account, network } = params;
5104
+ const info = this.options.networks.info(network);
5105
+ if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
5106
+ const ctx = this.routeCtx();
5107
+ if (info.family === "solana") return sendSolana(ctx, params);
5108
+ if (info.family === "bitcoin") return this.bitcoinRoute.send(ctx, params);
5109
+ return this.evmRoute.send(ctx, params);
5110
+ }
5111
+ /**
5112
+ * Speed up or cancel a pending/submitted EVM transaction at the same nonce — a fresh signature is
5113
+ * required and a rejection leaves the original untouched.
5114
+ */
5115
+ async replace(id, mode, verify) {
5116
+ if (this.locked) throw new Error("transactions require an unlocked vault");
5117
+ const old = this.getState().txs[id];
5118
+ if (old === void 0) throw new Error(`unknown transaction ${id}`);
5119
+ if (old.family === "bitcoin") return this.bitcoinRoute.replace(this.routeCtx(), old, mode, verify);
5120
+ if (old.family !== "evm") throw new Error("replace is EVM-only in this layer");
5121
+ if (old.state !== "pending" && old.state !== "submitted") throw new Error(`cannot replace a ${old.state} transaction`);
5122
+ return this.evmRoute.replace(this.routeCtx(), old, mode, verify);
5123
+ }
5124
+ /**
5125
+ * CPFP — spend an unconfirmed tx's own outputs into a higher-fee child so the package mines.
5126
+ * Bitcoin only; the child is linked via `parentId`.
5127
+ */
5128
+ async bumpChild(id, targetRate, verify) {
5129
+ if (this.locked) throw new Error("transactions require an unlocked vault");
5130
+ const old = this.getState().txs[id];
5131
+ if (old === void 0) throw new Error(`unknown transaction ${id}`);
5132
+ if (old.family !== "bitcoin") throw new Error("bumpChild is Bitcoin-only in this layer");
5133
+ if (old.state !== "pending" && old.state !== "submitted") throw new Error(`cannot bump a ${old.state} transaction`);
5134
+ return this.bitcoinRoute.bumpChild(this.routeCtx(), old, targetRate, verify);
5135
+ }
5136
+ track(id, hash) {
5137
+ if (this.timers.has(id) || hash === "") return;
5138
+ const poll = this.options.pollMs ?? 4e3;
5139
+ const stop = this.options.setTimer(poll, async () => {
5140
+ if (this.locked) return;
5141
+ try {
5142
+ const tx = this.getState().txs[id];
5143
+ if (tx === void 0) return;
5144
+ const ctx = this.routeCtx();
5145
+ const next = tx.family === "solana" ? await pollSolana(ctx, tx) : tx.family === "bitcoin" ? await this.bitcoinRoute.poll(ctx, tx) : await this.evmRoute.poll(ctx, tx);
5146
+ if (next === void 0) return;
5147
+ const updated = await this.transition(tx, { state: next });
5148
+ this.timers.delete(id);
5149
+ stop();
5150
+ const siblings = Object.values(this.getState().txs).filter((t) => t.id !== updated.id && t.hash !== void 0 && t.state !== "replaced" && (updated.replaces !== void 0 && t.id === updated.replaces || t.replaces !== void 0 && t.replaces === updated.id) && (t.state === "submitted" || t.state === "pending"));
5151
+ await Promise.all(siblings.map(async (sibling) => {
5152
+ await this.transition(sibling, { state: "replaced" });
5153
+ const siblingStop = this.timers.get(sibling.id);
5154
+ this.timers.delete(sibling.id);
5155
+ siblingStop?.();
5156
+ }));
5157
+ } catch (error) {
5158
+ this.options.logger.warn("tx status poll failed", error);
5159
+ }
5160
+ });
5161
+ this.timers.set(id, stop);
5162
+ }
5163
+ };
5164
+ //#endregion
3252
5165
  //#region src/engine.ts
3253
5166
  /**
3254
5167
  * Composition root: wires the Wallet's lock state to the SparkController's lifecycle. Unlocked →
@@ -3276,10 +5189,42 @@ function createWalletEngine(options) {
3276
5189
  fetch
3277
5190
  })
3278
5191
  });
5192
+ const networks = new NetworkController({
5193
+ fetch: options.fetch,
5194
+ rpc: options.rpc ?? {},
5195
+ openStore: options.openStore,
5196
+ logger: options.logger
5197
+ });
5198
+ const assets = new AssetsController({
5199
+ networks,
5200
+ openStore: options.openStore,
5201
+ setTimer: options.setTimer ?? ((ms, fn) => {
5202
+ const id = setTimeout(() => {
5203
+ fn();
5204
+ }, ms);
5205
+ return () => clearTimeout(id);
5206
+ }),
5207
+ logger: options.logger
5208
+ });
5209
+ const transactions = new TransactionController({
5210
+ wallet: options.wallet,
5211
+ networks,
5212
+ openStore: options.openStore,
5213
+ random: options.random,
5214
+ setTimer: options.setTimer ?? ((ms, fn) => {
5215
+ const id = setTimeout(() => {
5216
+ fn();
5217
+ }, ms);
5218
+ return () => clearTimeout(id);
5219
+ }),
5220
+ now: options.now,
5221
+ logger: options.logger
5222
+ });
3279
5223
  const accounts = new AccountTreeController({
3280
5224
  wallet: options.wallet,
3281
5225
  spark: controller,
3282
5226
  sparkNetwork: options.sparkNetwork,
5227
+ networks,
3283
5228
  logger: options.logger,
3284
5229
  now: options.now
3285
5230
  });
@@ -3313,8 +5258,14 @@ function createWalletEngine(options) {
3313
5258
  await accounts.init();
3314
5259
  await controller.init();
3315
5260
  await metaSync.init();
5261
+ await networks.init();
5262
+ await assets.init();
5263
+ await transactions.init();
3316
5264
  } else if (!unlocked && started) {
3317
5265
  started = false;
5266
+ transactions.lock();
5267
+ assets.lock();
5268
+ networks.lock();
3318
5269
  await metaSync.lock();
3319
5270
  await accounts.lock();
3320
5271
  await controller.lockAll();
@@ -3348,6 +5299,9 @@ function createWalletEngine(options) {
3348
5299
  });
3349
5300
  schedule();
3350
5301
  return {
5302
+ networks,
5303
+ assets,
5304
+ transactions,
3351
5305
  spark: controller,
3352
5306
  accounts,
3353
5307
  metaSync,