@arkade-os/sdk 0.4.19 → 0.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/contracts/contractWatcher.js +33 -3
- package/dist/cjs/contracts/handlers/default.js +10 -3
- package/dist/cjs/contracts/handlers/helpers.js +47 -5
- package/dist/cjs/contracts/handlers/vhtlc.js +4 -2
- package/dist/cjs/identity/descriptor.js +98 -0
- package/dist/cjs/identity/descriptorProvider.js +2 -0
- package/dist/cjs/identity/index.js +15 -1
- package/dist/cjs/identity/seedIdentity.js +91 -6
- package/dist/cjs/identity/serialize.js +166 -0
- package/dist/cjs/identity/staticDescriptorProvider.js +65 -0
- package/dist/cjs/index.js +6 -3
- package/dist/cjs/providers/ark.js +71 -46
- package/dist/cjs/providers/electrum.js +663 -0
- package/dist/cjs/providers/indexer.js +60 -43
- package/dist/cjs/providers/utils.js +62 -12
- package/dist/cjs/wallet/ramps.js +1 -1
- package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +10 -0
- package/dist/cjs/wallet/serviceWorker/wallet.js +137 -91
- package/dist/cjs/wallet/vtxo-manager.js +56 -8
- package/dist/cjs/wallet/wallet.js +130 -156
- package/dist/cjs/worker/messageBus.js +200 -56
- package/dist/esm/contracts/contractWatcher.js +33 -3
- package/dist/esm/contracts/handlers/default.js +10 -3
- package/dist/esm/contracts/handlers/helpers.js +47 -5
- package/dist/esm/contracts/handlers/vhtlc.js +4 -2
- package/dist/esm/identity/descriptor.js +92 -0
- package/dist/esm/identity/descriptorProvider.js +1 -0
- package/dist/esm/identity/index.js +6 -1
- package/dist/esm/identity/seedIdentity.js +89 -6
- package/dist/esm/identity/serialize.js +159 -0
- package/dist/esm/identity/staticDescriptorProvider.js +61 -0
- package/dist/esm/index.js +2 -1
- package/dist/esm/providers/ark.js +72 -47
- package/dist/esm/providers/electrum.js +658 -0
- package/dist/esm/providers/indexer.js +61 -44
- package/dist/esm/providers/utils.js +61 -12
- package/dist/esm/wallet/ramps.js +1 -1
- package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +10 -0
- package/dist/esm/wallet/serviceWorker/wallet.js +137 -91
- package/dist/esm/wallet/vtxo-manager.js +56 -8
- package/dist/esm/wallet/wallet.js +130 -156
- package/dist/esm/worker/messageBus.js +201 -57
- package/dist/types/contracts/contractWatcher.d.ts +3 -0
- package/dist/types/contracts/handlers/default.d.ts +1 -1
- package/dist/types/contracts/handlers/helpers.d.ts +1 -1
- package/dist/types/contracts/types.d.ts +11 -3
- package/dist/types/identity/descriptor.d.ts +35 -0
- package/dist/types/identity/descriptorProvider.d.ts +28 -0
- package/dist/types/identity/index.d.ts +7 -1
- package/dist/types/identity/seedIdentity.d.ts +41 -4
- package/dist/types/identity/serialize.d.ts +84 -0
- package/dist/types/identity/staticDescriptorProvider.d.ts +18 -0
- package/dist/types/index.d.ts +4 -2
- package/dist/types/providers/electrum.d.ts +212 -0
- package/dist/types/providers/utils.d.ts +10 -5
- package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +11 -2
- package/dist/types/wallet/serviceWorker/wallet.d.ts +27 -10
- package/dist/types/wallet/vtxo-manager.d.ts +2 -0
- package/dist/types/wallet/wallet.d.ts +7 -6
- package/dist/types/worker/messageBus.d.ts +68 -8
- package/package.json +3 -2
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ElectrumOnchainProvider = exports.WsElectrumChainSource = void 0;
|
|
4
|
+
const btc_signer_1 = require("@scure/btc-signer");
|
|
5
|
+
const utils_js_1 = require("@scure/btc-signer/utils.js");
|
|
6
|
+
const base_1 = require("@scure/base");
|
|
7
|
+
// Electrum protocol method names
|
|
8
|
+
const BroadcastTransaction = "blockchain.transaction.broadcast";
|
|
9
|
+
const BroadcastPackageMethod = "blockchain.transaction.broadcast_package";
|
|
10
|
+
const EstimateFee = "blockchain.estimatefee";
|
|
11
|
+
const GetBlockHeader = "blockchain.block.header";
|
|
12
|
+
const GetHistoryMethod = "blockchain.scripthash.get_history";
|
|
13
|
+
const GetTransactionMethod = "blockchain.transaction.get";
|
|
14
|
+
const SubscribeStatusMethod = "blockchain.scripthash";
|
|
15
|
+
const SubscribeHeadersMethod = "blockchain.headers";
|
|
16
|
+
const GetRelayFeeMethod = "blockchain.relayfee";
|
|
17
|
+
const ListUnspentMethod = "blockchain.scripthash.listunspent";
|
|
18
|
+
const MISSING_TRANSACTION = "missingtransaction";
|
|
19
|
+
const MAX_FETCH_TRANSACTIONS_ATTEMPTS = 5;
|
|
20
|
+
// Bitcoin block header is 80 bytes
|
|
21
|
+
const BLOCK_HEADER_SIZE = 80;
|
|
22
|
+
/**
|
|
23
|
+
* Parse a raw block header (80 bytes hex = 160 chars) to extract fields.
|
|
24
|
+
* Bitcoin block header layout:
|
|
25
|
+
* - version: 4 bytes (LE)
|
|
26
|
+
* - prevHash: 32 bytes
|
|
27
|
+
* - merkleRoot: 32 bytes
|
|
28
|
+
* - timestamp: 4 bytes (LE)
|
|
29
|
+
* - bits: 4 bytes
|
|
30
|
+
* - nonce: 4 bytes
|
|
31
|
+
*/
|
|
32
|
+
function parseBlockHeader(headerHex) {
|
|
33
|
+
const headerBytes = base_1.hex.decode(headerHex);
|
|
34
|
+
if (headerBytes.length !== BLOCK_HEADER_SIZE) {
|
|
35
|
+
throw new Error(`Invalid block header size: ${headerBytes.length}, expected ${BLOCK_HEADER_SIZE}`);
|
|
36
|
+
}
|
|
37
|
+
// timestamp is at offset 68 (4+32+32), 4 bytes little-endian
|
|
38
|
+
const view = new DataView(headerBytes.buffer, headerBytes.byteOffset);
|
|
39
|
+
const timestamp = view.getUint32(68, true);
|
|
40
|
+
// block hash = double SHA256 of header, reversed
|
|
41
|
+
const hash1 = (0, utils_js_1.sha256)(headerBytes);
|
|
42
|
+
const hash2 = (0, utils_js_1.sha256)(hash1);
|
|
43
|
+
const hashStr = base_1.hex.encode(new Uint8Array(hash2).reverse());
|
|
44
|
+
return { hash: hashStr, timestamp };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* WebSocket-based Electrum chain source using ws-electrumx-client.
|
|
48
|
+
* Provides low-level methods for the Electrum protocol.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* import { ElectrumWS } from "ws-electrumx-client";
|
|
53
|
+
* import { WsElectrumChainSource } from "./providers/electrum";
|
|
54
|
+
* import { networks } from "./networks";
|
|
55
|
+
*
|
|
56
|
+
* const ws = new ElectrumWS("wss://electrum.blockstream.info:50004");
|
|
57
|
+
* const chain = new WsElectrumChainSource(ws, networks.bitcoin);
|
|
58
|
+
*
|
|
59
|
+
* const history = await chain.fetchHistories([script]);
|
|
60
|
+
* await chain.close();
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
class WsElectrumChainSource {
|
|
64
|
+
constructor(ws, network) {
|
|
65
|
+
this.ws = ws;
|
|
66
|
+
this.network = network;
|
|
67
|
+
// Cached chain tip kept fresh by the headers subscription. Initialized
|
|
68
|
+
// lazily on first call to subscribeHeaders().
|
|
69
|
+
this.cachedTip = null;
|
|
70
|
+
this.headersSubscribePromise = null;
|
|
71
|
+
}
|
|
72
|
+
async fetchTransactions(txids) {
|
|
73
|
+
const requests = txids.map((txid) => ({
|
|
74
|
+
method: GetTransactionMethod,
|
|
75
|
+
params: [txid],
|
|
76
|
+
}));
|
|
77
|
+
for (let i = 0; i < MAX_FETCH_TRANSACTIONS_ATTEMPTS; i++) {
|
|
78
|
+
try {
|
|
79
|
+
const responses = await this.ws.batchRequest(...requests);
|
|
80
|
+
return responses.map((hexStr, i) => ({
|
|
81
|
+
txID: txids[i],
|
|
82
|
+
hex: hexStr,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
87
|
+
if (msg.toLowerCase().includes(MISSING_TRANSACTION)) {
|
|
88
|
+
console.warn("missing transaction error, retrying");
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw new Error("Unable to fetch transactions: " + txids);
|
|
96
|
+
}
|
|
97
|
+
async fetchVerboseTransaction(txid) {
|
|
98
|
+
return this.ws.request(GetTransactionMethod, txid, true);
|
|
99
|
+
}
|
|
100
|
+
async fetchVerboseTransactions(txids) {
|
|
101
|
+
if (txids.length === 0)
|
|
102
|
+
return [];
|
|
103
|
+
const requests = txids.map((txid) => ({
|
|
104
|
+
method: GetTransactionMethod,
|
|
105
|
+
params: [txid, true],
|
|
106
|
+
}));
|
|
107
|
+
return this.ws.batchRequest(...requests);
|
|
108
|
+
}
|
|
109
|
+
async unsubscribeScriptStatus(script) {
|
|
110
|
+
await this.ws
|
|
111
|
+
.unsubscribe(SubscribeStatusMethod, toScriptHash(script))
|
|
112
|
+
.catch(() => { });
|
|
113
|
+
}
|
|
114
|
+
async subscribeScriptStatus(script, callback) {
|
|
115
|
+
const scriptHash = toScriptHash(script);
|
|
116
|
+
await this.ws.subscribe(SubscribeStatusMethod, (scripthash, status) => {
|
|
117
|
+
if (scripthash === scriptHash) {
|
|
118
|
+
callback(scripthash, status);
|
|
119
|
+
}
|
|
120
|
+
}, scriptHash);
|
|
121
|
+
}
|
|
122
|
+
async fetchHistories(scripts) {
|
|
123
|
+
const scriptsHashes = scripts.map((s) => toScriptHash(s));
|
|
124
|
+
const responses = await this.ws.batchRequest(...scriptsHashes.map((s) => ({
|
|
125
|
+
method: GetHistoryMethod,
|
|
126
|
+
params: [s],
|
|
127
|
+
})));
|
|
128
|
+
return responses;
|
|
129
|
+
}
|
|
130
|
+
async fetchHistory(script) {
|
|
131
|
+
const scriptHash = toScriptHash(script);
|
|
132
|
+
return this.ws.request(GetHistoryMethod, scriptHash);
|
|
133
|
+
}
|
|
134
|
+
async fetchBlockHeaders(heights) {
|
|
135
|
+
const responses = await this.ws.batchRequest(...heights.map((h) => ({ method: GetBlockHeader, params: [h] })));
|
|
136
|
+
return responses.map((hexStr, i) => ({
|
|
137
|
+
height: heights[i],
|
|
138
|
+
hex: hexStr,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
async fetchBlockHeader(height) {
|
|
142
|
+
const headerHex = await this.ws.request(GetBlockHeader, height);
|
|
143
|
+
return { height, hex: headerHex };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Returns the current chain tip and keeps it fresh via a single
|
|
147
|
+
* server-side subscription. Subsequent calls return the cached tip
|
|
148
|
+
* (updated by background notifications) without round-tripping to the
|
|
149
|
+
* server. Previously each call issued `blockchain.headers.subscribe` as
|
|
150
|
+
* a regular request, leaving a stale subscription on the server every
|
|
151
|
+
* time — under polling that adds up. ws-electrumx-client deduplicates
|
|
152
|
+
* `subscribe()` by method+params, so registering once is enough.
|
|
153
|
+
*/
|
|
154
|
+
async subscribeHeaders() {
|
|
155
|
+
if (this.cachedTip)
|
|
156
|
+
return this.cachedTip;
|
|
157
|
+
if (this.headersSubscribePromise)
|
|
158
|
+
return this.headersSubscribePromise;
|
|
159
|
+
this.headersSubscribePromise = new Promise((resolve, reject) => {
|
|
160
|
+
let resolved = false;
|
|
161
|
+
this.ws
|
|
162
|
+
.subscribe(SubscribeHeadersMethod, (header) => {
|
|
163
|
+
if (!isHeaderSubscribeResult(header))
|
|
164
|
+
return;
|
|
165
|
+
this.cachedTip = header;
|
|
166
|
+
if (!resolved) {
|
|
167
|
+
resolved = true;
|
|
168
|
+
resolve(header);
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
.catch((err) => {
|
|
172
|
+
if (!resolved) {
|
|
173
|
+
resolved = true;
|
|
174
|
+
reject(err);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
return await this.headersSubscribePromise;
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
// Allow the next call to retry from scratch.
|
|
183
|
+
this.headersSubscribePromise = null;
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async estimateFees(targetNumberBlocks) {
|
|
188
|
+
const feeRate = await this.ws.request(EstimateFee, targetNumberBlocks);
|
|
189
|
+
return feeRate;
|
|
190
|
+
}
|
|
191
|
+
async broadcastTransaction(txHex) {
|
|
192
|
+
return this.ws.request(BroadcastTransaction, txHex);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Submit a package of raw transactions atomically via Fulcrum's
|
|
196
|
+
* `blockchain.transaction.broadcast_package` method, the on-the-wire
|
|
197
|
+
* equivalent of bitcoind's `submitpackage` RPC.
|
|
198
|
+
*
|
|
199
|
+
* Required for TRUC (BIP 431) 1P1C relay where the parent has zero
|
|
200
|
+
* (or below-minfee) fee and depends on the child to pay for both via
|
|
201
|
+
* CPFP — sequential broadcast cannot work in that case because the
|
|
202
|
+
* parent would be rejected from the mempool on its own.
|
|
203
|
+
*
|
|
204
|
+
* @param txHexes - Topologically sorted raw transactions; child must
|
|
205
|
+
* be the last element. Currently must be a 1P1C pair
|
|
206
|
+
* (length 2). Parents may not depend on each other.
|
|
207
|
+
* @returns The child transaction id (the last entry in the array),
|
|
208
|
+
* computed locally — `broadcast_package` itself returns
|
|
209
|
+
* `{success, errors}` rather than a txid.
|
|
210
|
+
* @throws If the server does not implement `broadcast_package` (e.g.
|
|
211
|
+
* ElectrumX, or older Fulcrum, or Fulcrum backed by bitcoind
|
|
212
|
+
* < v28.0.0). Callers must surface this clearly to users —
|
|
213
|
+
* this method does NOT silently fall back to sequential
|
|
214
|
+
* broadcasts because doing so would let TRUC packages fail
|
|
215
|
+
* in subtle ways.
|
|
216
|
+
* @throws If the server returns `success=false`, surfacing the
|
|
217
|
+
* underlying mempool rejection in the error message.
|
|
218
|
+
*/
|
|
219
|
+
async broadcastPackage(txHexes) {
|
|
220
|
+
const result = await this.ws.request(BroadcastPackageMethod, txHexes, false);
|
|
221
|
+
if (!result.success) {
|
|
222
|
+
const detail = result.errors
|
|
223
|
+
? JSON.stringify(result.errors)
|
|
224
|
+
: "unknown error";
|
|
225
|
+
throw new Error(`Package broadcast rejected: ${detail}`);
|
|
226
|
+
}
|
|
227
|
+
// The child txid is not in the response — derive it from the raw
|
|
228
|
+
// bytes (double-SHA256 of the serialized tx, reversed).
|
|
229
|
+
return childTxidFromHex(txHexes[txHexes.length - 1]);
|
|
230
|
+
}
|
|
231
|
+
async getRelayFee() {
|
|
232
|
+
return this.ws.request(GetRelayFeeMethod);
|
|
233
|
+
}
|
|
234
|
+
async close() {
|
|
235
|
+
try {
|
|
236
|
+
await this.ws.close("close");
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
console.debug("error closing ws:", e);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
waitForAddressReceivesTx(addr) {
|
|
243
|
+
return new Promise((resolve, reject) => {
|
|
244
|
+
const script = btc_signer_1.OutScript.encode((0, btc_signer_1.Address)(this.network).decode(addr));
|
|
245
|
+
this.subscribeScriptStatus(script, (_, status) => {
|
|
246
|
+
if (status !== null) {
|
|
247
|
+
resolve();
|
|
248
|
+
}
|
|
249
|
+
}).catch(reject);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
async listUnspents(addr) {
|
|
253
|
+
const script = btc_signer_1.OutScript.encode((0, btc_signer_1.Address)(this.network).decode(addr));
|
|
254
|
+
const scriptHash = toScriptHash(script);
|
|
255
|
+
const unspentsFromElectrum = await this.ws.request(ListUnspentMethod, scriptHash);
|
|
256
|
+
const txs = await this.fetchTransactions(unspentsFromElectrum.map((u) => u.tx_hash));
|
|
257
|
+
return unspentsFromElectrum.map((u, index) => {
|
|
258
|
+
const tx = btc_signer_1.Transaction.fromRaw(base_1.hex.decode(txs[index].hex), {
|
|
259
|
+
allowUnknownOutputs: true,
|
|
260
|
+
});
|
|
261
|
+
const output = tx.getOutput(u.tx_pos);
|
|
262
|
+
if (!output.script || output.amount === undefined) {
|
|
263
|
+
throw new Error(`Missing output data for ${u.tx_hash}:${u.tx_pos}`);
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
txid: u.tx_hash,
|
|
267
|
+
vout: u.tx_pos,
|
|
268
|
+
witnessUtxo: {
|
|
269
|
+
script: output.script,
|
|
270
|
+
value: output.amount,
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Get the address string for a script output, if decodable.
|
|
277
|
+
*/
|
|
278
|
+
addressForScript(scriptHex) {
|
|
279
|
+
try {
|
|
280
|
+
const script = base_1.hex.decode(scriptHex);
|
|
281
|
+
return (0, btc_signer_1.Address)(this.network).encode(btc_signer_1.OutScript.decode(script));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
exports.WsElectrumChainSource = WsElectrumChainSource;
|
|
289
|
+
/**
|
|
290
|
+
* Electrum-based implementation of the OnchainProvider interface.
|
|
291
|
+
* Replaces esplora polling with electrum subscriptions where possible.
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* ```typescript
|
|
295
|
+
* import { ElectrumWS } from "ws-electrumx-client";
|
|
296
|
+
* import { ElectrumOnchainProvider } from "./providers/electrum";
|
|
297
|
+
* import { networks } from "./networks";
|
|
298
|
+
*
|
|
299
|
+
* const ws = new ElectrumWS("wss://electrum.blockstream.info:50004");
|
|
300
|
+
* const provider = new ElectrumOnchainProvider(ws, networks.bitcoin);
|
|
301
|
+
*
|
|
302
|
+
* const coins = await provider.getCoins("bc1q...");
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
class ElectrumOnchainProvider {
|
|
306
|
+
constructor(ws, network) {
|
|
307
|
+
this.ws = ws;
|
|
308
|
+
this.network = network;
|
|
309
|
+
this.chain = new WsElectrumChainSource(ws, network);
|
|
310
|
+
}
|
|
311
|
+
async getCoins(address) {
|
|
312
|
+
const script = this.encodeAddress(address);
|
|
313
|
+
const scriptHash = toScriptHash(script);
|
|
314
|
+
const unspents = await this.ws.request(ListUnspentMethod, scriptHash);
|
|
315
|
+
return unspents.map((u) => ({
|
|
316
|
+
txid: u.tx_hash,
|
|
317
|
+
vout: u.tx_pos,
|
|
318
|
+
value: u.value,
|
|
319
|
+
status: {
|
|
320
|
+
confirmed: u.height > 0,
|
|
321
|
+
block_height: u.height > 0 ? u.height : undefined,
|
|
322
|
+
},
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
async getFeeRate() {
|
|
326
|
+
// electrum returns BTC/kB, we need sat/vB
|
|
327
|
+
// 1 BTC = 100_000_000 sat, 1 kB = 1000 bytes
|
|
328
|
+
// sat/vB = (BTC/kB) * 100_000_000 / 1000 = (BTC/kB) * 100_000
|
|
329
|
+
const feePerKb = await this.chain.estimateFees(1);
|
|
330
|
+
if (feePerKb < 0) {
|
|
331
|
+
// -1 means the daemon cannot estimate
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
return Math.max(1, Math.ceil(feePerKb * 100000));
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Broadcast a single transaction or a TRUC (BIP 431) 1P1C package
|
|
338
|
+
* atomically.
|
|
339
|
+
*
|
|
340
|
+
* **Server requirements for 1P1C packages:** the backing Electrum
|
|
341
|
+
* server must implement `blockchain.transaction.broadcast_package`
|
|
342
|
+
* (Fulcrum ≥ 1.10) and be backed by bitcoind ≥ v28.0.0. ElectrumX
|
|
343
|
+
* does not implement this method. There is **no fallback** to
|
|
344
|
+
* sequential parent-then-child broadcast: TRUC packages typically
|
|
345
|
+
* have a zero-fee parent and would be rejected from the mempool on
|
|
346
|
+
* their own, so a fallback would silently fail in subtle ways.
|
|
347
|
+
* Callers receiving a "method not found" error here should route
|
|
348
|
+
* through a different provider for that submission.
|
|
349
|
+
*
|
|
350
|
+
* @param txs - One transaction (single broadcast) or two
|
|
351
|
+
* topologically-sorted transactions (parent first,
|
|
352
|
+
* child last) for 1P1C package relay.
|
|
353
|
+
* @returns The broadcast txid (or the child txid for 1P1C packages).
|
|
354
|
+
*/
|
|
355
|
+
async broadcastTransaction(...txs) {
|
|
356
|
+
if (txs.length === 1) {
|
|
357
|
+
return this.chain.broadcastTransaction(txs[0]);
|
|
358
|
+
}
|
|
359
|
+
if (txs.length === 2) {
|
|
360
|
+
return this.chain.broadcastPackage(txs);
|
|
361
|
+
}
|
|
362
|
+
throw new Error("Only 1 or 1P1C package can be broadcast");
|
|
363
|
+
}
|
|
364
|
+
async getTxOutspends(txid) {
|
|
365
|
+
// Step 1: fetch the creating tx to get its output scripts (1 round trip)
|
|
366
|
+
const [txResult] = await this.chain.fetchTransactions([txid]);
|
|
367
|
+
const tx = btc_signer_1.Transaction.fromRaw(base_1.hex.decode(txResult.hex), {
|
|
368
|
+
allowUnknownOutputs: true,
|
|
369
|
+
});
|
|
370
|
+
const outputCount = tx.outputsLength;
|
|
371
|
+
const outputScriptHashes = [];
|
|
372
|
+
for (let i = 0; i < outputCount; i++) {
|
|
373
|
+
const output = tx.getOutput(i);
|
|
374
|
+
outputScriptHashes.push(output.script ? toScriptHash(output.script) : undefined);
|
|
375
|
+
}
|
|
376
|
+
const validScriptHashes = outputScriptHashes.filter((h) => h !== undefined);
|
|
377
|
+
const results = Array.from({ length: outputCount }, () => ({ spent: false, txid: "" }));
|
|
378
|
+
if (validScriptHashes.length === 0)
|
|
379
|
+
return results;
|
|
380
|
+
// Step 2: batch listunspent for all output scripthashes (1 round trip)
|
|
381
|
+
// This tells us exactly which txid:vout pairs are still unspent.
|
|
382
|
+
const unspentBatch = await this.ws.batchRequest(...validScriptHashes.map((sh) => ({
|
|
383
|
+
method: ListUnspentMethod,
|
|
384
|
+
params: [sh],
|
|
385
|
+
})));
|
|
386
|
+
const unspentSet = new Set();
|
|
387
|
+
let validIdx = 0;
|
|
388
|
+
for (let i = 0; i < outputCount; i++) {
|
|
389
|
+
if (outputScriptHashes[i] !== undefined) {
|
|
390
|
+
for (const u of unspentBatch[validIdx]) {
|
|
391
|
+
unspentSet.add(`${u.tx_hash}:${u.tx_pos}`);
|
|
392
|
+
}
|
|
393
|
+
validIdx++;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Step 3: batch get_history only for spent outputs (1 round trip)
|
|
397
|
+
const spentIndices = [];
|
|
398
|
+
const spentScriptHashes = [];
|
|
399
|
+
for (let i = 0; i < outputCount; i++) {
|
|
400
|
+
const sh = outputScriptHashes[i];
|
|
401
|
+
if (sh && !unspentSet.has(`${txid}:${i}`)) {
|
|
402
|
+
spentIndices.push(i);
|
|
403
|
+
spentScriptHashes.push(sh);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (spentIndices.length === 0)
|
|
407
|
+
return results;
|
|
408
|
+
const histories = await this.ws.batchRequest(...spentScriptHashes.map((sh) => ({
|
|
409
|
+
method: GetHistoryMethod,
|
|
410
|
+
params: [sh],
|
|
411
|
+
})));
|
|
412
|
+
// For each spent output find the spender in its history.
|
|
413
|
+
// Common case: history has exactly 2 entries (creating + spending tx).
|
|
414
|
+
// Ambiguous case (same script reused): batch-fetch all candidates at once.
|
|
415
|
+
const ambiguousIndices = [];
|
|
416
|
+
const ambiguousCandidates = [];
|
|
417
|
+
for (let j = 0; j < spentIndices.length; j++) {
|
|
418
|
+
const i = spentIndices[j];
|
|
419
|
+
const candidates = histories[j]
|
|
420
|
+
.map((h) => h.tx_hash)
|
|
421
|
+
.filter((hash) => hash !== txid);
|
|
422
|
+
if (candidates.length === 1) {
|
|
423
|
+
// Fast path: one candidate = the spender
|
|
424
|
+
results[i] = { spent: true, txid: candidates[0] };
|
|
425
|
+
}
|
|
426
|
+
else if (candidates.length > 1) {
|
|
427
|
+
ambiguousIndices.push(i);
|
|
428
|
+
ambiguousCandidates.push(candidates);
|
|
429
|
+
}
|
|
430
|
+
// candidates.length === 0 → mempool eviction, treat as unspent
|
|
431
|
+
}
|
|
432
|
+
// Step 4 (rare): batch-fetch all ambiguous candidate txs at once
|
|
433
|
+
if (ambiguousIndices.length > 0) {
|
|
434
|
+
const allCandidateTxids = [...new Set(ambiguousCandidates.flat())];
|
|
435
|
+
const fetched = await this.chain.fetchTransactions(allCandidateTxids);
|
|
436
|
+
const txMap = new Map(fetched.map((t) => [t.txID, t.hex]));
|
|
437
|
+
for (let j = 0; j < ambiguousIndices.length; j++) {
|
|
438
|
+
const i = ambiguousIndices[j];
|
|
439
|
+
for (const candidateTxid of ambiguousCandidates[j]) {
|
|
440
|
+
const rawHex = txMap.get(candidateTxid);
|
|
441
|
+
if (!rawHex)
|
|
442
|
+
continue;
|
|
443
|
+
const candidateTx = btc_signer_1.Transaction.fromRaw(base_1.hex.decode(rawHex), { allowUnknownOutputs: true, allowUnknownInputs: true });
|
|
444
|
+
let found = false;
|
|
445
|
+
for (let k = 0; k < candidateTx.inputsLength; k++) {
|
|
446
|
+
const input = candidateTx.getInput(k);
|
|
447
|
+
if (input.txid &&
|
|
448
|
+
base_1.hex.encode(input.txid) === txid &&
|
|
449
|
+
input.index === i) {
|
|
450
|
+
results[i] = { spent: true, txid: candidateTxid };
|
|
451
|
+
found = true;
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (found)
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return results;
|
|
461
|
+
}
|
|
462
|
+
async getTransactions(address) {
|
|
463
|
+
const script = this.encodeAddress(address);
|
|
464
|
+
const history = await this.chain.fetchHistory(script);
|
|
465
|
+
if (history.length === 0)
|
|
466
|
+
return [];
|
|
467
|
+
const txids = history.map((h) => h.tx_hash);
|
|
468
|
+
const verboseTxs = await this.chain.fetchVerboseTransactions(txids);
|
|
469
|
+
return verboseTxs.map((vtx) => this.verboseToExplorer(vtx));
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Map an electrum verbose transaction to the ExplorerTransaction shape.
|
|
473
|
+
*
|
|
474
|
+
* Output values are derived from the raw transaction hex when available,
|
|
475
|
+
* never from the floating-point `value` field returned by the daemon.
|
|
476
|
+
* That field has 8 decimal places and `Math.round(value * 1e8)` is safe
|
|
477
|
+
* in the common case but a footgun for protocol-level money handling —
|
|
478
|
+
* the raw bytes are exact.
|
|
479
|
+
*/
|
|
480
|
+
verboseToExplorer(vtx) {
|
|
481
|
+
const exactValuesByVout = parseExactSats(vtx);
|
|
482
|
+
return {
|
|
483
|
+
txid: vtx.txid,
|
|
484
|
+
vout: vtx.vout.map((v) => ({
|
|
485
|
+
scriptpubkey_address: v.scriptPubKey.address ||
|
|
486
|
+
v.scriptPubKey.addresses?.[0] ||
|
|
487
|
+
this.chain.addressForScript(v.scriptPubKey.hex) ||
|
|
488
|
+
"",
|
|
489
|
+
value: exactValuesByVout?.get(v.n) ??
|
|
490
|
+
String(Math.round(v.value * 1e8)),
|
|
491
|
+
})),
|
|
492
|
+
status: {
|
|
493
|
+
confirmed: vtx.confirmations > 0,
|
|
494
|
+
block_time: vtx.blocktime || vtx.time || 0,
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Decode `address` into its scriptPubKey, throwing a clear error if the
|
|
500
|
+
* input is malformed. @scure/btc-signer raises a generic decode error
|
|
501
|
+
* which is hard to map back to user input — this wraps it.
|
|
502
|
+
*/
|
|
503
|
+
encodeAddress(address) {
|
|
504
|
+
try {
|
|
505
|
+
return btc_signer_1.OutScript.encode((0, btc_signer_1.Address)(this.network).decode(address));
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
509
|
+
throw new Error(`Invalid address ${address}: ${reason}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async getTxStatus(txid) {
|
|
513
|
+
const vtx = await this.chain.fetchVerboseTransaction(txid);
|
|
514
|
+
if (vtx.confirmations <= 0) {
|
|
515
|
+
return { confirmed: false };
|
|
516
|
+
}
|
|
517
|
+
// Get block height from the verbose tx's blockhash
|
|
518
|
+
// We need the height, which is confirmations-based:
|
|
519
|
+
// height = tipHeight - confirmations + 1
|
|
520
|
+
const tip = await this.chain.subscribeHeaders();
|
|
521
|
+
const blockHeight = tip.height - vtx.confirmations + 1;
|
|
522
|
+
return {
|
|
523
|
+
confirmed: true,
|
|
524
|
+
blockTime: vtx.blocktime || vtx.time || 0,
|
|
525
|
+
blockHeight,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
async getChainTip() {
|
|
529
|
+
const tip = await this.chain.subscribeHeaders();
|
|
530
|
+
const { hash, timestamp } = parseBlockHeader(tip.hex);
|
|
531
|
+
return {
|
|
532
|
+
height: tip.height,
|
|
533
|
+
time: timestamp,
|
|
534
|
+
hash,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
async watchAddresses(addresses, eventCallback) {
|
|
538
|
+
const scripts = addresses.map((addr) => this.encodeAddress(addr));
|
|
539
|
+
const scriptHashes = scripts.map(toScriptHash);
|
|
540
|
+
// O(1) scripthash → script lookup, kept in sync with the
|
|
541
|
+
// scripts/scriptHashes arrays. Server notifications hit this on
|
|
542
|
+
// every push, so the previous indexOf was O(n) per event.
|
|
543
|
+
const scriptByHash = new Map(scriptHashes.map((h, i) => [h, scripts[i]]));
|
|
544
|
+
// Track known history per script to detect new txs.
|
|
545
|
+
const knownTxids = new Map();
|
|
546
|
+
// Initialize known-set in parallel — for a wallet watching many
|
|
547
|
+
// addresses this avoids n sequential round trips on first call.
|
|
548
|
+
const initialHistories = await Promise.all(scripts.map((s) => this.chain.fetchHistory(s)));
|
|
549
|
+
initialHistories.forEach((history, i) => {
|
|
550
|
+
knownTxids.set(scriptHashes[i], new Set(history.map((h) => h.tx_hash)));
|
|
551
|
+
});
|
|
552
|
+
// Per-scripthash mutex serializing concurrent notifications so
|
|
553
|
+
// two pushes for the same address can't fetch history in parallel
|
|
554
|
+
// and emit duplicate events. Each call chains onto the previous
|
|
555
|
+
// one's tail; failures are swallowed to keep the chain alive.
|
|
556
|
+
const inFlight = new Map();
|
|
557
|
+
const processStatusChange = async (scripthash) => {
|
|
558
|
+
const script = scriptByHash.get(scripthash);
|
|
559
|
+
if (!script)
|
|
560
|
+
return;
|
|
561
|
+
const history = await this.chain.fetchHistory(script);
|
|
562
|
+
const known = knownTxids.get(scripthash) ?? new Set();
|
|
563
|
+
const newTxids = history
|
|
564
|
+
.map((h) => h.tx_hash)
|
|
565
|
+
.filter((txid) => !known.has(txid));
|
|
566
|
+
if (newTxids.length === 0)
|
|
567
|
+
return;
|
|
568
|
+
for (const txid of newTxids)
|
|
569
|
+
known.add(txid);
|
|
570
|
+
knownTxids.set(scripthash, known);
|
|
571
|
+
const verboseTxs = await this.chain.fetchVerboseTransactions(newTxids);
|
|
572
|
+
eventCallback(verboseTxs.map((vtx) => this.verboseToExplorer(vtx)));
|
|
573
|
+
};
|
|
574
|
+
const handleStatusChange = (scripthash) => {
|
|
575
|
+
const previous = inFlight.get(scripthash) ?? Promise.resolve();
|
|
576
|
+
const next = previous.then(() => processStatusChange(scripthash));
|
|
577
|
+
// Keep the chain alive even when one link rejects.
|
|
578
|
+
inFlight.set(scripthash, next.catch(() => undefined));
|
|
579
|
+
return next;
|
|
580
|
+
};
|
|
581
|
+
// Register all subscriptions in parallel; if any one fails, tear
|
|
582
|
+
// down the others so we don't leak server-side subscriptions on
|
|
583
|
+
// a connection the caller never gets a stop() handle for.
|
|
584
|
+
const subscribed = [];
|
|
585
|
+
try {
|
|
586
|
+
await Promise.all(scripts.map(async (script) => {
|
|
587
|
+
await this.chain.subscribeScriptStatus(script, (scripthash, status) => {
|
|
588
|
+
if (status !== null) {
|
|
589
|
+
handleStatusChange(scripthash).catch(console.error);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
subscribed.push(script);
|
|
593
|
+
}));
|
|
594
|
+
}
|
|
595
|
+
catch (err) {
|
|
596
|
+
await Promise.allSettled(subscribed.map((s) => this.chain.unsubscribeScriptStatus(s)));
|
|
597
|
+
throw err;
|
|
598
|
+
}
|
|
599
|
+
return () => {
|
|
600
|
+
for (const script of scripts) {
|
|
601
|
+
this.chain.unsubscribeScriptStatus(script).catch(() => { });
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
/** Close the underlying WebSocket connection. */
|
|
606
|
+
async close() {
|
|
607
|
+
await this.chain.close();
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
exports.ElectrumOnchainProvider = ElectrumOnchainProvider;
|
|
611
|
+
function toScriptHash(script) {
|
|
612
|
+
return base_1.hex.encode((0, utils_js_1.sha256)(script).reverse());
|
|
613
|
+
}
|
|
614
|
+
function isHeaderSubscribeResult(v) {
|
|
615
|
+
if (typeof v !== "object" || v === null)
|
|
616
|
+
return false;
|
|
617
|
+
const obj = v;
|
|
618
|
+
return typeof obj.height === "number" && typeof obj.hex === "string";
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Compute the txid of a serialized transaction. For segwit transactions
|
|
622
|
+
* (every Ark transaction), the broadcast hex includes witness data, but
|
|
623
|
+
* the txid is the double-SHA256 of the legacy (witness-stripped)
|
|
624
|
+
* serialization. Hashing the raw broadcast bytes directly would yield
|
|
625
|
+
* the wtxid instead — silently breaking any caller that tracks the tx
|
|
626
|
+
* by id (round settlement, forfeit monitoring, exit paths).
|
|
627
|
+
*
|
|
628
|
+
* Delegating to `Transaction.fromRaw(...).id` lets @scure/btc-signer
|
|
629
|
+
* handle the witness-stripping correctly.
|
|
630
|
+
*/
|
|
631
|
+
function childTxidFromHex(txHex) {
|
|
632
|
+
const tx = btc_signer_1.Transaction.fromRaw(base_1.hex.decode(txHex), {
|
|
633
|
+
allowUnknownOutputs: true,
|
|
634
|
+
allowUnknownInputs: true,
|
|
635
|
+
});
|
|
636
|
+
return tx.id;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Decode `vtx.hex` (when the daemon includes it) and return a map of
|
|
640
|
+
* vout-index → exact sat amount as a base-10 string. Returns `null` if
|
|
641
|
+
* the hex is missing or unparseable; callers should fall back to the
|
|
642
|
+
* float-derived value in that case.
|
|
643
|
+
*/
|
|
644
|
+
function parseExactSats(vtx) {
|
|
645
|
+
if (!vtx.hex)
|
|
646
|
+
return null;
|
|
647
|
+
try {
|
|
648
|
+
const tx = btc_signer_1.Transaction.fromRaw(base_1.hex.decode(vtx.hex), {
|
|
649
|
+
allowUnknownOutputs: true,
|
|
650
|
+
});
|
|
651
|
+
const result = new Map();
|
|
652
|
+
for (let i = 0; i < tx.outputsLength; i++) {
|
|
653
|
+
const output = tx.getOutput(i);
|
|
654
|
+
if (output.amount === undefined)
|
|
655
|
+
continue;
|
|
656
|
+
result.set(i, output.amount.toString());
|
|
657
|
+
}
|
|
658
|
+
return result;
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
}
|