@cogcoin/client 0.5.11 → 0.5.13

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.
Files changed (46) hide show
  1. package/README.md +3 -1
  2. package/dist/app-paths.d.ts +1 -0
  3. package/dist/app-paths.js +3 -0
  4. package/dist/bitcoind/bootstrap/controller.d.ts +3 -0
  5. package/dist/bitcoind/bootstrap/controller.js +7 -5
  6. package/dist/bitcoind/client/factory.d.ts +8 -0
  7. package/dist/bitcoind/client/factory.js +43 -6
  8. package/dist/bitcoind/client/managed-client.js +19 -10
  9. package/dist/bitcoind/client/sync-engine.js +35 -4
  10. package/dist/bitcoind/progress/formatting.js +1 -1
  11. package/dist/bitcoind/testing.d.ts +1 -0
  12. package/dist/bitcoind/testing.js +1 -0
  13. package/dist/cli/commands/follow.js +47 -14
  14. package/dist/cli/commands/sync.js +48 -15
  15. package/dist/cli/context.js +5 -1
  16. package/dist/cli/output.js +11 -0
  17. package/dist/cli/runner.js +2 -0
  18. package/dist/cli/signals.d.ts +1 -1
  19. package/dist/cli/signals.js +17 -4
  20. package/dist/cli/types.d.ts +4 -0
  21. package/dist/cli/update-notifier.d.ts +2 -0
  22. package/dist/cli/update-notifier.js +276 -0
  23. package/dist/client/default-client.d.ts +1 -1
  24. package/dist/client/default-client.js +7 -1
  25. package/dist/client/factory.js +6 -1
  26. package/dist/sqlite/store.js +3 -0
  27. package/dist/types.d.ts +2 -0
  28. package/dist/wallet/archive.js +10 -8
  29. package/dist/wallet/coin-control.d.ts +41 -0
  30. package/dist/wallet/coin-control.js +365 -0
  31. package/dist/wallet/lifecycle.js +39 -2
  32. package/dist/wallet/mining/runner.js +46 -44
  33. package/dist/wallet/read/context.js +15 -6
  34. package/dist/wallet/reset.js +2 -0
  35. package/dist/wallet/state/storage.js +5 -4
  36. package/dist/wallet/tx/anchor.js +36 -51
  37. package/dist/wallet/tx/cog.js +19 -12
  38. package/dist/wallet/tx/common.d.ts +41 -10
  39. package/dist/wallet/tx/common.js +112 -5
  40. package/dist/wallet/tx/domain-admin.js +13 -8
  41. package/dist/wallet/tx/domain-market.js +19 -12
  42. package/dist/wallet/tx/field.js +21 -18
  43. package/dist/wallet/tx/register.js +17 -12
  44. package/dist/wallet/tx/reputation.js +13 -8
  45. package/dist/wallet/types.d.ts +4 -0
  46. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { attachOrStartManagedBitcoindService, probeManagedBitcoindService, } fro
7
7
  import { resolveCogcoinProcessingStartHeight } from "../../bitcoind/processing-start-height.js";
8
8
  import {} from "../../bitcoind/types.js";
9
9
  import { loadOrAutoUnlockWalletState, verifyManagedCoreWalletReplica, } from "../lifecycle.js";
10
+ import { normalizeWalletStateRecord, persistWalletCoinControlStateIfNeeded } from "../coin-control.js";
10
11
  import { persistNormalizedWalletDescriptorStateIfNeeded } from "../descriptor-normalization.js";
11
12
  import { inspectMiningControlPlane } from "../mining/index.js";
12
13
  import { normalizeMiningStateRecord } from "../mining/state.js";
@@ -76,9 +77,17 @@ async function normalizeLoadedWalletStateForRead(options) {
76
77
  replacePrimary: options.loaded.source === "backup",
77
78
  rpc: createRpcClient(node.rpc),
78
79
  });
79
- return {
80
- source: normalized.changed ? "primary" : options.loaded.source,
80
+ const coinControl = await persistWalletCoinControlStateIfNeeded({
81
81
  state: normalized.state,
82
+ access,
83
+ paths: options.paths,
84
+ nowUnixMs: options.now,
85
+ replacePrimary: (normalized.changed ? "primary" : options.loaded.source) === "backup",
86
+ rpc: createRpcClient(node.rpc),
87
+ });
88
+ return {
89
+ source: coinControl.changed ? "primary" : normalized.changed ? "primary" : options.loaded.source,
90
+ state: coinControl.state,
82
91
  };
83
92
  }
84
93
  finally {
@@ -186,10 +195,10 @@ async function inspectWalletLocalState(options = {}) {
186
195
  return {
187
196
  availability: "ready",
188
197
  walletRootId: unlocked.state.walletRootId,
189
- state: {
198
+ state: normalizeWalletStateRecord({
190
199
  ...unlocked.state,
191
200
  miningState: normalizeMiningStateRecord(unlocked.state.miningState),
192
- },
201
+ }),
193
202
  source: unlocked.source,
194
203
  unlockUntilUnixMs: unlocked.session.unlockUntilUnixMs,
195
204
  hasPrimaryStateFile,
@@ -226,10 +235,10 @@ async function inspectWalletLocalState(options = {}) {
226
235
  return {
227
236
  availability: "ready",
228
237
  walletRootId: loaded.state.walletRootId,
229
- state: {
238
+ state: normalizeWalletStateRecord({
230
239
  ...loaded.state,
231
240
  miningState: normalizeMiningStateRecord(loaded.state.miningState),
232
- },
241
+ }),
233
242
  source: loaded.source,
234
243
  unlockUntilUnixMs: null,
235
244
  hasPrimaryStateFile,
@@ -187,6 +187,8 @@ function createEntropyRetainedWalletState(previousState, nowUnixMs) {
187
187
  walletRootId,
188
188
  network: previousState.network,
189
189
  anchorValueSats: previousState.anchorValueSats,
190
+ proactiveReserveSats: previousState.proactiveReserveSats,
191
+ proactiveReserveOutpoints: previousState.proactiveReserveOutpoints,
190
192
  nextDedicatedIndex: previousState.nextDedicatedIndex,
191
193
  fundingIndex: previousState.fundingIndex,
192
194
  mnemonic: {
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { writeJsonFileAtomic } from "../fs/atomic.js";
3
+ import { normalizeWalletStateRecord } from "../coin-control.js";
3
4
  import { decryptJsonWithPassphrase, decryptJsonWithSecretProvider, encryptJsonWithPassphrase, encryptJsonWithSecretProvider, } from "./crypto.js";
4
5
  async function readEnvelope(path) {
5
6
  const raw = await readFile(path, "utf8");
@@ -73,18 +74,18 @@ export async function loadWalletState(paths, access) {
73
74
  try {
74
75
  return {
75
76
  source: "primary",
76
- state: typeof access === "string" || access instanceof Uint8Array
77
+ state: normalizeWalletStateRecord(typeof access === "string" || access instanceof Uint8Array
77
78
  ? await decryptJsonWithPassphrase(await readEnvelope(paths.primaryPath), access)
78
- : await decryptJsonWithSecretProvider(await readEnvelope(paths.primaryPath), access.provider),
79
+ : await decryptJsonWithSecretProvider(await readEnvelope(paths.primaryPath), access.provider)),
79
80
  };
80
81
  }
81
82
  catch (primaryError) {
82
83
  try {
83
84
  return {
84
85
  source: "backup",
85
- state: typeof access === "string" || access instanceof Uint8Array
86
+ state: normalizeWalletStateRecord(typeof access === "string" || access instanceof Uint8Array
86
87
  ? await decryptJsonWithPassphrase(await readEnvelope(paths.backupPath), access)
87
- : await decryptJsonWithSecretProvider(await readEnvelope(paths.backupPath), access.provider),
88
+ : await decryptJsonWithSecretProvider(await readEnvelope(paths.backupPath), access.provider)),
88
89
  };
89
90
  }
90
91
  catch {
@@ -5,11 +5,12 @@ import { attachOrStartManagedBitcoindService } from "../../bitcoind/service.js";
5
5
  import { createRpcClient } from "../../bitcoind/node.js";
6
6
  import { acquireFileLock } from "../fs/lock.js";
7
7
  import { deriveWalletIdentityMaterial, } from "../material.js";
8
+ import { computeDesignatedProactiveReserveOutpoints } from "../coin-control.js";
8
9
  import { resolveWalletRuntimePathsForTesting } from "../runtime.js";
9
10
  import { createDefaultWalletSecretProvider, } from "../state/provider.js";
10
11
  import { serializeDomainAnchor, serializeDomainTransfer, validateDomainName, } from "../cogop/index.js";
11
12
  import { openWalletReadContext } from "../read/index.js";
12
- import { assertWalletMutationContextReady, buildWalletMutationTransaction, isAlreadyAcceptedError, isBroadcastUnknownError, pauseMiningForWalletMutation, saveWalletStatePreservingUnlock, unlockTemporaryBuilderLocks, } from "./common.js";
13
+ import { assertFixedInputPrefixMatches, assertFundingInputsAfterFixedPrefix, assertWalletMutationContextReady, buildWalletMutationTransactionWithReserveFallback, getDecodedInputScriptPubKeyHex, isAlreadyAcceptedError, isBroadcastUnknownError, outpointKey, pauseMiningForWalletMutation, saveWalletStatePreservingUnlock, unlockTemporaryBuilderLocks, inputMatchesOutpoint, } from "./common.js";
13
14
  import { confirmYesNo } from "./confirm.js";
14
15
  const ACTIVE_FAMILY_STATUSES = new Set([
15
16
  "draft",
@@ -463,9 +464,7 @@ function buildTx1Plan(options) {
463
464
  return {
464
465
  sender: options.operation.sourceSender,
465
466
  changeAddress: options.state.funding.address,
466
- inputs: fundingUtxos.map((entry, index) => index === 0
467
- ? { txid: senderInput.txid, vout: senderInput.vout }
468
- : { txid: entry.txid, vout: entry.vout }),
467
+ fixedInputs: [{ txid: senderInput.txid, vout: senderInput.vout }],
469
468
  outputs,
470
469
  changePosition: 2,
471
470
  expectedOpReturnScriptHex: encodeOpReturnScript(serializeDomainTransfer(options.operation.chainDomain.domainId, Buffer.from(options.operation.targetIdentity.scriptPubKeyHex, "hex")).opReturnData),
@@ -474,6 +473,9 @@ function buildTx1Plan(options) {
474
473
  expectedReplacementAnchorScriptHex: null,
475
474
  expectedReplacementAnchorValueSats: null,
476
475
  allowedFundingScriptPubKeyHex: options.state.funding.scriptPubKeyHex,
476
+ eligibleFundingOutpointKeys: new Set(fundingUtxos
477
+ .filter((entry) => !(entry.txid === senderInput.txid && entry.vout === senderInput.vout))
478
+ .map((entry) => outpointKey({ txid: entry.txid, vout: entry.vout }))),
477
479
  requiredSenderOutpoint: null,
478
480
  requiredProvisionalOutpoint: null,
479
481
  errorPrefix: "wallet_anchor_tx1",
@@ -492,10 +494,7 @@ function buildTx1Plan(options) {
492
494
  return {
493
495
  sender: options.operation.sourceSender,
494
496
  changeAddress: options.state.funding.address,
495
- inputs: [
496
- { txid: sourceAnchor.txid, vout: sourceAnchor.vout },
497
- ...fundingUtxos.map((entry) => ({ txid: entry.txid, vout: entry.vout })),
498
- ],
497
+ fixedInputs: [{ txid: sourceAnchor.txid, vout: sourceAnchor.vout }],
499
498
  outputs,
500
499
  changePosition: 3,
501
500
  expectedOpReturnScriptHex: encodeOpReturnScript(serializeDomainTransfer(options.operation.chainDomain.domainId, Buffer.from(options.operation.targetIdentity.scriptPubKeyHex, "hex")).opReturnData),
@@ -504,6 +503,7 @@ function buildTx1Plan(options) {
504
503
  expectedReplacementAnchorScriptHex: options.operation.sourceSender.scriptPubKeyHex,
505
504
  expectedReplacementAnchorValueSats: BigInt(options.state.anchorValueSats),
506
505
  allowedFundingScriptPubKeyHex: options.state.funding.scriptPubKeyHex,
506
+ eligibleFundingOutpointKeys: new Set(fundingUtxos.map((entry) => outpointKey({ txid: entry.txid, vout: entry.vout }))),
507
507
  requiredSenderOutpoint: options.operation.sourceAnchorOutpoint,
508
508
  requiredProvisionalOutpoint: null,
509
509
  errorPrefix: "wallet_anchor_tx1",
@@ -535,10 +535,7 @@ function buildTx2Plan(options) {
535
535
  address: options.operation.targetIdentity.address,
536
536
  },
537
537
  changeAddress: options.state.funding.address,
538
- inputs: [
539
- { txid: provisional.txid, vout: provisional.vout },
540
- ...fundingUtxos.map((entry) => ({ txid: entry.txid, vout: entry.vout })),
541
- ],
538
+ fixedInputs: [{ txid: provisional.txid, vout: provisional.vout }],
542
539
  outputs: [
543
540
  { data: Buffer.from(opReturnData).toString("hex") },
544
541
  { [options.operation.targetIdentity.address]: satsToBtcNumber(BigInt(options.state.anchorValueSats)) },
@@ -550,6 +547,7 @@ function buildTx2Plan(options) {
550
547
  expectedReplacementAnchorScriptHex: null,
551
548
  expectedReplacementAnchorValueSats: null,
552
549
  allowedFundingScriptPubKeyHex: options.state.funding.scriptPubKeyHex,
550
+ eligibleFundingOutpointKeys: new Set(fundingUtxos.map((entry) => outpointKey({ txid: entry.txid, vout: entry.vout }))),
553
551
  requiredSenderOutpoint: null,
554
552
  requiredProvisionalOutpoint: {
555
553
  txid: provisional.txid,
@@ -558,30 +556,13 @@ function buildTx2Plan(options) {
558
556
  errorPrefix: "wallet_anchor_tx2",
559
557
  };
560
558
  }
561
- function getDecodedInputScriptPubKeyHex(input) {
562
- return input.prevout?.scriptPubKey?.hex ?? null;
563
- }
564
- function getDecodedInputVout(input) {
565
- const vout = input.vout;
566
- return typeof vout === "number" ? vout : null;
567
- }
568
- function inputMatchesOutpoint(input, outpoint) {
569
- return input.txid === outpoint.txid && getDecodedInputVout(input) === outpoint.vout;
570
- }
571
- function assertNoUnexpectedAnchorInputs(inputs, allowedScripts, unexpectedInputErrorCode) {
572
- for (const input of inputs) {
573
- const scriptPubKeyHex = getDecodedInputScriptPubKeyHex(input);
574
- if (scriptPubKeyHex === null || !allowedScripts.has(scriptPubKeyHex)) {
575
- throw new Error(unexpectedInputErrorCode);
576
- }
577
- }
578
- }
579
559
  function validateTx1Draft(decoded, funded, plan) {
580
560
  const inputs = decoded.tx.vin;
581
561
  const outputs = decoded.tx.vout;
582
562
  if (inputs.length === 0) {
583
563
  throw new Error(`${plan.errorPrefix}_sender_input_mismatch`);
584
564
  }
565
+ assertFixedInputPrefixMatches(inputs, plan.fixedInputs, `${plan.errorPrefix}_sender_input_mismatch`);
585
566
  const firstInputScriptPubKeyHex = getDecodedInputScriptPubKeyHex(inputs[0]);
586
567
  if (firstInputScriptPubKeyHex !== plan.sender.scriptPubKeyHex) {
587
568
  throw new Error(`${plan.errorPrefix}_sender_input_mismatch`);
@@ -590,14 +571,14 @@ function validateTx1Draft(decoded, funded, plan) {
590
571
  if (!inputMatchesOutpoint(inputs[0], plan.requiredSenderOutpoint)) {
591
572
  throw new Error(`${plan.errorPrefix}_sender_input_mismatch`);
592
573
  }
593
- if (inputs.length < 2 || getDecodedInputScriptPubKeyHex(inputs[1]) !== plan.allowedFundingScriptPubKeyHex) {
594
- throw new Error(`${plan.errorPrefix}_unexpected_funding_input`);
595
- }
596
- assertNoUnexpectedAnchorInputs(inputs.slice(2), new Set([plan.allowedFundingScriptPubKeyHex]), `${plan.errorPrefix}_unexpected_funding_input`);
597
- }
598
- else {
599
- assertNoUnexpectedAnchorInputs(inputs, new Set([plan.allowedFundingScriptPubKeyHex]), `${plan.errorPrefix}_unexpected_funding_input`);
600
574
  }
575
+ assertFundingInputsAfterFixedPrefix({
576
+ inputs,
577
+ fixedInputs: plan.fixedInputs,
578
+ allowedFundingScriptPubKeyHex: plan.allowedFundingScriptPubKeyHex,
579
+ eligibleFundingOutpointKeys: plan.eligibleFundingOutpointKeys,
580
+ errorCode: `${plan.errorPrefix}_unexpected_funding_input`,
581
+ });
601
582
  if (outputs[0]?.scriptPubKey?.hex !== plan.expectedOpReturnScriptHex) {
602
583
  throw new Error(`${plan.errorPrefix}_opreturn_mismatch`);
603
584
  }
@@ -635,15 +616,19 @@ function validateTx2Draft(decoded, funded, plan) {
635
616
  if (inputs.length === 0 || plan.requiredProvisionalOutpoint === null) {
636
617
  throw new Error(`${plan.errorPrefix}_provisional_input_mismatch`);
637
618
  }
619
+ assertFixedInputPrefixMatches(inputs, plan.fixedInputs, `${plan.errorPrefix}_provisional_input_mismatch`);
638
620
  const firstInputScriptPubKeyHex = getDecodedInputScriptPubKeyHex(inputs[0]);
639
621
  if (firstInputScriptPubKeyHex !== plan.sender.scriptPubKeyHex
640
622
  || !inputMatchesOutpoint(inputs[0], plan.requiredProvisionalOutpoint)) {
641
623
  throw new Error(`${plan.errorPrefix}_provisional_input_mismatch`);
642
624
  }
643
- if (inputs.length < 2 || getDecodedInputScriptPubKeyHex(inputs[1]) !== plan.allowedFundingScriptPubKeyHex) {
644
- throw new Error(`${plan.errorPrefix}_unexpected_funding_input`);
645
- }
646
- assertNoUnexpectedAnchorInputs(inputs.slice(2), new Set([plan.allowedFundingScriptPubKeyHex]), `${plan.errorPrefix}_unexpected_funding_input`);
625
+ assertFundingInputsAfterFixedPrefix({
626
+ inputs,
627
+ fixedInputs: plan.fixedInputs,
628
+ allowedFundingScriptPubKeyHex: plan.allowedFundingScriptPubKeyHex,
629
+ eligibleFundingOutpointKeys: plan.eligibleFundingOutpointKeys,
630
+ errorCode: `${plan.errorPrefix}_unexpected_funding_input`,
631
+ });
647
632
  if (outputs[0]?.scriptPubKey?.hex !== plan.expectedOpReturnScriptHex) {
648
633
  throw new Error(`${plan.errorPrefix}_opreturn_mismatch`);
649
634
  }
@@ -668,31 +653,29 @@ function validateTx2Draft(decoded, funded, plan) {
668
653
  }
669
654
  }
670
655
  async function buildTx1(options) {
671
- return buildWalletMutationTransaction({
656
+ const reserveCandidates = computeDesignatedProactiveReserveOutpoints(options.state, await options.rpc.listUnspent(options.walletName, 1));
657
+ return buildWalletMutationTransactionWithReserveFallback({
672
658
  rpc: options.rpc,
673
659
  walletName: options.walletName,
660
+ state: options.state,
674
661
  plan: options.plan,
675
662
  validateFundedDraft: validateTx1Draft,
676
663
  finalizeErrorCode: "wallet_anchor_tx1_finalize_failed",
677
664
  mempoolRejectPrefix: "wallet_anchor_tx1_mempool_rejected",
678
- builderOptions: {
679
- addInputs: false,
680
- },
665
+ reserveCandidates,
681
666
  });
682
667
  }
683
668
  async function buildTx2(options) {
684
- return buildWalletMutationTransaction({
669
+ const reserveCandidates = computeDesignatedProactiveReserveOutpoints(options.state, await options.rpc.listUnspent(options.walletName, 1));
670
+ return buildWalletMutationTransactionWithReserveFallback({
685
671
  rpc: options.rpc,
686
672
  walletName: options.walletName,
673
+ state: options.state,
687
674
  plan: options.plan,
688
675
  validateFundedDraft: validateTx2Draft,
689
676
  finalizeErrorCode: "wallet_anchor_tx2_finalize_failed",
690
677
  mempoolRejectPrefix: "wallet_anchor_tx2_mempool_rejected",
691
- builderOptions: {
692
- addInputs: false,
693
- includeUnsafe: true,
694
- minConf: 0,
695
- },
678
+ reserveCandidates,
696
679
  });
697
680
  }
698
681
  async function relockAnchorOutpoint(rpc, walletName, outpoint) {
@@ -998,6 +981,7 @@ async function submitTx2(options) {
998
981
  const builtTx2 = await buildTx2({
999
982
  rpc: options.rpc,
1000
983
  walletName: options.walletName,
984
+ state: nextState,
1001
985
  plan: tx2Plan,
1002
986
  });
1003
987
  const broadcastingTx2 = createBroadcastingTxRecord(builtTx2);
@@ -1232,6 +1216,7 @@ export async function anchorDomain(options) {
1232
1216
  const builtTx1 = await buildTx1({
1233
1217
  rpc,
1234
1218
  walletName,
1219
+ state: nextState,
1235
1220
  plan: tx1Plan,
1236
1221
  });
1237
1222
  const broadcastingTx1 = createBroadcastingTxRecord(builtTx1);
@@ -7,7 +7,7 @@ import { resolveWalletRuntimePathsForTesting } from "../runtime.js";
7
7
  import { createDefaultWalletSecretProvider, } from "../state/provider.js";
8
8
  import { serializeCogClaim, serializeCogLock, serializeCogTransfer, } from "../cogop/index.js";
9
9
  import { openWalletReadContext } from "../read/index.js";
10
- import { assertWalletMutationContextReady, buildWalletMutationTransaction, formatCogAmount, isAlreadyAcceptedError, isBroadcastUnknownError, pauseMiningForWalletMutation, saveWalletStatePreservingUnlock, unlockTemporaryBuilderLocks, updateMutationRecord, } from "./common.js";
10
+ import { assertFixedInputPrefixMatches, assertFundingInputsAfterFixedPrefix, assertWalletMutationContextReady, buildWalletMutationTransaction, formatCogAmount, isAlreadyAcceptedError, isBroadcastUnknownError, outpointKey, pauseMiningForWalletMutation, saveWalletStatePreservingUnlock, unlockTemporaryBuilderLocks, updateMutationRecord, } from "./common.js";
11
11
  import { confirmTypedAcknowledgement, confirmYesNo } from "./confirm.js";
12
12
  import { getCanonicalIdentitySelector, resolveIdentityBySelector, } from "./identity-selector.js";
13
13
  import { findPendingMutationByIntent, upsertPendingMutation } from "./journal.js";
@@ -258,11 +258,8 @@ function buildPlanForCogOperation(options) {
258
258
  return {
259
259
  sender: options.sender,
260
260
  changeAddress: options.state.funding.address,
261
- inputs: [
261
+ fixedInputs: [
262
262
  { txid: senderUtxo.txid, vout: senderUtxo.vout },
263
- ...fundingUtxos
264
- .filter((entry) => !(entry.txid === senderUtxo.txid && entry.vout === senderUtxo.vout))
265
- .map((entry) => ({ txid: entry.txid, vout: entry.vout })),
266
263
  ],
267
264
  outputs,
268
265
  changePosition: 1,
@@ -270,6 +267,9 @@ function buildPlanForCogOperation(options) {
270
267
  expectedAnchorScriptHex: null,
271
268
  expectedAnchorValueSats: null,
272
269
  allowedFundingScriptPubKeyHex: options.state.funding.scriptPubKeyHex,
270
+ eligibleFundingOutpointKeys: new Set(fundingUtxos
271
+ .filter((entry) => !(entry.txid === senderUtxo.txid && entry.vout === senderUtxo.vout))
272
+ .map((entry) => outpointKey({ txid: entry.txid, vout: entry.vout }))),
273
273
  errorPrefix: options.errorPrefix,
274
274
  };
275
275
  }
@@ -288,9 +288,8 @@ function buildPlanForCogOperation(options) {
288
288
  return {
289
289
  sender: options.sender,
290
290
  changeAddress: options.state.funding.address,
291
- inputs: [
291
+ fixedInputs: [
292
292
  { txid: anchorUtxo.txid, vout: anchorUtxo.vout },
293
- ...fundingUtxos.map((entry) => ({ txid: entry.txid, vout: entry.vout })),
294
293
  ],
295
294
  outputs,
296
295
  changePosition: 2,
@@ -298,6 +297,7 @@ function buildPlanForCogOperation(options) {
298
297
  expectedAnchorScriptHex: options.sender.scriptPubKeyHex,
299
298
  expectedAnchorValueSats: options.anchorValueSats,
300
299
  allowedFundingScriptPubKeyHex: options.state.funding.scriptPubKeyHex,
300
+ eligibleFundingOutpointKeys: new Set(fundingUtxos.map((entry) => outpointKey({ txid: entry.txid, vout: entry.vout }))),
301
301
  errorPrefix: options.errorPrefix,
302
302
  };
303
303
  }
@@ -307,14 +307,17 @@ function validateFundedDraft(decoded, funded, plan) {
307
307
  if (inputs.length === 0) {
308
308
  throw new Error(`${plan.errorPrefix}_missing_sender_input`);
309
309
  }
310
+ assertFixedInputPrefixMatches(inputs, plan.fixedInputs, `${plan.errorPrefix}_sender_input_mismatch`);
310
311
  if (inputs[0]?.prevout?.scriptPubKey?.hex !== plan.sender.scriptPubKeyHex) {
311
312
  throw new Error(`${plan.errorPrefix}_sender_input_mismatch`);
312
313
  }
313
- for (let index = 1; index < inputs.length; index += 1) {
314
- if (inputs[index]?.prevout?.scriptPubKey?.hex !== plan.allowedFundingScriptPubKeyHex) {
315
- throw new Error(`${plan.errorPrefix}_unexpected_funding_input`);
316
- }
317
- }
314
+ assertFundingInputsAfterFixedPrefix({
315
+ inputs,
316
+ fixedInputs: plan.fixedInputs,
317
+ allowedFundingScriptPubKeyHex: plan.allowedFundingScriptPubKeyHex,
318
+ eligibleFundingOutpointKeys: plan.eligibleFundingOutpointKeys,
319
+ errorCode: `${plan.errorPrefix}_unexpected_funding_input`,
320
+ });
318
321
  if (outputs[0]?.scriptPubKey?.hex !== plan.expectedOpReturnScriptHex) {
319
322
  throw new Error(`${plan.errorPrefix}_opreturn_mismatch`);
320
323
  }
@@ -344,6 +347,7 @@ async function buildTransaction(options) {
344
347
  return buildWalletMutationTransaction({
345
348
  rpc: options.rpc,
346
349
  walletName: options.walletName,
350
+ state: options.state,
347
351
  plan: options.plan,
348
352
  validateFundedDraft,
349
353
  finalizeErrorCode: `${options.plan.errorPrefix}_finalize_failed`,
@@ -699,6 +703,7 @@ export async function sendCog(options) {
699
703
  const built = await buildTransaction({
700
704
  rpc,
701
705
  walletName,
706
+ state: nextState,
702
707
  plan: buildPlanForCogOperation({
703
708
  state: nextState,
704
709
  allUtxos: await rpc.listUnspent(walletName, 1),
@@ -860,6 +865,7 @@ export async function lockCogToDomain(options) {
860
865
  const built = await buildTransaction({
861
866
  rpc,
862
867
  walletName,
868
+ state: nextState,
863
869
  plan: buildPlanForCogOperation({
864
870
  state: nextState,
865
871
  allUtxos: await rpc.listUnspent(walletName, 1),
@@ -1004,6 +1010,7 @@ async function runClaimLikeMutation(options, reclaim) {
1004
1010
  const built = await buildTransaction({
1005
1011
  rpc,
1006
1012
  walletName,
1013
+ state: nextState,
1007
1014
  plan: buildPlanForCogOperation({
1008
1015
  state: nextState,
1009
1016
  allUtxos: await rpc.listUnspent(walletName, 1),
@@ -1,4 +1,4 @@
1
- import type { RpcDecodedPsbt, RpcFinalizePsbtResult, RpcListUnspentEntry, RpcLockedUnspent, RpcTestMempoolAcceptResult, RpcTransaction, RpcWalletCreateFundedPsbtResult, RpcWalletProcessPsbtResult } from "../../bitcoind/types.js";
1
+ import type { RpcDecodedPsbt, RpcFinalizePsbtResult, RpcListUnspentEntry, RpcLockedUnspent, RpcTestMempoolAcceptResult, RpcTransaction, RpcVin, RpcWalletCreateFundedPsbtResult, RpcWalletProcessPsbtResult } from "../../bitcoind/types.js";
2
2
  import { type WalletSecretProvider } from "../state/provider.js";
3
3
  import type { OutpointRecord, PendingMutationRecord, PendingMutationStatus, WalletStateV1 } from "../types.js";
4
4
  import type { WalletReadContext } from "../read/index.js";
@@ -33,6 +33,8 @@ export interface BuiltWalletMutationTransaction {
33
33
  wtxid: string | null;
34
34
  temporaryBuilderLockedOutpoints: OutpointRecord[];
35
35
  }
36
+ export interface FixedWalletInput extends OutpointRecord {
37
+ }
36
38
  export declare function saveWalletStatePreservingUnlock(options: {
37
39
  state: WalletStateV1;
38
40
  provider: WalletSecretProvider;
@@ -49,8 +51,28 @@ export declare function updateMutationRecord(mutation: PendingMutationRecord, st
49
51
  }): PendingMutationRecord;
50
52
  export declare function unlockTemporaryBuilderLocks(rpc: Pick<WalletMutationRpcClient, "lockUnspent">, walletName: string, outpoints: OutpointRecord[]): Promise<void>;
51
53
  export declare function diffTemporaryLockedOutpoints(before: RpcLockedUnspent[], after: RpcLockedUnspent[]): OutpointRecord[];
54
+ export declare function getDecodedInputScriptPubKeyHex(input: RpcVin): string | null;
55
+ export declare function getDecodedInputVout(input: RpcVin): number | null;
56
+ export declare function inputMatchesOutpoint(input: RpcVin, outpoint: OutpointRecord): boolean;
57
+ export declare function assertFixedInputPrefixMatches(inputs: RpcVin[], fixedInputs: FixedWalletInput[], errorCode: string): void;
58
+ export declare function assertFundingInputsAfterFixedPrefix(options: {
59
+ inputs: RpcVin[];
60
+ fixedInputs: FixedWalletInput[];
61
+ allowedFundingScriptPubKeyHex: string;
62
+ eligibleFundingOutpointKeys: Set<string>;
63
+ errorCode: string;
64
+ }): void;
65
+ export declare function reconcilePersistentPolicyLocks(options: {
66
+ rpc: Pick<WalletMutationRpcClient, "listLockUnspent" | "lockUnspent" | "listUnspent">;
67
+ walletName: string;
68
+ state: WalletStateV1;
69
+ fixedInputs: FixedWalletInput[];
70
+ temporarilyUnlockedOutpoints?: readonly OutpointRecord[];
71
+ cleanupInactiveTemporaryBuilderLocks?: boolean;
72
+ }): Promise<void>;
52
73
  export declare function isBroadcastUnknownError(error: unknown): boolean;
53
74
  export declare function isAlreadyAcceptedError(error: unknown): boolean;
75
+ export declare function isInsufficientFundsError(error: unknown): boolean;
54
76
  export declare function assertWalletMutationContextReady(context: WalletReadContext, errorPrefix: string): asserts context is WalletReadContext & {
55
77
  localState: {
56
78
  availability: "ready";
@@ -67,11 +89,9 @@ export declare function pauseMiningForWalletMutation(options: {
67
89
  export declare function buildWalletMutationTransaction<TPlan>(options: {
68
90
  rpc: WalletMutationRpcClient;
69
91
  walletName: string;
92
+ state: WalletStateV1;
70
93
  plan: TPlan & {
71
- inputs: Array<{
72
- txid: string;
73
- vout: number;
74
- }>;
94
+ fixedInputs: FixedWalletInput[];
75
95
  outputs: unknown[];
76
96
  changeAddress: string;
77
97
  changePosition: number;
@@ -80,10 +100,21 @@ export declare function buildWalletMutationTransaction<TPlan>(options: {
80
100
  finalizeErrorCode: string;
81
101
  mempoolRejectPrefix: string;
82
102
  feeRate?: number;
83
- builderOptions?: {
84
- addInputs?: boolean;
85
- includeUnsafe?: boolean;
86
- minConf?: number;
87
- lockUnspents?: boolean;
103
+ temporarilyUnlockedPolicyOutpoints?: readonly OutpointRecord[];
104
+ }): Promise<BuiltWalletMutationTransaction>;
105
+ export declare function buildWalletMutationTransactionWithReserveFallback<TPlan>(options: {
106
+ rpc: WalletMutationRpcClient;
107
+ walletName: string;
108
+ state: WalletStateV1;
109
+ plan: TPlan & {
110
+ fixedInputs: FixedWalletInput[];
111
+ outputs: unknown[];
112
+ changeAddress: string;
113
+ changePosition: number;
88
114
  };
115
+ validateFundedDraft(decoded: RpcDecodedPsbt, funded: RpcWalletCreateFundedPsbtResult, plan: TPlan): void;
116
+ finalizeErrorCode: string;
117
+ mempoolRejectPrefix: string;
118
+ feeRate?: number;
119
+ reserveCandidates: readonly OutpointRecord[];
89
120
  }): Promise<BuiltWalletMutationTransaction>;
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { saveUnlockSession } from "../state/session.js";
3
3
  import { saveWalletState } from "../state/storage.js";
4
4
  import { createWalletSecretReference, } from "../state/provider.js";
5
+ import { reconcilePersistentPolicyLocks as reconcileWalletCoinControlLocks } from "../coin-control.js";
5
6
  import { requestMiningGenerationPreemption } from "../mining/coordination.js";
6
7
  export const DEFAULT_WALLET_MUTATION_FEE_RATE_SAT_VB = 10;
7
8
  function createUnlockSessionState(state, unlockUntilUnixMs, nowUnixMs) {
@@ -64,6 +65,53 @@ export function diffTemporaryLockedOutpoints(before, after) {
64
65
  vout: entry.vout,
65
66
  }));
66
67
  }
68
+ export function getDecodedInputScriptPubKeyHex(input) {
69
+ return input.prevout?.scriptPubKey?.hex ?? null;
70
+ }
71
+ export function getDecodedInputVout(input) {
72
+ const vout = input.vout;
73
+ return typeof vout === "number" ? vout : null;
74
+ }
75
+ export function inputMatchesOutpoint(input, outpoint) {
76
+ return input.txid === outpoint.txid && getDecodedInputVout(input) === outpoint.vout;
77
+ }
78
+ export function assertFixedInputPrefixMatches(inputs, fixedInputs, errorCode) {
79
+ if (inputs.length < fixedInputs.length) {
80
+ throw new Error(errorCode);
81
+ }
82
+ for (const [index, fixedInput] of fixedInputs.entries()) {
83
+ if (!inputMatchesOutpoint(inputs[index], fixedInput)) {
84
+ throw new Error(errorCode);
85
+ }
86
+ }
87
+ }
88
+ export function assertFundingInputsAfterFixedPrefix(options) {
89
+ for (let index = options.fixedInputs.length; index < options.inputs.length; index += 1) {
90
+ const input = options.inputs[index];
91
+ const scriptPubKeyHex = getDecodedInputScriptPubKeyHex(input);
92
+ const vout = getDecodedInputVout(input);
93
+ if (scriptPubKeyHex !== options.allowedFundingScriptPubKeyHex || vout === null || typeof input.txid !== "string") {
94
+ throw new Error(options.errorCode);
95
+ }
96
+ const key = outpointKey({
97
+ txid: input.txid,
98
+ vout,
99
+ });
100
+ if (!options.eligibleFundingOutpointKeys.has(key)) {
101
+ throw new Error(options.errorCode);
102
+ }
103
+ }
104
+ }
105
+ export async function reconcilePersistentPolicyLocks(options) {
106
+ await reconcileWalletCoinControlLocks({
107
+ rpc: options.rpc,
108
+ walletName: options.walletName,
109
+ state: options.state,
110
+ fixedInputs: options.fixedInputs,
111
+ temporarilyUnlockedOutpoints: options.temporarilyUnlockedOutpoints,
112
+ cleanupInactiveTemporaryBuilderLocks: options.cleanupInactiveTemporaryBuilderLocks,
113
+ });
114
+ }
67
115
  export function isBroadcastUnknownError(error) {
68
116
  const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
69
117
  return message.includes("timeout")
@@ -80,6 +128,10 @@ export function isAlreadyAcceptedError(error) {
80
128
  || message.includes("already in blockchain")
81
129
  || message.includes("txn-already-known");
82
130
  }
131
+ export function isInsufficientFundsError(error) {
132
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
133
+ return message.includes("insufficient funds");
134
+ }
83
135
  export function assertWalletMutationContextReady(context, errorPrefix) {
84
136
  if (context.localState.availability === "uninitialized") {
85
137
  throw new Error("wallet_uninitialized");
@@ -110,16 +162,23 @@ export async function pauseMiningForWalletMutation(options) {
110
162
  });
111
163
  }
112
164
  export async function buildWalletMutationTransaction(options) {
165
+ await reconcilePersistentPolicyLocks({
166
+ rpc: options.rpc,
167
+ walletName: options.walletName,
168
+ state: options.state,
169
+ fixedInputs: options.plan.fixedInputs,
170
+ temporarilyUnlockedOutpoints: options.temporarilyUnlockedPolicyOutpoints,
171
+ });
113
172
  const lockedBefore = await options.rpc.listLockUnspent(options.walletName);
114
173
  let temporaryBuilderLockedOutpoints = [];
115
174
  try {
116
- const funded = await options.rpc.walletCreateFundedPsbt(options.walletName, options.plan.inputs, options.plan.outputs, 0, {
117
- add_inputs: options.builderOptions?.addInputs ?? true,
118
- include_unsafe: options.builderOptions?.includeUnsafe ?? false,
119
- minconf: options.builderOptions?.minConf ?? 1,
175
+ const funded = await options.rpc.walletCreateFundedPsbt(options.walletName, options.plan.fixedInputs, options.plan.outputs, 0, {
176
+ add_inputs: true,
177
+ include_unsafe: false,
178
+ minconf: 1,
120
179
  changeAddress: options.plan.changeAddress,
121
180
  changePosition: options.plan.changePosition,
122
- lockUnspents: options.builderOptions?.lockUnspents ?? true,
181
+ lockUnspents: true,
123
182
  fee_rate: options.feeRate ?? DEFAULT_WALLET_MUTATION_FEE_RATE_SAT_VB,
124
183
  replaceable: true,
125
184
  subtractFeeFromOutputs: [],
@@ -139,6 +198,14 @@ export async function buildWalletMutationTransaction(options) {
139
198
  if (accepted == null || !accepted.allowed) {
140
199
  throw new Error(`${options.mempoolRejectPrefix}_${accepted?.["reject-reason"] ?? "unknown"}`);
141
200
  }
201
+ if ((options.temporarilyUnlockedPolicyOutpoints?.length ?? 0) > 0) {
202
+ await reconcilePersistentPolicyLocks({
203
+ rpc: options.rpc,
204
+ walletName: options.walletName,
205
+ state: options.state,
206
+ fixedInputs: options.plan.fixedInputs,
207
+ });
208
+ }
142
209
  return {
143
210
  funded,
144
211
  decoded,
@@ -151,6 +218,46 @@ export async function buildWalletMutationTransaction(options) {
151
218
  }
152
219
  catch (error) {
153
220
  await unlockTemporaryBuilderLocks(options.rpc, options.walletName, temporaryBuilderLockedOutpoints);
221
+ if ((options.temporarilyUnlockedPolicyOutpoints?.length ?? 0) > 0) {
222
+ await reconcilePersistentPolicyLocks({
223
+ rpc: options.rpc,
224
+ walletName: options.walletName,
225
+ state: options.state,
226
+ fixedInputs: options.plan.fixedInputs,
227
+ });
228
+ }
154
229
  throw error;
155
230
  }
156
231
  }
232
+ export async function buildWalletMutationTransactionWithReserveFallback(options) {
233
+ let unlockedReserveOutpoints = [];
234
+ let lastError = null;
235
+ for (let attempt = 0; attempt <= options.reserveCandidates.length; attempt += 1) {
236
+ if (attempt > 0) {
237
+ unlockedReserveOutpoints = [
238
+ ...unlockedReserveOutpoints,
239
+ options.reserveCandidates[attempt - 1],
240
+ ];
241
+ }
242
+ try {
243
+ return await buildWalletMutationTransaction({
244
+ rpc: options.rpc,
245
+ walletName: options.walletName,
246
+ state: options.state,
247
+ plan: options.plan,
248
+ validateFundedDraft: options.validateFundedDraft,
249
+ finalizeErrorCode: options.finalizeErrorCode,
250
+ mempoolRejectPrefix: options.mempoolRejectPrefix,
251
+ feeRate: options.feeRate,
252
+ temporarilyUnlockedPolicyOutpoints: unlockedReserveOutpoints,
253
+ });
254
+ }
255
+ catch (error) {
256
+ lastError = error;
257
+ if (!isInsufficientFundsError(error) || attempt === options.reserveCandidates.length) {
258
+ throw error;
259
+ }
260
+ }
261
+ }
262
+ throw lastError;
263
+ }