@bsv/wallet-toolbox-client 2.6.5 → 2.7.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.
@@ -175,23 +175,10 @@ var WalletError = class WalletError extends Error {
175
175
  * @returns stringified JSON representation of the error such that it can be desirialized to a WalletError.
176
176
  */
177
177
  static unknownToJson(error) {
178
- let json;
179
- let e;
180
- const t = typeof error;
181
- const ctorName = t === "object" && error !== null ? error.constructor : void 0;
182
- const ctor = ctorName != null && typeof ctorName.name === "string" ? ctorName.name : void 0;
183
- const name = t === "object" && error !== null && typeof error.name === "string" ? error.name : "";
184
- const message = t === "object" && error !== null && typeof error.message === "string" ? error.message : "";
185
- const hasToJson = t === "object" && typeof error?.toJson === "function";
186
- if (ctor != null && ctor !== "" && ctor.startsWith("WERR_") && hasToJson) json = error.toJson();
187
- else if (name !== "" && message !== "") {
188
- e = new WalletError(name, message);
189
- json = e.toJson();
190
- } else {
191
- e = new WalletError("WERR_UNKNOWN", String(error));
192
- json = e.toJson();
193
- }
194
- return json;
178
+ const candidate = error;
179
+ if ((error instanceof WalletError || candidate?.isError === true) && typeof candidate.toJson === "function") return candidate.toJson();
180
+ if (typeof candidate?.name === "string" && typeof candidate.message === "string") return new WalletError(candidate.name, candidate.message).toJson();
181
+ return new WalletError("WERR_UNKNOWN", String(error)).toJson();
195
182
  }
196
183
  };
197
184
  //#endregion
@@ -225,6 +212,47 @@ var WERR_INVALID_OPERATION = class extends WalletError {
225
212
  }
226
213
  };
227
214
  /**
215
+ * A destructive UTXO review could not obtain a conclusive verdict for every
216
+ * candidate. Consumers can match this name and retry later without parsing the
217
+ * human-readable message.
218
+ */
219
+ var WERR_UTXO_REVIEW_INCONCLUSIVE = class extends WalletError {
220
+ checked;
221
+ confirmedSpent;
222
+ unknown;
223
+ constructor(checked, confirmedSpent, unknown) {
224
+ super("WERR_UTXO_REVIEW_INCONCLUSIVE", `UTXO review was inconclusive for ${unknown} of ${checked} candidates; no outputs were changed.`);
225
+ this.checked = checked;
226
+ this.confirmedSpent = confirmedSpent;
227
+ this.unknown = unknown;
228
+ }
229
+ toJson() {
230
+ const obj = JSON.parse(super.toJson());
231
+ obj.checked = this.checked;
232
+ obj.confirmedSpent = this.confirmedSpent;
233
+ obj.unknown = this.unknown;
234
+ return JSON.stringify(obj);
235
+ }
236
+ };
237
+ /**
238
+ * An action-batch lifecycle operation cannot continue in the batch's current state.
239
+ */
240
+ var WERRActionBatchState = class extends WalletError {
241
+ state;
242
+ batchId;
243
+ constructor(state, batchId) {
244
+ super("WERR_ACTION_BATCH_STATE", `Action batch cannot continue because it is ${state}.`);
245
+ this.state = state;
246
+ this.batchId = batchId;
247
+ }
248
+ toJson() {
249
+ const obj = JSON.parse(super.toJson());
250
+ obj.state = this.state;
251
+ obj.batchId = this.batchId;
252
+ return JSON.stringify(obj);
253
+ }
254
+ };
255
+ /**
228
256
  * Unable to broadcast transaction at this time.
229
257
  */
230
258
  var WERR_BROADCAST_UNAVAILABLE = class extends WalletError {
@@ -422,6 +450,10 @@ function WalletErrorFromJson(json) {
422
450
  let e;
423
451
  const obj = json;
424
452
  switch (obj.name) {
453
+ case "WERR_ACTION_BATCH_STATE":
454
+ e = new WERRActionBatchState(obj.state, obj.batchId);
455
+ e.message = obj.message;
456
+ break;
425
457
  case "WERR_NOT_IMPLEMENTED":
426
458
  e = new WERR_NOT_IMPLEMENTED(obj.message);
427
459
  break;
@@ -431,6 +463,10 @@ function WalletErrorFromJson(json) {
431
463
  case "WERR_INVALID_OPERATION":
432
464
  e = new WERR_INVALID_OPERATION(obj.message);
433
465
  break;
466
+ case "WERR_UTXO_REVIEW_INCONCLUSIVE":
467
+ e = new WERR_UTXO_REVIEW_INCONCLUSIVE(obj.checked, obj.confirmedSpent, obj.unknown);
468
+ e.message = obj.message;
469
+ break;
434
470
  case "WERR_BROADCAST_UNAVAILABLE":
435
471
  e = new WERR_BROADCAST_UNAVAILABLE(obj.message);
436
472
  break;
@@ -507,9 +543,10 @@ const specOpWalletManagedUtxos = "284570a6213a74ba861c38b1cf790e1e400d9cf9324454
507
543
  /**
508
544
  * `listOutputs` special operation basket name value.
509
545
  *
510
- * Returns currently spendable wallet change outputs that fail to validate as unspent transaction outputs.
546
+ * Returns currently spendable wallet change outputs conclusively confirmed spent.
547
+ * The operation rejects if any candidate cannot be classified conclusively.
511
548
  *
512
- * Optional tag value 'release'. If present, updates invalid change outputs to not spendable.
549
+ * Optional tag value 'release'. If present, atomically updates only confirmed-spent change outputs to not spendable after rechecking current ownership and allocation state.
513
550
  *
514
551
  * Optional tag value 'all'. If present, processes all spendable true outputs, independent of baskets, but basket must be defined.
515
552
  */
@@ -839,6 +876,7 @@ var sdk_exports = /* @__PURE__ */ __exportAll({
839
876
  ProvenTxReqNonTerminalStatus: () => ProvenTxReqNonTerminalStatus,
840
877
  ProvenTxReqTerminalStatus: () => ProvenTxReqTerminalStatus,
841
878
  Validation: () => _bsv_sdk.Validation,
879
+ WERR_ACTION_BATCH_STATE: () => WERRActionBatchState,
842
880
  WERR_BAD_REQUEST: () => WERR_BAD_REQUEST,
843
881
  WERR_BROADCAST_UNAVAILABLE: () => WERR_BROADCAST_UNAVAILABLE,
844
882
  WERR_INSUFFICIENT_FUNDS: () => WERR_INSUFFICIENT_FUNDS,
@@ -853,6 +891,7 @@ var sdk_exports = /* @__PURE__ */ __exportAll({
853
891
  WERR_NOT_IMPLEMENTED: () => WERR_NOT_IMPLEMENTED,
854
892
  WERR_REVIEW_ACTIONS: () => WERR_REVIEW_ACTIONS,
855
893
  WERR_UNAUTHORIZED: () => WERR_UNAUTHORIZED,
894
+ WERR_UTXO_REVIEW_INCONCLUSIVE: () => WERR_UTXO_REVIEW_INCONCLUSIVE,
856
895
  WalletError: () => WalletError,
857
896
  WalletErrorFromJson: () => WalletErrorFromJson,
858
897
  isCreateActionSpecOp: () => isCreateActionSpecOp,
@@ -1019,8 +1058,8 @@ function toLookupNetworkPreset(chain) {
1019
1058
  switch (chain) {
1020
1059
  case "main": return "mainnet";
1021
1060
  case "test": return "testnet";
1061
+ case "ttn": return "teratestnet";
1022
1062
  case "stn":
1023
- case "ttn":
1024
1063
  case "tstn":
1025
1064
  case "mock": return "local";
1026
1065
  }
@@ -1391,6 +1430,42 @@ function makeBrc114ActionTimeLabel(unixMillis) {
1391
1430
  return `action time ${unixMillis}`;
1392
1431
  }
1393
1432
  //#endregion
1433
+ //#region ../src/utility/brc153ReferenceLabels.ts
1434
+ const BRC153_REFERENCE_PREFIX = "reference ";
1435
+ /**
1436
+ * Build the BRC-153 synthetic listActions label for an action reference.
1437
+ * Encodes reference bytes as lowercase hex (labels are lowercased by validation).
1438
+ */
1439
+ function makeBrc153ReferenceLabel(referenceBase64) {
1440
+ const bytes = _bsv_sdk.Utils.toArray(referenceBase64, "base64");
1441
+ return `${BRC153_REFERENCE_PREFIX}${_bsv_sdk.Utils.toHex(bytes)}`;
1442
+ }
1443
+ /**
1444
+ * True iff the label uses the reserved BRC-153 reference prefix.
1445
+ */
1446
+ function isBrc153ReferenceLabel(label) {
1447
+ return label.startsWith(BRC153_REFERENCE_PREFIX);
1448
+ }
1449
+ /**
1450
+ * Ensure labels contain exactly one wallet-authored `reference <hex>`.
1451
+ * Any existing reserved-prefix labels are replaced.
1452
+ */
1453
+ function applyBrc153ReferenceLabel(labels, referenceBase64) {
1454
+ const next = labels.filter((label) => !isBrc153ReferenceLabel(label));
1455
+ next.push(makeBrc153ReferenceLabel(referenceBase64));
1456
+ return next;
1457
+ }
1458
+ /**
1459
+ * Parse a BRC-153 synthetic reference label back to the BRC-100 Base64String reference.
1460
+ * Returns undefined if the label is not a valid reference label.
1461
+ */
1462
+ function parseBrc153ReferenceLabel(label) {
1463
+ if (!label.startsWith("reference ")) return void 0;
1464
+ const hex = label.slice(10);
1465
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/.test(hex)) return void 0;
1466
+ return _bsv_sdk.Utils.toBase64(_bsv_sdk.Utils.toArray(hex, "hex"));
1467
+ }
1468
+ //#endregion
1394
1469
  //#region ../src/storage/schema/entities/EntityBase.ts
1395
1470
  var EntityBase = class {
1396
1471
  api;
@@ -2094,6 +2169,58 @@ var EntityOutput = class EntityOutput extends EntityBase {
2094
2169
  }
2095
2170
  };
2096
2171
  //#endregion
2172
+ //#region ../src/storage/methods/managedChangePolicy.ts
2173
+ /** Historical default retained only to identify untouched wallet baskets. */
2174
+ const LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS = 32;
2175
+ /**
2176
+ * Default liquidity policy for wallet-managed change.
2177
+ *
2178
+ * The preferred minimum is deliberately much larger than the dust threshold.
2179
+ * Dust answers "can this output ever be spent economically?"; this value
2180
+ * answers "is this output useful as an independently selectable liquidity
2181
+ * unit at contemporary fee rates?".
2182
+ */
2183
+ const DEFAULT_MANAGED_CHANGE_TARGET_UTXOS = 144;
2184
+ const DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS = 5e3;
2185
+ const DEFAULT_MANAGED_CHANGE_MAX_OUTPUTS_PER_ACTION = 8;
2186
+ const DEFAULT_MANAGED_CHANGE_MIGRATION_INPUTS_PER_ACTION = 4;
2187
+ const DEFAULT_MANAGED_CHANGE_PENDING_COMPARISON_INPUTS = 16;
2188
+ /** True only for the exact historical default that is safe to auto-upgrade. */
2189
+ function isLegacyManagedChangeBasketDefault(basket) {
2190
+ return basket.name === "default" && basket.numberOfDesiredUTXOs === 144 && basket.minimumDesiredUTXOValue === 32;
2191
+ }
2192
+ /**
2193
+ * Normalize a legacy default while retaining every other field and every
2194
+ * operator-selected non-default value. Used by migrations, sync, and restore.
2195
+ */
2196
+ function upgradeLegacyManagedChangeBasketDefault(basket) {
2197
+ if (!isLegacyManagedChangeBasketDefault(basket)) return basket;
2198
+ return {
2199
+ ...basket,
2200
+ minimumDesiredUTXOValue: DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS
2201
+ };
2202
+ }
2203
+ function defaultManagedChangePolicy() {
2204
+ return {
2205
+ maxOutputsPerAction: 8,
2206
+ migrationInputsPerAction: 4,
2207
+ pendingComparisonInputs: 16
2208
+ };
2209
+ }
2210
+ function validateManagedChangePolicy(options) {
2211
+ const policy = {
2212
+ ...defaultManagedChangePolicy(),
2213
+ ...options
2214
+ };
2215
+ const validateLimit = (value, name, minimum) => {
2216
+ if (value !== -1 && (!Number.isSafeInteger(value) || value < minimum)) throw new WERR_INVALID_PARAMETER(`managedChangePolicy.${name}`, `${minimum === 0 ? "a non-negative" : "a positive"} safe integer or -1 for unlimited`);
2217
+ };
2218
+ validateLimit(policy.maxOutputsPerAction, "maxOutputsPerAction", 1);
2219
+ validateLimit(policy.migrationInputsPerAction, "migrationInputsPerAction", 0);
2220
+ validateLimit(policy.pendingComparisonInputs, "pendingComparisonInputs", 1);
2221
+ return policy;
2222
+ }
2223
+ //#endregion
2097
2224
  //#region ../src/storage/schema/entities/EntityOutputBasket.ts
2098
2225
  var EntityOutputBasket = class EntityOutputBasket extends EntityBase {
2099
2226
  constructor(api) {
@@ -2196,13 +2323,15 @@ var EntityOutputBasket = class EntityOutputBasket extends EntityBase {
2196
2323
  this.userId = userId;
2197
2324
  this.name ||= "default";
2198
2325
  this.basketId = 0;
2326
+ this.api = upgradeLegacyManagedChangeBasketDefault(this.api);
2199
2327
  this.basketId = await storage.insertOutputBasket(this.toApi(), trx);
2200
2328
  }
2201
2329
  async mergeExisting(storage, since, ei, syncMap, trx) {
2202
2330
  let wasMerged = false;
2203
2331
  if (ei.updated_at > this.updated_at) {
2204
- this.minimumDesiredUTXOValue = ei.minimumDesiredUTXOValue;
2205
- this.numberOfDesiredUTXOs = ei.numberOfDesiredUTXOs;
2332
+ const incoming = upgradeLegacyManagedChangeBasketDefault(ei);
2333
+ this.minimumDesiredUTXOValue = incoming.minimumDesiredUTXOValue;
2334
+ this.numberOfDesiredUTXOs = incoming.numberOfDesiredUTXOs;
2206
2335
  this.isDeleted = ei.isDeleted;
2207
2336
  this.updated_at = new Date(Math.max(ei.updated_at.getTime(), this.updated_at.getTime()));
2208
2337
  await storage.updateOutputBasket(this.id, this.toApi(), trx);
@@ -4372,6 +4501,12 @@ var WalletStorageManager = class {
4372
4501
  async renewActionBatch(batchId) {
4373
4502
  return await this.runAsWriter(async (writer) => await writer.renewActionBatch(await this.getAuth(true), batchId));
4374
4503
  }
4504
+ async resumeActionBatch(args) {
4505
+ return await this.runAsWriter(async (writer) => {
4506
+ if (writer.resumeActionBatch == null) throw new WERR_NOT_IMPLEMENTED("action batch resume is not available");
4507
+ return await writer.resumeActionBatch(await this.getAuth(true), args);
4508
+ });
4509
+ }
4375
4510
  async prepareActionBatchCommit(manifest) {
4376
4511
  return await this.runAsWriter(async (writer) => await writer.prepareActionBatchCommit(await this.getAuth(true), manifest));
4377
4512
  }
@@ -4726,8 +4861,20 @@ var WalletStorageManager = class {
4726
4861
  });
4727
4862
  return log;
4728
4863
  }
4864
+ /**
4865
+ * Return the remote HTTP(S) endpoint for a managed store, if any.
4866
+ *
4867
+ * Duck-types `endpointUrl` on the provider (as set by `StorageClientBase`).
4868
+ * Do **not** key this off `constructor.name === 'StorageClient'`: production
4869
+ * minifiers (Vite/esbuild/webpack) rename classes, so that check fails and
4870
+ * every remote store reports `endpointURL: undefined` even though the URL is
4871
+ * present. Consumers that match backups by URL (e.g. making a remote store
4872
+ * primary) then fail while sync still works, because sync walks `_backups`
4873
+ * without needing `endpointURL`.
4874
+ */
4729
4875
  getStoreEndpointURL(store) {
4730
- if (store.storage.constructor.name === "StorageClient") return store.storage.endpointUrl;
4876
+ const url = store.storage.endpointUrl;
4877
+ if (typeof url === "string" && url.length > 0) return url;
4731
4878
  }
4732
4879
  getStores() {
4733
4880
  const stores = [];
@@ -5153,7 +5300,7 @@ async function getBeefForTransaction(storage, txid, options) {
5153
5300
  const concurrency = normalizeConcurrency(options.maxConcurrency);
5154
5301
  while (frontier.length > 0) {
5155
5302
  const current = frontier.filter((item) => needsResolution(beef, item.txid, hasKnownTxid));
5156
- const resolved = await mapWithConcurrency(current, concurrency, async (item) => await resolveBeefForTransaction(storage, item.txid, options, hasKnownTxid, item.depth));
5303
+ const resolved = await mapWithConcurrency$1(current, concurrency, async (item) => await resolveBeefForTransaction(storage, item.txid, options, hasKnownTxid, item.depth));
5157
5304
  const next = [];
5158
5305
  for (let i = 0; i < resolved.length; i++) {
5159
5306
  const result = resolved[i];
@@ -5210,7 +5357,7 @@ function requiresSingleRootPolicy(options) {
5210
5357
  return options.ignoreStorage === true || options.minProofLevel !== void 0 || options.chainTracker != null || options.skipInvalidProofs === true;
5211
5358
  }
5212
5359
  async function mergeSingleRootFragments(storage, roots, options, beef) {
5213
- const fragments = await mapWithConcurrency(roots.filter((txid) => beef.findTxid(txid) == null), normalizeConcurrency(options.maxConcurrency), async (txid) => await getBeefForTransaction(storage, txid, {
5360
+ const fragments = await mapWithConcurrency$1(roots.filter((txid) => beef.findTxid(txid) == null), normalizeConcurrency(options.maxConcurrency), async (txid) => await getBeefForTransaction(storage, txid, {
5214
5361
  ...options,
5215
5362
  mergeToBeef: void 0
5216
5363
  }));
@@ -5329,7 +5476,7 @@ function needsResolution(beef, txid, hasKnownTxid) {
5329
5476
  async function mergeMissingFragments(storage, beef, missing, options) {
5330
5477
  if (missing.length === 0) return;
5331
5478
  if (options.ignoreServices === true) throw new WERR_INVALID_PARAMETER(`txid ${missing[0].txid}`, `valid transaction on chain ${storage.chain}`);
5332
- const fragments = await mapWithConcurrency(missing, normalizeConcurrency(options.maxConcurrency), async (item) => await getBeefForTransaction(storage, item.txid, {
5479
+ const fragments = await mapWithConcurrency$1(missing, normalizeConcurrency(options.maxConcurrency), async (item) => await getBeefForTransaction(storage, item.txid, {
5333
5480
  ...options,
5334
5481
  ignoreStorage: true,
5335
5482
  mergeToBeef: void 0
@@ -5352,7 +5499,7 @@ function makeKnownTxidLookup$1(knownTxids) {
5352
5499
  function normalizeConcurrency(value = 8) {
5353
5500
  return Number.isFinite(value) ? Math.max(1, Math.min(32, Math.floor(value))) : 8;
5354
5501
  }
5355
- async function mapWithConcurrency(values, concurrency, mapper) {
5502
+ async function mapWithConcurrency$1(values, concurrency, mapper) {
5356
5503
  const results = Array.from({ length: values.length }, () => void 0);
5357
5504
  let cursor = 0;
5358
5505
  await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => {
@@ -5763,6 +5910,190 @@ async function commitNewTxToStorage(storage, userId, vargs) {
5763
5910
  return r;
5764
5911
  }
5765
5912
  //#endregion
5913
+ //#region ../src/services/classifyOutputUtxo.ts
5914
+ async function mapWithConcurrency(values, maxConcurrency, worker) {
5915
+ const results = Array.from({ length: values.length });
5916
+ let nextIndex = 0;
5917
+ const workers = Array.from({ length: Math.min(maxConcurrency, values.length) }, async () => {
5918
+ while (nextIndex < values.length) {
5919
+ const index = nextIndex++;
5920
+ results[index] = await worker(values[index], index);
5921
+ }
5922
+ });
5923
+ await Promise.all(workers);
5924
+ return results;
5925
+ }
5926
+ /**
5927
+ * Classify an output without ever converting provider absence or failure into
5928
+ * positive spent evidence. This helper deliberately catches provider throws so
5929
+ * batch callers can finish classifying every candidate before deciding whether
5930
+ * a destructive operation is safe.
5931
+ */
5932
+ async function classifyOutputUtxo(services, output) {
5933
+ if (output.lockingScript == null || output.lockingScript.length === 0) return {
5934
+ verdict: "unknown",
5935
+ provider: "<no-locking-script>",
5936
+ error: new WERR_INVALID_PARAMETER("output.lockingScript", "validated by storage provider validateOutputScript.")
5937
+ };
5938
+ if (typeof output.txid !== "string" || !/^[0-9a-fA-F]{64}$/.test(output.txid) || !Number.isSafeInteger(output.vout) || output.vout < 0) return {
5939
+ verdict: "unknown",
5940
+ provider: "<invalid-outpoint>",
5941
+ error: new WERR_INVALID_PARAMETER("output outpoint", "a 32-byte transaction ID and non-negative vout")
5942
+ };
5943
+ try {
5944
+ const hash = services.hashOutputScript(_bsv_sdk.Utils.toHex(output.lockingScript));
5945
+ const result = await services.getUtxoStatus(hash, void 0, `${output.txid}.${output.vout}`);
5946
+ const provider = typeof result?.name === "string" && result.name.length > 0 ? result.name : "<invalid-provider-result>";
5947
+ if (result?.status !== "success" || typeof result.isUtxo !== "boolean" || provider === "<invalid-provider-result>") return {
5948
+ verdict: "unknown",
5949
+ provider,
5950
+ error: result?.error
5951
+ };
5952
+ return {
5953
+ verdict: result.isUtxo ? "unspent" : "spent",
5954
+ provider
5955
+ };
5956
+ } catch (error) {
5957
+ return {
5958
+ verdict: "unknown",
5959
+ provider: "<provider-error>",
5960
+ error
5961
+ };
5962
+ }
5963
+ }
5964
+ /**
5965
+ * Convert an internal tri-state classification to the historical boolean
5966
+ * contract only when a provider supplied a conclusive answer.
5967
+ */
5968
+ function requireConclusiveUtxo(classification) {
5969
+ if (classification.verdict === "unspent") return true;
5970
+ if (classification.verdict === "spent") return false;
5971
+ if (classification.error instanceof Error) throw classification.error;
5972
+ throw new WERR_INVALID_OPERATION(`UTXO provider ${classification.provider} did not return a conclusive result.`);
5973
+ }
5974
+ //#endregion
5975
+ //#region ../src/storage/methods/reconcileFailedTransactionInputs.ts
5976
+ async function quarantineLocalInputs(localInputs, storage, trx) {
5977
+ const staleOutpoints = /* @__PURE__ */ new Set();
5978
+ for (const { output, outpoint } of localInputs) {
5979
+ await storage.updateOutput(verifyId(output.outputId), { spendable: false }, trx);
5980
+ staleOutpoints.add(`${outpoint.txid}.${outpoint.vout}`);
5981
+ }
5982
+ return {
5983
+ checked: localInputs.length,
5984
+ staleConfirmed: localInputs.length,
5985
+ staleOutpoints: [...staleOutpoints]
5986
+ };
5987
+ }
5988
+ async function findLocalInputs(req, storage, trx) {
5989
+ const outpoints = _bsv_sdk.Transaction.fromBinary(req.rawTx).inputs.map((input) => ({
5990
+ txid: input.sourceTXID ?? "",
5991
+ vout: input.sourceOutputIndex ?? 0
5992
+ })).filter((outpoint) => outpoint.txid !== "");
5993
+ if (outpoints.length === 0) return [];
5994
+ const transactions = await storage.findTransactions({
5995
+ partial: { txid: req.txid },
5996
+ noRawTx: true,
5997
+ trx
5998
+ });
5999
+ const userIds = [...new Set(transactions.map((transaction) => transaction.userId))];
6000
+ const localInputs = [];
6001
+ const localKeys = /* @__PURE__ */ new Set();
6002
+ for (const userId of userIds) {
6003
+ const byOutpoint = await storage.findOutputsByOutpoints(userId, outpoints, trx);
6004
+ for (const outpoint of outpoints) {
6005
+ const output = byOutpoint[`${outpoint.txid}.${outpoint.vout}`];
6006
+ const localKey = `${userId}:${outpoint.txid}.${outpoint.vout}`;
6007
+ if (output != null && !localKeys.has(localKey)) {
6008
+ localKeys.add(localKey);
6009
+ localInputs.push({
6010
+ output,
6011
+ outpoint
6012
+ });
6013
+ }
6014
+ }
6015
+ }
6016
+ return localInputs;
6017
+ }
6018
+ /**
6019
+ * Conservatively quarantine every locally-owned input of a transaction after
6020
+ * Arcade supplies explicit missing-input/conflict evidence. This path is
6021
+ * deliberately independent of explorer or UTXO providers: a positive
6022
+ * broadcaster verdict is enough to stop the failed transaction from feeding
6023
+ * the same inputs into another action. A later validated mined proof remains
6024
+ * able to repair the transaction through the ordinary proof recovery path.
6025
+ */
6026
+ async function quarantineReqInputs(req, storage, trx, logger) {
6027
+ const result = await quarantineLocalInputs(await findLocalInputs(req, storage, trx), storage, trx);
6028
+ if (result.staleConfirmed > 0) logger?.log(`quarantineReqInputs: ${result.staleConfirmed} local input copy/copies quarantined for txid=${req.txid}`);
6029
+ return result;
6030
+ }
6031
+ /**
6032
+ * Keep only inputs created by a locally terminal parent unavailable after the
6033
+ * failed-child transition releases its allocations. Other inputs may still be
6034
+ * valid UTXOs and must remain reusable.
6035
+ */
6036
+ async function quarantineReqInputsFromFailedParents(req, failedParentTxids, storage, trx, logger) {
6037
+ const failedParents = new Set(failedParentTxids);
6038
+ const result = await quarantineLocalInputs((await findLocalInputs(req, storage, trx)).filter(({ outpoint }) => failedParents.has(outpoint.txid)), storage, trx);
6039
+ if (result.staleConfirmed > 0) logger?.log(`quarantineReqInputsFromFailedParents: ${result.staleConfirmed} local parent output copy/copies quarantined for txid=${req.txid}`);
6040
+ return result;
6041
+ }
6042
+ /**
6043
+ * Ask the configured UTXO-provider collection to classify the request's local
6044
+ * inputs and mark only positively confirmed spent outputs unavailable.
6045
+ * Provider errors and a collection with no providers are inconclusive and
6046
+ * never count as spent.
6047
+ */
6048
+ async function markConfirmedStaleReqInputs(req, storage, services, trx, logger) {
6049
+ const result = {
6050
+ checked: 0,
6051
+ staleConfirmed: 0,
6052
+ staleOutpoints: []
6053
+ };
6054
+ const localInputs = await findLocalInputs(req, storage, trx);
6055
+ const verdicts = /* @__PURE__ */ new Map();
6056
+ const uniqueInputs = /* @__PURE__ */ new Map();
6057
+ for (const localInput of localInputs) {
6058
+ const key = `${localInput.outpoint.txid}.${localInput.outpoint.vout}`;
6059
+ if (!uniqueInputs.has(key)) uniqueInputs.set(key, localInput);
6060
+ }
6061
+ await mapWithConcurrency([...uniqueInputs.values()], 4, async ({ output, outpoint }) => {
6062
+ const key = `${outpoint.txid}.${outpoint.vout}`;
6063
+ if (output.lockingScript == null) try {
6064
+ await storage.validateOutputScript(output, trx);
6065
+ } catch {
6066
+ verdicts.set(key, "inconclusive");
6067
+ return;
6068
+ }
6069
+ if (output.lockingScript == null) {
6070
+ verdicts.set(key, "inconclusive");
6071
+ return;
6072
+ }
6073
+ const classification = await classifyOutputUtxo(services, output);
6074
+ let verdict;
6075
+ if (classification.verdict === "unknown") verdict = "inconclusive";
6076
+ else if (classification.verdict === "unspent") verdict = "utxo";
6077
+ else verdict = "stale";
6078
+ verdicts.set(key, verdict);
6079
+ });
6080
+ const staleOutpoints = /* @__PURE__ */ new Set();
6081
+ for (const { output, outpoint } of localInputs) {
6082
+ const key = `${outpoint.txid}.${outpoint.vout}`;
6083
+ const verdict = verdicts.get(key);
6084
+ if (verdict == null || verdict === "inconclusive") continue;
6085
+ result.checked++;
6086
+ if (verdict === "stale") {
6087
+ await storage.updateOutput(verifyId(output.outputId), { spendable: false }, trx);
6088
+ result.staleConfirmed++;
6089
+ staleOutpoints.add(key);
6090
+ }
6091
+ }
6092
+ result.staleOutpoints = [...staleOutpoints];
6093
+ if (result.staleConfirmed > 0) logger?.log(`markConfirmedStaleReqInputs: ${result.staleConfirmed} of ${result.checked} local input copy/copies confirmed spent for txid=${req.txid}`);
6094
+ return result;
6095
+ }
6096
+ //#endregion
5766
6097
  //#region ../src/storage/methods/attemptToPostReqsToNetwork.ts
5767
6098
  /**
5768
6099
  * Attempt to post one or more `ProvenTxReq` with status 'unsent'
@@ -6102,64 +6433,7 @@ async function confirmDoubleSpend(ar, beef, storage, services, logger) {
6102
6433
  * that were actually evicted (added to history note for diagnostics).
6103
6434
  */
6104
6435
  async function markStaleInputsAsSpent(ar, storage, services, trx, logger) {
6105
- const result = {
6106
- checked: 0,
6107
- staleConfirmed: 0,
6108
- staleOutpoints: []
6109
- };
6110
- const req = ar.vreq.req;
6111
- const txIds = req.notify.transactionIds;
6112
- if (txIds == null || txIds.length === 0) return result;
6113
- const txRecord = (await storage.findTransactions({
6114
- partial: { transactionId: txIds[0] },
6115
- noRawTx: true,
6116
- trx
6117
- }))[0];
6118
- if (txRecord == null) return result;
6119
- const userId = txRecord.userId;
6120
- const outpoints = _bsv_sdk.Transaction.fromBinary(req.rawTx).inputs.map((i) => ({
6121
- txid: i.sourceTXID ?? "",
6122
- vout: i.sourceOutputIndex ?? 0
6123
- })).filter((o) => o.txid !== "");
6124
- if (outpoints.length === 0) return result;
6125
- const byOutpoint = await storage.findOutputsByOutpoints(userId, outpoints, trx);
6126
- const checks = await Promise.all(outpoints.map(async (outpoint) => {
6127
- const localOutput = byOutpoint[`${outpoint.txid}.${outpoint.vout}`];
6128
- if (localOutput == null) return { kind: "skipped" };
6129
- if (localOutput.lockingScript == null) try {
6130
- await storage.validateOutputScript(localOutput, trx);
6131
- } catch {
6132
- return { kind: "skipped" };
6133
- }
6134
- let isStillUtxo;
6135
- try {
6136
- isStillUtxo = await services.isUtxo(localOutput);
6137
- } catch {
6138
- return {
6139
- kind: "service-error",
6140
- localOutput
6141
- };
6142
- }
6143
- return isStillUtxo ? {
6144
- kind: "still-utxo",
6145
- localOutput
6146
- } : {
6147
- kind: "stale",
6148
- localOutput,
6149
- outpoint
6150
- };
6151
- }));
6152
- for (const c of checks) {
6153
- if (c.kind === "skipped") continue;
6154
- result.checked++;
6155
- if (c.kind === "stale") {
6156
- await storage.updateOutput(c.localOutput.outputId, { spendable: false }, trx);
6157
- result.staleConfirmed++;
6158
- result.staleOutpoints.push(`${c.outpoint.txid}.${c.outpoint.vout}`);
6159
- }
6160
- }
6161
- if (result.staleConfirmed > 0) logger?.log(`markStaleInputsAsSpent: ${result.staleConfirmed} of ${result.checked} input(s) confirmed-spent on chain for txid=${req.txid}`);
6162
- return result;
6436
+ return await markConfirmedStaleReqInputs(ar.vreq.req, storage, services, trx, logger);
6163
6437
  }
6164
6438
  //#endregion
6165
6439
  //#region ../src/storage/methods/listCertificates.ts
@@ -6312,6 +6586,75 @@ function removeDustOutputs(changeOutputs, dustFloor) {
6312
6586
  largest.satoshis += removed.satoshis;
6313
6587
  }
6314
6588
  }
6589
+ async function migrateLegacyChangeInputs(request) {
6590
+ const { params, result } = request;
6591
+ if (!params.surplusPoolShaping || result.changeOutputs.length === 0) return;
6592
+ if (request.targetNetCount <= request.netChangeCount()) return;
6593
+ const migrationLimit = params.maxMigrationInputs === -1 ? Number.MAX_SAFE_INTEGER : params.maxMigrationInputs ?? 0;
6594
+ for (let migrated = 0; migrated < migrationLimit; migrated++) {
6595
+ const marginalInputFee = request.feeTarget(1) - request.feeTarget();
6596
+ const candidate = await request.allocateChangeInput(0);
6597
+ if (candidate == null) break;
6598
+ if (candidate.satoshis >= params.changeInitialSatoshis || candidate.satoshis <= marginalInputFee) {
6599
+ await request.releaseChangeInput(candidate.outputId);
6600
+ break;
6601
+ }
6602
+ request.recordAllocatedInput(candidate);
6603
+ }
6604
+ }
6605
+ /**
6606
+ * Materialize the first managed-change output from surplus that is already in
6607
+ * the transaction. This is especially important for explicit/fixed inputs:
6608
+ * their value can fully fund an action before the allocator loop runs, but the
6609
+ * shaping policy must still capture the remainder without gathering another
6610
+ * wallet-managed input.
6611
+ */
6612
+ function materializeSurplusChangeOutput(request) {
6613
+ const { params, result } = request;
6614
+ if (!params.surplusPoolShaping || result.changeOutputs.length > 0) return;
6615
+ const availableAfterOutputFee = request.feeExcess(0, 1);
6616
+ if (availableAfterOutputFee < request.dustFloor) return;
6617
+ result.changeOutputs.push({
6618
+ satoshis: Math.min(availableAfterOutputFee, Math.max(request.dustFloor, params.changeFirstSatoshis)),
6619
+ lockingScriptLength: params.changeLockingScriptLength
6620
+ });
6621
+ request.feeExcess();
6622
+ }
6623
+ /**
6624
+ * Preserve the historical compatibility retry when another input can make a
6625
+ * viable change output, except when surplus-only shaping has nothing economic
6626
+ * to return. In that case the bounded remainder stays in the miner fee instead
6627
+ * of manufacturing change from an additional wallet input.
6628
+ */
6629
+ async function requireViableChangeOrRetainBoundedFee(request) {
6630
+ const { params, result } = request;
6631
+ const feeExcessNow = request.feeExcess();
6632
+ if (result.changeOutputs.length > 0 || feeExcessNow <= 0) return;
6633
+ if (params.surplusPoolShaping === true && request.feeExcess(0, 1) < request.dustFloor) return;
6634
+ const minimumChange = Math.max(request.dustFloor, params.changeFirstSatoshis);
6635
+ const totalSatoshisNeeded = request.spending() + request.feeTarget(0, 1) + minimumChange;
6636
+ const moreSatoshisNeeded = Math.max(1, totalSatoshisNeeded - request.funding());
6637
+ await request.releaseAllocatedChangeInputs();
6638
+ throw new WERR_INSUFFICIENT_FUNDS(totalSatoshisNeeded, moreSatoshisNeeded);
6639
+ }
6640
+ function shapeSurplusChangeOutputs(request) {
6641
+ const { params, result } = request;
6642
+ if (!params.surplusPoolShaping || result.changeOutputs.length !== 1) return;
6643
+ if (request.targetNetCount <= request.netChangeCount()) return;
6644
+ const originalSatoshis = result.changeOutputs[0].satoshis;
6645
+ const desiredOutputs = Math.min(request.maxChangeOutputs, Math.max(1, request.targetNetCount + result.allocatedChangeInputs.length));
6646
+ for (let count = desiredOutputs; count > 1; count--) {
6647
+ const addedOutputs = count - 1;
6648
+ const distributable = originalSatoshis - (request.feeTarget(0, addedOutputs) - request.feeTarget());
6649
+ if (distributable < count * params.changeInitialSatoshis) continue;
6650
+ result.changeOutputs = Array.from({ length: count }, () => ({
6651
+ satoshis: params.changeInitialSatoshis,
6652
+ lockingScriptLength: params.changeLockingScriptLength
6653
+ }));
6654
+ distributeExcessFees(result.changeOutputs, params.changeInitialSatoshis, distributable - count * params.changeInitialSatoshis, request.rand);
6655
+ break;
6656
+ }
6657
+ }
6315
6658
  /**
6316
6659
  * Simplifications:
6317
6660
  * - only support one change type with fixed length scripts.
@@ -6383,7 +6726,8 @@ async function generateChangeSdkCore(params, allocateChangeInput, releaseChangeI
6383
6726
  * Applies the per-transaction limit so that the UTXO pool grows
6384
6727
  * gradually rather than all at once.
6385
6728
  */
6386
- const maxChangeOutputs = params.maxChangeOutputs ?? 8;
6729
+ const maxChangeOutputs = params.maxChangeOutputs === -1 ? Number.MAX_SAFE_INTEGER : params.maxChangeOutputs ?? 8;
6730
+ const surplusPoolShaping = params.surplusPoolShaping === true;
6387
6731
  const randomVals = [...params.randomVals || []];
6388
6732
  const nextRandomVal = () => {
6389
6733
  let val = 0;
@@ -6466,6 +6810,7 @@ async function generateChangeSdkCore(params, allocateChangeInput, releaseChangeI
6466
6810
  return r.changeOutputs.length - r.allocatedChangeInputs.length;
6467
6811
  };
6468
6812
  const addOutputToBalanceNewInput = () => {
6813
+ if (surplusPoolShaping) return false;
6469
6814
  if (!hasTargetNetCount) return false;
6470
6815
  if (r.changeOutputs.length >= maxChangeOutputs) return false;
6471
6816
  return netChangeCount() - 1 < targetNetCount;
@@ -6481,6 +6826,7 @@ async function generateChangeSdkCore(params, allocateChangeInput, releaseChangeI
6481
6826
  feeExcessNow = feeExcess();
6482
6827
  };
6483
6828
  const addDesiredChangeOutputs = () => {
6829
+ if (surplusPoolShaping) return;
6484
6830
  while (r.changeOutputs.length < maxChangeOutputs && (hasTargetNetCount && targetNetCount > netChangeCount() || r.changeOutputs.length === 0 && feeExcess() > 0)) {
6485
6831
  const satoshis = r.changeOutputs.length === 0 ? Math.max(dustFloor, params.changeFirstSatoshis) : Math.max(dustFloor, params.changeInitialSatoshis);
6486
6832
  r.changeOutputs.push({
@@ -6496,7 +6842,8 @@ async function generateChangeSdkCore(params, allocateChangeInput, releaseChangeI
6496
6842
  if (removingOutputs || feeExcess() <= 0) return;
6497
6843
  if (!((ao === 1 || r.changeOutputs.length === 0) && r.changeOutputs.length < maxChangeOutputs)) return;
6498
6844
  const cap = r.changeOutputs.length === 0 ? params.changeFirstSatoshis : params.changeInitialSatoshis;
6499
- const satoshis = Math.min(feeExcess(), Math.max(dustFloor, cap));
6845
+ const outputFunding = surplusPoolShaping ? feeExcess(0, 1) : feeExcess();
6846
+ const satoshis = Math.min(outputFunding, Math.max(dustFloor, cap));
6500
6847
  if (satoshis >= dustFloor) r.changeOutputs.push({
6501
6848
  satoshis,
6502
6849
  lockingScriptLength: params.changeLockingScriptLength
@@ -6541,6 +6888,18 @@ async function generateChangeSdkCore(params, allocateChangeInput, releaseChangeI
6541
6888
  };
6542
6889
  }
6543
6890
  /**
6891
+ * The action may already be funded entirely by explicit/fixed inputs. In
6892
+ * that case the allocator loop never runs, so capture the existing surplus
6893
+ * here before the no-change compatibility guard. This operation cannot
6894
+ * allocate an input; bounded legacy migration remains a separate step.
6895
+ */
6896
+ materializeSurplusChangeOutput({
6897
+ params,
6898
+ result: r,
6899
+ dustFloor,
6900
+ feeExcess
6901
+ });
6902
+ /**
6544
6903
  * Trigger an account funding event if we don't have enough to cover this transaction.
6545
6904
  */
6546
6905
  if (feeExcess() < 0) {
@@ -6551,19 +6910,63 @@ async function generateChangeSdkCore(params, allocateChangeInput, releaseChangeI
6551
6910
  }
6552
6911
  /**
6553
6912
  * If needed, seek funding to avoid overspending on fees without a change output to recapture it.
6913
+ * An economically unreturnable shaping remainder stays in the miner fee;
6914
+ * gathering another input solely to manufacture change would violate
6915
+ * surplus-only shaping and make the action less efficient.
6554
6916
  */
6555
- if (r.changeOutputs.length === 0 && feeExcessNow > 0) {
6556
- const minimumChange = Math.max(dustFloor, params.changeFirstSatoshis);
6557
- const totalSatoshisNeeded = spending() + feeTarget(0, 1) + minimumChange;
6558
- const moreSatoshisNeeded = Math.max(1, totalSatoshisNeeded - funding());
6559
- await releaseAllocatedChangeInputs();
6560
- throw new WERR_INSUFFICIENT_FUNDS(totalSatoshisNeeded, moreSatoshisNeeded);
6561
- }
6917
+ await requireViableChangeOrRetainBoundedFee({
6918
+ params,
6919
+ result: r,
6920
+ dustFloor,
6921
+ feeExcess,
6922
+ feeTarget,
6923
+ funding,
6924
+ spending,
6925
+ releaseAllocatedChangeInputs
6926
+ });
6927
+ /**
6928
+ * Progressively retire economically useful legacy fragments without ever
6929
+ * making them necessary for the requested action. The target of zero asks
6930
+ * canonical allocators for their smallest remaining output. A candidate at
6931
+ * or above the preferred value is not legacy migration material and is
6932
+ * immediately released.
6933
+ */
6934
+ await migrateLegacyChangeInputs({
6935
+ params,
6936
+ result: r,
6937
+ targetNetCount,
6938
+ netChangeCount,
6939
+ feeTarget,
6940
+ allocateChangeInput,
6941
+ releaseChangeInput,
6942
+ recordAllocatedInput: (candidate) => {
6943
+ r.allocatedChangeInputs.push(candidate);
6944
+ allocatedFunding += candidate.satoshis;
6945
+ feeExcessNow = feeExcess();
6946
+ }
6947
+ });
6562
6948
  /**
6563
6949
  * Distribute the excess fees across the changeOutputs added.
6564
6950
  */
6565
6951
  feeExcessNow = distributeExcessFees(r.changeOutputs, params.changeInitialSatoshis, feeExcessNow, rand);
6566
6952
  /**
6953
+ * Pool growth is funded only from the surplus already present in the
6954
+ * transaction. Splitting one change output increases the serialized fee;
6955
+ * that exact delta is deducted before assigning the new outputs. If the
6956
+ * preferred minimum cannot be met, the transaction retains one smaller
6957
+ * output instead of gathering more inputs or refusing an otherwise valid
6958
+ * action.
6959
+ */
6960
+ shapeSurplusChangeOutputs({
6961
+ params,
6962
+ result: r,
6963
+ targetNetCount,
6964
+ netChangeCount,
6965
+ maxChangeOutputs,
6966
+ feeTarget,
6967
+ rand
6968
+ });
6969
+ /**
6567
6970
  * Remove any change outputs that ended up below the dust floor after distribution.
6568
6971
  * Consolidates removed satoshis into the largest remaining output.
6569
6972
  */
@@ -6594,7 +6997,11 @@ function validateGenerateChangeSdkResult(params, r) {
6594
6997
  ok = false;
6595
6998
  }
6596
6999
  const feeRequired = Math.ceil((r.size || 0) / 1e3 * (r.satsPerKb || 0));
6597
- if (feeRequired !== r.fee) {
7000
+ const minSpendTxSize = transactionSize([params.changeUnlockingScriptLength], [params.changeLockingScriptLength]);
7001
+ const dustFloor = Math.max(1, Math.ceil(minSpendTxSize / 1e3 * (r.satsPerKb || 0)) * 2);
7002
+ const feeWithChangeOutput = Math.ceil(((r.size || 0) + transactionOutputSize(params.changeLockingScriptLength)) / 1e3 * (r.satsPerKb || 0));
7003
+ const isBoundedUnreturnableShapingSurplus = params.surplusPoolShaping === true && r.changeOutputs.length === 0 && r.fee > feeRequired && r.fee - feeWithChangeOutput < dustFloor;
7004
+ if (feeRequired !== r.fee && !isBoundedUnreturnableShapingSurplus) {
6598
7005
  log += `required fee error ${feeRequired} !== ${r.fee};`;
6599
7006
  ok = false;
6600
7007
  }
@@ -6632,6 +7039,8 @@ function validateGenerateChangeSdkParams(params) {
6632
7039
  params.feeModel = validateStorageFeeModel(params.feeModel);
6633
7040
  if (params.feeModel.model !== "sat/kb") throw new WERR_INVALID_PARAMETER("feeModel.model", "'sat/kb'");
6634
7041
  _bsv_sdk.Validation.validateOptionalInteger(params.targetNetCount, "targetNetCount");
7042
+ if (params.maxChangeOutputs !== -1) _bsv_sdk.Validation.validateOptionalInteger(params.maxChangeOutputs, "maxChangeOutputs", 1);
7043
+ if (params.maxMigrationInputs !== -1) _bsv_sdk.Validation.validateOptionalInteger(params.maxMigrationInputs, "maxMigrationInputs", 0);
6635
7044
  _bsv_sdk.Validation.validateSatoshis(params.changeFirstSatoshis, "changeFirstSatoshis", 1);
6636
7045
  _bsv_sdk.Validation.validateSatoshis(params.changeInitialSatoshis, "changeInitialSatoshis", 1);
6637
7046
  _bsv_sdk.Validation.validateInteger(params.changeLockingScriptLength, "changeLockingScriptLength");
@@ -8099,6 +8508,8 @@ async function planFunding(state, args, explicit, noSendChange) {
8099
8508
  const allocated = /* @__PURE__ */ new Map();
8100
8509
  const noSend = [...noSendChange];
8101
8510
  const changeBasket = state.begin.changeBasket;
8511
+ const policy = validateManagedChangePolicy(state.begin.managedChangePolicy);
8512
+ const preferredSatoshis = Math.max(1, changeBasket.minimumDesiredUTXOValue);
8102
8513
  const params = {
8103
8514
  fixedInputs: explicit.map((output, index) => ({
8104
8515
  satoshis: output.satoshis,
@@ -8112,11 +8523,14 @@ async function planFunding(state, args, explicit, noSendChange) {
8112
8523
  lockingScriptLength: 25
8113
8524
  }] : []],
8114
8525
  feeModel: state.begin.feeModel,
8115
- changeInitialSatoshis: Math.max(1, changeBasket.minimumDesiredUTXOValue),
8116
- changeFirstSatoshis: Math.max(1, Math.round(changeBasket.minimumDesiredUTXOValue / 4)),
8526
+ changeInitialSatoshis: preferredSatoshis,
8527
+ changeFirstSatoshis: preferredSatoshis,
8117
8528
  changeLockingScriptLength: 25,
8118
8529
  changeUnlockingScriptLength: 107,
8119
8530
  targetNetCount: changeBasket.numberOfDesiredUTXOs - state.estimatedChangeCount,
8531
+ maxChangeOutputs: policy.maxOutputsPerAction,
8532
+ surplusPoolShaping: true,
8533
+ maxMigrationInputs: policy.migrationInputsPerAction,
8120
8534
  randomVals: args.randomVals
8121
8535
  };
8122
8536
  const allocate = async (targetSatoshis, exactSatoshis) => {
@@ -8135,7 +8549,18 @@ async function planFunding(state, args, explicit, noSendChange) {
8135
8549
  allocated.delete(outputId);
8136
8550
  if (noSendChange.includes(output)) noSend.push(output);
8137
8551
  };
8138
- const result = await generateChangeSdk(params, allocate, release, args.logger);
8552
+ let result;
8553
+ try {
8554
+ result = await generateChangeSdk(params, allocate, release, args.logger);
8555
+ } catch (error) {
8556
+ if (!(error instanceof WERR_INSUFFICIENT_FUNDS)) throw error;
8557
+ result = await generateChangeSdk({
8558
+ ...params,
8559
+ changeFirstSatoshis: 1,
8560
+ surplusPoolShaping: false,
8561
+ maxMigrationInputs: 0
8562
+ }, allocate, release, args.logger);
8563
+ }
8139
8564
  return {
8140
8565
  allocated: result.allocatedChangeInputs.map((input) => verifyTruthy(allocated.get(input.outputId))),
8141
8566
  changeSatoshis: result.changeOutputs.map((output) => output.satoshis),
@@ -8393,6 +8818,19 @@ async function compressActionBatchPackItems(items, encoding, maxBytes, maxItems)
8393
8818
  function mergeUnique(values) {
8394
8819
  return [...new Set(values)];
8395
8820
  }
8821
+ function isActionBatchStateError(error) {
8822
+ if (error instanceof WERRActionBatchState) return true;
8823
+ if (error == null || typeof error !== "object") return false;
8824
+ const candidate = error;
8825
+ return (candidate.name === "WERR_ACTION_BATCH_STATE" || candidate.code === "WERR_ACTION_BATCH_STATE") && typeof candidate.state === "string";
8826
+ }
8827
+ function isActionBatchCapacityError(error) {
8828
+ if (error == null || typeof error !== "object") return false;
8829
+ const candidate = error;
8830
+ const code = candidate.name === "Error" ? candidate.code : candidate.name ?? candidate.code;
8831
+ if (code === "WERR_INVALID_PARAMETER" && candidate.parameter === "firstAction") return true;
8832
+ return code === "WERR_INVALID_OPERATION" && typeof candidate.message === "string" && candidate.message.includes("action batch") && (candidate.message.includes("maximum") || candidate.message.includes("reserve"));
8833
+ }
8396
8834
  function nextGeometricTarget(value) {
8397
8835
  return Math.min(Number.MAX_SAFE_INTEGER, value * 2);
8398
8836
  }
@@ -8439,6 +8877,7 @@ var ActionBatchWorkspace = class {
8439
8877
  planned = /* @__PURE__ */ new Map();
8440
8878
  actions = [];
8441
8879
  lockingScripts = /* @__PURE__ */ new Map();
8880
+ canResume;
8442
8881
  ewmaConfirmedInputs = 1;
8443
8882
  ewmaConfirmedSatoshis = 0;
8444
8883
  hasConfirmedSample = false;
@@ -8458,6 +8897,7 @@ var ActionBatchWorkspace = class {
8458
8897
  this.batchId = begin.batchId;
8459
8898
  this.state = makePlannerState(begin, firstAction);
8460
8899
  this.expiresAt = Date.parse(begin.expiresAt);
8900
+ this.canResume = capabilities.resume === true && wallet.storage.resumeActionBatch != null;
8461
8901
  this.eagerUploadLanes = Array.from({ length: capabilities.packedUploads == null ? 1 : Math.max(1, capabilities.maxConcurrentUploads) }, async () => {});
8462
8902
  }
8463
8903
  get usesCompactManifest() {
@@ -8471,6 +8911,15 @@ var ActionBatchWorkspace = class {
8471
8911
  if (this.actions.some((action) => action.reference === reference || action.txid === reference)) return true;
8472
8912
  return Object.entries(this.wallet.pendingSignActions).some(([pendingReference, pending]) => this.planned.has(pendingReference) && pending.tx.id("hex") === reference);
8473
8913
  }
8914
+ ownsTransaction(txid) {
8915
+ return this.actions.some((action) => action.txid === txid) || Object.entries(this.wallet.pendingSignActions).some(([reference, pending]) => this.planned.has(reference) && pending.tx.id("hex") === txid);
8916
+ }
8917
+ references(args) {
8918
+ return [...args.inputs.map((input) => input.outpoint), ...args.options.noSendChange].some((outpoint) => this.state.staged.has(`${outpoint.txid}.${outpoint.vout}`)) || args.options.sendWith.some((txid) => this.ownsTransaction(txid));
8919
+ }
8920
+ referencesSendWith(txids) {
8921
+ return txids.some((txid) => this.ownsTransaction(txid));
8922
+ }
8474
8923
  get isEmpty() {
8475
8924
  return this.planned.size === 0 && this.actions.length === 0;
8476
8925
  }
@@ -8595,12 +9044,32 @@ var ActionBatchWorkspace = class {
8595
9044
  satoshis: extension.reservedOutputs.reduce((sum, output) => sum + output.satoshis, 0)
8596
9045
  };
8597
9046
  }
9047
+ async resume() {
9048
+ const outpoints = [...new Map([...this.state.reserved.values(), ...this.state.explicit.values()].filter((output) => output.txid != null).map((output) => [`${output.txid}.${output.vout}`, {
9049
+ txid: output.txid,
9050
+ vout: output.vout
9051
+ }])).values()];
9052
+ const result = await this.wallet.storage.resumeActionBatch({
9053
+ batchId: this.batchId,
9054
+ outpoints
9055
+ });
9056
+ this.expiresAt = Date.parse(result.expiresAt);
9057
+ }
8598
9058
  async renewIfNeeded() {
8599
- if (Date.now() >= this.expiresAt) return;
9059
+ if (Date.now() >= this.expiresAt) {
9060
+ if (this.canResume) await this.resume();
9061
+ return;
9062
+ }
8600
9063
  const renewAt = this.expiresAt - this.capabilities.leaseMs * .2;
8601
9064
  if (Date.now() < renewAt) return;
8602
9065
  this.renewal ??= this.wallet.storage.renewActionBatch(this.batchId).then((result) => {
8603
9066
  this.expiresAt = Date.parse(result.expiresAt);
9067
+ }).catch(async (error) => {
9068
+ if (isActionBatchStateError(error) && error.state === "expired") {
9069
+ if (this.canResume) await this.resume();
9070
+ return;
9071
+ }
9072
+ throw error;
8604
9073
  }).finally(() => {
8605
9074
  this.renewal = void 0;
8606
9075
  });
@@ -9031,24 +9500,42 @@ var ActionBatchController = class {
9031
9500
  this.workspace = new ActionBatchWorkspace(this.wallet, begin, args, capabilities);
9032
9501
  return this.workspace;
9033
9502
  }
9503
+ async retire(workspace) {
9504
+ await workspace.abort().catch(() => void 0);
9505
+ if (this.workspace === workspace) this.workspace = void 0;
9506
+ }
9507
+ async recover(workspace, input, isDelayed = false, resume = false) {
9508
+ try {
9509
+ if (resume) await workspace.resume();
9510
+ return Array.isArray(input) ? await workspace.commit(input, isDelayed) : await workspace.plan(input);
9511
+ } catch (error) {
9512
+ if (!resume && isActionBatchStateError(error) && error.state === "expired" && workspace.canResume) return await this.recover(workspace, input, isDelayed, true);
9513
+ if (isActionBatchStateError(error) && error.state !== "expired") await this.retire(workspace);
9514
+ throw error;
9515
+ }
9516
+ }
9034
9517
  async plan(args) {
9035
9518
  return await this.runExclusive(async () => {
9036
9519
  if (!args.isNewTx) return void 0;
9037
- let workspace = this.workspace;
9038
- let beganWorkspace = false;
9039
- if (workspace == null) {
9040
- if (!args.isNoSend) return void 0;
9041
- workspace = await this.begin(args);
9042
- if (workspace == null) return void 0;
9043
- beganWorkspace = true;
9520
+ const workspace = this.workspace;
9521
+ if (workspace != null) {
9522
+ if (!workspace.references(args)) return void 0;
9523
+ return await this.recover(workspace, args);
9044
9524
  }
9525
+ if (!args.isNoSend) return void 0;
9526
+ let begun;
9045
9527
  try {
9046
- return await workspace.plan(args);
9528
+ begun = await this.begin(args);
9047
9529
  } catch (error) {
9048
- if (beganWorkspace) {
9049
- await workspace.abort();
9050
- this.workspace = void 0;
9051
- }
9530
+ if (isActionBatchCapacityError(error)) return void 0;
9531
+ throw error;
9532
+ }
9533
+ if (begun == null) return void 0;
9534
+ try {
9535
+ return await begun.plan(args);
9536
+ } catch (error) {
9537
+ await this.retire(begun);
9538
+ if (isActionBatchCapacityError(error)) return void 0;
9052
9539
  throw error;
9053
9540
  }
9054
9541
  });
@@ -9058,11 +9545,21 @@ var ActionBatchController = class {
9058
9545
  const workspace = this.workspace;
9059
9546
  if (workspace == null) return void 0;
9060
9547
  if (prior != null && !workspace.ownsReference(prior.reference)) return void 0;
9548
+ if (prior == null && !workspace.referencesSendWith(args.options.sendWith)) return void 0;
9061
9549
  if (prior != null) workspace.stage(prior, args);
9062
- if (!(args.isSendWith || prior != null && !args.isNoSend)) return { sendWithResults: [] };
9550
+ const referencesWorkspace = workspace.referencesSendWith(args.options.sendWith);
9551
+ if (prior != null && args.isNoSend && args.isSendWith && !referencesWorkspace) return await this.wallet.storage.processAction({
9552
+ isNewTx: false,
9553
+ isSendWith: true,
9554
+ isNoSend: false,
9555
+ isDelayed: args.isDelayed,
9556
+ sendWith: [...args.options.sendWith],
9557
+ logger: args.logger
9558
+ });
9559
+ if (!(referencesWorkspace || prior != null && !args.isNoSend)) return { sendWithResults: [] };
9063
9560
  const sendWith = [...args.options.sendWith];
9064
9561
  if (prior != null && !args.isNoSend && !sendWith.includes(prior.tx.id("hex"))) sendWith.push(prior.tx.id("hex"));
9065
- const result = await workspace.commit(sendWith, args.isDelayed);
9562
+ const result = await this.recover(workspace, sendWith, args.isDelayed);
9066
9563
  this.workspace = void 0;
9067
9564
  return result;
9068
9565
  });
@@ -9806,14 +10303,15 @@ var Wallet = class {
9806
10303
  }
9807
10304
  /**
9808
10305
  * Uses `listOutputs` special operation to review the spendability via `Services` of
9809
- * outputs currently considered spendable. Returns the outputs that fail to verify.
10306
+ * outputs currently considered spendable. Returns only outputs conclusively
10307
+ * confirmed spent. Rejects the review if any provider result is inconclusive.
9810
10308
  *
9811
10309
  * Ignores the `limit` and `offset` properties.
9812
10310
  *
9813
10311
  * @param all Defaults to false. If false, only change outputs ('default' basket) are reviewed. If true, all spendable outputs are reviewed.
9814
- * @param release Defaults to false. If true, sets outputs that fail to verify to un-spendable (spendable: false)
10312
+ * @param release Defaults to false. If true, atomically sets conclusively spent outputs to un-spendable (spendable: false). No outputs change if any candidate is inconclusive.
9815
10313
  * @param optionalArgs Optional. Additional tags will constrain the outputs processed.
9816
- * @returns outputs which are/where considered spendable but currently fail to verify as spendable.
10314
+ * @returns outputs previously considered spendable but conclusively confirmed spent.
9817
10315
  */
9818
10316
  async reviewSpendableOutputs(all = false, release = false, optionalArgs) {
9819
10317
  const args = {
@@ -10021,7 +10519,7 @@ async function createActionCore(storage, auth, vargs, parent) {
10021
10519
  });
10022
10520
  const feeModel = validateStorageFeeModel(storage.feeModel);
10023
10521
  logger?.log(`validated fee model ${JSON.stringify(feeModel)}`);
10024
- const initialFundingPlan = await prepareFundingPlanWithSendingFallback(storage, [
10522
+ const initialFundingPlan = await prepareFundingPlanWithLiquidityPolicy(storage, [
10025
10523
  userId,
10026
10524
  vargs,
10027
10525
  xinputs,
@@ -10609,7 +11107,9 @@ async function traceStorageStep(storage, name, parent, attributes, callback) {
10609
11107
  attributes
10610
11108
  }, async (span) => await callback(span));
10611
11109
  }
10612
- function makeFundingParams(vargs, xinputs, xoutputs, changeBasket, feeModel, availableChangeCount) {
11110
+ function makeFundingParams(args) {
11111
+ const { storage, vargs, xinputs, xoutputs, changeBasket, feeModel, healthyChangeCount, compatibilityFallback } = args;
11112
+ const preferredSatoshis = Math.max(1, changeBasket.minimumDesiredUTXOValue);
10613
11113
  return {
10614
11114
  fixedInputs: xinputs.map((input) => ({
10615
11115
  satoshis: input.satoshis,
@@ -10620,35 +11120,52 @@ function makeFundingParams(vargs, xinputs, xoutputs, changeBasket, feeModel, ava
10620
11120
  lockingScriptLength: output.lockingScript.length / 2
10621
11121
  })),
10622
11122
  feeModel,
10623
- changeInitialSatoshis: Math.max(1, changeBasket.minimumDesiredUTXOValue),
10624
- changeFirstSatoshis: Math.max(1, Math.round(changeBasket.minimumDesiredUTXOValue / 4)),
11123
+ changeInitialSatoshis: preferredSatoshis,
11124
+ changeFirstSatoshis: compatibilityFallback ? 1 : preferredSatoshis,
10625
11125
  changeLockingScriptLength: 25,
10626
11126
  changeUnlockingScriptLength: 107,
10627
- targetNetCount: changeBasket.numberOfDesiredUTXOs - availableChangeCount,
11127
+ targetNetCount: changeBasket.numberOfDesiredUTXOs - healthyChangeCount,
11128
+ maxChangeOutputs: storage.managedChangePolicy.maxOutputsPerAction,
11129
+ surplusPoolShaping: !compatibilityFallback,
11130
+ maxMigrationInputs: compatibilityFallback ? 0 : storage.managedChangePolicy.migrationInputsPerAction,
10628
11131
  randomVals: vargs.randomVals
10629
11132
  };
10630
11133
  }
10631
- async function prepareFundingPlan(storage, context) {
10632
- const [userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel, excludeSending, parent, trx] = context;
10633
- const candidates = await traceStorageStep(storage, "wallet.storage.create_action.funding_candidates", parent, { "funding.exclude_sending": excludeSending }, async (span) => {
10634
- const outputs = await storage.findAvailableManagedChangeInputCandidates(userId, changeBasket.basketId, excludeSending, trx);
10635
- span?.end({ attributes: {
10636
- "funding.candidate_count": outputs.length,
10637
- "funding.candidate_satoshis": outputs.reduce((sum, output) => sum + output.satoshis, 0)
10638
- } });
10639
- return outputs;
10640
- });
11134
+ async function buildFundingPlan(storage, context, candidates, eligibleStatuses, policyTier, compatibilityFallback, parent) {
11135
+ const [userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel] = context;
10641
11136
  const noSendIds = new Set(noSendChangeIn.map((output) => output.outputId));
10642
- const available = candidates.filter((output) => !noSendIds.has(output.outputId));
10643
- const params = makeFundingParams(vargs, xinputs, xoutputs, changeBasket, feeModel, candidates.length);
11137
+ const available = candidates.filter((output) => !noSendIds.has(output.outputId) && eligibleStatuses.includes(output.transactionStatus));
11138
+ const preferredSatoshis = Math.max(1, changeBasket.minimumDesiredUTXOValue);
11139
+ const params = makeFundingParams({
11140
+ storage,
11141
+ vargs,
11142
+ xinputs,
11143
+ xoutputs,
11144
+ changeBasket,
11145
+ feeModel,
11146
+ healthyChangeCount: compatibilityFallback ? candidates.filter((output) => eligibleStatuses.includes(output.transactionStatus)).length : candidates.filter((output) => output.satoshis >= preferredSatoshis).length,
11147
+ compatibilityFallback
11148
+ });
11149
+ const noSendStatuses = await storage.findTransactionStatusesByIds(userId, noSendChangeIn.map((output) => output.transactionId));
11150
+ const plannedNoSend = noSendChangeIn.map((output) => ({
11151
+ outputId: output.outputId,
11152
+ transactionId: output.transactionId,
11153
+ satoshis: output.satoshis,
11154
+ txid: output.txid,
11155
+ vout: output.vout,
11156
+ transactionStatus: noSendStatuses.get(output.transactionId) ?? "nosend"
11157
+ }));
11158
+ const claimStatuses = [.../* @__PURE__ */ new Set([...eligibleStatuses, ...plannedNoSend.map((output) => output.transactionStatus)])];
10644
11159
  return await traceStorageStep(storage, "wallet.storage.create_action.funding_plan", parent, {
10645
11160
  "funding.candidate_count": available.length,
10646
- "funding.no_send_change_count": noSendChangeIn.length
11161
+ "funding.no_send_change_count": noSendChangeIn.length,
11162
+ "funding.policy_tier": policyTier,
11163
+ "funding.compatibility_fallback": compatibilityFallback
10647
11164
  }, async (span) => {
10648
11165
  const allocated = /* @__PURE__ */ new Map();
10649
11166
  const availableSelector = new CanonicalChangeSelector(available);
10650
- const noSend = [...noSendChangeIn];
10651
- const noSendById = new Map(noSendChangeIn.map((output) => [output.outputId, output]));
11167
+ const noSend = [...plannedNoSend];
11168
+ const noSendById = new Map(plannedNoSend.map((output) => [output.outputId, output]));
10652
11169
  const allocate = async (targetSatoshis, exactSatoshis) => {
10653
11170
  let output = noSend.pop();
10654
11171
  output ??= availableSelector.take(targetSatoshis, exactSatoshis);
@@ -10679,31 +11196,118 @@ async function prepareFundingPlan(storage, context) {
10679
11196
  result,
10680
11197
  selected,
10681
11198
  availableChangeCount: candidates.length,
10682
- excludeSending
11199
+ eligibleStatuses: claimStatuses,
11200
+ policyTier,
11201
+ compatibilityFallback
10683
11202
  };
10684
11203
  });
10685
11204
  }
10686
- async function prepareFundingPlanWithSendingFallback(storage, context, parent, trx) {
10687
- const excludeSending = !context[1].isDelayed;
11205
+ async function fundingPlanSerializedCost(storage, plan, knownTxids) {
11206
+ const txids = [...new Set(plan.selected.map((candidate) => verifyTruthy(candidate.txid)))];
11207
+ if (txids.length === 0) return plan.result.size;
10688
11208
  try {
10689
- return await prepareFundingPlan(storage, [
10690
- ...context,
10691
- excludeSending,
10692
- parent,
10693
- trx
10694
- ]);
11209
+ const beef = await storage.getBeefForTransactions(txids, {
11210
+ knownTxids,
11211
+ ignoreStorage: false,
11212
+ ignoreServices: true,
11213
+ ignoreNewProven: false
11214
+ });
11215
+ return plan.result.size + beef.toUint8Array().length;
11216
+ } catch {
11217
+ return Number.MAX_SAFE_INTEGER;
11218
+ }
11219
+ }
11220
+ const FUNDING_TIERS = [
11221
+ {
11222
+ policyTier: "completed",
11223
+ statuses: ["completed"]
11224
+ },
11225
+ {
11226
+ policyTier: "unproven",
11227
+ statuses: ["completed", "unproven"]
11228
+ },
11229
+ {
11230
+ policyTier: "sending",
11231
+ statuses: [
11232
+ "completed",
11233
+ "unproven",
11234
+ "sending"
11235
+ ]
11236
+ }
11237
+ ];
11238
+ async function resolveFundingCandidates(storage, userId, basketId, parent, trx) {
11239
+ const rawCandidates = await traceStorageStep(storage, "wallet.storage.create_action.funding_candidates", parent, { "funding.include_pending": true }, async (span) => {
11240
+ const outputs = await storage.findAvailableManagedChangeInputCandidates(userId, basketId, false, trx);
11241
+ span?.end({ attributes: {
11242
+ "funding.candidate_count": outputs.length,
11243
+ "funding.candidate_satoshis": outputs.reduce((sum, output) => sum + output.satoshis, 0),
11244
+ "funding.completed_candidate_count": outputs.filter((output) => output.transactionStatus === "completed").length,
11245
+ "funding.unproven_candidate_count": outputs.filter((output) => output.transactionStatus === "unproven").length,
11246
+ "funding.sending_candidate_count": outputs.filter((output) => output.transactionStatus === "sending").length
11247
+ } });
11248
+ return outputs;
11249
+ });
11250
+ const missingStatusIds = rawCandidates.filter((output) => output.transactionStatus == null).map((output) => output.transactionId);
11251
+ const missingStatuses = await storage.findTransactionStatusesByIds(userId, missingStatusIds, trx);
11252
+ const candidates = rawCandidates.map((output) => ({
11253
+ ...output,
11254
+ transactionStatus: output.transactionStatus ?? missingStatuses.get(output.transactionId)
11255
+ }));
11256
+ if (candidates.some((output) => output.transactionStatus == null)) throw new WERR_INTERNAL("managed change candidate is missing its source transaction status");
11257
+ return candidates;
11258
+ }
11259
+ async function buildFundingPlanForTier(storage, context, candidates, tier, parent) {
11260
+ try {
11261
+ return { plan: await buildFundingPlan(storage, context, candidates, tier.statuses, tier.policyTier, false, parent) };
10695
11262
  } catch (error) {
10696
- if (!excludeSending || !(error instanceof WERR_INSUFFICIENT_FUNDS)) throw error;
10697
- return await prepareFundingPlan(storage, [
10698
- ...context,
10699
- false,
10700
- parent,
10701
- trx
10702
- ]);
11263
+ if (!(error instanceof WERR_INSUFFICIENT_FUNDS)) throw error;
11264
+ }
11265
+ try {
11266
+ return { plan: await buildFundingPlan(storage, context, candidates, tier.statuses, "compatibility", true, parent) };
11267
+ } catch (error) {
11268
+ if (!(error instanceof WERR_INSUFFICIENT_FUNDS)) throw error;
11269
+ return { fundingError: error };
11270
+ }
11271
+ }
11272
+ function firstPlanNeedsNoPendingComparison(storage, plan) {
11273
+ const threshold = storage.managedChangePolicy.pendingComparisonInputs;
11274
+ return threshold === -1 || plan.selected.length <= threshold;
11275
+ }
11276
+ async function chooseLowestSerializedFundingPlan(storage, plans, knownTxids) {
11277
+ let chosen = plans[0];
11278
+ let chosenCost = await fundingPlanSerializedCost(storage, chosen, knownTxids);
11279
+ for (const alternative of plans.slice(1)) {
11280
+ const cost = await fundingPlanSerializedCost(storage, alternative, knownTxids);
11281
+ if (cost < chosenCost) {
11282
+ chosen = alternative;
11283
+ chosenCost = cost;
11284
+ }
11285
+ }
11286
+ return chosen;
11287
+ }
11288
+ async function prepareFundingPlanWithLiquidityPolicy(storage, context, parent, trx) {
11289
+ const [userId, vargs, , , changeBasket] = context;
11290
+ const candidates = await resolveFundingCandidates(storage, userId, changeBasket.basketId, parent, trx);
11291
+ const successful = [];
11292
+ let fundingError;
11293
+ for (const tier of FUNDING_TIERS) {
11294
+ const attempted = await buildFundingPlanForTier(storage, context, candidates, tier, parent);
11295
+ fundingError = attempted.fundingError ?? fundingError;
11296
+ const plan = attempted.plan;
11297
+ if (plan != null) {
11298
+ successful.push(plan);
11299
+ if (successful.length === 1 && firstPlanNeedsNoPendingComparison(storage, plan)) return plan;
11300
+ }
11301
+ }
11302
+ if (successful.length > 0) {
11303
+ if (successful.length === 1) return successful[0];
11304
+ return await chooseLowestSerializedFundingPlan(storage, successful, vargs.options.knownTxids);
10703
11305
  }
11306
+ if (fundingError != null) throw fundingError;
11307
+ throw new WERR_INTERNAL("funding policy produced neither a plan nor an error");
10704
11308
  }
10705
11309
  async function claimFundingPlan(storage, request) {
10706
- const [userId, basketId, excludeSending, transactionId, noSendChangeIn, plan, trx] = request;
11310
+ const [userId, basketId, eligibleStatuses, transactionId, noSendChangeIn, plan, trx] = request;
10707
11311
  if (plan.selected.length === 0) return {
10708
11312
  outputs: [],
10709
11313
  sourceTransactionCount: 0,
@@ -10711,10 +11315,8 @@ async function claimFundingPlan(storage, request) {
10711
11315
  scriptSourceTransactionCount: 0
10712
11316
  };
10713
11317
  const noSendIds = new Set(noSendChangeIn.map((output) => output.outputId));
10714
- const statuses = ["completed", "unproven"];
10715
- if (!excludeSending) statuses.push("sending");
10716
11318
  const claim = await storage.transaction(async (claimTrx) => {
10717
- const currentById = await storage.findFundingOutputsForUpdate(userId, plan.selected.map((output) => output.outputId), statuses, claimTrx);
11319
+ const currentById = await storage.findFundingOutputsForUpdate(userId, plan.selected.map((output) => output.outputId), eligibleStatuses, claimTrx);
10718
11320
  const transactionIds = [...new Set(Object.values(currentById).map((output) => output.transactionId))];
10719
11321
  const claimed = [];
10720
11322
  for (const planned of plan.selected) {
@@ -10784,7 +11386,7 @@ async function fundNewTransactionSdk(storage, userId, vargs, ctx, initialPlan, p
10784
11386
  const claim = await claimFundingPlan(storage, [
10785
11387
  userId,
10786
11388
  ctx.changeBasket.basketId,
10787
- plan.excludeSending,
11389
+ plan.eligibleStatuses,
10788
11390
  ctx.transactionId,
10789
11391
  ctx.noSendChangeIn,
10790
11392
  plan,
@@ -10802,7 +11404,7 @@ async function fundNewTransactionSdk(storage, userId, vargs, ctx, initialPlan, p
10802
11404
  }
10803
11405
  if (claim.conflict === "noSendChange") throw new WERR_INVALID_PARAMETER("noSendChange", "outputs that remain spendable during action planning");
10804
11406
  retryCount++;
10805
- plan = await prepareFundingPlanWithSendingFallback(storage, [
11407
+ plan = await prepareFundingPlanWithLiquidityPolicy(storage, [
10806
11408
  userId,
10807
11409
  vargs,
10808
11410
  ctx.xinputs,
@@ -12955,7 +13557,7 @@ var StorageReaderWriter = class extends StorageReader {
12955
13557
  userId: user.userId,
12956
13558
  name: "default",
12957
13559
  numberOfDesiredUTXOs: 144,
12958
- minimumDesiredUTXOValue: 32,
13560
+ minimumDesiredUTXOValue: DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS,
12959
13561
  isDeleted: false
12960
13562
  });
12961
13563
  break;
@@ -13236,8 +13838,10 @@ function validateActionBatchInlinePayload(manifest) {
13236
13838
  for (const { digest, bytes } of inlineBlobs) if (actionBatchBlobDigest(bytes) !== digest) throw new WERR_INVALID_PARAMETER("inlineBlobs", `content matching digest ${digest}`);
13237
13839
  }
13238
13840
  function requireUploadableBatch(batch) {
13239
- if (batch == null || batch.status === "committed" || batch.status === "aborted") throw new WERR_INVALID_OPERATION("action batch is not uploadable");
13240
- if (batch.hardExpiresAt.getTime() <= Date.now()) throw new WERR_INVALID_OPERATION("action batch hard lifetime has expired");
13841
+ if (batch == null) throw new WERRActionBatchState("missing");
13842
+ if (batch.status === "committed") throw new WERRActionBatchState("committed", batch.batchId);
13843
+ if (batch.status === "aborted") throw new WERRActionBatchState("aborted", batch.batchId);
13844
+ if (batch.hardExpiresAt.getTime() <= Date.now()) throw new WERRActionBatchState("hard-expired", batch.batchId);
13241
13845
  return batch;
13242
13846
  }
13243
13847
  function manifestPhysicalDigests(manifest) {
@@ -13597,7 +14201,10 @@ const ACTION_BATCH_LEASE_MS = 900 * 1e3;
13597
14201
  const ACTION_BATCH_HARD_LIFETIME_MS = 3600 * 1e3;
13598
14202
  const INITIAL_RESERVATION_LIMIT = 8;
13599
14203
  const INITIAL_EXTRA_OUTPUTS = 3;
13600
- function getActionBatchCapabilities() {
14204
+ function isValidOutpoint(outpoint) {
14205
+ return /^[0-9a-f]{64}$/.test(outpoint.txid) && Number.isSafeInteger(outpoint.vout) && outpoint.vout >= 0;
14206
+ }
14207
+ function getActionBatchCapabilities(maxReservedOutputs = 256, supportsResume = false) {
13601
14208
  return { actionBatch: {
13602
14209
  version: 1,
13603
14210
  maxInlineBytes: ACTION_BATCH_MAX_INLINE_BYTES,
@@ -13605,6 +14212,8 @@ function getActionBatchCapabilities() {
13605
14212
  maxConcurrentUploads: 4,
13606
14213
  leaseMs: ACTION_BATCH_LEASE_MS,
13607
14214
  hardLifetimeMs: ACTION_BATCH_HARD_LIFETIME_MS,
14215
+ resume: supportsResume ? true : void 0,
14216
+ maxReservedOutputs,
13608
14217
  compactBegin: true,
13609
14218
  manifestVersion: 2,
13610
14219
  commitByDigest: true,
@@ -13620,6 +14229,14 @@ function getActionBatchCapabilities() {
13620
14229
  function activeExpiry(batch, now = /* @__PURE__ */ new Date()) {
13621
14230
  return batch.status !== "active" && batch.status !== "prepared" || batch.expiresAt.getTime() <= now.getTime() || batch.hardExpiresAt.getTime() <= now.getTime();
13622
14231
  }
14232
+ function actionBatchErrorState(batch, now = /* @__PURE__ */ new Date()) {
14233
+ if (batch == null) return "missing";
14234
+ if (batch.hardExpiresAt.getTime() <= now.getTime()) return "hard-expired";
14235
+ if (batch.status === "aborted") return "aborted";
14236
+ if (batch.status === "committed") return "committed";
14237
+ if (batch.status === "expired" || batch.expiresAt.getTime() <= now.getTime()) return "expired";
14238
+ if (batch.status !== "active" && batch.status !== "prepared") return "inactive";
14239
+ }
13623
14240
  function canExpire(batch, now = /* @__PURE__ */ new Date()) {
13624
14241
  return (batch.status === "active" || batch.status === "prepared") && (batch.expiresAt.getTime() <= now.getTime() || batch.hardExpiresAt.getTime() <= now.getTime());
13625
14242
  }
@@ -13705,12 +14322,22 @@ function estimateFirstActionTarget(storage, args, inputSatoshis, outputScriptLen
13705
14322
  const minFee = Math.ceil(transactionSize([...inputLengths, 107], outputLengths) * fee / 1e3);
13706
14323
  return Math.max(1, outputSatoshis + minFee - inputSatoshis);
13707
14324
  }
14325
+ function selectReservationChange(candidates, targetSatoshis) {
14326
+ for (const status of [
14327
+ "completed",
14328
+ "unproven",
14329
+ "sending"
14330
+ ]) {
14331
+ const selected = selectCanonicalChange(candidates.filter((candidate) => candidate.transactionStatus === status), targetSatoshis);
14332
+ if (selected != null) return selected;
14333
+ }
14334
+ }
13708
14335
  function chooseReservationPool(candidates, targetSatoshis, limit, extras, fillLimit, planningCosts) {
13709
14336
  const remaining = candidates.filter((output) => output.satoshis > planningCosts.marginalInputFee);
13710
14337
  const chosen = [];
13711
14338
  let deficit = targetSatoshis + planningCosts.firstChangeCost;
13712
14339
  while (deficit > 0 && chosen.length < limit) {
13713
- const output = selectCanonicalChange(remaining, deficit + planningCosts.marginalInputFee);
14340
+ const output = selectReservationChange(remaining, deficit + planningCosts.marginalInputFee);
13714
14341
  if (output == null) break;
13715
14342
  chosen.push(output);
13716
14343
  remaining.splice(remaining.indexOf(output), 1);
@@ -13718,14 +14345,18 @@ function chooseReservationPool(candidates, targetSatoshis, limit, extras, fillLi
13718
14345
  }
13719
14346
  const desiredCount = fillLimit ? limit : Math.min(limit, chosen.length + extras);
13720
14347
  while (remaining.length > 0 && chosen.length < desiredCount) {
13721
- const output = selectCanonicalChange(remaining, targetSatoshis);
14348
+ const output = selectReservationChange(remaining, targetSatoshis);
13722
14349
  if (output == null) break;
13723
14350
  chosen.push(output);
13724
14351
  remaining.splice(remaining.indexOf(output), 1);
13725
14352
  }
13726
14353
  return chosen;
13727
14354
  }
13728
- function reservationPlanningCosts(storage, basket) {
14355
+ function reservationOutput(candidate) {
14356
+ const { transactionStatus: _transactionStatus, ...output } = candidate;
14357
+ return output;
14358
+ }
14359
+ function reservationPlanningCosts(storage, _basket) {
13729
14360
  const satsPerKb = validateStorageFeeModel(storage.feeModel).value ?? 0;
13730
14361
  const minimumSpendSize = transactionSize([107], [25]);
13731
14362
  const minimumSpendFee = Math.ceil(minimumSpendSize * satsPerKb / 1e3);
@@ -13733,7 +14364,7 @@ function reservationPlanningCosts(storage, basket) {
13733
14364
  const marginalInputSize = transactionSize([107], []) - transactionSize([], []);
13734
14365
  const marginalOutputSize = transactionSize([], [25]) - transactionSize([], []);
13735
14366
  return {
13736
- firstChangeCost: Math.max(dustFloor, Math.max(1, Math.round(basket.minimumDesiredUTXOValue / 4))) + Math.ceil(marginalOutputSize * satsPerKb / 1e3),
14367
+ firstChangeCost: dustFloor + Math.ceil(marginalOutputSize * satsPerKb / 1e3),
13737
14368
  marginalInputFee: Math.ceil(marginalInputSize * satsPerKb / 1e3)
13738
14369
  };
13739
14370
  }
@@ -13808,18 +14439,25 @@ async function beginActionBatch(storage, auth, args) {
13808
14439
  const explicit = await resolveExplicitOutputs(storage, userId, args.firstAction, outputScriptLengths != null);
13809
14440
  const noSendChange = await resolveNoSendChangeOutputs(storage, userId, args.firstAction);
13810
14441
  const fixedOutputIds = new Set([...explicit.outputs, ...noSendChange.outputs].map((output) => output.outputId));
13811
- const available = (await storage.findAvailableManagedChangeInputs(userId, changeBasket.basketId, !args.firstAction.isDelayed)).filter((output) => !fixedOutputIds.has(output.outputId));
14442
+ const availableOutputs = (await storage.findAvailableManagedChangeInputs(userId, changeBasket.basketId, false)).filter((output) => !fixedOutputIds.has(output.outputId));
14443
+ const availableStatuses = await storage.findTransactionStatusesByIds(userId, availableOutputs.map((output) => output.transactionId));
14444
+ const available = availableOutputs.map((output) => ({
14445
+ ...output,
14446
+ transactionStatus: availableStatuses.get(output.transactionId) ?? "sending"
14447
+ }));
13812
14448
  const target = estimateFirstActionTarget(storage, args.firstAction, explicit.inputSatoshis + noSendChange.inputSatoshis, outputScriptLengths);
13813
- const fixedOutputs = [...explicit.outputs, ...noSendChange.outputs];
13814
- const funding = chooseReservationPool(available, target, Math.max(0, INITIAL_RESERVATION_LIMIT - fixedOutputs.length), INITIAL_EXTRA_OUTPUTS, false, reservationPlanningCosts(storage, changeBasket));
14449
+ const fixedOutputs = [...new Map([...explicit.outputs, ...noSendChange.outputs].map((output) => [output.outputId, output])).values()];
14450
+ const maxReservedOutputs = storage.actionBatchMaxReservedOutputs;
14451
+ if (maxReservedOutputs >= 0 && fixedOutputs.length > maxReservedOutputs) throw new WERR_INVALID_PARAMETER("firstAction", `no more than ${maxReservedOutputs} persisted inputs`);
14452
+ const fundingOutputs = chooseReservationPool(available, target, Math.max(0, (maxReservedOutputs < 0 ? INITIAL_RESERVATION_LIMIT : Math.min(INITIAL_RESERVATION_LIMIT, maxReservedOutputs)) - fixedOutputs.length), INITIAL_EXTRA_OUTPUTS, false, reservationPlanningCosts(storage, changeBasket)).map(reservationOutput);
13815
14453
  const batch = newBatch(userId, args.batchId);
13816
14454
  await storage.transaction(async (trx) => {
13817
14455
  await storage.insertActionBatch(batch, trx);
13818
- await reserveOutputs(storage, batch, [...fixedOutputs, ...funding], trx);
14456
+ await reserveOutputs(storage, batch, [...fixedOutputs, ...fundingOutputs], trx);
13819
14457
  });
13820
14458
  let fundingResult;
13821
14459
  try {
13822
- fundingResult = await makeFundingResult(storage, args.firstAction, [...funding, ...fixedOutputs]);
14460
+ fundingResult = await makeFundingResult(storage, args.firstAction, [...fundingOutputs, ...fixedOutputs]);
13823
14461
  } catch (error) {
13824
14462
  await storage.transaction(async (trx) => await releaseBatchState(storage, batch, "aborted", trx));
13825
14463
  throw error;
@@ -13833,41 +14471,63 @@ async function beginActionBatch(storage, auth, args) {
13833
14471
  feeModel: validateStorageFeeModel(storage.feeModel),
13834
14472
  commissionSatoshis: storage.commissionSatoshis,
13835
14473
  commissionPubKeyHex: storage.commissionPubKeyHex,
13836
- availableChangeCount: available.length,
13837
- reservedOutputs: fundingResult.outputs.filter((output) => funding.some((candidate) => candidate.outputId === output.outputId)),
14474
+ availableChangeCount: available.filter((output) => output.satoshis >= Math.max(1, changeBasket.minimumDesiredUTXOValue)).length,
14475
+ managedChangePolicy: {
14476
+ maxOutputsPerAction: storage.managedChangePolicy.maxOutputsPerAction,
14477
+ migrationInputsPerAction: storage.managedChangePolicy.migrationInputsPerAction
14478
+ },
14479
+ reservedOutputs: fundingResult.outputs.filter((output) => fundingOutputs.some((candidate) => candidate.outputId === output.outputId)),
13838
14480
  explicitOutputs: fundingResult.outputs.filter((output) => explicitIds.has(output.outputId)),
13839
14481
  inputBeef: fundingResult.beef
13840
14482
  };
13841
14483
  }
13842
- function requireLiveBatch(batch) {
13843
- if (batch == null || activeExpiry(batch)) throw new WERR_INVALID_OPERATION("action batch is not active");
14484
+ function requireLiveBatch(batch, batchId) {
14485
+ const state = actionBatchErrorState(batch);
14486
+ if (state != null) throw new WERRActionBatchState(state, batchId ?? batch?.batchId);
14487
+ if (batch == null) throw new WERRActionBatchState("missing", batchId);
13844
14488
  return batch;
13845
14489
  }
13846
14490
  async function extendActionBatch(storage, auth, args) {
13847
14491
  const userId = verifyId(auth.userId);
13848
14492
  await cleanupExpiredActionBatches(storage);
13849
- const batch = requireLiveBatch(await storage.findActionBatch(userId, args.batchId));
14493
+ const batch = requireLiveBatch(await storage.findActionBatch(userId, args.batchId), args.batchId);
13850
14494
  const basket = verifyOne(await storage.findOutputBaskets({ partial: {
13851
14495
  userId,
13852
14496
  name: "default"
13853
14497
  } }));
13854
14498
  const alreadyReserved = await storage.findActionBatchOutputIds(batch.actionBatchId);
13855
- const available = await storage.findAvailableManagedChangeInputs(userId, basket.basketId, false);
13856
14499
  if (!Number.isSafeInteger(args.requestedOutputs) || args.requestedOutputs < 0) throw new WERR_INVALID_PARAMETER("requestedOutputs", "non-negative safe integer");
13857
- const requestedCount = Math.min(args.requestedOutputs, 64);
13858
- const funding = chooseReservationPool(available, Math.max(1, args.targetSatoshis), requestedCount, 0, true, reservationPlanningCosts(storage, basket));
14500
+ if (!Number.isSafeInteger(args.targetSatoshis) || args.targetSatoshis < 0) throw new WERR_INVALID_PARAMETER("targetSatoshis", "non-negative safe integer");
14501
+ const maxReservedOutputs = storage.actionBatchMaxReservedOutputs;
14502
+ if (maxReservedOutputs >= 0 && args.explicitOutpoints.length > maxReservedOutputs || new Set(args.explicitOutpoints.map((outpoint) => `${outpoint.txid}.${outpoint.vout}`)).size !== args.explicitOutpoints.length || args.explicitOutpoints.some((outpoint) => !isValidOutpoint(outpoint))) throw new WERR_INVALID_PARAMETER("explicitOutpoints", `at most ${maxReservedOutputs < 0 ? "the configured request capacity" : maxReservedOutputs} valid outpoints`);
14503
+ const remainingCapacity = maxReservedOutputs < 0 ? Number.MAX_SAFE_INTEGER : maxReservedOutputs - alreadyReserved.length;
14504
+ if (remainingCapacity <= 0 && (args.requestedOutputs > 0 || args.explicitOutpoints.length > 0)) throw new WERR_INVALID_OPERATION(`action batch already holds the maximum of ${maxReservedOutputs} outputs`);
13859
14505
  const explicitByOutpoint = await storage.findOutputsByOutpoints(userId, args.explicitOutpoints);
13860
- const explicit = Object.values(explicitByOutpoint).filter((output) => !alreadyReserved.includes(output.outputId));
13861
- const fundingResult = await makeFundingResult(storage, argsToFundingShape(args.includeSourceTransactions), [...funding, ...explicit]);
14506
+ const explicit = [...new Map(Object.values(explicitByOutpoint).filter((output) => !alreadyReserved.includes(output.outputId)).map((output) => [output.outputId, output])).values()];
14507
+ if (explicit.length > remainingCapacity) throw new WERR_INVALID_PARAMETER("explicitOutpoints", `no more than ${remainingCapacity} additional reserved outputs`);
14508
+ const explicitIds = new Set(explicit.map((output) => output.outputId));
14509
+ const availableOutputs = (await storage.findAvailableManagedChangeInputs(userId, basket.basketId, false)).filter((output) => !explicitIds.has(output.outputId));
14510
+ const availableStatuses = await storage.findTransactionStatusesByIds(userId, availableOutputs.map((output) => output.transactionId));
14511
+ const available = availableOutputs.map((output) => ({
14512
+ ...output,
14513
+ transactionStatus: availableStatuses.get(output.transactionId) ?? "sending"
14514
+ }));
14515
+ const requestedCount = Math.min(args.requestedOutputs, 64, remainingCapacity - explicit.length);
14516
+ const fundingOutputs = chooseReservationPool(available, Math.max(1, args.targetSatoshis), requestedCount, 0, true, reservationPlanningCosts(storage, basket)).map(reservationOutput);
14517
+ const fundingResult = await makeFundingResult(storage, argsToFundingShape(args.includeSourceTransactions), [...fundingOutputs, ...explicit]);
13862
14518
  const expiresAt = new Date(Math.min(batch.hardExpiresAt.getTime(), Date.now() + ACTION_BATCH_LEASE_MS));
13863
14519
  await storage.transaction(async (trx) => {
13864
- const current = requireLiveBatch(await storage.findActionBatchForUpdate(userId, args.batchId, trx));
13865
- await reserveOutputs(storage, current, [...funding, ...explicit], trx);
14520
+ const current = requireLiveBatch(await storage.findActionBatchForUpdate(userId, args.batchId, trx), args.batchId);
14521
+ const additions = [...new Map([...fundingOutputs, ...explicit].map((output) => [output.outputId, output])).values()];
14522
+ const currentReserved = new Set(await storage.findActionBatchOutputIds(current.actionBatchId, trx));
14523
+ const newAdditions = additions.filter((output) => !currentReserved.has(output.outputId));
14524
+ if (maxReservedOutputs >= 0 && currentReserved.size + newAdditions.length > maxReservedOutputs) throw new WERR_INVALID_OPERATION(`action batch cannot reserve more than ${maxReservedOutputs} outputs`);
14525
+ await reserveOutputs(storage, current, newAdditions, trx);
13866
14526
  await storage.updateActionBatch(current.actionBatchId, { expiresAt }, trx);
13867
14527
  });
13868
14528
  return {
13869
14529
  expiresAt: expiresAt.toISOString(),
13870
- reservedOutputs: fundingResult.outputs.filter((output) => funding.some((candidate) => candidate.outputId === output.outputId)),
14530
+ reservedOutputs: fundingResult.outputs.filter((output) => fundingOutputs.some((candidate) => candidate.outputId === output.outputId)),
13871
14531
  explicitOutputs: fundingResult.outputs.filter((output) => explicit.some((candidate) => candidate.outputId === output.outputId)),
13872
14532
  inputBeef: fundingResult.beef
13873
14533
  };
@@ -13903,14 +14563,64 @@ function argsToFundingShape(includeSourceTransactions) {
13903
14563
  async function renewActionBatch(storage, auth, batchId) {
13904
14564
  const userId = verifyId(auth.userId);
13905
14565
  return await storage.transaction(async (trx) => {
13906
- const batch = requireLiveBatch(await storage.findActionBatchForUpdate(userId, batchId, trx));
14566
+ const batch = requireLiveBatch(await storage.findActionBatchForUpdate(userId, batchId, trx), batchId);
13907
14567
  const expiresAt = new Date(Math.min(batch.hardExpiresAt.getTime(), Date.now() + ACTION_BATCH_LEASE_MS));
13908
14568
  await storage.updateActionBatch(batch.actionBatchId, { expiresAt }, trx);
13909
14569
  return { expiresAt: expiresAt.toISOString() };
13910
14570
  });
13911
14571
  }
14572
+ function validateResumeOutpoints(storage, args) {
14573
+ const unique = new Set(args.outpoints.map((outpoint) => `${outpoint.txid}.${outpoint.vout}`));
14574
+ const maxReservedOutputs = storage.actionBatchMaxReservedOutputs;
14575
+ if (maxReservedOutputs >= 0 && args.outpoints.length > maxReservedOutputs || unique.size !== args.outpoints.length || args.outpoints.some((outpoint) => !isValidOutpoint(outpoint))) throw new WERR_INVALID_PARAMETER("outpoints", `at most ${maxReservedOutputs < 0 ? "the configured request capacity" : maxReservedOutputs} unique valid outpoints`);
14576
+ }
14577
+ /**
14578
+ * Reacquire exactly the persisted inputs owned by one expired client
14579
+ * workspace. This is deliberately explicit: another action may not become a
14580
+ * member merely because it happens to use the same Wallet instance.
14581
+ */
14582
+ async function resumeActionBatch(storage, auth, args) {
14583
+ const userId = verifyId(auth.userId);
14584
+ validateResumeOutpoints(storage, args);
14585
+ await cleanupExpiredActionBatches(storage);
14586
+ return await storage.transaction(async (trx) => {
14587
+ const batch = await storage.findActionBatchForUpdate(userId, args.batchId, trx);
14588
+ const state = actionBatchErrorState(batch);
14589
+ if (state != null && state !== "expired") throw new WERRActionBatchState(state, args.batchId);
14590
+ if (batch == null) throw new WERRActionBatchState("missing", args.batchId);
14591
+ const expiresAt = new Date(Math.min(batch.hardExpiresAt.getTime(), Date.now() + ACTION_BATCH_LEASE_MS));
14592
+ if (state == null) {
14593
+ const reserved = new Set(await storage.findActionBatchOutputIds(batch.actionBatchId, trx));
14594
+ const current = await storage.findOutputsByOutpointsForUpdate(userId, args.outpoints, trx);
14595
+ if (Object.values(current).some((output) => !reserved.has(output.outputId)) || Object.keys(current).length !== args.outpoints.length || reserved.size !== args.outpoints.length) throw new WERRActionBatchState("conflicted", args.batchId);
14596
+ await storage.updateActionBatch(batch.actionBatchId, { expiresAt }, trx);
14597
+ return { expiresAt: expiresAt.toISOString() };
14598
+ }
14599
+ const stored = await storage.findOutputsByOutpointsForUpdate(userId, args.outpoints, trx);
14600
+ const outputs = args.outpoints.map((outpoint) => stored[`${outpoint.txid}.${outpoint.vout}`]);
14601
+ if (outputs.some((output) => output == null || !output.spendable || output.spentBy != null)) throw new WERRActionBatchState("conflicted", args.batchId);
14602
+ await storage.deleteActionBatchOutputReservations(batch.actionBatchId, trx);
14603
+ const exactOutputs = outputs;
14604
+ if ((await storage.findReservedActionBatchOutputIds(exactOutputs.map((output) => output.outputId), trx)).length > 0) throw new WERRActionBatchState("conflicted", args.batchId);
14605
+ const now = /* @__PURE__ */ new Date();
14606
+ await storage.reserveActionBatchOutputs(exactOutputs.map((output) => ({
14607
+ actionBatchId: batch.actionBatchId,
14608
+ outputId: output.outputId,
14609
+ created_at: now,
14610
+ updated_at: now
14611
+ })), trx);
14612
+ await storage.updateActionBatch(batch.actionBatchId, {
14613
+ status: "active",
14614
+ expiresAt,
14615
+ manifest: void 0,
14616
+ manifestDigest: void 0,
14617
+ uploadDigests: void 0
14618
+ }, trx);
14619
+ return { expiresAt: expiresAt.toISOString() };
14620
+ });
14621
+ }
13912
14622
  async function reacquireManifestInputs(storage, userId, batch, validated, trx) {
13913
- if (batch.hardExpiresAt.getTime() <= Date.now()) throw new WERR_INVALID_OPERATION("action batch hard lifetime has expired");
14623
+ if (batch.hardExpiresAt.getTime() <= Date.now()) throw new WERRActionBatchState("hard-expired", batch.batchId);
13914
14624
  const stagedTxids = new Set(validated.actions.map(({ action }) => action.txid));
13915
14625
  const outpoints = [...new Map(validated.actions.flatMap(({ action }) => action.plan.inputs).filter((input) => !stagedTxids.has(input.sourceTxid)).map((input) => [`${input.sourceTxid}.${input.sourceVout}`, {
13916
14626
  txid: input.sourceTxid,
@@ -14077,7 +14787,7 @@ async function persistAction(...[storage, userId, validated, reservedOutputIds,
14077
14787
  async function persistManifestAtomically(storage, userId, batch, manifest, validated) {
14078
14788
  return await storage.transaction(async (trx) => {
14079
14789
  const current = await storage.findActionBatchForUpdate(userId, batch.batchId, trx);
14080
- if (current == null) throw new WERR_INVALID_OPERATION("action batch was not found");
14790
+ if (current == null) throw new WERRActionBatchState("missing", batch.batchId);
14081
14791
  if (current.status === "committed") {
14082
14792
  if (current.manifestDigest !== manifest.digest) throw new WERR_INVALID_OPERATION("batch committed with another manifest");
14083
14793
  return {
@@ -14085,12 +14795,12 @@ async function persistManifestAtomically(storage, userId, batch, manifest, valid
14085
14795
  alreadyCommitted: true
14086
14796
  };
14087
14797
  }
14088
- if (current.status === "aborted") throw new WERR_INVALID_OPERATION("aborted action batch cannot be committed");
14798
+ if (current.status === "aborted") throw new WERRActionBatchState("aborted", batch.batchId);
14089
14799
  if (current.manifestDigest != null && current.manifestDigest !== manifest.digest) throw new WERR_INVALID_OPERATION("batch prepared with another manifest");
14090
14800
  if (current.status === "expired" || activeExpiry(current)) {
14091
- if (current.hardExpiresAt.getTime() <= Date.now()) throw new WERR_INVALID_OPERATION("action batch hard lifetime has expired");
14801
+ if (current.hardExpiresAt.getTime() <= Date.now()) throw new WERRActionBatchState("hard-expired", batch.batchId);
14092
14802
  await reacquireManifestInputs(storage, userId, current, validated, trx);
14093
- } else requireLiveBatch(current);
14803
+ } else requireLiveBatch(current, batch.batchId);
14094
14804
  const reservedOutputIds = new Set(await storage.findActionBatchOutputIds(current.actionBatchId, trx));
14095
14805
  const stagedTxids = new Set(validated.actions.map(({ action }) => action.txid));
14096
14806
  const inputOutpoints = [...new Map(validated.actions.flatMap(({ action }) => action.plan.inputs).filter((input) => !stagedTxids.has(input.sourceTxid)).map((input) => [`${input.sourceTxid}.${input.sourceVout}`, {
@@ -14168,14 +14878,14 @@ async function completeCommittedBatch(storage, userId, batch, manifest, alreadyC
14168
14878
  }
14169
14879
  async function commitActionBatchOnce(storage, userId, manifest) {
14170
14880
  const batch = await storage.findActionBatch(userId, manifest.batchId);
14171
- if (batch == null) throw new WERR_INVALID_OPERATION("action batch was not found");
14881
+ if (batch == null) throw new WERRActionBatchState("missing", manifest.batchId);
14172
14882
  if (batch.status === "committed") {
14173
14883
  if (batch.manifestDigest !== manifest.digest) throw new WERR_INVALID_OPERATION("batch committed with another manifest");
14174
14884
  return await completeCommittedBatch(storage, userId, batch, manifest, true);
14175
14885
  }
14176
- if (batch.status === "aborted") throw new WERR_INVALID_OPERATION("aborted action batch cannot be committed");
14177
- if (!(batch.status === "expired" || activeExpiry(batch))) requireLiveBatch(batch);
14178
- else if (batch.hardExpiresAt.getTime() <= Date.now()) throw new WERR_INVALID_OPERATION("action batch hard lifetime has expired");
14886
+ if (batch.status === "aborted") throw new WERRActionBatchState("aborted", manifest.batchId);
14887
+ if (!(batch.status === "expired" || activeExpiry(batch))) requireLiveBatch(batch, manifest.batchId);
14888
+ else if (batch.hardExpiresAt.getTime() <= Date.now()) throw new WERRActionBatchState("hard-expired", manifest.batchId);
14179
14889
  if (batch.manifestDigest != null && batch.manifestDigest !== manifest.digest) throw new WERR_INVALID_OPERATION("batch prepared with another manifest");
14180
14890
  const persisted = await persistManifestAtomically(storage, userId, batch, manifest, await validateManifestActions(storage, batch, manifest));
14181
14891
  return await completeCommittedBatch(storage, userId, persisted.batch, manifest, persisted.alreadyCommitted, persisted.share);
@@ -14258,6 +14968,8 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14258
14968
  commissionSatoshis;
14259
14969
  commissionPubKeyHex;
14260
14970
  maxRecursionDepth;
14971
+ actionBatchMaxReservedOutputs;
14972
+ managedChangePolicy;
14261
14973
  scriptVerifier;
14262
14974
  static defaultOptions() {
14263
14975
  return {
@@ -14266,7 +14978,9 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14266
14978
  value: 100
14267
14979
  },
14268
14980
  commissionSatoshis: 0,
14269
- commissionPubKeyHex: void 0
14981
+ commissionPubKeyHex: void 0,
14982
+ actionBatchMaxReservedOutputs: 256,
14983
+ managedChangePolicy: defaultManagedChangePolicy()
14270
14984
  };
14271
14985
  }
14272
14986
  static createStorageBaseOptions(chain) {
@@ -14281,6 +14995,10 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14281
14995
  this.commissionPubKeyHex = options.commissionPubKeyHex;
14282
14996
  this.commissionSatoshis = options.commissionSatoshis;
14283
14997
  this.maxRecursionDepth = 12;
14998
+ const maxReservedOutputs = options.actionBatchMaxReservedOutputs ?? 256;
14999
+ if (maxReservedOutputs !== -1 && (!Number.isSafeInteger(maxReservedOutputs) || maxReservedOutputs < 1)) throw new WERR_INVALID_PARAMETER("actionBatchMaxReservedOutputs", "a positive safe integer or -1 for unlimited");
15000
+ this.actionBatchMaxReservedOutputs = maxReservedOutputs;
15001
+ this.managedChangePolicy = validateManagedChangePolicy(options.managedChangePolicy);
14284
15002
  this.scriptVerifier = options.scriptVerifier;
14285
15003
  }
14286
15004
  /** Mark a planned set of change inputs spent within the caller's transaction. */
@@ -14311,12 +15029,16 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14311
15029
  }
14312
15030
  /** Read only the fields needed by the in-memory funding planner. */
14313
15031
  async findAvailableManagedChangeInputCandidates(userId, basketId, excludeSending, trx) {
14314
- return (await this.findAvailableManagedChangeInputs(userId, basketId, excludeSending, trx)).map(({ outputId, transactionId, satoshis, txid, vout }) => ({
15032
+ const outputs = await this.findAvailableManagedChangeInputs(userId, basketId, excludeSending, trx);
15033
+ const findStatuses = this.findTransactionStatusesByIds?.bind(this);
15034
+ const statuses = findStatuses == null ? /* @__PURE__ */ new Map() : await findStatuses(userId, outputs.map((output) => output.transactionId), trx);
15035
+ return outputs.map(({ outputId, transactionId, satoshis, txid, vout }) => ({
14315
15036
  outputId,
14316
15037
  transactionId,
14317
15038
  satoshis,
14318
15039
  txid,
14319
- vout
15040
+ vout,
15041
+ transactionStatus: statuses.get(transactionId)
14320
15042
  }));
14321
15043
  }
14322
15044
  /** Read the current status of a set of source transactions without loading raw transaction bytes. */
@@ -14404,7 +15126,7 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14404
15126
  throw new WERR_NOT_IMPLEMENTED();
14405
15127
  }
14406
15128
  async getCapabilities() {
14407
- return this.supportsActionBatchPersistence() ? getActionBatchCapabilities() : {};
15129
+ return this.supportsActionBatchPersistence() ? getActionBatchCapabilities(this.actionBatchMaxReservedOutputs, true) : {};
14408
15130
  }
14409
15131
  supportsActionBatchPersistence() {
14410
15132
  return false;
@@ -14423,6 +15145,9 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14423
15145
  async renewActionBatch(auth, batchId) {
14424
15146
  return await renewActionBatch(this, auth, batchId);
14425
15147
  }
15148
+ async resumeActionBatch(auth, args) {
15149
+ return await resumeActionBatch(this, auth, args);
15150
+ }
14426
15151
  async prepareActionBatchCommit(auth, manifest) {
14427
15152
  return await prepareActionBatchCommit(this, auth, manifest);
14428
15153
  }
@@ -15020,7 +15745,12 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
15020
15745
  for (const output of outputs) {
15021
15746
  const outputId = verifyId(output.outputId);
15022
15747
  await this.validateOutputScript(output);
15023
- verdicts.set(outputId, output.lockingScript == null ? void 0 : await services.isUtxo(output));
15748
+ if (output.lockingScript == null) {
15749
+ verdicts.set(outputId, void 0);
15750
+ continue;
15751
+ }
15752
+ const classification = await classifyOutputUtxo(services, output);
15753
+ verdicts.set(outputId, classification.verdict === "unknown" ? void 0 : classification.verdict === "unspent");
15024
15754
  }
15025
15755
  }
15026
15756
  return verdicts;
@@ -15182,16 +15912,12 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
15182
15912
  } });
15183
15913
  for (const o of outputs) {
15184
15914
  if (!o.spendable) continue;
15185
- if (!await this.checkOutputIsUtxo(o, services)) invalidSpendableOutputs.push(o);
15915
+ await this.validateOutputScript(o);
15916
+ if (!requireConclusiveUtxo(await classifyOutputUtxo(services, o))) invalidSpendableOutputs.push(o);
15186
15917
  }
15187
15918
  }
15188
15919
  return { invalidSpendableOutputs };
15189
15920
  }
15190
- async checkOutputIsUtxo(o, services) {
15191
- if (o.lockingScript == null || o.lockingScript.length === 0) return false;
15192
- const hash = services.hashOutputScript(asString(o.lockingScript));
15193
- return (await services.getUtxoStatus(hash, void 0, `${o.txid ?? ""}.${o.vout ?? ""}`)).isUtxo === true;
15194
- }
15195
15921
  async updateProvenTxReqDynamics(id, update, trx) {
15196
15922
  const partial = {};
15197
15923
  if (update.updated_at != null) partial.updated_at = update.updated_at;
@@ -15301,6 +16027,7 @@ const getLabelToSpecOp = () => {
15301
16027
  //#region ../src/storage/methods/listActionsIdb.ts
15302
16028
  async function enrichIdbActionLabels(storage, tx, action, timeFilterRequested) {
15303
16029
  action.labels = (await storage.getLabelsForTransactionId(tx.transactionId)).map((l) => l.label);
16030
+ if (tx.reference != null && tx.reference !== "") action.labels = applyBrc153ReferenceLabel(action.labels, tx.reference);
15304
16031
  if (timeFilterRequested) {
15305
16032
  const ts = tx.created_at ? new Date(tx.created_at).getTime() : NaN;
15306
16033
  if (!Number.isNaN(ts)) {
@@ -15419,20 +16146,129 @@ async function listActionsIdb(storage, auth, vargs) {
15419
16146
  return r;
15420
16147
  }
15421
16148
  //#endregion
15422
- //#region ../src/storage/methods/ListOutputsSpecOp.ts
15423
- const INVALID_CHANGE_MAX_CONCURRENCY = 12;
15424
- async function runWithConcurrency(values, maxConcurrency, worker) {
15425
- const active = [];
15426
- for (const value of values) {
15427
- const task = worker(value).finally(() => {
15428
- const i = active.indexOf(task);
15429
- if (i >= 0) active.splice(i, 1);
16149
+ //#region ../src/storage/methods/reviewUtxoOutputs.ts
16150
+ const MAX_AUDIT_PROVIDERS = 8;
16151
+ const MAX_PROVIDER_NAME_LENGTH = 128;
16152
+ const UTXO_REVIEW_PROVIDER_TIMEOUT_MSECS = 5e3;
16153
+ function diagnosticsFor(classifications, releasedOutputIds) {
16154
+ const allProviders = [...new Set(classifications.map((result) => result.status.provider.slice(0, MAX_PROVIDER_NAME_LENGTH)))];
16155
+ const providers = allProviders.slice(0, MAX_AUDIT_PROVIDERS);
16156
+ const confirmedSpent = classifications.filter((result) => result.status.verdict === "spent");
16157
+ const released = confirmedSpent.filter((result) => releasedOutputIds.has(result.output.outputId));
16158
+ return {
16159
+ checked: classifications.length,
16160
+ confirmedUnspent: classifications.filter((result) => result.status.verdict === "unspent").length,
16161
+ confirmedSpent: confirmedSpent.length,
16162
+ unknown: classifications.filter((result) => result.status.verdict === "unknown").length,
16163
+ confirmedSpentSatoshis: confirmedSpent.reduce((sum, result) => sum + result.output.satoshis, 0),
16164
+ released: released.length,
16165
+ releasedSatoshis: released.reduce((sum, result) => sum + result.output.satoshis, 0),
16166
+ providers,
16167
+ providerCount: allProviders.length,
16168
+ providersTruncated: allProviders.length > providers.length
16169
+ };
16170
+ }
16171
+ function auditDetails(diagnostics, userId, releaseMode, reason) {
16172
+ return JSON.stringify({
16173
+ operation: "specOpInvalidChange",
16174
+ reason,
16175
+ releaseMode,
16176
+ userId,
16177
+ ...diagnostics
16178
+ });
16179
+ }
16180
+ async function classifyCandidates(storage, outputs) {
16181
+ const services = storage.getServices();
16182
+ return await mapWithConcurrency(outputs.filter((output) => output.basketId != null), 4, async (output) => {
16183
+ try {
16184
+ await storage.validateOutputScript(output);
16185
+ } catch (error) {
16186
+ return {
16187
+ output,
16188
+ status: {
16189
+ verdict: "unknown",
16190
+ provider: "<script-validation-error>",
16191
+ error
16192
+ }
16193
+ };
16194
+ }
16195
+ let timeout;
16196
+ const providerCall = classifyOutputUtxo(services, output);
16197
+ const timedClassification = new Promise((resolve) => {
16198
+ timeout = setTimeout(() => resolve({
16199
+ verdict: "unknown",
16200
+ provider: "<review-timeout>",
16201
+ error: /* @__PURE__ */ new Error(`UTXO classification exceeded ${UTXO_REVIEW_PROVIDER_TIMEOUT_MSECS}ms`)
16202
+ }), UTXO_REVIEW_PROVIDER_TIMEOUT_MSECS);
16203
+ });
16204
+ try {
16205
+ return {
16206
+ output,
16207
+ status: await Promise.race([providerCall, timedClassification])
16208
+ };
16209
+ } finally {
16210
+ if (timeout != null) clearTimeout(timeout);
16211
+ providerCall.catch(() => void 0);
16212
+ }
16213
+ });
16214
+ }
16215
+ async function releaseConfirmedSpent(storage, auth, classifications, releaseMode) {
16216
+ const confirmedSpent = classifications.filter((result) => result.status.verdict === "spent");
16217
+ const releasedOutputIds = /* @__PURE__ */ new Set();
16218
+ await storage.transaction(async (trx) => {
16219
+ for (const { output } of confirmedSpent) {
16220
+ const current = await storage.findOutputById(output.outputId, trx, true);
16221
+ if (current == null || current.userId !== auth.userId) throw new WERR_INVALID_PARAMETER("outputId", `owned by user ${auth.userId}`);
16222
+ if (current.spendable !== true || current.spentBy != null) throw new WERR_INVALID_OPERATION(`Output ${output.outputId} changed state during UTXO review; no outputs were changed.`);
16223
+ if (await storage.updateOutput(verifyId(output.outputId), { spendable: false }, trx) !== 1) throw new WERR_INVALID_PARAMETER("outputId", `updated exactly once: ${output.outputId}`);
16224
+ releasedOutputIds.add(output.outputId);
16225
+ }
16226
+ const now = /* @__PURE__ */ new Date();
16227
+ const diagnostics = diagnosticsFor(classifications, releasedOutputIds);
16228
+ await storage.insertMonitorEvent({
16229
+ created_at: now,
16230
+ updated_at: now,
16231
+ id: 0,
16232
+ event: releaseMode === "atomic" ? "InvalidChangeRelease" : "InvalidChangeConclusiveRelease",
16233
+ details: auditDetails(diagnostics, auth.userId, releaseMode, "provider-confirmed-spent")
16234
+ }, trx);
16235
+ });
16236
+ return releasedOutputIds;
16237
+ }
16238
+ /**
16239
+ * Classify a bounded set of wallet outputs without treating provider failure as
16240
+ * proof that an output was spent. Read-only reviews always return their
16241
+ * conclusive and unknown partitions. Atomic release requires every verdict to
16242
+ * be conclusive; the operator-only conclusive mode releases the positively
16243
+ * spent subset while retaining and reporting unknowns.
16244
+ */
16245
+ async function reviewUtxoOutputs(storage, auth, outputs, releaseMode = "none") {
16246
+ const classifications = await classifyCandidates(storage, outputs);
16247
+ const confirmedSpentOutputs = classifications.filter((result) => result.status.verdict === "spent").map((result) => result.output);
16248
+ const unknownOutputs = classifications.filter((result) => result.status.verdict === "unknown").map((result) => result.output);
16249
+ if (releaseMode === "atomic" && unknownOutputs.length > 0) {
16250
+ const diagnostics = diagnosticsFor(classifications, /* @__PURE__ */ new Set());
16251
+ const now = /* @__PURE__ */ new Date();
16252
+ await storage.insertMonitorEvent({
16253
+ created_at: now,
16254
+ updated_at: now,
16255
+ id: 0,
16256
+ event: "InvalidChangeReleaseBlocked",
16257
+ details: auditDetails(diagnostics, auth.userId, releaseMode, "inconclusive-provider-result")
15430
16258
  });
15431
- active.push(task);
15432
- if (active.length >= maxConcurrency) await Promise.race(active);
16259
+ throw new WERR_UTXO_REVIEW_INCONCLUSIVE(diagnostics.checked, diagnostics.confirmedSpent, diagnostics.unknown);
15433
16260
  }
15434
- await Promise.all(active);
16261
+ const releasedOutputIds = releaseMode === "none" ? /* @__PURE__ */ new Set() : await releaseConfirmedSpent(storage, auth, classifications, releaseMode);
16262
+ for (const output of confirmedSpentOutputs) if (releasedOutputIds.has(output.outputId)) output.spendable = false;
16263
+ return {
16264
+ classifications,
16265
+ confirmedSpentOutputs,
16266
+ unknownOutputs,
16267
+ diagnostics: diagnosticsFor(classifications, releasedOutputIds)
16268
+ };
15435
16269
  }
16270
+ //#endregion
16271
+ //#region ../src/storage/methods/ListOutputsSpecOp.ts
15436
16272
  const getBasketToSpecOp = () => {
15437
16273
  return {
15438
16274
  [specOpWalletBalance]: {
@@ -15463,23 +16299,7 @@ const getBasketToSpecOp = () => {
15463
16299
  includeSpent: false,
15464
16300
  tagsToIntercept: ["release", "all"],
15465
16301
  filterOutputs: async (s, auth, vargs, specOpTags, outputs) => {
15466
- if (specOpTags.includes("release")) await s.reviewStatus({ agedLimit: /* @__PURE__ */ new Date(0) });
15467
- const invalidOutputIds = /* @__PURE__ */ new Set();
15468
- const services = s.getServices();
15469
- await runWithConcurrency(outputs, INVALID_CHANGE_MAX_CONCURRENCY, async (o) => {
15470
- if (!o.basketId) return;
15471
- await s.validateOutputScript(o);
15472
- let ok = false;
15473
- if (o.lockingScript != null && o.lockingScript.length > 0) ok = await services.isUtxo(o);
15474
- else ok = void 0;
15475
- if (ok === false) invalidOutputIds.add(o.outputId);
15476
- });
15477
- const filteredOutputs = outputs.filter((o) => invalidOutputIds.has(o.outputId));
15478
- if (specOpTags.includes("release")) await runWithConcurrency(filteredOutputs, INVALID_CHANGE_MAX_CONCURRENCY, async (o) => {
15479
- await s.updateOutput(o.outputId, { spendable: false });
15480
- o.spendable = false;
15481
- });
15482
- return filteredOutputs;
16302
+ return (await reviewUtxoOutputs(s, auth, outputs, specOpTags.includes("release") ? "atomic" : "none")).confirmedSpentOutputs;
15483
16303
  }
15484
16304
  },
15485
16305
  [specOpSetWalletChangeParams]: {
@@ -15632,7 +16452,7 @@ async function loadIdbOutputs(query) {
15632
16452
  "nosend",
15633
16453
  "sending"
15634
16454
  ],
15635
- noScript: true,
16455
+ noScript: specOp?.includeOutputScripts !== true,
15636
16456
  orderDescending
15637
16457
  };
15638
16458
  const pageManagedChange = specOp?.managedChangeOnly === true && specOp.ignoreLimit !== true;
@@ -15714,7 +16534,10 @@ async function listOutputsIdb(storage, auth, vargs, _originator) {
15714
16534
  });
15715
16535
  result.totalOutputs = totalOutputs;
15716
16536
  if (specOp != null) {
15717
- if (specOp.filterOutputs != null) outputs = await specOp.filterOutputs(storage, auth, vargs, specOpTags, outputs);
16537
+ if (specOp.filterOutputs != null) {
16538
+ outputs = await specOp.filterOutputs(storage, auth, vargs, specOpTags, outputs);
16539
+ result.totalOutputs = outputs.length;
16540
+ }
15718
16541
  if (specOp.resultFromOutputs != null) return specOp.resultFromOutputs(storage, auth, vargs, specOpTags, outputs);
15719
16542
  }
15720
16543
  await hydrateIdbOutputResult(storage, outputs, vargs, result);
@@ -15845,6 +16668,7 @@ async function scanCursor(cursor, since, offset, limit, matches, accept) {
15845
16668
  var StorageIdb = class extends StorageProvider {
15846
16669
  dbName;
15847
16670
  db;
16671
+ managedChangeDefaultsMigrated = false;
15848
16672
  constructor(options) {
15849
16673
  super(options);
15850
16674
  this.dbName = `wallet-toolbox-${this.chain}net`;
@@ -15882,6 +16706,7 @@ var StorageIdb = class extends StorageProvider {
15882
16706
  async verifyDB(storageName, storageIdentityKey) {
15883
16707
  if (this.db != null) return this.db;
15884
16708
  this.db = await this.initDB(storageName, storageIdentityKey);
16709
+ await this.migrateManagedChangeDefaults(this.db);
15885
16710
  this._settings = (await this.db.getAll("settings"))[0];
15886
16711
  this.whenLastAccess = /* @__PURE__ */ new Date();
15887
16712
  return this.db;
@@ -15915,7 +16740,7 @@ var StorageIdb = class extends StorageProvider {
15915
16740
  async initDB(storageName, storageIdentityKey) {
15916
16741
  const chain = this.chain;
15917
16742
  const maxOutputScript = 1024;
15918
- return await (0, idb.openDB)(this.dbName, 3, { upgrade(db, _oldVersion, _newVersion, transaction) {
16743
+ return await (0, idb.openDB)(this.dbName, 4, { upgrade(db, _oldVersion, _newVersion, transaction) {
15919
16744
  upgradeAllStoresV1(db);
15920
16745
  upgradeActionBatchStoresV2(db);
15921
16746
  const outputs = transaction.objectStore("outputs");
@@ -15941,6 +16766,22 @@ var StorageIdb = class extends StorageProvider {
15941
16766
  }
15942
16767
  } });
15943
16768
  }
16769
+ async migrateManagedChangeDefaults(db) {
16770
+ if (this.managedChangeDefaultsMigrated) return;
16771
+ const trx = db.transaction("output_baskets", "readwrite");
16772
+ let cursor = await trx.store.openCursor();
16773
+ while (cursor != null) {
16774
+ const basket = cursor.value;
16775
+ if (isLegacyManagedChangeBasketDefault(basket)) await cursor.update({
16776
+ ...basket,
16777
+ minimumDesiredUTXOValue: DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS,
16778
+ updated_at: /* @__PURE__ */ new Date()
16779
+ });
16780
+ cursor = await cursor.continue();
16781
+ }
16782
+ await trx.done;
16783
+ this.managedChangeDefaultsMigrated = true;
16784
+ }
15944
16785
  async reviewStatus(args) {
15945
16786
  return await reviewStatusIdb(this, args);
15946
16787
  }
@@ -16695,6 +17536,7 @@ var StorageIdb = class extends StorageProvider {
16695
17536
  if (this.db != null) this.db.close();
16696
17537
  this.db = void 0;
16697
17538
  this._settings = void 0;
17539
+ this.managedChangeDefaultsMigrated = false;
16698
17540
  }
16699
17541
  allStores = [
16700
17542
  "action_batches",
@@ -17867,6 +18709,9 @@ var StorageClientBase = class {
17867
18709
  async renewActionBatch(auth, batchId) {
17868
18710
  return await this.rpcCall("renewActionBatch", [auth, batchId]);
17869
18711
  }
18712
+ async resumeActionBatch(auth, args) {
18713
+ return await this.rpcCall("resumeActionBatch", [auth, args]);
18714
+ }
17870
18715
  async prepareActionBatchCommit(auth, manifest) {
17871
18716
  return await this.rpcCall("prepareActionBatchCommit", [auth, manifest]);
17872
18717
  }
@@ -18671,7 +19516,7 @@ async function restoreBRC38(storage, data) {
18671
19516
  await storage.transaction(async (trx) => {
18672
19517
  await storage.insertUser({ ...data.user }, trx);
18673
19518
  for (const row of data.provenTxs) await storage.insertProvenTx({ ...row }, trx);
18674
- for (const row of data.outputBaskets) await storage.insertOutputBasket({ ...row }, trx);
19519
+ for (const row of data.outputBaskets) await storage.insertOutputBasket(upgradeLegacyManagedChangeBasketDefault({ ...row }), trx);
18675
19520
  for (const row of data.outputTags) await storage.insertOutputTag({ ...row }, trx);
18676
19521
  for (const row of data.txLabels) await storage.insertTxLabel({ ...row }, trx);
18677
19522
  for (const row of data.transactions) await storage.insertTransaction({ ...row }, trx);
@@ -21730,13 +22575,37 @@ function configuredChaintracksClient(chain, serviceUrl) {
21730
22575
  return new ChaintracksServiceClient(chain, serviceUrl);
21731
22576
  }
21732
22577
  /**
22578
+ * True when running under a browser-style runtime — a real browser tab, or
22579
+ * the embedded webview a desktop wallet shell hosts its wallet logic in
22580
+ * (Metanet Client / User Wallet are Tauri WKWebViews) — where `fetch` is
22581
+ * subject to CORS enforcement that Node-style runtimes do not apply.
22582
+ */
22583
+ function isBrowserRuntime() {
22584
+ return typeof window !== "undefined" && window.document !== void 0;
22585
+ }
22586
+ /**
21733
22587
  * Returns the credential-free default ChainTracks client for a supported
21734
22588
  * public network, or an operator-configured client for stn/tstn.
22589
+ *
22590
+ * BROWSER RUNTIMES get the legacy CORS-enabled Chaintracks service for
22591
+ * main/test. The Go Chaintracks deployments (`arcade-v2-*.bsvblockchain.tech`)
22592
+ * currently serve no `Access-Control-Allow-Origin` header and answer OPTIONS
22593
+ * preflights with 404 (verified live 2026-08-11), so every fetch from a
22594
+ * browser-hosted wallet is CORS-blocked (WebKit surfaces it as
22595
+ * `TypeError: Load failed`) and the wallet loses `getHeight`, headers and
22596
+ * merkle-root validation wholesale. The repository service contract
22597
+ * (AGENTS.md: "browser, mobile, and unknown-domain clients must not be
22598
+ * silently blocked by CORS") requires a default that browsers can actually
22599
+ * reach. Once the Go deployments serve CORS (and a browser-run conformance
22600
+ * check proves it), this branch can be removed and browsers can share the v2
22601
+ * default. Node runtimes are unchanged.
21735
22602
  */
21736
22603
  function createDefaultChaintracksClient(chain) {
21737
22604
  switch (chain) {
21738
22605
  case "main":
21739
22606
  case "test":
22607
+ if (isBrowserRuntime()) return new ChaintracksServiceClient(chain, `https://${chain}net-chaintracks.babbage.systems`);
22608
+ return new GoChaintracksServiceClient(chain, arcadeDefaultUrl(chain), { apiPrefix: "/chaintracks/v2" });
21740
22609
  case "ttn": return new GoChaintracksServiceClient(chain, arcadeDefaultUrl(chain), { apiPrefix: "/chaintracks/v2" });
21741
22610
  case "stn": return configuredChaintracksClient(chain, stnChaintracksUrl());
21742
22611
  case "tstn": return configuredChaintracksClient(chain, tstnChaintracksUrl());
@@ -21775,7 +22644,7 @@ function createDefaultWalletServicesOptions(...[chain, arcCallbackUrl, arcCallba
21775
22644
  exchangeratesapiKey: void 0,
21776
22645
  chaintracksFiatExchangeRatesUrl,
21777
22646
  chaintracks,
21778
- arcUrl: arcDefaultUrl(chain),
22647
+ arcUrl: chain === "ttn" ? "" : arcDefaultUrl(chain),
21779
22648
  arcConfig: {
21780
22649
  apiKey: taalArcApiKey ?? void 0,
21781
22650
  deploymentId,
@@ -21791,7 +22660,7 @@ function createDefaultWalletServicesOptions(...[chain, arcCallbackUrl, arcCallba
21791
22660
  },
21792
22661
  bitailsApiKey
21793
22662
  };
21794
- const resolvedArcadeUrl = arcadeUrl;
22663
+ const resolvedArcadeUrl = arcadeUrl ?? (chain === "ttn" ? arcadeDefaultUrl(chain) : void 0);
21795
22664
  if (resolvedArcadeUrl != null && resolvedArcadeUrl !== "") {
21796
22665
  o.arcadeUrl = resolvedArcadeUrl;
21797
22666
  o.arcadeConfig = {
@@ -21821,7 +22690,7 @@ function arcDefaultUrl(chain) {
21821
22690
  case "main": return "https://arc.taal.com";
21822
22691
  case "test": return "https://arc-test.taal.com";
21823
22692
  case "stn": return stnArcadeUrl() ?? "";
21824
- case "ttn": return "https://arcade-v2-ttn-us-1.bsvblockchain.tech/";
22693
+ case "ttn": return "";
21825
22694
  case "tstn": return tstnArcadeUrl() ?? "";
21826
22695
  case "mock": return "";
21827
22696
  }
@@ -22193,6 +23062,40 @@ var ARC = class {
22193
23062
  }
22194
23063
  };
22195
23064
  //#endregion
23065
+ //#region ../src/services/providers/arcadeStatus.ts
23066
+ /**
23067
+ * Canonical classification shared by Arcade polling and SSE. Keeping this in
23068
+ * one place prevents a provider from calling a rejection "unknown" while the
23069
+ * monitor's event path calls the same status terminal.
23070
+ */
23071
+ function classifyArcadeRejection(event) {
23072
+ const extraInfo = event.extraInfo?.trim() ?? "";
23073
+ const detail = extraInfo.toLowerCase();
23074
+ if (event.status === 476 || detail.startsWith("parent rejected") || detail.includes("not final") || detail.includes("non-final")) return {
23075
+ terminal: false,
23076
+ inputConflict: false,
23077
+ reqStatus: "invalid",
23078
+ reason: event.status === 476 ? "transaction is not final" : "Arcade reported a retryable parent/locktime condition"
23079
+ };
23080
+ const orphanConflict = isArcDoubleSpendTxStatus(event.txStatus);
23081
+ const inputConflict = orphanConflict || event.status === 462 || event.status === 466 || /(?:utxo|input).*(?:spent|missing|conflict)|missing[- ]inputs?|already spent/.test(detail);
23082
+ const classifiedValidatorFailure = event.status != null && event.status >= 460 && event.status <= 475;
23083
+ const explicitInvalidStatus = isArcInvalidTxStatus(event.txStatus) && event.txStatus !== "REJECTED";
23084
+ const terminal = inputConflict || classifiedValidatorFailure || explicitInvalidStatus || extraInfo !== "";
23085
+ let reason = "Arcade supplied no durable rejection evidence";
23086
+ if (orphanConflict) reason = `Arcade reported ${event.txStatus}`;
23087
+ else if (inputConflict) reason = "Arcade supplied confirmed missing-input/conflict evidence";
23088
+ else if (classifiedValidatorFailure) reason = `Arcade supplied terminal validator code ${String(event.status)}`;
23089
+ else if (explicitInvalidStatus) reason = `Arcade reported terminal ${event.txStatus}`;
23090
+ else if (extraInfo !== "") reason = "Arcade supplied a terminal validator rejection reason";
23091
+ return {
23092
+ terminal,
23093
+ inputConflict,
23094
+ reqStatus: inputConflict ? "doubleSpend" : "invalid",
23095
+ reason
23096
+ };
23097
+ }
23098
+ //#endregion
22196
23099
  //#region ../src/services/providers/Arcade.ts
22197
23100
  function defaultDeploymentId() {
22198
23101
  return `ts-sdk-${_bsv_sdk.Utils.toHex((0, _bsv_sdk.Random)(16))}`;
@@ -22462,6 +23365,73 @@ var Arcade = class {
22462
23365
  return (await this.httpClient.request(`${this.URL}/tx/${txid}`, requestOptions)).data;
22463
23366
  }
22464
23367
  /**
23368
+ * Adapt Arcade's lifecycle endpoint to the shared transaction-status
23369
+ * provider contract. This lets monitor reconciliation remain operational
23370
+ * when an explorer such as WhatsOnChain is absent. Only network-observed
23371
+ * states count as known; RECEIVED/PENDING_RETRY and terminal rejection
23372
+ * states remain unknown, while MINED/IMMUTABLE are authoritative mined
23373
+ * observations whose proof is validated separately.
23374
+ */
23375
+ async getStatusForTxids(txids) {
23376
+ const r = {
23377
+ name: this.name,
23378
+ status: "success",
23379
+ results: []
23380
+ };
23381
+ let firstError;
23382
+ r.results = await Promise.all(txids.map(async (txid) => {
23383
+ try {
23384
+ const response = await this.httpClient.request(`${this.URL}/tx/${txid}`, {
23385
+ method: "GET",
23386
+ headers: this.requestHeaders()
23387
+ });
23388
+ if (Number(response.status) === 404) return {
23389
+ txid,
23390
+ status: "unknown",
23391
+ depth: void 0
23392
+ };
23393
+ if (!response.ok || Number(response.status) !== 200) throw new WERR_INVALID_OPERATION(`Arcade transaction status response ${String(response.status)}`);
23394
+ const status = response.data.txStatus;
23395
+ if (status === "MINED" || status === "IMMUTABLE") return {
23396
+ txid,
23397
+ status: "mined",
23398
+ depth: 1
23399
+ };
23400
+ if (status === "ACCEPTED_BY_NETWORK" || status === "SEEN_ON_NETWORK" || status === "SEEN_MULTIPLE_NODES") return {
23401
+ txid,
23402
+ status: "known",
23403
+ depth: 0
23404
+ };
23405
+ const classification = classifyArcadeRejection(response.data);
23406
+ return {
23407
+ txid,
23408
+ status: "unknown",
23409
+ depth: void 0,
23410
+ providerStatus: status,
23411
+ ...classification.terminal ? {
23412
+ terminal: true,
23413
+ inputConflict: classification.inputConflict,
23414
+ statusCode: response.data.status,
23415
+ description: (response.data.extraInfo || classification.reason).slice(0, 512),
23416
+ competingTxs: (response.data.competingTxs ?? []).slice(0, 24)
23417
+ } : {}
23418
+ };
23419
+ } catch (error_) {
23420
+ firstError ??= WalletError.fromUnknown(error_);
23421
+ return {
23422
+ txid,
23423
+ status: "unknown",
23424
+ depth: void 0
23425
+ };
23426
+ }
23427
+ }));
23428
+ if (firstError != null) {
23429
+ r.status = "error";
23430
+ r.error = firstError;
23431
+ }
23432
+ return r;
23433
+ }
23434
+ /**
22465
23435
  * `getMerklePath` provider: obtain a BUMP merkle proof for a mined transaction from Arcade.
22466
23436
  *
22467
23437
  * Arcade only has a proof for transactions it tracked (i.e. broadcast through it) that have
@@ -23252,6 +24222,10 @@ var Services = class Services {
23252
24222
  name: "WhatsOnChain",
23253
24223
  service: this.whatsonchain.getStatusForTxids.bind(this.whatsonchain)
23254
24224
  });
24225
+ if (this.arcade != null) this.getStatusForTxidsServices.add({
24226
+ name: "Arcade",
24227
+ service: this.arcade.getStatusForTxids.bind(this.arcade)
24228
+ });
23255
24229
  this.getScriptHashHistoryServices = new ServiceCollection("getScriptHashHistory");
23256
24230
  if (hasWhatsOnChain) this.getScriptHashHistoryServices.add({
23257
24231
  name: "WhatsOnChain",
@@ -23268,7 +24242,7 @@ var Services = class Services {
23268
24242
  name: "GorillaPoolArcBeef",
23269
24243
  service: this.arcGorillaPool.postBeef.bind(this.arcGorillaPool)
23270
24244
  });
23271
- this.postBeefServices.add({
24245
+ if (this.options.arcUrl != null && this.options.arcUrl !== "") this.postBeefServices.add({
23272
24246
  name: "TaalArcBeef",
23273
24247
  service: this.arcTaal.postBeef.bind(this.arcTaal)
23274
24248
  });
@@ -23354,30 +24328,65 @@ var Services = class Services {
23354
24328
  async getStatusForTxids(txids, useNext) {
23355
24329
  const services = this.getStatusForTxidsServices;
23356
24330
  if (useNext === true) services.next();
23357
- let r0 = {
24331
+ let fallback = {
23358
24332
  name: "<noservices>",
23359
24333
  status: "error",
23360
24334
  error: new WERR_INTERNAL("No services available."),
23361
24335
  results: []
23362
24336
  };
24337
+ const resultsByTxid = /* @__PURE__ */ new Map();
24338
+ const unresolved = new Set(txids);
24339
+ const providerNames = [];
24340
+ let successfulProvider = false;
24341
+ const rank = (result) => {
24342
+ if (result.status === "mined") return 4;
24343
+ if (result.status === "known") return 3;
24344
+ if (result.terminal === true) return 2;
24345
+ return 1;
24346
+ };
23363
24347
  for (let tries = 0; tries < services.count; tries++) {
23364
24348
  const stc = services.serviceToCall;
23365
24349
  try {
23366
- const r = await this.getStatusForTxidsBatched(stc, txids);
24350
+ const requestedTxids = [...unresolved];
24351
+ if (requestedTxids.length === 0) break;
24352
+ const r = await this.getStatusForTxidsBatched(stc, requestedTxids);
23367
24353
  if (r.status === "success") {
23368
24354
  services.addServiceCallSuccess(stc);
23369
- r0 = r;
23370
- break;
24355
+ successfulProvider = true;
24356
+ providerNames.push(r.name);
24357
+ for (const result of r.results) {
24358
+ const current = resultsByTxid.get(result.txid);
24359
+ if (current == null || rank(result) > rank(current)) resultsByTxid.set(result.txid, result);
24360
+ if (result.status === "mined" || result.status === "known") unresolved.delete(result.txid);
24361
+ }
24362
+ services.next();
24363
+ continue;
23371
24364
  }
24365
+ fallback = r;
23372
24366
  if (r.error != null) services.addServiceCallError(stc, r.error);
23373
24367
  else services.addServiceCallFailure(stc);
23374
24368
  } catch (error_) {
23375
24369
  const e = WalletError.fromUnknown(error_);
24370
+ fallback = {
24371
+ name: stc.providerName,
24372
+ status: "error",
24373
+ error: e,
24374
+ results: []
24375
+ };
23376
24376
  services.addServiceCallError(stc, e);
23377
24377
  }
23378
24378
  services.next();
23379
24379
  }
23380
- return r0;
24380
+ if (!successfulProvider) return fallback;
24381
+ return {
24382
+ name: [...new Set(providerNames)].join(","),
24383
+ status: "success",
24384
+ results: txids.map((txid) => resultsByTxid.get(txid) ?? {
24385
+ txid,
24386
+ status: "unknown",
24387
+ depth: void 0
24388
+ })
24389
+ };
23381
24390
  }
23382
24391
  async getStatusForTxidsBatched(stc, txids) {
23383
24392
  const results = [];
@@ -23404,9 +24413,7 @@ var Services = class Services {
23404
24413
  return _bsv_sdk.Utils.toHex(sha256Hash(_bsv_sdk.Utils.toArray(script, "hex")));
23405
24414
  }
23406
24415
  async isUtxo(output) {
23407
- if (output.lockingScript == null) throw new WERR_INVALID_PARAMETER("output.lockingScript", "validated by storage provider validateOutputScript.");
23408
- const hash = this.hashOutputScript(_bsv_sdk.Utils.toHex(output.lockingScript));
23409
- return (await this.getUtxoStatus(hash, void 0, `${output.txid ?? ""}.${output.vout}`)).isUtxo === true;
24416
+ return requireConclusiveUtxo(await classifyOutputUtxo(this, output));
23410
24417
  }
23411
24418
  async getUtxoStatus(output, outputFormat, outpoint, useNext, logger) {
23412
24419
  const services = this.getUtxoStatusServices;
@@ -23632,7 +24639,15 @@ var Services = class Services {
23632
24639
  const method = async () => {
23633
24640
  return await this.options.chaintracks.currentHeight();
23634
24641
  };
23635
- return await this.invokeChaintracksWithRetry(method, "current_height");
24642
+ try {
24643
+ return await this.invokeChaintracksWithRetry(method, "current_height");
24644
+ } catch (error_) {
24645
+ try {
24646
+ return (await this.whatsonchain.getChainInfo()).blocks;
24647
+ } catch {
24648
+ throw error_;
24649
+ }
24650
+ }
23636
24651
  }
23637
24652
  async hashToHeader(hash) {
23638
24653
  const method = async () => {
@@ -27351,17 +28366,34 @@ var ArcSSEClient = class {
27351
28366
  console.log(`${TAG} connected`);
27352
28367
  });
27353
28368
  this.es.addEventListener("status", (event) => {
28369
+ let data;
27354
28370
  try {
27355
- const data = JSON.parse(event.data);
27356
- console.log(`${TAG} event: txid=${data.txid} status=${data.txStatus}`);
27357
- if (typeof event.lastEventId === "string" && event.lastEventId !== "") {
27358
- this._lastEventId = event.lastEventId;
27359
- this.options.onLastEventIdChanged?.(event.lastEventId);
27360
- }
27361
- this.options.onEvent(data);
28371
+ data = JSON.parse(event.data);
27362
28372
  } catch {
27363
28373
  console.log(`${TAG} malformed event: ${String(event.data).substring(0, 200)}`);
28374
+ return;
28375
+ }
28376
+ console.log(`${TAG} event: txid=${data.txid} status=${data.txStatus}`);
28377
+ const receivedEventId = typeof event.lastEventId === "string" && event.lastEventId !== "" ? event.lastEventId : void 0;
28378
+ data.eventId = receivedEventId;
28379
+ let processing;
28380
+ try {
28381
+ processing = this.options.onEvent(data);
28382
+ } catch (error) {
28383
+ const e = error instanceof Error ? error : new Error(String(error));
28384
+ console.log(`${TAG} event processing failed: ${e.message}`);
28385
+ this.options.onError?.(e);
28386
+ return;
27364
28387
  }
28388
+ Promise.resolve(processing).then(async () => {
28389
+ if (receivedEventId == null) return;
28390
+ await this.options.onLastEventIdChanged?.(receivedEventId);
28391
+ this._lastEventId = receivedEventId;
28392
+ }).catch((error) => {
28393
+ const e = error instanceof Error ? error : new Error(String(error));
28394
+ console.log(`${TAG} event processing failed: ${e.message}`);
28395
+ this.options.onError?.(e);
28396
+ });
27365
28397
  });
27366
28398
  this.es.addEventListener("error", (event) => {
27367
28399
  console.log(`${TAG} error:`, JSON.stringify(event));
@@ -27396,94 +28428,6 @@ var ArcSSEClient = class {
27396
28428
  }
27397
28429
  };
27398
28430
  //#endregion
27399
- //#region ../src/monitor/tasks/TaskUnFail.ts
27400
- /**
27401
- * Setting provenTxReq status to 'unfail' when 'invalid' will attempt to find a merklePath, and if successful:
27402
- *
27403
- * 1. set the req status to 'unmined'
27404
- * 2. set the referenced txs to 'unproven'
27405
- * 3. determine if any inputs match user's existing outputs and if so update spentBy and spendable of those outputs.
27406
- * 4. set the txs outputs to spendable
27407
- *
27408
- * If it fails (to find a merklePath), returns the req status to 'invalid'.
27409
- */
27410
- var TaskUnFail = class TaskUnFail extends WalletMonitorTask {
27411
- triggerMsecs;
27412
- static taskName = "UnFail";
27413
- /**
27414
- * Set to true to trigger running this task
27415
- */
27416
- static checkNowRequested = false;
27417
- static get checkNow() {
27418
- return this.checkNowRequested;
27419
- }
27420
- static set checkNow(value) {
27421
- this.checkNowRequested = value;
27422
- }
27423
- constructor(monitor, triggerMsecs = Monitor.oneMinute * 10) {
27424
- super(monitor, TaskUnFail.taskName);
27425
- this.triggerMsecs = triggerMsecs;
27426
- }
27427
- trigger(nowMsecsSinceEpoch) {
27428
- return { run: TaskUnFail.checkNow || this.triggerMsecs > 0 && nowMsecsSinceEpoch - this.lastRunMsecsSinceEpoch > this.triggerMsecs };
27429
- }
27430
- async runTask() {
27431
- let log = "";
27432
- TaskUnFail.checkNow = false;
27433
- const limit = 100;
27434
- let offset = 0;
27435
- for (;;) {
27436
- const reqs = await this.storage.findProvenTxReqs({
27437
- partial: {},
27438
- status: ["unfail"],
27439
- paged: {
27440
- limit,
27441
- offset
27442
- }
27443
- });
27444
- if (reqs.length === 0) break;
27445
- log += `${reqs.length} reqs with status 'unfail'\n`;
27446
- const r = await this.unfail(reqs, 2);
27447
- log += `${r.log}\n`;
27448
- if (reqs.length < limit) break;
27449
- offset += limit;
27450
- }
27451
- return log;
27452
- }
27453
- async unfail(reqs, indent = 0) {
27454
- let log = "";
27455
- for (const reqApi of reqs) {
27456
- const req = new EntityProvenTxReq(reqApi);
27457
- log += " ".repeat(indent);
27458
- log += `reqId ${reqApi.provenTxReqId} txid ${reqApi.txid}: `;
27459
- if ((await this.monitor.services.getMerklePath(req.txid)).merklePath != null) {
27460
- log += "unfailed. status is now 'unmined'\n";
27461
- log += await this.unfailReq(req, indent + 2);
27462
- } else {
27463
- req.status = "invalid";
27464
- log += "returned to status 'invalid'\n";
27465
- await req.updateStorageDynamicProperties(this.storage);
27466
- }
27467
- }
27468
- return { log };
27469
- }
27470
- /**
27471
- * 2. set the referenced txs to 'unproven'
27472
- * 3. determine if any inputs match user's existing outputs and if so update spentBy and spendable of those outputs.
27473
- * 4. set the txs outputs to spendable
27474
- *
27475
- * @param req
27476
- * @param indent
27477
- * @returns
27478
- */
27479
- async unfailReq(req, indent) {
27480
- return await this.storage.runAsStorageProvider(async (sp) => await sp.unfailTransactionsForProof(req, indent, {
27481
- status: "unmined",
27482
- attempts: 0
27483
- }));
27484
- }
27485
- };
27486
- //#endregion
27487
28431
  //#region ../src/monitor/tasks/TaskArcSSE.ts
27488
28432
  /**
27489
28433
  * Monitor task that receives transaction status updates from Arcade via SSE
@@ -27527,16 +28471,14 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27527
28471
  arcApiKey: arcadeApiKey,
27528
28472
  lastEventId,
27529
28473
  EventSourceClass,
27530
- onEvent: (event) => {
27531
- this.pendingEvents.push(event);
27532
- },
28474
+ onEvent: async (event) => await new Promise((acknowledge) => {
28475
+ this.pendingEvents.push({
28476
+ event,
28477
+ acknowledge
28478
+ });
28479
+ }),
27533
28480
  onError: (err) => {
27534
28481
  this.logSetupEvent(`SSE error: ${err.message}`);
27535
- },
27536
- onLastEventIdChanged: (id) => {
27537
- this.monitor.options.saveLastSSEEventId?.(id).catch((e) => {
27538
- this.logSetupEvent(`failed to persist lastEventId: ${stringifyError(e)}`);
27539
- });
27540
28482
  }
27541
28483
  });
27542
28484
  this.sseClient.connect();
@@ -27550,10 +28492,21 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27550
28492
  return { run: this.pendingEvents.length > 0 };
27551
28493
  }
27552
28494
  async runTask() {
27553
- const events = this.pendingEvents.splice(0);
27554
- if (events.length === 0) return "";
28495
+ const eventCount = this.pendingEvents.length;
28496
+ if (eventCount === 0) return "";
27555
28497
  let log = "";
27556
- for (const event of events) log += await this.processStatusEvent(event);
28498
+ for (let i = 0; i < eventCount; i++) {
28499
+ const pending = this.pendingEvents[0];
28500
+ log += await this.processStatusEvent(pending.event);
28501
+ if (pending.event.eventId != null) try {
28502
+ await this.monitor.options.saveLastSSEEventId?.(pending.event.eventId);
28503
+ } catch (e) {
28504
+ await this.logSetupEvent(`failed to persist lastEventId: ${stringifyError(e)}`);
28505
+ throw e;
28506
+ }
28507
+ this.pendingEvents.shift();
28508
+ pending.acknowledge();
28509
+ }
27557
28510
  return log;
27558
28511
  }
27559
28512
  async fetchNow() {
@@ -27569,28 +28522,26 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27569
28522
  }
27570
28523
  for (const reqApi of reqs) {
27571
28524
  const req = new EntityProvenTxReq(reqApi);
27572
- const canRecoverRejectedRequest = this.canRecoverRejectedRequest(req, event);
27573
- if (ProvenTxReqTerminalStatus.includes(req.status) && !canRecoverRejectedRequest) {
28525
+ const canRecoverTerminalRequest = this.canRecoverTerminalRequest(req, event);
28526
+ if (ProvenTxReqTerminalStatus.includes(req.status) && !canRecoverTerminalRequest) {
27574
28527
  log += ` req ${req.id} already terminal: ${req.status}\n`;
27575
28528
  continue;
27576
28529
  }
27577
28530
  const note = {
27578
28531
  when: (/* @__PURE__ */ new Date()).toISOString(),
27579
28532
  what: "arcSSE",
27580
- arcStatus: event.txStatus
28533
+ arcStatus: event.txStatus,
28534
+ ...event.timestamp !== "" ? { arcTimestamp: event.timestamp } : {},
28535
+ ...event.status != null ? { statusCode: event.status } : {},
28536
+ ...event.extraInfo != null && event.extraInfo !== "" ? { extraInfo: event.extraInfo.slice(0, 512) } : {}
27581
28537
  };
27582
28538
  log += await this.applyStatusEvent(req, event, note);
27583
28539
  }
27584
28540
  this.monitor.callOnTransactionStatusChanged(event.txid, event.txStatus);
27585
28541
  return log;
27586
28542
  }
27587
- canRecoverRejectedRequest(req, event) {
27588
- return req.status === "invalid" && ([
27589
- "SENT_TO_NETWORK",
27590
- "ACCEPTED_BY_NETWORK",
27591
- "SEEN_ON_NETWORK",
27592
- "SEEN_MULTIPLE_NODES"
27593
- ].includes(event.txStatus) || ["MINED", "IMMUTABLE"].includes(event.txStatus));
28543
+ canRecoverTerminalRequest(req, event) {
28544
+ return (req.status === "invalid" || req.status === "doubleSpend") && ["MINED", "IMMUTABLE"].includes(event.txStatus);
27594
28545
  }
27595
28546
  async applyStatusEvent(req, event, note) {
27596
28547
  switch (event.txStatus) {
@@ -27600,11 +28551,16 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27600
28551
  case "SEEN_MULTIPLE_NODES": return await this.applyAcceptedStatus(req, note);
27601
28552
  case "MINED":
27602
28553
  case "IMMUTABLE": return await this.applyMinedStatus(req, note);
27603
- case "DOUBLE_SPEND_ATTEMPTED": return await this.applyDoubleSpendStatus(req, note);
27604
- case "REJECTED":
28554
+ case "DOUBLE_SPEND_ATTEMPTED":
28555
+ case "SEEN_IN_ORPHAN_MEMPOOL": return await this.applyDoubleSpendStatus(req, event, note);
28556
+ case "REJECTED": return await this.applyRejectedStatus(req, event, note);
28557
+ case "RECEIVED":
28558
+ case "PENDING_RETRY":
28559
+ case "STUMP_PROCESSING":
28560
+ case "UNKNOWN":
27605
28561
  req.addHistoryNote(note);
27606
28562
  await req.updateStorageDynamicProperties(this.storage);
27607
- return ` req ${req.id} rejection recorded; awaiting resolution\n`;
28563
+ return ` req ${req.id} ${event.txStatus} recorded; awaiting resolution\n`;
27608
28564
  default: return ` req ${req.id} unhandled status: ${event.txStatus}\n`;
27609
28565
  }
27610
28566
  }
@@ -27612,43 +28568,73 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27612
28568
  if (![
27613
28569
  "unsent",
27614
28570
  "sending",
27615
- "callback",
27616
- "invalid"
28571
+ "callback"
27617
28572
  ].includes(req.status)) return "";
27618
- const wasInvalid = req.status === "invalid";
27619
- let log = "";
27620
- if (wasInvalid) log += await new TaskUnFail(this.monitor).unfailReq(req, 4);
27621
- else if (req.notify.transactionIds != null) await this.storage.runAsStorageProvider(async (sp) => {
28573
+ if (req.notify.transactionIds != null) await this.storage.runAsStorageProvider(async (sp) => {
27622
28574
  await sp.updateTransactionsStatus(req.notify.transactionIds ?? [], "unproven");
27623
28575
  });
27624
28576
  req.status = "unmined";
27625
- if (wasInvalid) req.attempts = 0;
27626
28577
  req.wasBroadcast = true;
27627
28578
  req.addHistoryNote(note);
27628
28579
  await req.updateStorageDynamicProperties(this.storage);
27629
- return log + ` req ${req.id} => unmined\n`;
28580
+ return ` req ${req.id} => unmined\n`;
27630
28581
  }
27631
28582
  async applyMinedStatus(req, note) {
27632
- let log = "";
27633
- if (req.status === "invalid") {
27634
- log += await new TaskUnFail(this.monitor).unfailReq(req, 4);
27635
- req.status = "unmined";
27636
- req.attempts = 0;
27637
- req.wasBroadcast = true;
27638
- }
27639
28583
  req.addHistoryNote(note);
27640
28584
  await req.updateStorageDynamicProperties(this.storage);
27641
- return log + await this.fetchProofFromServices(req);
28585
+ return await this.fetchProofFromServices(req);
27642
28586
  }
27643
- async applyDoubleSpendStatus(req, note) {
27644
- req.status = "doubleSpend";
27645
- req.addHistoryNote(note);
27646
- await req.updateStorageDynamicProperties(this.storage);
27647
- const ids = req.notify.transactionIds;
27648
- if (ids != null) await this.storage.runAsStorageProvider(async (sp) => {
27649
- await sp.updateTransactionsStatus(ids, "failed");
28587
+ async applyDoubleSpendStatus(req, event, note) {
28588
+ return await this.applyTerminalFailure(req, note, classifyArcadeRejection(event));
28589
+ }
28590
+ classifyRejection(event) {
28591
+ return classifyArcadeRejection(event);
28592
+ }
28593
+ async applyRejectedStatus(req, event, note) {
28594
+ const classification = this.classifyRejection(event);
28595
+ if (!classification.terminal) {
28596
+ req.addHistoryNote({
28597
+ ...note,
28598
+ what: "arcSSERejectionPending",
28599
+ reason: classification.reason
28600
+ });
28601
+ await req.updateStorageDynamicProperties(this.storage);
28602
+ return ` req ${req.id} rejection recorded; awaiting resolution\n`;
28603
+ }
28604
+ return await this.applyTerminalFailure(req, note, classification);
28605
+ }
28606
+ async applyTerminalFailure(req, note, classification) {
28607
+ let quarantined = {
28608
+ checked: 0,
28609
+ staleConfirmed: 0,
28610
+ staleOutpoints: []
28611
+ };
28612
+ await this.storage.runAsStorageProvider(async (sp) => {
28613
+ await sp.transaction(async (trx) => {
28614
+ req.status = classification.reqStatus;
28615
+ req.addHistoryNote({
28616
+ ...note,
28617
+ what: "arcSSETerminalRejection",
28618
+ reason: classification.reason,
28619
+ inputConflict: classification.inputConflict
28620
+ });
28621
+ await req.updateStorageDynamicProperties(sp, trx);
28622
+ const ids = req.notify.transactionIds;
28623
+ if (ids != null) await sp.updateTransactionsStatus(ids, "failed", trx);
28624
+ if (classification.inputConflict) {
28625
+ quarantined = await quarantineReqInputs(req, sp, trx);
28626
+ req.addHistoryNote({
28627
+ when: (/* @__PURE__ */ new Date()).toISOString(),
28628
+ what: "arcSSEInputQuarantine",
28629
+ checked: quarantined.checked,
28630
+ confirmed: quarantined.staleConfirmed,
28631
+ ...quarantined.staleOutpoints.length > 0 ? { outpoints: quarantined.staleOutpoints.join(",") } : {}
28632
+ });
28633
+ await req.updateStorageDynamicProperties(sp, trx);
28634
+ }
28635
+ });
27650
28636
  });
27651
- return ` req ${req.id} => doubleSpend\n`;
28637
+ return ` req ${req.id} => ${classification.reqStatus}; quarantined ${quarantined.staleConfirmed} local input copy/copies\n`;
27652
28638
  }
27653
28639
  /**
27654
28640
  * Complete a MINED/IMMUTABLE status by using the configured proof providers.
@@ -28060,9 +29046,104 @@ var TaskCheckNoSends = class TaskCheckNoSends extends WalletMonitorTask {
28060
29046
  }
28061
29047
  };
28062
29048
  //#endregion
29049
+ //#region ../src/monitor/tasks/TaskUnFail.ts
29050
+ /**
29051
+ * Setting provenTxReq status to 'unfail' when 'invalid' will attempt to find a merklePath, and if successful:
29052
+ *
29053
+ * 1. set the req status to 'unmined'
29054
+ * 2. set the referenced txs to 'unproven'
29055
+ * 3. determine if any inputs match user's existing outputs and if so update spentBy and spendable of those outputs.
29056
+ * 4. set the txs outputs to spendable
29057
+ *
29058
+ * If it fails (to find a merklePath), returns the req status to 'invalid'.
29059
+ */
29060
+ var TaskUnFail = class TaskUnFail extends WalletMonitorTask {
29061
+ triggerMsecs;
29062
+ static taskName = "UnFail";
29063
+ /**
29064
+ * Set to true to trigger running this task
29065
+ */
29066
+ static checkNowRequested = false;
29067
+ static get checkNow() {
29068
+ return this.checkNowRequested;
29069
+ }
29070
+ static set checkNow(value) {
29071
+ this.checkNowRequested = value;
29072
+ }
29073
+ constructor(monitor, triggerMsecs = Monitor.oneMinute * 10) {
29074
+ super(monitor, TaskUnFail.taskName);
29075
+ this.triggerMsecs = triggerMsecs;
29076
+ }
29077
+ trigger(nowMsecsSinceEpoch) {
29078
+ return { run: TaskUnFail.checkNow || this.triggerMsecs > 0 && nowMsecsSinceEpoch - this.lastRunMsecsSinceEpoch > this.triggerMsecs };
29079
+ }
29080
+ async runTask() {
29081
+ let log = "";
29082
+ TaskUnFail.checkNow = false;
29083
+ const limit = 100;
29084
+ let offset = 0;
29085
+ for (;;) {
29086
+ const reqs = await this.storage.findProvenTxReqs({
29087
+ partial: {},
29088
+ status: ["unfail"],
29089
+ paged: {
29090
+ limit,
29091
+ offset
29092
+ }
29093
+ });
29094
+ if (reqs.length === 0) break;
29095
+ log += `${reqs.length} reqs with status 'unfail'\n`;
29096
+ const r = await this.unfail(reqs, 2);
29097
+ log += `${r.log}\n`;
29098
+ if (reqs.length < limit) break;
29099
+ offset += limit;
29100
+ }
29101
+ return log;
29102
+ }
29103
+ async unfail(reqs, indent = 0) {
29104
+ let log = "";
29105
+ for (const reqApi of reqs) {
29106
+ const req = new EntityProvenTxReq(reqApi);
29107
+ log += " ".repeat(indent);
29108
+ log += `reqId ${reqApi.provenTxReqId} txid ${reqApi.txid}: `;
29109
+ if ((await this.monitor.services.getMerklePath(req.txid)).merklePath != null) {
29110
+ log += "unfailed. status is now 'unmined'\n";
29111
+ log += await this.unfailReq(req, indent + 2);
29112
+ } else {
29113
+ req.status = "invalid";
29114
+ log += "returned to status 'invalid'\n";
29115
+ await req.updateStorageDynamicProperties(this.storage);
29116
+ }
29117
+ }
29118
+ return { log };
29119
+ }
29120
+ /**
29121
+ * 2. set the referenced txs to 'unproven'
29122
+ * 3. determine if any inputs match user's existing outputs and if so update spentBy and spendable of those outputs.
29123
+ * 4. set the txs outputs to spendable
29124
+ *
29125
+ * @param req
29126
+ * @param indent
29127
+ * @returns
29128
+ */
29129
+ async unfailReq(req, indent) {
29130
+ return await this.storage.runAsStorageProvider(async (sp) => await sp.unfailTransactionsForProof(req, indent, {
29131
+ status: "unmined",
29132
+ attempts: 0
29133
+ }));
29134
+ }
29135
+ };
29136
+ //#endregion
28063
29137
  //#region ../src/monitor/tasks/TaskReviewUtxos.ts
29138
+ const REVIEW_PAGE_DEFAULT_LIMIT = 20;
29139
+ const REVIEW_PAGE_MAX_LIMIT = 250;
28064
29140
  /**
28065
- * Use the reviewByIdentityKey method to review the utxos of a specific user by their identityKey.
29141
+ * Use the reviewByIdentityKey method to scan the UTXOs of a specific user by
29142
+ * identity key. The scan is read-only unless the caller explicitly requests
29143
+ * release, and release remains blocked if any provider result is inconclusive.
29144
+ * Operator UIs should use reviewPageByIdentityKey: it bounds each provider
29145
+ * round-trip and may explicitly release only the conclusive spent subset while
29146
+ * reporting unknowns.
28066
29147
  *
28067
29148
  * The task itself is disabled and will not run on a schedule; review must be triggered manually by calling reviewByIdentityKey.
28068
29149
  */
@@ -28079,7 +29160,7 @@ var TaskReviewUtxos = class TaskReviewUtxos extends WalletMonitorTask {
28079
29160
  static set checkNow(value) {
28080
29161
  this.checkNowRequested = value;
28081
29162
  }
28082
- constructor(monitor, triggerMsecs = 0, userLimit = 10, userOffset = 0, tags = ["release", "all"]) {
29163
+ constructor(monitor, triggerMsecs = 0, userLimit = 10, userOffset = 0, tags = ["all"]) {
28083
29164
  super(monitor, TaskReviewUtxos.taskName);
28084
29165
  this.triggerMsecs = triggerMsecs;
28085
29166
  this.userLimit = userLimit;
@@ -28093,8 +29174,8 @@ var TaskReviewUtxos = class TaskReviewUtxos extends WalletMonitorTask {
28093
29174
  TaskReviewUtxos.checkNow = false;
28094
29175
  return "TaskReviewUtxos is disabled; use reviewByIdentityKey instead.\n";
28095
29176
  }
28096
- async reviewByIdentityKey(identityKey, mode = "all") {
28097
- const tags = ["release", ...mode === "all" ? ["all"] : []];
29177
+ async reviewByIdentityKey(identityKey, mode = "all", release = false) {
29178
+ const tags = [...release ? ["release"] : [], ...mode === "all" ? ["all"] : []];
28098
29179
  const vargs = {
28099
29180
  basket: specOpInvalidChange,
28100
29181
  tags,
@@ -28122,8 +29203,150 @@ var TaskReviewUtxos = class TaskReviewUtxos extends WalletMonitorTask {
28122
29203
  return this.toUserLog(user, result.outputs, result.totalOutputs, total, tags);
28123
29204
  });
28124
29205
  }
29206
+ async reviewPageByIdentityKey(identityKey, mode = "all", release = false, pageLimit = REVIEW_PAGE_DEFAULT_LIMIT, offset = 0) {
29207
+ pageLimit = Math.min(Math.max(Math.trunc(pageLimit), 1), REVIEW_PAGE_MAX_LIMIT);
29208
+ offset = Math.max(Math.trunc(offset), 0);
29209
+ return await this.storage.runAsStorageProvider(async (sp) => {
29210
+ const user = (await sp.findUsers({ partial: { identityKey } }))[0];
29211
+ if (!user) return {
29212
+ found: false,
29213
+ identityKey,
29214
+ mode,
29215
+ release,
29216
+ offset,
29217
+ pageLimit,
29218
+ sourceScanned: 0,
29219
+ complete: true,
29220
+ checked: 0,
29221
+ confirmedUnspent: 0,
29222
+ confirmedSpent: 0,
29223
+ unknown: 0,
29224
+ confirmedSpentSatoshis: 0,
29225
+ released: 0,
29226
+ releasedSatoshis: 0,
29227
+ providers: [],
29228
+ providerCount: 0,
29229
+ providersTruncated: false,
29230
+ log: `identityKey ${identityKey} was not found\n`
29231
+ };
29232
+ let basketId;
29233
+ if (mode === "change") {
29234
+ basketId = (await sp.findOutputBaskets({ partial: {
29235
+ userId: user.userId,
29236
+ name: "default"
29237
+ } }))[0]?.basketId;
29238
+ if (basketId == null) return this.emptyPage(user, mode, release, pageLimit, offset);
29239
+ }
29240
+ const sourceOutputs = await sp.findOutputs({
29241
+ partial: {
29242
+ userId: user.userId,
29243
+ spendable: true,
29244
+ ...basketId != null ? { basketId } : {}
29245
+ },
29246
+ txStatus: [
29247
+ "completed",
29248
+ "unproven",
29249
+ "nosend",
29250
+ "sending"
29251
+ ],
29252
+ noScript: true,
29253
+ paged: {
29254
+ limit: pageLimit,
29255
+ offset
29256
+ }
29257
+ });
29258
+ const candidates = sourceOutputs.filter((output) => output.basketId != null);
29259
+ const review = await reviewUtxoOutputs(sp, {
29260
+ userId: user.userId,
29261
+ identityKey: user.identityKey
29262
+ }, candidates, release ? "conclusive" : "none");
29263
+ const complete = sourceOutputs.length < pageLimit;
29264
+ const nextOffset = complete ? void 0 : offset + sourceOutputs.length - review.diagnostics.released;
29265
+ const target = mode === "all" ? "spendable utxos" : "spendable change utxos";
29266
+ const action = release ? "released" : "found";
29267
+ let log = `userId ${user.userId}: page checked ${review.diagnostics.checked} ${target}; ${review.diagnostics.confirmedSpent} confirmed spent, ${review.diagnostics.confirmedUnspent} confirmed unspent, ${review.diagnostics.unknown} unknown; ${action} ${review.diagnostics.released}, ${user.identityKey}\n`;
29268
+ for (const output of review.confirmedSpentOutputs) log += ` ${output.txid}.${output.vout} ${output.satoshis} now ${output.spendable ? "spendable" : "spent"}\n`;
29269
+ if (review.unknownOutputs.length > 0) log += ` ${review.unknownOutputs.length} output(s) quarantined from release pending a conclusive provider result\n`;
29270
+ if (nextOffset != null) log += ` continue at offset ${nextOffset}\n`;
29271
+ return {
29272
+ found: true,
29273
+ userId: user.userId,
29274
+ identityKey: user.identityKey,
29275
+ mode,
29276
+ release,
29277
+ offset,
29278
+ pageLimit,
29279
+ sourceScanned: sourceOutputs.length,
29280
+ complete,
29281
+ ...nextOffset != null ? { nextOffset } : {},
29282
+ ...review.diagnostics,
29283
+ log
29284
+ };
29285
+ });
29286
+ }
29287
+ emptyPage(user, mode, release, pageLimit, offset) {
29288
+ return {
29289
+ found: true,
29290
+ userId: user.userId,
29291
+ identityKey: user.identityKey,
29292
+ mode,
29293
+ release,
29294
+ offset,
29295
+ pageLimit,
29296
+ sourceScanned: 0,
29297
+ complete: true,
29298
+ checked: 0,
29299
+ confirmedUnspent: 0,
29300
+ confirmedSpent: 0,
29301
+ unknown: 0,
29302
+ confirmedSpentSatoshis: 0,
29303
+ released: 0,
29304
+ releasedSatoshis: 0,
29305
+ providers: [],
29306
+ providerCount: 0,
29307
+ providersTruncated: false,
29308
+ log: `userId ${user.userId}: no invalid utxos found, ${user.identityKey}\n`
29309
+ };
29310
+ }
29311
+ /**
29312
+ * Report managed-change liquidity without changing it. Monitor deliberately
29313
+ * has no signing authority; progressive migration occurs only during a
29314
+ * caller-authorized createAction.
29315
+ */
29316
+ async reviewManagedChangeByIdentityKey(identityKey) {
29317
+ return await this.storage.runAsStorageProvider(async (sp) => {
29318
+ const user = (await sp.findUsers({ partial: { identityKey } }))[0];
29319
+ if (user == null) return `identityKey ${identityKey} was not found\n`;
29320
+ const basket = verifyOne(await sp.findOutputBaskets({ partial: {
29321
+ userId: user.userId,
29322
+ name: "default"
29323
+ } }));
29324
+ const outputs = (await sp.findOutputs({
29325
+ partial: {
29326
+ userId: user.userId,
29327
+ basketId: basket.basketId,
29328
+ spendable: true,
29329
+ ...managedChangeOutputFields
29330
+ },
29331
+ txStatus: [
29332
+ "completed",
29333
+ "unproven",
29334
+ "sending"
29335
+ ],
29336
+ noScript: true
29337
+ })).filter(isAutoSpendableChangeOutput);
29338
+ const reserved = new Set(await sp.findReservedActionBatchOutputIds(outputs.map((output) => output.outputId)));
29339
+ const statuses = await sp.findTransactionStatusesByIds(user.userId, outputs.map((output) => output.transactionId));
29340
+ const preferred = Math.max(1, basket.minimumDesiredUTXOValue);
29341
+ const healthy = outputs.filter((output) => output.satoshis >= preferred);
29342
+ const undersized = outputs.filter((output) => output.satoshis < preferred);
29343
+ const countStatus = (status) => outputs.filter((output) => statuses.get(output.transactionId) === status).length;
29344
+ const satoshis = outputs.reduce((sum, output) => sum + output.satoshis, 0);
29345
+ return `userId ${user.userId}: managed change ${outputs.length}/${basket.numberOfDesiredUTXOs}, healthy ${healthy.length}, undersized ${undersized.length}, reserved ${reserved.size}, completed ${countStatus("completed")}, unproven ${countStatus("unproven")}, sending ${countStatus("sending")}, satoshis ${satoshis}, preferred minimum ${preferred}\n`;
29346
+ });
29347
+ }
28125
29348
  toUserLog(user, outputs, totalOutputs, total, tags) {
28126
- const action = tags.includes("release") ? "updated to unspendable" : "found";
29349
+ const action = tags.includes("release") ? "confirmed spent and updated to unspendable" : "confirmed spent";
28127
29350
  const target = tags.includes("all") ? "spendable utxos" : "spendable change utxos";
28128
29351
  let log = `userId ${user.userId}: ${totalOutputs} ${target} ${action}, total ${total}, ${user.identityKey}\n`;
28129
29352
  for (const output of outputs) log += ` ${output.outpoint} ${output.satoshis} now ${output.spendable ? "spendable" : "spent"}\n`;
@@ -28132,6 +29355,13 @@ var TaskReviewUtxos = class TaskReviewUtxos extends WalletMonitorTask {
28132
29355
  };
28133
29356
  //#endregion
28134
29357
  //#region ../src/monitor/tasks/TaskReviewDoubleSpends.ts
29358
+ function hasDurableInputConflict(req) {
29359
+ try {
29360
+ return JSON.parse(req.history).notes?.some((note) => note.inputConflict === true || note.what === "arcSSEInputQuarantine") === true;
29361
+ } catch {
29362
+ return true;
29363
+ }
29364
+ }
28135
29365
  /**
28136
29366
  * Review recent reqs in terminal 'doubleSpend' state and move any false positives
28137
29367
  * back to 'unfail' so existing recovery handling can re-process them.
@@ -28199,14 +29429,16 @@ var TaskReviewDoubleSpends = class TaskReviewDoubleSpends extends WalletMonitorT
28199
29429
  let lastRetainedDoubleSpendIndex = -1;
28200
29430
  let retainedDoubleSpendCount = 0;
28201
29431
  for (const req of reqs) {
28202
- const status = (await this.monitor.services.getStatusForTxids([req.txid])).results[0]?.status;
29432
+ const gsr = await this.monitor.services.getStatusForTxids([req.txid]);
29433
+ const status = gsr.status === "success" ? gsr.results[0]?.status : void 0;
29434
+ const durableInputConflict = hasDurableInputConflict(req);
28203
29435
  reviewed.push(req);
28204
- if (status === "unknown") {
29436
+ if (!(status === "mined" || status === "known" && !durableInputConflict)) {
28205
29437
  lastRetainedDoubleSpendIndex = reviewed.length - 1;
28206
29438
  retainedDoubleSpendCount += 1;
28207
29439
  } else {
28208
29440
  unfails.push(req.provenTxReqId);
28209
- log += `unfail ${req.provenTxReqId} ${req.txid} status:${status}\n`;
29441
+ log += `unfail ${req.provenTxReqId} ${req.txid} status:${status} durableInputConflict:${String(durableInputConflict)}\n`;
28210
29442
  }
28211
29443
  }
28212
29444
  if (unfails.length > 0) await this.storage.runAsStorageProvider(async (sp) => {
@@ -28245,6 +29477,206 @@ var TaskReviewDoubleSpends = class TaskReviewDoubleSpends extends WalletMonitorT
28245
29477
  }
28246
29478
  };
28247
29479
  //#endregion
29480
+ //#region ../src/monitor/tasks/TaskReconcilePendingTransactions.ts
29481
+ const REVIEW_STATUSES = [
29482
+ "callback",
29483
+ "unmined",
29484
+ "sending",
29485
+ "unknown",
29486
+ "unconfirmed"
29487
+ ];
29488
+ /**
29489
+ * Poll durable transaction lifecycle state for aged, non-terminal requests.
29490
+ * This closes the gap where an Arcade rejection event was missed before the
29491
+ * monitor subscribed or while it was offline. Unknown/provider-error results
29492
+ * never mutate storage; only a provider's explicit terminal verdict does.
29493
+ */
29494
+ var TaskReconcilePendingTransactions = class TaskReconcilePendingTransactions extends WalletMonitorTask {
29495
+ triggerMsecs;
29496
+ reviewLimit;
29497
+ minAgeMinutes;
29498
+ triggerQuickMsecs;
29499
+ static taskName = "ReconcilePendingTransactions";
29500
+ triggerNextMsecs;
29501
+ constructor(monitor, triggerMsecs = Monitor.oneMinute * 12, reviewLimit = 100, minAgeMinutes = 60, triggerQuickMsecs = Monitor.oneMinute) {
29502
+ super(monitor, TaskReconcilePendingTransactions.taskName);
29503
+ this.triggerMsecs = triggerMsecs;
29504
+ this.reviewLimit = reviewLimit;
29505
+ this.minAgeMinutes = minAgeMinutes;
29506
+ this.triggerQuickMsecs = triggerQuickMsecs;
29507
+ this.triggerNextMsecs = this.triggerQuickMsecs;
29508
+ }
29509
+ trigger(nowMsecsSinceEpoch) {
29510
+ return { run: this.triggerNextMsecs > 0 && nowMsecsSinceEpoch - this.lastRunMsecsSinceEpoch > this.triggerNextMsecs };
29511
+ }
29512
+ async getCheckpoint() {
29513
+ let events = [];
29514
+ await this.storage.runAsStorageProvider(async (sp) => {
29515
+ events = await sp.findMonitorEvents({
29516
+ partial: { event: TaskReconcilePendingTransactions.taskName },
29517
+ orderDescending: true,
29518
+ paged: { limit: 5 }
29519
+ });
29520
+ });
29521
+ for (const event of events) {
29522
+ if (!event.details) continue;
29523
+ try {
29524
+ const parsed = JSON.parse(event.details);
29525
+ if (parsed.cycleComplete === true) return void 0;
29526
+ if (typeof parsed.resumeOffset === "number") return {
29527
+ resumeOffset: parsed.resumeOffset,
29528
+ expectedProvenTxReqId: parsed.expectedProvenTxReqId
29529
+ };
29530
+ } catch {
29531
+ continue;
29532
+ }
29533
+ }
29534
+ }
29535
+ async runTask() {
29536
+ const checkpoint = await this.getCheckpoint();
29537
+ const createdBefore = /* @__PURE__ */ new Date(Date.now() - this.minAgeMinutes * 60 * 1e3);
29538
+ const reqs = await this.findReqsToReview(checkpoint);
29539
+ const eligible = reqs.filter((req) => req.created_at <= createdBefore);
29540
+ const provider = eligible.length === 0 ? void 0 : await this.monitor.services.getStatusForTxids(eligible.map((req) => req.txid));
29541
+ const results = new Map(provider?.results.map((result) => [result.txid, result]) ?? []);
29542
+ let reconciled = 0;
29543
+ let inputConflicts = 0;
29544
+ let reviewLog = "";
29545
+ const retained = [];
29546
+ for (const req of reqs) {
29547
+ const result = results.get(req.txid);
29548
+ if (req.created_at > createdBefore) {
29549
+ retained.push(req);
29550
+ continue;
29551
+ }
29552
+ const applied = await this.applyTerminalVerdict(req, provider?.status === "success" ? result : void 0, provider?.status === "success" ? provider.name : void 0, createdBefore);
29553
+ if (!applied) {
29554
+ retained.push(req);
29555
+ continue;
29556
+ }
29557
+ reconciled++;
29558
+ if (applied.result.inputConflict === true) inputConflicts++;
29559
+ reviewLog += `reconciled ${req.provenTxReqId} ${req.txid} ${applied.result.providerStatus ?? "terminal"} => ${applied.result.inputConflict === true ? "doubleSpend" : "invalid"}\n`;
29560
+ }
29561
+ const fullPage = reqs.length >= this.reviewLimit;
29562
+ this.triggerNextMsecs = fullPage ? this.triggerQuickMsecs : this.triggerMsecs;
29563
+ let resumeOffset;
29564
+ let expectedProvenTxReqId;
29565
+ if (fullPage) if (retained.length === 0) resumeOffset = reqs.sourceOffset ?? 0;
29566
+ else {
29567
+ resumeOffset = (reqs.sourceOffset ?? 0) + retained.length - 1;
29568
+ expectedProvenTxReqId = retained.at(-1)?.provenTxReqId;
29569
+ }
29570
+ return JSON.stringify({
29571
+ reviewed: eligible.length,
29572
+ reconciled,
29573
+ inputConflicts,
29574
+ retained: retained.length,
29575
+ reviewLimit: this.reviewLimit,
29576
+ minAgeMinutes: this.minAgeMinutes,
29577
+ cycleComplete: !fullPage,
29578
+ ...resumeOffset != null ? { resumeOffset } : {},
29579
+ ...expectedProvenTxReqId != null ? { expectedProvenTxReqId } : {},
29580
+ reviewLog
29581
+ });
29582
+ }
29583
+ async findReqsToReview(checkpoint) {
29584
+ let offset = checkpoint?.resumeOffset ?? 0;
29585
+ if (checkpoint?.expectedProvenTxReqId != null) offset = (await this.storage.findProvenTxReqs({
29586
+ partial: {},
29587
+ status: [...REVIEW_STATUSES],
29588
+ paged: {
29589
+ limit: 1,
29590
+ offset
29591
+ }
29592
+ }))[0]?.provenTxReqId === checkpoint.expectedProvenTxReqId ? offset + 1 : 0;
29593
+ const reqs = await this.storage.findProvenTxReqs({
29594
+ partial: {},
29595
+ status: [...REVIEW_STATUSES],
29596
+ paged: {
29597
+ limit: this.reviewLimit,
29598
+ offset
29599
+ }
29600
+ });
29601
+ reqs.sourceOffset = offset;
29602
+ return reqs;
29603
+ }
29604
+ async applyTerminalVerdict(reqApi, providerResult, providerName, createdBefore) {
29605
+ let applied;
29606
+ await this.storage.runAsStorageProvider(async (sp) => {
29607
+ await sp.transaction(async (trx) => {
29608
+ const req = new EntityProvenTxReq(reqApi);
29609
+ await req.refreshFromStorage(sp, trx);
29610
+ if (!REVIEW_STATUSES.includes(req.status)) return;
29611
+ if (req.created_at > createdBefore) return;
29612
+ const failedParentTxids = await this.findFailedLocalParentTxids(req, sp, trx);
29613
+ const result = providerResult?.terminal === true ? providerResult : providerResult?.status === "known" || providerResult?.status === "mined" ? void 0 : this.failedParentVerdict(req.txid, failedParentTxids);
29614
+ if (result == null) return;
29615
+ const provider = providerResult?.terminal === true ? providerName ?? "transaction-status-provider" : "local-storage";
29616
+ req.status = result.inputConflict === true ? "doubleSpend" : "invalid";
29617
+ req.addHistoryNote({
29618
+ when: (/* @__PURE__ */ new Date()).toISOString(),
29619
+ what: "proactiveTerminalReconciliation",
29620
+ provider,
29621
+ providerStatus: result.providerStatus,
29622
+ statusCode: result.statusCode,
29623
+ description: result.description,
29624
+ inputConflict: result.inputConflict === true,
29625
+ competingTxs: result.competingTxs?.join(",")
29626
+ });
29627
+ await req.updateStorageDynamicProperties(sp, trx);
29628
+ if (req.notify.transactionIds != null) await sp.updateTransactionsStatus(req.notify.transactionIds, "failed", trx);
29629
+ if (result.inputConflict === true) {
29630
+ const quarantined = await quarantineReqInputs(req, sp, trx);
29631
+ req.addHistoryNote({
29632
+ when: (/* @__PURE__ */ new Date()).toISOString(),
29633
+ what: "proactiveInputQuarantine",
29634
+ checked: quarantined.checked,
29635
+ confirmed: quarantined.staleConfirmed,
29636
+ ...quarantined.staleOutpoints.length > 0 ? { outpoints: quarantined.staleOutpoints.join(",") } : {}
29637
+ });
29638
+ await req.updateStorageDynamicProperties(sp, trx);
29639
+ } else if (failedParentTxids.length > 0) {
29640
+ const quarantined = await quarantineReqInputsFromFailedParents(req, failedParentTxids, sp, trx);
29641
+ req.addHistoryNote({
29642
+ when: (/* @__PURE__ */ new Date()).toISOString(),
29643
+ what: "proactiveFailedParentQuarantine",
29644
+ checked: quarantined.checked,
29645
+ confirmed: quarantined.staleConfirmed,
29646
+ parentTxids: failedParentTxids.join(","),
29647
+ ...quarantined.staleOutpoints.length > 0 ? { outpoints: quarantined.staleOutpoints.join(",") } : {}
29648
+ });
29649
+ await req.updateStorageDynamicProperties(sp, trx);
29650
+ }
29651
+ applied = { result };
29652
+ });
29653
+ });
29654
+ if (applied) this.monitor.callOnTransactionStatusChanged(reqApi.txid, applied.result.providerStatus ?? "REJECTED");
29655
+ return applied;
29656
+ }
29657
+ async findFailedLocalParentTxids(req, storage, trx) {
29658
+ const parentTxids = [...new Set(_bsv_sdk.Transaction.fromBinary(req.rawTx).inputs.map((input) => input.sourceTXID).filter((txid) => txid != null && txid !== ""))];
29659
+ const failed = [];
29660
+ for (const txid of parentTxids) if ((await storage.findProvenTxReqs({
29661
+ partial: { txid },
29662
+ trx
29663
+ })).some((parent) => parent.status === "invalid" || parent.status === "doubleSpend")) failed.push(txid);
29664
+ return failed;
29665
+ }
29666
+ failedParentVerdict(txid, failedParentTxids) {
29667
+ if (failedParentTxids.length === 0) return void 0;
29668
+ return {
29669
+ txid,
29670
+ status: "unknown",
29671
+ depth: void 0,
29672
+ terminal: true,
29673
+ inputConflict: false,
29674
+ providerStatus: "LOCAL_PARENT_FAILED",
29675
+ description: `Local parent request is terminal: ${failedParentTxids.join(",")}`.slice(0, 512)
29676
+ };
29677
+ }
29678
+ };
29679
+ //#endregion
28248
29680
  //#region ../src/monitor/tasks/TaskReviewProvenTxs.ts
28249
29681
  /**
28250
29682
  * Backup verification task for recent proven_txs records.
@@ -28503,14 +29935,14 @@ var Monitor = class Monitor {
28503
29935
  purgeFailedAge: 5 * Monitor.oneDay
28504
29936
  };
28505
29937
  addAllTasksToOther() {
28506
- this._otherTasks.push(new TaskClock(this), new TaskNewHeader(this), new TaskMonitorCallHistory(this), new TaskSendWaiting(this), new TaskCheckForProofs(this), new TaskCheckNoSends(this), new TaskFailAbandoned(this), new TaskUnFail(this), new TaskReviewStatus(this), new TaskReorg(this), new TaskReviewUtxos(this), new TaskReviewDoubleSpends(this), new TaskReviewProvenTxs(this), new TaskCleanupActionBatches(this), new TaskPurge(this, this.defaultPurgeParams));
29938
+ this._otherTasks.push(new TaskClock(this), new TaskNewHeader(this), new TaskMonitorCallHistory(this), new TaskSendWaiting(this), new TaskCheckForProofs(this), new TaskCheckNoSends(this), new TaskFailAbandoned(this), new TaskUnFail(this), new TaskReviewStatus(this), new TaskReorg(this), new TaskReviewUtxos(this), new TaskReviewDoubleSpends(this), new TaskReconcilePendingTransactions(this), new TaskReviewProvenTxs(this), new TaskCleanupActionBatches(this), new TaskPurge(this, this.defaultPurgeParams));
28507
29939
  if (this.chain === "mock") this._otherTasks.push(new TaskMineBlock(this));
28508
29940
  }
28509
29941
  /**
28510
29942
  * Default tasks with settings appropriate for a single user storage
28511
29943
  */
28512
29944
  addDefaultTasks() {
28513
- this._tasks.push(new TaskClock(this), new TaskNewHeader(this), new TaskMonitorCallHistory(this), new TaskSendWaiting(this, 8 * Monitor.oneSecond, 7 * Monitor.oneSecond), new TaskCheckForProofs(this, 2 * Monitor.oneHour), new TaskCheckNoSends(this), new TaskFailAbandoned(this, 8 * Monitor.oneMinute), new TaskUnFail(this), new TaskReviewStatus(this), new TaskReorg(this), new TaskReviewDoubleSpends(this), new TaskReviewProvenTxs(this), new TaskCleanupActionBatches(this), new TaskArcadeSSE(this));
29945
+ this._tasks.push(new TaskClock(this), new TaskNewHeader(this), new TaskMonitorCallHistory(this), new TaskSendWaiting(this, 8 * Monitor.oneSecond, 7 * Monitor.oneSecond), new TaskCheckForProofs(this, 2 * Monitor.oneHour), new TaskCheckNoSends(this), new TaskFailAbandoned(this, 8 * Monitor.oneMinute), new TaskUnFail(this), new TaskReviewStatus(this), new TaskReorg(this), new TaskReviewDoubleSpends(this), new TaskReconcilePendingTransactions(this), new TaskReviewProvenTxs(this), new TaskCleanupActionBatches(this), new TaskArcadeSSE(this));
28514
29946
  this._otherTasks.push(new TaskPurge(this, this.defaultPurgeParams, 6 * Monitor.oneHour), new TaskReviewUtxos(this));
28515
29947
  if (this.chain === "mock") this._tasks.push(new TaskMineBlock(this));
28516
29948
  }
@@ -28518,7 +29950,7 @@ var Monitor = class Monitor {
28518
29950
  * Tasks appropriate for multi-user storage
28519
29951
  */
28520
29952
  addMultiUserTasks() {
28521
- this._tasks.push(new TaskClock(this), new TaskNewHeader(this), new TaskMonitorCallHistory(this), new TaskSendWaiting(this, 8 * Monitor.oneSecond, 7 * Monitor.oneSecond), new TaskCheckForProofs(this, 2 * Monitor.oneHour), new TaskCheckNoSends(this), new TaskFailAbandoned(this, 8 * Monitor.oneMinute), new TaskUnFail(this), new TaskReviewStatus(this), new TaskReorg(this), new TaskReviewDoubleSpends(this), new TaskReviewProvenTxs(this), new TaskCleanupActionBatches(this), new TaskArcadeSSE(this));
29953
+ this._tasks.push(new TaskClock(this), new TaskNewHeader(this), new TaskMonitorCallHistory(this), new TaskSendWaiting(this, 8 * Monitor.oneSecond, 7 * Monitor.oneSecond), new TaskCheckForProofs(this, 2 * Monitor.oneHour), new TaskCheckNoSends(this), new TaskFailAbandoned(this, 8 * Monitor.oneMinute), new TaskUnFail(this), new TaskReviewStatus(this), new TaskReorg(this), new TaskReviewDoubleSpends(this), new TaskReconcilePendingTransactions(this), new TaskReviewProvenTxs(this), new TaskCleanupActionBatches(this), new TaskArcadeSSE(this));
28522
29954
  this._otherTasks.push(new TaskPurge(this, this.defaultPurgeParams), new TaskReviewUtxos(this));
28523
29955
  if (this.chain === "mock") this._tasks.push(new TaskMineBlock(this));
28524
29956
  }
@@ -28889,6 +30321,7 @@ var SetupClient = class SetupClient {
28889
30321
  model: "sat/kb",
28890
30322
  value: 100
28891
30323
  },
30324
+ managedChangePolicy: args.managedChangePolicy,
28892
30325
  scriptVerifier: args.scriptVerifier
28893
30326
  });
28894
30327
  await storage.migrate(args.databaseName, randomBytesHex(33));
@@ -33478,7 +34911,7 @@ var WalletPermissionsManager = class WalletPermissionsManager {
33478
34911
  basket: basketName,
33479
34912
  tags
33480
34913
  }],
33481
- options: { acceptDelayedBroadcast: false }
34914
+ options: { acceptDelayedBroadcast: true }
33482
34915
  }, this.adminOriginator);
33483
34916
  }
33484
34917
  async mapWithConcurrency(items, concurrency, fn) {
@@ -33542,7 +34975,7 @@ var WalletPermissionsManager = class WalletPermissionsManager {
33542
34975
  await this.createAction({
33543
34976
  description: `Grant ${built.length} permissions`,
33544
34977
  outputs: built.map((b) => b.output),
33545
- options: { acceptDelayedBroadcast: false }
34978
+ options: { acceptDelayedBroadcast: true }
33546
34979
  }, this.adminOriginator);
33547
34980
  return built.map((b) => b.request);
33548
34981
  }, strict);
@@ -34939,6 +36372,7 @@ exports.ARGON2ID_MAX_MEMORY_KIB = ARGON2ID_MAX_MEMORY_KIB;
34939
36372
  exports.ARGON2ID_MAX_PARALLELISM = ARGON2ID_MAX_PARALLELISM;
34940
36373
  exports.AuthMethodInteractor = AuthMethodInteractor;
34941
36374
  exports.BHServiceClient = BHServiceClient;
36375
+ exports.BRC153_REFERENCE_PREFIX = BRC153_REFERENCE_PREFIX;
34942
36376
  exports.BulkFileDataManager = BulkFileDataManager;
34943
36377
  exports.BulkFileDataReader = BulkFileDataReader;
34944
36378
  exports.BulkFilesReader = BulkFilesReader;
@@ -34959,6 +36393,11 @@ exports.ChaintracksServiceClient = ChaintracksServiceClient;
34959
36393
  exports.ChaintracksStorageBase = ChaintracksStorageBase;
34960
36394
  exports.ChaintracksStorageIdb = ChaintracksStorageIdb;
34961
36395
  exports.ChaintracksStorageNoDb = ChaintracksStorageNoDb;
36396
+ exports.DEFAULT_MANAGED_CHANGE_MAX_OUTPUTS_PER_ACTION = DEFAULT_MANAGED_CHANGE_MAX_OUTPUTS_PER_ACTION;
36397
+ exports.DEFAULT_MANAGED_CHANGE_MIGRATION_INPUTS_PER_ACTION = DEFAULT_MANAGED_CHANGE_MIGRATION_INPUTS_PER_ACTION;
36398
+ exports.DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS = DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS;
36399
+ exports.DEFAULT_MANAGED_CHANGE_PENDING_COMPARISON_INPUTS = DEFAULT_MANAGED_CHANGE_PENDING_COMPARISON_INPUTS;
36400
+ exports.DEFAULT_MANAGED_CHANGE_TARGET_UTXOS = DEFAULT_MANAGED_CHANGE_TARGET_UTXOS;
34962
36401
  exports.DEFAULT_PROFILE_ID = DEFAULT_PROFILE_ID;
34963
36402
  exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
34964
36403
  exports.DevConsoleInteractor = DevConsoleInteractor;
@@ -34980,6 +36419,7 @@ exports.EntityUser = EntityUser;
34980
36419
  exports.GoChaintracksServiceClient = GoChaintracksServiceClient;
34981
36420
  exports.HeightRange = HeightRange;
34982
36421
  exports.KDF_MAX_HASH_LENGTH = KDF_MAX_HASH_LENGTH;
36422
+ exports.LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS = LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS;
34983
36423
  exports.LiveIngestorBase = LiveIngestorBase;
34984
36424
  exports.LiveIngestorChaintracksSSE = LiveIngestorChaintracksSSE;
34985
36425
  exports.LiveIngestorWhatsOnChainPoll = LiveIngestorWhatsOnChainPoll;
@@ -35014,6 +36454,7 @@ exports.WalletSettingsManager = WalletSettingsManager;
35014
36454
  exports.WalletSigner = WalletSigner;
35015
36455
  exports.WalletStorageManager = WalletStorageManager;
35016
36456
  exports.WhatsOnChainServices = WhatsOnChainServices;
36457
+ exports.applyBrc153ReferenceLabel = applyBrc153ReferenceLabel;
35017
36458
  exports.arcDefaultUrl = arcDefaultUrl;
35018
36459
  exports.arcGorillaPoolUrl = arcGorillaPoolUrl;
35019
36460
  exports.arcadeDefaultUrl = arcadeDefaultUrl;
@@ -35039,6 +36480,7 @@ exports.createIdbChaintracks = createIdbChaintracks;
35039
36480
  exports.createNoDbChaintracks = createNoDbChaintracks;
35040
36481
  exports.createSyncMap = createSyncMap;
35041
36482
  exports.decryptBRC39 = decryptBRC39;
36483
+ exports.defaultManagedChangePolicy = defaultManagedChangePolicy;
35042
36484
  exports.doubleSha256BE = doubleSha256BE;
35043
36485
  exports.doubleSha256LE = doubleSha256LE;
35044
36486
  exports.encryptBRC39 = encryptBRC39;
@@ -35053,6 +36495,8 @@ exports.importBRC39 = importBRC39;
35053
36495
  exports.isAutoSpendableChangeOutput = isAutoSpendableChangeOutput;
35054
36496
  exports.isBaseBlockHeader = isBaseBlockHeader;
35055
36497
  exports.isBlockHeader = isBlockHeader;
36498
+ exports.isBrc153ReferenceLabel = isBrc153ReferenceLabel;
36499
+ exports.isLegacyManagedChangeBasketDefault = isLegacyManagedChangeBasketDefault;
35056
36500
  exports.isLive = isLive;
35057
36501
  exports.isLiveBlockHeader = isLiveBlockHeader;
35058
36502
  exports.isManagedChangeOutput = isManagedChangeOutput;
@@ -35061,12 +36505,14 @@ exports.logWalletError = logWalletError;
35061
36505
  exports.logger = logger;
35062
36506
  exports.makeAtomicBeef = makeAtomicBeef;
35063
36507
  exports.makeBrc114ActionTimeLabel = makeBrc114ActionTimeLabel;
36508
+ exports.makeBrc153ReferenceLabel = makeBrc153ReferenceLabel;
35064
36509
  exports.managedChangeOutputFields = managedChangeOutputFields;
35065
36510
  exports.maxDate = maxDate;
35066
36511
  exports.optionalArraysEqual = optionalArraysEqual;
35067
36512
  exports.outputColumnsWithoutLockingScript = outputColumnsWithoutLockingScript;
35068
36513
  exports.parseBRC38Json = parseBRC38Json;
35069
36514
  exports.parseBrc114ActionTimeLabels = parseBrc114ActionTimeLabels;
36515
+ exports.parseBrc153ReferenceLabel = parseBrc153ReferenceLabel;
35070
36516
  exports.parseFileLink = parseFileLink;
35071
36517
  exports.parseTxScriptOffsets = parseTxScriptOffsets;
35072
36518
  exports.partitionActionLabels = partitionActionLabels;
@@ -35092,12 +36538,14 @@ exports.toDefaultChaintracksArguments = toDefaultChaintracksArguments;
35092
36538
  exports.toLookupNetworkPreset = toLookupNetworkPreset;
35093
36539
  exports.toWalletNetwork = toWalletNetwork;
35094
36540
  exports.transactionColumnsWithoutRawTx = transactionColumnsWithoutRawTx;
36541
+ exports.upgradeLegacyManagedChangeBasketDefault = upgradeLegacyManagedChangeBasketDefault;
35095
36542
  Object.defineProperty(exports, "utils", {
35096
36543
  enumerable: true,
35097
36544
  get: function() {
35098
36545
  return blockHeaderUtilities_exports;
35099
36546
  }
35100
36547
  });
36548
+ exports.validateManagedChangePolicy = validateManagedChangePolicy;
35101
36549
  exports.validateScriptHash = validateScriptHash;
35102
36550
  exports.validateSecondsSinceEpoch = validateSecondsSinceEpoch;
35103
36551
  exports.validateStorageFeeModel = validateStorageFeeModel;