@stablechain/agent-wallet-cli 0.0.0-stage → 0.1.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 +39 -2
- package/dist/agent/context.d.ts +37 -0
- package/dist/agent/context.js +66 -0
- package/dist/agent/grants.d.ts +17 -0
- package/dist/agent/grants.js +55 -0
- package/dist/agent/index.d.ts +5 -0
- package/dist/agent/index.js +6 -0
- package/dist/agent/payments.d.ts +138 -0
- package/dist/agent/payments.js +236 -0
- package/dist/agent/state.d.ts +15 -0
- package/dist/agent/state.js +97 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +239 -0
- package/dist/output.d.ts +4 -0
- package/dist/output.js +30 -0
- package/package.json +56 -4
package/README.md
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Agent wallet CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The `agent-wallet` executable manages the local agent signer, grants, payments, and recovery on Stable Testnet. Wallet protocol code comes from its declared `@stablechain/agent-wallet-sdk` dependency.
|
|
4
|
+
|
|
5
|
+
Install with Node.js 22 or newer:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install --global @stablechain/agent-wallet-cli@0.1.0
|
|
9
|
+
agent-wallet --help
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or run directly:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npx --yes --package=@stablechain/agent-wallet-cli@0.1.0 agent-wallet --help
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Each agent initializes its own signer and imports owner-provided public wallet settings. Set `WALLET_STATE_DIR` to a persistent private directory, especially in containers:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
agent-wallet init --json
|
|
22
|
+
agent-wallet config import --file ./wallet-config.json --json
|
|
23
|
+
agent-wallet whoami --json
|
|
24
|
+
agent-wallet grants link --recipient "$RECIPIENT" --budget 0.01 --expires-in 24h --json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The owner reviews the approval link, authorizes the grant, and funds the agent's gas through the approval UI. The CLI does not activate the owner's account or read the owner's key. Payments require a stable payment ID; recover an uncertain submission by reusing that ID and its saved journal. See the [CLI reference](https://github.com/stablelabs/agentic.stable.xyz/blob/main/docs/cli.md) for the command surface, JSON output, permissions, and recovery workflow.
|
|
28
|
+
|
|
29
|
+
For repository development:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm run build:sdk
|
|
33
|
+
npm run build --workspace @stablechain/agent-wallet-cli
|
|
34
|
+
npm run wallet -- --help
|
|
35
|
+
npm run test --workspace @stablechain/agent-wallet-cli
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The package compiles its own `src` into `dist`, exposes `dist/main.js` as the executable, and exports Node-only services through `@stablechain/agent-wallet-cli/agent` for the repository's compatibility scripts. It does not copy SDK source or bundle private signing state.
|
|
39
|
+
|
|
40
|
+
The public npm package includes compiled JavaScript, declarations, this README, and its manifest. The prepack hook rebuilds the package before packing or publishing. The SDK is a separate public dependency; React and the repository's build tools are not runtime dependencies.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Hex } from 'viem';
|
|
2
|
+
import { type WalletConfig, type PublicClient } from '@stablechain/agent-wallet-sdk/protocol';
|
|
3
|
+
import { StateStore } from './state.js';
|
|
4
|
+
export declare class AgentContext {
|
|
5
|
+
readonly state: StateStore;
|
|
6
|
+
constructor(directory?: string);
|
|
7
|
+
config(): Promise<WalletConfig>;
|
|
8
|
+
public(): Promise<{
|
|
9
|
+
config: WalletConfig;
|
|
10
|
+
client: PublicClient;
|
|
11
|
+
}>;
|
|
12
|
+
verify(client: PublicClient, config: WalletConfig): Promise<void>;
|
|
13
|
+
identity(): Promise<`0x${string}`>;
|
|
14
|
+
account(): Promise<{
|
|
15
|
+
address: import("viem").Address;
|
|
16
|
+
nonceManager?: import("viem").NonceManager | undefined;
|
|
17
|
+
sign: (parameters: {
|
|
18
|
+
hash: import("viem").Hash;
|
|
19
|
+
}) => Promise<Hex>;
|
|
20
|
+
signAuthorization: (parameters: import("viem").AuthorizationRequest) => Promise<import("viem/accounts").SignAuthorizationReturnType>;
|
|
21
|
+
signMessage: ({ message }: {
|
|
22
|
+
message: import("viem").SignableMessage;
|
|
23
|
+
}) => Promise<Hex>;
|
|
24
|
+
signTransaction: <serializer extends import("viem").SerializeTransactionFn<import("viem").TransactionSerializable> = import("viem").SerializeTransactionFn<import("viem").TransactionSerializable>, transaction extends Parameters<serializer>[0] = Parameters<serializer>[0]>(transaction: transaction, options?: {
|
|
25
|
+
serializer?: serializer | undefined;
|
|
26
|
+
} | undefined) => Promise<Hex>;
|
|
27
|
+
signTypedData: <const typedData extends import("viem").TypedData | Record<string, unknown>, primaryType extends keyof typedData | "EIP712Domain" = keyof typedData>(parameters: import("viem").TypedDataDefinition<typedData, primaryType>) => Promise<Hex>;
|
|
28
|
+
publicKey: Hex;
|
|
29
|
+
source: "privateKey";
|
|
30
|
+
type: "local";
|
|
31
|
+
}>;
|
|
32
|
+
init(): Promise<{
|
|
33
|
+
address: `0x${string}`;
|
|
34
|
+
retained: boolean;
|
|
35
|
+
stateDir: string;
|
|
36
|
+
}>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
|
|
3
|
+
import { address, validateConfig, publicClient, verifyAccount, WalletError } from '@stablechain/agent-wallet-sdk/protocol';
|
|
4
|
+
import { StateStore } from './state.js';
|
|
5
|
+
export class AgentContext {
|
|
6
|
+
state;
|
|
7
|
+
constructor(directory = process.env.WALLET_STATE_DIR || resolve(process.cwd(), '.agent-wallet')) { this.state = new StateStore(directory); }
|
|
8
|
+
async config() {
|
|
9
|
+
if (!await this.state.has('config.json'))
|
|
10
|
+
throw new WalletError('CONFIG_REQUIRED', 'Import operator-provided public settings with config import --file FILE.');
|
|
11
|
+
try {
|
|
12
|
+
return validateConfig(await this.state.read('config.json'));
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error instanceof WalletError)
|
|
16
|
+
throw error;
|
|
17
|
+
throw new WalletError('INVALID_CONFIG', 'Invalid wallet configuration.');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async public() { const config = await this.config(); return { config, client: publicClient(config.rpcUrl) }; }
|
|
21
|
+
async verify(client, config) {
|
|
22
|
+
try {
|
|
23
|
+
await verifyAccount(client, config);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error instanceof Error && error.constructor === Error)
|
|
27
|
+
throw new WalletError('ACCOUNT_NOT_VERIFIED', error.message);
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async identity() {
|
|
32
|
+
if (!await this.state.has('agent-key.json'))
|
|
33
|
+
throw new WalletError('AGENT_REQUIRED', 'Run agent-wallet init first.');
|
|
34
|
+
const record = await this.state.read('agent-key.json');
|
|
35
|
+
try {
|
|
36
|
+
return address(record.address, 'agent address');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new WalletError('INVALID_KEY_RECORD', 'Agent key record has no valid public address.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async account() {
|
|
43
|
+
const record = await this.state.read('agent-key.json');
|
|
44
|
+
try {
|
|
45
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(record.privateKey))
|
|
46
|
+
throw new Error();
|
|
47
|
+
const account = privateKeyToAccount(record.privateKey);
|
|
48
|
+
if (account.address !== record.address)
|
|
49
|
+
throw new Error();
|
|
50
|
+
return account;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new WalletError('INVALID_KEY_RECORD', 'Agent key record is invalid or its address differs. Retain it for recovery.');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async init() {
|
|
57
|
+
return this.state.lock(async () => {
|
|
58
|
+
if (await this.state.has('agent-key.json'))
|
|
59
|
+
return { address: (await this.account()).address, retained: true, stateDir: this.state.directory };
|
|
60
|
+
const privateKey = generatePrivateKey();
|
|
61
|
+
const account = privateKeyToAccount(privateKey);
|
|
62
|
+
await this.state.write('agent-key.json', { privateKey, address: account.address, createdAt: new Date().toISOString() });
|
|
63
|
+
return { address: account.address, retained: false, stateDir: this.state.directory };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type GrantDraft } from '@stablechain/agent-wallet-sdk/protocol';
|
|
2
|
+
import type { AgentContext } from './context.js';
|
|
3
|
+
export declare function grantLink(ctx: AgentContext, opt: Record<string, string | boolean | undefined>): Promise<{
|
|
4
|
+
grantId: `0x${string}`;
|
|
5
|
+
approvalUrl: string;
|
|
6
|
+
terms: GrantDraft;
|
|
7
|
+
status: string;
|
|
8
|
+
submitted: boolean;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function waitGrant(ctx: AgentContext, idText: string, timeout?: number): Promise<{
|
|
11
|
+
id: `0x${string}`;
|
|
12
|
+
grant: import("@stablechain/agent-wallet-sdk/protocol").Grant;
|
|
13
|
+
status: string;
|
|
14
|
+
remainingBudget: bigint;
|
|
15
|
+
block: bigint;
|
|
16
|
+
timestamp: bigint;
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { formatAmount, address, identifier, parseGrantDraft, readGrant, duration, input, WalletError } from '@stablechain/agent-wallet-sdk/protocol';
|
|
2
|
+
import { newId } from './state.js';
|
|
3
|
+
export async function grantLink(ctx, opt) {
|
|
4
|
+
return ctx.state.lock(async () => {
|
|
5
|
+
const config = await ctx.config();
|
|
6
|
+
const id = input(() => identifier(String(opt['grant-id'] || newId())));
|
|
7
|
+
const path = `grant-draft-${id}.json`;
|
|
8
|
+
const previous = await ctx.state.has(path) ? await ctx.state.read(path) : undefined;
|
|
9
|
+
if (Boolean(opt.expires) === Boolean(opt['expires-in']))
|
|
10
|
+
throw new WalletError('INVALID_INPUT', 'Supply exactly one of --expires or --expires-in.', 2);
|
|
11
|
+
if (!opt.recipient || !opt.budget)
|
|
12
|
+
throw new WalletError('INVALID_INPUT', '--recipient and --budget are required.', 2);
|
|
13
|
+
const expiresIn = opt['expires-in'] ? String(opt['expires-in']) : undefined;
|
|
14
|
+
const expires = opt.expires ? String(opt.expires) : previous && previous.expiresIn === expiresIn ? previous.draft.expires : new Date(Date.now() + duration(expiresIn, 'expiry duration')).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
15
|
+
const agent = opt.agent ? input(() => address(String(opt.agent))) : await ctx.identity();
|
|
16
|
+
const draft = { id, agent, recipient: String(opt.recipient), budget: String(opt.budget), expires };
|
|
17
|
+
const terms = input(() => parseGrantDraft(draft, config.alice, previous ? 0n : BigInt(Math.floor(Date.now() / 1000))));
|
|
18
|
+
draft.agent = terms.agent;
|
|
19
|
+
draft.recipient = terms.recipient;
|
|
20
|
+
draft.budget = formatAmount(terms.budget);
|
|
21
|
+
if (previous && (previous.alice !== config.alice || JSON.stringify(previous.draft) !== JSON.stringify(draft) || previous.expiresIn !== expiresIn))
|
|
22
|
+
throw new WalletError('GRANT_DRAFT_MISMATCH', 'Grant ID is already bound to different terms.', 2);
|
|
23
|
+
if (!previous)
|
|
24
|
+
await ctx.state.write(path, { schemaVersion: 1, alice: config.alice, draft, ...(expiresIn ? { expiresIn } : {}) });
|
|
25
|
+
const link = new URL(config.approvalUrl);
|
|
26
|
+
for (const [key, value] of Object.entries({ newGrant: id, agent: draft.agent, recipient: draft.recipient, budget: draft.budget, expires: draft.expires }))
|
|
27
|
+
link.searchParams.set(key, value);
|
|
28
|
+
return { grantId: id, approvalUrl: link.href, terms: draft, status: 'awaiting_owner', submitted: false };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export async function waitGrant(ctx, idText, timeout = 300_000) {
|
|
32
|
+
const id = input(() => identifier(idText));
|
|
33
|
+
const { config, client } = await ctx.public();
|
|
34
|
+
await ctx.verify(client, config);
|
|
35
|
+
const saved = await ctx.state.has(`grant-draft-${id}.json`) ? await ctx.state.read(`grant-draft-${id}.json`) : undefined;
|
|
36
|
+
const deadline = Date.now() + timeout;
|
|
37
|
+
while (true) {
|
|
38
|
+
const row = await readGrant(client, config, id);
|
|
39
|
+
if (row.grant.status !== 0 && saved) {
|
|
40
|
+
const terms = input(() => parseGrantDraft(saved.draft, config.alice, 0n)), g = row.grant;
|
|
41
|
+
if (saved.alice !== config.alice || terms.agent !== g.agent || terms.recipient !== g.recipient || terms.budget !== g.budget || terms.expiresAt !== g.expiresAt)
|
|
42
|
+
throw new WalletError('GRANT_TERMS_MISMATCH', 'Onchain grant differs from the saved approval terms.', 4, row);
|
|
43
|
+
}
|
|
44
|
+
if (row.status === 'Active') {
|
|
45
|
+
if (row.grant.agent !== await ctx.identity())
|
|
46
|
+
throw new WalletError('WRONG_SIGNER', 'Grant belongs to a different agent.', 4, row);
|
|
47
|
+
return row;
|
|
48
|
+
}
|
|
49
|
+
if (!['Unknown', 'Pending'].includes(row.status))
|
|
50
|
+
throw new WalletError('GRANT_NOT_PAYABLE', `Grant is ${row.status}.`, 4, row);
|
|
51
|
+
if (Date.now() >= deadline)
|
|
52
|
+
throw new WalletError('GRANT_WAIT_TIMEOUT', 'Authorization is still pending. Check the same grant ID later.', 6, row);
|
|
53
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(1000, deadline - Date.now())));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { type Hex } from 'viem';
|
|
2
|
+
import { type WaitOptions } from '@stablechain/agent-wallet-sdk/protocol';
|
|
3
|
+
import type { AgentContext } from './context.js';
|
|
4
|
+
export type PaymentJournal = {
|
|
5
|
+
schemaVersion?: 1;
|
|
6
|
+
kind: 'payment';
|
|
7
|
+
id: Hex;
|
|
8
|
+
intent: Record<string, string>;
|
|
9
|
+
context?: {
|
|
10
|
+
chainId: 2201;
|
|
11
|
+
token: string;
|
|
12
|
+
implementation: string;
|
|
13
|
+
implementationCodeHash: string;
|
|
14
|
+
};
|
|
15
|
+
raw?: Hex;
|
|
16
|
+
hash?: Hex;
|
|
17
|
+
confirmedHash?: Hex;
|
|
18
|
+
nonce?: string;
|
|
19
|
+
status: 'created' | 'signed' | 'submitted' | 'confirmed' | 'reverted' | 'rejected';
|
|
20
|
+
block?: string;
|
|
21
|
+
feeAtomic?: string;
|
|
22
|
+
};
|
|
23
|
+
export type PaymentInput = {
|
|
24
|
+
paymentId?: string;
|
|
25
|
+
grant?: string;
|
|
26
|
+
recipient?: string;
|
|
27
|
+
amount?: string;
|
|
28
|
+
};
|
|
29
|
+
export declare const paymentFilename: (id: string) => string;
|
|
30
|
+
export declare function paymentView(j: PaymentJournal): {
|
|
31
|
+
paymentId: `0x${string}`;
|
|
32
|
+
intent: Record<string, string>;
|
|
33
|
+
amount: string;
|
|
34
|
+
amountAtomic: string;
|
|
35
|
+
tokenDecimals: number;
|
|
36
|
+
status: "submitted" | "created" | "signed" | "confirmed" | "reverted" | "rejected";
|
|
37
|
+
transactionHash: `0x${string}` | undefined;
|
|
38
|
+
nonce: string | undefined;
|
|
39
|
+
block: string | undefined;
|
|
40
|
+
feeAtomic: string | undefined;
|
|
41
|
+
nativeDecimals: number;
|
|
42
|
+
explorer: string | undefined;
|
|
43
|
+
};
|
|
44
|
+
export declare function loadPayment(ctx: AgentContext, id: string): Promise<PaymentJournal>;
|
|
45
|
+
export declare function preparePayment(ctx: AgentContext, options: PaymentInput): Promise<{
|
|
46
|
+
paymentId: `0x${string}`;
|
|
47
|
+
intent: Record<string, string>;
|
|
48
|
+
amount: string;
|
|
49
|
+
amountAtomic: string;
|
|
50
|
+
tokenDecimals: number;
|
|
51
|
+
status: "submitted" | "created" | "signed" | "confirmed" | "reverted" | "rejected";
|
|
52
|
+
transactionHash: `0x${string}` | undefined;
|
|
53
|
+
nonce: string | undefined;
|
|
54
|
+
block: string | undefined;
|
|
55
|
+
feeAtomic: string | undefined;
|
|
56
|
+
nativeDecimals: number;
|
|
57
|
+
explorer: string | undefined;
|
|
58
|
+
}>;
|
|
59
|
+
export declare function waitPayment(ctx: AgentContext, id: string, options?: WaitOptions): Promise<{
|
|
60
|
+
success: boolean;
|
|
61
|
+
paymentId: `0x${string}`;
|
|
62
|
+
intent: Record<string, string>;
|
|
63
|
+
amount: string;
|
|
64
|
+
amountAtomic: string;
|
|
65
|
+
tokenDecimals: number;
|
|
66
|
+
status: "submitted" | "created" | "signed" | "confirmed" | "reverted" | "rejected";
|
|
67
|
+
transactionHash: `0x${string}` | undefined;
|
|
68
|
+
nonce: string | undefined;
|
|
69
|
+
block: string | undefined;
|
|
70
|
+
feeAtomic: string | undefined;
|
|
71
|
+
nativeDecimals: number;
|
|
72
|
+
explorer: string | undefined;
|
|
73
|
+
} | {
|
|
74
|
+
status: string;
|
|
75
|
+
paymentId: `0x${string}`;
|
|
76
|
+
intent: Record<string, string>;
|
|
77
|
+
amount: string;
|
|
78
|
+
amountAtomic: string;
|
|
79
|
+
tokenDecimals: number;
|
|
80
|
+
transactionHash: `0x${string}` | undefined;
|
|
81
|
+
nonce: string | undefined;
|
|
82
|
+
block: string | undefined;
|
|
83
|
+
feeAtomic: string | undefined;
|
|
84
|
+
nativeDecimals: number;
|
|
85
|
+
explorer: string | undefined;
|
|
86
|
+
}>;
|
|
87
|
+
export declare function pay(ctx: AgentContext, options: PaymentInput & {
|
|
88
|
+
dryRun?: boolean;
|
|
89
|
+
noWait?: boolean;
|
|
90
|
+
timeout?: number;
|
|
91
|
+
}): Promise<{
|
|
92
|
+
paymentId: `0x${string}`;
|
|
93
|
+
intent: Record<string, string>;
|
|
94
|
+
amount: string;
|
|
95
|
+
amountAtomic: string;
|
|
96
|
+
tokenDecimals: number;
|
|
97
|
+
status: "submitted" | "created" | "signed" | "confirmed" | "reverted" | "rejected";
|
|
98
|
+
transactionHash: `0x${string}` | undefined;
|
|
99
|
+
nonce: string | undefined;
|
|
100
|
+
block: string | undefined;
|
|
101
|
+
feeAtomic: string | undefined;
|
|
102
|
+
nativeDecimals: number;
|
|
103
|
+
explorer: string | undefined;
|
|
104
|
+
} | {
|
|
105
|
+
signed: boolean;
|
|
106
|
+
submitted: boolean;
|
|
107
|
+
to: `0x${string}`;
|
|
108
|
+
data: `0x${string}`;
|
|
109
|
+
gas: bigint;
|
|
110
|
+
maxFeePerGas: bigint;
|
|
111
|
+
maxPriorityFeePerGas: bigint;
|
|
112
|
+
block: bigint;
|
|
113
|
+
status: string;
|
|
114
|
+
paymentId: `0x${string}`;
|
|
115
|
+
intent: Record<string, string>;
|
|
116
|
+
amount: string;
|
|
117
|
+
amountAtomic: string;
|
|
118
|
+
tokenDecimals: number;
|
|
119
|
+
transactionHash: `0x${string}` | undefined;
|
|
120
|
+
nonce: string | undefined;
|
|
121
|
+
feeAtomic: string | undefined;
|
|
122
|
+
nativeDecimals: number;
|
|
123
|
+
explorer: string | undefined;
|
|
124
|
+
}>;
|
|
125
|
+
export declare function listPayments(ctx: AgentContext): Promise<{
|
|
126
|
+
paymentId: `0x${string}`;
|
|
127
|
+
intent: Record<string, string>;
|
|
128
|
+
amount: string;
|
|
129
|
+
amountAtomic: string;
|
|
130
|
+
tokenDecimals: number;
|
|
131
|
+
status: "submitted" | "created" | "signed" | "confirmed" | "reverted" | "rejected";
|
|
132
|
+
transactionHash: `0x${string}` | undefined;
|
|
133
|
+
nonce: string | undefined;
|
|
134
|
+
block: string | undefined;
|
|
135
|
+
feeAtomic: string | undefined;
|
|
136
|
+
nativeDecimals: number;
|
|
137
|
+
explorer: string | undefined;
|
|
138
|
+
}[]>;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { BaseError, ContractFunctionRevertedError, createWalletClient, encodeFunctionData, formatUnits, http, keccak256, parseTransaction, recoverTransactionAddress } from 'viem';
|
|
2
|
+
import { walletAbi, tokenAbi, address, chain, CHAIN_ID, identifier, TOKEN, formatAmount, parseAmount, paymentCheck, trackTransaction, verifyNetwork, input, WalletError } from '@stablechain/agent-wallet-sdk/protocol';
|
|
3
|
+
import { newId } from './state.js';
|
|
4
|
+
export const paymentFilename = (id) => `payment-${input(() => identifier(id))}.json`;
|
|
5
|
+
const paymentData = (j) => encodeFunctionData({ abi: walletAbi, functionName: 'pay', args: [identifier(j.intent.grantId), j.id, address(j.intent.recipient), BigInt(j.intent.amount)] });
|
|
6
|
+
const check = (j) => paymentCheck(address(j.intent.alice), identifier(j.intent.grantId), j.id, address(j.intent.recipient), BigInt(j.intent.amount));
|
|
7
|
+
export function paymentView(j) {
|
|
8
|
+
return { paymentId: j.id, intent: j.intent, amount: formatAmount(BigInt(j.intent.amount)), amountAtomic: j.intent.amount, tokenDecimals: 6, status: j.status, transactionHash: j.confirmedHash ?? j.hash, nonce: j.nonce, block: j.block, feeAtomic: j.feeAtomic, nativeDecimals: 18, explorer: j.hash ? `${chain.blockExplorers.default.url}/tx/${j.confirmedHash ?? j.hash}` : undefined };
|
|
9
|
+
}
|
|
10
|
+
export async function loadPayment(ctx, id) {
|
|
11
|
+
const filename = paymentFilename(id);
|
|
12
|
+
if (!await ctx.state.has(filename))
|
|
13
|
+
throw new WalletError('PAYMENT_NOT_FOUND', 'No saved payment with this ID. Retain existing journals for recovery.');
|
|
14
|
+
const j = await ctx.state.read(filename);
|
|
15
|
+
try {
|
|
16
|
+
if (j.kind !== 'payment' || j.id !== identifier(id) || j.schemaVersion !== undefined && j.schemaVersion !== 1 || !['created', 'signed', 'submitted', 'confirmed', 'reverted', 'rejected'].includes(j.status))
|
|
17
|
+
throw new Error();
|
|
18
|
+
address(j.intent.alice);
|
|
19
|
+
address(j.intent.agent);
|
|
20
|
+
identifier(j.intent.grantId);
|
|
21
|
+
address(j.intent.recipient);
|
|
22
|
+
if (!/^\d+$/.test(j.intent.amount) || BigInt(j.intent.amount) <= 0n || BigInt(j.intent.amount) >= 2n ** 256n)
|
|
23
|
+
throw new Error();
|
|
24
|
+
if (j.confirmedHash)
|
|
25
|
+
identifier(j.confirmedHash);
|
|
26
|
+
if (j.raw) {
|
|
27
|
+
const tx = parseTransaction(j.raw);
|
|
28
|
+
if (keccak256(j.raw) !== j.hash || tx.chainId !== CHAIN_ID || tx.to?.toLowerCase() !== j.intent.alice.toLowerCase() || tx.data !== paymentData(j) || (tx.value ?? 0n) !== 0n || (await recoverTransactionAddress({ serializedTransaction: j.raw })).toLowerCase() !== j.intent.agent.toLowerCase())
|
|
29
|
+
throw new Error();
|
|
30
|
+
if (j.nonce !== undefined && j.nonce !== String(tx.nonce))
|
|
31
|
+
throw new Error();
|
|
32
|
+
j.nonce = String(tx.nonce);
|
|
33
|
+
}
|
|
34
|
+
else if (j.hash || j.confirmedHash || ['signed', 'submitted', 'confirmed', 'reverted'].includes(j.status))
|
|
35
|
+
throw new Error();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new WalletError('INVALID_PAYMENT_JOURNAL', 'Payment journal or signed transaction differs from its saved intent. Retain it for inspection.');
|
|
39
|
+
}
|
|
40
|
+
return j;
|
|
41
|
+
}
|
|
42
|
+
async function intent(ctx, options, persist) {
|
|
43
|
+
const config = await ctx.config(), agent = await ctx.identity();
|
|
44
|
+
const id = input(() => identifier(options.paymentId || newId()));
|
|
45
|
+
const old = await ctx.state.has(paymentFilename(id)) ? await loadPayment(ctx, id) : undefined;
|
|
46
|
+
const grantId = options.grant ? input(() => identifier(options.grant)) : old?.intent.grantId;
|
|
47
|
+
const recipient = options.recipient ? input(() => address(options.recipient)) : old?.intent.recipient;
|
|
48
|
+
const amount = options.amount ? String(input(() => parseAmount(options.amount))) : old?.intent.amount;
|
|
49
|
+
if (!grantId || !recipient || !amount)
|
|
50
|
+
throw new WalletError('INVALID_INPUT', 'An unsaved payment requires --grant, --recipient, and --amount.', 2);
|
|
51
|
+
const normalized = { alice: config.alice, agent, grantId, recipient, amount };
|
|
52
|
+
if (old && (Object.entries(normalized).some(([key, value]) => old.intent[key] !== value) || old.context && (old.context.chainId !== CHAIN_ID || old.context.token !== TOKEN || old.context.implementation !== config.implementation || old.context.implementationCodeHash !== config.implementationCodeHash)))
|
|
53
|
+
throw new WalletError('PAYMENT_TERMS_MISMATCH', 'Payment ID is already bound to different terms or account configuration.', 2);
|
|
54
|
+
const j = old ?? { schemaVersion: 1, kind: 'payment', id, intent: normalized, context: { chainId: CHAIN_ID, token: TOKEN, implementation: config.implementation, implementationCodeHash: config.implementationCodeHash }, status: 'created' };
|
|
55
|
+
if (!old && persist)
|
|
56
|
+
await ctx.state.write(paymentFilename(id), j);
|
|
57
|
+
return { journal: j, config };
|
|
58
|
+
}
|
|
59
|
+
export async function preparePayment(ctx, options) {
|
|
60
|
+
return ctx.state.lock(async () => paymentView((await intent(ctx, options, true)).journal));
|
|
61
|
+
}
|
|
62
|
+
async function preflight(ctx, client, config, j) {
|
|
63
|
+
await ctx.verify(client, config);
|
|
64
|
+
if (await client.readContract({ address: config.alice, abi: walletAbi, functionName: 'paymentUsed', args: [j.id] }))
|
|
65
|
+
throw new WalletError('PAYMENT_ID_CONSUMED_UNVERIFIED', 'Payment ID has already been consumed. A matching receipt is required to prove its terms.', 4, paymentView(j));
|
|
66
|
+
const agent = address(j.intent.agent), recipient = address(j.intent.recipient), amount = BigInt(j.intent.amount);
|
|
67
|
+
const [tokenBalance, nativeBalance] = await Promise.all([
|
|
68
|
+
client.readContract({ address: TOKEN, abi: tokenAbi, functionName: 'balanceOf', args: [config.alice] }),
|
|
69
|
+
client.getBalance({ address: agent }),
|
|
70
|
+
]);
|
|
71
|
+
if (tokenBalance < amount)
|
|
72
|
+
throw new WalletError('INSUFFICIENT_PAYMENT_FUNDS', 'Alice has insufficient USDT0 for this payment.', 4, paymentView(j));
|
|
73
|
+
try {
|
|
74
|
+
await client.simulateContract({ account: agent, address: config.alice, abi: walletAbi, functionName: 'pay', args: [identifier(j.intent.grantId), j.id, recipient, amount] });
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const revert = error instanceof BaseError ? error.walk(e => e instanceof ContractFunctionRevertedError) : undefined;
|
|
78
|
+
if (revert instanceof ContractFunctionRevertedError)
|
|
79
|
+
throw new WalletError(revert.data?.errorName ?? 'PAYMENT_REJECTED', 'Payment rejected by the grant rules before submission.', 4, paymentView(j));
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
const block = await client.getBlock();
|
|
83
|
+
const gasEstimate = await client.estimateGas({ account: agent, to: config.alice, data: paymentData(j) });
|
|
84
|
+
const gas = gasEstimate + gasEstimate / 5n;
|
|
85
|
+
const maxFeePerGas = (block.baseFeePerGas ?? await client.getGasPrice()) * 2n;
|
|
86
|
+
if (nativeBalance < gas * maxFeePerGas)
|
|
87
|
+
throw new WalletError('INSUFFICIENT_GAS', `Gas payer ${agent} has ${formatUnits(nativeBalance, 18)} native USDT0. Fund this signer separately, then retry the same saved ID.`, 4, paymentView(j));
|
|
88
|
+
return { to: config.alice, data: paymentData(j), gas, maxFeePerGas, maxPriorityFeePerGas: 0n, block: block.number };
|
|
89
|
+
}
|
|
90
|
+
async function observe(ctx, client, j, options = {}, locked = true) {
|
|
91
|
+
try {
|
|
92
|
+
const result = await trackTransaction(client, j.confirmedHash ?? j.hash, check(j), options);
|
|
93
|
+
j.confirmedHash = result.hash;
|
|
94
|
+
j.status = result.success ? 'confirmed' : 'reverted';
|
|
95
|
+
j.block = result.block;
|
|
96
|
+
j.feeAtomic = result.feeAtomic;
|
|
97
|
+
if (locked)
|
|
98
|
+
await ctx.state.write(paymentFilename(j.id), j);
|
|
99
|
+
else
|
|
100
|
+
await ctx.state.lock(async () => {
|
|
101
|
+
const current = await loadPayment(ctx, j.id);
|
|
102
|
+
if (current.raw !== j.raw)
|
|
103
|
+
throw new WalletError('INVALID_PAYMENT_JOURNAL', 'Signed payment changed during observation.');
|
|
104
|
+
await ctx.state.write(paymentFilename(j.id), j);
|
|
105
|
+
});
|
|
106
|
+
if (!result.success)
|
|
107
|
+
throw new WalletError('PAYMENT_REVERTED', 'Transaction reverted onchain. No payment was committed; network fees may apply.', 7, paymentView(j));
|
|
108
|
+
return { ...paymentView(j), success: true };
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (error instanceof WalletError)
|
|
112
|
+
throw error;
|
|
113
|
+
if (error instanceof Error && error.constructor === Error)
|
|
114
|
+
throw new WalletError('PAYMENT_UNVERIFIED', error.message, 7, paymentView(j));
|
|
115
|
+
throw new WalletError('PAYMENT_OUTCOME_UNKNOWN', 'Payment confirmation is unknown. Check or resume the same saved ID.', 6, { ...paymentView(j), status: 'unknown' });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function hasReceipt(client, j) {
|
|
119
|
+
try {
|
|
120
|
+
return await client.getTransactionReceipt({ hash: j.confirmedHash ?? j.hash });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error.name === 'TransactionReceiptNotFoundError')
|
|
124
|
+
return undefined;
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export async function waitPayment(ctx, id, options = {}) {
|
|
129
|
+
// Do not hold a mutation lock while polling. Only commit the verified observation.
|
|
130
|
+
const j = await loadPayment(ctx, id);
|
|
131
|
+
if (!j.raw)
|
|
132
|
+
return { ...paymentView(j), status: 'not_submitted' };
|
|
133
|
+
const { client } = await ctx.public();
|
|
134
|
+
try {
|
|
135
|
+
await verifyNetwork(client);
|
|
136
|
+
return await observe(ctx, client, j, options, false);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error instanceof WalletError)
|
|
140
|
+
throw error;
|
|
141
|
+
throw new WalletError('PAYMENT_OUTCOME_UNKNOWN', 'Cannot observe the saved transaction. Check the same ID later.', 6, { ...paymentView(j), status: 'unknown' });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function reconcileSigner(ctx, client, j) {
|
|
145
|
+
for (const filename of await ctx.state.files('payment-')) {
|
|
146
|
+
const id = filename.slice(8, -5);
|
|
147
|
+
if (id === j.id)
|
|
148
|
+
continue;
|
|
149
|
+
const other = await loadPayment(ctx, id);
|
|
150
|
+
if (!other.raw || ['confirmed', 'reverted'].includes(other.status) || other.intent.agent !== j.intent.agent)
|
|
151
|
+
continue;
|
|
152
|
+
if (await hasReceipt(client, other)) {
|
|
153
|
+
try {
|
|
154
|
+
await observe(ctx, client, other);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
if (!(e instanceof WalletError) || e.code !== 'PAYMENT_REVERTED')
|
|
158
|
+
throw e;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else
|
|
162
|
+
throw new WalletError('UNRESOLVED_PAYMENT', 'Resolve the previous signed payment before signing another transaction.', 6, paymentView(other));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export async function pay(ctx, options) {
|
|
166
|
+
if (!options.paymentId)
|
|
167
|
+
throw new WalletError('PAYMENT_ID_REQUIRED', 'Use --payment-id, or save a new ID with payments prepare first.', 2);
|
|
168
|
+
const execute = async () => {
|
|
169
|
+
const { journal: j, config } = await intent(ctx, options, !options.dryRun);
|
|
170
|
+
const { client } = await ctx.public();
|
|
171
|
+
if (options.dryRun) {
|
|
172
|
+
const result = await preflight(ctx, client, config, j);
|
|
173
|
+
return { ...paymentView(j), status: 'simulated', ...result, signed: false, submitted: false };
|
|
174
|
+
}
|
|
175
|
+
if (j.raw) {
|
|
176
|
+
await verifyNetwork(client);
|
|
177
|
+
if (await hasReceipt(client, j))
|
|
178
|
+
return observe(ctx, client, j, { timeout: options.timeout });
|
|
179
|
+
if (j.status === 'reverted' || j.status === 'confirmed')
|
|
180
|
+
throw new WalletError('PAYMENT_OUTCOME_UNKNOWN', 'Saved receipt is no longer available. Check the same payment; do not submit a new intent.', 6, paymentView(j));
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
await reconcileSigner(ctx, client, j);
|
|
184
|
+
let request;
|
|
185
|
+
try {
|
|
186
|
+
request = await preflight(ctx, client, config, j);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error instanceof WalletError && error.exitCode === 4) {
|
|
190
|
+
j.status = 'rejected';
|
|
191
|
+
await ctx.state.write(paymentFilename(j.id), j);
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
const account = await ctx.account();
|
|
196
|
+
const wallet = createWalletClient({ account, chain, transport: http(config.rpcUrl) });
|
|
197
|
+
const prepared = await wallet.prepareTransactionRequest({ account, chain, to: request.to, data: request.data, gas: request.gas, maxFeePerGas: request.maxFeePerGas, maxPriorityFeePerGas: 0n, nonce: await client.getTransactionCount({ address: account.address, blockTag: 'pending' }) });
|
|
198
|
+
j.raw = await wallet.signTransaction(prepared);
|
|
199
|
+
j.hash = keccak256(j.raw);
|
|
200
|
+
j.nonce = String(prepared.nonce);
|
|
201
|
+
j.status = 'signed';
|
|
202
|
+
await ctx.state.write(paymentFilename(j.id), j);
|
|
203
|
+
}
|
|
204
|
+
await ctx.verify(client, config);
|
|
205
|
+
try {
|
|
206
|
+
await client.sendRawTransaction({ serializedTransaction: j.raw });
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
try {
|
|
210
|
+
await client.getTransaction({ hash: j.hash });
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
throw new WalletError('PAYMENT_OUTCOME_UNKNOWN', 'Submission outcome is unknown. Check or resume the same saved ID.', 6, { ...paymentView(j), status: 'unknown' });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
j.status = 'submitted';
|
|
217
|
+
await ctx.state.write(paymentFilename(j.id), j);
|
|
218
|
+
if (options.noWait)
|
|
219
|
+
return paymentView(j);
|
|
220
|
+
return observe(ctx, client, j, { timeout: options.timeout });
|
|
221
|
+
};
|
|
222
|
+
try {
|
|
223
|
+
return await (options.dryRun ? execute() : ctx.state.lock(execute));
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error instanceof WalletError || options.dryRun)
|
|
227
|
+
throw error;
|
|
228
|
+
const saved = await ctx.state.has(paymentFilename(options.paymentId)) ? await loadPayment(ctx, options.paymentId) : undefined;
|
|
229
|
+
if (saved?.raw)
|
|
230
|
+
throw new WalletError('PAYMENT_OUTCOME_UNKNOWN', 'The signed payment is recoverable. Check or resume the same saved ID.', 6, { ...paymentView(saved), status: 'unknown' });
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
export async function listPayments(ctx) {
|
|
235
|
+
return Promise.all((await ctx.state.files('payment-')).map(async (filename) => paymentView(await loadPayment(ctx, filename.slice(8, -5)))));
|
|
236
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const newId: () => `0x${string}`;
|
|
2
|
+
export declare function exists(path: string): Promise<boolean>;
|
|
3
|
+
export declare function secureDir(path: string, create?: boolean): Promise<void>;
|
|
4
|
+
export declare function readJson<T>(path: string): Promise<T>;
|
|
5
|
+
export declare function saveJson(path: string, value: unknown, publicMetadata?: boolean): Promise<void>;
|
|
6
|
+
export declare class StateStore {
|
|
7
|
+
readonly directory: string;
|
|
8
|
+
constructor(directory: string);
|
|
9
|
+
path(name: string): string;
|
|
10
|
+
read<T>(name: string): Promise<T>;
|
|
11
|
+
has(name: string): Promise<boolean>;
|
|
12
|
+
write(name: string, value: unknown): Promise<void>;
|
|
13
|
+
files(prefix: string): Promise<string[]>;
|
|
14
|
+
lock<T>(fn: () => Promise<T>): Promise<T>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { lstat, mkdir, open, readFile, readdir, rename, unlink } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { WalletError } from '@stablechain/agent-wallet-sdk/protocol';
|
|
5
|
+
export const newId = () => `0x${randomBytes(32).toString('hex')}`;
|
|
6
|
+
export async function exists(path) {
|
|
7
|
+
try {
|
|
8
|
+
await lstat(path);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
if (e.code === 'ENOENT')
|
|
13
|
+
return false;
|
|
14
|
+
throw e;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function secureDir(path, create = true) {
|
|
18
|
+
if (create)
|
|
19
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
20
|
+
const info = await lstat(path);
|
|
21
|
+
if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077))
|
|
22
|
+
throw new WalletError('UNSAFE_STATE', `State directory must be private (chmod 700): ${path}`);
|
|
23
|
+
}
|
|
24
|
+
export async function readJson(path) {
|
|
25
|
+
const info = await lstat(path);
|
|
26
|
+
if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077))
|
|
27
|
+
throw new WalletError('UNSAFE_STATE', `Local state must be a private regular file (chmod 600): ${path}`);
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new WalletError('INVALID_STATE', `Invalid JSON in ${path}. Retain this file for recovery.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function saveJson(path, value, publicMetadata = false) {
|
|
36
|
+
if (await exists(path)) {
|
|
37
|
+
const info = await lstat(path);
|
|
38
|
+
if (!info.isFile() || info.isSymbolicLink() || (!publicMetadata && (info.mode & 0o077)))
|
|
39
|
+
throw new WalletError('UNSAFE_STATE', `Refusing to replace unsafe local state: ${path}`);
|
|
40
|
+
}
|
|
41
|
+
const temp = `${path}.${randomBytes(8).toString('hex')}.tmp`;
|
|
42
|
+
const file = await open(temp, 'wx', 0o600);
|
|
43
|
+
try {
|
|
44
|
+
await file.writeFile(JSON.stringify(value, null, 2) + '\n');
|
|
45
|
+
await file.sync();
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await file.close();
|
|
49
|
+
}
|
|
50
|
+
await rename(temp, path);
|
|
51
|
+
const dir = await open(dirname(path), 'r');
|
|
52
|
+
try {
|
|
53
|
+
await dir.sync();
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await dir.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export class StateStore {
|
|
60
|
+
directory;
|
|
61
|
+
constructor(directory) { this.directory = resolve(directory); }
|
|
62
|
+
path(name) {
|
|
63
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(name))
|
|
64
|
+
throw new WalletError('INVALID_INPUT', 'Invalid state filename.', 2);
|
|
65
|
+
return resolve(this.directory, name);
|
|
66
|
+
}
|
|
67
|
+
async read(name) { await secureDir(this.directory, false); return readJson(this.path(name)); }
|
|
68
|
+
async has(name) { return exists(this.path(name)); }
|
|
69
|
+
async write(name, value) { await secureDir(this.directory, false); await saveJson(this.path(name), value); }
|
|
70
|
+
async files(prefix) {
|
|
71
|
+
if (!await exists(this.directory))
|
|
72
|
+
return [];
|
|
73
|
+
await secureDir(this.directory, false);
|
|
74
|
+
return (await readdir(this.directory)).filter(name => name.startsWith(prefix) && name.endsWith('.json')).sort();
|
|
75
|
+
}
|
|
76
|
+
async lock(fn) {
|
|
77
|
+
await secureDir(this.directory);
|
|
78
|
+
const path = this.path('command.lock');
|
|
79
|
+
let lock;
|
|
80
|
+
try {
|
|
81
|
+
lock = await open(path, 'wx', 0o600);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code === 'EEXIST')
|
|
85
|
+
throw new WalletError('STATE_LOCKED', `Another wallet command holds ${path}. Verify its process stopped before removing a stale lock.`, 8);
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
await lock.writeFile(String(process.pid));
|
|
90
|
+
return await fn();
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
await lock.close();
|
|
94
|
+
await unlink(path);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { address, identifier, validateConfig, listGrants, readBalances, readGrant, parseAmount, fundingSuggestion, paymentGasReserve, duration, input, WalletError } from '@stablechain/agent-wallet-sdk/protocol';
|
|
5
|
+
import { AgentContext } from './agent/context.js';
|
|
6
|
+
import { grantLink, waitGrant } from './agent/grants.js';
|
|
7
|
+
import { listPayments, loadPayment, pay, paymentView, preparePayment, waitPayment } from './agent/payments.js';
|
|
8
|
+
import { publicError, render } from './output.js';
|
|
9
|
+
const help = `agent-wallet — Stable Testnet agent payments
|
|
10
|
+
|
|
11
|
+
init Create or verify a private agent signer
|
|
12
|
+
whoami Show the public agent address
|
|
13
|
+
config import --file FILE Import operator-provided public settings
|
|
14
|
+
config show Show public settings
|
|
15
|
+
status Account, balances and unresolved payments
|
|
16
|
+
doctor Diagnose local state and account setup
|
|
17
|
+
grants link --recipient ADDRESS --budget AMOUNT (--expires UTC | --expires-in 24h)
|
|
18
|
+
[--agent ADDRESS] [--grant-id ID]
|
|
19
|
+
grants list [--all-agents] [--status STATUS] [--limit 20] [--cursor CURSOR]
|
|
20
|
+
grants show ID Grant terms and remaining budget
|
|
21
|
+
grants wait ID [--timeout 300s] Wait for exact saved authorization
|
|
22
|
+
gas check --grant ID Agent gas reserve and funding suggestion
|
|
23
|
+
payments prepare --grant ID --recipient ADDRESS --amount AMOUNT [--payment-id ID]
|
|
24
|
+
pay --payment-id ID [--grant ID --recipient ADDRESS --amount AMOUNT]
|
|
25
|
+
[--dry-run | --no-wait] [--timeout 120s]
|
|
26
|
+
payments list Local payment journals
|
|
27
|
+
payments show ID Inspect without broadcasting
|
|
28
|
+
payments wait ID [--timeout 120s] Observe without broadcasting
|
|
29
|
+
payments resume ID [--no-wait] [--timeout 120s]
|
|
30
|
+
|
|
31
|
+
Global: --json --state-dir DIR --help --version
|
|
32
|
+
Alice authorizes grants and funds gas through the approval UI.
|
|
33
|
+
`;
|
|
34
|
+
const allowed = {
|
|
35
|
+
init: [], whoami: [], status: [], doctor: [], 'config show': [], 'config import': ['file'],
|
|
36
|
+
'grants link': ['recipient', 'budget', 'expires', 'expires-in', 'agent', 'grant-id'],
|
|
37
|
+
'grants list': ['all-agents', 'status', 'limit', 'cursor'], 'grants show': [], 'grants wait': ['timeout'],
|
|
38
|
+
'gas check': ['grant'], 'payments prepare': ['grant', 'recipient', 'amount', 'payment-id'],
|
|
39
|
+
pay: ['grant', 'recipient', 'amount', 'payment-id', 'dry-run', 'no-wait', 'timeout'],
|
|
40
|
+
'payments list': [], 'payments show': [], 'payments wait': ['timeout'], 'payments resume': ['no-wait', 'timeout'],
|
|
41
|
+
};
|
|
42
|
+
const globalFlags = ['json', 'state-dir', 'help', 'version'];
|
|
43
|
+
const booleans = new Set(['json', 'help', 'version', 'all-agents', 'dry-run', 'no-wait']);
|
|
44
|
+
let command = 'agent-wallet';
|
|
45
|
+
let asJson = process.argv.slice(2).includes('--json');
|
|
46
|
+
try {
|
|
47
|
+
const names = [...new Set([...globalFlags, ...Object.values(allowed).flat()])];
|
|
48
|
+
const parsed = input(() => parseArgs({ options: Object.fromEntries(names.map(name => [name, { type: booleans.has(name) ? 'boolean' : 'string' }])), allowPositionals: true, strict: true }));
|
|
49
|
+
const opt = parsed.values;
|
|
50
|
+
asJson = Boolean(opt.json);
|
|
51
|
+
if (opt.help || opt.version || parsed.positionals.length === 0) {
|
|
52
|
+
const data = opt.version
|
|
53
|
+
? JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
54
|
+
: help;
|
|
55
|
+
render(opt.version ? 'version' : 'help', data, asJson);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const positions = [...parsed.positionals];
|
|
59
|
+
command = positions.shift();
|
|
60
|
+
if (['config', 'grants', 'gas', 'payments'].includes(command))
|
|
61
|
+
command += ` ${positions.shift() || ''}`;
|
|
62
|
+
if (!(command in allowed))
|
|
63
|
+
throw new WalletError('UNKNOWN_COMMAND', `Unknown command: ${command}. Use --help.`, 2);
|
|
64
|
+
const positionalCount = ['grants show', 'grants wait', 'payments show', 'payments wait', 'payments resume'].includes(command) ? 1 : 0;
|
|
65
|
+
if (positions.length !== positionalCount)
|
|
66
|
+
throw new WalletError('INVALID_INPUT', positionalCount ? 'Supply exactly one full ID.' : 'Unexpected positional argument.', 2);
|
|
67
|
+
for (const name of Object.keys(opt))
|
|
68
|
+
if (!globalFlags.includes(name) && !allowed[command].includes(name))
|
|
69
|
+
throw new WalletError('INVALID_INPUT', `--${name} is not supported by ${command}.`, 2);
|
|
70
|
+
if (opt['dry-run'] && opt['no-wait'])
|
|
71
|
+
throw new WalletError('INVALID_INPUT', '--dry-run and --no-wait cannot be combined.', 2);
|
|
72
|
+
const value = (name) => typeof opt[name] === 'string' ? String(opt[name]) : undefined;
|
|
73
|
+
const required = (name) => { const result = value(name); if (!result)
|
|
74
|
+
throw new WalletError('INVALID_INPUT', `Missing --${name}.`, 2); return result; };
|
|
75
|
+
// Reject syntax and scalar validation errors before creating private state.
|
|
76
|
+
for (const name of ['grant', 'grant-id', 'payment-id'])
|
|
77
|
+
if (opt[name])
|
|
78
|
+
input(() => identifier(required(name)));
|
|
79
|
+
for (const name of ['recipient', 'agent'])
|
|
80
|
+
if (opt[name])
|
|
81
|
+
input(() => address(required(name)));
|
|
82
|
+
for (const name of ['amount', 'budget'])
|
|
83
|
+
if (opt[name])
|
|
84
|
+
input(() => parseAmount(required(name)));
|
|
85
|
+
if (positionalCount)
|
|
86
|
+
input(() => identifier(positions[0]));
|
|
87
|
+
if (command === 'payments prepare')
|
|
88
|
+
for (const name of ['grant', 'recipient', 'amount'])
|
|
89
|
+
required(name);
|
|
90
|
+
if (command === 'grants link') {
|
|
91
|
+
required('recipient');
|
|
92
|
+
required('budget');
|
|
93
|
+
if (Boolean(opt.expires) === Boolean(opt['expires-in']))
|
|
94
|
+
throw new WalletError('INVALID_INPUT', 'Supply exactly one of --expires or --expires-in.', 2);
|
|
95
|
+
if (opt['expires-in'])
|
|
96
|
+
duration(required('expires-in'), 'expiry duration');
|
|
97
|
+
if (opt.expires)
|
|
98
|
+
input(() => {
|
|
99
|
+
const expiry = required('expires');
|
|
100
|
+
if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/.test(expiry) || new Date(expiry).toISOString().replace('.000Z', 'Z') !== expiry)
|
|
101
|
+
throw new Error('Expiry must be a valid UTC timestamp.');
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const ctx = new AgentContext(value('state-dir'));
|
|
105
|
+
const timeout = opt.timeout ? duration(required('timeout'), 'timeout') : undefined;
|
|
106
|
+
const paymentOptions = { paymentId: value('payment-id'), grant: value('grant'), recipient: value('recipient'), amount: value('amount') };
|
|
107
|
+
let data;
|
|
108
|
+
switch (command) {
|
|
109
|
+
case 'init':
|
|
110
|
+
data = await ctx.init();
|
|
111
|
+
break;
|
|
112
|
+
case 'whoami':
|
|
113
|
+
data = { address: await ctx.identity(), stateDir: ctx.state.directory };
|
|
114
|
+
break;
|
|
115
|
+
case 'config show':
|
|
116
|
+
data = await ctx.config();
|
|
117
|
+
break;
|
|
118
|
+
case 'config import': {
|
|
119
|
+
const text = await readFile(required('file'), 'utf8');
|
|
120
|
+
let raw;
|
|
121
|
+
try {
|
|
122
|
+
raw = JSON.parse(text);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new WalletError('INVALID_INPUT', 'Configuration must contain valid public wallet JSON.', 2);
|
|
126
|
+
}
|
|
127
|
+
const config = input(() => validateConfig(raw));
|
|
128
|
+
data = await ctx.state.lock(async () => {
|
|
129
|
+
if ((await ctx.state.files('payment-')).length && await ctx.state.has('config.json')) {
|
|
130
|
+
const previous = await ctx.config();
|
|
131
|
+
if (previous.alice !== config.alice || previous.implementation !== config.implementation || previous.implementationCodeHash !== config.implementationCodeHash || previous.rpcUrl !== config.rpcUrl)
|
|
132
|
+
throw new WalletError('CONFIG_IN_USE', 'Payment records bind this directory to its account and RPC. Use a separate state directory.');
|
|
133
|
+
}
|
|
134
|
+
const { publicClient } = await import('@stablechain/agent-wallet-sdk/protocol');
|
|
135
|
+
await ctx.verify(publicClient(config.rpcUrl), config);
|
|
136
|
+
await ctx.state.write('config.json', config);
|
|
137
|
+
return config;
|
|
138
|
+
});
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'doctor': {
|
|
142
|
+
const checks = [];
|
|
143
|
+
for (const [name, check] of [
|
|
144
|
+
['agent', () => ctx.account()],
|
|
145
|
+
['payments', () => listPayments(ctx)],
|
|
146
|
+
['account', async () => { const { config, client } = await ctx.public(); await ctx.verify(client, config); }],
|
|
147
|
+
['lock', async () => { if (await ctx.state.has('command.lock'))
|
|
148
|
+
throw new WalletError('STATE_LOCKED', 'A command lock exists. Verify its process before removing a stale lock.', 8); }],
|
|
149
|
+
]) {
|
|
150
|
+
try {
|
|
151
|
+
await check();
|
|
152
|
+
checks.push({ name, ok: true });
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const e = publicError(error);
|
|
156
|
+
checks.push({ name, ok: false, code: e.code, message: e.message });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
data = { checks, ready: checks.every(check => check.ok), stateDir: ctx.state.directory };
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case 'status': {
|
|
163
|
+
const payments = await listPayments(ctx);
|
|
164
|
+
const identity = await ctx.state.has('agent-key.json') ? await ctx.identity() : undefined;
|
|
165
|
+
if (!await ctx.state.has('config.json')) {
|
|
166
|
+
data = { configured: false, agent: identity, stateDir: ctx.state.directory, payments };
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
const { config, client } = await ctx.public();
|
|
170
|
+
await ctx.verify(client, config);
|
|
171
|
+
data = { configured: true, verified: true, ...(await readBalances(client, config, identity)), unresolvedPayments: payments.filter(j => !['confirmed', 'reverted', 'rejected'].includes(j.status)), stateDir: ctx.state.directory };
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case 'grants link':
|
|
175
|
+
data = await grantLink(ctx, opt);
|
|
176
|
+
break;
|
|
177
|
+
case 'grants wait':
|
|
178
|
+
data = await waitGrant(ctx, positions[0], timeout);
|
|
179
|
+
break;
|
|
180
|
+
case 'grants list': {
|
|
181
|
+
const { config, client } = await ctx.public();
|
|
182
|
+
await ctx.verify(client, config);
|
|
183
|
+
if (opt.status && !['unknown', 'pending', 'active', 'rejected', 'revoked', 'expired', 'exhausted'].includes(required('status').toLowerCase()))
|
|
184
|
+
throw new WalletError('INVALID_INPUT', 'Unknown grant status.', 2);
|
|
185
|
+
data = await listGrants(client, config, { agent: opt['all-agents'] ? undefined : await ctx.identity(), limit: opt.limit ? Number(required('limit')) : undefined, cursor: value('cursor'), status: value('status') });
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case 'grants show': {
|
|
189
|
+
const id = input(() => identifier(positions[0]));
|
|
190
|
+
const { config, client } = await ctx.public();
|
|
191
|
+
await ctx.verify(client, config);
|
|
192
|
+
const row = await readGrant(client, config, id);
|
|
193
|
+
data = { ...row, matchesAgent: await ctx.state.has('agent-key.json') ? row.grant.agent === await ctx.identity() : false };
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case 'gas check': {
|
|
197
|
+
const id = input(() => identifier(required('grant')));
|
|
198
|
+
const { config, client } = await ctx.public();
|
|
199
|
+
await ctx.verify(client, config);
|
|
200
|
+
const row = await readGrant(client, config, id);
|
|
201
|
+
const approval = new URL(config.approvalUrl);
|
|
202
|
+
approval.searchParams.set('grant', id);
|
|
203
|
+
const balance = await client.getBalance({ address: row.grant.agent });
|
|
204
|
+
try {
|
|
205
|
+
const reserve = await paymentGasReserve(client, config, id, row.grant);
|
|
206
|
+
data = { agent: row.grant.agent, balance, reserve, suggestedTopUp: fundingSuggestion(balance, reserve), ready: balance >= reserve, available: true, decimals: 18, approvalUrl: approval.href };
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
data = { agent: row.grant.agent, balance, ready: false, available: false, decimals: 18, approvalUrl: approval.href };
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case 'payments prepare':
|
|
214
|
+
data = await preparePayment(ctx, paymentOptions);
|
|
215
|
+
break;
|
|
216
|
+
case 'pay':
|
|
217
|
+
data = await pay(ctx, { ...paymentOptions, dryRun: Boolean(opt['dry-run']), noWait: Boolean(opt['no-wait']), timeout });
|
|
218
|
+
break;
|
|
219
|
+
case 'payments list':
|
|
220
|
+
data = await listPayments(ctx);
|
|
221
|
+
break;
|
|
222
|
+
case 'payments show':
|
|
223
|
+
data = paymentView(await loadPayment(ctx, positions[0]));
|
|
224
|
+
break;
|
|
225
|
+
case 'payments wait':
|
|
226
|
+
data = await waitPayment(ctx, positions[0], { timeout });
|
|
227
|
+
break;
|
|
228
|
+
case 'payments resume':
|
|
229
|
+
data = await pay(ctx, { paymentId: positions[0], noWait: Boolean(opt['no-wait']), timeout });
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
render(command, data, asJson);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
const e = publicError(error);
|
|
237
|
+
render(command, undefined, asJson, e);
|
|
238
|
+
process.exitCode = e.exitCode;
|
|
239
|
+
}
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { WalletError } from '@stablechain/agent-wallet-sdk/protocol';
|
|
2
|
+
export declare const json: (value: unknown) => string;
|
|
3
|
+
export declare function publicError(error: unknown): WalletError;
|
|
4
|
+
export declare function render(command: string, data: unknown, asJson: boolean, error?: WalletError): void;
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { BaseError } from 'viem';
|
|
2
|
+
import { WalletError } from '@stablechain/agent-wallet-sdk/protocol';
|
|
3
|
+
export const json = (value) => JSON.stringify(value, (_, v) => typeof v === 'bigint' ? v.toString() : v, 2);
|
|
4
|
+
export function publicError(error) {
|
|
5
|
+
if (error instanceof WalletError)
|
|
6
|
+
return error;
|
|
7
|
+
if (error instanceof BaseError)
|
|
8
|
+
return new WalletError('RPC_UNAVAILABLE', 'RPC request failed. Retry a status check; preserve saved payment IDs.', 5);
|
|
9
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
10
|
+
return new WalletError('STATE_REQUIRED', 'Required local state is missing. Run init and import public configuration.');
|
|
11
|
+
return new WalletError('COMMAND_FAILED', 'Wallet command failed. Retain local state for inspection.');
|
|
12
|
+
}
|
|
13
|
+
export function render(command, data, asJson, error) {
|
|
14
|
+
const result = {
|
|
15
|
+
schemaVersion: 1, ok: !error, command,
|
|
16
|
+
...(error ? { error: { code: error.code, message: error.message } } : {}),
|
|
17
|
+
data: error ? error.data ?? null : data,
|
|
18
|
+
...(error?.exitCode === 6 ? { recovery: { action: command.startsWith('grants') ? 'grants wait' : 'payments wait', ...(error.data && typeof error.data === 'object' && 'paymentId' in error.data ? { paymentId: error.data.paymentId } : {}) } } : {}),
|
|
19
|
+
};
|
|
20
|
+
if (asJson)
|
|
21
|
+
process.stdout.write(json(result) + '\n');
|
|
22
|
+
else if (error)
|
|
23
|
+
process.stderr.write(`${error.code}: ${error.message}\n${error.data ? json(error.data) + '\n' : ''}`);
|
|
24
|
+
else if (data && typeof data === 'object' && 'paymentId' in data && 'intent' in data && 'status' in data) {
|
|
25
|
+
const payment = data;
|
|
26
|
+
process.stdout.write(`Payment ${payment.status}\n Payment ID ${payment.paymentId}\n Grant ID ${payment.intent.grantId}\n Amount ${payment.amount} USDT0\n Payment source Alice: ${payment.intent.alice}\n Gas payer Agent: ${payment.intent.agent}\n${payment.explorer ? ` Transaction ${payment.explorer}\n` : ''}`);
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
process.stdout.write(typeof data === 'string' ? data + '\n' : json(data) + '\n');
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stablechain/agent-wallet-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"agent-wallet": "./dist/main.js"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
"./agent": {
|
|
10
|
+
"types": "./dist/agent/index.d.ts",
|
|
11
|
+
"import": "./dist/agent/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@stablechain/agent-wallet-sdk": "0.1.0",
|
|
22
|
+
"viem": "^2.38.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^22.15.0",
|
|
26
|
+
"typescript": "^5.8.0",
|
|
27
|
+
"vitest": "^3.2.0"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.json && node scripts/executable.mjs",
|
|
31
|
+
"wallet": "node dist/main.js",
|
|
32
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
33
|
+
"test": "vitest run --config vitest.config.ts",
|
|
34
|
+
"prepack": "npm run build"
|
|
35
|
+
},
|
|
36
|
+
"description": "Agent signer, grant discovery, bounded payments, and recovery for Agent Wallet on Stable Testnet.",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/stablelabs/agentic.stable.xyz.git",
|
|
40
|
+
"directory": "packages/cli"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/stablelabs/agentic.stable.xyz/tree/main/packages/cli#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/stablelabs/agentic.stable.xyz/issues"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"registry": "https://registry.npmjs.org/"
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"stablechain",
|
|
52
|
+
"agent-wallet",
|
|
53
|
+
"eip-7702",
|
|
54
|
+
"payments",
|
|
55
|
+
"testnet",
|
|
56
|
+
"cli"
|
|
57
|
+
]
|
|
58
|
+
}
|