@anywalletkit/wallet-btc 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +100 -2
- package/dist/chunk-KAAQYL5X.js +318 -0
- package/dist/chunk-KAAQYL5X.js.map +1 -0
- package/dist/history-B5fmsCjZ.d.cts +338 -0
- package/dist/history-B5fmsCjZ.d.ts +338 -0
- package/dist/history.cjs +332 -0
- package/dist/history.cjs.map +1 -0
- package/dist/history.d.cts +2 -0
- package/dist/history.d.ts +2 -0
- package/dist/history.js +17 -0
- package/dist/history.js.map +1 -0
- package/dist/index.cjs +1275 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +405 -0
- package/dist/index.d.ts +405 -0
- package/dist/index.js +966 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BlockbookClient,
|
|
3
|
+
CAIP2_GENESIS,
|
|
4
|
+
DEFAULT_TIMEOUT_MS,
|
|
5
|
+
HistoryUnavailableError,
|
|
6
|
+
btcExplorerTxUrl,
|
|
7
|
+
createBtcHistory,
|
|
8
|
+
decodeCursor,
|
|
9
|
+
encodeCursor,
|
|
10
|
+
fetchWithTimeout,
|
|
11
|
+
isHex64,
|
|
12
|
+
isRecord,
|
|
13
|
+
mapBlockbookTx,
|
|
14
|
+
unconfirmedOutgoing
|
|
15
|
+
} from "./chunk-KAAQYL5X.js";
|
|
16
|
+
|
|
17
|
+
// src/errors.ts
|
|
18
|
+
import { InsufficientFundsError, NetworkError, SignerError } from "@anywalletkit/core";
|
|
19
|
+
var UnsafeTransactionError = class extends Error {
|
|
20
|
+
code;
|
|
21
|
+
constructor(code, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "UnsafeTransactionError";
|
|
24
|
+
this.code = code;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
var TooManyInputsError = class extends Error {
|
|
28
|
+
maxInputs;
|
|
29
|
+
constructor(maxInputs) {
|
|
30
|
+
super(`The spend needs more than ${maxInputs} inputs; consolidate the UTXOs first or raise maxInputs.`);
|
|
31
|
+
this.name = "TooManyInputsError";
|
|
32
|
+
this.maxInputs = maxInputs;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var UnsupportedAddressTypeError = class extends Error {
|
|
36
|
+
constructor(message) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "UnsupportedAddressTypeError";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// src/esplora.ts
|
|
43
|
+
import { NetworkError as NetworkError2 } from "@anywalletkit/core";
|
|
44
|
+
var isStats = (v) => isRecord(v) && typeof v.funded_txo_sum === "number" && typeof v.spent_txo_sum === "number";
|
|
45
|
+
var isStatus = (v) => isRecord(v) && typeof v.confirmed === "boolean";
|
|
46
|
+
var EsploraClient = class {
|
|
47
|
+
base;
|
|
48
|
+
fetchFn;
|
|
49
|
+
timeoutMs;
|
|
50
|
+
constructor(config, fetchFn = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
51
|
+
const trimmed = config.url.replace(/\/+$/, "");
|
|
52
|
+
this.base = trimmed.endsWith("/api") ? trimmed : `${trimmed}/api`;
|
|
53
|
+
this.fetchFn = fetchFn;
|
|
54
|
+
this.timeoutMs = timeoutMs;
|
|
55
|
+
}
|
|
56
|
+
async connect() {
|
|
57
|
+
}
|
|
58
|
+
async close() {
|
|
59
|
+
}
|
|
60
|
+
async request(path, init) {
|
|
61
|
+
const url = `${this.base}${path}`;
|
|
62
|
+
const res = await fetchWithTimeout(this.fetchFn, url, init, this.timeoutMs, this.base);
|
|
63
|
+
if (!res.ok) throw new NetworkError2(`Esplora request failed: ${res.status} ${res.statusText} (${url})`, { provider: this.base });
|
|
64
|
+
return res;
|
|
65
|
+
}
|
|
66
|
+
async json(path, check) {
|
|
67
|
+
const res = await this.request(path);
|
|
68
|
+
let body;
|
|
69
|
+
try {
|
|
70
|
+
body = await res.json();
|
|
71
|
+
} catch (e) {
|
|
72
|
+
throw new NetworkError2(`Esplora returned non-JSON for ${path}`, { provider: this.base, cause: e });
|
|
73
|
+
}
|
|
74
|
+
if (!check(body)) throw new NetworkError2(`Esplora returned an unexpected shape for ${path}`, { provider: this.base });
|
|
75
|
+
return body;
|
|
76
|
+
}
|
|
77
|
+
async text(path, init) {
|
|
78
|
+
return (await this.request(path, init)).text();
|
|
79
|
+
}
|
|
80
|
+
async getBalance(address) {
|
|
81
|
+
const d = await this.json(`/address/${address}`, (v) => isRecord(v) && isStats(v.chain_stats) && isStats(v.mempool_stats));
|
|
82
|
+
const confirmed = BigInt(d.chain_stats.funded_txo_sum) - BigInt(d.chain_stats.spent_txo_sum);
|
|
83
|
+
const unconfirmed = BigInt(d.mempool_stats.funded_txo_sum) - BigInt(d.mempool_stats.spent_txo_sum);
|
|
84
|
+
const outgoing = BigInt(d.mempool_stats.spent_txo_sum) - BigInt(d.mempool_stats.funded_txo_sum);
|
|
85
|
+
return { confirmed, unconfirmed, unconfirmedOutgoing: outgoing > 0n ? outgoing : 0n };
|
|
86
|
+
}
|
|
87
|
+
async listUnspent(address) {
|
|
88
|
+
const d = await this.json(`/address/${address}/utxo`, (v) => Array.isArray(v) && v.every((u) => isRecord(u) && isHex64(u.txid) && typeof u.vout === "number" && typeof u.value === "number" && isStatus(u.status)));
|
|
89
|
+
return d.map((u) => ({ txid: u.txid, vout: u.vout, value: BigInt(u.value), height: u.status.confirmed && u.status.block_height ? u.status.block_height : -1 }));
|
|
90
|
+
}
|
|
91
|
+
/** Esplora pages 25 (mempool.space: up to 50) txs per call; walk `/txs/chain/<last>` until a short page. */
|
|
92
|
+
async getHistory(address) {
|
|
93
|
+
const items = [];
|
|
94
|
+
const check = (v) => Array.isArray(v) && v.every((t) => isRecord(t) && isHex64(t.txid) && isStatus(t.status));
|
|
95
|
+
let page = await this.json(`/address/${address}/txs`, check);
|
|
96
|
+
for (; ; ) {
|
|
97
|
+
for (const t of page) items.push({ txid: t.txid, height: t.status.confirmed && t.status.block_height ? t.status.block_height : -1 });
|
|
98
|
+
if (page.length < 25) break;
|
|
99
|
+
const confirmedTail = [...page].reverse().find((t) => t.status.confirmed);
|
|
100
|
+
if (!confirmedTail) break;
|
|
101
|
+
page = await this.json(`/address/${address}/txs/chain/${confirmedTail.txid}`, check);
|
|
102
|
+
if (page.length === 0) break;
|
|
103
|
+
}
|
|
104
|
+
return items;
|
|
105
|
+
}
|
|
106
|
+
async getTransactionInfo(txid) {
|
|
107
|
+
let d;
|
|
108
|
+
try {
|
|
109
|
+
d = await this.json(`/tx/${txid}`, (v) => isRecord(v) && isHex64(v.txid) && isStatus(v.status));
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (e instanceof NetworkError2 && /\b(400|404)\b/.test(e.message)) return void 0;
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
const height = d.status.confirmed && d.status.block_height ? d.status.block_height : -1;
|
|
115
|
+
const confirmations = height > 0 ? Math.max(await this.getBlockHeight() - height + 1, 1) : 0;
|
|
116
|
+
return { txid: d.txid, blockHeight: height, confirmations, ...typeof d.fee === "number" ? { fees: String(d.fee) } : {} };
|
|
117
|
+
}
|
|
118
|
+
async getTransaction(txid) {
|
|
119
|
+
const hex3 = (await this.text(`/tx/${txid}/hex`)).trim();
|
|
120
|
+
if (!/^[0-9a-fA-F]+$/.test(hex3) || hex3.length < 20) throw new NetworkError2(`Esplora returned no hex for ${txid}`, { provider: this.base });
|
|
121
|
+
return hex3;
|
|
122
|
+
}
|
|
123
|
+
async broadcast(rawTx) {
|
|
124
|
+
const id = (await this.text("/tx", { method: "POST", body: rawTx, headers: { "content-type": "text/plain" } })).trim();
|
|
125
|
+
if (!/^[0-9a-fA-F]{64}$/.test(id)) throw new NetworkError2(`Esplora broadcast returned an unexpected body: ${id.slice(0, 120)}`, { provider: this.base });
|
|
126
|
+
return id;
|
|
127
|
+
}
|
|
128
|
+
/** `/fee-estimates` → { "1": sat/vB, "2": …, "25": …, "144": … }; picks the nearest target ≥ `blocks`. */
|
|
129
|
+
async estimateFee(blocks) {
|
|
130
|
+
const d = await this.json("/fee-estimates", (v) => isRecord(v) && Object.values(v).every((n) => typeof n === "number"));
|
|
131
|
+
const targets = Object.keys(d).map(Number).filter((n) => Number.isFinite(n) && n > 0).sort((a, b) => a - b);
|
|
132
|
+
const target = targets.find((t) => t >= blocks) ?? targets.at(-1);
|
|
133
|
+
const rate = target !== void 0 ? d[String(target)] : void 0;
|
|
134
|
+
if (!rate || rate <= 0) throw new NetworkError2("Esplora has no fee estimate", { provider: this.base });
|
|
135
|
+
return rate;
|
|
136
|
+
}
|
|
137
|
+
async getBlockHeight() {
|
|
138
|
+
const h = Number((await this.text("/blocks/tip/height")).trim());
|
|
139
|
+
if (!Number.isInteger(h) || h <= 0) throw new NetworkError2("Esplora did not report a block height", { provider: this.base });
|
|
140
|
+
return h;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// src/client.ts
|
|
145
|
+
import { NetworkError as NetworkError3 } from "@anywalletkit/core";
|
|
146
|
+
var isClient = (c) => typeof c === "object" && c !== null && typeof c.listUnspent === "function";
|
|
147
|
+
function toClient(c, fetchFn, timeoutMs) {
|
|
148
|
+
if (isClient(c)) return c;
|
|
149
|
+
switch (c.type) {
|
|
150
|
+
case "blockbook-http":
|
|
151
|
+
return new BlockbookClient(c.clientConfig, fetchFn, timeoutMs);
|
|
152
|
+
case "esplora-http":
|
|
153
|
+
return new EsploraClient(c.clientConfig, fetchFn, timeoutMs);
|
|
154
|
+
default:
|
|
155
|
+
throw new Error(`Unknown BTC client type: ${String(c.type)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
var METHODS = ["connect", "close", "getBalance", "listUnspent", "getHistory", "getTransaction", "getTransactionInfo", "broadcast", "estimateFee", "getBlockHeight"];
|
|
159
|
+
function createClient(config) {
|
|
160
|
+
if (config.client === void 0) return void 0;
|
|
161
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
162
|
+
const list = (Array.isArray(config.client) ? config.client : [config.client]).map((c) => toClient(c, config.fetch, timeoutMs));
|
|
163
|
+
if (list.length === 0) return void 0;
|
|
164
|
+
if (list.length === 1) return list[0];
|
|
165
|
+
const attempts = list.length + (config.retries ?? 3);
|
|
166
|
+
const call = (method) => async (...args) => {
|
|
167
|
+
let last;
|
|
168
|
+
for (let i = 0; i < attempts; i++) {
|
|
169
|
+
const c = list[i % list.length];
|
|
170
|
+
const fn = c[method];
|
|
171
|
+
if (!fn) continue;
|
|
172
|
+
try {
|
|
173
|
+
return await fn.apply(c, args);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
last = e;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
throw new NetworkError3(`All BTC clients failed for ${method}`, { cause: last });
|
|
179
|
+
};
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const m of METHODS) out[m] = call(m);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/message.ts
|
|
186
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
187
|
+
import { secp256k1 } from "@noble/curves/secp256k1.js";
|
|
188
|
+
import { base64 } from "@scure/base";
|
|
189
|
+
var PREFIX = new TextEncoder().encode("Bitcoin Signed Message:\n");
|
|
190
|
+
function varint(n) {
|
|
191
|
+
if (n < 253) return Uint8Array.of(n);
|
|
192
|
+
if (n <= 65535) return Uint8Array.of(253, n & 255, n >> 8);
|
|
193
|
+
return Uint8Array.of(254, n & 255, n >> 8 & 255, n >> 16 & 255, n >>> 24 & 255);
|
|
194
|
+
}
|
|
195
|
+
function magicHash(message) {
|
|
196
|
+
const msg = typeof message === "string" ? new TextEncoder().encode(message) : message;
|
|
197
|
+
const len = varint(msg.length);
|
|
198
|
+
const buf = new Uint8Array(PREFIX.length + len.length + msg.length);
|
|
199
|
+
buf.set(PREFIX, 0);
|
|
200
|
+
buf.set(len, PREFIX.length);
|
|
201
|
+
buf.set(msg, PREFIX.length + len.length);
|
|
202
|
+
return sha256(sha256(buf));
|
|
203
|
+
}
|
|
204
|
+
function headerFlag(recovery, segwitType) {
|
|
205
|
+
let flag = recovery;
|
|
206
|
+
if (segwitType !== void 0) {
|
|
207
|
+
flag += 8;
|
|
208
|
+
if (segwitType === "p2wpkh") flag += 4;
|
|
209
|
+
} else flag += 4;
|
|
210
|
+
return 27 + flag;
|
|
211
|
+
}
|
|
212
|
+
function encodeMessageSignature(rs, recovery, segwitType = "p2wpkh") {
|
|
213
|
+
if (rs.length !== 64 || recovery > 1) throw new Error("BIP-137: expected a 64-byte r\u2016s signature and a 0/1 recovery id.");
|
|
214
|
+
const out = new Uint8Array(65);
|
|
215
|
+
out[0] = headerFlag(recovery, segwitType);
|
|
216
|
+
out.set(rs, 1);
|
|
217
|
+
return base64.encode(out);
|
|
218
|
+
}
|
|
219
|
+
function signMessage(message, privateKey, segwitType = "p2wpkh") {
|
|
220
|
+
const sig = secp256k1.sign(magicHash(message), privateKey, { prehash: false, format: "recovered" });
|
|
221
|
+
return encodeMessageSignature(sig.subarray(1), sig[0], segwitType);
|
|
222
|
+
}
|
|
223
|
+
async function signMessageWith(message, signer, segwitType = "p2wpkh") {
|
|
224
|
+
const sig = await signer.sign(magicHash(message), { scheme: "ecdsa-recoverable", context: "btc-message" });
|
|
225
|
+
return encodeMessageSignature(sig.subarray(0, 64), sig[64], segwitType);
|
|
226
|
+
}
|
|
227
|
+
function recoverMessagePublicKey(message, signature) {
|
|
228
|
+
let bytes;
|
|
229
|
+
try {
|
|
230
|
+
bytes = base64.decode(signature);
|
|
231
|
+
} catch {
|
|
232
|
+
return void 0;
|
|
233
|
+
}
|
|
234
|
+
if (bytes.length !== 65) return void 0;
|
|
235
|
+
const flag = bytes[0] - 27;
|
|
236
|
+
if (flag < 0 || flag > 15) return void 0;
|
|
237
|
+
const recovery = flag & 3;
|
|
238
|
+
const compressed = !!(flag & 4) || !!(flag & 8);
|
|
239
|
+
const segwit = flag & 8 ? flag & 4 ? "p2wpkh" : "p2sh(p2wpkh)" : void 0;
|
|
240
|
+
const recovered = new Uint8Array(65);
|
|
241
|
+
recovered[0] = recovery;
|
|
242
|
+
recovered.set(bytes.subarray(1), 1);
|
|
243
|
+
try {
|
|
244
|
+
const pub = secp256k1.recoverPublicKey(recovered, magicHash(message), { prehash: false });
|
|
245
|
+
const publicKey = compressed ? pub : secp256k1.Point.fromBytes(pub).toBytes(false);
|
|
246
|
+
return { publicKey, segwitType: segwit };
|
|
247
|
+
} catch {
|
|
248
|
+
return void 0;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/coinselect.ts
|
|
253
|
+
import { InsufficientFundsError as InsufficientFundsError2 } from "@anywalletkit/core";
|
|
254
|
+
var MAX_UTXO_INPUTS = 200;
|
|
255
|
+
var OVERHEAD_WEIGHT = 42;
|
|
256
|
+
function estimateVsize(inputs, inputWeight, outputVBytes2) {
|
|
257
|
+
const outputWeight = outputVBytes2.reduce((s, vb) => s + vb * 4, 0);
|
|
258
|
+
const extra = (inputs > 252 ? 2 : 0) + (outputVBytes2.length > 252 ? 2 : 0);
|
|
259
|
+
return Math.ceil((OVERHEAD_WEIGHT + extra * 4 + inputs * inputWeight + outputWeight) / 4);
|
|
260
|
+
}
|
|
261
|
+
var cmpUtxo = (a, b) => a.value > b.value ? -1 : a.value < b.value ? 1 : a.txid < b.txid ? -1 : a.txid > b.txid ? 1 : a.vout - b.vout;
|
|
262
|
+
function spendableUtxos(utxos, feeRate, inputWeight) {
|
|
263
|
+
const inputFee = BigInt(Math.ceil(inputWeight / 4)) * feeRate;
|
|
264
|
+
return utxos.filter((u) => u.value > inputFee).sort(cmpUtxo);
|
|
265
|
+
}
|
|
266
|
+
function selectCoins(o) {
|
|
267
|
+
if (o.feeRate < 1n) throw new Error("feeRate must be at least 1 sat/vB");
|
|
268
|
+
const maxInputs = o.maxInputs ?? MAX_UTXO_INPUTS;
|
|
269
|
+
const targetSum = o.targets.reduce((s, t) => s + t.value, 0n);
|
|
270
|
+
const targetVB = o.targets.map((t) => t.vbytes);
|
|
271
|
+
const candidates = spendableUtxos(o.utxos, o.feeRate, o.inputWeight);
|
|
272
|
+
const available = o.utxos.reduce((s, u) => s + u.value, 0n);
|
|
273
|
+
const feeFor = (n, withChange) => BigInt(estimateVsize(n, o.inputWeight, withChange ? [...targetVB, o.changeVBytes] : targetVB)) * o.feeRate;
|
|
274
|
+
const selected = [];
|
|
275
|
+
let sum = 0n;
|
|
276
|
+
for (const u of candidates) {
|
|
277
|
+
if (selected.length >= maxInputs) throw new TooManyInputsError(maxInputs);
|
|
278
|
+
selected.push(u);
|
|
279
|
+
sum += u.value;
|
|
280
|
+
const n = selected.length;
|
|
281
|
+
const feeNoChange = feeFor(n, false);
|
|
282
|
+
if (sum < targetSum + feeNoChange) continue;
|
|
283
|
+
const feeWithChange = feeFor(n, true);
|
|
284
|
+
const change = sum - targetSum - feeWithChange;
|
|
285
|
+
if (change >= o.changeDust) {
|
|
286
|
+
return { utxos: selected, fee: feeWithChange, change, changeFolded: false, vsize: estimateVsize(n, o.inputWeight, [...targetVB, o.changeVBytes]), feeRate: o.feeRate };
|
|
287
|
+
}
|
|
288
|
+
return { utxos: selected, fee: sum - targetSum, change: 0n, changeFolded: sum - targetSum > feeNoChange, vsize: estimateVsize(n, o.inputWeight, targetVB), feeRate: o.feeRate };
|
|
289
|
+
}
|
|
290
|
+
const need = targetSum + feeFor(Math.max(selected.length, 1), false);
|
|
291
|
+
throw new InsufficientFundsError2(need, available, "BTC");
|
|
292
|
+
}
|
|
293
|
+
function selectAll(o) {
|
|
294
|
+
const maxInputs = o.maxInputs ?? MAX_UTXO_INPUTS;
|
|
295
|
+
const utxos = spendableUtxos(o.utxos, o.feeRate, o.inputWeight).slice(0, maxInputs);
|
|
296
|
+
if (utxos.length === 0) return { utxos, fee: 0n, amount: 0n, vsize: 0 };
|
|
297
|
+
const vsize = estimateVsize(utxos.length, o.inputWeight, [o.outputVBytes]);
|
|
298
|
+
const fee = BigInt(vsize) * o.feeRate;
|
|
299
|
+
const amount = utxos.reduce((s, u) => s + u.value, 0n) - fee;
|
|
300
|
+
return { utxos, fee, amount: amount > 0n ? amount : 0n, vsize };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/describe.ts
|
|
304
|
+
import { Transaction } from "@scure/btc-signer";
|
|
305
|
+
import { hex } from "@scure/base";
|
|
306
|
+
|
|
307
|
+
// src/address.ts
|
|
308
|
+
import { Address, OutScript, p2tr, p2wpkh } from "@scure/btc-signer";
|
|
309
|
+
function decodeAddress(address, network) {
|
|
310
|
+
let decoded;
|
|
311
|
+
try {
|
|
312
|
+
decoded = Address(network).decode(address);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
throw new Error(`Invalid bitcoin address for this network: ${address}`, { cause: e });
|
|
315
|
+
}
|
|
316
|
+
return { script: OutScript.encode(decoded), kind: decoded.type };
|
|
317
|
+
}
|
|
318
|
+
function addressFromScript(script, network) {
|
|
319
|
+
try {
|
|
320
|
+
return Address(network).encode(OutScript.decode(script));
|
|
321
|
+
} catch {
|
|
322
|
+
return void 0;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function scriptKind(script) {
|
|
326
|
+
try {
|
|
327
|
+
return OutScript.decode(script).type;
|
|
328
|
+
} catch {
|
|
329
|
+
return "unknown";
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function spendInfo(publicKey, network, type) {
|
|
333
|
+
if (type === "p2tr") return p2tr(publicKey.length === 33 ? publicKey.subarray(1) : publicKey, void 0, network);
|
|
334
|
+
return p2wpkh(publicKey, network);
|
|
335
|
+
}
|
|
336
|
+
function addressFromPublicKey(publicKey, network, type = "p2wpkh") {
|
|
337
|
+
const { address } = spendInfo(publicKey, network, type);
|
|
338
|
+
if (!address) throw new Error(`Could not derive a ${type} address`);
|
|
339
|
+
return address;
|
|
340
|
+
}
|
|
341
|
+
var INPUT_WEIGHT = { p2wpkh: 272, p2tr: 230 };
|
|
342
|
+
function outputVBytes(kind, script) {
|
|
343
|
+
switch (kind) {
|
|
344
|
+
case "wpkh":
|
|
345
|
+
return 31;
|
|
346
|
+
case "tr":
|
|
347
|
+
case "wsh":
|
|
348
|
+
return 43;
|
|
349
|
+
case "pkh":
|
|
350
|
+
return 34;
|
|
351
|
+
case "sh":
|
|
352
|
+
return 32;
|
|
353
|
+
default:
|
|
354
|
+
return 9 + (script?.length ?? 34);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function dustLimit(kind) {
|
|
358
|
+
switch (kind) {
|
|
359
|
+
case "wpkh":
|
|
360
|
+
return 294n;
|
|
361
|
+
case "tr":
|
|
362
|
+
case "wsh":
|
|
363
|
+
return 330n;
|
|
364
|
+
case "pkh":
|
|
365
|
+
return 546n;
|
|
366
|
+
case "sh":
|
|
367
|
+
return 540n;
|
|
368
|
+
default:
|
|
369
|
+
return 546n;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
var DUST_LIMIT_P2WPKH = dustLimit("wpkh");
|
|
373
|
+
var DUST_LIMIT_P2TR = dustLimit("tr");
|
|
374
|
+
var kindOfType = (type) => type === "p2tr" ? "tr" : "wpkh";
|
|
375
|
+
|
|
376
|
+
// src/describe.ts
|
|
377
|
+
var DEFAULT_MAX_FEE_RATE = 500;
|
|
378
|
+
var RBF_SEQUENCE = 4294967293;
|
|
379
|
+
var FINAL_SEQUENCE = 4294967294;
|
|
380
|
+
function parseRawTransaction(rawHex) {
|
|
381
|
+
return Transaction.fromRaw(hex.decode(rawHex), { allowUnknownInputs: true, allowUnknownOutputs: true });
|
|
382
|
+
}
|
|
383
|
+
function vsizeOf(tx) {
|
|
384
|
+
try {
|
|
385
|
+
return tx.vsize;
|
|
386
|
+
} catch {
|
|
387
|
+
return tx.unsignedTx.length;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function describeTransaction(tx, inputValues, ctx) {
|
|
391
|
+
if (inputValues.length !== tx.inputsLength) throw new Error(`describeTransaction: ${tx.inputsLength} inputs but ${inputValues.length} values`);
|
|
392
|
+
const inputs = Array.from({ length: tx.inputsLength }, (_, i) => {
|
|
393
|
+
const inp = tx.getInput(i);
|
|
394
|
+
return { txid: inp.txid ? hex.encode(inp.txid) : "", vout: inp.index ?? 0, value: inputValues[i] };
|
|
395
|
+
});
|
|
396
|
+
const outputs = [];
|
|
397
|
+
const warnings = /* @__PURE__ */ new Set();
|
|
398
|
+
for (let i = 0; i < tx.outputsLength; i++) {
|
|
399
|
+
const out = tx.getOutput(i);
|
|
400
|
+
const value = out.amount ?? 0n;
|
|
401
|
+
const address = tx.getOutputAddress(i, ctx.network);
|
|
402
|
+
const role = address !== void 0 && address === ctx.ownAddress ? "change" : ctx.intended === void 0 ? "recipient" : address !== void 0 && address === ctx.intended.to && value === ctx.intended.value ? "recipient" : "unknown";
|
|
403
|
+
if (out.script && value < dustLimit(scriptKind(out.script))) warnings.add("dust-output");
|
|
404
|
+
outputs.push({ address, value, role });
|
|
405
|
+
}
|
|
406
|
+
const inputSum = inputs.reduce((s, i) => s + i.value, 0n);
|
|
407
|
+
const outputSum = outputs.reduce((s, o) => s + o.value, 0n);
|
|
408
|
+
const fee = inputSum - outputSum;
|
|
409
|
+
const amount = outputs.filter((o) => o.role !== "change").reduce((s, o) => s + o.value, 0n);
|
|
410
|
+
const vsize = vsizeOf(tx);
|
|
411
|
+
const feeRate = vsize > 0 ? Math.round(Number(fee) / vsize * 100) / 100 : 0;
|
|
412
|
+
if (feeRate > (ctx.maxFeeRate ?? DEFAULT_MAX_FEE_RATE)) warnings.add("high-fee-rate");
|
|
413
|
+
if (amount > 0n && fee * 10n > amount) warnings.add("fee-over-10pct-of-amount");
|
|
414
|
+
if (!outputs.some((o) => o.role === "change")) warnings.add("no-change");
|
|
415
|
+
let rbf = false;
|
|
416
|
+
for (let i = 0; i < tx.inputsLength; i++) if ((tx.getInput(i).sequence ?? 4294967295) < FINAL_SEQUENCE) rbf = true;
|
|
417
|
+
const desc = { kind: "btc-transfer", inputs, outputs, amount, fee, feeRate, vsize, rbf, lockTime: tx.lockTime, warnings: [...warnings] };
|
|
418
|
+
if (tx.isFinal) desc.txid = tx.id;
|
|
419
|
+
return desc;
|
|
420
|
+
}
|
|
421
|
+
function assertSafe(desc, ctx) {
|
|
422
|
+
const maxFeeRate = ctx.maxFeeRate ?? DEFAULT_MAX_FEE_RATE;
|
|
423
|
+
if (desc.feeRate > maxFeeRate) {
|
|
424
|
+
throw new UnsafeTransactionError("fee-rate-too-high", `Fee rate ${desc.feeRate} sat/vB exceeds the ${maxFeeRate} sat/vB ceiling (maxFeeRate).`);
|
|
425
|
+
}
|
|
426
|
+
if (desc.fee < 0n) throw new UnsafeTransactionError("unexpected-output", "Outputs exceed inputs.");
|
|
427
|
+
for (const o of desc.outputs) {
|
|
428
|
+
if (o.role === "change" && o.address !== ctx.ownAddress) throw new UnsafeTransactionError("change-not-ours", `Change output pays ${o.address ?? "<no address>"}, not ${ctx.ownAddress}.`);
|
|
429
|
+
}
|
|
430
|
+
if (ctx.intended) {
|
|
431
|
+
const recipients = desc.outputs.filter((o) => o.role === "recipient");
|
|
432
|
+
if (recipients.length !== 1) throw new UnsafeTransactionError("recipient-mismatch", `Expected one output of ${ctx.intended.value} sat to ${ctx.intended.to}, found ${recipients.length}.`);
|
|
433
|
+
const unknown = desc.outputs.filter((o) => o.role === "unknown");
|
|
434
|
+
if (unknown.length) throw new UnsafeTransactionError("unexpected-output", `Transaction carries ${unknown.length} output(s) that are neither the recipient nor our change.`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// src/network.ts
|
|
439
|
+
import { NETWORK, TEST_NETWORK } from "@scure/btc-signer";
|
|
440
|
+
var REGTEST_NETWORK = Object.freeze({ bech32: "bcrt", pubKeyHash: 111, scriptHash: 196, wif: 239 });
|
|
441
|
+
function networkParams(network) {
|
|
442
|
+
switch (network) {
|
|
443
|
+
case "bitcoin":
|
|
444
|
+
return NETWORK;
|
|
445
|
+
case "testnet":
|
|
446
|
+
return TEST_NETWORK;
|
|
447
|
+
case "regtest":
|
|
448
|
+
return REGTEST_NETWORK;
|
|
449
|
+
default:
|
|
450
|
+
throw new Error(`Unknown bitcoin network: ${String(network)}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
var coinType = (network) => network === "bitcoin" ? 0 : 1;
|
|
454
|
+
var purposeFor = (type) => type === "p2tr" ? 86 : 84;
|
|
455
|
+
|
|
456
|
+
// src/read-only-account.ts
|
|
457
|
+
import { InsufficientFundsError as InsufficientFundsError3, NetworkError as NetworkError4 } from "@anywalletkit/core";
|
|
458
|
+
var toBig = (v) => typeof v === "bigint" ? v : BigInt(Math.round(Number(v)));
|
|
459
|
+
var WalletAccountReadOnlyBtc = class {
|
|
460
|
+
/** The address. */
|
|
461
|
+
address;
|
|
462
|
+
chain;
|
|
463
|
+
/** Output script of `address`. */
|
|
464
|
+
scriptPubKey;
|
|
465
|
+
/** Script type of the address. */
|
|
466
|
+
scriptKind;
|
|
467
|
+
/** p2wpkh / p2tr when the address is one we know how to spend from; undefined for other script types. */
|
|
468
|
+
addressType;
|
|
469
|
+
/** Smallest output this address type may create, in satoshis. */
|
|
470
|
+
dustLimit;
|
|
471
|
+
_config;
|
|
472
|
+
_client;
|
|
473
|
+
_network;
|
|
474
|
+
_networkName;
|
|
475
|
+
constructor(address, config = {}) {
|
|
476
|
+
this._config = config;
|
|
477
|
+
this._networkName = config.network ?? "bitcoin";
|
|
478
|
+
this._network = networkParams(this._networkName);
|
|
479
|
+
const decoded = decodeAddress(address, this._network);
|
|
480
|
+
this.address = address;
|
|
481
|
+
this.scriptPubKey = decoded.script;
|
|
482
|
+
this.scriptKind = decoded.kind;
|
|
483
|
+
this.addressType = decoded.kind === "wpkh" ? "p2wpkh" : decoded.kind === "tr" ? "p2tr" : void 0;
|
|
484
|
+
this.dustLimit = dustLimit(decoded.kind);
|
|
485
|
+
this.chain = CAIP2_GENESIS[this._networkName];
|
|
486
|
+
this._client = createClient(config);
|
|
487
|
+
}
|
|
488
|
+
requireClient(what) {
|
|
489
|
+
if (!this._client) throw new NetworkError4(`The wallet must be connected to a client to ${what}.`);
|
|
490
|
+
return this._client;
|
|
491
|
+
}
|
|
492
|
+
/** Fee-rate ceiling in sat/vB: `BtcConfig.maxFeeRate` or {@link DEFAULT_MAX_FEE_RATE}. */
|
|
493
|
+
get maxFeeRate() {
|
|
494
|
+
return this._config.maxFeeRate ?? DEFAULT_MAX_FEE_RATE;
|
|
495
|
+
}
|
|
496
|
+
/** The address. */
|
|
497
|
+
async getAddress() {
|
|
498
|
+
return this.address;
|
|
499
|
+
}
|
|
500
|
+
/** Confirmed minus pending outgoing (Bitcoin Core wallet semantics). */
|
|
501
|
+
async getBalance() {
|
|
502
|
+
const { confirmed, unconfirmed, unconfirmedOutgoing: unconfirmedOutgoing2 } = await this.requireClient("read balances").getBalance(this.address);
|
|
503
|
+
return unconfirmedOutgoing2 !== void 0 ? confirmed - unconfirmedOutgoing2 : confirmed + unconfirmed;
|
|
504
|
+
}
|
|
505
|
+
/** Always throws: Bitcoin has no tokens. */
|
|
506
|
+
async getTokenBalance(_token) {
|
|
507
|
+
throw new Error("The 'getTokenBalance' method is not supported on the bitcoin blockchain.");
|
|
508
|
+
}
|
|
509
|
+
/** Weight one of OUR inputs adds; throws for address types we cannot spend from. */
|
|
510
|
+
inputWeight() {
|
|
511
|
+
if (!this.addressType) throw new UnsupportedAddressTypeError(`Cannot build spends from a ${this.scriptKind} address; only p2wpkh and p2tr accounts spend.`);
|
|
512
|
+
return INPUT_WEIGHT[this.addressType];
|
|
513
|
+
}
|
|
514
|
+
async resolveFeeRate(spend) {
|
|
515
|
+
if (spend.feeRate !== void 0) {
|
|
516
|
+
const r = toBig(spend.feeRate);
|
|
517
|
+
return r < 1n ? 1n : r;
|
|
518
|
+
}
|
|
519
|
+
const est = await this.requireClient("estimate fees").estimateFee(spend.confirmationTarget ?? 1);
|
|
520
|
+
return BigInt(Math.max(Math.ceil(est), 1));
|
|
521
|
+
}
|
|
522
|
+
/** Coin selection for a spend (see coinselect.ts). Validates the recipient and the dust floor first. */
|
|
523
|
+
async planSpend(spend) {
|
|
524
|
+
const amount = toBig(spend.value);
|
|
525
|
+
const recipient = decodeAddress(spend.to, this._network);
|
|
526
|
+
const floor = dustLimit(recipient.kind);
|
|
527
|
+
if (amount < floor) throw new Error(`The amount must be at least the dust limit of the recipient's address type (${floor} sat).`);
|
|
528
|
+
const inputWeight = this.inputWeight();
|
|
529
|
+
const feeRate = await this.resolveFeeRate(spend);
|
|
530
|
+
const utxos = await this.requireClient("plan a spend").listUnspent(this.address);
|
|
531
|
+
if (utxos.length === 0) throw new InsufficientFundsError3(amount, 0n, "BTC");
|
|
532
|
+
const own = kindOfType(this.addressType);
|
|
533
|
+
const selection = selectCoins({
|
|
534
|
+
utxos,
|
|
535
|
+
targets: [{ value: amount, vbytes: outputVBytes(recipient.kind, recipient.script) }],
|
|
536
|
+
feeRate,
|
|
537
|
+
inputWeight,
|
|
538
|
+
changeVBytes: outputVBytes(own),
|
|
539
|
+
changeDust: dustLimit(own),
|
|
540
|
+
maxInputs: this._config.maxInputs ?? MAX_UTXO_INPUTS
|
|
541
|
+
});
|
|
542
|
+
return { ...selection, amount, recipientScript: recipient.script, recipientKind: recipient.kind };
|
|
543
|
+
}
|
|
544
|
+
/** Fee of a spend in satoshis, from a coin selection at the current fee rate. */
|
|
545
|
+
async quoteSendTransaction(tx) {
|
|
546
|
+
const { fee } = await this.planSpend(tx);
|
|
547
|
+
return { fee };
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Fee of a transfer in satoshis.
|
|
551
|
+
*
|
|
552
|
+
* @throws Error when `token` is set.
|
|
553
|
+
*/
|
|
554
|
+
async quoteTransfer(o) {
|
|
555
|
+
if (o.token) throw new Error("Tokens are not supported on the bitcoin blockchain.");
|
|
556
|
+
return this.quoteSendTransaction({ to: o.recipient, value: o.amount });
|
|
557
|
+
}
|
|
558
|
+
/** Everything spendable in ONE transaction to a single output of our own address type, no change. */
|
|
559
|
+
async getMaxSpendable(opts = {}) {
|
|
560
|
+
const inputWeight = this.inputWeight();
|
|
561
|
+
const feeRate = await this.resolveFeeRate(opts);
|
|
562
|
+
const utxos = await this.requireClient("plan a spend").listUnspent(this.address);
|
|
563
|
+
const outKind = opts.to ? decodeAddress(opts.to, this._network).kind : kindOfType(this.addressType);
|
|
564
|
+
const sweep = selectAll({ utxos, feeRate, inputWeight, outputVBytes: outputVBytes(outKind), maxInputs: this._config.maxInputs ?? MAX_UTXO_INPUTS });
|
|
565
|
+
const amount = sweep.amount >= dustLimit(outKind) ? sweep.amount : 0n;
|
|
566
|
+
return { amount, fee: amount > 0n ? sweep.fee : 0n, changeValue: 0n, inputs: amount > 0n ? sweep.utxos.length : 0, feeRate };
|
|
567
|
+
}
|
|
568
|
+
/** Undefined until the transaction is mined. */
|
|
569
|
+
async getTransactionReceipt(hash) {
|
|
570
|
+
if (!/^[0-9a-fA-F]{64}$/.test(hash)) throw new Error("The 'getTransactionReceipt(hash)' method requires a valid transaction hash.");
|
|
571
|
+
const client = this.requireClient("fetch transaction receipts");
|
|
572
|
+
const info = client.getTransactionInfo ? await client.getTransactionInfo(hash) : void 0;
|
|
573
|
+
if (!info || !info.blockHeight || info.blockHeight <= 0) return void 0;
|
|
574
|
+
return {
|
|
575
|
+
hash,
|
|
576
|
+
status: 1,
|
|
577
|
+
blockNumber: BigInt(info.blockHeight),
|
|
578
|
+
confirmations: info.confirmations,
|
|
579
|
+
...info.fees !== void 0 ? { fee: BigInt(info.fees) } : {},
|
|
580
|
+
...info.hex !== void 0 ? { hex: info.hex } : {}
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
/** BIP-137: recover the key and compare its p2wpkh address with ours. Taproot addresses have no BIP-137 form. */
|
|
584
|
+
async verify(message, signature) {
|
|
585
|
+
if (this.addressType === "p2tr") throw new UnsupportedAddressTypeError("BIP-137 message signatures do not exist for taproot addresses (BIP-322 is not implemented).");
|
|
586
|
+
const rec = recoverMessagePublicKey(message, signature);
|
|
587
|
+
if (!rec || rec.publicKey.length !== 33) return false;
|
|
588
|
+
try {
|
|
589
|
+
return spendInfo(rec.publicKey, this._network, "p2wpkh").address === this.address;
|
|
590
|
+
} catch {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
/** Chain tip for anti-fee-sniping; undefined when disabled or the transport cannot tell. */
|
|
595
|
+
async lockTimeHeight() {
|
|
596
|
+
if (this._config.antiFeeSniping === false || !this._client?.getBlockHeight) return 0;
|
|
597
|
+
try {
|
|
598
|
+
return await this._client.getBlockHeight();
|
|
599
|
+
} catch {
|
|
600
|
+
return 0;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// src/account.ts
|
|
606
|
+
import { InsufficientFundsError as InsufficientFundsError4, LocalSigner, SignerError as SignerError2, requireCurve } from "@anywalletkit/core";
|
|
607
|
+
import { SigHash, Transaction as Transaction2, p2pkh } from "@scure/btc-signer";
|
|
608
|
+
import { schnorr, secp256k1 as secp256k12 } from "@noble/curves/secp256k1.js";
|
|
609
|
+
import { hex as hex2 } from "@scure/base";
|
|
610
|
+
function addressFor(publicKey, network = "bitcoin", type = "p2wpkh") {
|
|
611
|
+
return addressFromPublicKey(publicKey, typeof network === "string" ? networkParams(network) : network, type);
|
|
612
|
+
}
|
|
613
|
+
var WalletAccountBtc = class _WalletAccountBtc extends WalletAccountReadOnlyBtc {
|
|
614
|
+
/** UTXOs, fee rate and tip height come from the network before signing. */
|
|
615
|
+
needsNetwork = true;
|
|
616
|
+
/** Derivation path, or `external` for an external signer. */
|
|
617
|
+
path;
|
|
618
|
+
/** Compressed secp256k1 public key. */
|
|
619
|
+
publicKey;
|
|
620
|
+
addressType;
|
|
621
|
+
/** The signer that holds the key. */
|
|
622
|
+
signer;
|
|
623
|
+
_disposed = false;
|
|
624
|
+
_readOnly;
|
|
625
|
+
/** `publicKey` is the signer's compressed secp256k1 key (33 bytes); it is needed synchronously for the address. */
|
|
626
|
+
constructor(signer, publicKey, path, config = {}) {
|
|
627
|
+
const type = config.addressType ?? "p2wpkh";
|
|
628
|
+
super(addressFromPublicKey(publicKey, networkParams(config.network ?? "bitcoin"), type), config);
|
|
629
|
+
this.addressType = type;
|
|
630
|
+
this.signer = requireCurve(signer, "secp256k1");
|
|
631
|
+
const needed = type === "p2tr" ? "schnorr" : "ecdsa";
|
|
632
|
+
if (!this.signer.schemes.includes(needed)) throw new SignerError2(`A ${type} account needs a signer with the '${needed}' scheme (has: ${this.signer.schemes.join(", ")}).`);
|
|
633
|
+
this.publicKey = publicKey;
|
|
634
|
+
this.path = path;
|
|
635
|
+
}
|
|
636
|
+
/** Back-compat: wrap a raw derived key in a {@link LocalSigner} (the account takes ownership; it cannot derive). */
|
|
637
|
+
static fromPrivateKey(privateKey, publicKey, path, config = {}) {
|
|
638
|
+
return new _WalletAccountBtc(LocalSigner.fromPrivateKey("secp256k1", privateKey, publicKey, path), publicKey, path, config);
|
|
639
|
+
}
|
|
640
|
+
/** Account index for the standard m/{84|86}'/{0|1}'/0'/0/i path. */
|
|
641
|
+
get index() {
|
|
642
|
+
const m = /^m\/8[46]'\/[01]'\/0'\/0\/(\d+)$/.exec(this.path);
|
|
643
|
+
return m ? Number(m[1]) : void 0;
|
|
644
|
+
}
|
|
645
|
+
live() {
|
|
646
|
+
if (this._disposed) throw new SignerError2("This account has been disposed.");
|
|
647
|
+
}
|
|
648
|
+
/** BIP-137 p2wpkh signature, base64 (Electrum / Bitcoin Core encoding). Not available for taproot accounts. */
|
|
649
|
+
async sign(message) {
|
|
650
|
+
this.live();
|
|
651
|
+
if (this.addressType === "p2tr") throw new UnsupportedAddressTypeError("BIP-137 message signatures do not exist for taproot accounts (BIP-322 is not implemented).");
|
|
652
|
+
return signMessageWith(message, this.signer, "p2wpkh");
|
|
653
|
+
}
|
|
654
|
+
/** The x-only internal key and its BIP-86 tweak (no script tree) for taproot key-path spends. */
|
|
655
|
+
taprootKeys() {
|
|
656
|
+
const xonly = this.publicKey.length === 33 ? this.publicKey.subarray(1) : this.publicKey;
|
|
657
|
+
return { xonly, tweak: schnorr.utils.taggedHash("TapTweak", xonly) };
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Sign every input through the signer and attach the witness-ready signature:
|
|
661
|
+
* p2wpkh → `partialSig` (DER + SIGHASH_ALL), p2tr → `tapKeySig` (64 bytes, DEFAULT).
|
|
662
|
+
* btc-signer only computes the sighash and assembles the witness; it never sees a key.
|
|
663
|
+
*/
|
|
664
|
+
async signInputs(tx, values) {
|
|
665
|
+
if (this.addressType === "p2tr") {
|
|
666
|
+
const { tweak } = this.taprootKeys();
|
|
667
|
+
const scripts = Array.from({ length: tx.inputsLength }, () => this.scriptPubKey);
|
|
668
|
+
for (let i = 0; i < tx.inputsLength; i++) {
|
|
669
|
+
const sighash = tx.preimageWitnessV1(i, scripts, SigHash.DEFAULT, values);
|
|
670
|
+
const sig = await this.signer.sign(sighash, { scheme: "schnorr", context: "btc-taproot-keypath", tweak });
|
|
671
|
+
tx.updateInput(i, { tapKeySig: sig }, true);
|
|
672
|
+
}
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const scriptCode = p2pkh(this.publicKey).script;
|
|
676
|
+
for (let i = 0; i < tx.inputsLength; i++) {
|
|
677
|
+
const sighash = tx.preimageWitnessV0(i, scriptCode, SigHash.ALL, values[i]);
|
|
678
|
+
const rs = await this.signer.sign(sighash, { scheme: "ecdsa", context: "btc-sighash" });
|
|
679
|
+
const der = secp256k12.Signature.fromBytes(rs, "compact").toBytes("der");
|
|
680
|
+
const sig = new Uint8Array(der.length + 1);
|
|
681
|
+
sig.set(der, 0);
|
|
682
|
+
sig[der.length] = SigHash.ALL;
|
|
683
|
+
tx.updateInput(i, { partialSig: [[this.publicKey, sig]] }, true);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
ctx(intended) {
|
|
687
|
+
return { network: this._network, ownAddress: this.address, intended, maxFeeRate: this.maxFeeRate };
|
|
688
|
+
}
|
|
689
|
+
async buildAndSign(plan, to, recipientValue, change, lockTime, rbf) {
|
|
690
|
+
const spend = spendInfo(this.publicKey, this._network, this.addressType);
|
|
691
|
+
const tx = new Transaction2({ lockTime });
|
|
692
|
+
const sequence = rbf ? RBF_SEQUENCE : FINAL_SEQUENCE;
|
|
693
|
+
for (const u of plan.utxos) {
|
|
694
|
+
tx.addInput({ ...spend, txid: u.txid, index: u.vout, sequence, witnessUtxo: { script: this.scriptPubKey, amount: u.value } });
|
|
695
|
+
}
|
|
696
|
+
tx.addOutputAddress(to, recipientValue, this._network);
|
|
697
|
+
if (change > 0n) tx.addOutputAddress(this.address, change, this._network);
|
|
698
|
+
await this.signInputs(tx, plan.utxos.map((u) => u.value));
|
|
699
|
+
tx.finalize();
|
|
700
|
+
return tx;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Plan → sign → measure. The plan's fee assumes 72-byte DER signatures; if the
|
|
704
|
+
* signed vsize somehow needs more, the difference comes out of change (never the
|
|
705
|
+
* recipient) and the tx is re-signed once.
|
|
706
|
+
*/
|
|
707
|
+
async buildSignedTransaction(spend) {
|
|
708
|
+
this.live();
|
|
709
|
+
const plan = await this.planSpend(spend);
|
|
710
|
+
await this.verifyPrevouts(plan.utxos);
|
|
711
|
+
const lockTime = await this.lockTimeHeight();
|
|
712
|
+
const rbf = spend.rbf ?? this._config.rbf ?? true;
|
|
713
|
+
let change = plan.change;
|
|
714
|
+
let fee = plan.fee;
|
|
715
|
+
let tx = await this.buildAndSign(plan, spend.to, plan.amount, change, lockTime, rbf);
|
|
716
|
+
const required = BigInt(tx.vsize) * plan.feeRate;
|
|
717
|
+
if (required > fee) {
|
|
718
|
+
const delta = required - fee;
|
|
719
|
+
if (change === 0n) {
|
|
720
|
+
const have = plan.utxos.reduce((s, u) => s + u.value, 0n);
|
|
721
|
+
throw new InsufficientFundsError4(plan.amount + required, have, "BTC");
|
|
722
|
+
}
|
|
723
|
+
change = change - delta;
|
|
724
|
+
if (change < this.dustLimit) {
|
|
725
|
+
fee = plan.fee + plan.change;
|
|
726
|
+
change = 0n;
|
|
727
|
+
} else fee = required;
|
|
728
|
+
tx = await this.buildAndSign(plan, spend.to, plan.amount, change, lockTime, rbf);
|
|
729
|
+
if (BigInt(tx.vsize) * plan.feeRate > fee) throw new Error("Fee shortfall after output rebalance.");
|
|
730
|
+
}
|
|
731
|
+
const ctx = this.ctx({ to: spend.to, value: plan.amount });
|
|
732
|
+
const desc = describeTransaction(tx, plan.utxos.map((u) => u.value), ctx);
|
|
733
|
+
assertSafe(desc, ctx);
|
|
734
|
+
return { tx, fee, plan, desc };
|
|
735
|
+
}
|
|
736
|
+
checkMax(fee, limit, op) {
|
|
737
|
+
if (limit !== void 0 && fee > BigInt(limit)) throw new Error(`Exceeded maximum fee cost for ${op} operation.`);
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* BIP-143 (p2wpkh) commits each input's signature to that input's CLAIMED amount
|
|
741
|
+
* only: an indexer that understates a different coin in each of two signing
|
|
742
|
+
* sessions can combine their signatures into one valid tx whose real fee the
|
|
743
|
+
* wallet never showed (scan 2026-10-23 F2). So every p2wpkh coin is read from
|
|
744
|
+
* its full previous transaction, which must hash to the claimed txid, and its
|
|
745
|
+
* amount and script must match before anything is signed. Taproot (BIP-341)
|
|
746
|
+
* commits to every input amount and skips the extra requests.
|
|
747
|
+
*/
|
|
748
|
+
async verifyPrevouts(utxos) {
|
|
749
|
+
if (this.addressType === "p2tr") return;
|
|
750
|
+
const client = this.requireClient("verify the coins it spends");
|
|
751
|
+
const ours = hex2.encode(this.scriptPubKey);
|
|
752
|
+
const cache = /* @__PURE__ */ new Map();
|
|
753
|
+
for (const u of utxos) {
|
|
754
|
+
let prev = cache.get(u.txid);
|
|
755
|
+
if (!prev) {
|
|
756
|
+
prev = parseRawTransaction(await client.getTransaction(u.txid));
|
|
757
|
+
if (prev.id !== u.txid) throw new UnsafeTransactionError("prevout-mismatch", `The indexer's transaction for ${u.txid} hashes to ${prev.id}.`);
|
|
758
|
+
cache.set(u.txid, prev);
|
|
759
|
+
}
|
|
760
|
+
const out = u.vout < prev.outputsLength ? prev.getOutput(u.vout) : void 0;
|
|
761
|
+
if (out?.amount === void 0 || out.amount !== u.value) {
|
|
762
|
+
throw new UnsafeTransactionError("prevout-mismatch", `Coin ${u.txid}:${u.vout} is worth ${out?.amount ?? "nothing"} sat on chain, not the ${u.value} the indexer reported.`);
|
|
763
|
+
}
|
|
764
|
+
if (!out.script || hex2.encode(out.script) !== ours) throw new UnsafeTransactionError("prevout-mismatch", `Coin ${u.txid}:${u.vout} does not pay this account.`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/** Prevout values of a raw tx, fetched through the client (the wire format carries none). */
|
|
768
|
+
async inputValues(tx) {
|
|
769
|
+
const client = this.requireClient("inspect a raw transaction");
|
|
770
|
+
const cache = /* @__PURE__ */ new Map();
|
|
771
|
+
const values = [];
|
|
772
|
+
for (let i = 0; i < tx.inputsLength; i++) {
|
|
773
|
+
const inp = tx.getInput(i);
|
|
774
|
+
if (!inp.txid || inp.index === void 0) throw new Error("Raw transaction input without a prevout.");
|
|
775
|
+
const id = hex2.encode(inp.txid);
|
|
776
|
+
let prev = cache.get(id);
|
|
777
|
+
if (!prev) {
|
|
778
|
+
prev = parseRawTransaction(await client.getTransaction(id));
|
|
779
|
+
if (prev.id !== id) throw new UnsafeTransactionError("prevout-mismatch", `The indexer's transaction for ${id} hashes to ${prev.id}.`);
|
|
780
|
+
cache.set(id, prev);
|
|
781
|
+
}
|
|
782
|
+
const out = prev.getOutput(inp.index);
|
|
783
|
+
if (out.amount === void 0) throw new Error(`Prevout ${id}:${inp.index} not found.`);
|
|
784
|
+
values.push(out.amount);
|
|
785
|
+
}
|
|
786
|
+
return values;
|
|
787
|
+
}
|
|
788
|
+
/** Describe a raw signed tx (needs the network for prevout values) and run the safety checks. */
|
|
789
|
+
async describeRaw(rawHex) {
|
|
790
|
+
const tx = parseRawTransaction(rawHex);
|
|
791
|
+
const ctx = this.ctx();
|
|
792
|
+
const desc = describeTransaction(tx, await this.inputValues(tx), ctx);
|
|
793
|
+
assertSafe(desc, ctx);
|
|
794
|
+
return { tx, desc };
|
|
795
|
+
}
|
|
796
|
+
/** "What you sign": the fully resolved spend (signed, NOT broadcast) or a caller's raw hex. */
|
|
797
|
+
async describeTransaction(tx) {
|
|
798
|
+
if (typeof tx === "string") return (await this.describeRaw(tx)).desc;
|
|
799
|
+
return (await this.buildSignedTransaction(tx)).desc;
|
|
800
|
+
}
|
|
801
|
+
/** Sign without broadcasting. Returns the raw transaction hex. Raw hex input is returned unchanged. */
|
|
802
|
+
async signTransaction(tx) {
|
|
803
|
+
this.live();
|
|
804
|
+
if (typeof tx === "string") return tx;
|
|
805
|
+
const { tx: built, fee } = await this.buildSignedTransaction(tx);
|
|
806
|
+
this.checkMax(fee, this._config.transactionMaxFee, "transaction");
|
|
807
|
+
return built.hex;
|
|
808
|
+
}
|
|
809
|
+
async quoteSendTransaction(tx) {
|
|
810
|
+
if (typeof tx === "string") return { fee: (await this.describeRaw(tx)).desc.fee };
|
|
811
|
+
return super.quoteSendTransaction(tx);
|
|
812
|
+
}
|
|
813
|
+
async sendTransaction(tx) {
|
|
814
|
+
this.live();
|
|
815
|
+
const client = this.requireClient("send transactions");
|
|
816
|
+
let rawHex, hash, fee;
|
|
817
|
+
if (typeof tx === "string") {
|
|
818
|
+
const { tx: parsed, desc } = await this.describeRaw(tx);
|
|
819
|
+
rawHex = tx;
|
|
820
|
+
hash = parsed.id;
|
|
821
|
+
fee = desc.fee;
|
|
822
|
+
} else {
|
|
823
|
+
const built = await this.buildSignedTransaction(tx);
|
|
824
|
+
rawHex = built.tx.hex;
|
|
825
|
+
hash = built.tx.id;
|
|
826
|
+
fee = built.fee;
|
|
827
|
+
}
|
|
828
|
+
this.checkMax(fee, this._config.transactionMaxFee, "transaction");
|
|
829
|
+
const txid = await client.broadcast(rawHex);
|
|
830
|
+
return { hash: txid || hash, fee };
|
|
831
|
+
}
|
|
832
|
+
/** Native BTC only (`token` is rejected). Subject to `transferMaxFee`. */
|
|
833
|
+
async transfer(o) {
|
|
834
|
+
if (o.token) throw new Error("Tokens are not supported on the bitcoin blockchain.");
|
|
835
|
+
this.live();
|
|
836
|
+
const client = this.requireClient("transfer");
|
|
837
|
+
const built = await this.buildSignedTransaction({ to: o.recipient, value: o.amount });
|
|
838
|
+
this.checkMax(built.fee, this._config.transferMaxFee, "transfer");
|
|
839
|
+
const txid = await client.broadcast(built.tx.hex);
|
|
840
|
+
return { hash: txid || built.tx.id, fee: built.fee };
|
|
841
|
+
}
|
|
842
|
+
/** The same address as a {@link WalletAccountReadOnlyBtc}. */
|
|
843
|
+
toReadOnlyAccount() {
|
|
844
|
+
this._readOnly ??= new WalletAccountReadOnlyBtc(this.address, this._config);
|
|
845
|
+
return this._readOnly;
|
|
846
|
+
}
|
|
847
|
+
dispose() {
|
|
848
|
+
this._disposed = true;
|
|
849
|
+
this.signer.dispose();
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// src/manager.ts
|
|
854
|
+
import { LocalSigner as LocalSigner2, SignerError as SignerError3, WalletManager, isSeedSigner } from "@anywalletkit/core";
|
|
855
|
+
var WalletManagerBtc = class extends WalletManager {
|
|
856
|
+
constructor(seedOrSigner, config = {}) {
|
|
857
|
+
super(seedOrSigner, config);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* `m/84'/0'` for p2wpkh or `m/86'/0'` for p2tr on mainnet (coin type 1 on test networks); account
|
|
861
|
+
* `i` is at `basePath/0'/0/i`.
|
|
862
|
+
*/
|
|
863
|
+
get basePath() {
|
|
864
|
+
return `m/${purposeFor(this._config.addressType ?? "p2wpkh")}'/${coinType(this._config.network ?? "bitcoin")}'`;
|
|
865
|
+
}
|
|
866
|
+
async getAccount(index = 0) {
|
|
867
|
+
return this.getAccountByPath(`${this.basePath}/0'/0/${index}`);
|
|
868
|
+
}
|
|
869
|
+
async getAccountByPath(path) {
|
|
870
|
+
const full = path.startsWith("m/") ? path : `${this.basePath}/${path}`;
|
|
871
|
+
const cached = this._accounts.get(full);
|
|
872
|
+
if (cached) return cached;
|
|
873
|
+
const external = this.externalSigner();
|
|
874
|
+
if (external) return this.fromSigner(external, full);
|
|
875
|
+
const signer = LocalSigner2.leafFromSeed(this.seedBytes(), "secp256k1", full);
|
|
876
|
+
const account = new WalletAccountBtc(signer, signer.publicKey, full, this._config);
|
|
877
|
+
this._accounts.set(full, account);
|
|
878
|
+
return account;
|
|
879
|
+
}
|
|
880
|
+
/** Resolve `path` against an injected signer: the signer itself, or a child when it can derive there. */
|
|
881
|
+
async fromSigner(signer, path) {
|
|
882
|
+
this.requireSigner("secp256k1");
|
|
883
|
+
let leaf = signer;
|
|
884
|
+
let key = "external";
|
|
885
|
+
if (isSeedSigner(signer)) {
|
|
886
|
+
if (path === signer.path) key = path;
|
|
887
|
+
else if (path.startsWith(`${signer.path}/`)) {
|
|
888
|
+
leaf = await (signer.deriveLeaf ?? signer.derive).call(signer, path.slice(signer.path.length + 1));
|
|
889
|
+
key = path;
|
|
890
|
+
} else throw new SignerError3(`The injected signer sits at ${signer.path}; it cannot provide ${path}.`);
|
|
891
|
+
} else if (path !== `${this.basePath}/0'/0/0` && path !== "external") {
|
|
892
|
+
throw new SignerError3(`An external signer has one account; ${path} is not derivable from it.`);
|
|
893
|
+
}
|
|
894
|
+
const cached = this._accounts.get(key);
|
|
895
|
+
if (cached) return cached;
|
|
896
|
+
const account = new WalletAccountBtc(leaf, await leaf.getPublicKey(), key, this._config);
|
|
897
|
+
this._accounts.set(key, account);
|
|
898
|
+
return account;
|
|
899
|
+
}
|
|
900
|
+
/** A read-only account for any address, with this manager's config. */
|
|
901
|
+
getReadOnlyAccount(address) {
|
|
902
|
+
return new WalletAccountReadOnlyBtc(address, this._config);
|
|
903
|
+
}
|
|
904
|
+
/** sat/vB from the client's estimator: fast = 1 block, normal = 3, slow = 6. */
|
|
905
|
+
async getFeeRates() {
|
|
906
|
+
const probe = new WalletAccountReadOnlyBtc(
|
|
907
|
+
(this._config.network ?? "bitcoin") === "bitcoin" ? "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" : "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl",
|
|
908
|
+
this._config
|
|
909
|
+
);
|
|
910
|
+
const client = probe.requireClient("get fee rates");
|
|
911
|
+
const [fast, normal, slow] = await Promise.all([1, 3, 6].map((b) => client.estimateFee(b)));
|
|
912
|
+
const big = (n) => BigInt(Math.max(Math.ceil(n), 1));
|
|
913
|
+
return { slow: big(slow), normal: big(normal), fast: big(fast) };
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
export {
|
|
917
|
+
BlockbookClient,
|
|
918
|
+
CAIP2_GENESIS,
|
|
919
|
+
DEFAULT_MAX_FEE_RATE,
|
|
920
|
+
DUST_LIMIT_P2TR,
|
|
921
|
+
DUST_LIMIT_P2WPKH,
|
|
922
|
+
EsploraClient,
|
|
923
|
+
FINAL_SEQUENCE,
|
|
924
|
+
HistoryUnavailableError,
|
|
925
|
+
INPUT_WEIGHT,
|
|
926
|
+
MAX_UTXO_INPUTS,
|
|
927
|
+
RBF_SEQUENCE,
|
|
928
|
+
REGTEST_NETWORK,
|
|
929
|
+
TooManyInputsError,
|
|
930
|
+
UnsafeTransactionError,
|
|
931
|
+
UnsupportedAddressTypeError,
|
|
932
|
+
WalletAccountBtc,
|
|
933
|
+
WalletAccountReadOnlyBtc,
|
|
934
|
+
WalletManagerBtc,
|
|
935
|
+
addressFor,
|
|
936
|
+
addressFromPublicKey,
|
|
937
|
+
addressFromScript,
|
|
938
|
+
assertSafe,
|
|
939
|
+
btcExplorerTxUrl,
|
|
940
|
+
coinType,
|
|
941
|
+
createBtcHistory,
|
|
942
|
+
createClient,
|
|
943
|
+
decodeAddress,
|
|
944
|
+
decodeCursor as decodeHistoryCursor,
|
|
945
|
+
WalletManagerBtc as default,
|
|
946
|
+
describeTransaction,
|
|
947
|
+
dustLimit,
|
|
948
|
+
encodeCursor as encodeHistoryCursor,
|
|
949
|
+
encodeMessageSignature,
|
|
950
|
+
estimateVsize,
|
|
951
|
+
magicHash,
|
|
952
|
+
mapBlockbookTx,
|
|
953
|
+
networkParams,
|
|
954
|
+
outputVBytes,
|
|
955
|
+
parseRawTransaction,
|
|
956
|
+
purposeFor,
|
|
957
|
+
recoverMessagePublicKey,
|
|
958
|
+
scriptKind,
|
|
959
|
+
selectAll,
|
|
960
|
+
selectCoins,
|
|
961
|
+
signMessage,
|
|
962
|
+
signMessageWith,
|
|
963
|
+
spendableUtxos,
|
|
964
|
+
unconfirmedOutgoing
|
|
965
|
+
};
|
|
966
|
+
//# sourceMappingURL=index.js.map
|