@cogcoin/client 0.5.1 → 0.5.3

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.
@@ -8,6 +8,7 @@ import { resolveManagedServicePaths } from "../bitcoind/service-paths.js";
8
8
  import { createRpcClient } from "../bitcoind/node.js";
9
9
  import { openSqliteStore } from "../sqlite/index.js";
10
10
  import { readPortableWalletArchive, writePortableWalletArchive } from "./archive.js";
11
+ import { normalizeWalletDescriptorState, persistNormalizedWalletDescriptorStateIfNeeded, persistWalletStateUpdate, resolveNormalizedWalletDescriptorState, stripDescriptorChecksum, } from "./descriptor-normalization.js";
11
12
  import { acquireFileLock } from "./fs/lock.js";
12
13
  import { createInternalCoreWalletPassphrase, createMnemonicConfirmationChallenge, deriveWalletMaterialFromMnemonic, generateWalletMaterial, } from "./material.js";
13
14
  import { resolveWalletRuntimePathsForTesting } from "./runtime.js";
@@ -16,6 +17,7 @@ import { loadClientConfig } from "./mining/config.js";
16
17
  import { inspectMiningHookState } from "./mining/hooks.js";
17
18
  import { loadMiningRuntimeStatus, saveMiningRuntimeStatus } from "./mining/runtime-artifacts.js";
18
19
  import { normalizeMiningStateRecord } from "./mining/state.js";
20
+ import { clearWalletExplicitLock, loadWalletExplicitLock, saveWalletExplicitLock, } from "./state/explicit-lock.js";
19
21
  import { clearUnlockSession, loadUnlockSession, saveUnlockSession } from "./state/session.js";
20
22
  import { createDefaultWalletSecretProvider, createWalletRootId, createWalletSecretReference, } from "./state/provider.js";
21
23
  import { loadWalletState, saveWalletState } from "./state/storage.js";
@@ -23,9 +25,6 @@ export const DEFAULT_UNLOCK_DURATION_MS = 15 * 60 * 1000;
23
25
  function sanitizeWalletName(walletRootId) {
24
26
  return `cogcoin-${walletRootId}`.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 63);
25
27
  }
26
- function stripDescriptorChecksum(descriptor) {
27
- return descriptor.replace(/#[A-Za-z0-9]+$/, "");
28
- }
29
28
  async function pathExists(path) {
30
29
  try {
31
30
  await access(path, constants.F_OK);
@@ -144,6 +143,54 @@ function createUnlockSession(state, unlockUntilUnixMs, secretKeyId, nowUnixMs) {
144
143
  wrappedSessionKeyMaterial: secretKeyId,
145
144
  };
146
145
  }
146
+ function createWalletExplicitLock(walletRootId, nowUnixMs) {
147
+ return {
148
+ schemaVersion: 1,
149
+ walletRootId,
150
+ lockedAtUnixMs: nowUnixMs,
151
+ };
152
+ }
153
+ async function normalizeUnlockedWalletStateIfNeeded(options) {
154
+ let state = options.state;
155
+ let session = options.session;
156
+ let source = options.source;
157
+ if (options.dataDir !== undefined) {
158
+ const node = await (options.attachService ?? attachOrStartManagedBitcoindService)({
159
+ dataDir: options.dataDir,
160
+ chain: "main",
161
+ startHeight: 0,
162
+ walletRootId: state.walletRootId,
163
+ });
164
+ try {
165
+ const normalized = await persistNormalizedWalletDescriptorStateIfNeeded({
166
+ state,
167
+ access: {
168
+ provider: options.provider,
169
+ secretReference: createWalletSecretReference(state.walletRootId),
170
+ },
171
+ session,
172
+ paths: options.paths,
173
+ nowUnixMs: options.nowUnixMs,
174
+ replacePrimary: options.source === "backup",
175
+ rpc: (options.rpcFactory ?? createRpcClient)(node.rpc),
176
+ });
177
+ state = normalized.state;
178
+ session = normalized.session ?? session;
179
+ source = normalized.changed ? "primary" : options.source;
180
+ }
181
+ finally {
182
+ await node.stop?.().catch(() => undefined);
183
+ }
184
+ }
185
+ return {
186
+ session,
187
+ state: {
188
+ ...state,
189
+ miningState: normalizeMiningStateRecord(state.miningState),
190
+ },
191
+ source,
192
+ };
193
+ }
147
194
  function createPortableWalletArchivePayload(state, exportedAtUnixMs) {
148
195
  return {
149
196
  schemaVersion: 1,
@@ -518,22 +565,16 @@ function createStoppedBackgroundRuntimeSnapshot(snapshot, nowUnixMs) {
518
565
  };
519
566
  }
520
567
  async function persistRepairState(options) {
521
- const nextState = {
522
- ...options.state,
523
- stateRevision: options.state.stateRevision + 1,
524
- lastWrittenAtUnixMs: options.nowUnixMs,
525
- };
526
- if (options.replacePrimary) {
527
- await rm(options.paths.walletStatePath, { force: true }).catch(() => undefined);
528
- }
529
- await saveWalletState({
530
- primaryPath: options.paths.walletStatePath,
531
- backupPath: options.paths.walletStateBackupPath,
532
- }, nextState, {
533
- provider: options.provider,
534
- secretReference: createWalletSecretReference(nextState.walletRootId),
568
+ return await persistWalletStateUpdate({
569
+ state: options.state,
570
+ access: {
571
+ provider: options.provider,
572
+ secretReference: createWalletSecretReference(options.state.walletRootId),
573
+ },
574
+ paths: options.paths,
575
+ nowUnixMs: options.nowUnixMs,
576
+ replacePrimary: options.replacePrimary,
535
577
  });
536
- return nextState;
537
578
  }
538
579
  async function stopBackgroundMiningForRepair(options) {
539
580
  const pid = options.snapshot.backgroundWorkerPid;
@@ -630,13 +671,12 @@ async function importDescriptorIntoManagedCoreWallet(state, provider, paths, dat
630
671
  await createManagedWalletReplica(rpc, state.walletRootId, {
631
672
  managedWalletPassphrase: state.managedCoreWallet.internalPassphrase,
632
673
  });
633
- const privateDescriptor = await rpc.getDescriptorInfo(state.descriptor.privateExternal);
634
- const publicDescriptor = await rpc.getDescriptorInfo(state.descriptor.publicExternal);
674
+ const normalizedDescriptors = await resolveNormalizedWalletDescriptorState(state, rpc);
635
675
  const walletName = sanitizeWalletName(state.walletRootId);
636
676
  await rpc.walletPassphrase(walletName, state.managedCoreWallet.internalPassphrase, 10);
637
677
  try {
638
678
  const importResults = await rpc.importDescriptors(walletName, [{
639
- desc: privateDescriptor.descriptor,
679
+ desc: normalizedDescriptors.privateExternal,
640
680
  timestamp: state.walletBirthTime,
641
681
  active: false,
642
682
  internal: false,
@@ -649,12 +689,12 @@ async function importDescriptorIntoManagedCoreWallet(state, provider, paths, dat
649
689
  finally {
650
690
  await rpc.walletLock(walletName).catch(() => undefined);
651
691
  }
652
- const derivedFunding = await rpc.deriveAddresses(publicDescriptor.descriptor, [0, 0]);
692
+ const derivedFunding = await rpc.deriveAddresses(normalizedDescriptors.publicExternal, [0, 0]);
653
693
  if (derivedFunding[0] !== state.funding.address) {
654
694
  throw new Error("wallet_funding_address_verification_failed");
655
695
  }
656
696
  const descriptors = await rpc.listDescriptors(walletName);
657
- const importedDescriptor = descriptors.descriptors.find((entry) => entry.desc === privateDescriptor.descriptor);
697
+ const importedDescriptor = descriptors.descriptors.find((entry) => entry.desc === normalizedDescriptors.publicExternal);
658
698
  if (importedDescriptor == null) {
659
699
  throw new Error("wallet_descriptor_not_present_after_import");
660
700
  }
@@ -666,7 +706,7 @@ async function importDescriptorIntoManagedCoreWallet(state, provider, paths, dat
666
706
  privateKeysEnabled: true,
667
707
  created: false,
668
708
  proofStatus: "ready",
669
- descriptorChecksum: privateDescriptor.checksum,
709
+ descriptorChecksum: normalizedDescriptors.checksum,
670
710
  fundingAddress0: state.funding.address,
671
711
  fundingScriptPubKeyHex0: state.funding.scriptPubKeyHex,
672
712
  message: null,
@@ -677,14 +717,14 @@ async function importDescriptorIntoManagedCoreWallet(state, provider, paths, dat
677
717
  lastWrittenAtUnixMs: nowUnixMs,
678
718
  descriptor: {
679
719
  ...state.descriptor,
680
- privateExternal: privateDescriptor.descriptor,
681
- publicExternal: publicDescriptor.descriptor,
682
- checksum: privateDescriptor.checksum,
720
+ privateExternal: normalizedDescriptors.privateExternal,
721
+ publicExternal: normalizedDescriptors.publicExternal,
722
+ checksum: normalizedDescriptors.checksum,
683
723
  },
684
724
  managedCoreWallet: {
685
725
  ...state.managedCoreWallet,
686
726
  walletName,
687
- descriptorChecksum: privateDescriptor.checksum,
727
+ descriptorChecksum: normalizedDescriptors.checksum,
688
728
  fundingAddress0: verifiedReplica.fundingAddress0 ?? null,
689
729
  fundingScriptPubKeyHex0: verifiedReplica.fundingScriptPubKeyHex0 ?? null,
690
730
  proofStatus: "ready",
@@ -782,7 +822,7 @@ export async function loadUnlockedWalletState(options = {}) {
782
822
  const nowUnixMs = options.nowUnixMs ?? Date.now();
783
823
  const paths = options.paths ?? resolveWalletRuntimePathsForTesting();
784
824
  try {
785
- const session = await loadUnlockSession(paths.walletUnlockSessionPath, {
825
+ let session = await loadUnlockSession(paths.walletUnlockSessionPath, {
786
826
  provider,
787
827
  });
788
828
  if (session.unlockUntilUnixMs <= nowUnixMs) {
@@ -800,19 +840,97 @@ export async function loadUnlockedWalletState(options = {}) {
800
840
  await clearUnlockSession(paths.walletUnlockSessionPath);
801
841
  return null;
802
842
  }
803
- return {
843
+ return await normalizeUnlockedWalletStateIfNeeded({
844
+ provider,
804
845
  session,
805
- state: {
806
- ...loaded.state,
807
- miningState: normalizeMiningStateRecord(loaded.state.miningState),
808
- },
846
+ state: loaded.state,
809
847
  source: loaded.source,
810
- };
848
+ nowUnixMs,
849
+ paths,
850
+ dataDir: options.dataDir,
851
+ attachService: options.attachService,
852
+ rpcFactory: options.rpcFactory,
853
+ });
811
854
  }
812
855
  catch {
813
856
  return null;
814
857
  }
815
858
  }
859
+ export async function loadOrAutoUnlockWalletState(options = {}) {
860
+ const provider = options.provider ?? createDefaultWalletSecretProvider();
861
+ const nowUnixMs = options.nowUnixMs ?? Date.now();
862
+ const unlockDurationMs = options.unlockDurationMs ?? DEFAULT_UNLOCK_DURATION_MS;
863
+ const paths = options.paths ?? resolveWalletRuntimePathsForTesting();
864
+ const loadExisting = () => loadUnlockedWalletState({
865
+ provider,
866
+ nowUnixMs,
867
+ paths,
868
+ dataDir: options.dataDir,
869
+ attachService: options.attachService,
870
+ rpcFactory: options.rpcFactory,
871
+ });
872
+ const existing = await loadExisting();
873
+ if (existing !== null) {
874
+ return existing;
875
+ }
876
+ const loadAndMaybeAutoUnlock = async () => {
877
+ const reloaded = await loadExisting();
878
+ if (reloaded !== null) {
879
+ return reloaded;
880
+ }
881
+ let loaded;
882
+ try {
883
+ loaded = await loadWalletState({
884
+ primaryPath: paths.walletStatePath,
885
+ backupPath: paths.walletStateBackupPath,
886
+ }, {
887
+ provider,
888
+ });
889
+ }
890
+ catch {
891
+ return null;
892
+ }
893
+ const explicitLock = await loadWalletExplicitLock(paths.walletExplicitLockPath);
894
+ if (explicitLock !== null) {
895
+ if (explicitLock.walletRootId === loaded.state.walletRootId) {
896
+ await clearUnlockSession(paths.walletUnlockSessionPath);
897
+ return null;
898
+ }
899
+ await clearWalletExplicitLock(paths.walletExplicitLockPath);
900
+ }
901
+ const secretReference = createWalletSecretReference(loaded.state.walletRootId);
902
+ const unlockUntilUnixMs = nowUnixMs + unlockDurationMs;
903
+ const session = createUnlockSession(loaded.state, unlockUntilUnixMs, secretReference.keyId, nowUnixMs);
904
+ await saveUnlockSession(paths.walletUnlockSessionPath, session, {
905
+ provider,
906
+ secretReference,
907
+ });
908
+ return await normalizeUnlockedWalletStateIfNeeded({
909
+ provider,
910
+ session,
911
+ state: loaded.state,
912
+ source: loaded.source,
913
+ nowUnixMs,
914
+ paths,
915
+ dataDir: options.dataDir,
916
+ attachService: options.attachService,
917
+ rpcFactory: options.rpcFactory,
918
+ });
919
+ };
920
+ if (options.controlLockHeld) {
921
+ return await loadAndMaybeAutoUnlock();
922
+ }
923
+ const controlLock = await acquireFileLock(paths.walletControlLockPath, {
924
+ purpose: "wallet-auto-unlock",
925
+ walletRootId: null,
926
+ });
927
+ try {
928
+ return await loadAndMaybeAutoUnlock();
929
+ }
930
+ finally {
931
+ await controlLock.release();
932
+ }
933
+ }
816
934
  export async function initializeWallet(options) {
817
935
  if (!options.prompter.isInteractive) {
818
936
  throw new Error("wallet_init_requires_tty");
@@ -863,6 +981,7 @@ export async function initializeWallet(options) {
863
981
  });
864
982
  const verifiedState = await importDescriptorIntoManagedCoreWallet(initialState, provider, paths, options.dataDir, nowUnixMs, options.attachService, options.rpcFactory);
865
983
  const unlockUntilUnixMs = nowUnixMs + unlockDurationMs;
984
+ await clearWalletExplicitLock(paths.walletExplicitLockPath);
866
985
  await saveUnlockSession(paths.walletUnlockSessionPath, createUnlockSession(verifiedState, unlockUntilUnixMs, secretReference.keyId, nowUnixMs), {
867
986
  provider,
868
987
  secretReference,
@@ -896,6 +1015,7 @@ export async function unlockWallet(options = {}) {
896
1015
  });
897
1016
  const secretReference = createWalletSecretReference(loaded.state.walletRootId);
898
1017
  const unlockUntilUnixMs = nowUnixMs + unlockDurationMs;
1018
+ await clearWalletExplicitLock(paths.walletExplicitLockPath);
899
1019
  await saveUnlockSession(paths.walletUnlockSessionPath, createUnlockSession(loaded.state, unlockUntilUnixMs, secretReference.keyId, nowUnixMs), {
900
1020
  provider,
901
1021
  secretReference,
@@ -912,6 +1032,7 @@ export async function unlockWallet(options = {}) {
912
1032
  }
913
1033
  export async function lockWallet(options) {
914
1034
  const provider = options.provider ?? createDefaultWalletSecretProvider();
1035
+ const nowUnixMs = options.nowUnixMs ?? Date.now();
915
1036
  const paths = options.paths ?? resolveWalletRuntimePathsForTesting();
916
1037
  const controlLock = await acquireFileLock(paths.walletControlLockPath, {
917
1038
  purpose: "wallet-lock",
@@ -947,6 +1068,9 @@ export async function lockWallet(options) {
947
1068
  walletRootId = null;
948
1069
  }
949
1070
  await clearUnlockSession(paths.walletUnlockSessionPath);
1071
+ if (walletRootId !== null) {
1072
+ await saveWalletExplicitLock(paths.walletExplicitLockPath, createWalletExplicitLock(walletRootId, nowUnixMs));
1073
+ }
950
1074
  return {
951
1075
  walletRootId,
952
1076
  coreLocked,
@@ -968,10 +1092,11 @@ export async function exportWallet(options) {
968
1092
  walletRootId: null,
969
1093
  });
970
1094
  try {
971
- const unlocked = await loadUnlockedWalletState({
1095
+ const unlocked = await loadOrAutoUnlockWalletState({
972
1096
  provider,
973
1097
  nowUnixMs,
974
1098
  paths,
1099
+ controlLockHeld: true,
975
1100
  });
976
1101
  if (unlocked === null) {
977
1102
  throw new Error("wallet_locked");
@@ -1050,6 +1175,7 @@ export async function importWallet(options) {
1050
1175
  internalCoreWalletPassphrase: createInternalCoreWalletPassphrase(),
1051
1176
  });
1052
1177
  await clearUnlockSession(paths.walletUnlockSessionPath);
1178
+ await clearWalletExplicitLock(paths.walletExplicitLockPath);
1053
1179
  await saveWalletState({
1054
1180
  primaryPath: paths.walletStatePath,
1055
1181
  backupPath: paths.walletStateBackupPath,
@@ -1062,6 +1188,7 @@ export async function importWallet(options) {
1062
1188
  rpcFactory: options.rpcFactory,
1063
1189
  });
1064
1190
  const unlockUntilUnixMs = nowUnixMs + unlockDurationMs;
1191
+ await clearWalletExplicitLock(paths.walletExplicitLockPath);
1065
1192
  await saveUnlockSession(paths.walletUnlockSessionPath, createUnlockSession(importedState, unlockUntilUnixMs, secretReference.keyId, nowUnixMs), {
1066
1193
  provider,
1067
1194
  secretReference,
@@ -1242,6 +1369,11 @@ export async function repairWallet(options) {
1242
1369
  walletRootId: repairedState.walletRootId,
1243
1370
  });
1244
1371
  const bitcoindRpc = (options.rpcFactory ?? createRpcClient)(bitcoindHandle.rpc);
1372
+ const normalizedDescriptorState = await normalizeWalletDescriptorState(repairedState, bitcoindRpc);
1373
+ if (normalizedDescriptorState.changed) {
1374
+ repairedState = normalizedDescriptorState.state;
1375
+ repairStateNeedsPersist = true;
1376
+ }
1245
1377
  let replica = await verifyManagedCoreWalletReplica(repairedState, options.dataDir, {
1246
1378
  nodeHandle: bitcoindHandle,
1247
1379
  attachService: options.attachService,
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { acquireFileLock } from "../fs/lock.js";
3
- import { loadUnlockedWalletState } from "../lifecycle.js";
3
+ import { loadOrAutoUnlockWalletState } from "../lifecycle.js";
4
4
  import { resolveWalletRuntimePathsForTesting } from "../runtime.js";
5
5
  import { saveWalletState } from "../state/storage.js";
6
6
  import { createDefaultWalletSecretProvider, createWalletSecretReference, } from "../state/provider.js";
@@ -397,7 +397,7 @@ export async function enableMiningHooks(options) {
397
397
  paths,
398
398
  reason: "hooks-enable-mining",
399
399
  });
400
- const unlocked = await loadUnlockedWalletState({
400
+ const unlocked = await loadOrAutoUnlockWalletState({
401
401
  provider,
402
402
  nowUnixMs,
403
403
  paths,
@@ -556,7 +556,7 @@ export async function disableMiningHooks(options) {
556
556
  paths,
557
557
  reason: "hooks-disable-mining",
558
558
  });
559
- const unlocked = await loadUnlockedWalletState({
559
+ const unlocked = await loadOrAutoUnlockWalletState({
560
560
  provider,
561
561
  nowUnixMs,
562
562
  paths,
@@ -664,7 +664,7 @@ export async function setupBuiltInMining(options) {
664
664
  paths,
665
665
  reason: "mine-setup",
666
666
  });
667
- const unlocked = await loadUnlockedWalletState({
667
+ const unlocked = await loadOrAutoUnlockWalletState({
668
668
  provider,
669
669
  nowUnixMs,
670
670
  paths,
@@ -10,7 +10,7 @@ import { COG_OPCODES, COG_PREFIX } from "../cogop/constants.js";
10
10
  import { extractOpReturnPayloadFromScriptHex } from "../tx/register.js";
11
11
  import { DEFAULT_WALLET_MUTATION_FEE_RATE_SAT_VB, buildWalletMutationTransaction, isAlreadyAcceptedError, isBroadcastUnknownError, saveWalletStatePreservingUnlock, } from "../tx/common.js";
12
12
  import { acquireFileLock } from "../fs/lock.js";
13
- import { loadUnlockedWalletState } from "../lifecycle.js";
13
+ import { loadOrAutoUnlockWalletState } from "../lifecycle.js";
14
14
  import { isMineableWalletDomain, openWalletReadContext, } from "../read/index.js";
15
15
  import { resolveWalletRuntimePathsForTesting } from "../runtime.js";
16
16
  import { createDefaultWalletSecretProvider, } from "../state/provider.js";
@@ -1567,7 +1567,7 @@ async function publishCandidate(options) {
1567
1567
  };
1568
1568
  }
1569
1569
  async function ensureBuiltInSetupIfNeeded(options) {
1570
- const unlocked = await loadUnlockedWalletState({
1570
+ const unlocked = await loadOrAutoUnlockWalletState({
1571
1571
  provider: options.provider,
1572
1572
  paths: options.paths,
1573
1573
  });
@@ -3,16 +3,19 @@ import { type WalletSecretProvider } from "../state/provider.js";
3
3
  import type { WalletLocalStateStatus, WalletReadContext } from "./types.js";
4
4
  import type { WalletRuntimePaths } from "../runtime.js";
5
5
  declare function inspectWalletLocalState(options?: {
6
+ dataDir?: string;
6
7
  passphrase?: Uint8Array | string;
7
8
  secretProvider?: WalletSecretProvider;
8
9
  now?: number;
9
10
  paths?: WalletRuntimePaths;
11
+ walletControlLockHeld?: boolean;
10
12
  }): Promise<WalletLocalStateStatus>;
11
13
  export declare function openWalletReadContext(options: {
12
14
  dataDir: string;
13
15
  databasePath: string;
14
16
  walletStatePassphrase?: Uint8Array | string;
15
17
  secretProvider?: WalletSecretProvider;
18
+ walletControlLockHeld?: boolean;
16
19
  startupTimeoutMs?: number;
17
20
  now?: number;
18
21
  paths?: WalletRuntimePaths;
@@ -5,12 +5,14 @@ import { createRpcClient } from "../../bitcoind/node.js";
5
5
  import { UNINITIALIZED_WALLET_ROOT_ID } from "../../bitcoind/service-paths.js";
6
6
  import { attachOrStartManagedBitcoindService, probeManagedBitcoindService, } from "../../bitcoind/service.js";
7
7
  import {} from "../../bitcoind/types.js";
8
- import { loadUnlockedWalletState, verifyManagedCoreWalletReplica, } from "../lifecycle.js";
8
+ import { loadOrAutoUnlockWalletState, verifyManagedCoreWalletReplica, } from "../lifecycle.js";
9
+ import { persistNormalizedWalletDescriptorStateIfNeeded } from "../descriptor-normalization.js";
9
10
  import { inspectMiningControlPlane } from "../mining/index.js";
10
11
  import { normalizeMiningStateRecord } from "../mining/state.js";
11
12
  import { resolveWalletRuntimePathsForTesting } from "../runtime.js";
13
+ import { loadWalletExplicitLock } from "../state/explicit-lock.js";
12
14
  import { loadWalletState } from "../state/storage.js";
13
- import { createDefaultWalletSecretProvider, } from "../state/provider.js";
15
+ import { createDefaultWalletSecretProvider, createWalletSecretReference, } from "../state/provider.js";
14
16
  import { createWalletReadModel } from "./project.js";
15
17
  const DEFAULT_SERVICE_START_TIMEOUT_MS = 10_000;
16
18
  const STALE_HEARTBEAT_THRESHOLD_MS = 15_000;
@@ -23,6 +25,64 @@ async function pathExists(path) {
23
25
  return false;
24
26
  }
25
27
  }
28
+ function isLockedWalletAccessError(error) {
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ return message === "wallet_envelope_missing_secret_provider"
31
+ || message.startsWith("wallet_secret_missing_")
32
+ || message.startsWith("wallet_secret_provider_");
33
+ }
34
+ function describeLockedWalletMessage(options) {
35
+ if (options.explicitlyLocked) {
36
+ return "Wallet state exists but is explicitly locked until `cogcoin unlock` is run.";
37
+ }
38
+ const message = options.accessError instanceof Error ? options.accessError.message : String(options.accessError ?? "");
39
+ if (message === "wallet_envelope_missing_secret_provider") {
40
+ return "Wallet state exists but requires the local wallet-state passphrase.";
41
+ }
42
+ if (message.startsWith("wallet_secret_provider_")) {
43
+ return "Wallet state exists but the local secret provider is unavailable.";
44
+ }
45
+ if (message.startsWith("wallet_secret_missing_")) {
46
+ return "Wallet state exists but its local secret-provider material is unavailable.";
47
+ }
48
+ return options.hasUnlockSessionFile
49
+ ? "Wallet state exists but the unlock session is expired, invalid, or belongs to a different wallet root."
50
+ : "Wallet state exists but is currently locked.";
51
+ }
52
+ async function normalizeLoadedWalletStateForRead(options) {
53
+ if (options.dataDir === undefined) {
54
+ return options.loaded;
55
+ }
56
+ const node = await attachOrStartManagedBitcoindService({
57
+ dataDir: options.dataDir,
58
+ chain: "main",
59
+ startHeight: 0,
60
+ walletRootId: options.loaded.state.walletRootId,
61
+ });
62
+ try {
63
+ const access = typeof options.access === "string" || options.access instanceof Uint8Array
64
+ ? options.access
65
+ : {
66
+ provider: options.access.provider,
67
+ secretReference: createWalletSecretReference(options.loaded.state.walletRootId),
68
+ };
69
+ const normalized = await persistNormalizedWalletDescriptorStateIfNeeded({
70
+ state: options.loaded.state,
71
+ access,
72
+ paths: options.paths,
73
+ nowUnixMs: options.now,
74
+ replacePrimary: options.loaded.source === "backup",
75
+ rpc: createRpcClient(node.rpc),
76
+ });
77
+ return {
78
+ source: normalized.changed ? "primary" : options.loaded.source,
79
+ state: normalized.state,
80
+ };
81
+ }
82
+ finally {
83
+ await node.stop?.().catch(() => undefined);
84
+ }
85
+ }
26
86
  async function inspectWalletLocalState(options = {}) {
27
87
  const paths = options.paths ?? resolveWalletRuntimePathsForTesting();
28
88
  const now = options.now ?? Date.now();
@@ -47,21 +107,63 @@ async function inspectWalletLocalState(options = {}) {
47
107
  if (options.passphrase === undefined) {
48
108
  try {
49
109
  const provider = options.secretProvider ?? createDefaultWalletSecretProvider();
50
- const unlocked = await loadUnlockedWalletState({
110
+ const unlocked = await loadOrAutoUnlockWalletState({
51
111
  provider,
52
112
  nowUnixMs: now,
53
113
  paths,
114
+ dataDir: options.dataDir,
115
+ controlLockHeld: options.walletControlLockHeld,
54
116
  });
55
117
  if (unlocked === null) {
118
+ const explicitLock = await loadWalletExplicitLock(paths.walletExplicitLockPath);
119
+ const hasUnlockSessionFileNow = await pathExists(paths.walletUnlockSessionPath);
56
120
  try {
57
- await loadWalletState({
121
+ const loaded = await loadWalletState({
58
122
  primaryPath: paths.walletStatePath,
59
123
  backupPath: paths.walletStateBackupPath,
60
124
  }, {
61
125
  provider,
62
126
  });
127
+ await normalizeLoadedWalletStateForRead({
128
+ loaded,
129
+ access: { provider },
130
+ dataDir: options.dataDir,
131
+ now,
132
+ paths,
133
+ });
134
+ return {
135
+ availability: "locked",
136
+ walletRootId: loaded.state.walletRootId,
137
+ state: null,
138
+ source: loaded.source,
139
+ unlockUntilUnixMs: null,
140
+ hasPrimaryStateFile,
141
+ hasBackupStateFile,
142
+ hasUnlockSessionFile: hasUnlockSessionFileNow,
143
+ message: describeLockedWalletMessage({
144
+ explicitlyLocked: explicitLock?.walletRootId === loaded.state.walletRootId,
145
+ hasUnlockSessionFile: hasUnlockSessionFileNow,
146
+ }),
147
+ };
63
148
  }
64
149
  catch (error) {
150
+ if (isLockedWalletAccessError(error)) {
151
+ return {
152
+ availability: "locked",
153
+ walletRootId: null,
154
+ state: null,
155
+ source: null,
156
+ unlockUntilUnixMs: null,
157
+ hasPrimaryStateFile,
158
+ hasBackupStateFile,
159
+ hasUnlockSessionFile: hasUnlockSessionFileNow,
160
+ message: describeLockedWalletMessage({
161
+ accessError: error,
162
+ explicitlyLocked: false,
163
+ hasUnlockSessionFile: hasUnlockSessionFileNow,
164
+ }),
165
+ };
166
+ }
65
167
  return {
66
168
  availability: "local-state-corrupt",
67
169
  walletRootId: null,
@@ -70,23 +172,10 @@ async function inspectWalletLocalState(options = {}) {
70
172
  unlockUntilUnixMs: null,
71
173
  hasPrimaryStateFile,
72
174
  hasBackupStateFile,
73
- hasUnlockSessionFile,
175
+ hasUnlockSessionFile: hasUnlockSessionFileNow,
74
176
  message: error instanceof Error ? error.message : String(error),
75
177
  };
76
178
  }
77
- return {
78
- availability: "locked",
79
- walletRootId: null,
80
- state: null,
81
- source: null,
82
- unlockUntilUnixMs: null,
83
- hasPrimaryStateFile,
84
- hasBackupStateFile,
85
- hasUnlockSessionFile,
86
- message: hasUnlockSessionFile
87
- ? "Wallet state exists but the unlock session is expired, invalid, or belongs to a different wallet root."
88
- : "Wallet state exists but is currently locked.",
89
- };
90
179
  }
91
180
  return {
92
181
  availability: "ready",
@@ -99,7 +188,7 @@ async function inspectWalletLocalState(options = {}) {
99
188
  unlockUntilUnixMs: unlocked.session.unlockUntilUnixMs,
100
189
  hasPrimaryStateFile,
101
190
  hasBackupStateFile,
102
- hasUnlockSessionFile,
191
+ hasUnlockSessionFile: true,
103
192
  message: null,
104
193
  };
105
194
  }
@@ -118,10 +207,16 @@ async function inspectWalletLocalState(options = {}) {
118
207
  }
119
208
  }
120
209
  try {
121
- const loaded = await loadWalletState({
122
- primaryPath: paths.walletStatePath,
123
- backupPath: paths.walletStateBackupPath,
124
- }, options.passphrase);
210
+ const loaded = await normalizeLoadedWalletStateForRead({
211
+ loaded: await loadWalletState({
212
+ primaryPath: paths.walletStatePath,
213
+ backupPath: paths.walletStateBackupPath,
214
+ }, options.passphrase),
215
+ access: options.passphrase,
216
+ dataDir: options.dataDir,
217
+ now,
218
+ paths,
219
+ });
125
220
  return {
126
221
  availability: "ready",
127
222
  walletRootId: loaded.state.walletRootId,
@@ -408,8 +503,10 @@ export async function openWalletReadContext(options) {
408
503
  const startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_SERVICE_START_TIMEOUT_MS;
409
504
  const now = options.now ?? Date.now();
410
505
  const localState = await inspectWalletLocalState({
506
+ dataDir: options.dataDir,
411
507
  passphrase: options.walletStatePassphrase,
412
508
  secretProvider: options.secretProvider,
509
+ walletControlLockHeld: options.walletControlLockHeld,
413
510
  now,
414
511
  paths: options.paths,
415
512
  });
@@ -10,6 +10,7 @@ export interface WalletRuntimePaths {
10
10
  walletStatePath: string;
11
11
  walletStateBackupPath: string;
12
12
  walletUnlockSessionPath: string;
13
+ walletExplicitLockPath: string;
13
14
  walletControlLockPath: string;
14
15
  bitcoindLockPath: string;
15
16
  bitcoindStatusPath: string;
@@ -12,6 +12,7 @@ export function resolveWalletRuntimePathsForTesting(resolution = {}) {
12
12
  walletStatePath: paths.walletStatePath,
13
13
  walletStateBackupPath: paths.walletStateBackupPath,
14
14
  walletUnlockSessionPath: paths.walletUnlockSessionPath,
15
+ walletExplicitLockPath: paths.walletExplicitLockPath,
15
16
  walletControlLockPath: paths.walletControlLockPath,
16
17
  bitcoindLockPath: paths.bitcoindLockPath,
17
18
  bitcoindStatusPath: paths.bitcoindStatusPath,