@anywalletkit/wallet-btc 0.2.4 → 0.3.0

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
  }
@@ -901,7 +953,7 @@ var WalletAccountBtc = class _WalletAccountBtc extends WalletAccountReadOnlyBtc
901
953
  ctx(intended) {
902
954
  return { network: this._network, ownAddress: this.address, intended, maxFeeRate: this.maxFeeRate };
903
955
  }
904
- async buildAndSign(plan, to, recipientValue, change, lockTime, rbf) {
956
+ async signPlan(plan, to, recipientValue, change, lockTime, rbf) {
905
957
  const spend = spendInfo(this.publicKey, this._network, this.addressType);
906
958
  const tx = new import_btc_signer4.Transaction({ lockTime });
907
959
  const sequence = rbf ? RBF_SEQUENCE : FINAL_SEQUENCE;
@@ -919,7 +971,7 @@ var WalletAccountBtc = class _WalletAccountBtc extends WalletAccountReadOnlyBtc
919
971
  * signed vsize somehow needs more, the difference comes out of change (never the
920
972
  * recipient) and the tx is re-signed once.
921
973
  */
922
- async buildSignedTransaction(spend) {
974
+ async signSpend(spend) {
923
975
  this.live();
924
976
  const plan = await this.planSpend(spend);
925
977
  await this.verifyPrevouts(plan.utxos);
@@ -927,7 +979,7 @@ var WalletAccountBtc = class _WalletAccountBtc extends WalletAccountReadOnlyBtc
927
979
  const rbf = spend.rbf ?? this._config.rbf ?? true;
928
980
  let change = plan.change;
929
981
  let fee = plan.fee;
930
- let tx = await this.buildAndSign(plan, spend.to, plan.amount, change, lockTime, rbf);
982
+ let tx = await this.signPlan(plan, spend.to, plan.amount, change, lockTime, rbf);
931
983
  const required = BigInt(tx.vsize) * plan.feeRate;
932
984
  if (required > fee) {
933
985
  const delta = required - fee;
@@ -940,7 +992,7 @@ var WalletAccountBtc = class _WalletAccountBtc extends WalletAccountReadOnlyBtc
940
992
  fee = plan.fee + plan.change;
941
993
  change = 0n;
942
994
  } else fee = required;
943
- tx = await this.buildAndSign(plan, spend.to, plan.amount, change, lockTime, rbf);
995
+ tx = await this.signPlan(plan, spend.to, plan.amount, change, lockTime, rbf);
944
996
  if (BigInt(tx.vsize) * plan.feeRate > fee) throw new Error("Fee shortfall after output rebalance.");
945
997
  }
946
998
  const ctx = this.ctx({ to: spend.to, value: plan.amount });
@@ -1011,7 +1063,7 @@ var WalletAccountBtc = class _WalletAccountBtc extends WalletAccountReadOnlyBtc
1011
1063
  /** "What you sign": the fully resolved spend (signed, NOT broadcast) or a caller's raw hex. */
1012
1064
  async describeTransaction(tx) {
1013
1065
  if (typeof tx === "string") return (await this.describeRaw(tx)).desc;
1014
- return (await this.buildSignedTransaction(tx)).desc;
1066
+ return (await this.signSpend(tx)).desc;
1015
1067
  }
1016
1068
  /** Sign without broadcasting. Returns the raw transaction hex. Raw hex input gets the checks `sendTransaction` runs (prevouts through the client) and is returned unchanged, never re-signed. */
1017
1069
  async signTransaction(tx, options = {}) {
@@ -1021,7 +1073,7 @@ var WalletAccountBtc = class _WalletAccountBtc extends WalletAccountReadOnlyBtc
1021
1073
  this.checkMax(desc.fee, (0, import_core9.feeLimit)(this._config.transactionMaxFee, options), "transaction");
1022
1074
  return tx;
1023
1075
  }
1024
- const { tx: built, fee } = await this.buildSignedTransaction(tx);
1076
+ const { tx: built, fee } = await this.signSpend(tx);
1025
1077
  this.checkMax(fee, (0, import_core9.feeLimit)(this._config.transactionMaxFee, options), "transaction");
1026
1078
  return built.hex;
1027
1079
  }
@@ -1039,24 +1091,36 @@ var WalletAccountBtc = class _WalletAccountBtc extends WalletAccountReadOnlyBtc
1039
1091
  hash = parsed.id;
1040
1092
  fee = desc.fee;
1041
1093
  } else {
1042
- const built = await this.buildSignedTransaction(tx);
1094
+ const built = await this.signSpend(tx);
1043
1095
  rawHex = built.tx.hex;
1044
1096
  hash = built.tx.id;
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 = {}) {
1053
1104
  if (o.token) throw new Error("Tokens are not supported on the bitcoin blockchain.");
1054
1105
  this.live();
1055
1106
  const client = this.requireClient("transfer");
1056
- const built = await this.buildSignedTransaction({ to: o.recipient, value: o.amount });
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() {