aboutsummaryrefslogtreecommitdiffstats
path: root/src/api.js
blob: 7edb4e07fada14306d2092461b2ed82168ad3d8b (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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
// 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;

    // Global request throttle. mempool.space rate-limits bursts; an unthrottled
    // scan (~40 address lookups at once) gets a chunk of requests rejected, and
    // a throttled response arrives WITHOUT CORS headers, so the browser reports
    // it as a CORS error. We cap concurrency and space out request starts so we
    // stay under the limit, with exponential backoff to absorb the rest.
    this._maxConcurrent = 2;
    this._minGapMs = 250;
    this._active = 0;
    this._nextStart = 0;
    this._queue = [];
  }

  base() {
    return BASES[this.net] || BASES.mainnet;
  }

  explorerTx(txid) {
    const path = this.net === 'testnet' ? 'testnet/tx' : 'tx';
    return `https://mempool.space/${path}/${txid}`;
  }

  // Schedule a task respecting max concurrency + a minimum gap between starts.
  #schedule(task) {
    return new Promise((resolve, reject) => {
      this._queue.push({ task, resolve, reject });
      this.#pump();
    });
  }

  #pump() {
    while (this._active < this._maxConcurrent && this._queue.length) {
      const { task, resolve, reject } = this._queue.shift();
      this._active++;
      const now = Date.now();
      const startAt = Math.max(now, this._nextStart);
      this._nextStart = startAt + this._minGapMs;
      const delay = startAt - now;
      setTimeout(() => {
        Promise.resolve()
          .then(task)
          .then(resolve, reject)
          .finally(() => {
            this._active--;
            this.#pump();
          });
      }, delay);
    }
  }

  async #get(path, asText = false) {
    if (this.offline) throw new Error('offline');
    const url = this.base() + path;
    // Each attempt (including retries) goes back through the throttle so a burst
    // of retries can't re-trip the rate limit.
    const backoffs = [0, 700, 1500, 3000, 5000];
    let lastErr;
    for (const wait of backoffs) {
      if (wait) await sleep(wait);
      try {
        return await this.#schedule(async () => {
          const res = await fetch(url);
          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; // network/CORS rejection (TypeError) or thrown above
      }
    }
    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;
}