aboutsummaryrefslogtreecommitdiffstats
path: root/src/api.js
blob: c99ed40aa661dbe465889b183ca688a6ffa17c7f (plain) (blame)
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;
}