@anywalletkit/wallet-btc 0.2.5 → 0.3.1

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/index.cjs CHANGED
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
23
  BlockbookClient: () => BlockbookClient,
24
+ BroadcastUnknownError: () => BroadcastUnknownError,
24
25
  CAIP2_GENESIS: () => CAIP2_GENESIS,
25
26
  DEFAULT_MAX_FEE_RATE: () => DEFAULT_MAX_FEE_RATE,
26
27
  DUST_LIMIT_P2TR: () => DUST_LIMIT_P2TR,
@@ -54,6 +55,7 @@ __export(src_exports, {
54
55
  encodeHistoryCursor: () => encodeCursor,
55
56
  encodeMessageSignature: () => encodeMessageSignature,
56
57
  estimateVsize: () => estimateVsize,
58
+ isAnsweredRefusal: () => isAnsweredRefusal,
57
59
  magicHash: () => magicHash,
58
60
  mapBlockbookTx: () => mapBlockbookTx,
59
61
  networkParams: () => networkParams,
@@ -102,9 +104,17 @@ var UnsupportedAddressTypeError = class extends Error {
102
104
  this.name = "UnsupportedAddressTypeError";
103
105
  }
104
106
  };
105
-
106
- // src/blockbook.ts
107
- var import_core3 = require("@anywalletkit/core");
107
+ var BroadcastUnknownError = class extends Error {
108
+ /** Always true: the transaction may be on its way. */
109
+ maybeSent = true;
110
+ /** The transaction id (hex). */
111
+ hash;
112
+ constructor(hash, cause) {
113
+ super(`Broadcast outcome unknown for ${hash}: ${cause instanceof Error ? cause.message.slice(0, 200) : String(cause).slice(0, 200)}`, { cause });
114
+ this.name = "BroadcastUnknownError";
115
+ this.hash = hash;
116
+ }
117
+ };
108
118
 
109
119
  // src/http.ts
110
120
  var import_core2 = require("@anywalletkit/core");
@@ -117,11 +127,25 @@ async function fetchWithTimeout(fetchFn, url, init, timeoutMs, provider) {
117
127
  throw new import_core2.NetworkError(`Request failed: ${url}`, { provider, cause: e });
118
128
  }
119
129
  }
130
+ var refusedStatus = (status) => status >= 400 && status < 500 && status !== 408;
131
+ function refusal(e) {
132
+ return Object.assign(e, { answered: true });
133
+ }
134
+ function isAnsweredRefusal(e) {
135
+ return e?.answered === true;
136
+ }
137
+ function isAlreadyKnown(e) {
138
+ for (let x = e, depth = 0; x instanceof Error && depth < 5; x = x.cause, depth++) {
139
+ if (/txn-already-(in-mempool|known)|already (known|in (the )?(mempool|block ?chain))/i.test(x.message)) return true;
140
+ }
141
+ return false;
142
+ }
120
143
  var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
121
144
  var isHex64 = (v) => typeof v === "string" && /^[0-9a-fA-F]{64}$/.test(v);
122
145
  var isIntString = (v) => typeof v === "string" && /^-?\d+$/.test(v);
123
146
 
124
147
  // src/blockbook.ts
148
+ var import_core3 = require("@anywalletkit/core");
125
149
  var MEMPOOL_SPACE_URL = "https://mempool.space";
126
150
  function unconfirmedOutgoing(transactions, address) {
127
151
  const pending = (transactions ?? []).filter((tx) => tx.blockHeight === -1);
@@ -162,7 +186,11 @@ var BlockbookClient = class _BlockbookClient {
162
186
  async get(path, check) {
163
187
  const url = `${this.base}/api${path}`;
164
188
  const res = await fetchWithTimeout(this.fetchFn, url, void 0, this.timeoutMs, this.base);
165
- if (!res.ok) throw new import_core3.NetworkError(`Blockbook request failed: ${res.status} ${res.statusText} (${url})`, { provider: this.base });
189
+ if (!res.ok) {
190
+ const reason = refusedStatus(res.status) ? (await res.text().catch(() => "")).slice(0, 200) : "";
191
+ const err = new import_core3.NetworkError(`Blockbook request failed: ${res.status} ${res.statusText} (${url})${reason ? `: ${reason}` : ""}`, { provider: this.base });
192
+ throw refusedStatus(res.status) ? refusal(err) : err;
193
+ }
166
194
  let body;
167
195
  try {
168
196
  body = await res.json();
@@ -226,7 +254,7 @@ var BlockbookClient = class _BlockbookClient {
226
254
  }
227
255
  async broadcast(rawTx) {
228
256
  const data = await this.get(`/v2/sendtx/${rawTx}`, (v) => isRecord(v));
229
- if (data.error) throw new Error(typeof data.error === "string" ? data.error : data.error.message ?? "broadcast failed");
257
+ if (data.error) throw refusal(new Error(typeof data.error === "string" ? data.error : data.error.message ?? "broadcast failed"));
230
258
  if (!data.result) throw new import_core3.NetworkError("Blockbook returned no txid", { provider: this.base });
231
259
  return data.result;
232
260
  }
@@ -274,7 +302,11 @@ var EsploraClient = class {
274
302
  async request(path, init) {
275
303
  const url = `${this.base}${path}`;
276
304
  const res = await fetchWithTimeout(this.fetchFn, url, init, this.timeoutMs, this.base);
277
- if (!res.ok) throw new import_core4.NetworkError(`Esplora request failed: ${res.status} ${res.statusText} (${url})`, { provider: this.base });
305
+ if (!res.ok) {
306
+ const reason = refusedStatus(res.status) ? (await res.text().catch(() => "")).slice(0, 200) : "";
307
+ const err = new import_core4.NetworkError(`Esplora request failed: ${res.status} ${res.statusText} (${url})${reason ? `: ${reason}` : ""}`, { provider: this.base });
308
+ throw refusedStatus(res.status) ? refusal(err) : err;
309
+ }
278
310
  return res;
279
311
  }
280
312
  async json(path, check) {
@@ -379,6 +411,7 @@ function createClient(config) {
379
411
  const attempts = list.length + (config.retries ?? 3);
380
412
  const call = (method) => async (...args) => {
381
413
  let last;
414
+ let answered = true;
382
415
  for (let i = 0; i < attempts; i++) {
383
416
  const c = list[i % list.length];
384
417
  const fn = c[method];
@@ -387,9 +420,11 @@ function createClient(config) {
387
420
  return await fn.apply(c, args);
388
421
  } catch (e) {
389
422
  last = e;
423
+ answered &&= isAnsweredRefusal(e);
390
424
  }
391
425
  }
392
- throw new import_core5.NetworkError(`All BTC clients failed for ${method}`, { cause: last });
426
+ const err = new import_core5.NetworkError(`All BTC clients failed for ${method}`, { cause: last });
427
+ throw answered && last !== void 0 ? refusal(err) : err;
393
428
  };
394
429
  const out = {};
395
430
  for (const m of METHODS) out[m] = call(m);
@@ -610,11 +645,28 @@ function describeTransaction(tx, inputValues, ctx) {
610
645
  });
611
646
  const outputs = [];
612
647
  const warnings = /* @__PURE__ */ new Set();
648
+ const scriptOf = (address) => {
649
+ try {
650
+ return import_base2.hex.encode(decodeAddress(address, ctx.network).script);
651
+ } catch {
652
+ return void 0;
653
+ }
654
+ };
655
+ const ownScript = scriptOf(ctx.ownAddress);
656
+ const intendedScript = ctx.intended ? scriptOf(ctx.intended.to) : void 0;
657
+ let recipientSeen = false;
613
658
  for (let i = 0; i < tx.outputsLength; i++) {
614
659
  const out = tx.getOutput(i);
615
660
  const value = out.amount ?? 0n;
616
661
  const address = tx.getOutputAddress(i, ctx.network);
617
- const role = address !== void 0 && address === ctx.ownAddress ? "change" : ctx.intended === void 0 ? "recipient" : address !== void 0 && address === ctx.intended.to && value === ctx.intended.value ? "recipient" : "unknown";
662
+ const script = out.script ? import_base2.hex.encode(out.script) : void 0;
663
+ const mine = script !== void 0 && script === ownScript;
664
+ let role;
665
+ if (ctx.intended === void 0) role = mine ? "change" : "recipient";
666
+ else if (!recipientSeen && script !== void 0 && script === intendedScript && value === ctx.intended.value) {
667
+ role = "recipient";
668
+ recipientSeen = true;
669
+ } else role = mine ? "change" : "unknown";
618
670
  if (out.script && value < dustLimit(scriptKind(out.script))) warnings.add("dust-output");
619
671
  outputs.push({ address, value, role });
620
672
  }
@@ -1045,8 +1097,7 @@ var WalletAccountBtc = class _WalletAccountBtc extends WalletAccountReadOnlyBtc
1045
1097
  fee = built.fee;
1046
1098
  }
1047
1099
  this.checkMax(fee, (0, import_core9.feeLimit)(this._config.transactionMaxFee, options), "transaction");
1048
- const txid = await client.broadcast(rawHex);
1049
- return { hash: txid || hash, fee };
1100
+ return { hash: await this.broadcast(client, rawHex, hash), fee };
1050
1101
  }
1051
1102
  /** Native BTC only (`token` is rejected). Subject to `transferMaxFee`. */
1052
1103
  async transfer(o, writeOptions = {}) {
@@ -1055,8 +1106,21 @@ var WalletAccountBtc = class _WalletAccountBtc extends WalletAccountReadOnlyBtc
1055
1106
  const client = this.requireClient("transfer");
1056
1107
  const built = await this.signSpend({ to: o.recipient, value: o.amount });
1057
1108
  this.checkMax(built.fee, (0, import_core9.feeLimit)(this._config.transferMaxFee, writeOptions), "transfer");
1058
- const txid = await client.broadcast(built.tx.hex);
1059
- return { hash: txid || built.tx.id, fee: built.fee };
1109
+ return { hash: await this.broadcast(client, built.tx.hex, built.tx.id), fee: built.fee };
1110
+ }
1111
+ /**
1112
+ * Send once through the client. Only a node's refusal means nothing went out; "already in the
1113
+ * mempool / block chain" means an earlier attempt delivered it; anything else may have landed and
1114
+ * is a {@link BroadcastUnknownError} with the txid computed before sending (round 3, M12).
1115
+ */
1116
+ async broadcast(client, rawHex, txid) {
1117
+ try {
1118
+ return await client.broadcast(rawHex) || txid;
1119
+ } catch (e) {
1120
+ if (isAlreadyKnown(e)) return txid;
1121
+ if (isAnsweredRefusal(e)) throw e;
1122
+ throw new BroadcastUnknownError(txid, e);
1123
+ }
1060
1124
  }
1061
1125
  /** The same address as a {@link WalletAccountReadOnlyBtc}. */
1062
1126
  toReadOnlyAccount() {