@attocash/cli 0.1.1 → 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 +41 -2
- package/dist/application/app.d.ts +11 -0
- package/dist/application/app.js +37 -12
- package/dist/cli/cli.js +13 -2
- package/dist/cli/onboarding.d.ts +1 -2
- package/dist/cli/onboarding.js +2 -2
- package/dist/cli/output.js +1 -1
- package/dist/spending/payments.js +5 -6
- package/dist/storage/state.d.ts +7 -0
- package/dist/storage/state.js +61 -8
- package/dist/wallet/background-receive.d.ts +30 -0
- package/dist/wallet/background-receive.js +117 -0
- package/dist/wallet/receive-daemon.d.ts +1 -0
- package/dist/wallet/receive-daemon.js +63 -0
- package/dist/wallet/work-daemon.d.ts +1 -0
- package/dist/wallet/work-daemon.js +20 -0
- package/dist/wallet/work.d.ts +31 -9
- package/dist/wallet/work.js +307 -78
- package/dist/watches/manager.d.ts +3 -3
- package/package.json +1 -1
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
|
|
@@ -736,13 +775,13 @@ git clone https://github.com/attocash/integrations.git
|
|
|
736
775
|
cd integrations
|
|
737
776
|
npm ci
|
|
738
777
|
npm run pack
|
|
739
|
-
npm install --global ./attocash-cli-0.
|
|
778
|
+
npm install --global ./attocash-cli-0.0.0.tgz
|
|
740
779
|
```
|
|
741
780
|
|
|
742
781
|
To test MCP with the same local CLI build, install both artifacts together:
|
|
743
782
|
|
|
744
783
|
```sh
|
|
745
|
-
npm install --global ./attocash-cli-0.
|
|
784
|
+
npm install --global ./attocash-cli-0.0.0.tgz ./attocash-mcp-0.0.0.tgz
|
|
746
785
|
```
|
|
747
786
|
|
|
748
787
|
For development and testing, see the [contributor guide](https://github.com/attocash/integrations/blob/main/docs/contributing.md).
|
|
@@ -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
|
}
|
package/dist/application/app.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
package/dist/cli/onboarding.d.ts
CHANGED
|
@@ -8,7 +8,6 @@ interface ProposalOptions {
|
|
|
8
8
|
export declare function approveLimitsProposal({ id, directory }: ProposalOptions): Promise<unknown>;
|
|
9
9
|
export declare function rejectLimitsProposal({ id, directory }: ProposalOptions): Promise<unknown>;
|
|
10
10
|
/** Returns only public host configuration; recovery material is displayed directly on the TTY. */
|
|
11
|
-
export declare function setupMcp({
|
|
12
|
-
version: string;
|
|
11
|
+
export declare function setupMcp({ directory }: {
|
|
13
12
|
directory?: string;
|
|
14
13
|
}): Promise<unknown>;
|
package/dist/cli/onboarding.js
CHANGED
|
@@ -107,7 +107,7 @@ async function configurePool(current) {
|
|
|
107
107
|
return { indexes, consolidate };
|
|
108
108
|
}
|
|
109
109
|
/** Returns only public host configuration; recovery material is displayed directly on the TTY. */
|
|
110
|
-
export async function setupMcp({
|
|
110
|
+
export async function setupMcp({ directory }) {
|
|
111
111
|
requireTerminal();
|
|
112
112
|
process.stderr.write('Atto MCP setup. Recovery phrases stay in your OS password store and this terminal.\n');
|
|
113
113
|
const wallet = await choose('Wallet: [1] Dedicated MCP wallet (default), [2] Existing CLI wallet: ');
|
|
@@ -136,7 +136,7 @@ export async function setupMcp({ version, directory }) {
|
|
|
136
136
|
const { proposal } = await application.call('limits_propose', { policy, access, pool });
|
|
137
137
|
await approve(application, proposal.id);
|
|
138
138
|
process.stderr.write('\nAdd this public configuration to your MCP client. The explicit data directory preserves your wallet selection.\n');
|
|
139
|
-
return { mcpServers: { atto: { command: 'npx', args: ['--yes',
|
|
139
|
+
return { mcpServers: { atto: { command: 'npx', args: ['--yes', '@attocash/mcp@latest', '--data-dir', profile.directory] } } };
|
|
140
140
|
}
|
|
141
141
|
finally {
|
|
142
142
|
await application.close();
|
package/dist/cli/output.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|
package/dist/storage/state.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/storage/state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|