@bsv/wallet-toolbox-client 2.6.4 → 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,16 +10506,15 @@ 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 prepareFundingPlan(storage, [
10509
+ const initialFundingPlan = await prepareFundingPlanWithLiquidityPolicy(storage, [
10025
10510
  userId,
10026
10511
  vargs,
10027
10512
  xinputs,
10028
10513
  xoutputs,
10029
10514
  changeBasket,
10030
10515
  noSendChangeIn,
10031
- feeModel,
10032
- parent
10033
- ]);
10516
+ feeModel
10517
+ ], parent);
10034
10518
  logger?.log(`planned funding from ${initialFundingPlan.availableChangeCount} change inputs`);
10035
10519
  const allocatedBeefPrefetch = startAllocatedChangeBeefPrefetch(storage, vargs, initialFundingPlan.selected, beef, parent);
10036
10520
  const storageBeefBytes = storageBeef.toBinary();
@@ -10610,7 +11094,9 @@ async function traceStorageStep(storage, name, parent, attributes, callback) {
10610
11094
  attributes
10611
11095
  }, async (span) => await callback(span));
10612
11096
  }
10613
- 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);
10614
11100
  return {
10615
11101
  fixedInputs: xinputs.map((input) => ({
10616
11102
  satoshis: input.satoshis,
@@ -10621,36 +11107,52 @@ function makeFundingParams(vargs, xinputs, xoutputs, changeBasket, feeModel, ava
10621
11107
  lockingScriptLength: output.lockingScript.length / 2
10622
11108
  })),
10623
11109
  feeModel,
10624
- changeInitialSatoshis: Math.max(1, changeBasket.minimumDesiredUTXOValue),
10625
- changeFirstSatoshis: Math.max(1, Math.round(changeBasket.minimumDesiredUTXOValue / 4)),
11110
+ changeInitialSatoshis: preferredSatoshis,
11111
+ changeFirstSatoshis: compatibilityFallback ? 1 : preferredSatoshis,
10626
11112
  changeLockingScriptLength: 25,
10627
11113
  changeUnlockingScriptLength: 107,
10628
- targetNetCount: changeBasket.numberOfDesiredUTXOs - availableChangeCount,
11114
+ targetNetCount: changeBasket.numberOfDesiredUTXOs - healthyChangeCount,
11115
+ maxChangeOutputs: storage.managedChangePolicy.maxOutputsPerAction,
11116
+ surplusPoolShaping: !compatibilityFallback,
11117
+ maxMigrationInputs: compatibilityFallback ? 0 : storage.managedChangePolicy.migrationInputsPerAction,
10629
11118
  randomVals: vargs.randomVals
10630
11119
  };
10631
11120
  }
10632
- async function prepareFundingPlan(storage, context) {
10633
- const [userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel, parent, trx] = context;
10634
- const excludeSending = !vargs.isDelayed;
10635
- const candidates = await traceStorageStep(storage, "wallet.storage.create_action.funding_candidates", parent, { "funding.exclude_sending": excludeSending }, async (span) => {
10636
- const outputs = await storage.findAvailableManagedChangeInputCandidates(userId, changeBasket.basketId, excludeSending, trx);
10637
- span?.end({ attributes: {
10638
- "funding.candidate_count": outputs.length,
10639
- "funding.candidate_satoshis": outputs.reduce((sum, output) => sum + output.satoshis, 0)
10640
- } });
10641
- return outputs;
10642
- });
11121
+ async function buildFundingPlan(storage, context, candidates, eligibleStatuses, policyTier, compatibilityFallback, parent) {
11122
+ const [userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel] = context;
10643
11123
  const noSendIds = new Set(noSendChangeIn.map((output) => output.outputId));
10644
- const available = candidates.filter((output) => !noSendIds.has(output.outputId));
10645
- 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)])];
10646
11146
  return await traceStorageStep(storage, "wallet.storage.create_action.funding_plan", parent, {
10647
11147
  "funding.candidate_count": available.length,
10648
- "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
10649
11151
  }, async (span) => {
10650
11152
  const allocated = /* @__PURE__ */ new Map();
10651
11153
  const availableSelector = new CanonicalChangeSelector(available);
10652
- const noSend = [...noSendChangeIn];
10653
- 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]));
10654
11156
  const allocate = async (targetSatoshis, exactSatoshis) => {
10655
11157
  let output = noSend.pop();
10656
11158
  output ??= availableSelector.take(targetSatoshis, exactSatoshis);
@@ -10680,12 +11182,119 @@ async function prepareFundingPlan(storage, context) {
10680
11182
  params,
10681
11183
  result,
10682
11184
  selected,
10683
- availableChangeCount: candidates.length
11185
+ availableChangeCount: candidates.length,
11186
+ eligibleStatuses: claimStatuses,
11187
+ policyTier,
11188
+ compatibilityFallback
10684
11189
  };
10685
11190
  });
10686
11191
  }
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;
11195
+ try {
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) };
11249
+ } catch (error) {
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);
11292
+ }
11293
+ if (fundingError != null) throw fundingError;
11294
+ throw new WERR_INTERNAL("funding policy produced neither a plan nor an error");
11295
+ }
10687
11296
  async function claimFundingPlan(storage, request) {
10688
- const [userId, basketId, excludeSending, transactionId, noSendChangeIn, plan, trx] = request;
11297
+ const [userId, basketId, eligibleStatuses, transactionId, noSendChangeIn, plan, trx] = request;
10689
11298
  if (plan.selected.length === 0) return {
10690
11299
  outputs: [],
10691
11300
  sourceTransactionCount: 0,
@@ -10693,10 +11302,8 @@ async function claimFundingPlan(storage, request) {
10693
11302
  scriptSourceTransactionCount: 0
10694
11303
  };
10695
11304
  const noSendIds = new Set(noSendChangeIn.map((output) => output.outputId));
10696
- const statuses = ["completed", "unproven"];
10697
- if (!excludeSending) statuses.push("sending");
10698
11305
  const claim = await storage.transaction(async (claimTrx) => {
10699
- 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);
10700
11307
  const transactionIds = [...new Set(Object.values(currentById).map((output) => output.transactionId))];
10701
11308
  const claimed = [];
10702
11309
  for (const planned of plan.selected) {
@@ -10766,7 +11373,7 @@ async function fundNewTransactionSdk(storage, userId, vargs, ctx, initialPlan, p
10766
11373
  const claim = await claimFundingPlan(storage, [
10767
11374
  userId,
10768
11375
  ctx.changeBasket.basketId,
10769
- !vargs.isDelayed,
11376
+ plan.eligibleStatuses,
10770
11377
  ctx.transactionId,
10771
11378
  ctx.noSendChangeIn,
10772
11379
  plan,
@@ -10784,17 +11391,15 @@ async function fundNewTransactionSdk(storage, userId, vargs, ctx, initialPlan, p
10784
11391
  }
10785
11392
  if (claim.conflict === "noSendChange") throw new WERR_INVALID_PARAMETER("noSendChange", "outputs that remain spendable during action planning");
10786
11393
  retryCount++;
10787
- plan = await prepareFundingPlan(storage, [
11394
+ plan = await prepareFundingPlanWithLiquidityPolicy(storage, [
10788
11395
  userId,
10789
11396
  vargs,
10790
11397
  ctx.xinputs,
10791
11398
  ctx.xoutputs,
10792
11399
  ctx.changeBasket,
10793
11400
  ctx.noSendChangeIn,
10794
- ctx.feeModel,
10795
- parent,
10796
- trx
10797
- ]);
11401
+ ctx.feeModel
11402
+ ], parent, trx);
10798
11403
  }
10799
11404
  throw new WERR_INVALID_OPERATION("wallet funding changed repeatedly during action planning; retry createAction");
10800
11405
  });
@@ -12939,7 +13544,7 @@ var StorageReaderWriter = class extends StorageReader {
12939
13544
  userId: user.userId,
12940
13545
  name: "default",
12941
13546
  numberOfDesiredUTXOs: 144,
12942
- minimumDesiredUTXOValue: 32,
13547
+ minimumDesiredUTXOValue: DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS,
12943
13548
  isDeleted: false
12944
13549
  });
12945
13550
  break;
@@ -13220,8 +13825,10 @@ function validateActionBatchInlinePayload(manifest) {
13220
13825
  for (const { digest, bytes } of inlineBlobs) if (actionBatchBlobDigest(bytes) !== digest) throw new WERR_INVALID_PARAMETER("inlineBlobs", `content matching digest ${digest}`);
13221
13826
  }
13222
13827
  function requireUploadableBatch(batch) {
13223
- if (batch == null || batch.status === "committed" || batch.status === "aborted") throw new WERR_INVALID_OPERATION("action batch is not uploadable");
13224
- 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);
13225
13832
  return batch;
13226
13833
  }
13227
13834
  function manifestPhysicalDigests(manifest) {
@@ -13581,7 +14188,10 @@ const ACTION_BATCH_LEASE_MS = 900 * 1e3;
13581
14188
  const ACTION_BATCH_HARD_LIFETIME_MS = 3600 * 1e3;
13582
14189
  const INITIAL_RESERVATION_LIMIT = 8;
13583
14190
  const INITIAL_EXTRA_OUTPUTS = 3;
13584
- 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) {
13585
14195
  return { actionBatch: {
13586
14196
  version: 1,
13587
14197
  maxInlineBytes: ACTION_BATCH_MAX_INLINE_BYTES,
@@ -13589,6 +14199,8 @@ function getActionBatchCapabilities() {
13589
14199
  maxConcurrentUploads: 4,
13590
14200
  leaseMs: ACTION_BATCH_LEASE_MS,
13591
14201
  hardLifetimeMs: ACTION_BATCH_HARD_LIFETIME_MS,
14202
+ resume: supportsResume ? true : void 0,
14203
+ maxReservedOutputs,
13592
14204
  compactBegin: true,
13593
14205
  manifestVersion: 2,
13594
14206
  commitByDigest: true,
@@ -13604,6 +14216,14 @@ function getActionBatchCapabilities() {
13604
14216
  function activeExpiry(batch, now = /* @__PURE__ */ new Date()) {
13605
14217
  return batch.status !== "active" && batch.status !== "prepared" || batch.expiresAt.getTime() <= now.getTime() || batch.hardExpiresAt.getTime() <= now.getTime();
13606
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
+ }
13607
14227
  function canExpire(batch, now = /* @__PURE__ */ new Date()) {
13608
14228
  return (batch.status === "active" || batch.status === "prepared") && (batch.expiresAt.getTime() <= now.getTime() || batch.hardExpiresAt.getTime() <= now.getTime());
13609
14229
  }
@@ -13689,12 +14309,22 @@ function estimateFirstActionTarget(storage, args, inputSatoshis, outputScriptLen
13689
14309
  const minFee = Math.ceil(transactionSize([...inputLengths, 107], outputLengths) * fee / 1e3);
13690
14310
  return Math.max(1, outputSatoshis + minFee - inputSatoshis);
13691
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
+ }
13692
14322
  function chooseReservationPool(candidates, targetSatoshis, limit, extras, fillLimit, planningCosts) {
13693
14323
  const remaining = candidates.filter((output) => output.satoshis > planningCosts.marginalInputFee);
13694
14324
  const chosen = [];
13695
14325
  let deficit = targetSatoshis + planningCosts.firstChangeCost;
13696
14326
  while (deficit > 0 && chosen.length < limit) {
13697
- const output = selectCanonicalChange(remaining, deficit + planningCosts.marginalInputFee);
14327
+ const output = selectReservationChange(remaining, deficit + planningCosts.marginalInputFee);
13698
14328
  if (output == null) break;
13699
14329
  chosen.push(output);
13700
14330
  remaining.splice(remaining.indexOf(output), 1);
@@ -13702,14 +14332,18 @@ function chooseReservationPool(candidates, targetSatoshis, limit, extras, fillLi
13702
14332
  }
13703
14333
  const desiredCount = fillLimit ? limit : Math.min(limit, chosen.length + extras);
13704
14334
  while (remaining.length > 0 && chosen.length < desiredCount) {
13705
- const output = selectCanonicalChange(remaining, targetSatoshis);
14335
+ const output = selectReservationChange(remaining, targetSatoshis);
13706
14336
  if (output == null) break;
13707
14337
  chosen.push(output);
13708
14338
  remaining.splice(remaining.indexOf(output), 1);
13709
14339
  }
13710
14340
  return chosen;
13711
14341
  }
13712
- function reservationPlanningCosts(storage, basket) {
14342
+ function reservationOutput(candidate) {
14343
+ const { transactionStatus: _transactionStatus, ...output } = candidate;
14344
+ return output;
14345
+ }
14346
+ function reservationPlanningCosts(storage, _basket) {
13713
14347
  const satsPerKb = validateStorageFeeModel(storage.feeModel).value ?? 0;
13714
14348
  const minimumSpendSize = transactionSize([107], [25]);
13715
14349
  const minimumSpendFee = Math.ceil(minimumSpendSize * satsPerKb / 1e3);
@@ -13717,7 +14351,7 @@ function reservationPlanningCosts(storage, basket) {
13717
14351
  const marginalInputSize = transactionSize([107], []) - transactionSize([], []);
13718
14352
  const marginalOutputSize = transactionSize([], [25]) - transactionSize([], []);
13719
14353
  return {
13720
- 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),
13721
14355
  marginalInputFee: Math.ceil(marginalInputSize * satsPerKb / 1e3)
13722
14356
  };
13723
14357
  }
@@ -13792,18 +14426,25 @@ async function beginActionBatch(storage, auth, args) {
13792
14426
  const explicit = await resolveExplicitOutputs(storage, userId, args.firstAction, outputScriptLengths != null);
13793
14427
  const noSendChange = await resolveNoSendChangeOutputs(storage, userId, args.firstAction);
13794
14428
  const fixedOutputIds = new Set([...explicit.outputs, ...noSendChange.outputs].map((output) => output.outputId));
13795
- 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
+ }));
13796
14435
  const target = estimateFirstActionTarget(storage, args.firstAction, explicit.inputSatoshis + noSendChange.inputSatoshis, outputScriptLengths);
13797
- const fixedOutputs = [...explicit.outputs, ...noSendChange.outputs];
13798
- 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);
13799
14440
  const batch = newBatch(userId, args.batchId);
13800
14441
  await storage.transaction(async (trx) => {
13801
14442
  await storage.insertActionBatch(batch, trx);
13802
- await reserveOutputs(storage, batch, [...fixedOutputs, ...funding], trx);
14443
+ await reserveOutputs(storage, batch, [...fixedOutputs, ...fundingOutputs], trx);
13803
14444
  });
13804
14445
  let fundingResult;
13805
14446
  try {
13806
- fundingResult = await makeFundingResult(storage, args.firstAction, [...funding, ...fixedOutputs]);
14447
+ fundingResult = await makeFundingResult(storage, args.firstAction, [...fundingOutputs, ...fixedOutputs]);
13807
14448
  } catch (error) {
13808
14449
  await storage.transaction(async (trx) => await releaseBatchState(storage, batch, "aborted", trx));
13809
14450
  throw error;
@@ -13817,41 +14458,63 @@ async function beginActionBatch(storage, auth, args) {
13817
14458
  feeModel: validateStorageFeeModel(storage.feeModel),
13818
14459
  commissionSatoshis: storage.commissionSatoshis,
13819
14460
  commissionPubKeyHex: storage.commissionPubKeyHex,
13820
- availableChangeCount: available.length,
13821
- 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)),
13822
14467
  explicitOutputs: fundingResult.outputs.filter((output) => explicitIds.has(output.outputId)),
13823
14468
  inputBeef: fundingResult.beef
13824
14469
  };
13825
14470
  }
13826
- function requireLiveBatch(batch) {
13827
- 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);
13828
14475
  return batch;
13829
14476
  }
13830
14477
  async function extendActionBatch(storage, auth, args) {
13831
14478
  const userId = verifyId(auth.userId);
13832
14479
  await cleanupExpiredActionBatches(storage);
13833
- const batch = requireLiveBatch(await storage.findActionBatch(userId, args.batchId));
14480
+ const batch = requireLiveBatch(await storage.findActionBatch(userId, args.batchId), args.batchId);
13834
14481
  const basket = verifyOne(await storage.findOutputBaskets({ partial: {
13835
14482
  userId,
13836
14483
  name: "default"
13837
14484
  } }));
13838
14485
  const alreadyReserved = await storage.findActionBatchOutputIds(batch.actionBatchId);
13839
- const available = await storage.findAvailableManagedChangeInputs(userId, basket.basketId, false);
13840
14486
  if (!Number.isSafeInteger(args.requestedOutputs) || args.requestedOutputs < 0) throw new WERR_INVALID_PARAMETER("requestedOutputs", "non-negative safe integer");
13841
- const requestedCount = Math.min(args.requestedOutputs, 64);
13842
- 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`);
13843
14492
  const explicitByOutpoint = await storage.findOutputsByOutpoints(userId, args.explicitOutpoints);
13844
- const explicit = Object.values(explicitByOutpoint).filter((output) => !alreadyReserved.includes(output.outputId));
13845
- 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]);
13846
14505
  const expiresAt = new Date(Math.min(batch.hardExpiresAt.getTime(), Date.now() + ACTION_BATCH_LEASE_MS));
13847
14506
  await storage.transaction(async (trx) => {
13848
- const current = requireLiveBatch(await storage.findActionBatchForUpdate(userId, args.batchId, trx));
13849
- 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);
13850
14513
  await storage.updateActionBatch(current.actionBatchId, { expiresAt }, trx);
13851
14514
  });
13852
14515
  return {
13853
14516
  expiresAt: expiresAt.toISOString(),
13854
- 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)),
13855
14518
  explicitOutputs: fundingResult.outputs.filter((output) => explicit.some((candidate) => candidate.outputId === output.outputId)),
13856
14519
  inputBeef: fundingResult.beef
13857
14520
  };
@@ -13887,14 +14550,64 @@ function argsToFundingShape(includeSourceTransactions) {
13887
14550
  async function renewActionBatch(storage, auth, batchId) {
13888
14551
  const userId = verifyId(auth.userId);
13889
14552
  return await storage.transaction(async (trx) => {
13890
- const batch = requireLiveBatch(await storage.findActionBatchForUpdate(userId, batchId, trx));
14553
+ const batch = requireLiveBatch(await storage.findActionBatchForUpdate(userId, batchId, trx), batchId);
13891
14554
  const expiresAt = new Date(Math.min(batch.hardExpiresAt.getTime(), Date.now() + ACTION_BATCH_LEASE_MS));
13892
14555
  await storage.updateActionBatch(batch.actionBatchId, { expiresAt }, trx);
13893
14556
  return { expiresAt: expiresAt.toISOString() };
13894
14557
  });
13895
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
+ }
13896
14609
  async function reacquireManifestInputs(storage, userId, batch, validated, trx) {
13897
- 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);
13898
14611
  const stagedTxids = new Set(validated.actions.map(({ action }) => action.txid));
13899
14612
  const outpoints = [...new Map(validated.actions.flatMap(({ action }) => action.plan.inputs).filter((input) => !stagedTxids.has(input.sourceTxid)).map((input) => [`${input.sourceTxid}.${input.sourceVout}`, {
13900
14613
  txid: input.sourceTxid,
@@ -14061,7 +14774,7 @@ async function persistAction(...[storage, userId, validated, reservedOutputIds,
14061
14774
  async function persistManifestAtomically(storage, userId, batch, manifest, validated) {
14062
14775
  return await storage.transaction(async (trx) => {
14063
14776
  const current = await storage.findActionBatchForUpdate(userId, batch.batchId, trx);
14064
- if (current == null) throw new WERR_INVALID_OPERATION("action batch was not found");
14777
+ if (current == null) throw new WERRActionBatchState("missing", batch.batchId);
14065
14778
  if (current.status === "committed") {
14066
14779
  if (current.manifestDigest !== manifest.digest) throw new WERR_INVALID_OPERATION("batch committed with another manifest");
14067
14780
  return {
@@ -14069,12 +14782,12 @@ async function persistManifestAtomically(storage, userId, batch, manifest, valid
14069
14782
  alreadyCommitted: true
14070
14783
  };
14071
14784
  }
14072
- 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);
14073
14786
  if (current.manifestDigest != null && current.manifestDigest !== manifest.digest) throw new WERR_INVALID_OPERATION("batch prepared with another manifest");
14074
14787
  if (current.status === "expired" || activeExpiry(current)) {
14075
- 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);
14076
14789
  await reacquireManifestInputs(storage, userId, current, validated, trx);
14077
- } else requireLiveBatch(current);
14790
+ } else requireLiveBatch(current, batch.batchId);
14078
14791
  const reservedOutputIds = new Set(await storage.findActionBatchOutputIds(current.actionBatchId, trx));
14079
14792
  const stagedTxids = new Set(validated.actions.map(({ action }) => action.txid));
14080
14793
  const inputOutpoints = [...new Map(validated.actions.flatMap(({ action }) => action.plan.inputs).filter((input) => !stagedTxids.has(input.sourceTxid)).map((input) => [`${input.sourceTxid}.${input.sourceVout}`, {
@@ -14152,14 +14865,14 @@ async function completeCommittedBatch(storage, userId, batch, manifest, alreadyC
14152
14865
  }
14153
14866
  async function commitActionBatchOnce(storage, userId, manifest) {
14154
14867
  const batch = await storage.findActionBatch(userId, manifest.batchId);
14155
- if (batch == null) throw new WERR_INVALID_OPERATION("action batch was not found");
14868
+ if (batch == null) throw new WERRActionBatchState("missing", manifest.batchId);
14156
14869
  if (batch.status === "committed") {
14157
14870
  if (batch.manifestDigest !== manifest.digest) throw new WERR_INVALID_OPERATION("batch committed with another manifest");
14158
14871
  return await completeCommittedBatch(storage, userId, batch, manifest, true);
14159
14872
  }
14160
- if (batch.status === "aborted") throw new WERR_INVALID_OPERATION("aborted action batch cannot be committed");
14161
- if (!(batch.status === "expired" || activeExpiry(batch))) requireLiveBatch(batch);
14162
- 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);
14163
14876
  if (batch.manifestDigest != null && batch.manifestDigest !== manifest.digest) throw new WERR_INVALID_OPERATION("batch prepared with another manifest");
14164
14877
  const persisted = await persistManifestAtomically(storage, userId, batch, manifest, await validateManifestActions(storage, batch, manifest));
14165
14878
  return await completeCommittedBatch(storage, userId, persisted.batch, manifest, persisted.alreadyCommitted, persisted.share);
@@ -14242,6 +14955,8 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14242
14955
  commissionSatoshis;
14243
14956
  commissionPubKeyHex;
14244
14957
  maxRecursionDepth;
14958
+ actionBatchMaxReservedOutputs;
14959
+ managedChangePolicy;
14245
14960
  scriptVerifier;
14246
14961
  static defaultOptions() {
14247
14962
  return {
@@ -14250,7 +14965,9 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14250
14965
  value: 100
14251
14966
  },
14252
14967
  commissionSatoshis: 0,
14253
- commissionPubKeyHex: void 0
14968
+ commissionPubKeyHex: void 0,
14969
+ actionBatchMaxReservedOutputs: 256,
14970
+ managedChangePolicy: defaultManagedChangePolicy()
14254
14971
  };
14255
14972
  }
14256
14973
  static createStorageBaseOptions(chain) {
@@ -14265,6 +14982,10 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14265
14982
  this.commissionPubKeyHex = options.commissionPubKeyHex;
14266
14983
  this.commissionSatoshis = options.commissionSatoshis;
14267
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);
14268
14989
  this.scriptVerifier = options.scriptVerifier;
14269
14990
  }
14270
14991
  /** Mark a planned set of change inputs spent within the caller's transaction. */
@@ -14295,12 +15016,16 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14295
15016
  }
14296
15017
  /** Read only the fields needed by the in-memory funding planner. */
14297
15018
  async findAvailableManagedChangeInputCandidates(userId, basketId, excludeSending, trx) {
14298
- 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 }) => ({
14299
15023
  outputId,
14300
15024
  transactionId,
14301
15025
  satoshis,
14302
15026
  txid,
14303
- vout
15027
+ vout,
15028
+ transactionStatus: statuses.get(transactionId)
14304
15029
  }));
14305
15030
  }
14306
15031
  /** Read the current status of a set of source transactions without loading raw transaction bytes. */
@@ -14388,7 +15113,7 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14388
15113
  throw new WERR_NOT_IMPLEMENTED();
14389
15114
  }
14390
15115
  async getCapabilities() {
14391
- return this.supportsActionBatchPersistence() ? getActionBatchCapabilities() : {};
15116
+ return this.supportsActionBatchPersistence() ? getActionBatchCapabilities(this.actionBatchMaxReservedOutputs, true) : {};
14392
15117
  }
14393
15118
  supportsActionBatchPersistence() {
14394
15119
  return false;
@@ -14407,6 +15132,9 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
14407
15132
  async renewActionBatch(auth, batchId) {
14408
15133
  return await renewActionBatch(this, auth, batchId);
14409
15134
  }
15135
+ async resumeActionBatch(auth, args) {
15136
+ return await resumeActionBatch(this, auth, args);
15137
+ }
14410
15138
  async prepareActionBatchCommit(auth, manifest) {
14411
15139
  return await prepareActionBatchCommit(this, auth, manifest);
14412
15140
  }
@@ -15004,7 +15732,12 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
15004
15732
  for (const output of outputs) {
15005
15733
  const outputId = verifyId(output.outputId);
15006
15734
  await this.validateOutputScript(output);
15007
- 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");
15008
15741
  }
15009
15742
  }
15010
15743
  return verdicts;
@@ -15166,16 +15899,12 @@ var StorageProvider = class StorageProvider extends StorageReaderWriter {
15166
15899
  } });
15167
15900
  for (const o of outputs) {
15168
15901
  if (!o.spendable) continue;
15169
- if (!await this.checkOutputIsUtxo(o, services)) invalidSpendableOutputs.push(o);
15902
+ await this.validateOutputScript(o);
15903
+ if (!requireConclusiveUtxo(await classifyOutputUtxo(services, o))) invalidSpendableOutputs.push(o);
15170
15904
  }
15171
15905
  }
15172
15906
  return { invalidSpendableOutputs };
15173
15907
  }
15174
- async checkOutputIsUtxo(o, services) {
15175
- if (o.lockingScript == null || o.lockingScript.length === 0) return false;
15176
- const hash = services.hashOutputScript(asString(o.lockingScript));
15177
- return (await services.getUtxoStatus(hash, void 0, `${o.txid ?? ""}.${o.vout ?? ""}`)).isUtxo === true;
15178
- }
15179
15908
  async updateProvenTxReqDynamics(id, update, trx) {
15180
15909
  const partial = {};
15181
15910
  if (update.updated_at != null) partial.updated_at = update.updated_at;
@@ -15285,6 +16014,7 @@ const getLabelToSpecOp = () => {
15285
16014
  //#region ../src/storage/methods/listActionsIdb.ts
15286
16015
  async function enrichIdbActionLabels(storage, tx, action, timeFilterRequested) {
15287
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);
15288
16018
  if (timeFilterRequested) {
15289
16019
  const ts = tx.created_at ? new Date(tx.created_at).getTime() : NaN;
15290
16020
  if (!Number.isNaN(ts)) {
@@ -15403,20 +16133,129 @@ async function listActionsIdb(storage, auth, vargs) {
15403
16133
  return r;
15404
16134
  }
15405
16135
  //#endregion
15406
- //#region ../src/storage/methods/ListOutputsSpecOp.ts
15407
- const INVALID_CHANGE_MAX_CONCURRENCY = 12;
15408
- async function runWithConcurrency(values, maxConcurrency, worker) {
15409
- const active = [];
15410
- for (const value of values) {
15411
- const task = worker(value).finally(() => {
15412
- const i = active.indexOf(task);
15413
- 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);
15414
16190
  });
15415
- active.push(task);
15416
- if (active.length >= maxConcurrency) await Promise.race(active);
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")
16245
+ });
16246
+ throw new WERR_UTXO_REVIEW_INCONCLUSIVE(diagnostics.checked, diagnostics.confirmedSpent, diagnostics.unknown);
15417
16247
  }
15418
- 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
+ };
15419
16256
  }
16257
+ //#endregion
16258
+ //#region ../src/storage/methods/ListOutputsSpecOp.ts
15420
16259
  const getBasketToSpecOp = () => {
15421
16260
  return {
15422
16261
  [specOpWalletBalance]: {
@@ -15447,23 +16286,7 @@ const getBasketToSpecOp = () => {
15447
16286
  includeSpent: false,
15448
16287
  tagsToIntercept: ["release", "all"],
15449
16288
  filterOutputs: async (s, auth, vargs, specOpTags, outputs) => {
15450
- if (specOpTags.includes("release")) await s.reviewStatus({ agedLimit: /* @__PURE__ */ new Date(0) });
15451
- const invalidOutputIds = /* @__PURE__ */ new Set();
15452
- const services = s.getServices();
15453
- await runWithConcurrency(outputs, INVALID_CHANGE_MAX_CONCURRENCY, async (o) => {
15454
- if (!o.basketId) return;
15455
- await s.validateOutputScript(o);
15456
- let ok = false;
15457
- if (o.lockingScript != null && o.lockingScript.length > 0) ok = await services.isUtxo(o);
15458
- else ok = void 0;
15459
- if (ok === false) invalidOutputIds.add(o.outputId);
15460
- });
15461
- const filteredOutputs = outputs.filter((o) => invalidOutputIds.has(o.outputId));
15462
- if (specOpTags.includes("release")) await runWithConcurrency(filteredOutputs, INVALID_CHANGE_MAX_CONCURRENCY, async (o) => {
15463
- await s.updateOutput(o.outputId, { spendable: false });
15464
- o.spendable = false;
15465
- });
15466
- return filteredOutputs;
16289
+ return (await reviewUtxoOutputs(s, auth, outputs, specOpTags.includes("release") ? "atomic" : "none")).confirmedSpentOutputs;
15467
16290
  }
15468
16291
  },
15469
16292
  [specOpSetWalletChangeParams]: {
@@ -15616,7 +16439,7 @@ async function loadIdbOutputs(query) {
15616
16439
  "nosend",
15617
16440
  "sending"
15618
16441
  ],
15619
- noScript: true,
16442
+ noScript: specOp?.includeOutputScripts !== true,
15620
16443
  orderDescending
15621
16444
  };
15622
16445
  const pageManagedChange = specOp?.managedChangeOnly === true && specOp.ignoreLimit !== true;
@@ -15698,7 +16521,10 @@ async function listOutputsIdb(storage, auth, vargs, _originator) {
15698
16521
  });
15699
16522
  result.totalOutputs = totalOutputs;
15700
16523
  if (specOp != null) {
15701
- 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
+ }
15702
16528
  if (specOp.resultFromOutputs != null) return specOp.resultFromOutputs(storage, auth, vargs, specOpTags, outputs);
15703
16529
  }
15704
16530
  await hydrateIdbOutputResult(storage, outputs, vargs, result);
@@ -15829,6 +16655,7 @@ async function scanCursor(cursor, since, offset, limit, matches, accept) {
15829
16655
  var StorageIdb = class extends StorageProvider {
15830
16656
  dbName;
15831
16657
  db;
16658
+ managedChangeDefaultsMigrated = false;
15832
16659
  constructor(options) {
15833
16660
  super(options);
15834
16661
  this.dbName = `wallet-toolbox-${this.chain}net`;
@@ -15866,6 +16693,7 @@ var StorageIdb = class extends StorageProvider {
15866
16693
  async verifyDB(storageName, storageIdentityKey) {
15867
16694
  if (this.db != null) return this.db;
15868
16695
  this.db = await this.initDB(storageName, storageIdentityKey);
16696
+ await this.migrateManagedChangeDefaults(this.db);
15869
16697
  this._settings = (await this.db.getAll("settings"))[0];
15870
16698
  this.whenLastAccess = /* @__PURE__ */ new Date();
15871
16699
  return this.db;
@@ -15899,7 +16727,7 @@ var StorageIdb = class extends StorageProvider {
15899
16727
  async initDB(storageName, storageIdentityKey) {
15900
16728
  const chain = this.chain;
15901
16729
  const maxOutputScript = 1024;
15902
- 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) {
15903
16731
  upgradeAllStoresV1(db);
15904
16732
  upgradeActionBatchStoresV2(db);
15905
16733
  const outputs = transaction.objectStore("outputs");
@@ -15925,6 +16753,22 @@ var StorageIdb = class extends StorageProvider {
15925
16753
  }
15926
16754
  } });
15927
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
+ }
15928
16772
  async reviewStatus(args) {
15929
16773
  return await reviewStatusIdb(this, args);
15930
16774
  }
@@ -16679,6 +17523,7 @@ var StorageIdb = class extends StorageProvider {
16679
17523
  if (this.db != null) this.db.close();
16680
17524
  this.db = void 0;
16681
17525
  this._settings = void 0;
17526
+ this.managedChangeDefaultsMigrated = false;
16682
17527
  }
16683
17528
  allStores = [
16684
17529
  "action_batches",
@@ -17851,6 +18696,9 @@ var StorageClientBase = class {
17851
18696
  async renewActionBatch(auth, batchId) {
17852
18697
  return await this.rpcCall("renewActionBatch", [auth, batchId]);
17853
18698
  }
18699
+ async resumeActionBatch(auth, args) {
18700
+ return await this.rpcCall("resumeActionBatch", [auth, args]);
18701
+ }
17854
18702
  async prepareActionBatchCommit(auth, manifest) {
17855
18703
  return await this.rpcCall("prepareActionBatchCommit", [auth, manifest]);
17856
18704
  }
@@ -18655,7 +19503,7 @@ async function restoreBRC38(storage, data) {
18655
19503
  await storage.transaction(async (trx) => {
18656
19504
  await storage.insertUser({ ...data.user }, trx);
18657
19505
  for (const row of data.provenTxs) await storage.insertProvenTx({ ...row }, trx);
18658
- for (const row of data.outputBaskets) await storage.insertOutputBasket({ ...row }, trx);
19506
+ for (const row of data.outputBaskets) await storage.insertOutputBasket(upgradeLegacyManagedChangeBasketDefault({ ...row }), trx);
18659
19507
  for (const row of data.outputTags) await storage.insertOutputTag({ ...row }, trx);
18660
19508
  for (const row of data.txLabels) await storage.insertTxLabel({ ...row }, trx);
18661
19509
  for (const row of data.transactions) await storage.insertTransaction({ ...row }, trx);
@@ -21714,13 +22562,37 @@ function configuredChaintracksClient(chain, serviceUrl) {
21714
22562
  return new ChaintracksServiceClient(chain, serviceUrl);
21715
22563
  }
21716
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
+ /**
21717
22574
  * Returns the credential-free default ChainTracks client for a supported
21718
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.
21719
22589
  */
21720
22590
  function createDefaultChaintracksClient(chain) {
21721
22591
  switch (chain) {
21722
22592
  case "main":
21723
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" });
21724
22596
  case "ttn": return new GoChaintracksServiceClient(chain, arcadeDefaultUrl(chain), { apiPrefix: "/chaintracks/v2" });
21725
22597
  case "stn": return configuredChaintracksClient(chain, stnChaintracksUrl());
21726
22598
  case "tstn": return configuredChaintracksClient(chain, tstnChaintracksUrl());
@@ -21759,7 +22631,7 @@ function createDefaultWalletServicesOptions(...[chain, arcCallbackUrl, arcCallba
21759
22631
  exchangeratesapiKey: void 0,
21760
22632
  chaintracksFiatExchangeRatesUrl,
21761
22633
  chaintracks,
21762
- arcUrl: arcDefaultUrl(chain),
22634
+ arcUrl: chain === "ttn" ? "" : arcDefaultUrl(chain),
21763
22635
  arcConfig: {
21764
22636
  apiKey: taalArcApiKey ?? void 0,
21765
22637
  deploymentId,
@@ -21775,7 +22647,7 @@ function createDefaultWalletServicesOptions(...[chain, arcCallbackUrl, arcCallba
21775
22647
  },
21776
22648
  bitailsApiKey
21777
22649
  };
21778
- const resolvedArcadeUrl = arcadeUrl;
22650
+ const resolvedArcadeUrl = arcadeUrl ?? (chain === "ttn" ? arcadeDefaultUrl(chain) : void 0);
21779
22651
  if (resolvedArcadeUrl != null && resolvedArcadeUrl !== "") {
21780
22652
  o.arcadeUrl = resolvedArcadeUrl;
21781
22653
  o.arcadeConfig = {
@@ -21805,7 +22677,7 @@ function arcDefaultUrl(chain) {
21805
22677
  case "main": return "https://arc.taal.com";
21806
22678
  case "test": return "https://arc-test.taal.com";
21807
22679
  case "stn": return stnArcadeUrl() ?? "";
21808
- case "ttn": return "https://arcade-v2-ttn-us-1.bsvblockchain.tech/";
22680
+ case "ttn": return "";
21809
22681
  case "tstn": return tstnArcadeUrl() ?? "";
21810
22682
  case "mock": return "";
21811
22683
  }
@@ -22177,6 +23049,40 @@ var ARC = class {
22177
23049
  }
22178
23050
  };
22179
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
22180
23086
  //#region ../src/services/providers/Arcade.ts
22181
23087
  function defaultDeploymentId() {
22182
23088
  return `ts-sdk-${_bsv_sdk.Utils.toHex((0, _bsv_sdk.Random)(16))}`;
@@ -22446,6 +23352,73 @@ var Arcade = class {
22446
23352
  return (await this.httpClient.request(`${this.URL}/tx/${txid}`, requestOptions)).data;
22447
23353
  }
22448
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
+ /**
22449
23422
  * `getMerklePath` provider: obtain a BUMP merkle proof for a mined transaction from Arcade.
22450
23423
  *
22451
23424
  * Arcade only has a proof for transactions it tracked (i.e. broadcast through it) that have
@@ -23236,6 +24209,10 @@ var Services = class Services {
23236
24209
  name: "WhatsOnChain",
23237
24210
  service: this.whatsonchain.getStatusForTxids.bind(this.whatsonchain)
23238
24211
  });
24212
+ if (this.arcade != null) this.getStatusForTxidsServices.add({
24213
+ name: "Arcade",
24214
+ service: this.arcade.getStatusForTxids.bind(this.arcade)
24215
+ });
23239
24216
  this.getScriptHashHistoryServices = new ServiceCollection("getScriptHashHistory");
23240
24217
  if (hasWhatsOnChain) this.getScriptHashHistoryServices.add({
23241
24218
  name: "WhatsOnChain",
@@ -23252,7 +24229,7 @@ var Services = class Services {
23252
24229
  name: "GorillaPoolArcBeef",
23253
24230
  service: this.arcGorillaPool.postBeef.bind(this.arcGorillaPool)
23254
24231
  });
23255
- this.postBeefServices.add({
24232
+ if (this.options.arcUrl != null && this.options.arcUrl !== "") this.postBeefServices.add({
23256
24233
  name: "TaalArcBeef",
23257
24234
  service: this.arcTaal.postBeef.bind(this.arcTaal)
23258
24235
  });
@@ -23338,30 +24315,65 @@ var Services = class Services {
23338
24315
  async getStatusForTxids(txids, useNext) {
23339
24316
  const services = this.getStatusForTxidsServices;
23340
24317
  if (useNext === true) services.next();
23341
- let r0 = {
24318
+ let fallback = {
23342
24319
  name: "<noservices>",
23343
24320
  status: "error",
23344
24321
  error: new WERR_INTERNAL("No services available."),
23345
24322
  results: []
23346
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
+ };
23347
24334
  for (let tries = 0; tries < services.count; tries++) {
23348
24335
  const stc = services.serviceToCall;
23349
24336
  try {
23350
- 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);
23351
24340
  if (r.status === "success") {
23352
24341
  services.addServiceCallSuccess(stc);
23353
- r0 = r;
23354
- 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;
23355
24351
  }
24352
+ fallback = r;
23356
24353
  if (r.error != null) services.addServiceCallError(stc, r.error);
23357
24354
  else services.addServiceCallFailure(stc);
23358
24355
  } catch (error_) {
23359
24356
  const e = WalletError.fromUnknown(error_);
24357
+ fallback = {
24358
+ name: stc.providerName,
24359
+ status: "error",
24360
+ error: e,
24361
+ results: []
24362
+ };
23360
24363
  services.addServiceCallError(stc, e);
23361
24364
  }
23362
24365
  services.next();
23363
24366
  }
23364
- 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
+ };
23365
24377
  }
23366
24378
  async getStatusForTxidsBatched(stc, txids) {
23367
24379
  const results = [];
@@ -23388,9 +24400,7 @@ var Services = class Services {
23388
24400
  return _bsv_sdk.Utils.toHex(sha256Hash(_bsv_sdk.Utils.toArray(script, "hex")));
23389
24401
  }
23390
24402
  async isUtxo(output) {
23391
- if (output.lockingScript == null) throw new WERR_INVALID_PARAMETER("output.lockingScript", "validated by storage provider validateOutputScript.");
23392
- const hash = this.hashOutputScript(_bsv_sdk.Utils.toHex(output.lockingScript));
23393
- return (await this.getUtxoStatus(hash, void 0, `${output.txid ?? ""}.${output.vout}`)).isUtxo === true;
24403
+ return requireConclusiveUtxo(await classifyOutputUtxo(this, output));
23394
24404
  }
23395
24405
  async getUtxoStatus(output, outputFormat, outpoint, useNext, logger) {
23396
24406
  const services = this.getUtxoStatusServices;
@@ -23616,7 +24626,15 @@ var Services = class Services {
23616
24626
  const method = async () => {
23617
24627
  return await this.options.chaintracks.currentHeight();
23618
24628
  };
23619
- 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
+ }
23620
24638
  }
23621
24639
  async hashToHeader(hash) {
23622
24640
  const method = async () => {
@@ -27335,17 +28353,34 @@ var ArcSSEClient = class {
27335
28353
  console.log(`${TAG} connected`);
27336
28354
  });
27337
28355
  this.es.addEventListener("status", (event) => {
28356
+ let data;
27338
28357
  try {
27339
- const data = JSON.parse(event.data);
27340
- console.log(`${TAG} event: txid=${data.txid} status=${data.txStatus}`);
27341
- if (typeof event.lastEventId === "string" && event.lastEventId !== "") {
27342
- this._lastEventId = event.lastEventId;
27343
- this.options.onLastEventIdChanged?.(event.lastEventId);
27344
- }
27345
- this.options.onEvent(data);
28358
+ data = JSON.parse(event.data);
27346
28359
  } catch {
27347
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;
27348
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
+ });
27349
28384
  });
27350
28385
  this.es.addEventListener("error", (event) => {
27351
28386
  console.log(`${TAG} error:`, JSON.stringify(event));
@@ -27380,94 +28415,6 @@ var ArcSSEClient = class {
27380
28415
  }
27381
28416
  };
27382
28417
  //#endregion
27383
- //#region ../src/monitor/tasks/TaskUnFail.ts
27384
- /**
27385
- * Setting provenTxReq status to 'unfail' when 'invalid' will attempt to find a merklePath, and if successful:
27386
- *
27387
- * 1. set the req status to 'unmined'
27388
- * 2. set the referenced txs to 'unproven'
27389
- * 3. determine if any inputs match user's existing outputs and if so update spentBy and spendable of those outputs.
27390
- * 4. set the txs outputs to spendable
27391
- *
27392
- * If it fails (to find a merklePath), returns the req status to 'invalid'.
27393
- */
27394
- var TaskUnFail = class TaskUnFail extends WalletMonitorTask {
27395
- triggerMsecs;
27396
- static taskName = "UnFail";
27397
- /**
27398
- * Set to true to trigger running this task
27399
- */
27400
- static checkNowRequested = false;
27401
- static get checkNow() {
27402
- return this.checkNowRequested;
27403
- }
27404
- static set checkNow(value) {
27405
- this.checkNowRequested = value;
27406
- }
27407
- constructor(monitor, triggerMsecs = Monitor.oneMinute * 10) {
27408
- super(monitor, TaskUnFail.taskName);
27409
- this.triggerMsecs = triggerMsecs;
27410
- }
27411
- trigger(nowMsecsSinceEpoch) {
27412
- return { run: TaskUnFail.checkNow || this.triggerMsecs > 0 && nowMsecsSinceEpoch - this.lastRunMsecsSinceEpoch > this.triggerMsecs };
27413
- }
27414
- async runTask() {
27415
- let log = "";
27416
- TaskUnFail.checkNow = false;
27417
- const limit = 100;
27418
- let offset = 0;
27419
- for (;;) {
27420
- const reqs = await this.storage.findProvenTxReqs({
27421
- partial: {},
27422
- status: ["unfail"],
27423
- paged: {
27424
- limit,
27425
- offset
27426
- }
27427
- });
27428
- if (reqs.length === 0) break;
27429
- log += `${reqs.length} reqs with status 'unfail'\n`;
27430
- const r = await this.unfail(reqs, 2);
27431
- log += `${r.log}\n`;
27432
- if (reqs.length < limit) break;
27433
- offset += limit;
27434
- }
27435
- return log;
27436
- }
27437
- async unfail(reqs, indent = 0) {
27438
- let log = "";
27439
- for (const reqApi of reqs) {
27440
- const req = new EntityProvenTxReq(reqApi);
27441
- log += " ".repeat(indent);
27442
- log += `reqId ${reqApi.provenTxReqId} txid ${reqApi.txid}: `;
27443
- if ((await this.monitor.services.getMerklePath(req.txid)).merklePath != null) {
27444
- log += "unfailed. status is now 'unmined'\n";
27445
- log += await this.unfailReq(req, indent + 2);
27446
- } else {
27447
- req.status = "invalid";
27448
- log += "returned to status 'invalid'\n";
27449
- await req.updateStorageDynamicProperties(this.storage);
27450
- }
27451
- }
27452
- return { log };
27453
- }
27454
- /**
27455
- * 2. set the referenced txs to 'unproven'
27456
- * 3. determine if any inputs match user's existing outputs and if so update spentBy and spendable of those outputs.
27457
- * 4. set the txs outputs to spendable
27458
- *
27459
- * @param req
27460
- * @param indent
27461
- * @returns
27462
- */
27463
- async unfailReq(req, indent) {
27464
- return await this.storage.runAsStorageProvider(async (sp) => await sp.unfailTransactionsForProof(req, indent, {
27465
- status: "unmined",
27466
- attempts: 0
27467
- }));
27468
- }
27469
- };
27470
- //#endregion
27471
28418
  //#region ../src/monitor/tasks/TaskArcSSE.ts
27472
28419
  /**
27473
28420
  * Monitor task that receives transaction status updates from Arcade via SSE
@@ -27511,16 +28458,14 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27511
28458
  arcApiKey: arcadeApiKey,
27512
28459
  lastEventId,
27513
28460
  EventSourceClass,
27514
- onEvent: (event) => {
27515
- this.pendingEvents.push(event);
27516
- },
28461
+ onEvent: async (event) => await new Promise((acknowledge) => {
28462
+ this.pendingEvents.push({
28463
+ event,
28464
+ acknowledge
28465
+ });
28466
+ }),
27517
28467
  onError: (err) => {
27518
28468
  this.logSetupEvent(`SSE error: ${err.message}`);
27519
- },
27520
- onLastEventIdChanged: (id) => {
27521
- this.monitor.options.saveLastSSEEventId?.(id).catch((e) => {
27522
- this.logSetupEvent(`failed to persist lastEventId: ${stringifyError(e)}`);
27523
- });
27524
28469
  }
27525
28470
  });
27526
28471
  this.sseClient.connect();
@@ -27534,10 +28479,21 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27534
28479
  return { run: this.pendingEvents.length > 0 };
27535
28480
  }
27536
28481
  async runTask() {
27537
- const events = this.pendingEvents.splice(0);
27538
- if (events.length === 0) return "";
28482
+ const eventCount = this.pendingEvents.length;
28483
+ if (eventCount === 0) return "";
27539
28484
  let log = "";
27540
- 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
+ }
27541
28497
  return log;
27542
28498
  }
27543
28499
  async fetchNow() {
@@ -27553,28 +28509,26 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27553
28509
  }
27554
28510
  for (const reqApi of reqs) {
27555
28511
  const req = new EntityProvenTxReq(reqApi);
27556
- const canRecoverRejectedRequest = this.canRecoverRejectedRequest(req, event);
27557
- if (ProvenTxReqTerminalStatus.includes(req.status) && !canRecoverRejectedRequest) {
28512
+ const canRecoverTerminalRequest = this.canRecoverTerminalRequest(req, event);
28513
+ if (ProvenTxReqTerminalStatus.includes(req.status) && !canRecoverTerminalRequest) {
27558
28514
  log += ` req ${req.id} already terminal: ${req.status}\n`;
27559
28515
  continue;
27560
28516
  }
27561
28517
  const note = {
27562
28518
  when: (/* @__PURE__ */ new Date()).toISOString(),
27563
28519
  what: "arcSSE",
27564
- 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) } : {}
27565
28524
  };
27566
28525
  log += await this.applyStatusEvent(req, event, note);
27567
28526
  }
27568
28527
  this.monitor.callOnTransactionStatusChanged(event.txid, event.txStatus);
27569
28528
  return log;
27570
28529
  }
27571
- canRecoverRejectedRequest(req, event) {
27572
- return req.status === "invalid" && ([
27573
- "SENT_TO_NETWORK",
27574
- "ACCEPTED_BY_NETWORK",
27575
- "SEEN_ON_NETWORK",
27576
- "SEEN_MULTIPLE_NODES"
27577
- ].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);
27578
28532
  }
27579
28533
  async applyStatusEvent(req, event, note) {
27580
28534
  switch (event.txStatus) {
@@ -27584,11 +28538,16 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27584
28538
  case "SEEN_MULTIPLE_NODES": return await this.applyAcceptedStatus(req, note);
27585
28539
  case "MINED":
27586
28540
  case "IMMUTABLE": return await this.applyMinedStatus(req, note);
27587
- case "DOUBLE_SPEND_ATTEMPTED": return await this.applyDoubleSpendStatus(req, note);
27588
- 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":
27589
28548
  req.addHistoryNote(note);
27590
28549
  await req.updateStorageDynamicProperties(this.storage);
27591
- return ` req ${req.id} rejection recorded; awaiting resolution\n`;
28550
+ return ` req ${req.id} ${event.txStatus} recorded; awaiting resolution\n`;
27592
28551
  default: return ` req ${req.id} unhandled status: ${event.txStatus}\n`;
27593
28552
  }
27594
28553
  }
@@ -27596,43 +28555,73 @@ var TaskArcadeSSE = class TaskArcadeSSE extends WalletMonitorTask {
27596
28555
  if (![
27597
28556
  "unsent",
27598
28557
  "sending",
27599
- "callback",
27600
- "invalid"
28558
+ "callback"
27601
28559
  ].includes(req.status)) return "";
27602
- const wasInvalid = req.status === "invalid";
27603
- let log = "";
27604
- if (wasInvalid) log += await new TaskUnFail(this.monitor).unfailReq(req, 4);
27605
- else if (req.notify.transactionIds != null) await this.storage.runAsStorageProvider(async (sp) => {
28560
+ if (req.notify.transactionIds != null) await this.storage.runAsStorageProvider(async (sp) => {
27606
28561
  await sp.updateTransactionsStatus(req.notify.transactionIds ?? [], "unproven");
27607
28562
  });
27608
28563
  req.status = "unmined";
27609
- if (wasInvalid) req.attempts = 0;
27610
28564
  req.wasBroadcast = true;
27611
28565
  req.addHistoryNote(note);
27612
28566
  await req.updateStorageDynamicProperties(this.storage);
27613
- return log + ` req ${req.id} => unmined\n`;
28567
+ return ` req ${req.id} => unmined\n`;
27614
28568
  }
27615
28569
  async applyMinedStatus(req, note) {
27616
- let log = "";
27617
- if (req.status === "invalid") {
27618
- log += await new TaskUnFail(this.monitor).unfailReq(req, 4);
27619
- req.status = "unmined";
27620
- req.attempts = 0;
27621
- req.wasBroadcast = true;
27622
- }
27623
28570
  req.addHistoryNote(note);
27624
28571
  await req.updateStorageDynamicProperties(this.storage);
27625
- return log + await this.fetchProofFromServices(req);
28572
+ return await this.fetchProofFromServices(req);
27626
28573
  }
27627
- async applyDoubleSpendStatus(req, note) {
27628
- req.status = "doubleSpend";
27629
- req.addHistoryNote(note);
27630
- await req.updateStorageDynamicProperties(this.storage);
27631
- const ids = req.notify.transactionIds;
27632
- if (ids != null) await this.storage.runAsStorageProvider(async (sp) => {
27633
- 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
+ });
27634
28623
  });
27635
- return ` req ${req.id} => doubleSpend\n`;
28624
+ return ` req ${req.id} => ${classification.reqStatus}; quarantined ${quarantined.staleConfirmed} local input copy/copies\n`;
27636
28625
  }
27637
28626
  /**
27638
28627
  * Complete a MINED/IMMUTABLE status by using the configured proof providers.
@@ -28044,9 +29033,104 @@ var TaskCheckNoSends = class TaskCheckNoSends extends WalletMonitorTask {
28044
29033
  }
28045
29034
  };
28046
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
28047
29124
  //#region ../src/monitor/tasks/TaskReviewUtxos.ts
29125
+ const REVIEW_PAGE_DEFAULT_LIMIT = 20;
29126
+ const REVIEW_PAGE_MAX_LIMIT = 250;
28048
29127
  /**
28049
- * 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.
28050
29134
  *
28051
29135
  * The task itself is disabled and will not run on a schedule; review must be triggered manually by calling reviewByIdentityKey.
28052
29136
  */
@@ -28063,7 +29147,7 @@ var TaskReviewUtxos = class TaskReviewUtxos extends WalletMonitorTask {
28063
29147
  static set checkNow(value) {
28064
29148
  this.checkNowRequested = value;
28065
29149
  }
28066
- constructor(monitor, triggerMsecs = 0, userLimit = 10, userOffset = 0, tags = ["release", "all"]) {
29150
+ constructor(monitor, triggerMsecs = 0, userLimit = 10, userOffset = 0, tags = ["all"]) {
28067
29151
  super(monitor, TaskReviewUtxos.taskName);
28068
29152
  this.triggerMsecs = triggerMsecs;
28069
29153
  this.userLimit = userLimit;
@@ -28077,8 +29161,8 @@ var TaskReviewUtxos = class TaskReviewUtxos extends WalletMonitorTask {
28077
29161
  TaskReviewUtxos.checkNow = false;
28078
29162
  return "TaskReviewUtxos is disabled; use reviewByIdentityKey instead.\n";
28079
29163
  }
28080
- async reviewByIdentityKey(identityKey, mode = "all") {
28081
- const tags = ["release", ...mode === "all" ? ["all"] : []];
29164
+ async reviewByIdentityKey(identityKey, mode = "all", release = false) {
29165
+ const tags = [...release ? ["release"] : [], ...mode === "all" ? ["all"] : []];
28082
29166
  const vargs = {
28083
29167
  basket: specOpInvalidChange,
28084
29168
  tags,
@@ -28106,8 +29190,150 @@ var TaskReviewUtxos = class TaskReviewUtxos extends WalletMonitorTask {
28106
29190
  return this.toUserLog(user, result.outputs, result.totalOutputs, total, tags);
28107
29191
  });
28108
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
+ }
28109
29335
  toUserLog(user, outputs, totalOutputs, total, tags) {
28110
- const action = tags.includes("release") ? "updated to unspendable" : "found";
29336
+ const action = tags.includes("release") ? "confirmed spent and updated to unspendable" : "confirmed spent";
28111
29337
  const target = tags.includes("all") ? "spendable utxos" : "spendable change utxos";
28112
29338
  let log = `userId ${user.userId}: ${totalOutputs} ${target} ${action}, total ${total}, ${user.identityKey}\n`;
28113
29339
  for (const output of outputs) log += ` ${output.outpoint} ${output.satoshis} now ${output.spendable ? "spendable" : "spent"}\n`;
@@ -28116,6 +29342,13 @@ var TaskReviewUtxos = class TaskReviewUtxos extends WalletMonitorTask {
28116
29342
  };
28117
29343
  //#endregion
28118
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
+ }
28119
29352
  /**
28120
29353
  * Review recent reqs in terminal 'doubleSpend' state and move any false positives
28121
29354
  * back to 'unfail' so existing recovery handling can re-process them.
@@ -28183,14 +29416,16 @@ var TaskReviewDoubleSpends = class TaskReviewDoubleSpends extends WalletMonitorT
28183
29416
  let lastRetainedDoubleSpendIndex = -1;
28184
29417
  let retainedDoubleSpendCount = 0;
28185
29418
  for (const req of reqs) {
28186
- 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);
28187
29422
  reviewed.push(req);
28188
- if (status === "unknown") {
29423
+ if (!(status === "mined" || status === "known" && !durableInputConflict)) {
28189
29424
  lastRetainedDoubleSpendIndex = reviewed.length - 1;
28190
29425
  retainedDoubleSpendCount += 1;
28191
29426
  } else {
28192
29427
  unfails.push(req.provenTxReqId);
28193
- log += `unfail ${req.provenTxReqId} ${req.txid} status:${status}\n`;
29428
+ log += `unfail ${req.provenTxReqId} ${req.txid} status:${status} durableInputConflict:${String(durableInputConflict)}\n`;
28194
29429
  }
28195
29430
  }
28196
29431
  if (unfails.length > 0) await this.storage.runAsStorageProvider(async (sp) => {
@@ -28229,6 +29464,168 @@ var TaskReviewDoubleSpends = class TaskReviewDoubleSpends extends WalletMonitorT
28229
29464
  }
28230
29465
  };
28231
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
28232
29629
  //#region ../src/monitor/tasks/TaskReviewProvenTxs.ts
28233
29630
  /**
28234
29631
  * Backup verification task for recent proven_txs records.
@@ -28487,14 +29884,14 @@ var Monitor = class Monitor {
28487
29884
  purgeFailedAge: 5 * Monitor.oneDay
28488
29885
  };
28489
29886
  addAllTasksToOther() {
28490
- 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));
28491
29888
  if (this.chain === "mock") this._otherTasks.push(new TaskMineBlock(this));
28492
29889
  }
28493
29890
  /**
28494
29891
  * Default tasks with settings appropriate for a single user storage
28495
29892
  */
28496
29893
  addDefaultTasks() {
28497
- 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));
28498
29895
  this._otherTasks.push(new TaskPurge(this, this.defaultPurgeParams, 6 * Monitor.oneHour), new TaskReviewUtxos(this));
28499
29896
  if (this.chain === "mock") this._tasks.push(new TaskMineBlock(this));
28500
29897
  }
@@ -28502,7 +29899,7 @@ var Monitor = class Monitor {
28502
29899
  * Tasks appropriate for multi-user storage
28503
29900
  */
28504
29901
  addMultiUserTasks() {
28505
- 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));
28506
29903
  this._otherTasks.push(new TaskPurge(this, this.defaultPurgeParams), new TaskReviewUtxos(this));
28507
29904
  if (this.chain === "mock") this._tasks.push(new TaskMineBlock(this));
28508
29905
  }
@@ -28873,6 +30270,7 @@ var SetupClient = class SetupClient {
28873
30270
  model: "sat/kb",
28874
30271
  value: 100
28875
30272
  },
30273
+ managedChangePolicy: args.managedChangePolicy,
28876
30274
  scriptVerifier: args.scriptVerifier
28877
30275
  });
28878
30276
  await storage.migrate(args.databaseName, randomBytesHex(33));
@@ -33462,7 +34860,7 @@ var WalletPermissionsManager = class WalletPermissionsManager {
33462
34860
  basket: basketName,
33463
34861
  tags
33464
34862
  }],
33465
- options: { acceptDelayedBroadcast: false }
34863
+ options: { acceptDelayedBroadcast: true }
33466
34864
  }, this.adminOriginator);
33467
34865
  }
33468
34866
  async mapWithConcurrency(items, concurrency, fn) {
@@ -33526,7 +34924,7 @@ var WalletPermissionsManager = class WalletPermissionsManager {
33526
34924
  await this.createAction({
33527
34925
  description: `Grant ${built.length} permissions`,
33528
34926
  outputs: built.map((b) => b.output),
33529
- options: { acceptDelayedBroadcast: false }
34927
+ options: { acceptDelayedBroadcast: true }
33530
34928
  }, this.adminOriginator);
33531
34929
  return built.map((b) => b.request);
33532
34930
  }, strict);
@@ -34923,6 +36321,7 @@ exports.ARGON2ID_MAX_MEMORY_KIB = ARGON2ID_MAX_MEMORY_KIB;
34923
36321
  exports.ARGON2ID_MAX_PARALLELISM = ARGON2ID_MAX_PARALLELISM;
34924
36322
  exports.AuthMethodInteractor = AuthMethodInteractor;
34925
36323
  exports.BHServiceClient = BHServiceClient;
36324
+ exports.BRC153_REFERENCE_PREFIX = BRC153_REFERENCE_PREFIX;
34926
36325
  exports.BulkFileDataManager = BulkFileDataManager;
34927
36326
  exports.BulkFileDataReader = BulkFileDataReader;
34928
36327
  exports.BulkFilesReader = BulkFilesReader;
@@ -34943,6 +36342,11 @@ exports.ChaintracksServiceClient = ChaintracksServiceClient;
34943
36342
  exports.ChaintracksStorageBase = ChaintracksStorageBase;
34944
36343
  exports.ChaintracksStorageIdb = ChaintracksStorageIdb;
34945
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;
34946
36350
  exports.DEFAULT_PROFILE_ID = DEFAULT_PROFILE_ID;
34947
36351
  exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
34948
36352
  exports.DevConsoleInteractor = DevConsoleInteractor;
@@ -34964,6 +36368,7 @@ exports.EntityUser = EntityUser;
34964
36368
  exports.GoChaintracksServiceClient = GoChaintracksServiceClient;
34965
36369
  exports.HeightRange = HeightRange;
34966
36370
  exports.KDF_MAX_HASH_LENGTH = KDF_MAX_HASH_LENGTH;
36371
+ exports.LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS = LEGACY_MANAGED_CHANGE_MINIMUM_SATOSHIS;
34967
36372
  exports.LiveIngestorBase = LiveIngestorBase;
34968
36373
  exports.LiveIngestorChaintracksSSE = LiveIngestorChaintracksSSE;
34969
36374
  exports.LiveIngestorWhatsOnChainPoll = LiveIngestorWhatsOnChainPoll;
@@ -34998,6 +36403,7 @@ exports.WalletSettingsManager = WalletSettingsManager;
34998
36403
  exports.WalletSigner = WalletSigner;
34999
36404
  exports.WalletStorageManager = WalletStorageManager;
35000
36405
  exports.WhatsOnChainServices = WhatsOnChainServices;
36406
+ exports.applyBrc153ReferenceLabel = applyBrc153ReferenceLabel;
35001
36407
  exports.arcDefaultUrl = arcDefaultUrl;
35002
36408
  exports.arcGorillaPoolUrl = arcGorillaPoolUrl;
35003
36409
  exports.arcadeDefaultUrl = arcadeDefaultUrl;
@@ -35023,6 +36429,7 @@ exports.createIdbChaintracks = createIdbChaintracks;
35023
36429
  exports.createNoDbChaintracks = createNoDbChaintracks;
35024
36430
  exports.createSyncMap = createSyncMap;
35025
36431
  exports.decryptBRC39 = decryptBRC39;
36432
+ exports.defaultManagedChangePolicy = defaultManagedChangePolicy;
35026
36433
  exports.doubleSha256BE = doubleSha256BE;
35027
36434
  exports.doubleSha256LE = doubleSha256LE;
35028
36435
  exports.encryptBRC39 = encryptBRC39;
@@ -35037,6 +36444,8 @@ exports.importBRC39 = importBRC39;
35037
36444
  exports.isAutoSpendableChangeOutput = isAutoSpendableChangeOutput;
35038
36445
  exports.isBaseBlockHeader = isBaseBlockHeader;
35039
36446
  exports.isBlockHeader = isBlockHeader;
36447
+ exports.isBrc153ReferenceLabel = isBrc153ReferenceLabel;
36448
+ exports.isLegacyManagedChangeBasketDefault = isLegacyManagedChangeBasketDefault;
35040
36449
  exports.isLive = isLive;
35041
36450
  exports.isLiveBlockHeader = isLiveBlockHeader;
35042
36451
  exports.isManagedChangeOutput = isManagedChangeOutput;
@@ -35045,12 +36454,14 @@ exports.logWalletError = logWalletError;
35045
36454
  exports.logger = logger;
35046
36455
  exports.makeAtomicBeef = makeAtomicBeef;
35047
36456
  exports.makeBrc114ActionTimeLabel = makeBrc114ActionTimeLabel;
36457
+ exports.makeBrc153ReferenceLabel = makeBrc153ReferenceLabel;
35048
36458
  exports.managedChangeOutputFields = managedChangeOutputFields;
35049
36459
  exports.maxDate = maxDate;
35050
36460
  exports.optionalArraysEqual = optionalArraysEqual;
35051
36461
  exports.outputColumnsWithoutLockingScript = outputColumnsWithoutLockingScript;
35052
36462
  exports.parseBRC38Json = parseBRC38Json;
35053
36463
  exports.parseBrc114ActionTimeLabels = parseBrc114ActionTimeLabels;
36464
+ exports.parseBrc153ReferenceLabel = parseBrc153ReferenceLabel;
35054
36465
  exports.parseFileLink = parseFileLink;
35055
36466
  exports.parseTxScriptOffsets = parseTxScriptOffsets;
35056
36467
  exports.partitionActionLabels = partitionActionLabels;
@@ -35076,12 +36487,14 @@ exports.toDefaultChaintracksArguments = toDefaultChaintracksArguments;
35076
36487
  exports.toLookupNetworkPreset = toLookupNetworkPreset;
35077
36488
  exports.toWalletNetwork = toWalletNetwork;
35078
36489
  exports.transactionColumnsWithoutRawTx = transactionColumnsWithoutRawTx;
36490
+ exports.upgradeLegacyManagedChangeBasketDefault = upgradeLegacyManagedChangeBasketDefault;
35079
36491
  Object.defineProperty(exports, "utils", {
35080
36492
  enumerable: true,
35081
36493
  get: function() {
35082
36494
  return blockHeaderUtilities_exports;
35083
36495
  }
35084
36496
  });
36497
+ exports.validateManagedChangePolicy = validateManagedChangePolicy;
35085
36498
  exports.validateScriptHash = validateScriptHash;
35086
36499
  exports.validateSecondsSinceEpoch = validateSecondsSinceEpoch;
35087
36500
  exports.validateStorageFeeModel = validateStorageFeeModel;