@bsv/wallet-toolbox-client 2.6.5 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,177 @@ 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 findLocalInputs(req, storage, trx) {
5977
+ const outpoints = _bsv_sdk.Transaction.fromBinary(req.rawTx).inputs.map((input) => ({
5978
+ txid: input.sourceTXID ?? "",
5979
+ vout: input.sourceOutputIndex ?? 0
5980
+ })).filter((outpoint) => outpoint.txid !== "");
5981
+ if (outpoints.length === 0) return [];
5982
+ const transactions = await storage.findTransactions({
5983
+ partial: { txid: req.txid },
5984
+ noRawTx: true,
5985
+ trx
5986
+ });
5987
+ const userIds = [...new Set(transactions.map((transaction) => transaction.userId))];
5988
+ const localInputs = [];
5989
+ const localKeys = /* @__PURE__ */ new Set();
5990
+ for (const userId of userIds) {
5991
+ const byOutpoint = await storage.findOutputsByOutpoints(userId, outpoints, trx);
5992
+ for (const outpoint of outpoints) {
5993
+ const output = byOutpoint[`${outpoint.txid}.${outpoint.vout}`];
5994
+ const localKey = `${userId}:${outpoint.txid}.${outpoint.vout}`;
5995
+ if (output != null && !localKeys.has(localKey)) {
5996
+ localKeys.add(localKey);
5997
+ localInputs.push({
5998
+ output,
5999
+ outpoint
6000
+ });
6001
+ }
6002
+ }
6003
+ }
6004
+ return localInputs;
6005
+ }
6006
+ /**
6007
+ * Conservatively quarantine every locally-owned input of a transaction after
6008
+ * Arcade supplies explicit missing-input/conflict evidence. This path is
6009
+ * deliberately independent of explorer or UTXO providers: a positive
6010
+ * broadcaster verdict is enough to stop the failed transaction from feeding
6011
+ * the same inputs into another action. A later validated mined proof remains
6012
+ * able to repair the transaction through the ordinary proof recovery path.
6013
+ */
6014
+ async function quarantineReqInputs(req, storage, trx, logger) {
6015
+ const localInputs = await findLocalInputs(req, storage, trx);
6016
+ const staleOutpoints = /* @__PURE__ */ new Set();
6017
+ for (const { output, outpoint } of localInputs) {
6018
+ await storage.updateOutput(verifyId(output.outputId), { spendable: false }, trx);
6019
+ staleOutpoints.add(`${outpoint.txid}.${outpoint.vout}`);
6020
+ }
6021
+ const result = {
6022
+ checked: localInputs.length,
6023
+ staleConfirmed: localInputs.length,
6024
+ staleOutpoints: [...staleOutpoints]
6025
+ };
6026
+ if (result.staleConfirmed > 0) logger?.log(`quarantineReqInputs: ${result.staleConfirmed} local input copy/copies quarantined for txid=${req.txid}`);
6027
+ return result;
6028
+ }
6029
+ /**
6030
+ * Ask the configured UTXO-provider collection to classify the request's local
6031
+ * inputs and mark only positively confirmed spent outputs unavailable.
6032
+ * Provider errors and a collection with no providers are inconclusive and
6033
+ * never count as spent.
6034
+ */
6035
+ async function markConfirmedStaleReqInputs(req, storage, services, trx, logger) {
6036
+ const result = {
6037
+ checked: 0,
6038
+ staleConfirmed: 0,
6039
+ staleOutpoints: []
6040
+ };
6041
+ const localInputs = await findLocalInputs(req, storage, trx);
6042
+ const verdicts = /* @__PURE__ */ new Map();
6043
+ const uniqueInputs = /* @__PURE__ */ new Map();
6044
+ for (const localInput of localInputs) {
6045
+ const key = `${localInput.outpoint.txid}.${localInput.outpoint.vout}`;
6046
+ if (!uniqueInputs.has(key)) uniqueInputs.set(key, localInput);
6047
+ }
6048
+ await mapWithConcurrency([...uniqueInputs.values()], 4, async ({ output, outpoint }) => {
6049
+ const key = `${outpoint.txid}.${outpoint.vout}`;
6050
+ if (output.lockingScript == null) try {
6051
+ await storage.validateOutputScript(output, trx);
6052
+ } catch {
6053
+ verdicts.set(key, "inconclusive");
6054
+ return;
6055
+ }
6056
+ if (output.lockingScript == null) {
6057
+ verdicts.set(key, "inconclusive");
6058
+ return;
6059
+ }
6060
+ const classification = await classifyOutputUtxo(services, output);
6061
+ let verdict;
6062
+ if (classification.verdict === "unknown") verdict = "inconclusive";
6063
+ else if (classification.verdict === "unspent") verdict = "utxo";
6064
+ else verdict = "stale";
6065
+ verdicts.set(key, verdict);
6066
+ });
6067
+ const staleOutpoints = /* @__PURE__ */ new Set();
6068
+ for (const { output, outpoint } of localInputs) {
6069
+ const key = `${outpoint.txid}.${outpoint.vout}`;
6070
+ const verdict = verdicts.get(key);
6071
+ if (verdict == null || verdict === "inconclusive") continue;
6072
+ result.checked++;
6073
+ if (verdict === "stale") {
6074
+ await storage.updateOutput(verifyId(output.outputId), { spendable: false }, trx);
6075
+ result.staleConfirmed++;
6076
+ staleOutpoints.add(key);
6077
+ }
6078
+ }
6079
+ result.staleOutpoints = [...staleOutpoints];
6080
+ if (result.staleConfirmed > 0) logger?.log(`markConfirmedStaleReqInputs: ${result.staleConfirmed} of ${result.checked} local input copy/copies confirmed spent for txid=${req.txid}`);
6081
+ return result;
6082
+ }
6083
+ //#endregion
5766
6084
  //#region ../src/storage/methods/attemptToPostReqsToNetwork.ts
5767
6085
  /**
5768
6086
  * Attempt to post one or more `ProvenTxReq` with status 'unsent'
@@ -6102,64 +6420,7 @@ async function confirmDoubleSpend(ar, beef, storage, services, logger) {
6102
6420
  * that were actually evicted (added to history note for diagnostics).
6103
6421
  */
6104
6422
  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;
6423
+ return await markConfirmedStaleReqInputs(ar.vreq.req, storage, services, trx, logger);
6163
6424
  }
6164
6425
  //#endregion
6165
6426
  //#region ../src/storage/methods/listCertificates.ts
@@ -6312,6 +6573,75 @@ function removeDustOutputs(changeOutputs, dustFloor) {
6312
6573
  largest.satoshis += removed.satoshis;
6313
6574
  }
6314
6575
  }
6576
+ async function migrateLegacyChangeInputs(request) {
6577
+ const { params, result } = request;
6578
+ if (!params.surplusPoolShaping || result.changeOutputs.length === 0) return;
6579
+ if (request.targetNetCount <= request.netChangeCount()) return;
6580
+ const migrationLimit = params.maxMigrationInputs === -1 ? Number.MAX_SAFE_INTEGER : params.maxMigrationInputs ?? 0;
6581
+ for (let migrated = 0; migrated < migrationLimit; migrated++) {
6582
+ const marginalInputFee = request.feeTarget(1) - request.feeTarget();
6583
+ const candidate = await request.allocateChangeInput(0);
6584
+ if (candidate == null) break;
6585
+ if (candidate.satoshis >= params.changeInitialSatoshis || candidate.satoshis <= marginalInputFee) {
6586
+ await request.releaseChangeInput(candidate.outputId);
6587
+ break;
6588
+ }
6589
+ request.recordAllocatedInput(candidate);
6590
+ }
6591
+ }
6592
+ /**
6593
+ * Materialize the first managed-change output from surplus that is already in
6594
+ * the transaction. This is especially important for explicit/fixed inputs:
6595
+ * their value can fully fund an action before the allocator loop runs, but the
6596
+ * shaping policy must still capture the remainder without gathering another
6597
+ * wallet-managed input.
6598
+ */
6599
+ function materializeSurplusChangeOutput(request) {
6600
+ const { params, result } = request;
6601
+ if (!params.surplusPoolShaping || result.changeOutputs.length > 0) return;
6602
+ const availableAfterOutputFee = request.feeExcess(0, 1);
6603
+ if (availableAfterOutputFee < request.dustFloor) return;
6604
+ result.changeOutputs.push({
6605
+ satoshis: Math.min(availableAfterOutputFee, Math.max(request.dustFloor, params.changeFirstSatoshis)),
6606
+ lockingScriptLength: params.changeLockingScriptLength
6607
+ });
6608
+ request.feeExcess();
6609
+ }
6610
+ /**
6611
+ * Preserve the historical compatibility retry when another input can make a
6612
+ * viable change output, except when surplus-only shaping has nothing economic
6613
+ * to return. In that case the bounded remainder stays in the miner fee instead
6614
+ * of manufacturing change from an additional wallet input.
6615
+ */
6616
+ async function requireViableChangeOrRetainBoundedFee(request) {
6617
+ const { params, result } = request;
6618
+ const feeExcessNow = request.feeExcess();
6619
+ if (result.changeOutputs.length > 0 || feeExcessNow <= 0) return;
6620
+ if (params.surplusPoolShaping === true && request.feeExcess(0, 1) < request.dustFloor) return;
6621
+ const minimumChange = Math.max(request.dustFloor, params.changeFirstSatoshis);
6622
+ const totalSatoshisNeeded = request.spending() + request.feeTarget(0, 1) + minimumChange;
6623
+ const moreSatoshisNeeded = Math.max(1, totalSatoshisNeeded - request.funding());
6624
+ await request.releaseAllocatedChangeInputs();
6625
+ throw new WERR_INSUFFICIENT_FUNDS(totalSatoshisNeeded, moreSatoshisNeeded);
6626
+ }
6627
+ function shapeSurplusChangeOutputs(request) {
6628
+ const { params, result } = request;
6629
+ if (!params.surplusPoolShaping || result.changeOutputs.length !== 1) return;
6630
+ if (request.targetNetCount <= request.netChangeCount()) return;
6631
+ const originalSatoshis = result.changeOutputs[0].satoshis;
6632
+ const desiredOutputs = Math.min(request.maxChangeOutputs, Math.max(1, request.targetNetCount + result.allocatedChangeInputs.length));
6633
+ for (let count = desiredOutputs; count > 1; count--) {
6634
+ const addedOutputs = count - 1;
6635
+ const distributable = originalSatoshis - (request.feeTarget(0, addedOutputs) - request.feeTarget());
6636
+ if (distributable < count * params.changeInitialSatoshis) continue;
6637
+ result.changeOutputs = Array.from({ length: count }, () => ({
6638
+ satoshis: params.changeInitialSatoshis,
6639
+ lockingScriptLength: params.changeLockingScriptLength
6640
+ }));
6641
+ distributeExcessFees(result.changeOutputs, params.changeInitialSatoshis, distributable - count * params.changeInitialSatoshis, request.rand);
6642
+ break;
6643
+ }
6644
+ }
6315
6645
  /**
6316
6646
  * Simplifications:
6317
6647
  * - only support one change type with fixed length scripts.
@@ -6383,7 +6713,8 @@ async function generateChangeSdkCore(params, allocateChangeInput, releaseChangeI
6383
6713
  * Applies the per-transaction limit so that the UTXO pool grows
6384
6714
  * gradually rather than all at once.
6385
6715
  */
6386
- const maxChangeOutputs = params.maxChangeOutputs ?? 8;
6716
+ const maxChangeOutputs = params.maxChangeOutputs === -1 ? Number.MAX_SAFE_INTEGER : params.maxChangeOutputs ?? 8;
6717
+ const surplusPoolShaping = params.surplusPoolShaping === true;
6387
6718
  const randomVals = [...params.randomVals || []];
6388
6719
  const nextRandomVal = () => {
6389
6720
  let val = 0;
@@ -6466,6 +6797,7 @@ async function generateChangeSdkCore(params, allocateChangeInput, releaseChangeI
6466
6797
  return r.changeOutputs.length - r.allocatedChangeInputs.length;
6467
6798
  };
6468
6799
  const addOutputToBalanceNewInput = () => {
6800
+ if (surplusPoolShaping) return false;
6469
6801
  if (!hasTargetNetCount) return false;
6470
6802
  if (r.changeOutputs.length >= maxChangeOutputs) return false;
6471
6803
  return netChangeCount() - 1 < targetNetCount;
@@ -6481,6 +6813,7 @@ async function generateChangeSdkCore(params, allocateChangeInput, releaseChangeI
6481
6813
  feeExcessNow = feeExcess();
6482
6814
  };
6483
6815
  const addDesiredChangeOutputs = () => {
6816
+ if (surplusPoolShaping) return;
6484
6817
  while (r.changeOutputs.length < maxChangeOutputs && (hasTargetNetCount && targetNetCount > netChangeCount() || r.changeOutputs.length === 0 && feeExcess() > 0)) {
6485
6818
  const satoshis = r.changeOutputs.length === 0 ? Math.max(dustFloor, params.changeFirstSatoshis) : Math.max(dustFloor, params.changeInitialSatoshis);
6486
6819
  r.changeOutputs.push({
@@ -6496,7 +6829,8 @@ async function generateChangeSdkCore(params, allocateChangeInput, releaseChangeI
6496
6829
  if (removingOutputs || feeExcess() <= 0) return;
6497
6830
  if (!((ao === 1 || r.changeOutputs.length === 0) && r.changeOutputs.length < maxChangeOutputs)) return;
6498
6831
  const cap = r.changeOutputs.length === 0 ? params.changeFirstSatoshis : params.changeInitialSatoshis;
6499
- const satoshis = Math.min(feeExcess(), Math.max(dustFloor, cap));
6832
+ const outputFunding = surplusPoolShaping ? feeExcess(0, 1) : feeExcess();
6833
+ const satoshis = Math.min(outputFunding, Math.max(dustFloor, cap));
6500
6834
  if (satoshis >= dustFloor) r.changeOutputs.push({
6501
6835
  satoshis,
6502
6836
  lockingScriptLength: params.changeLockingScriptLength
@@ -6541,6 +6875,18 @@ async function generateChangeSdkCore(params, allocateChangeInput, releaseChangeI
6541
6875
  };
6542
6876
  }
6543
6877
  /**
6878
+ * The action may already be funded entirely by explicit/fixed inputs. In
6879
+ * that case the allocator loop never runs, so capture the existing surplus
6880
+ * here before the no-change compatibility guard. This operation cannot
6881
+ * allocate an input; bounded legacy migration remains a separate step.
6882
+ */
6883
+ materializeSurplusChangeOutput({
6884
+ params,
6885
+ result: r,
6886
+ dustFloor,
6887
+ feeExcess
6888
+ });
6889
+ /**
6544
6890
  * Trigger an account funding event if we don't have enough to cover this transaction.
6545
6891
  */
6546
6892
  if (feeExcess() < 0) {
@@ -6551,19 +6897,63 @@ async function generateChangeSdkCore(params, allocateChangeInput, releaseChangeI
6551
6897
  }
6552
6898
  /**
6553
6899
  * If needed, seek funding to avoid overspending on fees without a change output to recapture it.
6900
+ * An economically unreturnable shaping remainder stays in the miner fee;
6901
+ * gathering another input solely to manufacture change would violate
6902
+ * surplus-only shaping and make the action less efficient.
6554
6903
  */
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
- }
6904
+ await requireViableChangeOrRetainBoundedFee({
6905
+ params,
6906
+ result: r,
6907
+ dustFloor,
6908
+ feeExcess,
6909
+ feeTarget,
6910
+ funding,
6911
+ spending,
6912
+ releaseAllocatedChangeInputs
6913
+ });
6914
+ /**
6915
+ * Progressively retire economically useful legacy fragments without ever
6916
+ * making them necessary for the requested action. The target of zero asks
6917
+ * canonical allocators for their smallest remaining output. A candidate at
6918
+ * or above the preferred value is not legacy migration material and is
6919
+ * immediately released.
6920
+ */
6921
+ await migrateLegacyChangeInputs({
6922
+ params,
6923
+ result: r,
6924
+ targetNetCount,
6925
+ netChangeCount,
6926
+ feeTarget,
6927
+ allocateChangeInput,
6928
+ releaseChangeInput,
6929
+ recordAllocatedInput: (candidate) => {
6930
+ r.allocatedChangeInputs.push(candidate);
6931
+ allocatedFunding += candidate.satoshis;
6932
+ feeExcessNow = feeExcess();
6933
+ }
6934
+ });
6562
6935
  /**
6563
6936
  * Distribute the excess fees across the changeOutputs added.
6564
6937
  */
6565
6938
  feeExcessNow = distributeExcessFees(r.changeOutputs, params.changeInitialSatoshis, feeExcessNow, rand);
6566
6939
  /**
6940
+ * Pool growth is funded only from the surplus already present in the
6941
+ * transaction. Splitting one change output increases the serialized fee;
6942
+ * that exact delta is deducted before assigning the new outputs. If the
6943
+ * preferred minimum cannot be met, the transaction retains one smaller
6944
+ * output instead of gathering more inputs or refusing an otherwise valid
6945
+ * action.
6946
+ */
6947
+ shapeSurplusChangeOutputs({
6948
+ params,
6949
+ result: r,
6950
+ targetNetCount,
6951
+ netChangeCount,
6952
+ maxChangeOutputs,
6953
+ feeTarget,
6954
+ rand
6955
+ });
6956
+ /**
6567
6957
  * Remove any change outputs that ended up below the dust floor after distribution.
6568
6958
  * Consolidates removed satoshis into the largest remaining output.
6569
6959
  */
@@ -6594,7 +6984,11 @@ function validateGenerateChangeSdkResult(params, r) {
6594
6984
  ok = false;
6595
6985
  }
6596
6986
  const feeRequired = Math.ceil((r.size || 0) / 1e3 * (r.satsPerKb || 0));
6597
- if (feeRequired !== r.fee) {
6987
+ const minSpendTxSize = transactionSize([params.changeUnlockingScriptLength], [params.changeLockingScriptLength]);
6988
+ const dustFloor = Math.max(1, Math.ceil(minSpendTxSize / 1e3 * (r.satsPerKb || 0)) * 2);
6989
+ const feeWithChangeOutput = Math.ceil(((r.size || 0) + transactionOutputSize(params.changeLockingScriptLength)) / 1e3 * (r.satsPerKb || 0));
6990
+ const isBoundedUnreturnableShapingSurplus = params.surplusPoolShaping === true && r.changeOutputs.length === 0 && r.fee > feeRequired && r.fee - feeWithChangeOutput < dustFloor;
6991
+ if (feeRequired !== r.fee && !isBoundedUnreturnableShapingSurplus) {
6598
6992
  log += `required fee error ${feeRequired} !== ${r.fee};`;
6599
6993
  ok = false;
6600
6994
  }
@@ -6632,6 +7026,8 @@ function validateGenerateChangeSdkParams(params) {
6632
7026
  params.feeModel = validateStorageFeeModel(params.feeModel);
6633
7027
  if (params.feeModel.model !== "sat/kb") throw new WERR_INVALID_PARAMETER("feeModel.model", "'sat/kb'");
6634
7028
  _bsv_sdk.Validation.validateOptionalInteger(params.targetNetCount, "targetNetCount");
7029
+ if (params.maxChangeOutputs !== -1) _bsv_sdk.Validation.validateOptionalInteger(params.maxChangeOutputs, "maxChangeOutputs", 1);
7030
+ if (params.maxMigrationInputs !== -1) _bsv_sdk.Validation.validateOptionalInteger(params.maxMigrationInputs, "maxMigrationInputs", 0);
6635
7031
  _bsv_sdk.Validation.validateSatoshis(params.changeFirstSatoshis, "changeFirstSatoshis", 1);
6636
7032
  _bsv_sdk.Validation.validateSatoshis(params.changeInitialSatoshis, "changeInitialSatoshis", 1);
6637
7033
  _bsv_sdk.Validation.validateInteger(params.changeLockingScriptLength, "changeLockingScriptLength");
@@ -8099,6 +8495,8 @@ async function planFunding(state, args, explicit, noSendChange) {
8099
8495
  const allocated = /* @__PURE__ */ new Map();
8100
8496
  const noSend = [...noSendChange];
8101
8497
  const changeBasket = state.begin.changeBasket;
8498
+ const policy = validateManagedChangePolicy(state.begin.managedChangePolicy);
8499
+ const preferredSatoshis = Math.max(1, changeBasket.minimumDesiredUTXOValue);
8102
8500
  const params = {
8103
8501
  fixedInputs: explicit.map((output, index) => ({
8104
8502
  satoshis: output.satoshis,
@@ -8112,11 +8510,14 @@ async function planFunding(state, args, explicit, noSendChange) {
8112
8510
  lockingScriptLength: 25
8113
8511
  }] : []],
8114
8512
  feeModel: state.begin.feeModel,
8115
- changeInitialSatoshis: Math.max(1, changeBasket.minimumDesiredUTXOValue),
8116
- changeFirstSatoshis: Math.max(1, Math.round(changeBasket.minimumDesiredUTXOValue / 4)),
8513
+ changeInitialSatoshis: preferredSatoshis,
8514
+ changeFirstSatoshis: preferredSatoshis,
8117
8515
  changeLockingScriptLength: 25,
8118
8516
  changeUnlockingScriptLength: 107,
8119
8517
  targetNetCount: changeBasket.numberOfDesiredUTXOs - state.estimatedChangeCount,
8518
+ maxChangeOutputs: policy.maxOutputsPerAction,
8519
+ surplusPoolShaping: true,
8520
+ maxMigrationInputs: policy.migrationInputsPerAction,
8120
8521
  randomVals: args.randomVals
8121
8522
  };
8122
8523
  const allocate = async (targetSatoshis, exactSatoshis) => {
@@ -8135,7 +8536,18 @@ async function planFunding(state, args, explicit, noSendChange) {
8135
8536
  allocated.delete(outputId);
8136
8537
  if (noSendChange.includes(output)) noSend.push(output);
8137
8538
  };
8138
- const result = await generateChangeSdk(params, allocate, release, args.logger);
8539
+ let result;
8540
+ try {
8541
+ result = await generateChangeSdk(params, allocate, release, args.logger);
8542
+ } catch (error) {
8543
+ if (!(error instanceof WERR_INSUFFICIENT_FUNDS)) throw error;
8544
+ result = await generateChangeSdk({
8545
+ ...params,
8546
+ changeFirstSatoshis: 1,
8547
+ surplusPoolShaping: false,
8548
+ maxMigrationInputs: 0
8549
+ }, allocate, release, args.logger);
8550
+ }
8139
8551
  return {
8140
8552
  allocated: result.allocatedChangeInputs.map((input) => verifyTruthy(allocated.get(input.outputId))),
8141
8553
  changeSatoshis: result.changeOutputs.map((output) => output.satoshis),
@@ -8393,6 +8805,19 @@ async function compressActionBatchPackItems(items, encoding, maxBytes, maxItems)
8393
8805
  function mergeUnique(values) {
8394
8806
  return [...new Set(values)];
8395
8807
  }
8808
+ function isActionBatchStateError(error) {
8809
+ if (error instanceof WERRActionBatchState) return true;
8810
+ if (error == null || typeof error !== "object") return false;
8811
+ const candidate = error;
8812
+ return (candidate.name === "WERR_ACTION_BATCH_STATE" || candidate.code === "WERR_ACTION_BATCH_STATE") && typeof candidate.state === "string";
8813
+ }
8814
+ function isActionBatchCapacityError(error) {
8815
+ if (error == null || typeof error !== "object") return false;
8816
+ const candidate = error;
8817
+ const code = candidate.name === "Error" ? candidate.code : candidate.name ?? candidate.code;
8818
+ if (code === "WERR_INVALID_PARAMETER" && candidate.parameter === "firstAction") return true;
8819
+ return code === "WERR_INVALID_OPERATION" && typeof candidate.message === "string" && candidate.message.includes("action batch") && (candidate.message.includes("maximum") || candidate.message.includes("reserve"));
8820
+ }
8396
8821
  function nextGeometricTarget(value) {
8397
8822
  return Math.min(Number.MAX_SAFE_INTEGER, value * 2);
8398
8823
  }
@@ -8439,6 +8864,7 @@ var ActionBatchWorkspace = class {
8439
8864
  planned = /* @__PURE__ */ new Map();
8440
8865
  actions = [];
8441
8866
  lockingScripts = /* @__PURE__ */ new Map();
8867
+ canResume;
8442
8868
  ewmaConfirmedInputs = 1;
8443
8869
  ewmaConfirmedSatoshis = 0;
8444
8870
  hasConfirmedSample = false;
@@ -8458,6 +8884,7 @@ var ActionBatchWorkspace = class {
8458
8884
  this.batchId = begin.batchId;
8459
8885
  this.state = makePlannerState(begin, firstAction);
8460
8886
  this.expiresAt = Date.parse(begin.expiresAt);
8887
+ this.canResume = capabilities.resume === true && wallet.storage.resumeActionBatch != null;
8461
8888
  this.eagerUploadLanes = Array.from({ length: capabilities.packedUploads == null ? 1 : Math.max(1, capabilities.maxConcurrentUploads) }, async () => {});
8462
8889
  }
8463
8890
  get usesCompactManifest() {
@@ -8471,6 +8898,15 @@ var ActionBatchWorkspace = class {
8471
8898
  if (this.actions.some((action) => action.reference === reference || action.txid === reference)) return true;
8472
8899
  return Object.entries(this.wallet.pendingSignActions).some(([pendingReference, pending]) => this.planned.has(pendingReference) && pending.tx.id("hex") === reference);
8473
8900
  }
8901
+ ownsTransaction(txid) {
8902
+ 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);
8903
+ }
8904
+ references(args) {
8905
+ 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));
8906
+ }
8907
+ referencesSendWith(txids) {
8908
+ return txids.some((txid) => this.ownsTransaction(txid));
8909
+ }
8474
8910
  get isEmpty() {
8475
8911
  return this.planned.size === 0 && this.actions.length === 0;
8476
8912
  }
@@ -8595,12 +9031,32 @@ var ActionBatchWorkspace = class {
8595
9031
  satoshis: extension.reservedOutputs.reduce((sum, output) => sum + output.satoshis, 0)
8596
9032
  };
8597
9033
  }
9034
+ async resume() {
9035
+ const outpoints = [...new Map([...this.state.reserved.values(), ...this.state.explicit.values()].filter((output) => output.txid != null).map((output) => [`${output.txid}.${output.vout}`, {
9036
+ txid: output.txid,
9037
+ vout: output.vout
9038
+ }])).values()];
9039
+ const result = await this.wallet.storage.resumeActionBatch({
9040
+ batchId: this.batchId,
9041
+ outpoints
9042
+ });
9043
+ this.expiresAt = Date.parse(result.expiresAt);
9044
+ }
8598
9045
  async renewIfNeeded() {
8599
- if (Date.now() >= this.expiresAt) return;
9046
+ if (Date.now() >= this.expiresAt) {
9047
+ if (this.canResume) await this.resume();
9048
+ return;
9049
+ }
8600
9050
  const renewAt = this.expiresAt - this.capabilities.leaseMs * .2;
8601
9051
  if (Date.now() < renewAt) return;
8602
9052
  this.renewal ??= this.wallet.storage.renewActionBatch(this.batchId).then((result) => {
8603
9053
  this.expiresAt = Date.parse(result.expiresAt);
9054
+ }).catch(async (error) => {
9055
+ if (isActionBatchStateError(error) && error.state === "expired") {
9056
+ if (this.canResume) await this.resume();
9057
+ return;
9058
+ }
9059
+ throw error;
8604
9060
  }).finally(() => {
8605
9061
  this.renewal = void 0;
8606
9062
  });
@@ -9031,24 +9487,42 @@ var ActionBatchController = class {
9031
9487
  this.workspace = new ActionBatchWorkspace(this.wallet, begin, args, capabilities);
9032
9488
  return this.workspace;
9033
9489
  }
9490
+ async retire(workspace) {
9491
+ await workspace.abort().catch(() => void 0);
9492
+ if (this.workspace === workspace) this.workspace = void 0;
9493
+ }
9494
+ async recover(workspace, input, isDelayed = false, resume = false) {
9495
+ try {
9496
+ if (resume) await workspace.resume();
9497
+ return Array.isArray(input) ? await workspace.commit(input, isDelayed) : await workspace.plan(input);
9498
+ } catch (error) {
9499
+ if (!resume && isActionBatchStateError(error) && error.state === "expired" && workspace.canResume) return await this.recover(workspace, input, isDelayed, true);
9500
+ if (isActionBatchStateError(error) && error.state !== "expired") await this.retire(workspace);
9501
+ throw error;
9502
+ }
9503
+ }
9034
9504
  async plan(args) {
9035
9505
  return await this.runExclusive(async () => {
9036
9506
  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;
9507
+ const workspace = this.workspace;
9508
+ if (workspace != null) {
9509
+ if (!workspace.references(args)) return void 0;
9510
+ return await this.recover(workspace, args);
9044
9511
  }
9512
+ if (!args.isNoSend) return void 0;
9513
+ let begun;
9045
9514
  try {
9046
- return await workspace.plan(args);
9515
+ begun = await this.begin(args);
9047
9516
  } catch (error) {
9048
- if (beganWorkspace) {
9049
- await workspace.abort();
9050
- this.workspace = void 0;
9051
- }
9517
+ if (isActionBatchCapacityError(error)) return void 0;
9518
+ throw error;
9519
+ }
9520
+ if (begun == null) return void 0;
9521
+ try {
9522
+ return await begun.plan(args);
9523
+ } catch (error) {
9524
+ await this.retire(begun);
9525
+ if (isActionBatchCapacityError(error)) return void 0;
9052
9526
  throw error;
9053
9527
  }
9054
9528
  });
@@ -9058,11 +9532,21 @@ var ActionBatchController = class {
9058
9532
  const workspace = this.workspace;
9059
9533
  if (workspace == null) return void 0;
9060
9534
  if (prior != null && !workspace.ownsReference(prior.reference)) return void 0;
9535
+ if (prior == null && !workspace.referencesSendWith(args.options.sendWith)) return void 0;
9061
9536
  if (prior != null) workspace.stage(prior, args);
9062
- if (!(args.isSendWith || prior != null && !args.isNoSend)) return { sendWithResults: [] };
9537
+ const referencesWorkspace = workspace.referencesSendWith(args.options.sendWith);
9538
+ if (prior != null && args.isNoSend && args.isSendWith && !referencesWorkspace) return await this.wallet.storage.processAction({
9539
+ isNewTx: false,
9540
+ isSendWith: true,
9541
+ isNoSend: false,
9542
+ isDelayed: args.isDelayed,
9543
+ sendWith: [...args.options.sendWith],
9544
+ logger: args.logger
9545
+ });
9546
+ if (!(referencesWorkspace || prior != null && !args.isNoSend)) return { sendWithResults: [] };
9063
9547
  const sendWith = [...args.options.sendWith];
9064
9548
  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);
9549
+ const result = await this.recover(workspace, sendWith, args.isDelayed);
9066
9550
  this.workspace = void 0;
9067
9551
  return result;
9068
9552
  });
@@ -9806,14 +10290,15 @@ var Wallet = class {
9806
10290
  }
9807
10291
  /**
9808
10292
  * Uses `listOutputs` special operation to review the spendability via `Services` of
9809
- * outputs currently considered spendable. Returns the outputs that fail to verify.
10293
+ * outputs currently considered spendable. Returns only outputs conclusively
10294
+ * confirmed spent. Rejects the review if any provider result is inconclusive.
9810
10295
  *
9811
10296
  * Ignores the `limit` and `offset` properties.
9812
10297
  *
9813
10298
  * @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)
10299
+ * @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
10300
  * @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.
10301
+ * @returns outputs previously considered spendable but conclusively confirmed spent.
9817
10302
  */
9818
10303
  async reviewSpendableOutputs(all = false, release = false, optionalArgs) {
9819
10304
  const args = {
@@ -10021,7 +10506,7 @@ async function createActionCore(storage, auth, vargs, parent) {
10021
10506
  });
10022
10507
  const feeModel = validateStorageFeeModel(storage.feeModel);
10023
10508
  logger?.log(`validated fee model ${JSON.stringify(feeModel)}`);
10024
- const initialFundingPlan = await prepareFundingPlanWithSendingFallback(storage, [
10509
+ const initialFundingPlan = await prepareFundingPlanWithLiquidityPolicy(storage, [
10025
10510
  userId,
10026
10511
  vargs,
10027
10512
  xinputs,
@@ -10609,7 +11094,9 @@ async function traceStorageStep(storage, name, parent, attributes, callback) {
10609
11094
  attributes
10610
11095
  }, async (span) => await callback(span));
10611
11096
  }
10612
- function makeFundingParams(vargs, xinputs, xoutputs, changeBasket, feeModel, availableChangeCount) {
11097
+ function makeFundingParams(args) {
11098
+ const { storage, vargs, xinputs, xoutputs, changeBasket, feeModel, healthyChangeCount, compatibilityFallback } = args;
11099
+ const preferredSatoshis = Math.max(1, changeBasket.minimumDesiredUTXOValue);
10613
11100
  return {
10614
11101
  fixedInputs: xinputs.map((input) => ({
10615
11102
  satoshis: input.satoshis,
@@ -10620,35 +11107,52 @@ function makeFundingParams(vargs, xinputs, xoutputs, changeBasket, feeModel, ava
10620
11107
  lockingScriptLength: output.lockingScript.length / 2
10621
11108
  })),
10622
11109
  feeModel,
10623
- changeInitialSatoshis: Math.max(1, changeBasket.minimumDesiredUTXOValue),
10624
- changeFirstSatoshis: Math.max(1, Math.round(changeBasket.minimumDesiredUTXOValue / 4)),
11110
+ changeInitialSatoshis: preferredSatoshis,
11111
+ changeFirstSatoshis: compatibilityFallback ? 1 : preferredSatoshis,
10625
11112
  changeLockingScriptLength: 25,
10626
11113
  changeUnlockingScriptLength: 107,
10627
- targetNetCount: changeBasket.numberOfDesiredUTXOs - availableChangeCount,
11114
+ targetNetCount: changeBasket.numberOfDesiredUTXOs - healthyChangeCount,
11115
+ maxChangeOutputs: storage.managedChangePolicy.maxOutputsPerAction,
11116
+ surplusPoolShaping: !compatibilityFallback,
11117
+ maxMigrationInputs: compatibilityFallback ? 0 : storage.managedChangePolicy.migrationInputsPerAction,
10628
11118
  randomVals: vargs.randomVals
10629
11119
  };
10630
11120
  }
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
- });
11121
+ async function buildFundingPlan(storage, context, candidates, eligibleStatuses, policyTier, compatibilityFallback, parent) {
11122
+ const [userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel] = context;
10641
11123
  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);
11124
+ const available = candidates.filter((output) => !noSendIds.has(output.outputId) && eligibleStatuses.includes(output.transactionStatus));
11125
+ const preferredSatoshis = Math.max(1, changeBasket.minimumDesiredUTXOValue);
11126
+ const params = makeFundingParams({
11127
+ storage,
11128
+ vargs,
11129
+ xinputs,
11130
+ xoutputs,
11131
+ changeBasket,
11132
+ feeModel,
11133
+ healthyChangeCount: compatibilityFallback ? candidates.filter((output) => eligibleStatuses.includes(output.transactionStatus)).length : candidates.filter((output) => output.satoshis >= preferredSatoshis).length,
11134
+ compatibilityFallback
11135
+ });
11136
+ const noSendStatuses = await storage.findTransactionStatusesByIds(userId, noSendChangeIn.map((output) => output.transactionId));
11137
+ const plannedNoSend = noSendChangeIn.map((output) => ({
11138
+ outputId: output.outputId,
11139
+ transactionId: output.transactionId,
11140
+ satoshis: output.satoshis,
11141
+ txid: output.txid,
11142
+ vout: output.vout,
11143
+ transactionStatus: noSendStatuses.get(output.transactionId) ?? "nosend"
11144
+ }));
11145
+ const claimStatuses = [.../* @__PURE__ */ new Set([...eligibleStatuses, ...plannedNoSend.map((output) => output.transactionStatus)])];
10644
11146
  return await traceStorageStep(storage, "wallet.storage.create_action.funding_plan", parent, {
10645
11147
  "funding.candidate_count": available.length,
10646
- "funding.no_send_change_count": noSendChangeIn.length
11148
+ "funding.no_send_change_count": noSendChangeIn.length,
11149
+ "funding.policy_tier": policyTier,
11150
+ "funding.compatibility_fallback": compatibilityFallback
10647
11151
  }, async (span) => {
10648
11152
  const allocated = /* @__PURE__ */ new Map();
10649
11153
  const availableSelector = new CanonicalChangeSelector(available);
10650
- const noSend = [...noSendChangeIn];
10651
- const noSendById = new Map(noSendChangeIn.map((output) => [output.outputId, output]));
11154
+ const noSend = [...plannedNoSend];
11155
+ const noSendById = new Map(plannedNoSend.map((output) => [output.outputId, output]));
10652
11156
  const allocate = async (targetSatoshis, exactSatoshis) => {
10653
11157
  let output = noSend.pop();
10654
11158
  output ??= availableSelector.take(targetSatoshis, exactSatoshis);
@@ -10679,31 +11183,118 @@ async function prepareFundingPlan(storage, context) {
10679
11183
  result,
10680
11184
  selected,
10681
11185
  availableChangeCount: candidates.length,
10682
- excludeSending
11186
+ eligibleStatuses: claimStatuses,
11187
+ policyTier,
11188
+ compatibilityFallback
10683
11189
  };
10684
11190
  });
10685
11191
  }
10686
- async function prepareFundingPlanWithSendingFallback(storage, context, parent, trx) {
10687
- const excludeSending = !context[1].isDelayed;
11192
+ async function fundingPlanSerializedCost(storage, plan, knownTxids) {
11193
+ const txids = [...new Set(plan.selected.map((candidate) => verifyTruthy(candidate.txid)))];
11194
+ if (txids.length === 0) return plan.result.size;
10688
11195
  try {
10689
- return await prepareFundingPlan(storage, [
10690
- ...context,
10691
- excludeSending,
10692
- parent,
10693
- trx
10694
- ]);
11196
+ const beef = await storage.getBeefForTransactions(txids, {
11197
+ knownTxids,
11198
+ ignoreStorage: false,
11199
+ ignoreServices: true,
11200
+ ignoreNewProven: false
11201
+ });
11202
+ return plan.result.size + beef.toUint8Array().length;
11203
+ } catch {
11204
+ return Number.MAX_SAFE_INTEGER;
11205
+ }
11206
+ }
11207
+ const FUNDING_TIERS = [
11208
+ {
11209
+ policyTier: "completed",
11210
+ statuses: ["completed"]
11211
+ },
11212
+ {
11213
+ policyTier: "unproven",
11214
+ statuses: ["completed", "unproven"]
11215
+ },
11216
+ {
11217
+ policyTier: "sending",
11218
+ statuses: [
11219
+ "completed",
11220
+ "unproven",
11221
+ "sending"
11222
+ ]
11223
+ }
11224
+ ];
11225
+ async function resolveFundingCandidates(storage, userId, basketId, parent, trx) {
11226
+ const rawCandidates = await traceStorageStep(storage, "wallet.storage.create_action.funding_candidates", parent, { "funding.include_pending": true }, async (span) => {
11227
+ const outputs = await storage.findAvailableManagedChangeInputCandidates(userId, basketId, false, trx);
11228
+ span?.end({ attributes: {
11229
+ "funding.candidate_count": outputs.length,
11230
+ "funding.candidate_satoshis": outputs.reduce((sum, output) => sum + output.satoshis, 0),
11231
+ "funding.completed_candidate_count": outputs.filter((output) => output.transactionStatus === "completed").length,
11232
+ "funding.unproven_candidate_count": outputs.filter((output) => output.transactionStatus === "unproven").length,
11233
+ "funding.sending_candidate_count": outputs.filter((output) => output.transactionStatus === "sending").length
11234
+ } });
11235
+ return outputs;
11236
+ });
11237
+ const missingStatusIds = rawCandidates.filter((output) => output.transactionStatus == null).map((output) => output.transactionId);
11238
+ const missingStatuses = await storage.findTransactionStatusesByIds(userId, missingStatusIds, trx);
11239
+ const candidates = rawCandidates.map((output) => ({
11240
+ ...output,
11241
+ transactionStatus: output.transactionStatus ?? missingStatuses.get(output.transactionId)
11242
+ }));
11243
+ if (candidates.some((output) => output.transactionStatus == null)) throw new WERR_INTERNAL("managed change candidate is missing its source transaction status");
11244
+ return candidates;
11245
+ }
11246
+ async function buildFundingPlanForTier(storage, context, candidates, tier, parent) {
11247
+ try {
11248
+ return { plan: await buildFundingPlan(storage, context, candidates, tier.statuses, tier.policyTier, false, parent) };
10695
11249
  } 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
- ]);
11250
+ if (!(error instanceof WERR_INSUFFICIENT_FUNDS)) throw error;
11251
+ }
11252
+ try {
11253
+ return { plan: await buildFundingPlan(storage, context, candidates, tier.statuses, "compatibility", true, parent) };
11254
+ } catch (error) {
11255
+ if (!(error instanceof WERR_INSUFFICIENT_FUNDS)) throw error;
11256
+ return { fundingError: error };
11257
+ }
11258
+ }
11259
+ function firstPlanNeedsNoPendingComparison(storage, plan) {
11260
+ const threshold = storage.managedChangePolicy.pendingComparisonInputs;
11261
+ return threshold === -1 || plan.selected.length <= threshold;
11262
+ }
11263
+ async function chooseLowestSerializedFundingPlan(storage, plans, knownTxids) {
11264
+ let chosen = plans[0];
11265
+ let chosenCost = await fundingPlanSerializedCost(storage, chosen, knownTxids);
11266
+ for (const alternative of plans.slice(1)) {
11267
+ const cost = await fundingPlanSerializedCost(storage, alternative, knownTxids);
11268
+ if (cost < chosenCost) {
11269
+ chosen = alternative;
11270
+ chosenCost = cost;
11271
+ }
11272
+ }
11273
+ return chosen;
11274
+ }
11275
+ async function prepareFundingPlanWithLiquidityPolicy(storage, context, parent, trx) {
11276
+ const [userId, vargs, , , changeBasket] = context;
11277
+ const candidates = await resolveFundingCandidates(storage, userId, changeBasket.basketId, parent, trx);
11278
+ const successful = [];
11279
+ let fundingError;
11280
+ for (const tier of FUNDING_TIERS) {
11281
+ const attempted = await buildFundingPlanForTier(storage, context, candidates, tier, parent);
11282
+ fundingError = attempted.fundingError ?? fundingError;
11283
+ const plan = attempted.plan;
11284
+ if (plan != null) {
11285
+ successful.push(plan);
11286
+ if (successful.length === 1 && firstPlanNeedsNoPendingComparison(storage, plan)) return plan;
11287
+ }
11288
+ }
11289
+ if (successful.length > 0) {
11290
+ if (successful.length === 1) return successful[0];
11291
+ return await chooseLowestSerializedFundingPlan(storage, successful, vargs.options.knownTxids);
10703
11292
  }
11293
+ if (fundingError != null) throw fundingError;
11294
+ throw new WERR_INTERNAL("funding policy produced neither a plan nor an error");
10704
11295
  }
10705
11296
  async function claimFundingPlan(storage, request) {
10706
- const [userId, basketId, excludeSending, transactionId, noSendChangeIn, plan, trx] = request;
11297
+ const [userId, basketId, eligibleStatuses, transactionId, noSendChangeIn, plan, trx] = request;
10707
11298
  if (plan.selected.length === 0) return {
10708
11299
  outputs: [],
10709
11300
  sourceTransactionCount: 0,
@@ -10711,10 +11302,8 @@ async function claimFundingPlan(storage, request) {
10711
11302
  scriptSourceTransactionCount: 0
10712
11303
  };
10713
11304
  const noSendIds = new Set(noSendChangeIn.map((output) => output.outputId));
10714
- const statuses = ["completed", "unproven"];
10715
- if (!excludeSending) statuses.push("sending");
10716
11305
  const claim = await storage.transaction(async (claimTrx) => {
10717
- const currentById = await storage.findFundingOutputsForUpdate(userId, plan.selected.map((output) => output.outputId), statuses, claimTrx);
11306
+ const currentById = await storage.findFundingOutputsForUpdate(userId, plan.selected.map((output) => output.outputId), eligibleStatuses, claimTrx);
10718
11307
  const transactionIds = [...new Set(Object.values(currentById).map((output) => output.transactionId))];
10719
11308
  const claimed = [];
10720
11309
  for (const planned of plan.selected) {
@@ -10784,7 +11373,7 @@ async function fundNewTransactionSdk(storage, userId, vargs, ctx, initialPlan, p
10784
11373
  const claim = await claimFundingPlan(storage, [
10785
11374
  userId,
10786
11375
  ctx.changeBasket.basketId,
10787
- plan.excludeSending,
11376
+ plan.eligibleStatuses,
10788
11377
  ctx.transactionId,
10789
11378
  ctx.noSendChangeIn,
10790
11379
  plan,
@@ -10802,7 +11391,7 @@ async function fundNewTransactionSdk(storage, userId, vargs, ctx, initialPlan, p
10802
11391
  }
10803
11392
  if (claim.conflict === "noSendChange") throw new WERR_INVALID_PARAMETER("noSendChange", "outputs that remain spendable during action planning");
10804
11393
  retryCount++;
10805
- plan = await prepareFundingPlanWithSendingFallback(storage, [
11394
+ plan = await prepareFundingPlanWithLiquidityPolicy(storage, [
10806
11395
  userId,
10807
11396
  vargs,
10808
11397
  ctx.xinputs,
@@ -12955,7 +13544,7 @@ var StorageReaderWriter = class extends StorageReader {
12955
13544
  userId: user.userId,
12956
13545
  name: "default",
12957
13546
  numberOfDesiredUTXOs: 144,
12958
- minimumDesiredUTXOValue: 32,
13547
+ minimumDesiredUTXOValue: DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS,
12959
13548
  isDeleted: false
12960
13549
  });
12961
13550
  break;
@@ -13236,8 +13825,10 @@ function validateActionBatchInlinePayload(manifest) {
13236
13825
  for (const { digest, bytes } of inlineBlobs) if (actionBatchBlobDigest(bytes) !== digest) throw new WERR_INVALID_PARAMETER("inlineBlobs", `content matching digest ${digest}`);
13237
13826
  }
13238
13827
  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");
13828
+ if (batch == null) throw new WERRActionBatchState("missing");
13829
+ if (batch.status === "committed") throw new WERRActionBatchState("committed", batch.batchId);
13830
+ if (batch.status === "aborted") throw new WERRActionBatchState("aborted", batch.batchId);
13831
+ if (batch.hardExpiresAt.getTime() <= Date.now()) throw new WERRActionBatchState("hard-expired", batch.batchId);
13241
13832
  return batch;
13242
13833
  }
13243
13834
  function manifestPhysicalDigests(manifest) {
@@ -13597,7 +14188,10 @@ const ACTION_BATCH_LEASE_MS = 900 * 1e3;
13597
14188
  const ACTION_BATCH_HARD_LIFETIME_MS = 3600 * 1e3;
13598
14189
  const INITIAL_RESERVATION_LIMIT = 8;
13599
14190
  const INITIAL_EXTRA_OUTPUTS = 3;
13600
- function getActionBatchCapabilities() {
14191
+ function isValidOutpoint(outpoint) {
14192
+ return /^[0-9a-f]{64}$/.test(outpoint.txid) && Number.isSafeInteger(outpoint.vout) && outpoint.vout >= 0;
14193
+ }
14194
+ function getActionBatchCapabilities(maxReservedOutputs = 256, supportsResume = false) {
13601
14195
  return { actionBatch: {
13602
14196
  version: 1,
13603
14197
  maxInlineBytes: ACTION_BATCH_MAX_INLINE_BYTES,
@@ -13605,6 +14199,8 @@ function getActionBatchCapabilities() {
13605
14199
  maxConcurrentUploads: 4,
13606
14200
  leaseMs: ACTION_BATCH_LEASE_MS,
13607
14201
  hardLifetimeMs: ACTION_BATCH_HARD_LIFETIME_MS,
14202
+ resume: supportsResume ? true : void 0,
14203
+ maxReservedOutputs,
13608
14204
  compactBegin: true,
13609
14205
  manifestVersion: 2,
13610
14206
  commitByDigest: true,
@@ -13620,6 +14216,14 @@ function getActionBatchCapabilities() {
13620
14216
  function activeExpiry(batch, now = /* @__PURE__ */ new Date()) {
13621
14217
  return batch.status !== "active" && batch.status !== "prepared" || batch.expiresAt.getTime() <= now.getTime() || batch.hardExpiresAt.getTime() <= now.getTime();
13622
14218
  }
14219
+ function actionBatchErrorState(batch, now = /* @__PURE__ */ new Date()) {
14220
+ if (batch == null) return "missing";
14221
+ if (batch.hardExpiresAt.getTime() <= now.getTime()) return "hard-expired";
14222
+ if (batch.status === "aborted") return "aborted";
14223
+ if (batch.status === "committed") return "committed";
14224
+ if (batch.status === "expired" || batch.expiresAt.getTime() <= now.getTime()) return "expired";
14225
+ if (batch.status !== "active" && batch.status !== "prepared") return "inactive";
14226
+ }
13623
14227
  function canExpire(batch, now = /* @__PURE__ */ new Date()) {
13624
14228
  return (batch.status === "active" || batch.status === "prepared") && (batch.expiresAt.getTime() <= now.getTime() || batch.hardExpiresAt.getTime() <= now.getTime());
13625
14229
  }
@@ -13705,12 +14309,22 @@ function estimateFirstActionTarget(storage, args, inputSatoshis, outputScriptLen
13705
14309
  const minFee = Math.ceil(transactionSize([...inputLengths, 107], outputLengths) * fee / 1e3);
13706
14310
  return Math.max(1, outputSatoshis + minFee - inputSatoshis);
13707
14311
  }
14312
+ function selectReservationChange(candidates, targetSatoshis) {
14313
+ for (const status of [
14314
+ "completed",
14315
+ "unproven",
14316
+ "sending"
14317
+ ]) {
14318
+ const selected = selectCanonicalChange(candidates.filter((candidate) => candidate.transactionStatus === status), targetSatoshis);
14319
+ if (selected != null) return selected;
14320
+ }
14321
+ }
13708
14322
  function chooseReservationPool(candidates, targetSatoshis, limit, extras, fillLimit, planningCosts) {
13709
14323
  const remaining = candidates.filter((output) => output.satoshis > planningCosts.marginalInputFee);
13710
14324
  const chosen = [];
13711
14325
  let deficit = targetSatoshis + planningCosts.firstChangeCost;
13712
14326
  while (deficit > 0 && chosen.length < limit) {
13713
- const output = selectCanonicalChange(remaining, deficit + planningCosts.marginalInputFee);
14327
+ const output = selectReservationChange(remaining, deficit + planningCosts.marginalInputFee);
13714
14328
  if (output == null) break;
13715
14329
  chosen.push(output);
13716
14330
  remaining.splice(remaining.indexOf(output), 1);
@@ -13718,14 +14332,18 @@ function chooseReservationPool(candidates, targetSatoshis, limit, extras, fillLi
13718
14332
  }
13719
14333
  const desiredCount = fillLimit ? limit : Math.min(limit, chosen.length + extras);
13720
14334
  while (remaining.length > 0 && chosen.length < desiredCount) {
13721
- const output = selectCanonicalChange(remaining, targetSatoshis);
14335
+ const output = selectReservationChange(remaining, targetSatoshis);
13722
14336
  if (output == null) break;
13723
14337
  chosen.push(output);
13724
14338
  remaining.splice(remaining.indexOf(output), 1);
13725
14339
  }
13726
14340
  return chosen;
13727
14341
  }
13728
- function reservationPlanningCosts(storage, basket) {
14342
+ function reservationOutput(candidate) {
14343
+ const { transactionStatus: _transactionStatus, ...output } = candidate;
14344
+ return output;
14345
+ }
14346
+ function reservationPlanningCosts(storage, _basket) {
13729
14347
  const satsPerKb = validateStorageFeeModel(storage.feeModel).value ?? 0;
13730
14348
  const minimumSpendSize = transactionSize([107], [25]);
13731
14349
  const minimumSpendFee = Math.ceil(minimumSpendSize * satsPerKb / 1e3);
@@ -13733,7 +14351,7 @@ function reservationPlanningCosts(storage, basket) {
13733
14351
  const marginalInputSize = transactionSize([107], []) - transactionSize([], []);
13734
14352
  const marginalOutputSize = transactionSize([], [25]) - transactionSize([], []);
13735
14353
  return {
13736
- firstChangeCost: Math.max(dustFloor, Math.max(1, Math.round(basket.minimumDesiredUTXOValue / 4))) + Math.ceil(marginalOutputSize * satsPerKb / 1e3),
14354
+ firstChangeCost: dustFloor + Math.ceil(marginalOutputSize * satsPerKb / 1e3),
13737
14355
  marginalInputFee: Math.ceil(marginalInputSize * satsPerKb / 1e3)
13738
14356
  };
13739
14357
  }
@@ -13808,18 +14426,25 @@ async function beginActionBatch(storage, auth, args) {
13808
14426
  const explicit = await resolveExplicitOutputs(storage, userId, args.firstAction, outputScriptLengths != null);
13809
14427
  const noSendChange = await resolveNoSendChangeOutputs(storage, userId, args.firstAction);
13810
14428
  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));
14429
+ const availableOutputs = (await storage.findAvailableManagedChangeInputs(userId, changeBasket.basketId, false)).filter((output) => !fixedOutputIds.has(output.outputId));
14430
+ const availableStatuses = await storage.findTransactionStatusesByIds(userId, availableOutputs.map((output) => output.transactionId));
14431
+ const available = availableOutputs.map((output) => ({
14432
+ ...output,
14433
+ transactionStatus: availableStatuses.get(output.transactionId) ?? "sending"
14434
+ }));
13812
14435
  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));
14436
+ const fixedOutputs = [...new Map([...explicit.outputs, ...noSendChange.outputs].map((output) => [output.outputId, output])).values()];
14437
+ const maxReservedOutputs = storage.actionBatchMaxReservedOutputs;
14438
+ if (maxReservedOutputs >= 0 && fixedOutputs.length > maxReservedOutputs) throw new WERR_INVALID_PARAMETER("firstAction", `no more than ${maxReservedOutputs} persisted inputs`);
14439
+ 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
14440
  const batch = newBatch(userId, args.batchId);
13816
14441
  await storage.transaction(async (trx) => {
13817
14442
  await storage.insertActionBatch(batch, trx);
13818
- await reserveOutputs(storage, batch, [...fixedOutputs, ...funding], trx);
14443
+ await reserveOutputs(storage, batch, [...fixedOutputs, ...fundingOutputs], trx);
13819
14444
  });
13820
14445
  let fundingResult;
13821
14446
  try {
13822
- fundingResult = await makeFundingResult(storage, args.firstAction, [...funding, ...fixedOutputs]);
14447
+ fundingResult = await makeFundingResult(storage, args.firstAction, [...fundingOutputs, ...fixedOutputs]);
13823
14448
  } catch (error) {
13824
14449
  await storage.transaction(async (trx) => await releaseBatchState(storage, batch, "aborted", trx));
13825
14450
  throw error;
@@ -13833,41 +14458,63 @@ async function beginActionBatch(storage, auth, args) {
13833
14458
  feeModel: validateStorageFeeModel(storage.feeModel),
13834
14459
  commissionSatoshis: storage.commissionSatoshis,
13835
14460
  commissionPubKeyHex: storage.commissionPubKeyHex,
13836
- availableChangeCount: available.length,
13837
- reservedOutputs: fundingResult.outputs.filter((output) => funding.some((candidate) => candidate.outputId === output.outputId)),
14461
+ availableChangeCount: available.filter((output) => output.satoshis >= Math.max(1, changeBasket.minimumDesiredUTXOValue)).length,
14462
+ managedChangePolicy: {
14463
+ maxOutputsPerAction: storage.managedChangePolicy.maxOutputsPerAction,
14464
+ migrationInputsPerAction: storage.managedChangePolicy.migrationInputsPerAction
14465
+ },
14466
+ reservedOutputs: fundingResult.outputs.filter((output) => fundingOutputs.some((candidate) => candidate.outputId === output.outputId)),
13838
14467
  explicitOutputs: fundingResult.outputs.filter((output) => explicitIds.has(output.outputId)),
13839
14468
  inputBeef: fundingResult.beef
13840
14469
  };
13841
14470
  }
13842
- function requireLiveBatch(batch) {
13843
- if (batch == null || activeExpiry(batch)) throw new WERR_INVALID_OPERATION("action batch is not active");
14471
+ function requireLiveBatch(batch, batchId) {
14472
+ const state = actionBatchErrorState(batch);
14473
+ if (state != null) throw new WERRActionBatchState(state, batchId ?? batch?.batchId);
14474
+ if (batch == null) throw new WERRActionBatchState("missing", batchId);
13844
14475
  return batch;
13845
14476
  }
13846
14477
  async function extendActionBatch(storage, auth, args) {
13847
14478
  const userId = verifyId(auth.userId);
13848
14479
  await cleanupExpiredActionBatches(storage);
13849
- const batch = requireLiveBatch(await storage.findActionBatch(userId, args.batchId));
14480
+ const batch = requireLiveBatch(await storage.findActionBatch(userId, args.batchId), args.batchId);
13850
14481
  const basket = verifyOne(await storage.findOutputBaskets({ partial: {
13851
14482
  userId,
13852
14483
  name: "default"
13853
14484
  } }));
13854
14485
  const alreadyReserved = await storage.findActionBatchOutputIds(batch.actionBatchId);
13855
- const available = await storage.findAvailableManagedChangeInputs(userId, basket.basketId, false);
13856
14486
  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));
14487
+ if (!Number.isSafeInteger(args.targetSatoshis) || args.targetSatoshis < 0) throw new WERR_INVALID_PARAMETER("targetSatoshis", "non-negative safe integer");
14488
+ const maxReservedOutputs = storage.actionBatchMaxReservedOutputs;
14489
+ 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`);
14490
+ const remainingCapacity = maxReservedOutputs < 0 ? Number.MAX_SAFE_INTEGER : maxReservedOutputs - alreadyReserved.length;
14491
+ 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
14492
  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]);
14493
+ const explicit = [...new Map(Object.values(explicitByOutpoint).filter((output) => !alreadyReserved.includes(output.outputId)).map((output) => [output.outputId, output])).values()];
14494
+ if (explicit.length > remainingCapacity) throw new WERR_INVALID_PARAMETER("explicitOutpoints", `no more than ${remainingCapacity} additional reserved outputs`);
14495
+ const explicitIds = new Set(explicit.map((output) => output.outputId));
14496
+ const availableOutputs = (await storage.findAvailableManagedChangeInputs(userId, basket.basketId, false)).filter((output) => !explicitIds.has(output.outputId));
14497
+ const availableStatuses = await storage.findTransactionStatusesByIds(userId, availableOutputs.map((output) => output.transactionId));
14498
+ const available = availableOutputs.map((output) => ({
14499
+ ...output,
14500
+ transactionStatus: availableStatuses.get(output.transactionId) ?? "sending"
14501
+ }));
14502
+ const requestedCount = Math.min(args.requestedOutputs, 64, remainingCapacity - explicit.length);
14503
+ const fundingOutputs = chooseReservationPool(available, Math.max(1, args.targetSatoshis), requestedCount, 0, true, reservationPlanningCosts(storage, basket)).map(reservationOutput);
14504
+ const fundingResult = await makeFundingResult(storage, argsToFundingShape(args.includeSourceTransactions), [...fundingOutputs, ...explicit]);
13862
14505
  const expiresAt = new Date(Math.min(batch.hardExpiresAt.getTime(), Date.now() + ACTION_BATCH_LEASE_MS));
13863
14506
  await storage.transaction(async (trx) => {
13864
- const current = requireLiveBatch(await storage.findActionBatchForUpdate(userId, args.batchId, trx));
13865
- await reserveOutputs(storage, current, [...funding, ...explicit], trx);
14507
+ const current = requireLiveBatch(await storage.findActionBatchForUpdate(userId, args.batchId, trx), args.batchId);
14508
+ const additions = [...new Map([...fundingOutputs, ...explicit].map((output) => [output.outputId, output])).values()];
14509
+ const currentReserved = new Set(await storage.findActionBatchOutputIds(current.actionBatchId, trx));
14510
+ const newAdditions = additions.filter((output) => !currentReserved.has(output.outputId));
14511
+ if (maxReservedOutputs >= 0 && currentReserved.size + newAdditions.length > maxReservedOutputs) throw new WERR_INVALID_OPERATION(`action batch cannot reserve more than ${maxReservedOutputs} outputs`);
14512
+ await reserveOutputs(storage, current, newAdditions, trx);
13866
14513
  await storage.updateActionBatch(current.actionBatchId, { expiresAt }, trx);
13867
14514
  });
13868
14515
  return {
13869
14516
  expiresAt: expiresAt.toISOString(),
13870
- reservedOutputs: fundingResult.outputs.filter((output) => funding.some((candidate) => candidate.outputId === output.outputId)),
14517
+ reservedOutputs: fundingResult.outputs.filter((output) => fundingOutputs.some((candidate) => candidate.outputId === output.outputId)),
13871
14518
  explicitOutputs: fundingResult.outputs.filter((output) => explicit.some((candidate) => candidate.outputId === output.outputId)),
13872
14519
  inputBeef: fundingResult.beef
13873
14520
  };
@@ -13903,14 +14550,64 @@ function argsToFundingShape(includeSourceTransactions) {
13903
14550
  async function renewActionBatch(storage, auth, batchId) {
13904
14551
  const userId = verifyId(auth.userId);
13905
14552
  return await storage.transaction(async (trx) => {
13906
- const batch = requireLiveBatch(await storage.findActionBatchForUpdate(userId, batchId, trx));
14553
+ const batch = requireLiveBatch(await storage.findActionBatchForUpdate(userId, batchId, trx), batchId);
13907
14554
  const expiresAt = new Date(Math.min(batch.hardExpiresAt.getTime(), Date.now() + ACTION_BATCH_LEASE_MS));
13908
14555
  await storage.updateActionBatch(batch.actionBatchId, { expiresAt }, trx);
13909
14556
  return { expiresAt: expiresAt.toISOString() };
13910
14557
  });
13911
14558
  }
14559
+ function validateResumeOutpoints(storage, args) {
14560
+ const unique = new Set(args.outpoints.map((outpoint) => `${outpoint.txid}.${outpoint.vout}`));
14561
+ const maxReservedOutputs = storage.actionBatchMaxReservedOutputs;
14562
+ 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`);
14563
+ }
14564
+ /**
14565
+ * Reacquire exactly the persisted inputs owned by one expired client
14566
+ * workspace. This is deliberately explicit: another action may not become a
14567
+ * member merely because it happens to use the same Wallet instance.
14568
+ */
14569
+ async function resumeActionBatch(storage, auth, args) {
14570
+ const userId = verifyId(auth.userId);
14571
+ validateResumeOutpoints(storage, args);
14572
+ await cleanupExpiredActionBatches(storage);
14573
+ return await storage.transaction(async (trx) => {
14574
+ const batch = await storage.findActionBatchForUpdate(userId, args.batchId, trx);
14575
+ const state = actionBatchErrorState(batch);
14576
+ if (state != null && state !== "expired") throw new WERRActionBatchState(state, args.batchId);
14577
+ if (batch == null) throw new WERRActionBatchState("missing", args.batchId);
14578
+ const expiresAt = new Date(Math.min(batch.hardExpiresAt.getTime(), Date.now() + ACTION_BATCH_LEASE_MS));
14579
+ if (state == null) {
14580
+ const reserved = new Set(await storage.findActionBatchOutputIds(batch.actionBatchId, trx));
14581
+ const current = await storage.findOutputsByOutpointsForUpdate(userId, args.outpoints, trx);
14582
+ 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);
14583
+ await storage.updateActionBatch(batch.actionBatchId, { expiresAt }, trx);
14584
+ return { expiresAt: expiresAt.toISOString() };
14585
+ }
14586
+ const stored = await storage.findOutputsByOutpointsForUpdate(userId, args.outpoints, trx);
14587
+ const outputs = args.outpoints.map((outpoint) => stored[`${outpoint.txid}.${outpoint.vout}`]);
14588
+ if (outputs.some((output) => output == null || !output.spendable || output.spentBy != null)) throw new WERRActionBatchState("conflicted", args.batchId);
14589
+ await storage.deleteActionBatchOutputReservations(batch.actionBatchId, trx);
14590
+ const exactOutputs = outputs;
14591
+ if ((await storage.findReservedActionBatchOutputIds(exactOutputs.map((output) => output.outputId), trx)).length > 0) throw new WERRActionBatchState("conflicted", args.batchId);
14592
+ const now = /* @__PURE__ */ new Date();
14593
+ await storage.reserveActionBatchOutputs(exactOutputs.map((output) => ({
14594
+ actionBatchId: batch.actionBatchId,
14595
+ outputId: output.outputId,
14596
+ created_at: now,
14597
+ updated_at: now
14598
+ })), trx);
14599
+ await storage.updateActionBatch(batch.actionBatchId, {
14600
+ status: "active",
14601
+ expiresAt,
14602
+ manifest: void 0,
14603
+ manifestDigest: void 0,
14604
+ uploadDigests: void 0
14605
+ }, trx);
14606
+ return { expiresAt: expiresAt.toISOString() };
14607
+ });
14608
+ }
13912
14609
  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");
14610
+ if (batch.hardExpiresAt.getTime() <= Date.now()) throw new WERRActionBatchState("hard-expired", batch.batchId);
13914
14611
  const stagedTxids = new Set(validated.actions.map(({ action }) => action.txid));
13915
14612
  const outpoints = [...new Map(validated.actions.flatMap(({ action }) => action.plan.inputs).filter((input) => !stagedTxids.has(input.sourceTxid)).map((input) => [`${input.sourceTxid}.${input.sourceVout}`, {
13916
14613
  txid: input.sourceTxid,
@@ -14077,7 +14774,7 @@ async function persistAction(...[storage, userId, validated, reservedOutputIds,
14077
14774
  async function persistManifestAtomically(storage, userId, batch, manifest, validated) {
14078
14775
  return await storage.transaction(async (trx) => {
14079
14776
  const current = await storage.findActionBatchForUpdate(userId, batch.batchId, trx);
14080
- if (current == null) throw new WERR_INVALID_OPERATION("action batch was not found");
14777
+ if (current == null) throw new WERRActionBatchState("missing", batch.batchId);
14081
14778
  if (current.status === "committed") {
14082
14779
  if (current.manifestDigest !== manifest.digest) throw new WERR_INVALID_OPERATION("batch committed with another manifest");
14083
14780
  return {
@@ -14085,12 +14782,12 @@ async function persistManifestAtomically(storage, userId, batch, manifest, valid
14085
14782
  alreadyCommitted: true
14086
14783
  };
14087
14784
  }
14088
- if (current.status === "aborted") throw new WERR_INVALID_OPERATION("aborted action batch cannot be committed");
14785
+ if (current.status === "aborted") throw new WERRActionBatchState("aborted", batch.batchId);
14089
14786
  if (current.manifestDigest != null && current.manifestDigest !== manifest.digest) throw new WERR_INVALID_OPERATION("batch prepared with another manifest");
14090
14787
  if (current.status === "expired" || activeExpiry(current)) {
14091
- if (current.hardExpiresAt.getTime() <= Date.now()) throw new WERR_INVALID_OPERATION("action batch hard lifetime has expired");
14788
+ if (current.hardExpiresAt.getTime() <= Date.now()) throw new WERRActionBatchState("hard-expired", batch.batchId);
14092
14789
  await reacquireManifestInputs(storage, userId, current, validated, trx);
14093
- } else requireLiveBatch(current);
14790
+ } else requireLiveBatch(current, batch.batchId);
14094
14791
  const reservedOutputIds = new Set(await storage.findActionBatchOutputIds(current.actionBatchId, trx));
14095
14792
  const stagedTxids = new Set(validated.actions.map(({ action }) => action.txid));
14096
14793
  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 +14865,14 @@ async function completeCommittedBatch(storage, userId, batch, manifest, alreadyC
14168
14865
  }
14169
14866
  async function commitActionBatchOnce(storage, userId, manifest) {
14170
14867
  const batch = await storage.findActionBatch(userId, manifest.batchId);
14171
- if (batch == null) throw new WERR_INVALID_OPERATION("action batch was not found");
14868
+ if (batch == null) throw new WERRActionBatchState("missing", manifest.batchId);
14172
14869
  if (batch.status === "committed") {
14173
14870
  if (batch.manifestDigest !== manifest.digest) throw new WERR_INVALID_OPERATION("batch committed with another manifest");
14174
14871
  return await completeCommittedBatch(storage, userId, batch, manifest, true);
14175
14872
  }
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");
14873
+ if (batch.status === "aborted") throw new WERRActionBatchState("aborted", manifest.batchId);
14874
+ if (!(batch.status === "expired" || activeExpiry(batch))) requireLiveBatch(batch, manifest.batchId);
14875
+ else if (batch.hardExpiresAt.getTime() <= Date.now()) throw new WERRActionBatchState("hard-expired", manifest.batchId);
14179
14876
  if (batch.manifestDigest != null && batch.manifestDigest !== manifest.digest) throw new WERR_INVALID_OPERATION("batch prepared with another manifest");
14180
14877
  const persisted = await persistManifestAtomically(storage, userId, batch, manifest, await validateManifestActions(storage, batch, manifest));
14181
14878
  return await completeCommittedBatch(storage, userId, persisted.batch, manifest, persisted.alreadyCommitted, persisted.share);
@@ -14258,6 +14955,8 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14258
14955
  commissionSatoshis;
14259
14956
  commissionPubKeyHex;
14260
14957
  maxRecursionDepth;
14958
+ actionBatchMaxReservedOutputs;
14959
+ managedChangePolicy;
14261
14960
  scriptVerifier;
14262
14961
  static defaultOptions() {
14263
14962
  return {
@@ -14266,7 +14965,9 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14266
14965
  value: 100
14267
14966
  },
14268
14967
  commissionSatoshis: 0,
14269
- commissionPubKeyHex: void 0
14968
+ commissionPubKeyHex: void 0,
14969
+ actionBatchMaxReservedOutputs: 256,
14970
+ managedChangePolicy: defaultManagedChangePolicy()
14270
14971
  };
14271
14972
  }
14272
14973
  static createStorageBaseOptions(chain) {
@@ -14281,6 +14982,10 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14281
14982
  this.commissionPubKeyHex = options.commissionPubKeyHex;
14282
14983
  this.commissionSatoshis = options.commissionSatoshis;
14283
14984
  this.maxRecursionDepth = 12;
14985
+ const maxReservedOutputs = options.actionBatchMaxReservedOutputs ?? 256;
14986
+ if (maxReservedOutputs !== -1 && (!Number.isSafeInteger(maxReservedOutputs) || maxReservedOutputs < 1)) throw new WERR_INVALID_PARAMETER("actionBatchMaxReservedOutputs", "a positive safe integer or -1 for unlimited");
14987
+ this.actionBatchMaxReservedOutputs = maxReservedOutputs;
14988
+ this.managedChangePolicy = validateManagedChangePolicy(options.managedChangePolicy);
14284
14989
  this.scriptVerifier = options.scriptVerifier;
14285
14990
  }
14286
14991
  /** Mark a planned set of change inputs spent within the caller's transaction. */
@@ -14311,12 +15016,16 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14311
15016
  }
14312
15017
  /** Read only the fields needed by the in-memory funding planner. */
14313
15018
  async findAvailableManagedChangeInputCandidates(userId, basketId, excludeSending, trx) {
14314
- return (await this.findAvailableManagedChangeInputs(userId, basketId, excludeSending, trx)).map(({ outputId, transactionId, satoshis, txid, vout }) => ({
15019
+ const outputs = await this.findAvailableManagedChangeInputs(userId, basketId, excludeSending, trx);
15020
+ const findStatuses = this.findTransactionStatusesByIds?.bind(this);
15021
+ const statuses = findStatuses == null ? /* @__PURE__ */ new Map() : await findStatuses(userId, outputs.map((output) => output.transactionId), trx);
15022
+ return outputs.map(({ outputId, transactionId, satoshis, txid, vout }) => ({
14315
15023
  outputId,
14316
15024
  transactionId,
14317
15025
  satoshis,
14318
15026
  txid,
14319
- vout
15027
+ vout,
15028
+ transactionStatus: statuses.get(transactionId)
14320
15029
  }));
14321
15030
  }
14322
15031
  /** Read the current status of a set of source transactions without loading raw transaction bytes. */
@@ -14404,7 +15113,7 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14404
15113
  throw new WERR_NOT_IMPLEMENTED();
14405
15114
  }
14406
15115
  async getCapabilities() {
14407
- return this.supportsActionBatchPersistence() ? getActionBatchCapabilities() : {};
15116
+ return this.supportsActionBatchPersistence() ? getActionBatchCapabilities(this.actionBatchMaxReservedOutputs, true) : {};
14408
15117
  }
14409
15118
  supportsActionBatchPersistence() {
14410
15119
  return false;
@@ -14423,6 +15132,9 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14423
15132
  async renewActionBatch(auth, batchId) {
14424
15133
  return await renewActionBatch(this, auth, batchId);
14425
15134
  }
15135
+ async resumeActionBatch(auth, args) {
15136
+ return await resumeActionBatch(this, auth, args);
15137
+ }
14426
15138
  async prepareActionBatchCommit(auth, manifest) {
14427
15139
  return await prepareActionBatchCommit(this, auth, manifest);
14428
15140
  }
@@ -15020,7 +15732,12 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
15020
15732
  for (const output of outputs) {
15021
15733
  const outputId = verifyId(output.outputId);
15022
15734
  await this.validateOutputScript(output);
15023
- verdicts.set(outputId, output.lockingScript == null ? void 0 : await services.isUtxo(output));
15735
+ if (output.lockingScript == null) {
15736
+ verdicts.set(outputId, void 0);
15737
+ continue;
15738
+ }
15739
+ const classification = await classifyOutputUtxo(services, output);
15740
+ verdicts.set(outputId, classification.verdict === "unknown" ? void 0 : classification.verdict === "unspent");
15024
15741
  }
15025
15742
  }
15026
15743
  return verdicts;
@@ -15182,16 +15899,12 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
15182
15899
  } });
15183
15900
  for (const o of outputs) {
15184
15901
  if (!o.spendable) continue;
15185
- if (!await this.checkOutputIsUtxo(o, services)) invalidSpendableOutputs.push(o);
15902
+ await this.validateOutputScript(o);
15903
+ if (!requireConclusiveUtxo(await classifyOutputUtxo(services, o))) invalidSpendableOutputs.push(o);
15186
15904
  }
15187
15905
  }
15188
15906
  return { invalidSpendableOutputs };
15189
15907
  }
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
15908
  async updateProvenTxReqDynamics(id, update, trx) {
15196
15909
  const partial = {};
15197
15910
  if (update.updated_at != null) partial.updated_at = update.updated_at;
@@ -15301,6 +16014,7 @@ const getLabelToSpecOp = () => {
15301
16014
  //#region ../src/storage/methods/listActionsIdb.ts
15302
16015
  async function enrichIdbActionLabels(storage, tx, action, timeFilterRequested) {
15303
16016
  action.labels = (await storage.getLabelsForTransactionId(tx.transactionId)).map((l) => l.label);
16017
+ if (tx.reference != null && tx.reference !== "") action.labels = applyBrc153ReferenceLabel(action.labels, tx.reference);
15304
16018
  if (timeFilterRequested) {
15305
16019
  const ts = tx.created_at ? new Date(tx.created_at).getTime() : NaN;
15306
16020
  if (!Number.isNaN(ts)) {
@@ -15419,20 +16133,129 @@ async function listActionsIdb(storage, auth, vargs) {
15419
16133
  return r;
15420
16134
  }
15421
16135
  //#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);
16136
+ //#region ../src/storage/methods/reviewUtxoOutputs.ts
16137
+ const MAX_AUDIT_PROVIDERS = 8;
16138
+ const MAX_PROVIDER_NAME_LENGTH = 128;
16139
+ const UTXO_REVIEW_PROVIDER_TIMEOUT_MSECS = 5e3;
16140
+ function diagnosticsFor(classifications, releasedOutputIds) {
16141
+ const allProviders = [...new Set(classifications.map((result) => result.status.provider.slice(0, MAX_PROVIDER_NAME_LENGTH)))];
16142
+ const providers = allProviders.slice(0, MAX_AUDIT_PROVIDERS);
16143
+ const confirmedSpent = classifications.filter((result) => result.status.verdict === "spent");
16144
+ const released = confirmedSpent.filter((result) => releasedOutputIds.has(result.output.outputId));
16145
+ return {
16146
+ checked: classifications.length,
16147
+ confirmedUnspent: classifications.filter((result) => result.status.verdict === "unspent").length,
16148
+ confirmedSpent: confirmedSpent.length,
16149
+ unknown: classifications.filter((result) => result.status.verdict === "unknown").length,
16150
+ confirmedSpentSatoshis: confirmedSpent.reduce((sum, result) => sum + result.output.satoshis, 0),
16151
+ released: released.length,
16152
+ releasedSatoshis: released.reduce((sum, result) => sum + result.output.satoshis, 0),
16153
+ providers,
16154
+ providerCount: allProviders.length,
16155
+ providersTruncated: allProviders.length > providers.length
16156
+ };
16157
+ }
16158
+ function auditDetails(diagnostics, userId, releaseMode, reason) {
16159
+ return JSON.stringify({
16160
+ operation: "specOpInvalidChange",
16161
+ reason,
16162
+ releaseMode,
16163
+ userId,
16164
+ ...diagnostics
16165
+ });
16166
+ }
16167
+ async function classifyCandidates(storage, outputs) {
16168
+ const services = storage.getServices();
16169
+ return await mapWithConcurrency(outputs.filter((output) => output.basketId != null), 4, async (output) => {
16170
+ try {
16171
+ await storage.validateOutputScript(output);
16172
+ } catch (error) {
16173
+ return {
16174
+ output,
16175
+ status: {
16176
+ verdict: "unknown",
16177
+ provider: "<script-validation-error>",
16178
+ error
16179
+ }
16180
+ };
16181
+ }
16182
+ let timeout;
16183
+ const providerCall = classifyOutputUtxo(services, output);
16184
+ const timedClassification = new Promise((resolve) => {
16185
+ timeout = setTimeout(() => resolve({
16186
+ verdict: "unknown",
16187
+ provider: "<review-timeout>",
16188
+ error: /* @__PURE__ */ new Error(`UTXO classification exceeded ${UTXO_REVIEW_PROVIDER_TIMEOUT_MSECS}ms`)
16189
+ }), UTXO_REVIEW_PROVIDER_TIMEOUT_MSECS);
16190
+ });
16191
+ try {
16192
+ return {
16193
+ output,
16194
+ status: await Promise.race([providerCall, timedClassification])
16195
+ };
16196
+ } finally {
16197
+ if (timeout != null) clearTimeout(timeout);
16198
+ providerCall.catch(() => void 0);
16199
+ }
16200
+ });
16201
+ }
16202
+ async function releaseConfirmedSpent(storage, auth, classifications, releaseMode) {
16203
+ const confirmedSpent = classifications.filter((result) => result.status.verdict === "spent");
16204
+ const releasedOutputIds = /* @__PURE__ */ new Set();
16205
+ await storage.transaction(async (trx) => {
16206
+ for (const { output } of confirmedSpent) {
16207
+ const current = await storage.findOutputById(output.outputId, trx, true);
16208
+ if (current == null || current.userId !== auth.userId) throw new WERR_INVALID_PARAMETER("outputId", `owned by user ${auth.userId}`);
16209
+ if (current.spendable !== true || current.spentBy != null) throw new WERR_INVALID_OPERATION(`Output ${output.outputId} changed state during UTXO review; no outputs were changed.`);
16210
+ if (await storage.updateOutput(verifyId(output.outputId), { spendable: false }, trx) !== 1) throw new WERR_INVALID_PARAMETER("outputId", `updated exactly once: ${output.outputId}`);
16211
+ releasedOutputIds.add(output.outputId);
16212
+ }
16213
+ const now = /* @__PURE__ */ new Date();
16214
+ const diagnostics = diagnosticsFor(classifications, releasedOutputIds);
16215
+ await storage.insertMonitorEvent({
16216
+ created_at: now,
16217
+ updated_at: now,
16218
+ id: 0,
16219
+ event: releaseMode === "atomic" ? "InvalidChangeRelease" : "InvalidChangeConclusiveRelease",
16220
+ details: auditDetails(diagnostics, auth.userId, releaseMode, "provider-confirmed-spent")
16221
+ }, trx);
16222
+ });
16223
+ return releasedOutputIds;
16224
+ }
16225
+ /**
16226
+ * Classify a bounded set of wallet outputs without treating provider failure as
16227
+ * proof that an output was spent. Read-only reviews always return their
16228
+ * conclusive and unknown partitions. Atomic release requires every verdict to
16229
+ * be conclusive; the operator-only conclusive mode releases the positively
16230
+ * spent subset while retaining and reporting unknowns.
16231
+ */
16232
+ async function reviewUtxoOutputs(storage, auth, outputs, releaseMode = "none") {
16233
+ const classifications = await classifyCandidates(storage, outputs);
16234
+ const confirmedSpentOutputs = classifications.filter((result) => result.status.verdict === "spent").map((result) => result.output);
16235
+ const unknownOutputs = classifications.filter((result) => result.status.verdict === "unknown").map((result) => result.output);
16236
+ if (releaseMode === "atomic" && unknownOutputs.length > 0) {
16237
+ const diagnostics = diagnosticsFor(classifications, /* @__PURE__ */ new Set());
16238
+ const now = /* @__PURE__ */ new Date();
16239
+ await storage.insertMonitorEvent({
16240
+ created_at: now,
16241
+ updated_at: now,
16242
+ id: 0,
16243
+ event: "InvalidChangeReleaseBlocked",
16244
+ details: auditDetails(diagnostics, auth.userId, releaseMode, "inconclusive-provider-result")
15430
16245
  });
15431
- active.push(task);
15432
- if (active.length >= maxConcurrency) await Promise.race(active);
16246
+ throw new WERR_UTXO_REVIEW_INCONCLUSIVE(diagnostics.checked, diagnostics.confirmedSpent, diagnostics.unknown);
15433
16247
  }
15434
- await Promise.all(active);
16248
+ const releasedOutputIds = releaseMode === "none" ? /* @__PURE__ */ new Set() : await releaseConfirmedSpent(storage, auth, classifications, releaseMode);
16249
+ for (const output of confirmedSpentOutputs) if (releasedOutputIds.has(output.outputId)) output.spendable = false;
16250
+ return {
16251
+ classifications,
16252
+ confirmedSpentOutputs,
16253
+ unknownOutputs,
16254
+ diagnostics: diagnosticsFor(classifications, releasedOutputIds)
16255
+ };
15435
16256
  }
16257
+ //#endregion
16258
+ //#region ../src/storage/methods/ListOutputsSpecOp.ts
15436
16259
  const getBasketToSpecOp = () => {
15437
16260
  return {
15438
16261
  [specOpWalletBalance]: {
@@ -15463,23 +16286,7 @@ const getBasketToSpecOp = () => {
15463
16286
  includeSpent: false,
15464
16287
  tagsToIntercept: ["release", "all"],
15465
16288
  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;
16289
+ return (await reviewUtxoOutputs(s, auth, outputs, specOpTags.includes("release") ? "atomic" : "none")).confirmedSpentOutputs;
15483
16290
  }
15484
16291
  },
15485
16292
  [specOpSetWalletChangeParams]: {
@@ -15632,7 +16439,7 @@ async function loadIdbOutputs(query) {
15632
16439
  "nosend",
15633
16440
  "sending"
15634
16441
  ],
15635
- noScript: true,
16442
+ noScript: specOp?.includeOutputScripts !== true,
15636
16443
  orderDescending
15637
16444
  };
15638
16445
  const pageManagedChange = specOp?.managedChangeOnly === true && specOp.ignoreLimit !== true;
@@ -15714,7 +16521,10 @@ async function listOutputsIdb(storage, auth, vargs, _originator) {
15714
16521
  });
15715
16522
  result.totalOutputs = totalOutputs;
15716
16523
  if (specOp != null) {
15717
- if (specOp.filterOutputs != null) outputs = await specOp.filterOutputs(storage, auth, vargs, specOpTags, outputs);
16524
+ if (specOp.filterOutputs != null) {
16525
+ outputs = await specOp.filterOutputs(storage, auth, vargs, specOpTags, outputs);
16526
+ result.totalOutputs = outputs.length;
16527
+ }
15718
16528
  if (specOp.resultFromOutputs != null) return specOp.resultFromOutputs(storage, auth, vargs, specOpTags, outputs);
15719
16529
  }
15720
16530
  await hydrateIdbOutputResult(storage, outputs, vargs, result);
@@ -15845,6 +16655,7 @@ async function scanCursor(cursor, since, offset, limit, matches, accept) {
15845
16655
  var StorageIdb = class extends StorageProvider {
15846
16656
  dbName;
15847
16657
  db;
16658
+ managedChangeDefaultsMigrated = false;
15848
16659
  constructor(options) {
15849
16660
  super(options);
15850
16661
  this.dbName = `wallet-toolbox-${this.chain}net`;
@@ -15882,6 +16693,7 @@ var StorageIdb = class extends StorageProvider {
15882
16693
  async verifyDB(storageName, storageIdentityKey) {
15883
16694
  if (this.db != null) return this.db;
15884
16695
  this.db = await this.initDB(storageName, storageIdentityKey);
16696
+ await this.migrateManagedChangeDefaults(this.db);
15885
16697
  this._settings = (await this.db.getAll("settings"))[0];
15886
16698
  this.whenLastAccess = /* @__PURE__ */ new Date();
15887
16699
  return this.db;
@@ -15915,7 +16727,7 @@ var StorageIdb = class extends StorageProvider {
15915
16727
  async initDB(storageName, storageIdentityKey) {
15916
16728
  const chain = this.chain;
15917
16729
  const maxOutputScript = 1024;
15918
- return await (0, idb.openDB)(this.dbName, 3, { upgrade(db, _oldVersion, _newVersion, transaction) {
16730
+ return await (0, idb.openDB)(this.dbName, 4, { upgrade(db, _oldVersion, _newVersion, transaction) {
15919
16731
  upgradeAllStoresV1(db);
15920
16732
  upgradeActionBatchStoresV2(db);
15921
16733
  const outputs = transaction.objectStore("outputs");
@@ -15941,6 +16753,22 @@ var StorageIdb = class extends StorageProvider {
15941
16753
  }
15942
16754
  } });
15943
16755
  }
16756
+ async migrateManagedChangeDefaults(db) {
16757
+ if (this.managedChangeDefaultsMigrated) return;
16758
+ const trx = db.transaction("output_baskets", "readwrite");
16759
+ let cursor = await trx.store.openCursor();
16760
+ while (cursor != null) {
16761
+ const basket = cursor.value;
16762
+ if (isLegacyManagedChangeBasketDefault(basket)) await cursor.update({
16763
+ ...basket,
16764
+ minimumDesiredUTXOValue: DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS,
16765
+ updated_at: /* @__PURE__ */ new Date()
16766
+ });
16767
+ cursor = await cursor.continue();
16768
+ }
16769
+ await trx.done;
16770
+ this.managedChangeDefaultsMigrated = true;
16771
+ }
15944
16772
  async reviewStatus(args) {
15945
16773
  return await reviewStatusIdb(this, args);
15946
16774
  }
@@ -16695,6 +17523,7 @@ var StorageIdb = class extends StorageProvider {
16695
17523
  if (this.db != null) this.db.close();
16696
17524
  this.db = void 0;
16697
17525
  this._settings = void 0;
17526
+ this.managedChangeDefaultsMigrated = false;
16698
17527
  }
16699
17528
  allStores = [
16700
17529
  "action_batches",
@@ -17867,6 +18696,9 @@ var StorageClientBase = class {
17867
18696
  async renewActionBatch(auth, batchId) {
17868
18697
  return await this.rpcCall("renewActionBatch", [auth, batchId]);
17869
18698
  }
18699
+ async resumeActionBatch(auth, args) {
18700
+ return await this.rpcCall("resumeActionBatch", [auth, args]);
18701
+ }
17870
18702
  async prepareActionBatchCommit(auth, manifest) {
17871
18703
  return await this.rpcCall("prepareActionBatchCommit", [auth, manifest]);
17872
18704
  }
@@ -18671,7 +19503,7 @@ async function restoreBRC38(storage, data) {
18671
19503
  await storage.transaction(async (trx) => {
18672
19504
  await storage.insertUser({ ...data.user }, trx);
18673
19505
  for (const row of data.provenTxs) await storage.insertProvenTx({ ...row }, trx);
18674
- for (const row of data.outputBaskets) await storage.insertOutputBasket({ ...row }, trx);
19506
+ for (const row of data.outputBaskets) await storage.insertOutputBasket(upgradeLegacyManagedChangeBasketDefault({ ...row }), trx);
18675
19507
  for (const row of data.outputTags) await storage.insertOutputTag({ ...row }, trx);
18676
19508
  for (const row of data.txLabels) await storage.insertTxLabel({ ...row }, trx);
18677
19509
  for (const row of data.transactions) await storage.insertTransaction({ ...row }, trx);
@@ -21730,13 +22562,37 @@ function configuredChaintracksClient(chain, serviceUrl) {
21730
22562
  return new ChaintracksServiceClient(chain, serviceUrl);
21731
22563
  }
21732
22564
  /**
22565
+ * True when running under a browser-style runtime — a real browser tab, or
22566
+ * the embedded webview a desktop wallet shell hosts its wallet logic in
22567
+ * (Metanet Client / User Wallet are Tauri WKWebViews) — where `fetch` is
22568
+ * subject to CORS enforcement that Node-style runtimes do not apply.
22569
+ */
22570
+ function isBrowserRuntime() {
22571
+ return typeof window !== "undefined" && window.document !== void 0;
22572
+ }
22573
+ /**
21733
22574
  * Returns the credential-free default ChainTracks client for a supported
21734
22575
  * public network, or an operator-configured client for stn/tstn.
22576
+ *
22577
+ * BROWSER RUNTIMES get the legacy CORS-enabled Chaintracks service for
22578
+ * main/test. The Go Chaintracks deployments (`arcade-v2-*.bsvblockchain.tech`)
22579
+ * currently serve no `Access-Control-Allow-Origin` header and answer OPTIONS
22580
+ * preflights with 404 (verified live 2026-08-11), so every fetch from a
22581
+ * browser-hosted wallet is CORS-blocked (WebKit surfaces it as
22582
+ * `TypeError: Load failed`) and the wallet loses `getHeight`, headers and
22583
+ * merkle-root validation wholesale. The repository service contract
22584
+ * (AGENTS.md: "browser, mobile, and unknown-domain clients must not be
22585
+ * silently blocked by CORS") requires a default that browsers can actually
22586
+ * reach. Once the Go deployments serve CORS (and a browser-run conformance
22587
+ * check proves it), this branch can be removed and browsers can share the v2
22588
+ * default. Node runtimes are unchanged.
21735
22589
  */
21736
22590
  function createDefaultChaintracksClient(chain) {
21737
22591
  switch (chain) {
21738
22592
  case "main":
21739
22593
  case "test":
22594
+ if (isBrowserRuntime()) return new ChaintracksServiceClient(chain, `https://${chain}net-chaintracks.babbage.systems`);
22595
+ return new GoChaintracksServiceClient(chain, arcadeDefaultUrl(chain), { apiPrefix: "/chaintracks/v2" });
21740
22596
  case "ttn": return new GoChaintracksServiceClient(chain, arcadeDefaultUrl(chain), { apiPrefix: "/chaintracks/v2" });
21741
22597
  case "stn": return configuredChaintracksClient(chain, stnChaintracksUrl());
21742
22598
  case "tstn": return configuredChaintracksClient(chain, tstnChaintracksUrl());
@@ -21775,7 +22631,7 @@ function createDefaultWalletServicesOptions(...[chain, arcCallbackUrl, arcCallba
21775
22631
  exchangeratesapiKey: void 0,
21776
22632
  chaintracksFiatExchangeRatesUrl,
21777
22633
  chaintracks,
21778
- arcUrl: arcDefaultUrl(chain),
22634
+ arcUrl: chain === "ttn" ? "" : arcDefaultUrl(chain),
21779
22635
  arcConfig: {
21780
22636
  apiKey: taalArcApiKey ?? void 0,
21781
22637
  deploymentId,
@@ -21791,7 +22647,7 @@ function createDefaultWalletServicesOptions(...[chain, arcCallbackUrl, arcCallba
21791
22647
  },
21792
22648
  bitailsApiKey
21793
22649
  };
21794
- const resolvedArcadeUrl = arcadeUrl;
22650
+ const resolvedArcadeUrl = arcadeUrl ?? (chain === "ttn" ? arcadeDefaultUrl(chain) : void 0);
21795
22651
  if (resolvedArcadeUrl != null && resolvedArcadeUrl !== "") {
21796
22652
  o.arcadeUrl = resolvedArcadeUrl;
21797
22653
  o.arcadeConfig = {
@@ -21821,7 +22677,7 @@ function arcDefaultUrl(chain) {
21821
22677
  case "main": return "https://arc.taal.com";
21822
22678
  case "test": return "https://arc-test.taal.com";
21823
22679
  case "stn": return stnArcadeUrl() ?? "";
21824
- case "ttn": return "https://arcade-v2-ttn-us-1.bsvblockchain.tech/";
22680
+ case "ttn": return "";
21825
22681
  case "tstn": return tstnArcadeUrl() ?? "";
21826
22682
  case "mock": return "";
21827
22683
  }
@@ -22193,6 +23049,40 @@ var ARC = class {
22193
23049
  }
22194
23050
  };
22195
23051
  //#endregion
23052
+ //#region ../src/services/providers/arcadeStatus.ts
23053
+ /**
23054
+ * Canonical classification shared by Arcade polling and SSE. Keeping this in
23055
+ * one place prevents a provider from calling a rejection "unknown" while the
23056
+ * monitor's event path calls the same status terminal.
23057
+ */
23058
+ function classifyArcadeRejection(event) {
23059
+ const extraInfo = event.extraInfo?.trim() ?? "";
23060
+ const detail = extraInfo.toLowerCase();
23061
+ if (event.status === 476 || detail.startsWith("parent rejected") || detail.includes("not final") || detail.includes("non-final")) return {
23062
+ terminal: false,
23063
+ inputConflict: false,
23064
+ reqStatus: "invalid",
23065
+ reason: event.status === 476 ? "transaction is not final" : "Arcade reported a retryable parent/locktime condition"
23066
+ };
23067
+ const orphanConflict = isArcDoubleSpendTxStatus(event.txStatus);
23068
+ const inputConflict = orphanConflict || event.status === 462 || event.status === 466 || /(?:utxo|input).*(?:spent|missing|conflict)|missing[- ]inputs?|already spent/.test(detail);
23069
+ const classifiedValidatorFailure = event.status != null && event.status >= 460 && event.status <= 475;
23070
+ const explicitInvalidStatus = isArcInvalidTxStatus(event.txStatus) && event.txStatus !== "REJECTED";
23071
+ const terminal = inputConflict || classifiedValidatorFailure || explicitInvalidStatus || extraInfo !== "";
23072
+ let reason = "Arcade supplied no durable rejection evidence";
23073
+ if (orphanConflict) reason = `Arcade reported ${event.txStatus}`;
23074
+ else if (inputConflict) reason = "Arcade supplied confirmed missing-input/conflict evidence";
23075
+ else if (classifiedValidatorFailure) reason = `Arcade supplied terminal validator code ${String(event.status)}`;
23076
+ else if (explicitInvalidStatus) reason = `Arcade reported terminal ${event.txStatus}`;
23077
+ else if (extraInfo !== "") reason = "Arcade supplied a terminal validator rejection reason";
23078
+ return {
23079
+ terminal,
23080
+ inputConflict,
23081
+ reqStatus: inputConflict ? "doubleSpend" : "invalid",
23082
+ reason
23083
+ };
23084
+ }
23085
+ //#endregion
22196
23086
  //#region ../src/services/providers/Arcade.ts
22197
23087
  function defaultDeploymentId() {
22198
23088
  return `ts-sdk-${_bsv_sdk.Utils.toHex((0, _bsv_sdk.Random)(16))}`;
@@ -22462,6 +23352,73 @@ var Arcade = class {
22462
23352
  return (await this.httpClient.request(`${this.URL}/tx/${txid}`, requestOptions)).data;
22463
23353
  }
22464
23354
  /**
23355
+ * Adapt Arcade's lifecycle endpoint to the shared transaction-status
23356
+ * provider contract. This lets monitor reconciliation remain operational
23357
+ * when an explorer such as WhatsOnChain is absent. Only network-observed
23358
+ * states count as known; RECEIVED/PENDING_RETRY and terminal rejection
23359
+ * states remain unknown, while MINED/IMMUTABLE are authoritative mined
23360
+ * observations whose proof is validated separately.
23361
+ */
23362
+ async getStatusForTxids(txids) {
23363
+ const r = {
23364
+ name: this.name,
23365
+ status: "success",
23366
+ results: []
23367
+ };
23368
+ let firstError;
23369
+ r.results = await Promise.all(txids.map(async (txid) => {
23370
+ try {
23371
+ const response = await this.httpClient.request(`${this.URL}/tx/${txid}`, {
23372
+ method: "GET",
23373
+ headers: this.requestHeaders()
23374
+ });
23375
+ if (Number(response.status) === 404) return {
23376
+ txid,
23377
+ status: "unknown",
23378
+ depth: void 0
23379
+ };
23380
+ if (!response.ok || Number(response.status) !== 200) throw new WERR_INVALID_OPERATION(`Arcade transaction status response ${String(response.status)}`);
23381
+ const status = response.data.txStatus;
23382
+ if (status === "MINED" || status === "IMMUTABLE") return {
23383
+ txid,
23384
+ status: "mined",
23385
+ depth: 1
23386
+ };
23387
+ if (status === "ACCEPTED_BY_NETWORK" || status === "SEEN_ON_NETWORK" || status === "SEEN_MULTIPLE_NODES") return {
23388
+ txid,
23389
+ status: "known",
23390
+ depth: 0
23391
+ };
23392
+ const classification = classifyArcadeRejection(response.data);
23393
+ return {
23394
+ txid,
23395
+ status: "unknown",
23396
+ depth: void 0,
23397
+ providerStatus: status,
23398
+ ...classification.terminal ? {
23399
+ terminal: true,
23400
+ inputConflict: classification.inputConflict,
23401
+ statusCode: response.data.status,
23402
+ description: (response.data.extraInfo || classification.reason).slice(0, 512),
23403
+ competingTxs: (response.data.competingTxs ?? []).slice(0, 24)
23404
+ } : {}
23405
+ };
23406
+ } catch (error_) {
23407
+ firstError ??= WalletError.fromUnknown(error_);
23408
+ return {
23409
+ txid,
23410
+ status: "unknown",
23411
+ depth: void 0
23412
+ };
23413
+ }
23414
+ }));
23415
+ if (firstError != null) {
23416
+ r.status = "error";
23417
+ r.error = firstError;
23418
+ }
23419
+ return r;
23420
+ }
23421
+ /**
22465
23422
  * `getMerklePath` provider: obtain a BUMP merkle proof for a mined transaction from Arcade.
22466
23423
  *
22467
23424
  * Arcade only has a proof for transactions it tracked (i.e. broadcast through it) that have
@@ -23252,6 +24209,10 @@ var Services = class Services {
23252
24209
  name: "WhatsOnChain",
23253
24210
  service: this.whatsonchain.getStatusForTxids.bind(this.whatsonchain)
23254
24211
  });
24212
+ if (this.arcade != null) this.getStatusForTxidsServices.add({
24213
+ name: "Arcade",
24214
+ service: this.arcade.getStatusForTxids.bind(this.arcade)
24215
+ });
23255
24216
  this.getScriptHashHistoryServices = new ServiceCollection("getScriptHashHistory");
23256
24217
  if (hasWhatsOnChain) this.getScriptHashHistoryServices.add({
23257
24218
  name: "WhatsOnChain",
@@ -23268,7 +24229,7 @@ var Services = class Services {
23268
24229
  name: "GorillaPoolArcBeef",
23269
24230
  service: this.arcGorillaPool.postBeef.bind(this.arcGorillaPool)
23270
24231
  });
23271
- this.postBeefServices.add({
24232
+ if (this.options.arcUrl != null && this.options.arcUrl !== "") this.postBeefServices.add({
23272
24233
  name: "TaalArcBeef",
23273
24234
  service: this.arcTaal.postBeef.bind(this.arcTaal)
23274
24235
  });
@@ -23354,30 +24315,65 @@ var Services = class Services {
23354
24315
  async getStatusForTxids(txids, useNext) {
23355
24316
  const services = this.getStatusForTxidsServices;
23356
24317
  if (useNext === true) services.next();
23357
- let r0 = {
24318
+ let fallback = {
23358
24319
  name: "<noservices>",
23359
24320
  status: "error",
23360
24321
  error: new WERR_INTERNAL("No services available."),
23361
24322
  results: []
23362
24323
  };
24324
+ const resultsByTxid = /* @__PURE__ */ new Map();
24325
+ const unresolved = new Set(txids);
24326
+ const providerNames = [];
24327
+ let successfulProvider = false;
24328
+ const rank = (result) => {
24329
+ if (result.status === "mined") return 4;
24330
+ if (result.status === "known") return 3;
24331
+ if (result.terminal === true) return 2;
24332
+ return 1;
24333
+ };
23363
24334
  for (let tries = 0; tries < services.count; tries++) {
23364
24335
  const stc = services.serviceToCall;
23365
24336
  try {
23366
- const r = await this.getStatusForTxidsBatched(stc, txids);
24337
+ const requestedTxids = [...unresolved];
24338
+ if (requestedTxids.length === 0) break;
24339
+ const r = await this.getStatusForTxidsBatched(stc, requestedTxids);
23367
24340
  if (r.status === "success") {
23368
24341
  services.addServiceCallSuccess(stc);
23369
- r0 = r;
23370
- break;
24342
+ successfulProvider = true;
24343
+ providerNames.push(r.name);
24344
+ for (const result of r.results) {
24345
+ const current = resultsByTxid.get(result.txid);
24346
+ if (current == null || rank(result) > rank(current)) resultsByTxid.set(result.txid, result);
24347
+ if (result.status === "mined" || result.status === "known") unresolved.delete(result.txid);
24348
+ }
24349
+ services.next();
24350
+ continue;
23371
24351
  }
24352
+ fallback = r;
23372
24353
  if (r.error != null) services.addServiceCallError(stc, r.error);
23373
24354
  else services.addServiceCallFailure(stc);
23374
24355
  } catch (error_) {
23375
24356
  const e = WalletError.fromUnknown(error_);
24357
+ fallback = {
24358
+ name: stc.providerName,
24359
+ status: "error",
24360
+ error: e,
24361
+ results: []
24362
+ };
23376
24363
  services.addServiceCallError(stc, e);
23377
24364
  }
23378
24365
  services.next();
23379
24366
  }
23380
- return r0;
24367
+ if (!successfulProvider) return fallback;
24368
+ return {
24369
+ name: [...new Set(providerNames)].join(","),
24370
+ status: "success",
24371
+ results: txids.map((txid) => resultsByTxid.get(txid) ?? {
24372
+ txid,
24373
+ status: "unknown",
24374
+ depth: void 0
24375
+ })
24376
+ };
23381
24377
  }
23382
24378
  async getStatusForTxidsBatched(stc, txids) {
23383
24379
  const results = [];
@@ -23404,9 +24400,7 @@ var Services = class Services {
23404
24400
  return _bsv_sdk.Utils.toHex(sha256Hash(_bsv_sdk.Utils.toArray(script, "hex")));
23405
24401
  }
23406
24402
  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;
24403
+ return requireConclusiveUtxo(await classifyOutputUtxo(this, output));
23410
24404
  }
23411
24405
  async getUtxoStatus(output, outputFormat, outpoint, useNext, logger) {
23412
24406
  const services = this.getUtxoStatusServices;
@@ -23632,7 +24626,15 @@ var Services = class Services {
23632
24626
  const method = async () => {
23633
24627
  return await this.options.chaintracks.currentHeight();
23634
24628
  };
23635
- return await this.invokeChaintracksWithRetry(method, "current_height");
24629
+ try {
24630
+ return await this.invokeChaintracksWithRetry(method, "current_height");
24631
+ } catch (error_) {
24632
+ try {
24633
+ return (await this.whatsonchain.getChainInfo()).blocks;
24634
+ } catch {
24635
+ throw error_;
24636
+ }
24637
+ }
23636
24638
  }
23637
24639
  async hashToHeader(hash) {
23638
24640
  const method = async () => {
@@ -27351,17 +28353,34 @@ var ArcSSEClient = class {
27351
28353
  console.log(`${TAG} connected`);
27352
28354
  });
27353
28355
  this.es.addEventListener("status", (event) => {
28356
+ let data;
27354
28357
  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);
28358
+ data = JSON.parse(event.data);
27362
28359
  } catch {
27363
28360
  console.log(`${TAG} malformed event: ${String(event.data).substring(0, 200)}`);
28361
+ return;
28362
+ }
28363
+ console.log(`${TAG} event: txid=${data.txid} status=${data.txStatus}`);
28364
+ const receivedEventId = typeof event.lastEventId === "string" && event.lastEventId !== "" ? event.lastEventId : void 0;
28365
+ data.eventId = receivedEventId;
28366
+ let processing;
28367
+ try {
28368
+ processing = this.options.onEvent(data);
28369
+ } catch (error) {
28370
+ const e = error instanceof Error ? error : new Error(String(error));
28371
+ console.log(`${TAG} event processing failed: ${e.message}`);
28372
+ this.options.onError?.(e);
28373
+ return;
27364
28374
  }
28375
+ Promise.resolve(processing).then(async () => {
28376
+ if (receivedEventId == null) return;
28377
+ await this.options.onLastEventIdChanged?.(receivedEventId);
28378
+ this._lastEventId = receivedEventId;
28379
+ }).catch((error) => {
28380
+ const e = error instanceof Error ? error : new Error(String(error));
28381
+ console.log(`${TAG} event processing failed: ${e.message}`);
28382
+ this.options.onError?.(e);
28383
+ });
27365
28384
  });
27366
28385
  this.es.addEventListener("error", (event) => {
27367
28386
  console.log(`${TAG} error:`, JSON.stringify(event));
@@ -27396,94 +28415,6 @@ var ArcSSEClient = class {
27396
28415
  }
27397
28416
  };
27398
28417
  //#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
28418
  //#region ../src/monitor/tasks/TaskArcSSE.ts
27488
28419
  /**
27489
28420
  * Monitor task that receives transaction status updates from Arcade via SSE
@@ -27527,16 +28458,14 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27527
28458
  arcApiKey: arcadeApiKey,
27528
28459
  lastEventId,
27529
28460
  EventSourceClass,
27530
- onEvent: (event) => {
27531
- this.pendingEvents.push(event);
27532
- },
28461
+ onEvent: async (event) => await new Promise((acknowledge) => {
28462
+ this.pendingEvents.push({
28463
+ event,
28464
+ acknowledge
28465
+ });
28466
+ }),
27533
28467
  onError: (err) => {
27534
28468
  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
28469
  }
27541
28470
  });
27542
28471
  this.sseClient.connect();
@@ -27550,10 +28479,21 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27550
28479
  return { run: this.pendingEvents.length > 0 };
27551
28480
  }
27552
28481
  async runTask() {
27553
- const events = this.pendingEvents.splice(0);
27554
- if (events.length === 0) return "";
28482
+ const eventCount = this.pendingEvents.length;
28483
+ if (eventCount === 0) return "";
27555
28484
  let log = "";
27556
- for (const event of events) log += await this.processStatusEvent(event);
28485
+ for (let i = 0; i < eventCount; i++) {
28486
+ const pending = this.pendingEvents[0];
28487
+ log += await this.processStatusEvent(pending.event);
28488
+ if (pending.event.eventId != null) try {
28489
+ await this.monitor.options.saveLastSSEEventId?.(pending.event.eventId);
28490
+ } catch (e) {
28491
+ await this.logSetupEvent(`failed to persist lastEventId: ${stringifyError(e)}`);
28492
+ throw e;
28493
+ }
28494
+ this.pendingEvents.shift();
28495
+ pending.acknowledge();
28496
+ }
27557
28497
  return log;
27558
28498
  }
27559
28499
  async fetchNow() {
@@ -27569,28 +28509,26 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27569
28509
  }
27570
28510
  for (const reqApi of reqs) {
27571
28511
  const req = new EntityProvenTxReq(reqApi);
27572
- const canRecoverRejectedRequest = this.canRecoverRejectedRequest(req, event);
27573
- if (ProvenTxReqTerminalStatus.includes(req.status) && !canRecoverRejectedRequest) {
28512
+ const canRecoverTerminalRequest = this.canRecoverTerminalRequest(req, event);
28513
+ if (ProvenTxReqTerminalStatus.includes(req.status) && !canRecoverTerminalRequest) {
27574
28514
  log += ` req ${req.id} already terminal: ${req.status}\n`;
27575
28515
  continue;
27576
28516
  }
27577
28517
  const note = {
27578
28518
  when: (/* @__PURE__ */ new Date()).toISOString(),
27579
28519
  what: "arcSSE",
27580
- arcStatus: event.txStatus
28520
+ arcStatus: event.txStatus,
28521
+ ...event.timestamp !== "" ? { arcTimestamp: event.timestamp } : {},
28522
+ ...event.status != null ? { statusCode: event.status } : {},
28523
+ ...event.extraInfo != null && event.extraInfo !== "" ? { extraInfo: event.extraInfo.slice(0, 512) } : {}
27581
28524
  };
27582
28525
  log += await this.applyStatusEvent(req, event, note);
27583
28526
  }
27584
28527
  this.monitor.callOnTransactionStatusChanged(event.txid, event.txStatus);
27585
28528
  return log;
27586
28529
  }
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));
28530
+ canRecoverTerminalRequest(req, event) {
28531
+ return (req.status === "invalid" || req.status === "doubleSpend") && ["MINED", "IMMUTABLE"].includes(event.txStatus);
27594
28532
  }
27595
28533
  async applyStatusEvent(req, event, note) {
27596
28534
  switch (event.txStatus) {
@@ -27600,11 +28538,16 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27600
28538
  case "SEEN_MULTIPLE_NODES": return await this.applyAcceptedStatus(req, note);
27601
28539
  case "MINED":
27602
28540
  case "IMMUTABLE": return await this.applyMinedStatus(req, note);
27603
- case "DOUBLE_SPEND_ATTEMPTED": return await this.applyDoubleSpendStatus(req, note);
27604
- case "REJECTED":
28541
+ case "DOUBLE_SPEND_ATTEMPTED":
28542
+ case "SEEN_IN_ORPHAN_MEMPOOL": return await this.applyDoubleSpendStatus(req, event, note);
28543
+ case "REJECTED": return await this.applyRejectedStatus(req, event, note);
28544
+ case "RECEIVED":
28545
+ case "PENDING_RETRY":
28546
+ case "STUMP_PROCESSING":
28547
+ case "UNKNOWN":
27605
28548
  req.addHistoryNote(note);
27606
28549
  await req.updateStorageDynamicProperties(this.storage);
27607
- return ` req ${req.id} rejection recorded; awaiting resolution\n`;
28550
+ return ` req ${req.id} ${event.txStatus} recorded; awaiting resolution\n`;
27608
28551
  default: return ` req ${req.id} unhandled status: ${event.txStatus}\n`;
27609
28552
  }
27610
28553
  }
@@ -27612,43 +28555,73 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27612
28555
  if (![
27613
28556
  "unsent",
27614
28557
  "sending",
27615
- "callback",
27616
- "invalid"
28558
+ "callback"
27617
28559
  ].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) => {
28560
+ if (req.notify.transactionIds != null) await this.storage.runAsStorageProvider(async (sp) => {
27622
28561
  await sp.updateTransactionsStatus(req.notify.transactionIds ?? [], "unproven");
27623
28562
  });
27624
28563
  req.status = "unmined";
27625
- if (wasInvalid) req.attempts = 0;
27626
28564
  req.wasBroadcast = true;
27627
28565
  req.addHistoryNote(note);
27628
28566
  await req.updateStorageDynamicProperties(this.storage);
27629
- return log + ` req ${req.id} => unmined\n`;
28567
+ return ` req ${req.id} => unmined\n`;
27630
28568
  }
27631
28569
  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
28570
  req.addHistoryNote(note);
27640
28571
  await req.updateStorageDynamicProperties(this.storage);
27641
- return log + await this.fetchProofFromServices(req);
28572
+ return await this.fetchProofFromServices(req);
27642
28573
  }
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");
28574
+ async applyDoubleSpendStatus(req, event, note) {
28575
+ return await this.applyTerminalFailure(req, note, classifyArcadeRejection(event));
28576
+ }
28577
+ classifyRejection(event) {
28578
+ return classifyArcadeRejection(event);
28579
+ }
28580
+ async applyRejectedStatus(req, event, note) {
28581
+ const classification = this.classifyRejection(event);
28582
+ if (!classification.terminal) {
28583
+ req.addHistoryNote({
28584
+ ...note,
28585
+ what: "arcSSERejectionPending",
28586
+ reason: classification.reason
28587
+ });
28588
+ await req.updateStorageDynamicProperties(this.storage);
28589
+ return ` req ${req.id} rejection recorded; awaiting resolution\n`;
28590
+ }
28591
+ return await this.applyTerminalFailure(req, note, classification);
28592
+ }
28593
+ async applyTerminalFailure(req, note, classification) {
28594
+ let quarantined = {
28595
+ checked: 0,
28596
+ staleConfirmed: 0,
28597
+ staleOutpoints: []
28598
+ };
28599
+ await this.storage.runAsStorageProvider(async (sp) => {
28600
+ await sp.transaction(async (trx) => {
28601
+ req.status = classification.reqStatus;
28602
+ req.addHistoryNote({
28603
+ ...note,
28604
+ what: "arcSSETerminalRejection",
28605
+ reason: classification.reason,
28606
+ inputConflict: classification.inputConflict
28607
+ });
28608
+ await req.updateStorageDynamicProperties(sp, trx);
28609
+ const ids = req.notify.transactionIds;
28610
+ if (ids != null) await sp.updateTransactionsStatus(ids, "failed", trx);
28611
+ if (classification.inputConflict) {
28612
+ quarantined = await quarantineReqInputs(req, sp, trx);
28613
+ req.addHistoryNote({
28614
+ when: (/* @__PURE__ */ new Date()).toISOString(),
28615
+ what: "arcSSEInputQuarantine",
28616
+ checked: quarantined.checked,
28617
+ confirmed: quarantined.staleConfirmed,
28618
+ ...quarantined.staleOutpoints.length > 0 ? { outpoints: quarantined.staleOutpoints.join(",") } : {}
28619
+ });
28620
+ await req.updateStorageDynamicProperties(sp, trx);
28621
+ }
28622
+ });
27650
28623
  });
27651
- return ` req ${req.id} => doubleSpend\n`;
28624
+ return ` req ${req.id} => ${classification.reqStatus}; quarantined ${quarantined.staleConfirmed} local input copy/copies\n`;
27652
28625
  }
27653
28626
  /**
27654
28627
  * Complete a MINED/IMMUTABLE status by using the configured proof providers.
@@ -28060,9 +29033,104 @@ var TaskCheckNoSends = class TaskCheckNoSends extends WalletMonitorTask {
28060
29033
  }
28061
29034
  };
28062
29035
  //#endregion
29036
+ //#region ../src/monitor/tasks/TaskUnFail.ts
29037
+ /**
29038
+ * Setting provenTxReq status to 'unfail' when 'invalid' will attempt to find a merklePath, and if successful:
29039
+ *
29040
+ * 1. set the req status to 'unmined'
29041
+ * 2. set the referenced txs to 'unproven'
29042
+ * 3. determine if any inputs match user's existing outputs and if so update spentBy and spendable of those outputs.
29043
+ * 4. set the txs outputs to spendable
29044
+ *
29045
+ * If it fails (to find a merklePath), returns the req status to 'invalid'.
29046
+ */
29047
+ var TaskUnFail = class TaskUnFail extends WalletMonitorTask {
29048
+ triggerMsecs;
29049
+ static taskName = "UnFail";
29050
+ /**
29051
+ * Set to true to trigger running this task
29052
+ */
29053
+ static checkNowRequested = false;
29054
+ static get checkNow() {
29055
+ return this.checkNowRequested;
29056
+ }
29057
+ static set checkNow(value) {
29058
+ this.checkNowRequested = value;
29059
+ }
29060
+ constructor(monitor, triggerMsecs = Monitor.oneMinute * 10) {
29061
+ super(monitor, TaskUnFail.taskName);
29062
+ this.triggerMsecs = triggerMsecs;
29063
+ }
29064
+ trigger(nowMsecsSinceEpoch) {
29065
+ return { run: TaskUnFail.checkNow || this.triggerMsecs > 0 && nowMsecsSinceEpoch - this.lastRunMsecsSinceEpoch > this.triggerMsecs };
29066
+ }
29067
+ async runTask() {
29068
+ let log = "";
29069
+ TaskUnFail.checkNow = false;
29070
+ const limit = 100;
29071
+ let offset = 0;
29072
+ for (;;) {
29073
+ const reqs = await this.storage.findProvenTxReqs({
29074
+ partial: {},
29075
+ status: ["unfail"],
29076
+ paged: {
29077
+ limit,
29078
+ offset
29079
+ }
29080
+ });
29081
+ if (reqs.length === 0) break;
29082
+ log += `${reqs.length} reqs with status 'unfail'\n`;
29083
+ const r = await this.unfail(reqs, 2);
29084
+ log += `${r.log}\n`;
29085
+ if (reqs.length < limit) break;
29086
+ offset += limit;
29087
+ }
29088
+ return log;
29089
+ }
29090
+ async unfail(reqs, indent = 0) {
29091
+ let log = "";
29092
+ for (const reqApi of reqs) {
29093
+ const req = new EntityProvenTxReq(reqApi);
29094
+ log += " ".repeat(indent);
29095
+ log += `reqId ${reqApi.provenTxReqId} txid ${reqApi.txid}: `;
29096
+ if ((await this.monitor.services.getMerklePath(req.txid)).merklePath != null) {
29097
+ log += "unfailed. status is now 'unmined'\n";
29098
+ log += await this.unfailReq(req, indent + 2);
29099
+ } else {
29100
+ req.status = "invalid";
29101
+ log += "returned to status 'invalid'\n";
29102
+ await req.updateStorageDynamicProperties(this.storage);
29103
+ }
29104
+ }
29105
+ return { log };
29106
+ }
29107
+ /**
29108
+ * 2. set the referenced txs to 'unproven'
29109
+ * 3. determine if any inputs match user's existing outputs and if so update spentBy and spendable of those outputs.
29110
+ * 4. set the txs outputs to spendable
29111
+ *
29112
+ * @param req
29113
+ * @param indent
29114
+ * @returns
29115
+ */
29116
+ async unfailReq(req, indent) {
29117
+ return await this.storage.runAsStorageProvider(async (sp) => await sp.unfailTransactionsForProof(req, indent, {
29118
+ status: "unmined",
29119
+ attempts: 0
29120
+ }));
29121
+ }
29122
+ };
29123
+ //#endregion
28063
29124
  //#region ../src/monitor/tasks/TaskReviewUtxos.ts
29125
+ const REVIEW_PAGE_DEFAULT_LIMIT = 20;
29126
+ const REVIEW_PAGE_MAX_LIMIT = 250;
28064
29127
  /**
28065
- * Use the reviewByIdentityKey method to review the utxos of a specific user by their identityKey.
29128
+ * Use the reviewByIdentityKey method to scan the UTXOs of a specific user by
29129
+ * identity key. The scan is read-only unless the caller explicitly requests
29130
+ * release, and release remains blocked if any provider result is inconclusive.
29131
+ * Operator UIs should use reviewPageByIdentityKey: it bounds each provider
29132
+ * round-trip and may explicitly release only the conclusive spent subset while
29133
+ * reporting unknowns.
28066
29134
  *
28067
29135
  * The task itself is disabled and will not run on a schedule; review must be triggered manually by calling reviewByIdentityKey.
28068
29136
  */
@@ -28079,7 +29147,7 @@ var TaskReviewUtxos = class TaskReviewUtxos extends WalletMonitorTask {
28079
29147
  static set checkNow(value) {
28080
29148
  this.checkNowRequested = value;
28081
29149
  }
28082
- constructor(monitor, triggerMsecs = 0, userLimit = 10, userOffset = 0, tags = ["release", "all"]) {
29150
+ constructor(monitor, triggerMsecs = 0, userLimit = 10, userOffset = 0, tags = ["all"]) {
28083
29151
  super(monitor, TaskReviewUtxos.taskName);
28084
29152
  this.triggerMsecs = triggerMsecs;
28085
29153
  this.userLimit = userLimit;
@@ -28093,8 +29161,8 @@ var TaskReviewUtxos = class TaskReviewUtxos extends WalletMonitorTask {
28093
29161
  TaskReviewUtxos.checkNow = false;
28094
29162
  return "TaskReviewUtxos is disabled; use reviewByIdentityKey instead.\n";
28095
29163
  }
28096
- async reviewByIdentityKey(identityKey, mode = "all") {
28097
- const tags = ["release", ...mode === "all" ? ["all"] : []];
29164
+ async reviewByIdentityKey(identityKey, mode = "all", release = false) {
29165
+ const tags = [...release ? ["release"] : [], ...mode === "all" ? ["all"] : []];
28098
29166
  const vargs = {
28099
29167
  basket: specOpInvalidChange,
28100
29168
  tags,
@@ -28122,8 +29190,150 @@ var TaskReviewUtxos = class TaskReviewUtxos extends WalletMonitorTask {
28122
29190
  return this.toUserLog(user, result.outputs, result.totalOutputs, total, tags);
28123
29191
  });
28124
29192
  }
29193
+ async reviewPageByIdentityKey(identityKey, mode = "all", release = false, pageLimit = REVIEW_PAGE_DEFAULT_LIMIT, offset = 0) {
29194
+ pageLimit = Math.min(Math.max(Math.trunc(pageLimit), 1), REVIEW_PAGE_MAX_LIMIT);
29195
+ offset = Math.max(Math.trunc(offset), 0);
29196
+ return await this.storage.runAsStorageProvider(async (sp) => {
29197
+ const user = (await sp.findUsers({ partial: { identityKey } }))[0];
29198
+ if (!user) return {
29199
+ found: false,
29200
+ identityKey,
29201
+ mode,
29202
+ release,
29203
+ offset,
29204
+ pageLimit,
29205
+ sourceScanned: 0,
29206
+ complete: true,
29207
+ checked: 0,
29208
+ confirmedUnspent: 0,
29209
+ confirmedSpent: 0,
29210
+ unknown: 0,
29211
+ confirmedSpentSatoshis: 0,
29212
+ released: 0,
29213
+ releasedSatoshis: 0,
29214
+ providers: [],
29215
+ providerCount: 0,
29216
+ providersTruncated: false,
29217
+ log: `identityKey ${identityKey} was not found\n`
29218
+ };
29219
+ let basketId;
29220
+ if (mode === "change") {
29221
+ basketId = (await sp.findOutputBaskets({ partial: {
29222
+ userId: user.userId,
29223
+ name: "default"
29224
+ } }))[0]?.basketId;
29225
+ if (basketId == null) return this.emptyPage(user, mode, release, pageLimit, offset);
29226
+ }
29227
+ const sourceOutputs = await sp.findOutputs({
29228
+ partial: {
29229
+ userId: user.userId,
29230
+ spendable: true,
29231
+ ...basketId != null ? { basketId } : {}
29232
+ },
29233
+ txStatus: [
29234
+ "completed",
29235
+ "unproven",
29236
+ "nosend",
29237
+ "sending"
29238
+ ],
29239
+ noScript: true,
29240
+ paged: {
29241
+ limit: pageLimit,
29242
+ offset
29243
+ }
29244
+ });
29245
+ const candidates = sourceOutputs.filter((output) => output.basketId != null);
29246
+ const review = await reviewUtxoOutputs(sp, {
29247
+ userId: user.userId,
29248
+ identityKey: user.identityKey
29249
+ }, candidates, release ? "conclusive" : "none");
29250
+ const complete = sourceOutputs.length < pageLimit;
29251
+ const nextOffset = complete ? void 0 : offset + sourceOutputs.length - review.diagnostics.released;
29252
+ const target = mode === "all" ? "spendable utxos" : "spendable change utxos";
29253
+ const action = release ? "released" : "found";
29254
+ 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`;
29255
+ for (const output of review.confirmedSpentOutputs) log += ` ${output.txid}.${output.vout} ${output.satoshis} now ${output.spendable ? "spendable" : "spent"}\n`;
29256
+ if (review.unknownOutputs.length > 0) log += ` ${review.unknownOutputs.length} output(s) quarantined from release pending a conclusive provider result\n`;
29257
+ if (nextOffset != null) log += ` continue at offset ${nextOffset}\n`;
29258
+ return {
29259
+ found: true,
29260
+ userId: user.userId,
29261
+ identityKey: user.identityKey,
29262
+ mode,
29263
+ release,
29264
+ offset,
29265
+ pageLimit,
29266
+ sourceScanned: sourceOutputs.length,
29267
+ complete,
29268
+ ...nextOffset != null ? { nextOffset } : {},
29269
+ ...review.diagnostics,
29270
+ log
29271
+ };
29272
+ });
29273
+ }
29274
+ emptyPage(user, mode, release, pageLimit, offset) {
29275
+ return {
29276
+ found: true,
29277
+ userId: user.userId,
29278
+ identityKey: user.identityKey,
29279
+ mode,
29280
+ release,
29281
+ offset,
29282
+ pageLimit,
29283
+ sourceScanned: 0,
29284
+ complete: true,
29285
+ checked: 0,
29286
+ confirmedUnspent: 0,
29287
+ confirmedSpent: 0,
29288
+ unknown: 0,
29289
+ confirmedSpentSatoshis: 0,
29290
+ released: 0,
29291
+ releasedSatoshis: 0,
29292
+ providers: [],
29293
+ providerCount: 0,
29294
+ providersTruncated: false,
29295
+ log: `userId ${user.userId}: no invalid utxos found, ${user.identityKey}\n`
29296
+ };
29297
+ }
29298
+ /**
29299
+ * Report managed-change liquidity without changing it. Monitor deliberately
29300
+ * has no signing authority; progressive migration occurs only during a
29301
+ * caller-authorized createAction.
29302
+ */
29303
+ async reviewManagedChangeByIdentityKey(identityKey) {
29304
+ return await this.storage.runAsStorageProvider(async (sp) => {
29305
+ const user = (await sp.findUsers({ partial: { identityKey } }))[0];
29306
+ if (user == null) return `identityKey ${identityKey} was not found\n`;
29307
+ const basket = verifyOne(await sp.findOutputBaskets({ partial: {
29308
+ userId: user.userId,
29309
+ name: "default"
29310
+ } }));
29311
+ const outputs = (await sp.findOutputs({
29312
+ partial: {
29313
+ userId: user.userId,
29314
+ basketId: basket.basketId,
29315
+ spendable: true,
29316
+ ...managedChangeOutputFields
29317
+ },
29318
+ txStatus: [
29319
+ "completed",
29320
+ "unproven",
29321
+ "sending"
29322
+ ],
29323
+ noScript: true
29324
+ })).filter(isAutoSpendableChangeOutput);
29325
+ const reserved = new Set(await sp.findReservedActionBatchOutputIds(outputs.map((output) => output.outputId)));
29326
+ const statuses = await sp.findTransactionStatusesByIds(user.userId, outputs.map((output) => output.transactionId));
29327
+ const preferred = Math.max(1, basket.minimumDesiredUTXOValue);
29328
+ const healthy = outputs.filter((output) => output.satoshis >= preferred);
29329
+ const undersized = outputs.filter((output) => output.satoshis < preferred);
29330
+ const countStatus = (status) => outputs.filter((output) => statuses.get(output.transactionId) === status).length;
29331
+ const satoshis = outputs.reduce((sum, output) => sum + output.satoshis, 0);
29332
+ 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`;
29333
+ });
29334
+ }
28125
29335
  toUserLog(user, outputs, totalOutputs, total, tags) {
28126
- const action = tags.includes("release") ? "updated to unspendable" : "found";
29336
+ const action = tags.includes("release") ? "confirmed spent and updated to unspendable" : "confirmed spent";
28127
29337
  const target = tags.includes("all") ? "spendable utxos" : "spendable change utxos";
28128
29338
  let log = `userId ${user.userId}: ${totalOutputs} ${target} ${action}, total ${total}, ${user.identityKey}\n`;
28129
29339
  for (const output of outputs) log += ` ${output.outpoint} ${output.satoshis} now ${output.spendable ? "spendable" : "spent"}\n`;
@@ -28132,6 +29342,13 @@ var TaskReviewUtxos = class TaskReviewUtxos extends WalletMonitorTask {
28132
29342
  };
28133
29343
  //#endregion
28134
29344
  //#region ../src/monitor/tasks/TaskReviewDoubleSpends.ts
29345
+ function hasDurableInputConflict(req) {
29346
+ try {
29347
+ return JSON.parse(req.history).notes?.some((note) => note.inputConflict === true || note.what === "arcSSEInputQuarantine") === true;
29348
+ } catch {
29349
+ return true;
29350
+ }
29351
+ }
28135
29352
  /**
28136
29353
  * Review recent reqs in terminal 'doubleSpend' state and move any false positives
28137
29354
  * back to 'unfail' so existing recovery handling can re-process them.
@@ -28199,14 +29416,16 @@ var TaskReviewDoubleSpends = class TaskReviewDoubleSpends extends WalletMonitorT
28199
29416
  let lastRetainedDoubleSpendIndex = -1;
28200
29417
  let retainedDoubleSpendCount = 0;
28201
29418
  for (const req of reqs) {
28202
- const status = (await this.monitor.services.getStatusForTxids([req.txid])).results[0]?.status;
29419
+ const gsr = await this.monitor.services.getStatusForTxids([req.txid]);
29420
+ const status = gsr.status === "success" ? gsr.results[0]?.status : void 0;
29421
+ const durableInputConflict = hasDurableInputConflict(req);
28203
29422
  reviewed.push(req);
28204
- if (status === "unknown") {
29423
+ if (!(status === "mined" || status === "known" && !durableInputConflict)) {
28205
29424
  lastRetainedDoubleSpendIndex = reviewed.length - 1;
28206
29425
  retainedDoubleSpendCount += 1;
28207
29426
  } else {
28208
29427
  unfails.push(req.provenTxReqId);
28209
- log += `unfail ${req.provenTxReqId} ${req.txid} status:${status}\n`;
29428
+ log += `unfail ${req.provenTxReqId} ${req.txid} status:${status} durableInputConflict:${String(durableInputConflict)}\n`;
28210
29429
  }
28211
29430
  }
28212
29431
  if (unfails.length > 0) await this.storage.runAsStorageProvider(async (sp) => {
@@ -28245,6 +29464,168 @@ var TaskReviewDoubleSpends = class TaskReviewDoubleSpends extends WalletMonitorT
28245
29464
  }
28246
29465
  };
28247
29466
  //#endregion
29467
+ //#region ../src/monitor/tasks/TaskReconcilePendingTransactions.ts
29468
+ const REVIEW_STATUSES = [
29469
+ "callback",
29470
+ "unmined",
29471
+ "sending",
29472
+ "unknown",
29473
+ "unconfirmed"
29474
+ ];
29475
+ /**
29476
+ * Poll durable transaction lifecycle state for aged, non-terminal requests.
29477
+ * This closes the gap where an Arcade rejection event was missed before the
29478
+ * monitor subscribed or while it was offline. Unknown/provider-error results
29479
+ * never mutate storage; only a provider's explicit terminal verdict does.
29480
+ */
29481
+ var TaskReconcilePendingTransactions = class TaskReconcilePendingTransactions extends WalletMonitorTask {
29482
+ triggerMsecs;
29483
+ reviewLimit;
29484
+ minAgeMinutes;
29485
+ triggerQuickMsecs;
29486
+ static taskName = "ReconcilePendingTransactions";
29487
+ triggerNextMsecs;
29488
+ constructor(monitor, triggerMsecs = Monitor.oneMinute * 12, reviewLimit = 100, minAgeMinutes = 60, triggerQuickMsecs = Monitor.oneMinute) {
29489
+ super(monitor, TaskReconcilePendingTransactions.taskName);
29490
+ this.triggerMsecs = triggerMsecs;
29491
+ this.reviewLimit = reviewLimit;
29492
+ this.minAgeMinutes = minAgeMinutes;
29493
+ this.triggerQuickMsecs = triggerQuickMsecs;
29494
+ this.triggerNextMsecs = this.triggerQuickMsecs;
29495
+ }
29496
+ trigger(nowMsecsSinceEpoch) {
29497
+ return { run: this.triggerNextMsecs > 0 && nowMsecsSinceEpoch - this.lastRunMsecsSinceEpoch > this.triggerNextMsecs };
29498
+ }
29499
+ async getCheckpoint() {
29500
+ let events = [];
29501
+ await this.storage.runAsStorageProvider(async (sp) => {
29502
+ events = await sp.findMonitorEvents({
29503
+ partial: { event: TaskReconcilePendingTransactions.taskName },
29504
+ orderDescending: true,
29505
+ paged: { limit: 5 }
29506
+ });
29507
+ });
29508
+ for (const event of events) {
29509
+ if (!event.details) continue;
29510
+ try {
29511
+ const parsed = JSON.parse(event.details);
29512
+ if (parsed.cycleComplete === true) return void 0;
29513
+ if (typeof parsed.resumeOffset === "number") return {
29514
+ resumeOffset: parsed.resumeOffset,
29515
+ expectedProvenTxReqId: parsed.expectedProvenTxReqId
29516
+ };
29517
+ } catch {
29518
+ continue;
29519
+ }
29520
+ }
29521
+ }
29522
+ async runTask() {
29523
+ const checkpoint = await this.getCheckpoint();
29524
+ const updatedBefore = /* @__PURE__ */ new Date(Date.now() - this.minAgeMinutes * 60 * 1e3);
29525
+ const reqs = await this.findReqsToReview(checkpoint);
29526
+ const eligible = reqs.filter((req) => req.updated_at <= updatedBefore);
29527
+ const provider = eligible.length === 0 ? void 0 : await this.monitor.services.getStatusForTxids(eligible.map((req) => req.txid));
29528
+ const results = new Map(provider?.results.map((result) => [result.txid, result]) ?? []);
29529
+ let reconciled = 0;
29530
+ let inputConflicts = 0;
29531
+ let reviewLog = "";
29532
+ const retained = [];
29533
+ for (const req of reqs) {
29534
+ const result = results.get(req.txid);
29535
+ if (req.updated_at > updatedBefore || provider?.status !== "success" || result?.terminal !== true) {
29536
+ retained.push(req);
29537
+ continue;
29538
+ }
29539
+ if (!await this.applyTerminalVerdict(req, result, provider.name)) {
29540
+ retained.push(req);
29541
+ continue;
29542
+ }
29543
+ reconciled++;
29544
+ if (result.inputConflict === true) inputConflicts++;
29545
+ reviewLog += `reconciled ${req.provenTxReqId} ${req.txid} ${result.providerStatus ?? "terminal"} => ${result.inputConflict === true ? "doubleSpend" : "invalid"}\n`;
29546
+ }
29547
+ const fullPage = reqs.length >= this.reviewLimit;
29548
+ this.triggerNextMsecs = fullPage ? this.triggerQuickMsecs : this.triggerMsecs;
29549
+ let resumeOffset;
29550
+ let expectedProvenTxReqId;
29551
+ if (fullPage) if (retained.length === 0) resumeOffset = reqs.sourceOffset ?? 0;
29552
+ else {
29553
+ resumeOffset = (reqs.sourceOffset ?? 0) + retained.length - 1;
29554
+ expectedProvenTxReqId = retained.at(-1)?.provenTxReqId;
29555
+ }
29556
+ return JSON.stringify({
29557
+ reviewed: eligible.length,
29558
+ reconciled,
29559
+ inputConflicts,
29560
+ retained: retained.length,
29561
+ reviewLimit: this.reviewLimit,
29562
+ minAgeMinutes: this.minAgeMinutes,
29563
+ cycleComplete: !fullPage,
29564
+ ...resumeOffset != null ? { resumeOffset } : {},
29565
+ ...expectedProvenTxReqId != null ? { expectedProvenTxReqId } : {},
29566
+ reviewLog
29567
+ });
29568
+ }
29569
+ async findReqsToReview(checkpoint) {
29570
+ let offset = checkpoint?.resumeOffset ?? 0;
29571
+ if (checkpoint?.expectedProvenTxReqId != null) offset = (await this.storage.findProvenTxReqs({
29572
+ partial: {},
29573
+ status: [...REVIEW_STATUSES],
29574
+ paged: {
29575
+ limit: 1,
29576
+ offset
29577
+ }
29578
+ }))[0]?.provenTxReqId === checkpoint.expectedProvenTxReqId ? offset + 1 : 0;
29579
+ const reqs = await this.storage.findProvenTxReqs({
29580
+ partial: {},
29581
+ status: [...REVIEW_STATUSES],
29582
+ paged: {
29583
+ limit: this.reviewLimit,
29584
+ offset
29585
+ }
29586
+ });
29587
+ reqs.sourceOffset = offset;
29588
+ return reqs;
29589
+ }
29590
+ async applyTerminalVerdict(reqApi, result, provider) {
29591
+ let applied = false;
29592
+ await this.storage.runAsStorageProvider(async (sp) => {
29593
+ await sp.transaction(async (trx) => {
29594
+ const req = new EntityProvenTxReq(reqApi);
29595
+ await req.refreshFromStorage(sp, trx);
29596
+ if (!REVIEW_STATUSES.includes(req.status)) return;
29597
+ req.status = result.inputConflict === true ? "doubleSpend" : "invalid";
29598
+ req.addHistoryNote({
29599
+ when: (/* @__PURE__ */ new Date()).toISOString(),
29600
+ what: "proactiveTerminalReconciliation",
29601
+ provider,
29602
+ providerStatus: result.providerStatus,
29603
+ statusCode: result.statusCode,
29604
+ description: result.description,
29605
+ inputConflict: result.inputConflict === true,
29606
+ competingTxs: result.competingTxs?.join(",")
29607
+ });
29608
+ await req.updateStorageDynamicProperties(sp, trx);
29609
+ if (req.notify.transactionIds != null) await sp.updateTransactionsStatus(req.notify.transactionIds, "failed", trx);
29610
+ if (result.inputConflict === true) {
29611
+ const quarantined = await quarantineReqInputs(req, sp, trx);
29612
+ req.addHistoryNote({
29613
+ when: (/* @__PURE__ */ new Date()).toISOString(),
29614
+ what: "proactiveInputQuarantine",
29615
+ checked: quarantined.checked,
29616
+ confirmed: quarantined.staleConfirmed,
29617
+ ...quarantined.staleOutpoints.length > 0 ? { outpoints: quarantined.staleOutpoints.join(",") } : {}
29618
+ });
29619
+ await req.updateStorageDynamicProperties(sp, trx);
29620
+ }
29621
+ applied = true;
29622
+ });
29623
+ });
29624
+ if (applied) this.monitor.callOnTransactionStatusChanged(reqApi.txid, result.providerStatus ?? "REJECTED");
29625
+ return applied;
29626
+ }
29627
+ };
29628
+ //#endregion
28248
29629
  //#region ../src/monitor/tasks/TaskReviewProvenTxs.ts
28249
29630
  /**
28250
29631
  * Backup verification task for recent proven_txs records.
@@ -28503,14 +29884,14 @@ var Monitor = class Monitor {
28503
29884
  purgeFailedAge: 5 * Monitor.oneDay
28504
29885
  };
28505
29886
  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));
29887
+ 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
29888
  if (this.chain === "mock") this._otherTasks.push(new TaskMineBlock(this));
28508
29889
  }
28509
29890
  /**
28510
29891
  * Default tasks with settings appropriate for a single user storage
28511
29892
  */
28512
29893
  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));
29894
+ 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
29895
  this._otherTasks.push(new TaskPurge(this, this.defaultPurgeParams, 6 * Monitor.oneHour), new TaskReviewUtxos(this));
28515
29896
  if (this.chain === "mock") this._tasks.push(new TaskMineBlock(this));
28516
29897
  }
@@ -28518,7 +29899,7 @@ var Monitor = class Monitor {
28518
29899
  * Tasks appropriate for multi-user storage
28519
29900
  */
28520
29901
  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));
29902
+ 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
29903
  this._otherTasks.push(new TaskPurge(this, this.defaultPurgeParams), new TaskReviewUtxos(this));
28523
29904
  if (this.chain === "mock") this._tasks.push(new TaskMineBlock(this));
28524
29905
  }
@@ -28889,6 +30270,7 @@ var SetupClient = class SetupClient {
28889
30270
  model: "sat/kb",
28890
30271
  value: 100
28891
30272
  },
30273
+ managedChangePolicy: args.managedChangePolicy,
28892
30274
  scriptVerifier: args.scriptVerifier
28893
30275
  });
28894
30276
  await storage.migrate(args.databaseName, randomBytesHex(33));
@@ -33478,7 +34860,7 @@ var WalletPermissionsManager = class WalletPermissionsManager {
33478
34860
  basket: basketName,
33479
34861
  tags
33480
34862
  }],
33481
- options: { acceptDelayedBroadcast: false }
34863
+ options: { acceptDelayedBroadcast: true }
33482
34864
  }, this.adminOriginator);
33483
34865
  }
33484
34866
  async mapWithConcurrency(items, concurrency, fn) {
@@ -33542,7 +34924,7 @@ var WalletPermissionsManager = class WalletPermissionsManager {
33542
34924
  await this.createAction({
33543
34925
  description: `Grant ${built.length} permissions`,
33544
34926
  outputs: built.map((b) => b.output),
33545
- options: { acceptDelayedBroadcast: false }
34927
+ options: { acceptDelayedBroadcast: true }
33546
34928
  }, this.adminOriginator);
33547
34929
  return built.map((b) => b.request);
33548
34930
  }, strict);
@@ -34939,6 +36321,7 @@ exports.ARGON2ID_MAX_MEMORY_KIB = ARGON2ID_MAX_MEMORY_KIB;
34939
36321
  exports.ARGON2ID_MAX_PARALLELISM = ARGON2ID_MAX_PARALLELISM;
34940
36322
  exports.AuthMethodInteractor = AuthMethodInteractor;
34941
36323
  exports.BHServiceClient = BHServiceClient;
36324
+ exports.BRC153_REFERENCE_PREFIX = BRC153_REFERENCE_PREFIX;
34942
36325
  exports.BulkFileDataManager = BulkFileDataManager;
34943
36326
  exports.BulkFileDataReader = BulkFileDataReader;
34944
36327
  exports.BulkFilesReader = BulkFilesReader;
@@ -34959,6 +36342,11 @@ exports.ChaintracksServiceClient = ChaintracksServiceClient;
34959
36342
  exports.ChaintracksStorageBase = ChaintracksStorageBase;
34960
36343
  exports.ChaintracksStorageIdb = ChaintracksStorageIdb;
34961
36344
  exports.ChaintracksStorageNoDb = ChaintracksStorageNoDb;
36345
+ exports.DEFAULT_MANAGED_CHANGE_MAX_OUTPUTS_PER_ACTION = DEFAULT_MANAGED_CHANGE_MAX_OUTPUTS_PER_ACTION;
36346
+ exports.DEFAULT_MANAGED_CHANGE_MIGRATION_INPUTS_PER_ACTION = DEFAULT_MANAGED_CHANGE_MIGRATION_INPUTS_PER_ACTION;
36347
+ exports.DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS = DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS;
36348
+ exports.DEFAULT_MANAGED_CHANGE_PENDING_COMPARISON_INPUTS = DEFAULT_MANAGED_CHANGE_PENDING_COMPARISON_INPUTS;
36349
+ exports.DEFAULT_MANAGED_CHANGE_TARGET_UTXOS = DEFAULT_MANAGED_CHANGE_TARGET_UTXOS;
34962
36350
  exports.DEFAULT_PROFILE_ID = DEFAULT_PROFILE_ID;
34963
36351
  exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
34964
36352
  exports.DevConsoleInteractor = DevConsoleInteractor;
@@ -34980,6 +36368,7 @@ exports.EntityUser = EntityUser;
34980
36368
  exports.GoChaintracksServiceClient = GoChaintracksServiceClient;
34981
36369
  exports.HeightRange = HeightRange;
34982
36370
  exports.KDF_MAX_HASH_LENGTH = KDF_MAX_HASH_LENGTH;
36371
+ exports.LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS = LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS;
34983
36372
  exports.LiveIngestorBase = LiveIngestorBase;
34984
36373
  exports.LiveIngestorChaintracksSSE = LiveIngestorChaintracksSSE;
34985
36374
  exports.LiveIngestorWhatsOnChainPoll = LiveIngestorWhatsOnChainPoll;
@@ -35014,6 +36403,7 @@ exports.WalletSettingsManager = WalletSettingsManager;
35014
36403
  exports.WalletSigner = WalletSigner;
35015
36404
  exports.WalletStorageManager = WalletStorageManager;
35016
36405
  exports.WhatsOnChainServices = WhatsOnChainServices;
36406
+ exports.applyBrc153ReferenceLabel = applyBrc153ReferenceLabel;
35017
36407
  exports.arcDefaultUrl = arcDefaultUrl;
35018
36408
  exports.arcGorillaPoolUrl = arcGorillaPoolUrl;
35019
36409
  exports.arcadeDefaultUrl = arcadeDefaultUrl;
@@ -35039,6 +36429,7 @@ exports.createIdbChaintracks = createIdbChaintracks;
35039
36429
  exports.createNoDbChaintracks = createNoDbChaintracks;
35040
36430
  exports.createSyncMap = createSyncMap;
35041
36431
  exports.decryptBRC39 = decryptBRC39;
36432
+ exports.defaultManagedChangePolicy = defaultManagedChangePolicy;
35042
36433
  exports.doubleSha256BE = doubleSha256BE;
35043
36434
  exports.doubleSha256LE = doubleSha256LE;
35044
36435
  exports.encryptBRC39 = encryptBRC39;
@@ -35053,6 +36444,8 @@ exports.importBRC39 = importBRC39;
35053
36444
  exports.isAutoSpendableChangeOutput = isAutoSpendableChangeOutput;
35054
36445
  exports.isBaseBlockHeader = isBaseBlockHeader;
35055
36446
  exports.isBlockHeader = isBlockHeader;
36447
+ exports.isBrc153ReferenceLabel = isBrc153ReferenceLabel;
36448
+ exports.isLegacyManagedChangeBasketDefault = isLegacyManagedChangeBasketDefault;
35056
36449
  exports.isLive = isLive;
35057
36450
  exports.isLiveBlockHeader = isLiveBlockHeader;
35058
36451
  exports.isManagedChangeOutput = isManagedChangeOutput;
@@ -35061,12 +36454,14 @@ exports.logWalletError = logWalletError;
35061
36454
  exports.logger = logger;
35062
36455
  exports.makeAtomicBeef = makeAtomicBeef;
35063
36456
  exports.makeBrc114ActionTimeLabel = makeBrc114ActionTimeLabel;
36457
+ exports.makeBrc153ReferenceLabel = makeBrc153ReferenceLabel;
35064
36458
  exports.managedChangeOutputFields = managedChangeOutputFields;
35065
36459
  exports.maxDate = maxDate;
35066
36460
  exports.optionalArraysEqual = optionalArraysEqual;
35067
36461
  exports.outputColumnsWithoutLockingScript = outputColumnsWithoutLockingScript;
35068
36462
  exports.parseBRC38Json = parseBRC38Json;
35069
36463
  exports.parseBrc114ActionTimeLabels = parseBrc114ActionTimeLabels;
36464
+ exports.parseBrc153ReferenceLabel = parseBrc153ReferenceLabel;
35070
36465
  exports.parseFileLink = parseFileLink;
35071
36466
  exports.parseTxScriptOffsets = parseTxScriptOffsets;
35072
36467
  exports.partitionActionLabels = partitionActionLabels;
@@ -35092,12 +36487,14 @@ exports.toDefaultChaintracksArguments = toDefaultChaintracksArguments;
35092
36487
  exports.toLookupNetworkPreset = toLookupNetworkPreset;
35093
36488
  exports.toWalletNetwork = toWalletNetwork;
35094
36489
  exports.transactionColumnsWithoutRawTx = transactionColumnsWithoutRawTx;
36490
+ exports.upgradeLegacyManagedChangeBasketDefault = upgradeLegacyManagedChangeBasketDefault;
35095
36491
  Object.defineProperty(exports, "utils", {
35096
36492
  enumerable: true,
35097
36493
  get: function() {
35098
36494
  return blockHeaderUtilities_exports;
35099
36495
  }
35100
36496
  });
36497
+ exports.validateManagedChangePolicy = validateManagedChangePolicy;
35101
36498
  exports.validateScriptHash = validateScriptHash;
35102
36499
  exports.validateSecondsSinceEpoch = validateSecondsSinceEpoch;
35103
36500
  exports.validateStorageFeeModel = validateStorageFeeModel;