@cogcoin/client 0.5.2 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@cogcoin/client`
2
2
 
3
- `@cogcoin/client@0.5.2` is the store-backed Cogcoin client package for applications that want a local wallet, durable SQLite-backed state, and a managed Bitcoin Core integration around `@cogcoin/indexer`. It publishes the reusable client APIs, the SQLite adapter, the managed `bitcoind` integration, and the first-party `cogcoin` CLI in one package.
3
+ `@cogcoin/client@0.5.3` is the store-backed Cogcoin client package for applications that want a local wallet, durable SQLite-backed state, and a managed Bitcoin Core integration around `@cogcoin/indexer`. It publishes the reusable client APIs, the SQLite adapter, the managed `bitcoind` integration, and the first-party `cogcoin` CLI in one package.
4
4
 
5
5
  Use Node 22 or newer.
6
6
 
@@ -32,6 +32,8 @@ npx cogcoin sync
32
32
 
33
33
  Verify the installed genesis artifacts before using the client in a production implementation.
34
34
  The installed package provides the `cogcoin` command for local wallet setup, sync, reads, writes, and mining workflows.
35
+ Provider-backed local wallets unlock on demand by default; `cogcoin wallet lock` suppresses that behavior until `cogcoin unlock` is run again.
36
+ Passphrase-wrapped wallet-state flows still require explicit passphrase-based access.
35
37
 
36
38
  ## Dependency Surface
37
39
 
@@ -79,6 +81,7 @@ The installed `cogcoin` command covers the first-party local wallet and node wor
79
81
  - mining and hook commands such as `mine`, `mine start`, `mine stop`, `mine status`, `mine log`, `mine setup`, and `hooks status`
80
82
 
81
83
  The CLI also supports stable `--output json` and `--output preview-json` envelopes on the commands that advertise machine-readable output.
84
+ For provider-backed local wallets, normal reads, mutations, export, and mining setup flows auto-materialize a local unlock session when the wallet is not explicitly locked.
82
85
 
83
86
  ## SQLite Store
84
87
 
@@ -18,6 +18,7 @@ export interface CogcoinResolvedPaths {
18
18
  walletStatePath: string;
19
19
  walletStateBackupPath: string;
20
20
  walletUnlockSessionPath: string;
21
+ walletExplicitLockPath: string;
21
22
  walletControlLockPath: string;
22
23
  bitcoindLockPath: string;
23
24
  bitcoindStatusPath: string;
package/dist/app-paths.js CHANGED
@@ -64,6 +64,7 @@ export function resolveCogcoinPathsForTesting(resolution = {}) {
64
64
  const walletStatePath = joinForPlatform(platform, walletStateDirectory, "wallet-state.enc");
65
65
  const walletStateBackupPath = joinForPlatform(platform, walletStateDirectory, "wallet-state.enc.bak");
66
66
  const walletUnlockSessionPath = joinForPlatform(platform, runtimeRoot, "wallet-unlock-session.enc");
67
+ const walletExplicitLockPath = joinForPlatform(platform, runtimeRoot, "wallet-explicit-lock.json");
67
68
  const walletControlLockPath = joinForPlatform(platform, runtimeRoot, "wallet-control.lock");
68
69
  const bitcoindLockPath = joinForPlatform(platform, runtimeRoot, "bitcoind.lock");
69
70
  const bitcoindStatusPath = joinForPlatform(platform, runtimeRoot, "bitcoind-status.json");
@@ -91,6 +92,7 @@ export function resolveCogcoinPathsForTesting(resolution = {}) {
91
92
  walletStatePath,
92
93
  walletStateBackupPath,
93
94
  walletUnlockSessionPath,
95
+ walletExplicitLockPath,
94
96
  walletControlLockPath,
95
97
  bitcoindLockPath,
96
98
  bitcoindStatusPath,
@@ -211,7 +211,7 @@ export function createCliErrorPresentation(errorCode, fallbackMessage) {
211
211
  if (errorCode === "wallet_locked") {
212
212
  return {
213
213
  what: "Wallet is locked.",
214
- why: "This command needs access to the unlocked local wallet state before it can continue.",
214
+ why: "This command needs access to the unlocked local wallet state before it can continue. Provider-backed wallets unlock on demand unless they were explicitly locked or the local secret store is unavailable.",
215
215
  next: "Run `cogcoin unlock --for 15m` and retry.",
216
216
  };
217
217
  }
@@ -1,3 +1,3 @@
1
1
  import type { ParsedCliArgs } from "./types.js";
2
- export declare const HELP_TEXT = "Usage: cogcoin <command> [options]\n\nCommands:\n status Show wallet-aware local service and chain status\n status --output json Emit the stable v1 machine-readable status envelope\n init Initialize a new local wallet root\n init --output json Emit the stable v1 machine-readable init result envelope\n repair Recover bounded wallet/indexer/runtime state\n unlock Unlock the local wallet for a limited duration\n wallet address Alias for address\n wallet ids Alias for ids\n hooks enable mining Enable a validated custom mining hook\n hooks disable mining Return to built-in mining hooks\n hooks status Show mining hook validation and trust status\n mine Run the miner in the foreground\n mine start Start the miner as a background worker\n mine stop Stop the active background miner\n mine setup Configure the built-in mining provider\n mine setup --output json\n Emit the stable v1 machine-readable mine setup result envelope\n mine status Show mining control-plane health and readiness\n mine log Show recent mining control-plane events\n anchor <domain> Anchor an owned unanchored domain with the Tx1/Tx2 family\n register <domain> [--from <identity>]\n Register a root domain or subdomain\n transfer <domain> --to <btc-target>\n Transfer an unanchored domain to another BTC identity\n sell <domain> <price> List an unanchored domain for sale in COG\n unsell <domain> Clear an active domain listing\n buy <domain> [--from <identity>]\n Buy an unanchored listed domain in COG\n send <amount> --to <btc-target>\n Send COG from one local identity to another BTC target\n claim <lock-id> --preimage <32-byte-hex>\n Claim an active COG lock before timeout\n reclaim <lock-id> Reclaim an expired COG lock as the original locker\n cog lock <amount> --to-domain <domain> (--for <blocks-or-duration> | --until-height <height>) --condition <sha256hex>\n Lock COG to an anchored recipient domain\n wallet status Show detailed wallet-local status and service health\n wallet init Initialize a new local wallet root\n wallet unlock Unlock the local wallet for a limited duration\n wallet lock Lock the local wallet immediately\n wallet export <path> Export a portable encrypted wallet archive\n wallet import <path> Import a portable encrypted wallet archive\n address Show the BTC funding identity for this wallet\n ids List locally controlled identities\n balance Show per-identity COG balances\n locks Show locally related active COG locks\n domain list Alias for domains\n domain show <domain> Alias for show <domain>\n domains [--anchored] [--listed] [--mineable]\n Show locally related domains\n show <domain> Show one domain and its local-wallet relationship\n fields <domain> List current fields on a domain\n field <domain> <field> Show one current field value\n field create <domain> <field>\n Create a new anchored field, optionally with an initial value\n field set <domain> <field>\n Update an existing anchored field value\n field clear <domain> <field>\n Clear an existing anchored field value\n rep give <source-domain> <target-domain> <amount>\n Burn COG as anchored-domain reputation support\n rep revoke <source-domain> <target-domain> <amount>\n Revoke visible support without refunding burned COG\n\nOptions:\n --db <path> Override the SQLite database path\n --data-dir <path> Override the managed bitcoin datadir\n --for <duration> Unlock duration like 15m, 2h, or 1d\n --message <text> Founding message text for anchor\n --to <btc-target> Transfer or send target as an address or spk:<hex>\n --from <identity> Resolve sender identity as id:<n>, domain:<name>, address, or spk:<hex>\n --to-domain <domain>\n Recipient domain for cog lock\n --condition <sha256hex>\n 32-byte lock condition hash\n --until-height <height>\n Absolute timeout height for cog lock\n --preimage <32-byte-hex>\n Claim preimage for an active lock\n --review <text> Optional public review text for reputation operations\n --text <utf8> UTF-8 payload text for endpoint or field writes\n --json <json> UTF-8 payload JSON text for endpoint or field writes\n --bytes <spec> Payload bytes as hex:<hex> or @<path>\n --permanent Create the field as permanent\n --format <spec> Advanced field format as raw:<u8>\n --value <spec> Advanced field value as hex:<hex>, @<path>, or utf8:<text>\n --claimable Show only currently claimable locks\n --reclaimable Show only currently reclaimable locks\n --anchored Show only anchored domains\n --listed Show only currently listed domains\n --mineable Show only locally mineable root domains\n --limit <n> Limit list rows (1..1000)\n --all Show all rows for list commands\n --verify Run full custom-hook verification\n --follow Follow mining log output\n --output <mode> Output mode: text, json, or preview-json\n --progress <mode> Progress output mode: auto, tty, or none\n --force-race Allow a visible root registration race\n --yes Approve eligible plain yes/no mutation confirmations non-interactively\n --help Show help\n --version Show package version\n\nQuickstart:\n 1. Run `cogcoin init` to create the wallet.\n 2. Run `cogcoin sync` to bootstrap assumeutxo and the managed Bitcoin/indexer state.\n 3. Run `cogcoin address`, then fund the wallet with about 0.0015 BTC so you can buy a 6+ character domain to start mining and still keep BTC available for mining transaction fees.\n\nExamples:\n cogcoin status --output json\n cogcoin init --output json\n cogcoin wallet address\n cogcoin domain list --mineable\n cogcoin register alpha-child\n cogcoin register weatherbot --from id:1\n cogcoin anchor alpha\n cogcoin buy alpha\n cogcoin buy alpha --from id:1\n cogcoin field create alpha bio --text \"hello\"\n cogcoin rep give alpha beta 10 --review \"great operator\"\n cogcoin hooks status\n cogcoin mine setup --output json\n cogcoin register alpha-child --output preview-json\n cogcoin mine status\n";
2
+ export declare const HELP_TEXT = "Usage: cogcoin <command> [options]\n\nCommands:\n status Show wallet-aware local service and chain status\n status --output json Emit the stable v1 machine-readable status envelope\n init Initialize a new local wallet root\n init --output json Emit the stable v1 machine-readable init result envelope\n repair Recover bounded wallet/indexer/runtime state\n unlock Clear an explicit wallet lock and unlock for a limited duration\n wallet address Alias for address\n wallet ids Alias for ids\n hooks enable mining Enable a validated custom mining hook\n hooks disable mining Return to built-in mining hooks\n hooks status Show mining hook validation and trust status\n mine Run the miner in the foreground\n mine start Start the miner as a background worker\n mine stop Stop the active background miner\n mine setup Configure the built-in mining provider\n mine setup --output json\n Emit the stable v1 machine-readable mine setup result envelope\n mine status Show mining control-plane health and readiness\n mine log Show recent mining control-plane events\n anchor <domain> Anchor an owned unanchored domain with the Tx1/Tx2 family\n register <domain> [--from <identity>]\n Register a root domain or subdomain\n transfer <domain> --to <btc-target>\n Transfer an unanchored domain to another BTC identity\n sell <domain> <price> List an unanchored domain for sale in COG\n unsell <domain> Clear an active domain listing\n buy <domain> [--from <identity>]\n Buy an unanchored listed domain in COG\n send <amount> --to <btc-target>\n Send COG from one local identity to another BTC target\n claim <lock-id> --preimage <32-byte-hex>\n Claim an active COG lock before timeout\n reclaim <lock-id> Reclaim an expired COG lock as the original locker\n cog lock <amount> --to-domain <domain> (--for <blocks-or-duration> | --until-height <height>) --condition <sha256hex>\n Lock COG to an anchored recipient domain\n wallet status Show detailed wallet-local status and service health\n wallet init Initialize a new local wallet root\n wallet unlock Clear an explicit wallet lock and unlock for a limited duration\n wallet lock Lock the local wallet and disable on-demand unlock\n wallet export <path> Export a portable encrypted wallet archive\n wallet import <path> Import a portable encrypted wallet archive\n address Show the BTC funding identity for this wallet\n ids List locally controlled identities\n balance Show per-identity COG balances\n locks Show locally related active COG locks\n domain list Alias for domains\n domain show <domain> Alias for show <domain>\n domains [--anchored] [--listed] [--mineable]\n Show locally related domains\n show <domain> Show one domain and its local-wallet relationship\n fields <domain> List current fields on a domain\n field <domain> <field> Show one current field value\n field create <domain> <field>\n Create a new anchored field, optionally with an initial value\n field set <domain> <field>\n Update an existing anchored field value\n field clear <domain> <field>\n Clear an existing anchored field value\n rep give <source-domain> <target-domain> <amount>\n Burn COG as anchored-domain reputation support\n rep revoke <source-domain> <target-domain> <amount>\n Revoke visible support without refunding burned COG\n\nOptions:\n --db <path> Override the SQLite database path\n --data-dir <path> Override the managed bitcoin datadir\n --for <duration> Unlock duration like 15m, 2h, or 1d when unlocking explicitly\n --message <text> Founding message text for anchor\n --to <btc-target> Transfer or send target as an address or spk:<hex>\n --from <identity> Resolve sender identity as id:<n>, domain:<name>, address, or spk:<hex>\n --to-domain <domain>\n Recipient domain for cog lock\n --condition <sha256hex>\n 32-byte lock condition hash\n --until-height <height>\n Absolute timeout height for cog lock\n --preimage <32-byte-hex>\n Claim preimage for an active lock\n --review <text> Optional public review text for reputation operations\n --text <utf8> UTF-8 payload text for endpoint or field writes\n --json <json> UTF-8 payload JSON text for endpoint or field writes\n --bytes <spec> Payload bytes as hex:<hex> or @<path>\n --permanent Create the field as permanent\n --format <spec> Advanced field format as raw:<u8>\n --value <spec> Advanced field value as hex:<hex>, @<path>, or utf8:<text>\n --claimable Show only currently claimable locks\n --reclaimable Show only currently reclaimable locks\n --anchored Show only anchored domains\n --listed Show only currently listed domains\n --mineable Show only locally mineable root domains\n --limit <n> Limit list rows (1..1000)\n --all Show all rows for list commands\n --verify Run full custom-hook verification\n --follow Follow mining log output\n --output <mode> Output mode: text, json, or preview-json\n --progress <mode> Progress output mode: auto, tty, or none\n --force-race Allow a visible root registration race\n --yes Approve eligible plain yes/no mutation confirmations non-interactively\n --help Show help\n --version Show package version\n\nQuickstart:\n 1. Run `cogcoin init` to create the wallet.\n 2. Run `cogcoin sync` to bootstrap assumeutxo and the managed Bitcoin/indexer state.\n 3. Run `cogcoin address`, then fund the wallet with about 0.0015 BTC so you can buy a 6+ character domain to start mining and still keep BTC available for mining transaction fees.\n\nExamples:\n cogcoin status --output json\n cogcoin init --output json\n cogcoin wallet address\n cogcoin domain list --mineable\n cogcoin register alpha-child\n cogcoin register weatherbot --from id:1\n cogcoin anchor alpha\n cogcoin buy alpha\n cogcoin buy alpha --from id:1\n cogcoin field create alpha bio --text \"hello\"\n cogcoin rep give alpha beta 10 --review \"great operator\"\n cogcoin hooks status\n cogcoin mine setup --output json\n cogcoin register alpha-child --output preview-json\n cogcoin mine status\n";
3
3
  export declare function parseCliArgs(argv: string[]): ParsedCliArgs;
package/dist/cli/parse.js CHANGED
@@ -7,7 +7,7 @@ Commands:
7
7
  init Initialize a new local wallet root
8
8
  init --output json Emit the stable v1 machine-readable init result envelope
9
9
  repair Recover bounded wallet/indexer/runtime state
10
- unlock Unlock the local wallet for a limited duration
10
+ unlock Clear an explicit wallet lock and unlock for a limited duration
11
11
  wallet address Alias for address
12
12
  wallet ids Alias for ids
13
13
  hooks enable mining Enable a validated custom mining hook
@@ -39,8 +39,8 @@ Commands:
39
39
  Lock COG to an anchored recipient domain
40
40
  wallet status Show detailed wallet-local status and service health
41
41
  wallet init Initialize a new local wallet root
42
- wallet unlock Unlock the local wallet for a limited duration
43
- wallet lock Lock the local wallet immediately
42
+ wallet unlock Clear an explicit wallet lock and unlock for a limited duration
43
+ wallet lock Lock the local wallet and disable on-demand unlock
44
44
  wallet export <path> Export a portable encrypted wallet archive
45
45
  wallet import <path> Import a portable encrypted wallet archive
46
46
  address Show the BTC funding identity for this wallet
@@ -68,7 +68,7 @@ Commands:
68
68
  Options:
69
69
  --db <path> Override the SQLite database path
70
70
  --data-dir <path> Override the managed bitcoin datadir
71
- --for <duration> Unlock duration like 15m, 2h, or 1d
71
+ --for <duration> Unlock duration like 15m, 2h, or 1d when unlocking explicitly
72
72
  --message <text> Founding message text for anchor
73
73
  --to <btc-target> Transfer or send target as an address or spk:<hex>
74
74
  --from <identity> Resolve sender identity as id:<n>, domain:<name>, address, or spk:<hex>
@@ -127,6 +127,16 @@ export declare function loadUnlockedWalletState(options?: {
127
127
  attachService?: typeof attachOrStartManagedBitcoindService;
128
128
  rpcFactory?: (config: Parameters<typeof createRpcClient>[0]) => WalletLifecycleRpcClient;
129
129
  }): Promise<LoadedUnlockedWalletState | null>;
130
+ export declare function loadOrAutoUnlockWalletState(options?: {
131
+ provider?: WalletSecretProvider;
132
+ nowUnixMs?: number;
133
+ unlockDurationMs?: number;
134
+ paths?: WalletRuntimePaths;
135
+ dataDir?: string;
136
+ controlLockHeld?: boolean;
137
+ attachService?: typeof attachOrStartManagedBitcoindService;
138
+ rpcFactory?: (config: Parameters<typeof createRpcClient>[0]) => WalletLifecycleRpcClient;
139
+ }): Promise<LoadedUnlockedWalletState | null>;
130
140
  export declare function initializeWallet(options: {
131
141
  dataDir: string;
132
142
  provider?: WalletSecretProvider;
@@ -146,6 +156,7 @@ export declare function unlockWallet(options?: {
146
156
  export declare function lockWallet(options: {
147
157
  dataDir: string;
148
158
  provider?: WalletSecretProvider;
159
+ nowUnixMs?: number;
149
160
  paths?: WalletRuntimePaths;
150
161
  attachService?: typeof attachOrStartManagedBitcoindService;
151
162
  rpcFactory?: (config: Parameters<typeof createRpcClient>[0]) => WalletLifecycleRpcClient;
@@ -17,6 +17,7 @@ import { loadClientConfig } from "./mining/config.js";
17
17
  import { inspectMiningHookState } from "./mining/hooks.js";
18
18
  import { loadMiningRuntimeStatus, saveMiningRuntimeStatus } from "./mining/runtime-artifacts.js";
19
19
  import { normalizeMiningStateRecord } from "./mining/state.js";
20
+ import { clearWalletExplicitLock, loadWalletExplicitLock, saveWalletExplicitLock, } from "./state/explicit-lock.js";
20
21
  import { clearUnlockSession, loadUnlockSession, saveUnlockSession } from "./state/session.js";
21
22
  import { createDefaultWalletSecretProvider, createWalletRootId, createWalletSecretReference, } from "./state/provider.js";
22
23
  import { loadWalletState, saveWalletState } from "./state/storage.js";
@@ -142,6 +143,54 @@ function createUnlockSession(state, unlockUntilUnixMs, secretKeyId, nowUnixMs) {
142
143
  wrappedSessionKeyMaterial: secretKeyId,
143
144
  };
144
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
+ }
145
194
  function createPortableWalletArchivePayload(state, exportedAtUnixMs) {
146
195
  return {
147
196
  schemaVersion: 1,
@@ -791,47 +840,95 @@ export async function loadUnlockedWalletState(options = {}) {
791
840
  await clearUnlockSession(paths.walletUnlockSessionPath);
792
841
  return null;
793
842
  }
794
- let state = loaded.state;
795
- let source = loaded.source;
796
- if (options.dataDir !== undefined) {
797
- const node = await (options.attachService ?? attachOrStartManagedBitcoindService)({
798
- dataDir: options.dataDir,
799
- chain: "main",
800
- startHeight: 0,
801
- walletRootId: state.walletRootId,
843
+ return await normalizeUnlockedWalletStateIfNeeded({
844
+ provider,
845
+ session,
846
+ state: loaded.state,
847
+ source: loaded.source,
848
+ nowUnixMs,
849
+ paths,
850
+ dataDir: options.dataDir,
851
+ attachService: options.attachService,
852
+ rpcFactory: options.rpcFactory,
853
+ });
854
+ }
855
+ catch {
856
+ return null;
857
+ }
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,
802
888
  });
803
- try {
804
- const normalized = await persistNormalizedWalletDescriptorStateIfNeeded({
805
- state,
806
- access: {
807
- provider,
808
- secretReference: createWalletSecretReference(state.walletRootId),
809
- },
810
- session,
811
- paths,
812
- nowUnixMs,
813
- replacePrimary: loaded.source === "backup",
814
- rpc: (options.rpcFactory ?? createRpcClient)(node.rpc),
815
- });
816
- state = normalized.state;
817
- session = normalized.session ?? session;
818
- source = normalized.changed ? "primary" : loaded.source;
819
- }
820
- finally {
821
- await node.stop?.().catch(() => undefined);
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;
822
898
  }
899
+ await clearWalletExplicitLock(paths.walletExplicitLockPath);
823
900
  }
824
- return {
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,
825
910
  session,
826
- state: {
827
- ...state,
828
- miningState: normalizeMiningStateRecord(state.miningState),
829
- },
830
- source,
831
- };
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();
832
922
  }
833
- catch {
834
- return null;
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();
835
932
  }
836
933
  }
837
934
  export async function initializeWallet(options) {
@@ -884,6 +981,7 @@ export async function initializeWallet(options) {
884
981
  });
885
982
  const verifiedState = await importDescriptorIntoManagedCoreWallet(initialState, provider, paths, options.dataDir, nowUnixMs, options.attachService, options.rpcFactory);
886
983
  const unlockUntilUnixMs = nowUnixMs + unlockDurationMs;
984
+ await clearWalletExplicitLock(paths.walletExplicitLockPath);
887
985
  await saveUnlockSession(paths.walletUnlockSessionPath, createUnlockSession(verifiedState, unlockUntilUnixMs, secretReference.keyId, nowUnixMs), {
888
986
  provider,
889
987
  secretReference,
@@ -917,6 +1015,7 @@ export async function unlockWallet(options = {}) {
917
1015
  });
918
1016
  const secretReference = createWalletSecretReference(loaded.state.walletRootId);
919
1017
  const unlockUntilUnixMs = nowUnixMs + unlockDurationMs;
1018
+ await clearWalletExplicitLock(paths.walletExplicitLockPath);
920
1019
  await saveUnlockSession(paths.walletUnlockSessionPath, createUnlockSession(loaded.state, unlockUntilUnixMs, secretReference.keyId, nowUnixMs), {
921
1020
  provider,
922
1021
  secretReference,
@@ -933,6 +1032,7 @@ export async function unlockWallet(options = {}) {
933
1032
  }
934
1033
  export async function lockWallet(options) {
935
1034
  const provider = options.provider ?? createDefaultWalletSecretProvider();
1035
+ const nowUnixMs = options.nowUnixMs ?? Date.now();
936
1036
  const paths = options.paths ?? resolveWalletRuntimePathsForTesting();
937
1037
  const controlLock = await acquireFileLock(paths.walletControlLockPath, {
938
1038
  purpose: "wallet-lock",
@@ -968,6 +1068,9 @@ export async function lockWallet(options) {
968
1068
  walletRootId = null;
969
1069
  }
970
1070
  await clearUnlockSession(paths.walletUnlockSessionPath);
1071
+ if (walletRootId !== null) {
1072
+ await saveWalletExplicitLock(paths.walletExplicitLockPath, createWalletExplicitLock(walletRootId, nowUnixMs));
1073
+ }
971
1074
  return {
972
1075
  walletRootId,
973
1076
  coreLocked,
@@ -989,10 +1092,11 @@ export async function exportWallet(options) {
989
1092
  walletRootId: null,
990
1093
  });
991
1094
  try {
992
- const unlocked = await loadUnlockedWalletState({
1095
+ const unlocked = await loadOrAutoUnlockWalletState({
993
1096
  provider,
994
1097
  nowUnixMs,
995
1098
  paths,
1099
+ controlLockHeld: true,
996
1100
  });
997
1101
  if (unlocked === null) {
998
1102
  throw new Error("wallet_locked");
@@ -1071,6 +1175,7 @@ export async function importWallet(options) {
1071
1175
  internalCoreWalletPassphrase: createInternalCoreWalletPassphrase(),
1072
1176
  });
1073
1177
  await clearUnlockSession(paths.walletUnlockSessionPath);
1178
+ await clearWalletExplicitLock(paths.walletExplicitLockPath);
1074
1179
  await saveWalletState({
1075
1180
  primaryPath: paths.walletStatePath,
1076
1181
  backupPath: paths.walletStateBackupPath,
@@ -1083,6 +1188,7 @@ export async function importWallet(options) {
1083
1188
  rpcFactory: options.rpcFactory,
1084
1189
  });
1085
1190
  const unlockUntilUnixMs = nowUnixMs + unlockDurationMs;
1191
+ await clearWalletExplicitLock(paths.walletExplicitLockPath);
1086
1192
  await saveUnlockSession(paths.walletUnlockSessionPath, createUnlockSession(importedState, unlockUntilUnixMs, secretReference.keyId, nowUnixMs), {
1087
1193
  provider,
1088
1194
  secretReference,
@@ -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
  });
@@ -8,12 +8,14 @@ declare function inspectWalletLocalState(options?: {
8
8
  secretProvider?: WalletSecretProvider;
9
9
  now?: number;
10
10
  paths?: WalletRuntimePaths;
11
+ walletControlLockHeld?: boolean;
11
12
  }): Promise<WalletLocalStateStatus>;
12
13
  export declare function openWalletReadContext(options: {
13
14
  dataDir: string;
14
15
  databasePath: string;
15
16
  walletStatePassphrase?: Uint8Array | string;
16
17
  secretProvider?: WalletSecretProvider;
18
+ walletControlLockHeld?: boolean;
17
19
  startupTimeoutMs?: number;
18
20
  now?: number;
19
21
  paths?: WalletRuntimePaths;
@@ -5,11 +5,12 @@ 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
9
  import { persistNormalizedWalletDescriptorStateIfNeeded } from "../descriptor-normalization.js";
10
10
  import { inspectMiningControlPlane } from "../mining/index.js";
11
11
  import { normalizeMiningStateRecord } from "../mining/state.js";
12
12
  import { resolveWalletRuntimePathsForTesting } from "../runtime.js";
13
+ import { loadWalletExplicitLock } from "../state/explicit-lock.js";
13
14
  import { loadWalletState } from "../state/storage.js";
14
15
  import { createDefaultWalletSecretProvider, createWalletSecretReference, } from "../state/provider.js";
15
16
  import { createWalletReadModel } from "./project.js";
@@ -24,6 +25,30 @@ async function pathExists(path) {
24
25
  return false;
25
26
  }
26
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
+ }
27
52
  async function normalizeLoadedWalletStateForRead(options) {
28
53
  if (options.dataDir === undefined) {
29
54
  return options.loaded;
@@ -82,13 +107,16 @@ async function inspectWalletLocalState(options = {}) {
82
107
  if (options.passphrase === undefined) {
83
108
  try {
84
109
  const provider = options.secretProvider ?? createDefaultWalletSecretProvider();
85
- const unlocked = await loadUnlockedWalletState({
110
+ const unlocked = await loadOrAutoUnlockWalletState({
86
111
  provider,
87
112
  nowUnixMs: now,
88
113
  paths,
89
114
  dataDir: options.dataDir,
115
+ controlLockHeld: options.walletControlLockHeld,
90
116
  });
91
117
  if (unlocked === null) {
118
+ const explicitLock = await loadWalletExplicitLock(paths.walletExplicitLockPath);
119
+ const hasUnlockSessionFileNow = await pathExists(paths.walletUnlockSessionPath);
92
120
  try {
93
121
  const loaded = await loadWalletState({
94
122
  primaryPath: paths.walletStatePath,
@@ -103,8 +131,39 @@ async function inspectWalletLocalState(options = {}) {
103
131
  now,
104
132
  paths,
105
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
+ };
106
148
  }
107
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
+ }
108
167
  return {
109
168
  availability: "local-state-corrupt",
110
169
  walletRootId: null,
@@ -113,23 +172,10 @@ async function inspectWalletLocalState(options = {}) {
113
172
  unlockUntilUnixMs: null,
114
173
  hasPrimaryStateFile,
115
174
  hasBackupStateFile,
116
- hasUnlockSessionFile,
175
+ hasUnlockSessionFile: hasUnlockSessionFileNow,
117
176
  message: error instanceof Error ? error.message : String(error),
118
177
  };
119
178
  }
120
- return {
121
- availability: "locked",
122
- walletRootId: null,
123
- state: null,
124
- source: null,
125
- unlockUntilUnixMs: null,
126
- hasPrimaryStateFile,
127
- hasBackupStateFile,
128
- hasUnlockSessionFile,
129
- message: hasUnlockSessionFile
130
- ? "Wallet state exists but the unlock session is expired, invalid, or belongs to a different wallet root."
131
- : "Wallet state exists but is currently locked.",
132
- };
133
179
  }
134
180
  return {
135
181
  availability: "ready",
@@ -142,7 +188,7 @@ async function inspectWalletLocalState(options = {}) {
142
188
  unlockUntilUnixMs: unlocked.session.unlockUntilUnixMs,
143
189
  hasPrimaryStateFile,
144
190
  hasBackupStateFile,
145
- hasUnlockSessionFile,
191
+ hasUnlockSessionFile: true,
146
192
  message: null,
147
193
  };
148
194
  }
@@ -460,6 +506,7 @@ export async function openWalletReadContext(options) {
460
506
  dataDir: options.dataDir,
461
507
  passphrase: options.walletStatePassphrase,
462
508
  secretProvider: options.secretProvider,
509
+ walletControlLockHeld: options.walletControlLockHeld,
463
510
  now,
464
511
  paths: options.paths,
465
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,
@@ -0,0 +1,4 @@
1
+ import type { WalletExplicitLockStateV1 } from "../types.js";
2
+ export declare function loadWalletExplicitLock(lockPath: string): Promise<WalletExplicitLockStateV1 | null>;
3
+ export declare function saveWalletExplicitLock(lockPath: string, state: WalletExplicitLockStateV1): Promise<void>;
4
+ export declare function clearWalletExplicitLock(lockPath: string): Promise<void>;
@@ -0,0 +1,19 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { writeJsonFileAtomic } from "../fs/atomic.js";
3
+ export async function loadWalletExplicitLock(lockPath) {
4
+ try {
5
+ return JSON.parse(await readFile(lockPath, "utf8"));
6
+ }
7
+ catch (error) {
8
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
9
+ return null;
10
+ }
11
+ throw error;
12
+ }
13
+ }
14
+ export async function saveWalletExplicitLock(lockPath, state) {
15
+ await writeJsonFileAtomic(lockPath, state, { mode: 0o600 });
16
+ }
17
+ export async function clearWalletExplicitLock(lockPath) {
18
+ await rm(lockPath, { force: true });
19
+ }
@@ -996,6 +996,7 @@ export async function anchorDomain(options) {
996
996
  dataDir: options.dataDir,
997
997
  databasePath: options.databasePath,
998
998
  secretProvider: provider,
999
+ walletControlLockHeld: true,
999
1000
  paths,
1000
1001
  });
1001
1002
  try {
@@ -623,6 +623,7 @@ export async function sendCog(options) {
623
623
  dataDir: options.dataDir,
624
624
  databasePath: options.databasePath,
625
625
  secretProvider: provider,
626
+ walletControlLockHeld: true,
626
627
  paths,
627
628
  });
628
629
  try {
@@ -763,6 +764,7 @@ export async function lockCogToDomain(options) {
763
764
  dataDir: options.dataDir,
764
765
  databasePath: options.databasePath,
765
766
  secretProvider: provider,
767
+ walletControlLockHeld: true,
766
768
  paths,
767
769
  });
768
770
  try {
@@ -918,6 +920,7 @@ async function runClaimLikeMutation(options, reclaim) {
918
920
  dataDir: options.dataDir,
919
921
  databasePath: options.databasePath,
920
922
  secretProvider: provider,
923
+ walletControlLockHeld: true,
921
924
  paths,
922
925
  });
923
926
  try {
@@ -557,6 +557,7 @@ async function submitDomainAdminMutation(options) {
557
557
  dataDir: options.dataDir,
558
558
  databasePath: options.databasePath,
559
559
  secretProvider: provider,
560
+ walletControlLockHeld: true,
560
561
  paths,
561
562
  });
562
563
  try {
@@ -683,6 +683,7 @@ export async function transferDomain(options) {
683
683
  dataDir: options.dataDir,
684
684
  databasePath: options.databasePath,
685
685
  secretProvider: provider,
686
+ walletControlLockHeld: true,
686
687
  paths,
687
688
  });
688
689
  try {
@@ -916,6 +917,7 @@ async function runSellMutation(options) {
916
917
  dataDir: options.dataDir,
917
918
  databasePath: options.databasePath,
918
919
  secretProvider: provider,
920
+ walletControlLockHeld: true,
919
921
  paths,
920
922
  });
921
923
  try {
@@ -1143,6 +1145,7 @@ export async function buyDomain(options) {
1143
1145
  dataDir: options.dataDir,
1144
1146
  databasePath: options.databasePath,
1145
1147
  secretProvider: provider,
1148
+ walletControlLockHeld: true,
1146
1149
  paths,
1147
1150
  });
1148
1151
  try {
@@ -1267,6 +1267,7 @@ async function submitStandaloneFieldMutation(options) {
1267
1267
  dataDir: options.dataDir,
1268
1268
  databasePath: options.databasePath,
1269
1269
  secretProvider: provider,
1270
+ walletControlLockHeld: true,
1270
1271
  paths,
1271
1272
  });
1272
1273
  try {
@@ -1418,6 +1419,7 @@ async function submitFieldCreateFamily(options) {
1418
1419
  dataDir: options.dataDir,
1419
1420
  databasePath: options.databasePath,
1420
1421
  secretProvider: provider,
1422
+ walletControlLockHeld: true,
1421
1423
  paths,
1422
1424
  });
1423
1425
  try {
@@ -686,6 +686,7 @@ export async function registerDomain(options) {
686
686
  dataDir: options.dataDir,
687
687
  databasePath: options.databasePath,
688
688
  secretProvider: provider,
689
+ walletControlLockHeld: true,
689
690
  paths,
690
691
  });
691
692
  try {
@@ -528,6 +528,7 @@ async function submitReputationMutation(options) {
528
528
  dataDir: options.dataDir,
529
529
  databasePath: options.databasePath,
530
530
  secretProvider: provider,
531
+ walletControlLockHeld: true,
531
532
  paths,
532
533
  });
533
534
  try {
@@ -247,3 +247,8 @@ export interface UnlockSessionStateV1 {
247
247
  sourceStateRevision: number;
248
248
  wrappedSessionKeyMaterial: string;
249
249
  }
250
+ export interface WalletExplicitLockStateV1 {
251
+ schemaVersion: 1;
252
+ walletRootId: string;
253
+ lockedAtUnixMs: number;
254
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cogcoin/client",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Store-backed Cogcoin client with wallet flows, SQLite persistence, and managed Bitcoin Core integration.",
5
5
  "license": "MIT",
6
6
  "type": "module",