@arkade-os/sdk 0.4.26 → 0.4.27

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 (47) hide show
  1. package/README.md +5 -25
  2. package/dist/cjs/contracts/contractManager.js +31 -11
  3. package/dist/cjs/contracts/contractWatcher.js +2 -2
  4. package/dist/cjs/identity/hdCapableIdentity.js +18 -0
  5. package/dist/cjs/identity/index.js +3 -1
  6. package/dist/cjs/identity/seedIdentity.js +16 -0
  7. package/dist/cjs/index.js +4 -2
  8. package/dist/cjs/wallet/delegator.js +10 -4
  9. package/dist/cjs/wallet/hdDescriptorProvider.js +29 -0
  10. package/dist/cjs/wallet/inputSignerRouter.js +98 -0
  11. package/dist/cjs/wallet/serviceWorker/wallet.js +1 -0
  12. package/dist/cjs/wallet/signingErrors.js +32 -0
  13. package/dist/cjs/wallet/unroll.js +5 -1
  14. package/dist/cjs/wallet/wallet.js +232 -86
  15. package/dist/cjs/wallet/walletReceiveRotator.js +547 -0
  16. package/dist/cjs/worker/messageBus.js +1 -0
  17. package/dist/esm/contracts/contractManager.js +31 -11
  18. package/dist/esm/contracts/contractWatcher.js +2 -2
  19. package/dist/esm/identity/hdCapableIdentity.js +17 -1
  20. package/dist/esm/identity/index.js +1 -0
  21. package/dist/esm/identity/seedIdentity.js +16 -0
  22. package/dist/esm/index.js +2 -2
  23. package/dist/esm/wallet/delegator.js +10 -4
  24. package/dist/esm/wallet/hdDescriptorProvider.js +29 -0
  25. package/dist/esm/wallet/inputSignerRouter.js +94 -0
  26. package/dist/esm/wallet/serviceWorker/wallet.js +1 -0
  27. package/dist/esm/wallet/signingErrors.js +27 -0
  28. package/dist/esm/wallet/unroll.js +5 -1
  29. package/dist/esm/wallet/wallet.js +231 -86
  30. package/dist/esm/wallet/walletReceiveRotator.js +540 -0
  31. package/dist/esm/worker/messageBus.js +1 -0
  32. package/dist/types/contracts/contractManager.d.ts +33 -3
  33. package/dist/types/contracts/types.d.ts +19 -2
  34. package/dist/types/identity/descriptorProvider.d.ts +7 -0
  35. package/dist/types/identity/hdCapableIdentity.d.ts +30 -3
  36. package/dist/types/identity/index.d.ts +1 -0
  37. package/dist/types/identity/seedIdentity.d.ts +16 -0
  38. package/dist/types/index.d.ts +6 -6
  39. package/dist/types/wallet/hdDescriptorProvider.d.ts +22 -1
  40. package/dist/types/wallet/index.d.ts +34 -0
  41. package/dist/types/wallet/inputSignerRouter.d.ts +35 -0
  42. package/dist/types/wallet/serviceWorker/wallet.d.ts +10 -0
  43. package/dist/types/wallet/signingErrors.d.ts +19 -0
  44. package/dist/types/wallet/wallet.d.ts +51 -2
  45. package/dist/types/wallet/walletReceiveRotator.d.ts +306 -0
  46. package/dist/types/worker/messageBus.d.ts +1 -0
  47. package/package.json +1 -1
@@ -10,12 +10,11 @@ import { RestArkProvider, } from "../providers/ark.js";
10
10
  import { buildForfeitTx } from "../forfeit.js";
11
11
  import { validateConnectorsTxGraph, validateVtxoTxGraph, } from "../tree/validation.js";
12
12
  import { validateBatchRecipients } from "./validation.js";
13
- import { isBatchSignable } from "../identity/index.js";
14
13
  import { DEFAULT_ARKADE_SERVER_URL, isExpired, isRecoverable, isSpendable, isSubdust, TxType, } from "./index.js";
15
14
  import { createAssetPacket, selectCoinsWithAsset, selectedCoinsToAssetInputs, } from "./asset.js";
16
15
  import { VtxoScript } from "../script/base.js";
17
16
  import { CSVMultisigTapscript } from "../script/tapscript.js";
18
- import { buildOffchainTx, combineTapscriptSigs, hasBoardingTxExpired, isValidArkAddress, } from "../utils/arkTransaction.js";
17
+ import { buildOffchainTx, hasBoardingTxExpired, isValidArkAddress, } from "../utils/arkTransaction.js";
19
18
  import { DEFAULT_RENEWAL_CONFIG, DEFAULT_SETTLEMENT_CONFIG, VtxoManager, } from "./vtxo-manager.js";
20
19
  import { ArkNote } from "../arknote/index.js";
21
20
  import { Intent } from "../intent/index.js";
@@ -35,7 +34,32 @@ import { contractHandlers } from "../contracts/handlers/index.js";
35
34
  import { timelockToSequence } from "../utils/timelock.js";
36
35
  import { clearSyncCursor, updateWalletState } from "../utils/syncCursors.js";
37
36
  import { validateVtxosForScript, saveVtxosForContract, } from "../contracts/vtxoOwnership.js";
37
+ import { WalletReceiveRotator } from "./walletReceiveRotator.js";
38
+ import { InputSignerRouter } from "./inputSignerRouter.js";
39
+ import { DescriptorSigningProviderMissingError, MissingSigningDescriptorError, } from "./signingErrors.js";
38
40
  export const getArkadeServerUrl = ({ arkServerUrl, }) => arkServerUrl || DEFAULT_ARKADE_SERVER_URL;
41
+ // Build per-input jobs for an intent proof. Index 0 of the proof is a
42
+ // synthetic BIP-322 toSpend reference whose witnessUtxo.script mirrors
43
+ // coin[0]'s pkScript, so we map it to the same source contract as
44
+ // coin[0]; coins 0..N-1 then map to proof inputs 1..N.
45
+ function intentProofJobs(coins) {
46
+ if (coins.length === 0)
47
+ return [];
48
+ const coinJobs = coins.map((coin, i) => ({
49
+ index: i + 1,
50
+ lookupScript: VtxoScript.decode(coin.tapTree).pkScript,
51
+ }));
52
+ return [{ index: 0, lookupScript: coinJobs[0].lookupScript }, ...coinJobs];
53
+ }
54
+ // Built-in ArkProvider implementations (Rest/Expo) expose `serverUrl`,
55
+ // but the interface itself does not declare a URL accessor — so this is a
56
+ // structural read that returns undefined for custom implementations.
57
+ function extractArkProviderUrl(provider) {
58
+ const serverUrl = provider.serverUrl;
59
+ return typeof serverUrl === "string" && serverUrl.length > 0
60
+ ? serverUrl
61
+ : undefined;
62
+ }
39
63
  // Historical unilateral exit delay for mainnet (~7 days in seconds).
40
64
  // Kept so existing wallets can still discover and spend VTXOs sent to the
41
65
  // legacy address after arkd starts advertising a different delay.
@@ -67,6 +91,7 @@ function hasToReadonly(identity) {
67
91
  "toReadonly" in identity &&
68
92
  typeof identity.toReadonly === "function");
69
93
  }
94
+ export { DescriptorSigningProviderMissingError, MissingSigningDescriptorError };
70
95
  export class ReadonlyWallet {
71
96
  get assetManager() {
72
97
  return this._assetManager;
@@ -77,7 +102,6 @@ export class ReadonlyWallet {
77
102
  this.onchainProvider = onchainProvider;
78
103
  this.indexerProvider = indexerProvider;
79
104
  this.arkServerPublicKey = arkServerPublicKey;
80
- this.offchainTapscript = offchainTapscript;
81
105
  this.boardingTapscript = boardingTapscript;
82
106
  this.dustAmount = dustAmount;
83
107
  this.walletRepository = walletRepository;
@@ -102,6 +126,7 @@ export class ReadonlyWallet {
102
126
  `Create identity with { isMainnet: ${serverIsMainnet} } to match.`);
103
127
  }
104
128
  }
129
+ this._offchainTapscript = offchainTapscript;
105
130
  this.watcherConfig = watcherConfig;
106
131
  this._assetManager = new ReadonlyAssetManager(this.indexerProvider);
107
132
  // Defensive for direct-construction callers; setupWalletConfig already
@@ -114,25 +139,45 @@ export class ReadonlyWallet {
114
139
  DefaultVtxo.Script.DEFAULT_TIMELOCK,
115
140
  ];
116
141
  }
142
+ /**
143
+ * Currently-active receive tapscript. Read-only from the outside;
144
+ * mutated only via {@link Wallet.setOffchainTapscriptForRotation}
145
+ * by {@link WalletReceiveRotator.rotate}.
146
+ */
147
+ get offchainTapscript() {
148
+ return this._offchainTapscript;
149
+ }
117
150
  /**
118
151
  * Protected helper to set up shared wallet configuration.
119
152
  * Extracts common logic used by both ReadonlyWallet.create() and Wallet.create().
120
153
  */
121
154
  static async setupWalletConfig(config, pubKey) {
155
+ const arkadeServerUrl = getArkadeServerUrl(config);
122
156
  // Use provided arkProvider instance or create a new one from arkServerUrl
123
- const arkProvider = config.arkProvider ||
124
- (() => {
125
- return new RestArkProvider(getArkadeServerUrl(config));
126
- })();
127
- // Extract arkServerUrl from provider if not explicitly provided
128
- const arkServerUrl = config.arkServerUrl || arkProvider.serverUrl;
129
- if (!arkServerUrl) {
130
- throw new Error("Could not determine arkServerUrl from provider");
131
- }
132
- // Use provided indexerProvider instance or create a new one
133
- // indexerUrl defaults to arkServerUrl if not provided
134
- const indexerUrl = config.indexerUrl || arkServerUrl;
135
- const indexerProvider = config.indexerProvider || new RestIndexerProvider(indexerUrl);
157
+ const arkProvider = config.arkProvider ?? new RestArkProvider(arkadeServerUrl);
158
+ // Resolve the indexer provider. If a full instance is supplied, use it
159
+ // directly. Otherwise pick a URL with priority:
160
+ // 1. explicit config.indexerUrl
161
+ // 2. URL derived from the injected arkProvider (so a custom
162
+ // arkProvider does not silently pair with the public default)
163
+ // 3. arkadeServerUrl (only when no custom arkProvider was injected)
164
+ let indexerProvider = config.indexerProvider;
165
+ if (!indexerProvider) {
166
+ let indexerUrl = config.indexerUrl;
167
+ if (!indexerUrl) {
168
+ if (config.arkProvider) {
169
+ const derived = extractArkProviderUrl(config.arkProvider);
170
+ if (!derived) {
171
+ throw new Error("indexerUrl is required when arkProvider is provided without a discoverable serverUrl");
172
+ }
173
+ indexerUrl = derived;
174
+ }
175
+ else {
176
+ indexerUrl = arkadeServerUrl;
177
+ }
178
+ }
179
+ indexerProvider = new RestIndexerProvider(indexerUrl);
180
+ }
136
181
  const info = await arkProvider.getInfo();
137
182
  const network = getNetwork(info.network);
138
183
  // Guard: detect identity/server network mismatch for seed-based identities.
@@ -642,13 +687,28 @@ export class ReadonlyWallet {
642
687
  walletRepository: this.walletRepository,
643
688
  watcherConfig: this.watcherConfig,
644
689
  });
690
+ // Register the wallet's baseline always-active contracts: every
691
+ // `walletContractTimelocks` entry × {default, delegate-if-enabled}.
692
+ // This matrix is bound to INDEX 0 — the identity's x-only pubkey
693
+ // — by design: it's the permanent fallback set the wallet wants
694
+ // active forever, independent of any HD rotation. Rotated
695
+ // display contracts (registered separately by
696
+ // {@link WalletReceiveRotator.rotate}) are intentionally
697
+ // single-timelock-single-pubkey at the CURRENT arkd delay, and
698
+ // get the `metadata.source = WALLET_RECEIVE_SOURCE` tag so the
699
+ // next boot can find them. We deliberately do NOT re-register
700
+ // the matrix at a rotated pubkey: doing so would dilute the
701
+ // "index-0 baseline" guarantee and turn every rotation into a
702
+ // multi-timelock matrix expansion on every boot.
703
+ const baselinePubkey = await this.identity.xOnlyPublicKey();
645
704
  for (const csvTimelock of this.walletContractTimelocks) {
646
705
  const csvTimelockStr = timelockToSequence(csvTimelock).toString();
647
706
  const defaultScript = new DefaultVtxo.Script({
648
- pubKey: this.offchainTapscript.options.pubKey,
707
+ pubKey: baselinePubkey,
649
708
  serverPubKey: this.offchainTapscript.options.serverPubKey,
650
709
  csvTimelock,
651
710
  });
711
+ const defaultScriptHex = hex.encode(defaultScript.pkScript);
652
712
  await manager.createContract({
653
713
  type: "default",
654
714
  params: {
@@ -656,7 +716,7 @@ export class ReadonlyWallet {
656
716
  serverPubKey: hex.encode(defaultScript.options.serverPubKey),
657
717
  csvTimelock: csvTimelockStr,
658
718
  },
659
- script: hex.encode(defaultScript.pkScript),
719
+ script: defaultScriptHex,
660
720
  address: defaultScript
661
721
  .address(this.network.hrp, this.arkServerPublicKey)
662
722
  .encode(),
@@ -664,11 +724,12 @@ export class ReadonlyWallet {
664
724
  });
665
725
  if (this.offchainTapscript instanceof DelegateVtxo.Script) {
666
726
  const delegateScript = new DelegateVtxo.Script({
667
- pubKey: this.offchainTapscript.options.pubKey,
727
+ pubKey: baselinePubkey,
668
728
  serverPubKey: this.offchainTapscript.options.serverPubKey,
669
729
  delegatePubKey: this.offchainTapscript.options.delegatePubKey,
670
730
  csvTimelock,
671
731
  });
732
+ const delegateScriptHex = hex.encode(delegateScript.pkScript);
672
733
  await manager.createContract({
673
734
  type: "delegate",
674
735
  params: {
@@ -677,7 +738,7 @@ export class ReadonlyWallet {
677
738
  delegatePubKey: hex.encode(delegateScript.options.delegatePubKey),
678
739
  csvTimelock: csvTimelockStr,
679
740
  },
680
- script: hex.encode(delegateScript.pkScript),
741
+ script: delegateScriptHex,
681
742
  address: delegateScript
682
743
  .address(this.network.hrp, this.arkServerPublicKey)
683
744
  .encode(),
@@ -736,6 +797,15 @@ export class ReadonlyWallet {
736
797
  * ```
737
798
  */
738
799
  export class Wallet extends ReadonlyWallet {
800
+ /**
801
+ * @internal Sole write path for `offchainTapscript` after construction.
802
+ * Called by {@link WalletReceiveRotator.rotate} once the rotated
803
+ * display contract has been persisted. External code must treat
804
+ * `offchainTapscript` as read-only.
805
+ */
806
+ setOffchainTapscriptForRotation(tapscript) {
807
+ this._offchainTapscript = tapscript;
808
+ }
739
809
  _addPendingSpends(inputs) {
740
810
  for (const input of inputs) {
741
811
  if ("virtualStatus" in input) {
@@ -766,12 +836,13 @@ export class Wallet extends ReadonlyWallet {
766
836
  }
767
837
  constructor(identity, network, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
768
838
  /** @deprecated Use settlementConfig */
769
- renewalConfig, delegatorProvider, watcherConfig, settlementConfig, walletContractTimelocks) {
839
+ renewalConfig, delegatorProvider, watcherConfig, settlementConfig, walletContractTimelocks, receiveRotator, descriptorProvider) {
770
840
  super(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository, delegatorProvider, watcherConfig, walletContractTimelocks);
771
841
  this.arkProvider = arkProvider;
772
842
  this.serverUnrollScript = serverUnrollScript;
773
843
  this.forfeitOutputScript = forfeitOutputScript;
774
844
  this.forfeitPubkey = forfeitPubkey;
845
+ this._receiveRotatorInstalled = false;
775
846
  /**
776
847
  * Async mutex that serializes all operations submitting VTXOs to the Arkade
777
848
  * server (`settle`, `send`, `sendBitcoin`). This prevents VtxoManager's
@@ -808,6 +879,14 @@ export class Wallet extends ReadonlyWallet {
808
879
  this._delegatorManager = delegatorProvider
809
880
  ? new DelegatorManagerImpl(delegatorProvider, arkProvider, identity)
810
881
  : undefined;
882
+ this._receiveRotator = receiveRotator;
883
+ this._descriptorProvider = descriptorProvider;
884
+ this._signerRouter = new InputSignerRouter({
885
+ identity,
886
+ contractRepository,
887
+ descriptorProvider,
888
+ boardingPkScript: boardingTapscript.pkScript,
889
+ });
811
890
  }
812
891
  get assetManager() {
813
892
  this._walletAssetManager ?? (this._walletAssetManager = new AssetManager(this));
@@ -823,18 +902,48 @@ export class Wallet extends ReadonlyWallet {
823
902
  this._vtxoManagerInitializing = Promise.resolve(new VtxoManager(this, this.renewalConfig, this.settlementConfig));
824
903
  try {
825
904
  const manager = await this._vtxoManagerInitializing;
905
+ // First-time hookup of the HD rotator: subscribe to
906
+ // `vtxo_received` AFTER the contract manager (which is
907
+ // initialised inside the VtxoManager construction path) has
908
+ // registered the wallet's baseline contracts. The flag
909
+ // makes this idempotent across repeated `getVtxoManager`
910
+ // calls — install runs at most once per wallet instance.
911
+ // Cache the manager and flip the install flag only after
912
+ // `install()` resolves; otherwise a failing install would
913
+ // leave the manager cached and silently disable HD
914
+ // rotation for the lifetime of this wallet.
915
+ if (this._receiveRotator && !this._receiveRotatorInstalled) {
916
+ try {
917
+ await this._receiveRotator.install(this);
918
+ }
919
+ catch (installErr) {
920
+ await manager.dispose();
921
+ throw installErr;
922
+ }
923
+ this._receiveRotatorInstalled = true;
924
+ }
826
925
  this._vtxoManager = manager;
827
926
  return manager;
828
927
  }
829
- catch (error) {
830
- this._vtxoManagerInitializing = undefined;
831
- throw error;
832
- }
833
928
  finally {
834
929
  this._vtxoManagerInitializing = undefined;
835
930
  }
836
931
  }
837
932
  async dispose() {
933
+ // Tear down the rotation subscription + drain in-flight rotations
934
+ // first so no late `vtxo_received` event can queue work on a
935
+ // disposing wallet, and so any in-flight `createContract` call
936
+ // finishes before we dispose the contract manager underneath it.
937
+ // A rotator-disposal failure must not abort the rest of
938
+ // teardown — the contract manager / super still need to run on
939
+ // best-effort, so we capture and rethrow at the end.
940
+ let rotatorError;
941
+ try {
942
+ await this._receiveRotator?.dispose();
943
+ }
944
+ catch (error) {
945
+ rotatorError = error;
946
+ }
838
947
  const manager = this._vtxoManager ??
839
948
  (this._vtxoManagerInitializing
840
949
  ? await this._vtxoManagerInitializing.catch(() => undefined)
@@ -852,6 +961,9 @@ export class Wallet extends ReadonlyWallet {
852
961
  this._vtxoManagerInitializing = undefined;
853
962
  await super.dispose();
854
963
  }
964
+ if (rotatorError) {
965
+ throw rotatorError;
966
+ }
855
967
  }
856
968
  /**
857
969
  * Create a full wallet and initialize its background managers.
@@ -887,7 +999,14 @@ export class Wallet extends ReadonlyWallet {
887
999
  const forfeitPubkey = hex.decode(setup.info.forfeitPubkey).slice(1);
888
1000
  const forfeitAddress = Address(setup.network).decode(setup.info.forfeitAddress);
889
1001
  const forfeitOutputScript = OutScript.encode(forfeitAddress);
890
- const wallet = new Wallet(config.identity, setup.network, setup.onchainProvider, setup.arkProvider, setup.indexerProvider, setup.serverPubKey, setup.offchainTapscript, setup.boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, setup.dustAmount, setup.walletRepository, setup.contractRepository, config.renewalConfig, config.delegatorProvider, config.watcherConfig, config.settlementConfig, setup.walletContractTimelocks);
1002
+ // HD wiring (boot path) resolved via the descriptor provider.
1003
+ // The rotator (when present) is handed to the constructor as
1004
+ // the last positional arg and `getVtxoManager()` lazily
1005
+ // installs its `vtxo_received` subscription on first call,
1006
+ // after the contract manager has registered the wallet's
1007
+ // baseline contracts.
1008
+ const boot = await WalletReceiveRotator.resolveBoot(config, setup);
1009
+ const wallet = new Wallet(config.identity, setup.network, setup.onchainProvider, setup.arkProvider, setup.indexerProvider, setup.serverPubKey, boot?.offchainTapscript ?? setup.offchainTapscript, setup.boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, setup.dustAmount, setup.walletRepository, setup.contractRepository, config.renewalConfig, config.delegatorProvider, config.watcherConfig, config.settlementConfig, setup.walletContractTimelocks, boot?.rotator, boot?.provider);
891
1010
  await wallet.getVtxoManager();
892
1011
  return wallet;
893
1012
  }
@@ -934,6 +1053,14 @@ export class Wallet extends ReadonlyWallet {
934
1053
  }
935
1054
  if (params.selectedVtxos && params.selectedVtxos.length > 0) {
936
1055
  return this._withTxLock(async () => {
1056
+ // Snapshot the active receive tapscript synchronously
1057
+ // before any `await` so the change output's pkScript and
1058
+ // the change-VTXO metadata written later by
1059
+ // `updateDbAfterOffchainTx` are bound to the same
1060
+ // tapscript even if `WalletReceiveRotator.rotate` fires
1061
+ // during the offchain round-trip.
1062
+ const offchainTapscript = this.offchainTapscript;
1063
+ const arkAddress = offchainTapscript.address(this.network.hrp, this.arkServerPublicKey);
937
1064
  const selectedVtxoSum = params
938
1065
  .selectedVtxos.map((v) => v.value)
939
1066
  .reduce((a, b) => a + b, 0);
@@ -958,8 +1085,8 @@ export class Wallet extends ReadonlyWallet {
958
1085
  // add change output if needed
959
1086
  if (selected.changeAmount > 0n) {
960
1087
  const changeOutputScript = selected.changeAmount < this.dustAmount
961
- ? this.arkAddress.subdustPkScript
962
- : this.arkAddress.pkScript;
1088
+ ? arkAddress.subdustPkScript
1089
+ : arkAddress.pkScript;
963
1090
  outputs.push({
964
1091
  script: changeOutputScript,
965
1092
  amount: BigInt(selected.changeAmount),
@@ -968,7 +1095,7 @@ export class Wallet extends ReadonlyWallet {
968
1095
  this._addPendingSpends(selected.inputs);
969
1096
  try {
970
1097
  const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
971
- await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
1098
+ await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0, offchainTapscript);
972
1099
  return arkTxid;
973
1100
  }
974
1101
  finally {
@@ -1238,9 +1365,11 @@ export class Wallet extends ReadonlyWallet {
1238
1365
  settlementPsbt.updateInput(i, {
1239
1366
  tapLeafScript: [input.forfeitTapLeafScript],
1240
1367
  });
1241
- settlementPsbt = await this.identity.sign(settlementPsbt, [
1242
- i,
1243
- ]);
1368
+ const script = settlementPsbt.getInput(i).witnessUtxo?.script;
1369
+ if (!script) {
1370
+ throw new Error("The server returned incomplete data. Settlement input is missing witnessUtxo.script");
1371
+ }
1372
+ settlementPsbt = await this._signerRouter.sign(settlementPsbt, [{ index: i, lookupScript: script }]);
1244
1373
  hasBoardingUtxos = true;
1245
1374
  break;
1246
1375
  }
@@ -1289,7 +1418,12 @@ export class Wallet extends ReadonlyWallet {
1289
1418
  },
1290
1419
  ], forfeitOutputScript);
1291
1420
  // do not sign the connector input
1292
- forfeitTx = await this.identity.sign(forfeitTx, [0]);
1421
+ forfeitTx = await this._signerRouter.sign(forfeitTx, [
1422
+ {
1423
+ index: 0,
1424
+ lookupScript: VtxoScript.decode(input.tapTree).pkScript,
1425
+ },
1426
+ ]);
1293
1427
  signedForfeits.push(base64.encode(forfeitTx.toPSBT()));
1294
1428
  }
1295
1429
  if (signedForfeits.length > 0 || hasBoardingUtxos) {
@@ -1392,6 +1526,22 @@ export class Wallet extends ReadonlyWallet {
1392
1526
  },
1393
1527
  };
1394
1528
  }
1529
+ /**
1530
+ * Build {@link InputSigningJob}s for a tx whose signable inputs can be
1531
+ * resolved from their own `witnessUtxo.script`. Inputs without a
1532
+ * `witnessUtxo` are silently omitted, mirroring the wallet's
1533
+ * historical silent-skip behaviour for cosigner/connector inputs.
1534
+ */
1535
+ inputSigningJobsFromWitnessUtxos(tx, indexes) {
1536
+ const candidateIndexes = indexes ?? Array.from({ length: tx.inputsLength }, (_, i) => i);
1537
+ const jobs = [];
1538
+ for (const index of candidateIndexes) {
1539
+ const script = tx.getInput(index).witnessUtxo?.script;
1540
+ if (script)
1541
+ jobs.push({ index, lookupScript: script });
1542
+ }
1543
+ return jobs;
1544
+ }
1395
1545
  async safeRegisterIntent(intent, inputs) {
1396
1546
  try {
1397
1547
  return await this.arkProvider.registerIntent(intent);
@@ -1424,7 +1574,7 @@ export class Wallet extends ReadonlyWallet {
1424
1574
  cosigners_public_keys: cosignerPubKeys,
1425
1575
  };
1426
1576
  const proof = Intent.create(message, coins, outputs);
1427
- const signedProof = await this.identity.sign(proof);
1577
+ const signedProof = await this._signerRouter.sign(proof, intentProofJobs(coins));
1428
1578
  return {
1429
1579
  proof: base64.encode(signedProof.toPSBT()),
1430
1580
  message,
@@ -1436,7 +1586,7 @@ export class Wallet extends ReadonlyWallet {
1436
1586
  expire_at: 0,
1437
1587
  };
1438
1588
  const proof = Intent.create(message, coins, []);
1439
- const signedProof = await this.identity.sign(proof);
1589
+ const signedProof = await this._signerRouter.sign(proof, intentProofJobs(coins));
1440
1590
  return {
1441
1591
  proof: base64.encode(signedProof.toPSBT()),
1442
1592
  message,
@@ -1448,7 +1598,7 @@ export class Wallet extends ReadonlyWallet {
1448
1598
  expire_at: 0,
1449
1599
  };
1450
1600
  const proof = Intent.create(message, coins, []);
1451
- const signedProof = await this.identity.sign(proof);
1601
+ const signedProof = await this._signerRouter.sign(proof, intentProofJobs(coins));
1452
1602
  return {
1453
1603
  proof: base64.encode(signedProof.toPSBT()),
1454
1604
  message,
@@ -1513,7 +1663,7 @@ export class Wallet extends ReadonlyWallet {
1513
1663
  try {
1514
1664
  const finalCheckpoints = await Promise.all(pendingTx.signedCheckpointTxs.map(async (c) => {
1515
1665
  const tx = Transaction.fromPSBT(base64.decode(c));
1516
- const signedCheckpoint = await this.identity.sign(tx);
1666
+ const signedCheckpoint = await this._signerRouter.sign(tx, this.inputSigningJobsFromWitnessUtxos(tx));
1517
1667
  return base64.encode(signedCheckpoint.toPSBT());
1518
1668
  }));
1519
1669
  await this.arkProvider.finalizeTx(pendingTx.arkTxid, finalCheckpoints);
@@ -1573,10 +1723,19 @@ export class Wallet extends ReadonlyWallet {
1573
1723
  if (args.length === 0) {
1574
1724
  throw new Error("At least one receiver is required");
1575
1725
  }
1726
+ // Snapshot the active receive tapscript synchronously before any
1727
+ // `await`. `WalletReceiveRotator.rotate` mutates
1728
+ // `this.offchainTapscript` without acquiring `_txLock`, so any
1729
+ // yield between here and `updateDbAfterOffchainTx` opens a window
1730
+ // where the change-output pkScript (built from `outputAddress`
1731
+ // below) and the change-VTXO metadata (built from the snapshot
1732
+ // inside `updateDbAfterOffchainTx`) could come from different
1733
+ // tapscripts. Threading the snapshot pins both reads.
1734
+ const offchainTapscript = this.offchainTapscript;
1735
+ const outputAddress = offchainTapscript.address(this.network.hrp, this.arkServerPublicKey);
1736
+ const address = outputAddress.encode();
1576
1737
  // validate recipients and populate undefined amount with dust amount
1577
1738
  const recipients = validateRecipients(args, Number(this.dustAmount));
1578
- const address = await this.getAddress();
1579
- const outputAddress = ArkAddress.decode(address);
1580
1739
  const virtualCoins = await this.getVtxos({
1581
1740
  withRecoverable: false,
1582
1741
  });
@@ -1707,7 +1866,7 @@ export class Wallet extends ReadonlyWallet {
1707
1866
  this._addPendingSpends(selectedCoins);
1708
1867
  try {
1709
1868
  const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
1710
- await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
1869
+ await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, offchainTapscript, changeReceiver?.assets);
1711
1870
  return arkTxid;
1712
1871
  }
1713
1872
  finally {
@@ -1726,47 +1885,25 @@ export class Wallet extends ReadonlyWallet {
1726
1885
  tapLeafScript: input.forfeitTapLeafScript,
1727
1886
  };
1728
1887
  }), outputs, this.serverUnrollScript);
1729
- let signedVirtualTx;
1730
- let userSignedCheckpoints;
1731
- if (isBatchSignable(this.identity)) {
1732
- // Batch-sign arkTx + all checkpoints in one wallet popup.
1733
- // Clone so the provider can't mutate originals before submitTx.
1734
- const requests = [
1735
- { tx: offchainTx.arkTx.clone() },
1736
- ...offchainTx.checkpoints.map((c) => ({ tx: c.clone() })),
1737
- ];
1738
- const signed = await this.identity.signMultiple(requests);
1739
- if (signed.length !== requests.length) {
1740
- throw new Error(`signMultiple returned ${signed.length} transactions, expected ${requests.length}`);
1741
- }
1742
- const [firstSignedTx, ...signedCheckpoints] = signed;
1743
- signedVirtualTx = firstSignedTx;
1744
- userSignedCheckpoints = signedCheckpoints;
1745
- }
1746
- else {
1747
- signedVirtualTx = await this.identity.sign(offchainTx.arkTx);
1748
- }
1888
+ // arkTx inputs spend checkpoint outputs, so each input's
1889
+ // `witnessUtxo.script` is the checkpoint pkScript — not the
1890
+ // source VTXO contract's pkScript. Build the routing jobs from
1891
+ // the source VTXO scripts (positionally aligned to `inputs[i]`)
1892
+ // so the router can resolve each input's owning contract.
1893
+ const arkTxJobs = inputs.map((input, index) => ({
1894
+ index,
1895
+ lookupScript: VtxoScript.decode(input.tapTree).pkScript,
1896
+ }));
1897
+ const signedVirtualTx = await this._signerRouter.sign(offchainTx.arkTx, arkTxJobs);
1749
1898
  // Mark pending before submitting — if we crash between submit and
1750
1899
  // finalize, the next init will recover via finalizePendingTxs.
1751
1900
  await this.setPendingTxFlag(true);
1752
1901
  const { arkTxid, signedCheckpointTxs } = await this.arkProvider.submitTx(base64.encode(signedVirtualTx.toPSBT()), offchainTx.checkpoints.map((c) => base64.encode(c.toPSBT())));
1753
- let finalCheckpoints;
1754
- if (userSignedCheckpoints) {
1755
- // Merge pre-signed user signatures onto server-signed checkpoints
1756
- finalCheckpoints = signedCheckpointTxs.map((c, i) => {
1757
- const serverSigned = Transaction.fromPSBT(base64.decode(c));
1758
- combineTapscriptSigs(userSignedCheckpoints[i], serverSigned);
1759
- return base64.encode(serverSigned.toPSBT());
1760
- });
1761
- }
1762
- else {
1763
- // Legacy: sign each checkpoint individually (N popups)
1764
- finalCheckpoints = await Promise.all(signedCheckpointTxs.map(async (c) => {
1765
- const tx = Transaction.fromPSBT(base64.decode(c));
1766
- const signedCheckpoint = await this.identity.sign(tx);
1767
- return base64.encode(signedCheckpoint.toPSBT());
1768
- }));
1769
- }
1902
+ const finalCheckpoints = await Promise.all(signedCheckpointTxs.map(async (c) => {
1903
+ const tx = Transaction.fromPSBT(base64.decode(c));
1904
+ const signedCheckpoint = await this._signerRouter.sign(tx, this.inputSigningJobsFromWitnessUtxos(tx));
1905
+ return base64.encode(signedCheckpoint.toPSBT());
1906
+ }));
1770
1907
  await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints);
1771
1908
  try {
1772
1909
  await this.setPendingTxFlag(false);
@@ -1776,8 +1913,17 @@ export class Wallet extends ReadonlyWallet {
1776
1913
  }
1777
1914
  return { arkTxid, signedCheckpointTxs };
1778
1915
  }
1779
- // mark virtual outputs as spent, save change outputs if any
1780
- async updateDbAfterOffchainTx(inputs, arkTxid, signedCheckpointTxs, sentAmount, changeAmount, changeVout, changeAssets) {
1916
+ // mark virtual outputs as spent, save change outputs if any.
1917
+ // `offchainTapscript` is the snapshot the caller captured under
1918
+ // `_txLock` before any `await`; deriving both the change-VTXO
1919
+ // metadata and `primaryAddress` from it here guarantees the local
1920
+ // record matches the pkScript the server saw on the inbound
1921
+ // transaction, even if `WalletReceiveRotator.rotate` swaps
1922
+ // `this.offchainTapscript` mid-flight.
1923
+ async updateDbAfterOffchainTx(inputs, arkTxid, signedCheckpointTxs, sentAmount, changeAmount, changeVout, offchainTapscript, changeAssets) {
1924
+ const primaryAddress = offchainTapscript
1925
+ .address(this.network.hrp, this.arkServerPublicKey)
1926
+ .encode();
1781
1927
  try {
1782
1928
  const spentVtxos = [];
1783
1929
  const commitmentTxIds = new Set();
@@ -1824,7 +1970,6 @@ export class Wallet extends ReadonlyWallet {
1824
1970
  }
1825
1971
  }
1826
1972
  const createdAt = Date.now();
1827
- const primaryAddr = this.arkAddress.encode();
1828
1973
  // Only save a change virtual output for preconfirmed coins (those with a batchExpiry).
1829
1974
  // Inputs without a batchExpiry are already settled/unrolled and don't need tracking.
1830
1975
  let changeVtxo;
@@ -1833,11 +1978,11 @@ export class Wallet extends ReadonlyWallet {
1833
1978
  txid: arkTxid,
1834
1979
  vout: changeVout,
1835
1980
  createdAt: new Date(createdAt),
1836
- forfeitTapLeafScript: this.offchainTapscript.forfeit(),
1837
- intentTapLeafScript: this.offchainTapscript.forfeit(),
1981
+ forfeitTapLeafScript: offchainTapscript.forfeit(),
1982
+ intentTapLeafScript: offchainTapscript.forfeit(),
1838
1983
  isUnrolled: false,
1839
1984
  isSpent: false,
1840
- tapTree: this.offchainTapscript.encode(),
1985
+ tapTree: offchainTapscript.encode(),
1841
1986
  value: Number(changeAmount),
1842
1987
  virtualStatus: {
1843
1988
  state: "preconfirmed",
@@ -1848,7 +1993,7 @@ export class Wallet extends ReadonlyWallet {
1848
1993
  confirmed: false,
1849
1994
  },
1850
1995
  assets: changeAssets,
1851
- script: hex.encode(this.offchainTapscript.pkScript),
1996
+ script: hex.encode(offchainTapscript.pkScript),
1852
1997
  };
1853
1998
  }
1854
1999
  // Route spent rows to their owning contract bucket. The wallet's
@@ -1879,9 +2024,9 @@ export class Wallet extends ReadonlyWallet {
1879
2024
  }
1880
2025
  // Change is always primary-script by construction.
1881
2026
  if (changeVtxo) {
1882
- await saveVtxosForContract(this.walletRepository, { script: changeVtxo.script, address: primaryAddr }, [changeVtxo]);
2027
+ await saveVtxosForContract(this.walletRepository, { script: changeVtxo.script, address: primaryAddress }, [changeVtxo]);
1883
2028
  }
1884
- await this.walletRepository.saveTransactions(primaryAddr, [
2029
+ await this.walletRepository.saveTransactions(primaryAddress, [
1885
2030
  {
1886
2031
  key: {
1887
2032
  boardingTxid: "",