@scure/btc-signer 2.2.0 → 2.4.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/README.md +196 -37
- package/index.d.ts +4 -4
- package/index.js +3 -4
- package/musig2.d.ts +28 -9
- package/musig2.js +46 -18
- package/net.d.ts +355 -0
- package/net.js +880 -0
- package/p2p.d.ts +0 -1
- package/p2p.js +23 -7
- package/package.json +18 -21
- package/payment.d.ts +18 -11
- package/payment.js +203 -58
- package/psbt.d.ts +680 -10
- package/psbt.js +204 -43
- package/script.d.ts +1 -2
- package/script.js +71 -59
- package/src/_type_test.ts +83 -0
- package/src/index.ts +4 -2
- package/src/musig2.ts +71 -19
- package/src/net.ts +1111 -0
- package/src/p2p.ts +22 -6
- package/src/payment.ts +233 -70
- package/src/psbt.ts +228 -39
- package/src/script.ts +57 -35
- package/src/transaction.ts +869 -168
- package/src/utils.ts +92 -4
- package/src/utxo.ts +223 -113
- package/transaction.d.ts +40 -7
- package/transaction.js +745 -151
- package/utils.d.ts +45 -2
- package/utils.js +80 -5
- package/utxo.d.ts +192 -2
- package/utxo.js +200 -99
- package/index.d.ts.map +0 -1
- package/index.js.map +0 -1
- package/musig2.d.ts.map +0 -1
- package/musig2.js.map +0 -1
- package/p2p.d.ts.map +0 -1
- package/p2p.js.map +0 -1
- package/payment.d.ts.map +0 -1
- package/payment.js.map +0 -1
- package/psbt.d.ts.map +0 -1
- package/psbt.js.map +0 -1
- package/script.d.ts.map +0 -1
- package/script.js.map +0 -1
- package/transaction.d.ts.map +0 -1
- package/transaction.js.map +0 -1
- package/utils.d.ts.map +0 -1
- package/utils.js.map +0 -1
- package/utxo.d.ts.map +0 -1
- package/utxo.js.map +0 -1
package/net.js
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
import { hex } from '@scure/base';
|
|
2
|
+
import { utils as packedUtils } from 'micro-packed';
|
|
3
|
+
import { Address } from "./payment.js";
|
|
4
|
+
import { getPrevOut, inputBeforeSign, normalizeInput, PRECISION, Transaction, } from "./transaction.js";
|
|
5
|
+
import { NETWORK } from "./utils.js";
|
|
6
|
+
// Be friendly to bad ECMAScript parsers by not using bigint literals.
|
|
7
|
+
const _0n = /* @__PURE__ */ BigInt(0);
|
|
8
|
+
/** Subclass for all EsploraProvider related errors */
|
|
9
|
+
export class EsploraError extends Error {
|
|
10
|
+
constructor(message, opts = {}) {
|
|
11
|
+
super(message);
|
|
12
|
+
if (opts.status !== undefined)
|
|
13
|
+
this.status = opts.status;
|
|
14
|
+
if (opts.path !== undefined)
|
|
15
|
+
this.path = opts.path;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const HEX64 = /^[0-9a-fA-F]{64}$/;
|
|
19
|
+
const ESPLORA_PER_PAGE = 25;
|
|
20
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
21
|
+
const validateRecord = (value, name) => {
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
23
|
+
throw new EsploraError(`expected ${name} object`);
|
|
24
|
+
return value;
|
|
25
|
+
};
|
|
26
|
+
const validateArray = (value, name) => {
|
|
27
|
+
if (!Array.isArray(value))
|
|
28
|
+
throw new EsploraError(`expected ${name} array`);
|
|
29
|
+
return value;
|
|
30
|
+
};
|
|
31
|
+
const validateString = (value, name) => {
|
|
32
|
+
if (typeof value !== 'string')
|
|
33
|
+
throw new EsploraError(`expected ${name} string`);
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
const validateBool = (value, name) => {
|
|
37
|
+
if (typeof value !== 'boolean')
|
|
38
|
+
throw new EsploraError(`expected ${name} boolean`);
|
|
39
|
+
return value;
|
|
40
|
+
};
|
|
41
|
+
const validateInt = (value, name) => {
|
|
42
|
+
const num = typeof value === 'string' && /^-?[0-9]+$/.test(value) ? Number(value) : value;
|
|
43
|
+
if (typeof num !== 'number' || !Number.isSafeInteger(num))
|
|
44
|
+
throw new EsploraError(`expected ${name} safe integer`);
|
|
45
|
+
return num;
|
|
46
|
+
};
|
|
47
|
+
const validateUint = (value, name) => {
|
|
48
|
+
const num = validateInt(value, name);
|
|
49
|
+
if (num < 0)
|
|
50
|
+
throw new EsploraError(`expected ${name} non-negative safe integer`);
|
|
51
|
+
return num;
|
|
52
|
+
};
|
|
53
|
+
const validatePositiveInt = (value, name) => {
|
|
54
|
+
const num = validateInt(value, name);
|
|
55
|
+
if (num <= 0)
|
|
56
|
+
throw new EsploraError(`expected ${name} positive safe integer`);
|
|
57
|
+
return num;
|
|
58
|
+
};
|
|
59
|
+
const validateBigint = (value, name) => {
|
|
60
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
|
|
61
|
+
return BigInt(value);
|
|
62
|
+
if (typeof value === 'string' && /^[0-9]+$/.test(value))
|
|
63
|
+
return BigInt(value);
|
|
64
|
+
throw new EsploraError(`expected ${name} non-negative integer`);
|
|
65
|
+
};
|
|
66
|
+
const pickString = (obj, key, name) => validateString(obj[key], name);
|
|
67
|
+
const validateHash = (value, name) => {
|
|
68
|
+
if (!HEX64.test(value))
|
|
69
|
+
throw new EsploraError(`expected ${name} hex string`);
|
|
70
|
+
return value.toLowerCase();
|
|
71
|
+
};
|
|
72
|
+
const txidPath = (txid) => {
|
|
73
|
+
if (typeof txid !== 'string')
|
|
74
|
+
throw new EsploraError('expected txid hex string');
|
|
75
|
+
return validateHash(txid, 'txid');
|
|
76
|
+
};
|
|
77
|
+
const parseRawTx = (raw, txid) => {
|
|
78
|
+
const tx = Transaction.fromRaw(hex.decode(raw), {
|
|
79
|
+
allowUnknownInputs: true,
|
|
80
|
+
allowUnknownOutputs: true,
|
|
81
|
+
disableScriptCheck: true,
|
|
82
|
+
// Consensus does not restrict nVersion; served history may contain
|
|
83
|
+
// transactions with non-standard versions and must still verify.
|
|
84
|
+
allowUnknownVersion: true,
|
|
85
|
+
});
|
|
86
|
+
if (tx.id !== txid)
|
|
87
|
+
throw new EsploraError(`wrong raw txid, expected ${txid} got ${tx.id}`);
|
|
88
|
+
};
|
|
89
|
+
const validateOpts = (opts) => {
|
|
90
|
+
if (opts !== undefined && !packedUtils.isPlainObject(opts))
|
|
91
|
+
throw new EsploraError(`"opts" expected object or undefined, got type=${typeof opts}`);
|
|
92
|
+
return { ...opts };
|
|
93
|
+
};
|
|
94
|
+
const validateScanOpts = (opts) => {
|
|
95
|
+
const res = validateOpts(opts);
|
|
96
|
+
if (res.concurrency !== undefined)
|
|
97
|
+
res.concurrency = validatePositiveInt(res.concurrency, 'concurrency');
|
|
98
|
+
return res;
|
|
99
|
+
};
|
|
100
|
+
const validateTransfersOpts = (opts) => {
|
|
101
|
+
const res = validateScanOpts(opts);
|
|
102
|
+
if (res.fromBlock !== undefined)
|
|
103
|
+
res.fromBlock = validateUint(res.fromBlock, 'fromBlock');
|
|
104
|
+
if (res.toBlock !== undefined)
|
|
105
|
+
res.toBlock = validateUint(res.toBlock, 'toBlock');
|
|
106
|
+
if (res.limit !== undefined)
|
|
107
|
+
res.limit = validatePositiveInt(res.limit, 'limit');
|
|
108
|
+
if (res.afterTxid !== undefined)
|
|
109
|
+
res.afterTxid = validateHash(res.afterTxid, 'afterTxid');
|
|
110
|
+
if (res.fromBlock !== undefined && res.toBlock !== undefined && res.toBlock < res.fromBlock)
|
|
111
|
+
throw new EsploraError('expected toBlock >= fromBlock');
|
|
112
|
+
if (res.afterTxid !== undefined && (res.fromBlock !== undefined || res.toBlock !== undefined))
|
|
113
|
+
throw new EsploraError('expected afterTxid without block range');
|
|
114
|
+
if (res.onProgress !== undefined && typeof res.onProgress !== 'function')
|
|
115
|
+
throw new EsploraError(`"onProgress" expected function, got type=${typeof res.onProgress}`);
|
|
116
|
+
return res;
|
|
117
|
+
};
|
|
118
|
+
const validateHistoryOpts = (opts) => {
|
|
119
|
+
const res = validateTransfersOpts(opts);
|
|
120
|
+
if (res.order !== undefined && res.order !== 'newest' && res.order !== 'oldest')
|
|
121
|
+
throw new EsploraError(`"order" expected 'newest' | 'oldest', got type=${typeof res.order}`);
|
|
122
|
+
return res;
|
|
123
|
+
};
|
|
124
|
+
const throwIfAborted = (signal, name) => {
|
|
125
|
+
if (signal && signal.aborted)
|
|
126
|
+
throw signal.reason ?? new EsploraError(`${name}: aborted`);
|
|
127
|
+
};
|
|
128
|
+
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
129
|
+
// An already-aborted signal never fires 'abort' again; without this check
|
|
130
|
+
// the full delay would elapse before the abort is observed.
|
|
131
|
+
if (signal && signal.aborted)
|
|
132
|
+
return reject(signal.reason ?? new EsploraError('aborted'));
|
|
133
|
+
const done = () => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
if (signal)
|
|
136
|
+
signal.removeEventListener('abort', onAbort);
|
|
137
|
+
};
|
|
138
|
+
const onAbort = () => {
|
|
139
|
+
done();
|
|
140
|
+
reject(signal.reason ?? new EsploraError('aborted'));
|
|
141
|
+
};
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
done();
|
|
144
|
+
resolve();
|
|
145
|
+
}, ms);
|
|
146
|
+
if (signal)
|
|
147
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
148
|
+
});
|
|
149
|
+
// Blockstream/electrs does not emit 500; public frontends shed load with
|
|
150
|
+
// 429/502/503/504 responses. GETs back off and retry those instead of aborting.
|
|
151
|
+
const RETRYABLE_STATUS = [429, 502, 503, 504];
|
|
152
|
+
// WPT records browser network failures as 'Failed to fetch', while undici uses
|
|
153
|
+
// 'fetch failed'; proxy HTML or truncated bodies surface as JSON parse errors.
|
|
154
|
+
const RETRYABLE_NET = new RegExp('failed to fetch|fetch failed|socket|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|' +
|
|
155
|
+
'not valid JSON|in JSON at position|unexpected end of JSON input|' +
|
|
156
|
+
'bad gateway|gateway time-?out|service unavailable', 'i');
|
|
157
|
+
const isTransientError = (error) => {
|
|
158
|
+
if (!(error instanceof Error))
|
|
159
|
+
return false;
|
|
160
|
+
if (error.name === 'AbortError')
|
|
161
|
+
return false; // user aborts are not transient
|
|
162
|
+
if (error instanceof EsploraError)
|
|
163
|
+
return error.status !== undefined && RETRYABLE_STATUS.includes(error.status);
|
|
164
|
+
return RETRYABLE_NET.test(error.message);
|
|
165
|
+
};
|
|
166
|
+
const RETRY_ATTEMPTS = 8;
|
|
167
|
+
// Exponential backoff with jitter, ~125ms first: the tail must outlast a
|
|
168
|
+
// rate-limit burst, not just a dropped request.
|
|
169
|
+
const retryDelay = (attempt) => Math.min(250 * 2 ** attempt, 8000) * (0.5 + Math.random());
|
|
170
|
+
// Maps items through an async fn with at most `concurrency` in flight,
|
|
171
|
+
// preserving input order in the result: raw-tx fan-out must not stampede
|
|
172
|
+
// rate-limited backends with one giant Promise.all.
|
|
173
|
+
async function mapPool(items, fn, opts) {
|
|
174
|
+
const out = new Array(items.length);
|
|
175
|
+
let cursor = 0;
|
|
176
|
+
// Once any item fails the pool's result is already lost; surviving workers
|
|
177
|
+
// finish their in-flight item but must not keep pulling new ones against a
|
|
178
|
+
// backend that may be the very reason for the failure.
|
|
179
|
+
let failed = false;
|
|
180
|
+
const worker = async () => {
|
|
181
|
+
for (;;) {
|
|
182
|
+
throwIfAborted(opts.signal, opts.name);
|
|
183
|
+
if (failed)
|
|
184
|
+
return;
|
|
185
|
+
const index = cursor++;
|
|
186
|
+
if (index >= items.length)
|
|
187
|
+
return;
|
|
188
|
+
try {
|
|
189
|
+
out[index] = await fn(items[index], index);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
failed = true;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
await Promise.all(Array.from({ length: Math.min(opts.concurrency, items.length) }, worker));
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
const fixStatus = (value) => {
|
|
201
|
+
const raw = validateRecord(value, 'tx.status');
|
|
202
|
+
// Esplora uses snake_case and second timestamps; expose the same compact names and
|
|
203
|
+
// millisecond timestamps as the rest of this provider surface.
|
|
204
|
+
const res = { confirmed: validateBool(raw.confirmed, 'status.confirmed') };
|
|
205
|
+
if (raw.block_height !== undefined)
|
|
206
|
+
res.block = validateUint(raw.block_height, 'block_height');
|
|
207
|
+
if (raw.block_hash !== undefined)
|
|
208
|
+
res.blockHash = validateHash(validateString(raw.block_hash, 'block_hash'), 'block_hash');
|
|
209
|
+
if (raw.block_time !== undefined)
|
|
210
|
+
res.timestamp = validateUint(raw.block_time, 'block_time') * 1000;
|
|
211
|
+
return res;
|
|
212
|
+
};
|
|
213
|
+
const fixOutput = (value) => {
|
|
214
|
+
const raw = validateRecord(value, 'tx.output');
|
|
215
|
+
const res = {
|
|
216
|
+
scriptPubKey: pickString(raw, 'scriptpubkey', 'scriptpubkey'),
|
|
217
|
+
value: validateBigint(raw.value, 'output.value'),
|
|
218
|
+
};
|
|
219
|
+
if (raw.scriptpubkey_address !== undefined)
|
|
220
|
+
res.scriptPubKeyAddress = validateString(raw.scriptpubkey_address, 'scriptpubkey_address');
|
|
221
|
+
return res;
|
|
222
|
+
};
|
|
223
|
+
const fixInput = (value) => {
|
|
224
|
+
const raw = validateRecord(value, 'tx.input');
|
|
225
|
+
const res = {};
|
|
226
|
+
if (raw.txid !== undefined)
|
|
227
|
+
res.txid = validateHash(validateString(raw.txid, 'input.txid'), 'input.txid');
|
|
228
|
+
if (raw.vout !== undefined)
|
|
229
|
+
res.index = validateUint(raw.vout, 'input.vout');
|
|
230
|
+
if (raw.prevout !== undefined && raw.prevout)
|
|
231
|
+
res.prevout = fixOutput(raw.prevout);
|
|
232
|
+
if (raw.sequence !== undefined)
|
|
233
|
+
res.sequence = validateUint(raw.sequence, 'input.sequence');
|
|
234
|
+
if (raw.is_coinbase !== undefined)
|
|
235
|
+
res.isCoinbase = validateBool(raw.is_coinbase, 'input.is_coinbase');
|
|
236
|
+
return res;
|
|
237
|
+
};
|
|
238
|
+
const fixTx = (value) => {
|
|
239
|
+
const raw = validateRecord(value, 'tx');
|
|
240
|
+
return {
|
|
241
|
+
txid: validateHash(pickString(raw, 'txid', 'txid'), 'txid'),
|
|
242
|
+
version: validateInt(raw.version, 'version'),
|
|
243
|
+
lockTime: validateUint(raw.locktime, 'locktime'),
|
|
244
|
+
size: validateUint(raw.size, 'size'),
|
|
245
|
+
weight: validateUint(raw.weight, 'weight'),
|
|
246
|
+
fee: validateBigint(raw.fee, 'fee'),
|
|
247
|
+
inputs: validateArray(raw.vin, 'vin').map(fixInput),
|
|
248
|
+
outputs: validateArray(raw.vout, 'vout').map(fixOutput),
|
|
249
|
+
status: fixStatus(raw.status),
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
const fixBlock = (value, transactions) => {
|
|
253
|
+
const raw = validateRecord(value, 'block');
|
|
254
|
+
const res = {
|
|
255
|
+
hash: validateHash(pickString(raw, 'id', 'block.id'), 'block.id'),
|
|
256
|
+
number: validateUint(raw.height, 'block.height'),
|
|
257
|
+
version: validateInt(raw.version, 'block.version'),
|
|
258
|
+
timestamp: validateUint(raw.timestamp, 'block.timestamp') * 1000,
|
|
259
|
+
size: validateUint(raw.size, 'block.size'),
|
|
260
|
+
weight: validateUint(raw.weight, 'block.weight'),
|
|
261
|
+
merkleRoot: validateHash(pickString(raw, 'merkle_root', 'block.merkle_root'), 'merkle_root'),
|
|
262
|
+
transactions,
|
|
263
|
+
};
|
|
264
|
+
if (raw.previousblockhash !== undefined)
|
|
265
|
+
res.parentHash = validateHash(validateString(raw.previousblockhash, 'previousblockhash'), 'previousblockhash');
|
|
266
|
+
if (raw.mediantime !== undefined)
|
|
267
|
+
res.medianTime = validateUint(raw.mediantime, 'block.mediantime') * 1000;
|
|
268
|
+
if (raw.nonce !== undefined)
|
|
269
|
+
res.nonce = validateUint(raw.nonce, 'block.nonce');
|
|
270
|
+
if (raw.bits !== undefined)
|
|
271
|
+
res.bits = validateUint(raw.bits, 'block.bits');
|
|
272
|
+
if (raw.difficulty !== undefined) {
|
|
273
|
+
const difficulty = typeof raw.difficulty === 'string' && /^[0-9]+(?:\.[0-9]+)?$/.test(raw.difficulty)
|
|
274
|
+
? Number(raw.difficulty)
|
|
275
|
+
: raw.difficulty;
|
|
276
|
+
if (typeof difficulty !== 'number' || !Number.isFinite(difficulty))
|
|
277
|
+
throw new EsploraError('expected block.difficulty finite number');
|
|
278
|
+
res.difficulty = difficulty;
|
|
279
|
+
}
|
|
280
|
+
return res;
|
|
281
|
+
};
|
|
282
|
+
const txTransfersRow = (tx, raw) => {
|
|
283
|
+
const transfers = [];
|
|
284
|
+
for (const input of tx.inputs) {
|
|
285
|
+
if (!input.prevout)
|
|
286
|
+
continue;
|
|
287
|
+
const transfer = { value: input.prevout.value };
|
|
288
|
+
if (input.prevout.scriptPubKeyAddress !== undefined)
|
|
289
|
+
transfer.from = input.prevout.scriptPubKeyAddress;
|
|
290
|
+
transfers.push(transfer);
|
|
291
|
+
}
|
|
292
|
+
for (const output of tx.outputs) {
|
|
293
|
+
const transfer = { value: output.value };
|
|
294
|
+
if (output.scriptPubKeyAddress !== undefined)
|
|
295
|
+
transfer.to = output.scriptPubKeyAddress;
|
|
296
|
+
transfers.push(transfer);
|
|
297
|
+
}
|
|
298
|
+
const info = {
|
|
299
|
+
version: tx.version,
|
|
300
|
+
lockTime: tx.lockTime,
|
|
301
|
+
size: tx.size,
|
|
302
|
+
weight: tx.weight,
|
|
303
|
+
fee: tx.fee,
|
|
304
|
+
raw,
|
|
305
|
+
};
|
|
306
|
+
if (tx.status.blockHash !== undefined)
|
|
307
|
+
info.blockHash = tx.status.blockHash;
|
|
308
|
+
const res = { txid: tx.txid, transfers, info };
|
|
309
|
+
if (tx.status.timestamp !== undefined)
|
|
310
|
+
res.timestamp = tx.status.timestamp;
|
|
311
|
+
if (tx.status.block !== undefined)
|
|
312
|
+
res.block = tx.status.block;
|
|
313
|
+
return res;
|
|
314
|
+
};
|
|
315
|
+
/**
|
|
316
|
+
* Esplora-compatible Bitcoin HTTP provider.
|
|
317
|
+
*
|
|
318
|
+
* Runtime transport is caller-provided `fetch`. The repository `test/proxy.ts`
|
|
319
|
+
* bridge is test/dev tooling for serving the wallet/history HTTP subset from Electrum TCP.
|
|
320
|
+
* Transient backend failures (429/5xx, dropped connections) are retried with
|
|
321
|
+
* exponential backoff on GET requests; long-running scans accept `AbortSignal`.
|
|
322
|
+
* @param fetch - Fetch-compatible HTTP transport.
|
|
323
|
+
* @param url - Base URL of an Esplora-compatible HTTP API.
|
|
324
|
+
* @param network - Bitcoin address network parameters.
|
|
325
|
+
* @example
|
|
326
|
+
* Create a provider with a caller-owned transport.
|
|
327
|
+
* ```ts
|
|
328
|
+
* import { EsploraProvider } from '@scure/btc-signer/net.js';
|
|
329
|
+
* const httpFetch = async () => ({
|
|
330
|
+
* ok: true,
|
|
331
|
+
* status: 200,
|
|
332
|
+
* text: async () => '1',
|
|
333
|
+
* json: async () => ({ '2': 1 }),
|
|
334
|
+
* });
|
|
335
|
+
* const net = new EsploraProvider(httpFetch, 'http://127.0.0.1:3000');
|
|
336
|
+
* await net.height();
|
|
337
|
+
* ```
|
|
338
|
+
*/
|
|
339
|
+
export class EsploraProvider {
|
|
340
|
+
fetch;
|
|
341
|
+
url;
|
|
342
|
+
address;
|
|
343
|
+
constructor(fetch, url, network = NETWORK) {
|
|
344
|
+
if (typeof fetch !== 'function')
|
|
345
|
+
throw new EsploraError('expected fetch function');
|
|
346
|
+
if (typeof url !== 'string' || !url.length)
|
|
347
|
+
throw new EsploraError('expected url');
|
|
348
|
+
this.fetch = fetch;
|
|
349
|
+
this.url = url;
|
|
350
|
+
this.address = Address(network);
|
|
351
|
+
}
|
|
352
|
+
// Single attempt, no retry: used directly by POSTs (sendTx), which could
|
|
353
|
+
// succeed on the backend while the response is lost — blindly
|
|
354
|
+
// re-broadcasting would misreport.
|
|
355
|
+
async request(path, opts = {}) {
|
|
356
|
+
const method = opts.method || 'GET';
|
|
357
|
+
const res = await this.fetch(`${this.url}${path}`, opts);
|
|
358
|
+
if (res.ok)
|
|
359
|
+
return res;
|
|
360
|
+
const text = await res.text();
|
|
361
|
+
const status = res.statusText ? `${res.status} ${res.statusText}` : `${res.status}`;
|
|
362
|
+
const suffix = text ? `: ${text}` : '';
|
|
363
|
+
throw new EsploraError(`${method} ${path} failed ${status}${suffix}`, {
|
|
364
|
+
status: res.status,
|
|
365
|
+
path,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
// Retrying GET with the body consumed INSIDE the retry scope: a proxy
|
|
369
|
+
// serving an HTML error page with status 200 only fails at res.json(), and
|
|
370
|
+
// that failure must back off like a status failure would.
|
|
371
|
+
async requestBody(path, kind, signal) {
|
|
372
|
+
const opts = signal ? { signal } : {};
|
|
373
|
+
for (let attempt = 0;; attempt++) {
|
|
374
|
+
// Injected transports may ignore an already-aborted signal, so cancel before every attempt.
|
|
375
|
+
throwIfAborted(signal, 'request');
|
|
376
|
+
try {
|
|
377
|
+
const res = await this.request(path, opts);
|
|
378
|
+
return kind === 'json' ? await res.json() : await res.text();
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
if (attempt >= RETRY_ATTEMPTS || !isTransientError(error))
|
|
382
|
+
throw error;
|
|
383
|
+
await sleep(retryDelay(attempt), signal);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
getJson(path, signal) {
|
|
388
|
+
return this.requestBody(path, 'json', signal);
|
|
389
|
+
}
|
|
390
|
+
getText(path, signal) {
|
|
391
|
+
return this.requestBody(path, 'text', signal);
|
|
392
|
+
}
|
|
393
|
+
addressPath(address) {
|
|
394
|
+
if (typeof address !== 'string')
|
|
395
|
+
throw new EsploraError('expected address string');
|
|
396
|
+
this.address.decode(address);
|
|
397
|
+
return address;
|
|
398
|
+
}
|
|
399
|
+
canonicalAddress(address) {
|
|
400
|
+
if (typeof address !== 'string')
|
|
401
|
+
throw new EsploraError('expected address string');
|
|
402
|
+
return this.address.encode(this.address.decode(address));
|
|
403
|
+
}
|
|
404
|
+
txHex(txid, signal) {
|
|
405
|
+
return this.getText(`/tx/${txidPath(txid)}/hex`, signal);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Fetches raw tx hex and verifies it is actually the transaction the txid
|
|
409
|
+
* names, otherwise balances would be computed from whatever transaction the
|
|
410
|
+
* backend chose to serve. `memo` dedupes fetches within one scan: a tx
|
|
411
|
+
* shared by several watched addresses must cost one request, not one per
|
|
412
|
+
* address stream.
|
|
413
|
+
*/
|
|
414
|
+
fetchRawTx(txid, signal, memo) {
|
|
415
|
+
const cached = memo?.get(txid);
|
|
416
|
+
if (cached)
|
|
417
|
+
return cached;
|
|
418
|
+
const raw = this.txHex(txid, signal).then((rawTx) => {
|
|
419
|
+
parseRawTx(rawTx, txid);
|
|
420
|
+
return rawTx;
|
|
421
|
+
});
|
|
422
|
+
memo?.set(txid, raw);
|
|
423
|
+
return raw;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Pages through Esplora address history newest-first, one transaction at a
|
|
427
|
+
* time. The single owner of cursor pagination: the mempool-aware afterTxid
|
|
428
|
+
* jump, the full-page heuristic and the cursor-loop guard live here.
|
|
429
|
+
*/
|
|
430
|
+
async *addressTxs(addr, afterTxid, signal) {
|
|
431
|
+
// Guards against backends that ignore the chain cursor and keep returning
|
|
432
|
+
// the same page, which would otherwise loop forever.
|
|
433
|
+
const seenCursors = new Set();
|
|
434
|
+
let path = `/address/${addr}/txs`;
|
|
435
|
+
// The first page holds mempool transactions the /txs/chain cursor cannot
|
|
436
|
+
// address, so the cursor is searched there before jumping.
|
|
437
|
+
let cursor = afterTxid;
|
|
438
|
+
for (;;) {
|
|
439
|
+
throwIfAborted(signal, 'history');
|
|
440
|
+
const page = validateArray(await this.getJson(path, signal), 'address.txs').map(fixTx);
|
|
441
|
+
if (!page.length)
|
|
442
|
+
break;
|
|
443
|
+
let start = 0;
|
|
444
|
+
if (cursor !== undefined) {
|
|
445
|
+
const idx = page.findIndex((tx) => tx.txid === cursor);
|
|
446
|
+
if (idx === -1) {
|
|
447
|
+
seenCursors.add(cursor);
|
|
448
|
+
path = `/address/${addr}/txs/chain/${cursor}`;
|
|
449
|
+
cursor = undefined;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
start = idx + 1;
|
|
453
|
+
cursor = undefined;
|
|
454
|
+
}
|
|
455
|
+
for (let i = start; i < page.length; i++)
|
|
456
|
+
yield page[i];
|
|
457
|
+
const last = page[page.length - 1];
|
|
458
|
+
if (page.length < ESPLORA_PER_PAGE || !last.status.confirmed)
|
|
459
|
+
break;
|
|
460
|
+
if (seenCursors.has(last.txid))
|
|
461
|
+
throw new EsploraError('history: pagination cursor loop');
|
|
462
|
+
seenCursors.add(last.txid);
|
|
463
|
+
path = `/address/${addr}/txs/chain/${last.txid}`;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async *historyInner(addr, opts, rawMemo) {
|
|
467
|
+
const signal = opts.signal;
|
|
468
|
+
const newest = (opts.order ?? 'newest') === 'newest';
|
|
469
|
+
const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
|
|
470
|
+
let scannedTxs = 0;
|
|
471
|
+
// Progress percent is exact: address stats already count confirmed and
|
|
472
|
+
// mempool transactions, at the cost of one extra request when listening.
|
|
473
|
+
const totalTxs = opts.onProgress ? (await this.balance(addr, { signal })).txCount : 0;
|
|
474
|
+
const report = (block) => {
|
|
475
|
+
if (!opts.onProgress)
|
|
476
|
+
return;
|
|
477
|
+
const progress = {
|
|
478
|
+
scannedTxs,
|
|
479
|
+
totalTxs,
|
|
480
|
+
percent: totalTxs ? Math.min(100, Math.round((scannedTxs / totalTxs) * 100)) : 100,
|
|
481
|
+
};
|
|
482
|
+
if (block !== undefined)
|
|
483
|
+
progress.currentBlock = block;
|
|
484
|
+
opts.onProgress(progress);
|
|
485
|
+
};
|
|
486
|
+
const net = this;
|
|
487
|
+
const resolve = async function* (chunk) {
|
|
488
|
+
const raws = await mapPool(chunk, (tx) => net.fetchRawTx(tx.txid, signal, rawMemo), {
|
|
489
|
+
concurrency,
|
|
490
|
+
signal,
|
|
491
|
+
name: 'history',
|
|
492
|
+
});
|
|
493
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
494
|
+
// An abort between pulls must stop before another already-resolved row escapes.
|
|
495
|
+
throwIfAborted(signal, 'history');
|
|
496
|
+
yield txTransfersRow(chunk[i], raws[i]);
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
const buffered = [];
|
|
500
|
+
let chunk = [];
|
|
501
|
+
let kept = 0;
|
|
502
|
+
for await (const tx of this.addressTxs(addr, opts.afterTxid, signal)) {
|
|
503
|
+
scannedTxs++;
|
|
504
|
+
const block = tx.status.block;
|
|
505
|
+
report(block);
|
|
506
|
+
// Unconfirmed transactions have no block. Keep them in open-ended syncs, but not in fixed
|
|
507
|
+
// historical ranges where callers asked for an exact block interval.
|
|
508
|
+
const inRange = block === undefined
|
|
509
|
+
? opts.toBlock === undefined
|
|
510
|
+
: !((opts.fromBlock !== undefined && block < opts.fromBlock) ||
|
|
511
|
+
(opts.toBlock !== undefined && block > opts.toBlock));
|
|
512
|
+
if (inRange) {
|
|
513
|
+
kept++;
|
|
514
|
+
if (newest) {
|
|
515
|
+
chunk.push(tx);
|
|
516
|
+
if (chunk.length >= ESPLORA_PER_PAGE) {
|
|
517
|
+
for await (const row of resolve(chunk))
|
|
518
|
+
yield row;
|
|
519
|
+
chunk = [];
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
else
|
|
523
|
+
buffered.push(tx);
|
|
524
|
+
}
|
|
525
|
+
if (opts.limit !== undefined && kept >= opts.limit)
|
|
526
|
+
break;
|
|
527
|
+
// Chain pages are newest-first: once a confirmed transaction drops below
|
|
528
|
+
// fromBlock, everything further is older and the scan can stop.
|
|
529
|
+
if (block !== undefined && opts.fromBlock !== undefined && block < opts.fromBlock)
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
if (newest) {
|
|
533
|
+
if (chunk.length)
|
|
534
|
+
for await (const row of resolve(chunk))
|
|
535
|
+
yield row;
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// Esplora address history is newest-first. Reverse the scanned stream instead of sorting by
|
|
539
|
+
// txid; same-block txids have no chronological meaning and sorting them reshuffles page
|
|
540
|
+
// boundaries when callers increase `limit` for address pagination.
|
|
541
|
+
buffered.reverse();
|
|
542
|
+
for (let i = 0; i < buffered.length; i += ESPLORA_PER_PAGE)
|
|
543
|
+
for await (const row of resolve(buffered.slice(i, i + ESPLORA_PER_PAGE)))
|
|
544
|
+
yield row;
|
|
545
|
+
}
|
|
546
|
+
async *historyMultiInner(addresses, opts) {
|
|
547
|
+
const newest = (opts.order ?? 'newest') === 'newest';
|
|
548
|
+
// Mempool rows (no block) sort as newest; same-block rows from different
|
|
549
|
+
// addresses have no canonical order and keep stream priority.
|
|
550
|
+
const cmp = (a, b) => {
|
|
551
|
+
const aBlock = a.block ?? Number.MAX_SAFE_INTEGER;
|
|
552
|
+
const bBlock = b.block ?? Number.MAX_SAFE_INTEGER;
|
|
553
|
+
if (aBlock === bBlock)
|
|
554
|
+
return 0;
|
|
555
|
+
return (aBlock < bBlock ? 1 : -1) * (newest ? 1 : -1);
|
|
556
|
+
};
|
|
557
|
+
// One raw-tx memo for the whole merged scan: a tx touching several watched
|
|
558
|
+
// addresses is discovered by each of their streams but fetched only once.
|
|
559
|
+
const rawMemo = new Map();
|
|
560
|
+
const streams = addresses.map((address) => this.historyInner(address, opts, rawMemo));
|
|
561
|
+
const heads = new Array(streams.length).fill(undefined);
|
|
562
|
+
// Streams advance one at a time: keeps request bursts bounded, and a k-way
|
|
563
|
+
// merge only ever needs one new head per yield.
|
|
564
|
+
const advance = async (index) => {
|
|
565
|
+
const item = await streams[index].next();
|
|
566
|
+
heads[index] = item.done ? undefined : item.value;
|
|
567
|
+
};
|
|
568
|
+
try {
|
|
569
|
+
for (let i = 0; i < streams.length; i++)
|
|
570
|
+
await advance(i);
|
|
571
|
+
// A transaction touching several watched addresses appears in each of
|
|
572
|
+
// their histories; it must merge into one row, not repeat per address.
|
|
573
|
+
const seen = new Set();
|
|
574
|
+
for (;;) {
|
|
575
|
+
throwIfAborted(opts.signal, 'historyMulti');
|
|
576
|
+
let best = -1;
|
|
577
|
+
for (let i = 0; i < heads.length; i++) {
|
|
578
|
+
if (heads[i] === undefined)
|
|
579
|
+
continue;
|
|
580
|
+
if (best < 0 || cmp(heads[i], heads[best]) < 0)
|
|
581
|
+
best = i;
|
|
582
|
+
}
|
|
583
|
+
if (best < 0)
|
|
584
|
+
return;
|
|
585
|
+
const row = heads[best];
|
|
586
|
+
if (!seen.has(row.txid)) {
|
|
587
|
+
seen.add(row.txid);
|
|
588
|
+
const participants = addresses.filter((address) => row.transfers.some((transfer) => transfer.from === address || transfer.to === address));
|
|
589
|
+
// A fallible next read must not suppress the head this stream already resolved.
|
|
590
|
+
yield { ...row, addresses: participants };
|
|
591
|
+
}
|
|
592
|
+
await advance(best);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
await Promise.allSettled(streams.map((stream) => stream.return(undefined)));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
async height(opts = {}) {
|
|
600
|
+
return validateUint(await this.getText('/blocks/tip/height', opts.signal), 'height');
|
|
601
|
+
}
|
|
602
|
+
async blockInfo(block) {
|
|
603
|
+
const hash = validateHash(await this.getText(`/block-height/${validateUint(block, 'block')}`), 'block hash');
|
|
604
|
+
const [info, rawTxids] = await Promise.all([
|
|
605
|
+
this.getJson(`/block/${hash}`),
|
|
606
|
+
this.getJson(`/block/${hash}/txids`),
|
|
607
|
+
]);
|
|
608
|
+
const transactions = validateArray(rawTxids, 'block.txids').map((txid) => validateHash(validateString(txid, 'block.txid'), 'block.txid'));
|
|
609
|
+
return fixBlock(info, transactions);
|
|
610
|
+
}
|
|
611
|
+
async fee(target = 2) {
|
|
612
|
+
target = validatePositiveInt(target, 'fee target');
|
|
613
|
+
const fees = validateRecord(await this.getJson('/fee-estimates'), 'fee-estimates');
|
|
614
|
+
let fee = fees[String(target)];
|
|
615
|
+
if (fee === undefined) {
|
|
616
|
+
const keys = Object.keys(fees)
|
|
617
|
+
.map((i) => Number(i))
|
|
618
|
+
.filter((i) => Number.isSafeInteger(i) && i > 0)
|
|
619
|
+
.sort((a, b) => a - b);
|
|
620
|
+
// Missing exact targets use the next faster estimate first; underpaying is worse than
|
|
621
|
+
// overpaying by the small gap between adjacent Esplora fee targets.
|
|
622
|
+
const faster = keys.filter((i) => i <= target).pop();
|
|
623
|
+
const slower = keys.find((i) => i > target);
|
|
624
|
+
const key = faster || slower;
|
|
625
|
+
if (key !== undefined)
|
|
626
|
+
fee = fees[String(key)];
|
|
627
|
+
}
|
|
628
|
+
if (typeof fee !== 'number' || !Number.isFinite(fee) || fee <= 0)
|
|
629
|
+
throw new EsploraError('missing fee estimate');
|
|
630
|
+
return BigInt(Math.ceil(fee));
|
|
631
|
+
}
|
|
632
|
+
/** Lightweight method to receive the "unspent" amount without getting the full UTXO list. */
|
|
633
|
+
async balance(address, opts = {}) {
|
|
634
|
+
const res = validateRecord(await this.getJson(`/address/${this.addressPath(address)}`, opts.signal), 'address');
|
|
635
|
+
const chain = validateRecord(res.chain_stats, 'chain_stats');
|
|
636
|
+
const mempool = validateRecord(res.mempool_stats, 'mempool_stats');
|
|
637
|
+
const funded = validateBigint(chain.funded_txo_sum, 'chain.funded_txo_sum') +
|
|
638
|
+
validateBigint(mempool.funded_txo_sum, 'mempool.funded_txo_sum');
|
|
639
|
+
const spent = validateBigint(chain.spent_txo_sum, 'chain.spent_txo_sum') +
|
|
640
|
+
validateBigint(mempool.spent_txo_sum, 'mempool.spent_txo_sum');
|
|
641
|
+
const txCount = validateUint(chain.tx_count, 'chain.tx_count') +
|
|
642
|
+
validateUint(mempool.tx_count, 'mempool.tx_count');
|
|
643
|
+
return { symbol: 'BTC', decimals: PRECISION, balance: funded - spent, txCount };
|
|
644
|
+
}
|
|
645
|
+
async txCount(address, opts = {}) {
|
|
646
|
+
return (await this.balance(address, opts)).txCount;
|
|
647
|
+
}
|
|
648
|
+
async sendTx(tx) {
|
|
649
|
+
if (typeof tx !== 'string')
|
|
650
|
+
throw new EsploraError('expected tx hex string');
|
|
651
|
+
return validateHash(await (await this.request('/tx', {
|
|
652
|
+
method: 'POST',
|
|
653
|
+
headers: { 'content-type': 'text/plain' },
|
|
654
|
+
body: tx,
|
|
655
|
+
})).text(), 'broadcast txid');
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Polls transaction status until it confirms (plus optional extra
|
|
659
|
+
* confirmations). A just-broadcast transaction may briefly be unknown to the
|
|
660
|
+
* backend, so 404 responses keep polling instead of failing.
|
|
661
|
+
*/
|
|
662
|
+
async waitForTx(txid, opts = {}) {
|
|
663
|
+
const id = txidPath(txid);
|
|
664
|
+
const options = validateOpts(opts);
|
|
665
|
+
const confirmations = options.confirmations === undefined
|
|
666
|
+
? 1
|
|
667
|
+
: validatePositiveInt(options.confirmations, 'confirmations');
|
|
668
|
+
const pollIntervalMs = options.pollIntervalMs === undefined
|
|
669
|
+
? 5000
|
|
670
|
+
: validatePositiveInt(options.pollIntervalMs, 'pollIntervalMs');
|
|
671
|
+
if (options.timeoutMs !== undefined)
|
|
672
|
+
validatePositiveInt(options.timeoutMs, 'timeoutMs');
|
|
673
|
+
const start = Date.now();
|
|
674
|
+
const deadline = async (fn) => {
|
|
675
|
+
if (options.timeoutMs === undefined)
|
|
676
|
+
return fn(options.signal);
|
|
677
|
+
const remaining = options.timeoutMs - (Date.now() - start);
|
|
678
|
+
if (remaining <= 0)
|
|
679
|
+
throw new EsploraError('waitForTx: timeout');
|
|
680
|
+
const error = new EsploraError('waitForTx: timeout');
|
|
681
|
+
const controller = new AbortController();
|
|
682
|
+
const signal = options.signal
|
|
683
|
+
? AbortSignal.any([options.signal, controller.signal])
|
|
684
|
+
: controller.signal;
|
|
685
|
+
// Race too: a caller-supplied transport may ignore AbortSignal.
|
|
686
|
+
const expired = sleep(remaining, controller.signal).then(() => {
|
|
687
|
+
controller.abort(error);
|
|
688
|
+
throw error;
|
|
689
|
+
});
|
|
690
|
+
try {
|
|
691
|
+
return await Promise.race([fn(signal), expired]);
|
|
692
|
+
}
|
|
693
|
+
finally {
|
|
694
|
+
controller.abort();
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
for (;;) {
|
|
698
|
+
throwIfAborted(options.signal, 'waitForTx');
|
|
699
|
+
if (options.timeoutMs !== undefined && Date.now() - start >= options.timeoutMs)
|
|
700
|
+
throw new EsploraError('waitForTx: timeout');
|
|
701
|
+
try {
|
|
702
|
+
// Single attempt, no request-level retry: the poll loop is already a
|
|
703
|
+
// retry cadence, and stacking backoff inside it only delays polls.
|
|
704
|
+
const status = await deadline(async (signal) => {
|
|
705
|
+
const res = await this.request(`/tx/${id}/status`, signal ? { signal } : {});
|
|
706
|
+
return fixStatus(await res.json());
|
|
707
|
+
});
|
|
708
|
+
if (status.confirmed && status.block !== undefined) {
|
|
709
|
+
if (confirmations <= 1)
|
|
710
|
+
return status;
|
|
711
|
+
const height = await deadline((signal) => this.height({ signal }));
|
|
712
|
+
if (height - status.block + 1 >= confirmations)
|
|
713
|
+
return status;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
// 404: a just-broadcast transaction may not be visible to the backend
|
|
718
|
+
// yet. Transient failures: an outage must not reject a wait that is
|
|
719
|
+
// documented to run until timeoutMs; the next poll retries. Anything
|
|
720
|
+
// else (validation, abort, 4xx) propagates.
|
|
721
|
+
if (!(error instanceof EsploraError && error.status === 404) && !isTransientError(error))
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
724
|
+
const remaining = options.timeoutMs === undefined ? pollIntervalMs : options.timeoutMs - (Date.now() - start);
|
|
725
|
+
if (remaining <= 0)
|
|
726
|
+
throw new EsploraError('waitForTx: timeout');
|
|
727
|
+
if (options.timeoutMs !== undefined && remaining <= pollIntervalMs) {
|
|
728
|
+
// Sleeping the rest of the window reaches the deadline; the timer may
|
|
729
|
+
// wake before Date.now() agrees, so don't re-poll on a clock check.
|
|
730
|
+
await sleep(remaining, options.signal);
|
|
731
|
+
throw new EsploraError('waitForTx: timeout');
|
|
732
|
+
}
|
|
733
|
+
await sleep(pollIntervalMs, options.signal);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
async txInfo(txid) {
|
|
737
|
+
const id = txidPath(txid);
|
|
738
|
+
const [rawInfo, raw] = await Promise.all([this.getJson(`/tx/${id}`), this.txHex(id)]);
|
|
739
|
+
const info = fixTx(rawInfo);
|
|
740
|
+
if (info.txid !== id)
|
|
741
|
+
throw new EsploraError(`wrong txid, expected ${id} got ${info.txid}`);
|
|
742
|
+
parseRawTx(raw, id);
|
|
743
|
+
return { ...info, raw };
|
|
744
|
+
}
|
|
745
|
+
async unspent(address, opts = {}) {
|
|
746
|
+
const options = validateScanOpts(opts);
|
|
747
|
+
const items = validateArray(await this.getJson(`/address/${this.addressPath(address)}/utxo`, options.signal), 'address.utxo');
|
|
748
|
+
const outpoints = items.map((value) => {
|
|
749
|
+
const raw = validateRecord(value, 'utxo');
|
|
750
|
+
return {
|
|
751
|
+
txid: validateHash(pickString(raw, 'txid', 'utxo.txid'), 'utxo.txid'),
|
|
752
|
+
index: validateUint(raw.vout, 'utxo.vout'),
|
|
753
|
+
};
|
|
754
|
+
});
|
|
755
|
+
// Several outputs of one funding transaction need its hex only once.
|
|
756
|
+
const txids = [...new Set(outpoints.map((outpoint) => outpoint.txid))];
|
|
757
|
+
const raws = await mapPool(txids, (txid) => this.fetchRawTx(txid, options.signal), {
|
|
758
|
+
concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
|
|
759
|
+
signal: options.signal,
|
|
760
|
+
name: 'unspent',
|
|
761
|
+
});
|
|
762
|
+
const rawByTxid = new Map(txids.map((txid, i) => [txid, raws[i]]));
|
|
763
|
+
const utxo = outpoints.map(({ txid, index }) => ({
|
|
764
|
+
txid,
|
|
765
|
+
index,
|
|
766
|
+
nonWitnessUtxo: hex.decode(rawByTxid.get(txid)),
|
|
767
|
+
}));
|
|
768
|
+
let balance = _0n;
|
|
769
|
+
for (const input of utxo) {
|
|
770
|
+
const normalized = normalizeInput(input, undefined, undefined, true);
|
|
771
|
+
inputBeforeSign(normalized);
|
|
772
|
+
balance += getPrevOut(normalized).amount;
|
|
773
|
+
}
|
|
774
|
+
return { symbol: 'BTC', decimals: PRECISION, balance, utxo };
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Streaming address history. Yields the same {@link TxTransfers} rows as
|
|
778
|
+
* {@link EsploraProvider.transfers}, one at a time, so callers can render or
|
|
779
|
+
* persist rows without waiting for the whole scan.
|
|
780
|
+
*
|
|
781
|
+
* `order: 'newest'` (default) follows Esplora pagination from the mempool
|
|
782
|
+
* backward and streams genuinely: stopping early (break / `limit`) also
|
|
783
|
+
* stops fetching. `order: 'oldest'` yields in transfers() order and must
|
|
784
|
+
* buffer transaction metadata first, since Esplora only pages newest-first;
|
|
785
|
+
* raw transactions still stream in bounded batches.
|
|
786
|
+
* @example
|
|
787
|
+
* ```ts
|
|
788
|
+
* for await (const tx of net.history(address, { limit: 10 })) console.log(tx.txid);
|
|
789
|
+
* ```
|
|
790
|
+
*/
|
|
791
|
+
history(address, opts = {}) {
|
|
792
|
+
const options = validateHistoryOpts(opts);
|
|
793
|
+
return this.historyInner(this.addressPath(address), options);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Merged history across several addresses (HD wallets, watch lists): one
|
|
797
|
+
* txid-deduplicated stream in `order`, k-way merged from per-address
|
|
798
|
+
* {@link EsploraProvider.history} streams. A transaction moving funds
|
|
799
|
+
* between two watched addresses appears once; its `addresses` field lists
|
|
800
|
+
* the watched participants. All options apply to each underlying stream, so
|
|
801
|
+
* `limit` caps rows per address, not the merged total; `afterTxid` is
|
|
802
|
+
* rejected because a chain cursor only exists in one address's history.
|
|
803
|
+
* Same-block rows from different addresses have no canonical order.
|
|
804
|
+
*/
|
|
805
|
+
historyMulti(addresses, opts = {}) {
|
|
806
|
+
if (!Array.isArray(addresses) || !addresses.length)
|
|
807
|
+
throw new EsploraError(`"addresses" expected non-empty array, got type=${typeof addresses}`);
|
|
808
|
+
const options = validateHistoryOpts(opts);
|
|
809
|
+
// afterTxid is a single-address cursor: it exists in at most one watched
|
|
810
|
+
// address's history, and fanning it out to the other streams would make
|
|
811
|
+
// their chain-cursor jumps silently drop or misorder those histories.
|
|
812
|
+
if (options.afterTxid !== undefined)
|
|
813
|
+
throw new EsploraError('expected historyMulti without afterTxid');
|
|
814
|
+
// Esplora echoes canonical encodings in transfer rows; re-encode the
|
|
815
|
+
// watched set so participant matching also works for valid but
|
|
816
|
+
// non-canonical inputs (e.g. uppercase bech32 from a QR code).
|
|
817
|
+
const unique = [...new Set(addresses.map((address) => this.canonicalAddress(address)))];
|
|
818
|
+
return this.historyMultiInner(unique, options);
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Address history as chronological transfer rows, oldest first. Buffered
|
|
822
|
+
* variant of {@link EsploraProvider.history}; use that to stream rows.
|
|
823
|
+
*/
|
|
824
|
+
async transfers(address, opts = {}) {
|
|
825
|
+
const options = validateTransfersOpts(opts);
|
|
826
|
+
const txs = [];
|
|
827
|
+
const stream = this.historyInner(this.addressPath(address), { ...options, order: 'oldest' });
|
|
828
|
+
for await (const tx of stream)
|
|
829
|
+
txs.push(tx);
|
|
830
|
+
return txs;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Calculates balances at specific point in time after tx.
|
|
835
|
+
* Info from multiple addresses can be merged when transactions are already sorted.
|
|
836
|
+
* @param transfers - Transaction transfer records.
|
|
837
|
+
* @returns Same transfer records with running balance snapshots attached.
|
|
838
|
+
* @example
|
|
839
|
+
* Fold an address history into running balances.
|
|
840
|
+
* ```ts
|
|
841
|
+
* import { calcTransfersDiff } from '@scure/btc-signer/net.js';
|
|
842
|
+
* calcTransfersDiff([]);
|
|
843
|
+
* ```
|
|
844
|
+
*/
|
|
845
|
+
export function calcTransfersDiff(transfers) {
|
|
846
|
+
validateArray(transfers, 'transfers');
|
|
847
|
+
for (let i = 0; i < transfers.length; i++) {
|
|
848
|
+
const tx = validateRecord(transfers[i], `transfers.${i}`);
|
|
849
|
+
validateString(tx.txid, `transfers.${i}.txid`);
|
|
850
|
+
const moves = validateArray(tx.transfers, `transfers.${i}.transfers`);
|
|
851
|
+
validateRecord(tx.info, `transfers.${i}.info`);
|
|
852
|
+
for (let j = 0; j < moves.length; j++) {
|
|
853
|
+
const transfer = validateRecord(moves[j], `transfers.${i}.transfers.${j}`);
|
|
854
|
+
if (transfer.from !== undefined)
|
|
855
|
+
validateString(transfer.from, `transfers.${i}.transfers.${j}.from`);
|
|
856
|
+
if (transfer.to !== undefined)
|
|
857
|
+
validateString(transfer.to, `transfers.${i}.transfers.${j}.to`);
|
|
858
|
+
// Transfer diffs include spends as negative deltas, so this boundary only checks type.
|
|
859
|
+
if (typeof transfer.value !== 'bigint')
|
|
860
|
+
throw new EsploraError(`expected transfers.${i}.transfers.${j}.value bigint, got type=${typeof transfer.value}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
const balances = {};
|
|
864
|
+
for (const tx of transfers) {
|
|
865
|
+
for (const transfer of tx.transfers) {
|
|
866
|
+
if (transfer.from) {
|
|
867
|
+
if (balances[transfer.from] === undefined)
|
|
868
|
+
balances[transfer.from] = _0n;
|
|
869
|
+
balances[transfer.from] -= transfer.value;
|
|
870
|
+
}
|
|
871
|
+
if (transfer.to) {
|
|
872
|
+
if (balances[transfer.to] === undefined)
|
|
873
|
+
balances[transfer.to] = _0n;
|
|
874
|
+
balances[transfer.to] += transfer.value;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
Object.assign(tx, { balances: { ...balances } });
|
|
878
|
+
}
|
|
879
|
+
return transfers;
|
|
880
|
+
}
|