@attocash/cli 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -689,6 +689,45 @@ mnemonic is lost from the password store, recover it from your private backup
689
689
  using `atto wallet import` in an uninitialized profile. Preserve the old public
690
690
  state and pending-send records for reconciliation before resuming payments.
691
691
 
692
+ ## Background receiving and work preparation
693
+
694
+ `atto wallet receive` keeps automatic receiving in the current terminal. To
695
+ keep receiving after the terminal exits, use:
696
+
697
+ ```sh
698
+ atto wallet receive --background
699
+ atto wallet receive status
700
+ atto wallet receive stop
701
+ ```
702
+
703
+ The background receiver uses the same profile selected by `--data-dir`, the OS
704
+ password store, active addresses, minimum receive amount, and automatic
705
+ receiving setting. It does not install a service, does not start after reboot,
706
+ and does not grant MCP spending access. `wallet status` reports its separate
707
+ state and latest operational error. Stop it before resetting a profile.
708
+
709
+ All three commands accept `--data-dir <directory>` and `--json`. Starting or
710
+ stopping repeatedly is safe. Start acknowledges local initialization; it does
711
+ not promise that the node or password store is reachable. The detached process
712
+ inherits your login environment and needs access to the OS password store. On
713
+ Linux, keep the Secret Service session available; on macOS and Windows, allow
714
+ the CLI to access its credential. Check `wallet receive status` for retry errors.
715
+ No recovery phrase is stored in the public profile or passed on the command line.
716
+
717
+ Stop prevents new receives and reports `stopping` until the current operation
718
+ finishes. A crashed receiver reports `stopped`; start it explicitly again after
719
+ a crash, reboot, or restoring a backup. Foreground and MCP receivers may coexist,
720
+ but stopping this background receiver does not stop those separate sessions.
721
+
722
+ After a completed send, receive, representative change, or approved pool step,
723
+ the CLI may finish public proof-of-work preparation in a short detached process.
724
+ This contains only public account heads and worker settings, runs for at most a
725
+ minute, and caches usable work for a later transaction. A changed account head
726
+ or an immediate transaction can still require fresh work.
727
+ The worker exits when its queue is drained, keeps at most two speculative
728
+ requests active, and leaves failed or unfinished jobs eligible for a later CLI
729
+ invocation. Reset cancels queued preparation before clearing the profile.
730
+
692
731
  ## Update notices
693
732
 
694
733
  Interactive CLI commands can show a notice on stderr when a newer stable CLI
@@ -4,6 +4,7 @@ import type { SendRetry } from '../network/retry.js';
4
4
  import { type SecretStore } from '../storage/secrets.js';
5
5
  import { StateStore } from '../storage/state.js';
6
6
  import { SpendLedger, type McpAccess } from '../spending/ledger.js';
7
+ import { type WorkExecution } from '../wallet/work.js';
7
8
  import { type ReceiveProgress } from '../wallet/auto-receive.js';
8
9
  import type { AccountPool, SpendingPolicy, WalletIdentity } from '../wallet/types.js';
9
10
  import { MarketData } from '../pricing/market.js';
@@ -16,6 +17,10 @@ export declare class AttoApplication {
16
17
  private readonly labels;
17
18
  private readonly auto;
18
19
  private readonly work;
20
+ private readonly background;
21
+ private readonly receivingAllowed;
22
+ private preparation?;
23
+ private readonly preparationAbort;
19
24
  private readonly payments;
20
25
  private watchManager?;
21
26
  private watchConfiguration;
@@ -36,6 +41,8 @@ export declare class AttoApplication {
36
41
  sendRetry?: SendRetry;
37
42
  globalDirectory?: GlobalDirectory;
38
43
  onDestination?: (binding: DestinationBinding) => void;
44
+ workExecution?: WorkExecution;
45
+ receivingAllowed?: () => boolean;
39
46
  });
40
47
  private settings;
41
48
  private addresses;
@@ -133,6 +140,10 @@ export declare class AttoApplication {
133
140
  private invoke;
134
141
  call(name: string, input?: Record<string, unknown>): Promise<unknown>;
135
142
  start(): Promise<void>;
143
+ /** Terminal-only detached receiver control; it never changes MCP approval. */
144
+ startBackgroundReceiver(): Promise<import("../wallet/background-receive.js").BackgroundReceiveStatus>;
145
+ stopBackgroundReceiver(): Promise<import("../wallet/background-receive.js").BackgroundReceiveStatus>;
146
+ resumeWork(): void;
136
147
  private preparePoolWork;
137
148
  close(): Promise<void>;
138
149
  }
@@ -12,6 +12,7 @@ import { resolveWalletProfile } from '../storage/profiles.js';
12
12
  import { SpendLedger } from '../spending/ledger.js';
13
13
  import { Payments } from '../spending/payments.js';
14
14
  import { WalletWork } from '../wallet/work.js';
15
+ import { BackgroundReceiver } from '../wallet/background-receive.js';
15
16
  import { WatchManager } from '../watches/manager.js';
16
17
  import { AutoReceiver } from '../wallet/auto-receive.js';
17
18
  import { defaultSettings } from '../wallet/defaults.js';
@@ -30,6 +31,10 @@ export class AttoApplication {
30
31
  labels;
31
32
  auto;
32
33
  work;
34
+ background;
35
+ receivingAllowed;
36
+ preparation;
37
+ preparationAbort = new AbortController();
33
38
  payments;
34
39
  watchManager;
35
40
  watchConfiguration = '';
@@ -55,13 +60,15 @@ export class AttoApplication {
55
60
  if (!this.store.get('settings'))
56
61
  this.store.set('settings', defaultSettings());
57
62
  });
58
- this.work = new WalletWork(this.store, () => this.settings());
63
+ this.work = new WalletWork(this.store, () => this.settings(), options.workExecution);
64
+ this.background = new BackgroundReceiver(this.store);
65
+ this.receivingAllowed = options.receivingAllowed ?? (() => true);
59
66
  this.payments = new Payments(this.store, this.ledger, {
60
67
  settings: () => this.settings(), addresses: () => this.addresses(), seed: () => this.seed(),
61
68
  requireWrite: () => this.requireMcpWrite(), mcp: this.mcp,
62
69
  }, this.market, this.work, options.sendRetry, options.onDestination);
63
70
  this.auto = new AutoReceiver(() => ({
64
- settings: this.mcp && this.ledger.mcpAccess() !== 'spend' ? { ...this.settings(), autoReceive: false } : this.settings(),
71
+ settings: !this.receivingAllowed() || (this.mcp && this.ledger.mcpAccess() !== 'spend') ? { ...this.settings(), autoReceive: false } : this.settings(),
65
72
  addresses: this.addresses(),
66
73
  }), (index, hash) => this.receive({ index, hash }, true), event => options.onReceiveProgress?.(this.labels.decorate(event, this.settings().network)));
67
74
  }
@@ -178,6 +185,8 @@ export class AttoApplication {
178
185
  this.requireSession();
179
186
  if (this.mcp)
180
187
  throw new AttoError('LOCAL_APPROVAL_REQUIRED', 'Wallet reset requires approval in a local terminal.');
188
+ if (this.background.status().state !== 'stopped')
189
+ throw new AttoError('WALLET_BUSY', 'Stop the background receiver before resetting this wallet.');
181
190
  if (this.started || this.calls.size || this.recoveryReads || this.store.busy
182
191
  || this.watchManager?.list().some(watch => ['running', 'reconnecting'].includes(watch.status))) {
183
192
  throw new AttoError('WALLET_BUSY', 'Stop active wallet operations and reset from a new terminal command.');
@@ -202,7 +211,7 @@ export class AttoApplication {
202
211
  try {
203
212
  // A previous completed send may still have speculative public work queued.
204
213
  // Stop it before clearing state; normal calls cannot start during reset.
205
- await this.work.close();
214
+ await this.work.cancel();
206
215
  await this.store.withExclusiveReset(() => this.store.withWalletLock(async () => {
207
216
  if ((this.identity()?.fingerprint ?? null) !== expectedFingerprint) {
208
217
  throw new AttoError('WALLET_CHANGED', 'The wallet changed after confirmation. Review it before resetting again.');
@@ -353,6 +362,8 @@ export class AttoApplication {
353
362
  return found;
354
363
  }
355
364
  async receive(request, automatic = false) {
365
+ if (automatic && !this.receivingAllowed())
366
+ throw new AttoError('AUTO_RECEIVE_DISABLED', 'Automatic receiving is stopping.');
356
367
  const index = request.index ?? 0;
357
368
  const operation = async () => {
358
369
  await this.store.withWalletLock(async () => {
@@ -374,6 +385,7 @@ export class AttoApplication {
374
385
  if (transaction && transaction.hash.toString() === prior.blockHash && await transaction.isValid()) {
375
386
  const result = this.result(transaction, 'received');
376
387
  this.store.set(key, { ...prior, result });
388
+ this.work.prepareConfirmed(transaction.block);
377
389
  return result;
378
390
  }
379
391
  // Refresh pending state below: an unconsumed send permits retrying receive
@@ -403,9 +415,7 @@ export class AttoApplication {
403
415
  const transaction = await wallet.receive(receivable, parseAddress(request.representative ?? this.settings().representative), null);
404
416
  const result = { ...this.result(transaction, 'received'), index, amount: amountOutput(receivable.amount.toString()) };
405
417
  this.store.set(key, { hash, index, blockHash: transaction.hash.toString(), result });
406
- const account = await wallet.getAccountByIndex(toAttoIndex(index));
407
- if (account)
408
- this.work.prepare([account]);
418
+ this.work.prepareConfirmed(transaction.block);
409
419
  return result;
410
420
  }
411
421
  finally {
@@ -455,9 +465,7 @@ export class AttoApplication {
455
465
  }, this.work.worker());
456
466
  wallet = execution.wallet;
457
467
  const transaction = await wallet.change(toAttoIndex(index), parseAddress(representative), null);
458
- const account = await wallet.getAccountByIndex(toAttoIndex(index));
459
- if (account)
460
- this.work.prepare([account]);
468
+ this.work.prepareConfirmed(transaction.block);
461
469
  return this.result(transaction, 'representative_changed');
462
470
  }
463
471
  finally {
@@ -486,7 +494,7 @@ export class AttoApplication {
486
494
  const reader = this.reader();
487
495
  switch (name) {
488
496
  case 'doctor': return runDoctor({ directory: this.store.directory, access: this.mcp ? 'mcp' : undefined, signal: this.doctorAbort.signal, globalDirectory: args.globalDirectory });
489
- case 'wallet_status': return { directory: this.store.directory, initialized: Boolean(this.identity()), identity: this.identity() ?? null, settings: this.settings(), addresses: this.addresses(), autoReceive: this.auto.status(), mcpAccess: this.ledger.mcpAccess(), pool: this.ledger.pool(), pendingSends: this.ledger.pending().map(({ id, hash, status }) => ({ requestId: id, hash, status })), resetPending: Boolean(this.store.get(RESET_KEY)) };
497
+ case 'wallet_status': return { directory: this.store.directory, initialized: Boolean(this.identity()), identity: this.identity() ?? null, settings: this.settings(), addresses: this.addresses(), autoReceive: this.auto.status(), backgroundReceive: this.background.status(), mcpAccess: this.ledger.mcpAccess(), pool: this.ledger.pool(), pendingSends: this.ledger.pending().map(({ id, hash, status }) => ({ requestId: id, hash, status })), resetPending: Boolean(this.store.get(RESET_KEY)) };
490
498
  case 'wallet_configure': return this.configure(args);
491
499
  case 'address_list': return { addresses: this.addresses() };
492
500
  case 'address_add': return this.store.withWalletLock(async () => {
@@ -628,14 +636,29 @@ export class AttoApplication {
628
636
  this.requireReady();
629
637
  this.started = true;
630
638
  this.auto.start();
631
- await this.preparePoolWork();
639
+ this.preparation ??= this.preparePoolWork();
640
+ await this.preparation;
632
641
  }
642
+ /** Terminal-only detached receiver control; it never changes MCP approval. */
643
+ async startBackgroundReceiver() {
644
+ this.requireReady();
645
+ if (this.mcp)
646
+ throw new AttoError('LOCAL_APPROVAL_REQUIRED', 'Start background receiving through the CLI.');
647
+ return this.background.start();
648
+ }
649
+ async stopBackgroundReceiver() {
650
+ this.requireSession();
651
+ if (this.mcp)
652
+ throw new AttoError('LOCAL_APPROVAL_REQUIRED', 'Stop background receiving through the CLI.');
653
+ return this.background.stop();
654
+ }
655
+ resumeWork() { this.work.resume(); }
633
656
  async preparePoolWork() {
634
657
  if (this.mcp && this.ledger.mcpAccess() !== 'spend')
635
658
  return;
636
659
  const indexes = this.ledger.pool().indexes;
637
660
  await Promise.allSettled(this.addresses().filter(address => indexes.includes(address.index)).map(async (address) => {
638
- const account = await this.reader().account(address.address);
661
+ const account = await this.reader().account(address.address, this.preparationAbort.signal);
639
662
  if (account && !this.closed)
640
663
  this.work.prepare([account]);
641
664
  }));
@@ -645,7 +668,9 @@ export class AttoApplication {
645
668
  return;
646
669
  this.closed = true;
647
670
  this.doctorAbort.abort();
671
+ this.preparationAbort.abort();
648
672
  await this.auto.close();
673
+ await this.preparation;
649
674
  await Promise.allSettled([...this.calls]);
650
675
  await this.watchManager?.close();
651
676
  await this.work.close();
package/dist/cli/cli.js CHANGED
@@ -53,7 +53,7 @@ export async function runCli(argv = process.argv) {
53
53
  const currentVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
54
54
  let application;
55
55
  let sendRequestId;
56
- const app = (onReceiveProgress, sendRetry, onDestination) => application ??= new AttoApplication({ directory: program.opts().dataDir, onReceiveProgress, sendRetry, onDestination });
56
+ const app = (onReceiveProgress, sendRetry, onDestination) => application ??= new AttoApplication({ directory: program.opts().dataDir, onReceiveProgress, sendRetry, onDestination, workExecution: onReceiveProgress ? 'in-process' : 'detached' });
57
57
  const jsonOutput = (value) => { process.stdout.write(`${JSON.stringify(value, (_, value) => typeof value === 'bigint' ? value.toString() : value)}\n`); };
58
58
  const output = (result, operation) => {
59
59
  if (program.opts().json)
@@ -67,6 +67,7 @@ export async function runCli(argv = process.argv) {
67
67
  return diagnose(input);
68
68
  }
69
69
  const result = await app().call(name, compact(input));
70
+ app().resumeWork();
70
71
  output(result, name);
71
72
  };
72
73
  const waitForSignal = async (work) => {
@@ -166,8 +167,14 @@ export async function runCli(argv = process.argv) {
166
167
  if (program.opts().json)
167
168
  output({ displayed: true });
168
169
  });
169
- wallet.command('receive').description('Keep automatic receiving running until Ctrl+C')
170
+ const receiveCommand = wallet.command('receive').description('Keep automatic receiving running until Ctrl+C')
171
+ .option('--background', 'Keep receiving after this terminal exits; restart manually after reboot')
170
172
  .action(async () => {
173
+ if (activeCommand.opts().background) {
174
+ const status = await app().startBackgroundReceiver();
175
+ output({ backgroundReceive: status }, 'wallet_receive');
176
+ return;
177
+ }
171
178
  const receiving = app(event => output(event, 'receive_progress'));
172
179
  const status = await receiving.call('wallet_status');
173
180
  if (!status.initialized)
@@ -183,6 +190,8 @@ export async function runCli(argv = process.argv) {
183
190
  await new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true }));
184
191
  });
185
192
  });
193
+ receiveCommand.command('status').description('Read detached receiver status').action(() => call('wallet_status'));
194
+ receiveCommand.command('stop').description('Request detached receiver shutdown').action(async () => output({ backgroundReceive: await app().stopBackgroundReceiver() }, 'wallet_receive'));
186
195
  const address = program.command('address').description('Manage mnemonic-derived public addresses');
187
196
  address.command('list').description('List all saved addresses and their activation state').action(() => call('address_list'));
188
197
  address.command('add').description('Add and activate the next account address; receiving funds opens it on the network').action(() => call('address_add'));
@@ -254,6 +263,7 @@ export async function runCli(argv = process.argv) {
254
263
  signal,
255
264
  onRetry: (error, delayMs) => progress({ requestId: sendRequestId, error: errorResult(error), retryInMs: delayMs }, `${error.message} Retrying in ${delayMs / 1000}s. Press Ctrl+C to stop.\n`),
256
265
  }, binding => progress({ requestId: sendRequestId, destination: binding.address, network: binding.network, ...(binding.label ? { personalName: binding.label } : {}) })).call('send', input);
266
+ app().resumeWork();
257
267
  output(result, 'send');
258
268
  });
259
269
  });
@@ -386,6 +396,7 @@ export async function runCli(argv = process.argv) {
386
396
  'atto wallet configure': 'atto wallet configure --node-url https://node-public.live.application.atto.cash --auto-receive',
387
397
  'atto wallet create': 'atto wallet create', 'atto wallet import': 'atto wallet import',
388
398
  'atto wallet reset': 'atto wallet reset', 'atto wallet backup': 'atto wallet backup', 'atto wallet receive': 'atto wallet receive',
399
+ 'atto wallet receive status': 'atto wallet receive status', 'atto wallet receive stop': 'atto wallet receive stop',
389
400
  'atto address': 'atto address add\n atto address list', 'atto address list': 'atto address list',
390
401
  'atto address add': 'atto address add', 'atto address derive': 'atto address derive 3',
391
402
  'atto address activate': 'atto address activate 1', 'atto address deactivate': 'atto address deactivate 1',
@@ -137,7 +137,7 @@ function formatUnlabeledHumanResult(result, operation) {
137
137
  address: record(result.identity) ? result.identity.address : null,
138
138
  network: result.settings.network,
139
139
  automaticReceiving: result.settings.autoReceive ? 'Enabled' : 'Disabled',
140
- ...(operation === 'wallet_status' ? { receivingInThisProcess: receiver.running, lastReceiveError: receiver.lastError } : {}),
140
+ ...(operation === 'wallet_status' ? { receivingInThisProcess: receiver.running, lastReceiveError: receiver.lastError, backgroundReceiver: result.backgroundReceive } : {}),
141
141
  representative: result.settings.representative,
142
142
  minReceiveRaw: result.settings.minReceiveRaw,
143
143
  nodeUrl: result.settings.nodeUrl,
@@ -333,15 +333,11 @@ export class Payments {
333
333
  throw new AttoError('RECEIVABLE_NOT_PENDING', 'The consolidation transfer is not available to receive yet. Resume this payment with the same request ID.');
334
334
  }
335
335
  const transaction = await execution.wallet.receive(receivable, parseAddress(settings.representative), null);
336
- const account = await execution.wallet.getAccountByIndex(toAttoIndex(index));
337
- if (account)
338
- this.work.prepare([account]);
336
+ this.work.prepareConfirmed(transaction.block);
339
337
  return transaction;
340
338
  }
341
339
  const transaction = await execution.wallet.sendByIndex(toAttoIndex(index), parseAddress(step?.destination ?? record.destination), AttoAmount.from(AttoUnit.RAW, step?.raw ?? record.raw), null);
342
- const account = await execution.wallet.getAccountByIndex(toAttoIndex(index));
343
- if (account)
344
- this.work.prepare([account]);
340
+ this.work.prepareConfirmed(transaction.block);
345
341
  return transaction;
346
342
  }
347
343
  finally {
@@ -390,6 +386,7 @@ export class Payments {
390
386
  });
391
387
  if (outcome.status === 'rejected')
392
388
  return;
389
+ this.work.prepareConfirmed(outcome.transaction.block);
393
390
  }
394
391
  if (!record.hash) {
395
392
  if (!record.plan)
@@ -403,6 +400,8 @@ export class Payments {
403
400
  else if (outcome.status === 'rejected')
404
401
  this.ledger.reject(record.id);
405
402
  });
403
+ if (outcome.status === 'published')
404
+ this.work.prepareConfirmed(outcome.transaction.block);
406
405
  }
407
406
  async poolStatus() {
408
407
  const pool = this.ledger.pool();
@@ -9,6 +9,7 @@ export declare class StateStore {
9
9
  private closed;
10
10
  private lockRequests;
11
11
  private exclusiveReset;
12
+ private auxiliaryLocks;
12
13
  get busy(): boolean;
13
14
  constructor(directory: string);
14
15
  get<T>(key: string): T | undefined;
@@ -28,5 +29,11 @@ export declare class StateStore {
28
29
  /** Acquire account locks first; short wallet reservations may run inside fn.
29
30
  * Closing the connection (including process death) releases every lock. */
30
31
  withAccountLocks<T>(indexes: readonly number[], fn: () => Promise<T>): Promise<T>;
32
+ /** A process lifetime lock. Unlike the wallet mutation lock this has no
33
+ * bearing on ordinary commands, and SQLite releases it if the owner dies. */
34
+ tryProcessLock(name: string): (() => void) | undefined;
35
+ /** Computation locks never share the wallet/account mutation databases. */
36
+ tryWorkLock(target: string): (() => void) | undefined;
37
+ private tryAuxiliaryLock;
31
38
  close(): void;
32
39
  }
@@ -4,6 +4,20 @@ import { join, resolve } from 'node:path';
4
4
  import { DatabaseSync } from 'node:sqlite';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { AttoError } from '../domain/errors.js';
7
+ import { createHash } from 'node:crypto';
8
+ function ensureDatabaseFile(path) {
9
+ // Closing any raw descriptor for an existing SQLite file drops this process's
10
+ // POSIX locks, even when a different connection still owns the lease.
11
+ try {
12
+ closeSync(openSync(path, 'ax', 0o600));
13
+ }
14
+ catch (error) {
15
+ if (error.code !== 'EEXIST')
16
+ throw error;
17
+ }
18
+ if (process.platform !== 'win32')
19
+ chmodSync(path, 0o600);
20
+ }
7
21
  export function defaultDataDirectory() {
8
22
  if (process.platform === 'win32')
9
23
  return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'Atto MCP');
@@ -21,6 +35,7 @@ export class StateStore {
21
35
  closed = false;
22
36
  lockRequests = 0;
23
37
  exclusiveReset = false;
38
+ auxiliaryLocks = 0;
24
39
  get busy() { return this.lockRequests !== 0 || this.exclusiveReset; }
25
40
  constructor(directory) {
26
41
  this.directory = resolve(directory);
@@ -28,9 +43,7 @@ export class StateStore {
28
43
  const connections = [];
29
44
  const open = (name) => {
30
45
  const path = join(this.directory, name);
31
- closeSync(openSync(path, 'a', 0o600));
32
- if (process.platform !== 'win32')
33
- chmodSync(path, 0o600);
46
+ ensureDatabaseFile(path);
34
47
  const connection = new DatabaseSync(path);
35
48
  connections.push(connection);
36
49
  return connection;
@@ -93,7 +106,7 @@ export class StateStore {
93
106
  * store closes, even if another session prevents acquiring exclusivity. */
94
107
  async withExclusiveReset(fn) {
95
108
  this.requireOpen();
96
- if (this.lockRequests !== 0 || this.exclusiveReset)
109
+ if (this.lockRequests !== 0 || this.auxiliaryLocks !== 0 || this.exclusiveReset)
97
110
  throw new AttoError('WALLET_BUSY', 'Wait for wallet operations before resetting this profile.');
98
111
  try {
99
112
  this.lifecycle.exec('ROLLBACK;');
@@ -192,9 +205,7 @@ export class StateStore {
192
205
  // Sorted acquisition prevents cycles when payments consolidate accounts.
193
206
  for (const index of [...new Set(indexes)].sort((left, right) => left - right)) {
194
207
  const file = join(directory, `${index}.sqlite`);
195
- closeSync(openSync(file, 'a', 0o600));
196
- if (process.platform !== 'win32')
197
- chmodSync(file, 0o600);
208
+ ensureDatabaseFile(file);
198
209
  const connection = new DatabaseSync(file);
199
210
  connections.push(connection);
200
211
  connection.exec('PRAGMA busy_timeout = 0; BEGIN IMMEDIATE;');
@@ -242,10 +253,52 @@ export class StateStore {
242
253
  }
243
254
  }
244
255
  }
256
+ /** A process lifetime lock. Unlike the wallet mutation lock this has no
257
+ * bearing on ordinary commands, and SQLite releases it if the owner dies. */
258
+ tryProcessLock(name) {
259
+ if (!/^[a-z0-9-]{1,64}$/i.test(name))
260
+ throw new AttoError('INVALID_LOCK', 'Invalid process lock name.');
261
+ return this.tryAuxiliaryLock('process-locks', name);
262
+ }
263
+ /** Computation locks never share the wallet/account mutation databases. */
264
+ tryWorkLock(target) {
265
+ return this.tryAuxiliaryLock('work-locks', createHash('sha256').update(target).digest('hex'));
266
+ }
267
+ tryAuxiliaryLock(folder, name) {
268
+ this.requireOpen();
269
+ const directory = join(this.directory, folder);
270
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
271
+ const file = join(directory, `${name}.sqlite`);
272
+ ensureDatabaseFile(file);
273
+ const connection = new DatabaseSync(file);
274
+ try {
275
+ connection.exec('PRAGMA busy_timeout = 0; BEGIN IMMEDIATE;');
276
+ }
277
+ catch (error) {
278
+ connection.close();
279
+ const code = error.errcode;
280
+ if (code === 5 || code === 6)
281
+ return undefined;
282
+ throw error;
283
+ }
284
+ this.auxiliaryLocks++;
285
+ let held = true;
286
+ return () => {
287
+ if (!held)
288
+ return;
289
+ held = false;
290
+ try {
291
+ connection.close();
292
+ }
293
+ finally {
294
+ this.auxiliaryLocks--;
295
+ }
296
+ };
297
+ }
245
298
  close() {
246
299
  if (this.closed)
247
300
  return;
248
- if (this.lockRequests !== 0)
301
+ if (this.lockRequests !== 0 || this.auxiliaryLocks !== 0)
249
302
  throw new AttoError('WALLET_BUSY', 'Wait for wallet operations before closing state.');
250
303
  this.coordination.close();
251
304
  this.state.close();
@@ -0,0 +1,30 @@
1
+ import { errorResult } from '../domain/errors.js';
2
+ import type { StateStore } from '../storage/state.js';
3
+ import type { ReceiveProgress } from './auto-receive.js';
4
+ interface ReceiverRecord {
5
+ token: string;
6
+ desired: boolean;
7
+ state: 'starting' | 'running' | 'stopping' | 'stopped';
8
+ lastError: ReturnType<typeof errorResult> | null;
9
+ }
10
+ export interface BackgroundReceiveStatus {
11
+ state: 'running' | 'stopping' | 'stopped';
12
+ lastError: ReturnType<typeof errorResult> | null;
13
+ }
14
+ export declare function requireReceivingProfile(store: StateStore): void;
15
+ /** Profile control and observable process ownership. Persisted intent alone
16
+ * cannot make a receiver live or restart it after restoring a backup. */
17
+ export declare class BackgroundReceiver {
18
+ private readonly store;
19
+ constructor(store: StateStore);
20
+ status(): BackgroundReceiveStatus;
21
+ start(): Promise<BackgroundReceiveStatus>;
22
+ stop(): Promise<BackgroundReceiveStatus>;
23
+ private launch;
24
+ /** A stale launch token cannot restart a stopped/reset/restored profile. */
25
+ claim(token: string): (() => void) | undefined;
26
+ stopping(token: string): boolean;
27
+ update(token: string, update: Partial<Omit<ReceiverRecord, 'token'>>): void;
28
+ progress(token: string, event: ReceiveProgress): void;
29
+ }
30
+ export {};
@@ -0,0 +1,117 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { AttoError, errorResult } from '../domain/errors.js';
5
+ const KEY = 'receive.background';
6
+ export function requireReceivingProfile(store) {
7
+ if (store.get('wallet.reset'))
8
+ throw new AttoError('WALLET_RESET_REQUIRED', 'Finish wallet reset before starting receiving.');
9
+ if (!store.get('identity'))
10
+ throw new AttoError('WALLET_NOT_INITIALIZED', 'Create or import a wallet before receiving: atto wallet create or atto wallet import.');
11
+ if (!store.get('settings')?.autoReceive)
12
+ throw new AttoError('AUTO_RECEIVE_DISABLED', 'Automatic receiving is disabled. Enable it with atto wallet configure --auto-receive.');
13
+ if (!store.get('addresses')?.some(address => address.active))
14
+ throw new AttoError('NO_ACTIVE_ACCOUNTS', 'Add or activate a wallet address before receiving.');
15
+ }
16
+ /** Profile control and observable process ownership. Persisted intent alone
17
+ * cannot make a receiver live or restart it after restoring a backup. */
18
+ export class BackgroundReceiver {
19
+ store;
20
+ constructor(store) {
21
+ this.store = store;
22
+ }
23
+ status() {
24
+ const record = this.store.get(KEY);
25
+ if (!record)
26
+ return { state: 'stopped', lastError: null };
27
+ const release = this.store.tryProcessLock('receive-daemon');
28
+ const live = !release;
29
+ release?.();
30
+ const lastError = record.lastError ?? (!live && record.state !== 'stopped'
31
+ ? errorResult(new AttoError('RECEIVER_EXITED', 'The background receiver exited. Start it again with atto wallet receive --background.')) : null);
32
+ return { state: live ? record.desired ? 'running' : 'stopping' : 'stopped', lastError };
33
+ }
34
+ async start() {
35
+ // Serialize launch/stop decisions, including startup acknowledgment. The
36
+ // child performs only local initialization before acknowledging this call.
37
+ return this.store.withWalletLock(async () => {
38
+ const status = this.status();
39
+ if (status.state !== 'stopped')
40
+ return status;
41
+ requireReceivingProfile(this.store);
42
+ const token = randomUUID();
43
+ this.store.set(KEY, { token, desired: true, state: 'starting', lastError: null });
44
+ try {
45
+ await this.launch(token);
46
+ }
47
+ catch (error) {
48
+ this.update(token, { desired: false, state: 'stopping', lastError: errorResult(error) });
49
+ throw error;
50
+ }
51
+ return this.status();
52
+ });
53
+ }
54
+ async stop() {
55
+ return this.store.withWalletLock(async () => {
56
+ const status = this.status();
57
+ const record = this.store.get(KEY);
58
+ if (record)
59
+ this.store.set(KEY, { ...record, desired: false, state: status.state === 'stopped' ? 'stopped' : 'stopping', lastError: status.lastError });
60
+ return { ...status, state: status.state === 'stopped' ? 'stopped' : 'stopping' };
61
+ });
62
+ }
63
+ launch(token) {
64
+ return new Promise((resolve, reject) => {
65
+ const child = spawn(process.execPath, [fileURLToPath(new URL('./receive-daemon.js', import.meta.url)), this.store.directory, token], {
66
+ detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true,
67
+ });
68
+ let settled = false;
69
+ const finish = (error) => {
70
+ if (settled)
71
+ return;
72
+ settled = true;
73
+ clearTimeout(timer);
74
+ if (child.connected)
75
+ child.disconnect();
76
+ child.unref();
77
+ if (error)
78
+ reject(error);
79
+ else
80
+ resolve();
81
+ };
82
+ const timer = setTimeout(() => finish(new AttoError('RECEIVER_START_TIMEOUT', 'Background receiver initialization timed out. Check wallet receive status before retrying.')), 5000);
83
+ child.on('error', () => finish(new AttoError('RECEIVER_START_FAILED', 'The background receiver could not start. Check the installed CLI and profile permissions.')));
84
+ child.once('exit', () => finish(new AttoError('RECEIVER_START_FAILED', 'The background receiver exited before initialization completed. Check wallet receive status.')));
85
+ child.on('message', (message) => {
86
+ if (message && typeof message === 'object' && 'ready' in message && message.ready === token)
87
+ finish();
88
+ });
89
+ child.unref();
90
+ });
91
+ }
92
+ /** A stale launch token cannot restart a stopped/reset/restored profile. */
93
+ claim(token) {
94
+ return this.store.transaction(() => {
95
+ if (this.stopping(token))
96
+ return;
97
+ return this.store.tryProcessLock('receive-daemon');
98
+ });
99
+ }
100
+ stopping(token) {
101
+ const record = this.store.get(KEY);
102
+ return record?.token !== token || !record.desired;
103
+ }
104
+ update(token, update) {
105
+ this.store.transaction(() => {
106
+ const record = this.store.get(KEY);
107
+ if (record?.token === token)
108
+ this.store.set(KEY, { ...record, ...update });
109
+ });
110
+ }
111
+ progress(token, event) {
112
+ if (event.event === 'retry' || event.event === 'reconnecting')
113
+ this.update(token, { lastError: event.error });
114
+ else if (event.event === 'received')
115
+ this.update(token, { lastError: null });
116
+ }
117
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ process.env.KOTLIN_LOGGING_STARTUP_MESSAGE = 'false';
2
+ const [directory, token] = process.argv.slice(2);
3
+ if (directory && token) {
4
+ const { setTimeout: delay } = await import('node:timers/promises');
5
+ const { StateStore } = await import('../storage/state.js');
6
+ const { AttoApplication } = await import('../application/app.js');
7
+ const { BackgroundReceiver, requireReceivingProfile } = await import('./background-receive.js');
8
+ const { errorResult } = await import('../domain/errors.js');
9
+ let store;
10
+ let application;
11
+ let receiver;
12
+ let release;
13
+ const stop = () => receiver?.update(token, { desired: false, state: 'stopping' });
14
+ try {
15
+ store = new StateStore(directory);
16
+ receiver = new BackgroundReceiver(store);
17
+ release = receiver.claim(token);
18
+ if (release) {
19
+ requireReceivingProfile(store);
20
+ const owner = receiver;
21
+ application = new AttoApplication({
22
+ directory, receivingAllowed: () => !owner.stopping(token),
23
+ onReceiveProgress: event => owner.progress(token, event),
24
+ });
25
+ process.once('SIGINT', stop);
26
+ process.once('SIGTERM', stop);
27
+ receiver.update(token, { state: 'running' });
28
+ // Acknowledge local construction/ownership before starting any network or
29
+ // password-store operation. IPC is detached immediately after the reply.
30
+ if (process.connected)
31
+ await new Promise((resolve, reject) => {
32
+ process.send({ ready: token }, error => error ? reject(error) : resolve());
33
+ });
34
+ if (process.connected)
35
+ process.disconnect();
36
+ if (!receiver.stopping(token)) {
37
+ void application.start().catch(error => owner.update(token, { lastError: errorResult(error) }));
38
+ while (!receiver.stopping(token))
39
+ await delay(100);
40
+ }
41
+ }
42
+ }
43
+ catch (error) {
44
+ if (release)
45
+ receiver?.update(token, { desired: false, state: 'stopping', lastError: errorResult(error) });
46
+ }
47
+ finally {
48
+ // Keep ownership and stopping visible until the current receive, startup
49
+ // lookups, and work requests have all finished or been canceled.
50
+ if (release)
51
+ receiver?.update(token, { desired: false, state: 'stopping' });
52
+ await application?.close();
53
+ if (release)
54
+ receiver?.update(token, { desired: false, state: 'stopped' });
55
+ release?.();
56
+ store?.close();
57
+ process.removeListener('SIGINT', stop);
58
+ process.removeListener('SIGTERM', stop);
59
+ if (process.connected)
60
+ process.disconnect();
61
+ }
62
+ }
63
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ process.env.KOTLIN_LOGGING_STARTUP_MESSAGE = 'false';
2
+ const [directory, epoch] = process.argv.slice(2);
3
+ if (directory && epoch) {
4
+ const { StateStore } = await import('../storage/state.js');
5
+ const { WalletWork } = await import('./work.js');
6
+ let store;
7
+ let work;
8
+ try {
9
+ store = new StateStore(directory);
10
+ const state = store;
11
+ work = new WalletWork(state, () => state.get('settings'));
12
+ await work.runDetached(epoch);
13
+ }
14
+ catch { /* Public jobs remain eligible after startup or storage failures. */ }
15
+ finally {
16
+ await work?.close();
17
+ store?.close();
18
+ }
19
+ }
20
+ export {};
@@ -5,25 +5,47 @@ export interface BlockWorker {
5
5
  workBlock(block: AttoBlock, signal?: AbortSignal): Promise<AttoWork>;
6
6
  close(): void;
7
7
  }
8
- /** Public work only: no signer, mnemonic, or seed enters this owner. */
8
+ export type WorkExecution = 'in-process' | 'detached';
9
+ /** Public preparation, validation and caching. No credential or signer enters
10
+ * this owner. Speculative failures never escape into completed transactions. */
9
11
  export declare class WalletWork {
10
12
  private readonly store;
11
13
  private readonly settings;
12
- private readonly queued;
13
- private readonly pending;
14
- private readonly requests;
15
- private backgroundCount;
14
+ private readonly execution;
16
15
  private closed;
17
- constructor(store: StateStore, settings: () => WalletSettings);
16
+ private drainTask?;
17
+ private resumeRequested;
18
+ private readonly active;
19
+ private readonly computations;
20
+ private readonly requests;
21
+ constructor(store: StateStore, settings: () => WalletSettings, execution?: WorkExecution);
22
+ private scope;
23
+ private nextBlock;
24
+ private key;
25
+ private head;
26
+ private same;
18
27
  isReady(account: AttoAccount): boolean;
19
28
  prepare(accounts: readonly AttoAccount[]): void;
29
+ /** A confirmed block is sufficient to prepare its successor, including after
30
+ * publication recovery. This unsigned change template is never published;
31
+ * work depends on the head, height, network and time, not its representative. */
32
+ prepareConfirmed(block: AttoBlock): void;
33
+ private enqueue;
34
+ /** Resume only after successful CLI input validation/operation, never in the constructor. */
35
+ resume(): void;
20
36
  worker(): BlockWorker;
21
- close(): Promise<void>;
22
- private nextBlock;
23
- private key;
37
+ private queue;
38
+ /** Caller owns the short state transaction. Lower observations cannot undo
39
+ * newer heads; an equal-height replacement invalidates the previous fork. */
40
+ private observe;
24
41
  private read;
25
42
  private save;
26
43
  private obtain;
27
44
  private compute;
45
+ /** Release the singleton inside the final queue transaction. An enqueue is
46
+ * either seen by this owner or its launch candidate can become the next owner. */
47
+ runDetached(epoch: string): Promise<void>;
28
48
  private drain;
49
+ cancel(): Promise<void>;
50
+ close(): Promise<void>;
29
51
  }
@@ -1,62 +1,153 @@
1
- import { AttoInstant, AttoWork, attoAccountChange, attoBlockWorkTarget, } from '@attocash/commons-core';
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { performance } from 'node:perf_hooks';
6
+ import { AttoBlock, AttoChangeBlock, AttoInstant, AttoWork, attoAccountChange, attoBlockWorkTarget, toAttoHeight } from '@attocash/commons-core';
2
7
  import { requestWork } from '../network/work.js';
3
8
  import { AttoError } from '../domain/errors.js';
4
- const BACKGROUND_LIMIT = 2;
9
+ const QUEUE_KEY = 'work.queue';
10
+ const EPOCH_KEY = 'work.epoch';
5
11
  const QUEUE_LIMIT = 100;
6
- /** Public work only: no signer, mnemonic, or seed enters this owner. */
12
+ const CONCURRENCY = 2;
13
+ const SPECULATIVE_MS = 10_000;
14
+ const WORKER_MS = 60_000;
15
+ /** Public preparation, validation and caching. No credential or signer enters
16
+ * this owner. Speculative failures never escape into completed transactions. */
7
17
  export class WalletWork {
8
18
  store;
9
19
  settings;
10
- queued = new Map();
11
- pending = new Map();
12
- requests = new Set();
13
- backgroundCount = 0;
20
+ execution;
14
21
  closed = false;
15
- constructor(store, settings) {
22
+ drainTask;
23
+ resumeRequested = false;
24
+ active = new Set();
25
+ computations = new Map();
26
+ requests = new Set();
27
+ constructor(store, settings, execution = 'in-process') {
16
28
  this.store = store;
17
29
  this.settings = settings;
30
+ this.execution = execution;
31
+ }
32
+ scope() {
33
+ const { network, nodeUrl, workerUrl } = this.settings();
34
+ return JSON.stringify([this.store.get(EPOCH_KEY) ?? null, this.store.get('identity') ?? null, network, nodeUrl, workerUrl]);
35
+ }
36
+ nextBlock(account) {
37
+ return attoAccountChange(account, account.representativeAddress, AttoInstant.Companion.now().toString()).block;
18
38
  }
39
+ key(block) { return `work.${block.network.name}.${block.publicKey}`; }
40
+ head(block) { return { scope: this.scope(), target: attoBlockWorkTarget(block), height: block.height.toString() }; }
41
+ same(left, right) { return left.scope === right.scope && left.target === right.target && left.height === right.height; }
19
42
  isReady(account) {
20
- if (this.closed || account.network.name !== this.settings().network)
43
+ try {
44
+ return !this.closed && account.network.name === this.settings().network && Boolean(this.read(this.nextBlock(account)));
45
+ }
46
+ catch {
21
47
  return false;
22
- return this.read(this.nextBlock(account)) !== undefined;
48
+ }
23
49
  }
24
50
  prepare(accounts) {
51
+ try {
52
+ this.enqueue(accounts.map(account => this.nextBlock(account)));
53
+ }
54
+ catch { /* Public preparation must not change a completed payment's result. */ }
55
+ }
56
+ /** A confirmed block is sufficient to prepare its successor, including after
57
+ * publication recovery. This unsigned change template is never published;
58
+ * work depends on the head, height, network and time, not its representative. */
59
+ prepareConfirmed(block) {
60
+ try {
61
+ this.enqueue([new AttoChangeBlock(block.network, block.version, block.algorithm, block.publicKey, toAttoHeight((BigInt(block.height.toString()) + 1n).toString()), block.balance, AttoInstant.Companion.now(), block.hash, block.algorithm, block.publicKey)]);
62
+ }
63
+ catch { /* Even template/storage failures are optional after confirmation. */ }
64
+ }
65
+ enqueue(blocks) {
25
66
  if (this.closed)
26
67
  return;
27
- for (const account of accounts) {
28
- if (account.network.name !== this.settings().network)
29
- continue;
30
- const block = this.nextBlock(account);
31
- if (this.read(block))
32
- continue;
33
- const key = this.key(block);
34
- if (this.queued.size < QUEUE_LIMIT || this.queued.has(key))
35
- this.queued.set(key, block);
36
- }
37
- this.drain();
68
+ try {
69
+ this.store.transaction(() => {
70
+ if (!this.store.get(EPOCH_KEY))
71
+ this.store.set(EPOCH_KEY, randomUUID());
72
+ let queue = this.queue();
73
+ for (const block of blocks) {
74
+ if (block.network.name !== this.settings().network)
75
+ continue;
76
+ const key = this.key(block);
77
+ const head = this.head(block);
78
+ if (!this.observe(key, head))
79
+ continue;
80
+ queue = queue.filter(job => job.scope === head.scope);
81
+ const position = queue.findIndex(job => job.key === key);
82
+ if (this.read(block)) {
83
+ queue = queue.filter(job => job.key !== key || !this.same(job, head));
84
+ }
85
+ else if (position !== -1) {
86
+ if (!this.same(queue[position], head))
87
+ queue[position] = { ...head, key, id: randomUUID(), block: block.toJson() };
88
+ }
89
+ else if (queue.length < QUEUE_LIMIT)
90
+ queue.push({ ...head, key, id: randomUUID(), block: block.toJson() });
91
+ }
92
+ this.store.set(QUEUE_KEY, queue);
93
+ });
94
+ this.resume();
95
+ }
96
+ catch { /* A completed transaction survives speculative storage/launch failures. */ }
97
+ }
98
+ /** Resume only after successful CLI input validation/operation, never in the constructor. */
99
+ resume() {
100
+ if (this.closed)
101
+ return;
102
+ try {
103
+ if (!this.queue().length)
104
+ return;
105
+ if (this.execution === 'detached') {
106
+ // Always launch a candidate after enqueueing. The child checks ownership
107
+ // in the same state transaction used by the draining owner's exit.
108
+ const child = spawn(process.execPath, [fileURLToPath(new URL('./work-daemon.js', import.meta.url)), this.store.directory, this.store.get(EPOCH_KEY) ?? ''], {
109
+ detached: true, stdio: 'ignore', windowsHide: true,
110
+ });
111
+ child.on('error', () => { });
112
+ child.unref();
113
+ }
114
+ else if (this.drainTask)
115
+ this.resumeRequested = true;
116
+ else {
117
+ this.resumeRequested = false;
118
+ this.drainTask = this.drain().catch(() => { }).finally(() => {
119
+ this.drainTask = undefined;
120
+ if (this.resumeRequested)
121
+ this.resume();
122
+ });
123
+ }
124
+ }
125
+ catch { /* Pending jobs remain eligible on a later invocation. */ }
38
126
  }
39
127
  worker() {
40
- // A signing operation borrows this cache; only the application closes it.
41
128
  return { workBlock: (block, signal) => this.obtain(block, false, signal), close() { } };
42
129
  }
43
- async close() {
44
- this.closed = true;
45
- this.queued.clear();
46
- for (const request of this.requests)
47
- request.abort();
48
- await Promise.allSettled([...this.pending.values()].map(job => job.promise));
130
+ queue() {
131
+ const value = this.store.get(QUEUE_KEY);
132
+ if (!Array.isArray(value))
133
+ return [];
134
+ return value.filter((job) => job !== null && typeof job === 'object'
135
+ && ['id', 'key', 'scope', 'target', 'block'].every(key => typeof job[key] === 'string')
136
+ && typeof job.height === 'string' && /^\d+$/.test(job.height)).slice(0, QUEUE_LIMIT);
49
137
  }
50
- nextBlock(account) {
51
- // Commons constructs the next-head target and validates its own work. This
52
- // unsigned block is only a work request; it is never signed or published.
53
- return attoAccountChange(account, account.representativeAddress, AttoInstant.Companion.now().toString()).block;
138
+ /** Caller owns the short state transaction. Lower observations cannot undo
139
+ * newer heads; an equal-height replacement invalidates the previous fork. */
140
+ observe(key, head) {
141
+ const prior = this.store.get('head.' + key);
142
+ if (prior?.scope === head.scope && /^\d+$/.test(prior.height) && BigInt(prior.height) > BigInt(head.height))
143
+ return false;
144
+ this.store.set('head.' + key, head);
145
+ return true;
54
146
  }
55
- key(block) { return `work.${block.network.name}.${block.publicKey.toString()}`; }
56
147
  read(block) {
57
148
  try {
58
149
  const record = this.store.get(this.key(block));
59
- if (!record || record.target !== attoBlockWorkTarget(block) || !/^[0-9a-f]{16}$/i.test(record.work))
150
+ if (!record || !this.same(record, this.head(block)) || !/^[0-9a-f]{16}$/i.test(record.work))
60
151
  return;
61
152
  const work = AttoWork.Companion.parse(record.work);
62
153
  return work.isValid(block) ? work : undefined;
@@ -65,63 +156,126 @@ export class WalletWork {
65
156
  return undefined;
66
157
  }
67
158
  }
68
- save(block, work) {
69
- if (this.closed || block.network.name !== this.settings().network)
159
+ save(block, head, work) {
160
+ if (this.closed || this.scope() !== head.scope)
70
161
  return;
71
- const key = this.key(block);
72
162
  this.store.transaction(() => {
73
- const prior = this.store.get(key);
74
- // An older in-flight job must not displace work for a newer account head,
75
- // including work another CLI/MCP process has already persisted.
76
- if (prior && /^\d+$/.test(prior.height) && BigInt(prior.height) > BigInt(block.height.toString()))
163
+ const observed = this.store.get('head.' + this.key(block));
164
+ if (observed && !this.same(observed, head))
77
165
  return;
78
- this.store.set(key, { target: attoBlockWorkTarget(block), height: block.height.toString(), work: work.toString() });
166
+ this.store.set(this.key(block), { scope: head.scope, target: head.target, height: head.height, work: work.toString() });
167
+ this.store.set(QUEUE_KEY, this.queue().filter(job => job.key !== this.key(block) || !this.same(job, head)));
79
168
  });
80
169
  }
81
- async obtain(block, speculative = false, signal) {
82
- if (this.closed)
83
- throw new AttoError('WORK_CLOSED', 'The wallet work cache is closed.');
84
- const settings = this.settings();
85
- if (block.network.name !== settings.network)
86
- throw new AttoError('NETWORK_MISMATCH', 'Work must use the configured wallet network.');
87
- const ready = this.read(block);
88
- if (ready)
89
- return ready;
90
- const key = `${this.key(block)}.${attoBlockWorkTarget(block)}`;
91
- const existing = this.pending.get(key);
170
+ async obtain(block, speculative, signal) {
171
+ // Establish the shared generation before any computation takes its lock.
172
+ // Otherwise a first enqueue could change its scope while foreground work
173
+ // was already running under an uninitialized generation.
174
+ if (!this.store.get(EPOCH_KEY)) {
175
+ try {
176
+ this.store.transaction(() => { if (!this.store.get(EPOCH_KEY))
177
+ this.store.set(EPOCH_KEY, randomUUID()); });
178
+ }
179
+ catch { /* Foreground work can still use its ordinary uncached fallback. */ }
180
+ }
181
+ const key = this.scope() + ':' + this.key(block) + ':' + attoBlockWorkTarget(block);
182
+ const existing = this.computations.get(key);
92
183
  if (existing) {
184
+ let cancel;
93
185
  try {
94
- const work = await existing.promise;
95
- // A job begun before a threshold change may no longer satisfy this block.
186
+ const cancelled = new Promise((_, reject) => {
187
+ cancel = () => reject(new AttoError('CANCELLED', 'Send cancelled.'));
188
+ if (signal?.aborted)
189
+ cancel();
190
+ else
191
+ signal?.addEventListener('abort', cancel, { once: true });
192
+ });
193
+ const work = await Promise.race([existing.promise, cancelled]);
96
194
  if (work.isValid(block))
97
195
  return work;
98
196
  }
99
197
  catch (error) {
100
- // Speculation has a shorter deadline. A real transaction retains the
101
- // normal worker budget if that earlier background attempt failed.
102
- if (speculative || !existing.speculative)
198
+ if (signal?.aborted || speculative || !existing.speculative)
103
199
  throw error;
104
200
  }
201
+ finally {
202
+ if (cancel)
203
+ signal?.removeEventListener('abort', cancel);
204
+ }
105
205
  }
106
- if (this.closed)
107
- throw new AttoError('WORK_CLOSED', 'The wallet work cache is closed.');
108
- const job = { promise: this.compute(block, settings.workerUrl, speculative ? 10 : 60, signal), speculative };
109
- this.pending.set(key, job);
206
+ const operation = this.compute(block, speculative, signal);
207
+ this.active.add(operation);
208
+ const computation = { promise: operation, speculative };
209
+ this.computations.set(key, computation);
110
210
  try {
111
- return await job.promise;
211
+ return await operation;
112
212
  }
113
213
  finally {
114
- if (this.pending.get(key) === job)
115
- this.pending.delete(key);
214
+ this.active.delete(operation);
215
+ if (this.computations.get(key) === computation)
216
+ this.computations.delete(key);
116
217
  }
117
218
  }
118
- async compute(block, url, timeoutSeconds, signal) {
219
+ async compute(block, speculative, signal) {
220
+ if (this.closed)
221
+ throw new AttoError('WORK_CLOSED', 'The wallet work cache is closed.');
222
+ const settings = this.settings();
223
+ if (block.network.name !== settings.network)
224
+ throw new AttoError('NETWORK_MISMATCH', 'Work must use the configured wallet network.');
225
+ const head = this.head(block);
119
226
  const controller = new AbortController();
120
227
  this.requests.add(controller);
121
- const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
228
+ const combined = AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]);
229
+ let timer = setTimeout(() => controller.abort(), speculative ? SPECULATIVE_MS : WORKER_MS);
230
+ let release;
231
+ let slot;
122
232
  try {
123
- const work = await requestWork(block, url, AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]));
124
- this.save(block, work);
233
+ if (!speculative) {
234
+ try {
235
+ this.store.transaction(() => this.observe(this.key(block), head));
236
+ }
237
+ catch { /* Coordination/cache failure falls back to foreground generation. */ }
238
+ }
239
+ for (;;) {
240
+ combined.throwIfAborted();
241
+ if (this.scope() !== head.scope)
242
+ throw new AttoError('WORK_OBSOLETE', 'Wallet configuration changed during work preparation.');
243
+ const ready = this.read(block);
244
+ if (ready)
245
+ return ready;
246
+ try {
247
+ release = this.store.tryWorkLock(head.scope + ':' + this.key(block) + ':' + head.target);
248
+ }
249
+ catch (error) {
250
+ if (speculative)
251
+ throw error;
252
+ break;
253
+ }
254
+ if (release)
255
+ break;
256
+ await delay(25, undefined, { signal: combined });
257
+ }
258
+ if (speculative) {
259
+ while (!slot) {
260
+ for (let index = 0; index < CONCURRENCY && !slot; index++)
261
+ slot = this.store.tryWorkLock('speculative-slot-' + index);
262
+ if (!slot)
263
+ await delay(25, undefined, { signal: combined });
264
+ }
265
+ }
266
+ else {
267
+ // Waiting for speculation does not consume the normal generation budget.
268
+ clearTimeout(timer);
269
+ timer = setTimeout(() => controller.abort(), WORKER_MS);
270
+ }
271
+ const ready = this.read(block);
272
+ if (ready)
273
+ return ready;
274
+ const work = await requestWork(block, settings.workerUrl, combined);
275
+ try {
276
+ this.save(block, head, work);
277
+ }
278
+ catch { /* The validated nonce remains usable. */ }
125
279
  return work;
126
280
  }
127
281
  catch (error) {
@@ -138,17 +292,92 @@ export class WalletWork {
138
292
  finally {
139
293
  clearTimeout(timer);
140
294
  this.requests.delete(controller);
295
+ slot?.();
296
+ release?.();
141
297
  }
142
298
  }
143
- drain() {
144
- while (!this.closed && this.backgroundCount < BACKGROUND_LIMIT && this.queued.size > 0) {
145
- const [key, block] = this.queued.entries().next().value;
146
- this.queued.delete(key);
147
- this.backgroundCount++;
148
- void this.obtain(block, true).catch(() => { }).finally(() => {
149
- this.backgroundCount--;
150
- this.drain();
151
- });
299
+ /** Release the singleton inside the final queue transaction. An enqueue is
300
+ * either seen by this owner or its launch candidate can become the next owner. */
301
+ async runDetached(epoch) {
302
+ let release;
303
+ this.store.transaction(() => {
304
+ if (this.store.get(EPOCH_KEY) === epoch)
305
+ release = this.store.tryProcessLock('work-daemon');
306
+ });
307
+ if (!release)
308
+ return;
309
+ const unlock = () => { release?.(); release = undefined; };
310
+ try {
311
+ await this.drain(epoch, unlock);
312
+ }
313
+ finally {
314
+ await this.close();
315
+ unlock();
316
+ }
317
+ }
318
+ async drain(epoch = this.store.get(EPOCH_KEY), unlock) {
319
+ const attempted = new Set();
320
+ const pending = new Map();
321
+ const started = performance.now();
322
+ try {
323
+ for (;;) {
324
+ if (this.closed || this.store.get(EPOCH_KEY) !== epoch || (unlock && performance.now() - started >= WORKER_MS))
325
+ break;
326
+ const jobs = this.store.transaction(() => {
327
+ const queue = this.queue().filter(job => job.scope === this.scope());
328
+ this.store.set(QUEUE_KEY, queue);
329
+ const next = queue.filter(job => !attempted.has(job.id)).slice(0, CONCURRENCY - pending.size);
330
+ if (!next.length && !pending.size)
331
+ unlock?.();
332
+ return next;
333
+ });
334
+ if (!jobs.length && !pending.size)
335
+ return;
336
+ for (const job of jobs) {
337
+ attempted.add(job.id);
338
+ const task = (async () => {
339
+ const template = AttoBlock.fromJson(job.block);
340
+ if (!(template instanceof AttoChangeBlock))
341
+ return;
342
+ const block = new AttoChangeBlock(template.network, template.version, template.algorithm, template.publicKey, template.height, template.balance, AttoInstant.Companion.now(), template.previous, template.representativeAlgorithm, template.representativePublicKey);
343
+ const observed = this.store.get('head.' + job.key);
344
+ if (this.key(block) !== job.key || !this.same(this.head(block), job) || (observed && !this.same(observed, job)))
345
+ return;
346
+ const work = await this.obtain(block, true);
347
+ // Cache hits also remove jobs left by interrupted owners.
348
+ this.save(block, job, work);
349
+ })().catch(() => { }).finally(() => pending.delete(job.id));
350
+ pending.set(job.id, task);
351
+ }
352
+ await Promise.race([...pending.values(), delay(25)]);
353
+ }
152
354
  }
355
+ finally {
356
+ if (unlock)
357
+ for (const controller of this.requests)
358
+ controller.abort();
359
+ await Promise.allSettled([...pending.values()]);
360
+ }
361
+ }
362
+ async cancel() {
363
+ this.store.transaction(() => { this.store.set(EPOCH_KEY, randomUUID()); this.store.set(QUEUE_KEY, []); });
364
+ await this.close();
365
+ const deadline = performance.now() + 5000;
366
+ for (;;) {
367
+ const release = this.store.tryProcessLock('work-daemon');
368
+ if (release) {
369
+ release();
370
+ return;
371
+ }
372
+ if (performance.now() >= deadline)
373
+ throw new AttoError('WALLET_BUSY', 'Public work preparation is still stopping. Retry wallet reset.');
374
+ await delay(25);
375
+ }
376
+ }
377
+ async close() {
378
+ this.closed = true;
379
+ for (const controller of this.requests)
380
+ controller.abort();
381
+ await Promise.allSettled([...this.active, ...(this.drainTask ? [this.drainTask] : [])]);
153
382
  }
154
383
  }
@@ -28,7 +28,7 @@ export declare class WatchManager {
28
28
  } | undefined;
29
29
  id: string;
30
30
  filter: StreamFilter;
31
- status: "running" | "reconnecting" | "stopped" | "completed";
31
+ status: "reconnecting" | "running" | "stopped" | "completed";
32
32
  createdAt: string;
33
33
  reconnects: number;
34
34
  replayable: boolean;
@@ -40,7 +40,7 @@ export declare class WatchManager {
40
40
  } | undefined;
41
41
  id: string;
42
42
  filter: StreamFilter;
43
- status: "running" | "reconnecting" | "stopped" | "completed";
43
+ status: "reconnecting" | "running" | "stopped" | "completed";
44
44
  createdAt: string;
45
45
  reconnects: number;
46
46
  replayable: boolean;
@@ -60,7 +60,7 @@ export declare class WatchManager {
60
60
  } | undefined;
61
61
  id: string;
62
62
  filter: StreamFilter;
63
- status: "running" | "reconnecting" | "stopped" | "completed";
63
+ status: "reconnecting" | "running" | "stopped" | "completed";
64
64
  createdAt: string;
65
65
  reconnects: number;
66
66
  replayable: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@attocash/cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Atto wallet CLI and reusable wallet engine with local key custody and shared spending limits",
5
5
  "license": "BSD-3-Clause",
6
6
  "type": "module",