1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
|
// Thin wrapper around the mempool.space REST API.
//
// Every network call goes through here so that "offline mode" can be enforced
// in exactly one place: when offline, any call throws and the UI relies on an
// imported snapshot instead.
const BASES = {
mainnet: 'https://mempool.space/api',
testnet: 'https://mempool.space/testnet/api',
};
export class Api {
constructor(net = 'mainnet') {
this.net = net;
this.offline = false;
}
base() {
return BASES[this.net] || BASES.mainnet;
}
explorerTx(txid) {
const path = this.net === 'testnet' ? 'testnet/tx' : 'tx';
return `https://mempool.space/${path}/${txid}`;
}
async #get(path, asText = false) {
if (this.offline) throw new Error('offline');
// Retry transient failures (mempool.space rate-limits bursts of requests
// during a scan, which can surface as network/CORS errors from file://).
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
if (attempt) await sleep(400 * attempt + 200);
try {
const res = await fetch(this.base() + path);
if (res.status === 429) throw new Error('rate limited');
if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${path}`);
return asText ? res.text() : res.json();
} catch (e) {
lastErr = e;
}
}
throw lastErr;
}
// chain_stats / mempool_stats let us decide whether an address has ever been
// used without downloading its full history.
addressInfo(address) {
return this.#get(`/address/${address}`);
}
addressUtxos(address) {
return this.#get(`/address/${address}/utxo`);
}
// Returns up to ~50 of the most recent transactions touching the address
// (mempool first, then confirmed).
addressTxs(address) {
return this.#get(`/address/${address}/txs`);
}
async feeRates() {
try {
return await this.#get('/v1/fees/recommended');
} catch {
return { fastestFee: 20, halfHourFee: 10, hourFee: 5, economyFee: 2, minimumFee: 1 };
}
}
async price() {
try {
const p = await this.#get('/v1/prices');
return p && p.USD ? p.USD : null;
} catch {
return null;
}
}
async broadcast(hexTx) {
if (this.offline) throw new Error('offline');
const res = await fetch(this.base() + '/tx', { method: 'POST', body: hexTx });
const body = await res.text();
if (!res.ok) throw new Error(body || `${res.status} broadcast failed`);
return body.trim(); // txid
}
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// Run async jobs with a small concurrency cap so we stay friendly to the API.
export async function pool(items, worker, concurrency = 4) {
const results = new Array(items.length);
let next = 0;
async function run() {
while (next < items.length) {
const i = next++;
results[i] = await worker(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run));
return results;
}
|