@ola-893/agentpay-cli 0.2.0 → 0.4.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.
@@ -0,0 +1,56 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { usdc } from './settings.mjs';
3
+
4
+ export const activities = Object.freeze({
5
+ 'spot-trading': 'Mirror an eligible spot swap.',
6
+ 'staking-moves': 'Follow validator or staking pool allocations.',
7
+ 'liquidity-ranges': 'Mirror concentrated-liquidity ranges.',
8
+ 'prediction-markets': 'Mirror outcome positions.',
9
+ 'portfolio-rebalance': 'Follow portfolio target weights.',
10
+ 'yield-reallocation': 'Follow lending or yield-vault allocations.',
11
+ 'reward-reinvestment': 'Compound rewards following a published strategy.',
12
+ });
13
+
14
+ export const spot = 'spot-trading';
15
+ export const assetId = value => typeof value === 'string' && /^[a-z0-9][a-z0-9:._-]{0,155}$/i.test(value);
16
+ export const rawAmount = value => typeof value === 'string' && /^[1-9][0-9]{0,37}$/.test(value);
17
+
18
+ export function spotOrder({ from, to, amount }) {
19
+ if (!assetId(from) || !assetId(to) || from === to || !rawAmount(amount)) throw new Error('Specify distinct --from and --to asset IDs and --amount-raw in the input asset’s atomic units.');
20
+ return { origin_asset: from, destination_asset: to, amount };
21
+ }
22
+
23
+ export function executionPolicy(flags, previous = {}) {
24
+ const assets = flags.assets?.split(',') ?? previous.assets;
25
+ const maxInput = flags['max-input-raw'] ?? previous.maxInput;
26
+ const inputAsset = flags['input-asset'] ?? previous.inputAsset;
27
+ const fixedInput = flags['copy-input-raw'] ?? previous.fixedInput;
28
+ const maxAge = Number(flags['max-age'] ?? previous.maxAge ?? 60);
29
+ const slippageBps = Number(flags['max-slippage-bps'] ?? previous.slippageBps ?? 50);
30
+ if (!assets?.length || assets.length > 32 || !assets.every(assetId) || !assets.includes(inputAsset) || !rawAmount(maxInput) || !rawAmount(fixedInput) || BigInt(fixedInput) > BigInt(maxInput)) throw new Error('Set --assets, --input-asset, --max-input-raw and --copy-input-raw. Use one input asset so caps have a consistent unit.');
31
+ if (!Number.isInteger(maxAge) || maxAge < 1 || maxAge > 3600 || !Number.isInteger(slippageBps) || slippageBps < 0 || slippageBps > 500) throw new Error('Use --max-age 1–3600 seconds and --max-slippage-bps 0–500.');
32
+ return { assets: [...new Set(assets)], inputAsset, maxInput, fixedInput, maxAge, slippageBps,
33
+ ...(flags['max-notional'] ? { maxNotionalRawUSDC: usdc(flags['max-notional']) } : previous.maxNotionalRawUSDC ? { maxNotionalRawUSDC: previous.maxNotionalRawUSDC } : {}) };
34
+ }
35
+
36
+ export function proposedOrder(action, policy, now = Date.now()) {
37
+ if (!policy) return { reason: 'EXECUTION_POLICY_REQUIRED' };
38
+ if (action.at > now + 5000 || now - action.at > policy.maxAge * 1000 || now >= action.expiresAt) return { reason: 'ACTION_EXPIRED' };
39
+ if (action.from !== policy.inputAsset || !policy.assets.includes(action.to)) return { reason: 'ASSET_NOT_ALLOWED' };
40
+ return { order: spotOrder({ from: action.from, to: action.to, amount: policy.fixedInput }) };
41
+ }
42
+
43
+ export function checkQuote(quote, order, action, policy) {
44
+ if (!rawAmount(quote.amountOut) || !rawAmount(quote.amountIn) || quote.amountIn !== order.amount) return 'UNUSABLE_QUOTE';
45
+ const expected = BigInt(action.amountOut) * BigInt(order.amount);
46
+ const observed = BigInt(quote.amountOut) * BigInt(action.amountIn);
47
+ if (observed * 10000n < expected * BigInt(10000 - policy.slippageBps)) return 'PRICE_MOVED';
48
+ if (policy.maxNotionalRawUSDC) {
49
+ let usd;
50
+ try { usd = BigInt(usdc(String(quote.amountInUsd))); } catch { return 'USD_PRICE_UNAVAILABLE'; }
51
+ if (usd > BigInt(policy.maxNotionalRawUSDC)) return 'NOTIONAL_LIMIT';
52
+ }
53
+ return null;
54
+ }
55
+
56
+ export const operationKey = (agentId, actionId) => createHash('sha256').update(`${agentId}:${actionId}`).digest('hex');
@@ -0,0 +1,220 @@
1
+ import { resolve } from 'node:path';
2
+ import { readAgentState, withAgentState, agentSetupState, createAgent, listAgents, selectAgent } from './agents.mjs';
3
+ import { initProject } from './config.mjs';
4
+ import { activities, executionPolicy, spot, spotOrder } from './copy-activity.mjs';
5
+ import { intentsConfig } from './copy-intents.mjs';
6
+ import { configurePublish, getListing, originOf, publicationHistory, subscribers } from './copy-feed.mjs';
7
+ import { changeMode, decideAction, previewCopy, reconcileCopy, runCopyAgent, runSpotTask, startCopy, stopCopy } from './copy-runtime.mjs';
8
+ import { settings, setLimits, setRuntime, reconcileLimits, usdc } from './settings.mjs';
9
+ import { setupTestnet } from './setup.mjs';
10
+ import { payout, reconcilePayouts } from './vault-money.mjs';
11
+ import { readKey } from '@ola-893/agentpay-core/internal-config';
12
+ import { inspectNearProfile, NearAgentPayClient } from '@ola-893/agentpay-core/near';
13
+
14
+ export const copyFlags = {
15
+ 'agent:create': '', 'agent:list': '', 'agent:use': '', 'agent:show': '',
16
+ 'agent:setup': 'network deployment owner-id owner-key recipient vault-name agent-name rpc service deploy',
17
+ 'task:list': '', 'task:enable': 'intents-agent-id intents-key-env intents-grant-env live confidential', 'task:disable': '',
18
+ 'task:run': 'from to amount-raw execute',
19
+ 'publish:set': 'activities url port rate duration recipient nova-group nova-account nova-network nova-key-env performance-file',
20
+ 'publish:start': '', 'publish:stop': '', 'publish:status': '', 'publish:history': '',
21
+ 'earnings:show': '', 'earnings:claim': 'from recipient-key', discover: 'activity url id',
22
+ 'copy:preview': 'url', 'copy:start': 'url activity', 'copy:mode': '', 'copy:pending': '', 'copy:approve': '', 'copy:reject': '',
23
+ 'copy:pause': '', 'copy:resume': '', 'copy:stop': '', 'copy:status': '', 'copy:history': '',
24
+ 'guard:show': '', 'guard:set': 'budget max-stream max-rate max-duration recipient owner-key max-5xx max-transport-errors max-health-failures idle-timeout max-requests reuse poll-seconds close-on-exit failure-cooldown assets input-asset max-input-raw copy-input-raw max-age max-slippage-bps max-notional stale-seconds feed-failures',
25
+ withdraw: 'owner-key', balance: '', status: '',
26
+ };
27
+
28
+ export const copyPositionals = new Set(['agent:create', 'agent:use', 'agent:show', 'task:enable', 'task:disable', 'task:run', 'copy:start', 'copy:mode', 'copy:approve', 'copy:reject', 'guard:set']);
29
+ const paymentLimits = { budget: 'budget', 'max-stream': 'max_deposit', 'max-rate': 'max_rate', 'max-duration': 'max_duration_seconds', recipient: 'recipients' };
30
+ const runtimeLimits = { 'max-5xx': 'maxConsecutive5xx', 'max-transport-errors': 'maxConsecutiveTransportErrors', 'max-health-failures': 'maxConsecutiveHealthFailures', 'idle-timeout': 'idleTimeoutSeconds', 'max-requests': 'maxRequestsPerStream', 'poll-seconds': 'healthPollSeconds', 'failure-cooldown': 'failureCooldownSeconds', reuse: 'reuseStream', 'close-on-exit': 'closeOnExit' };
31
+ const executionFlags = 'assets input-asset max-input-raw copy-input-raw max-age max-slippage-bps max-notional'.split(' ');
32
+ const txLink = hash => `https://testnet.nearblocks.io/txns/${hash}`;
33
+
34
+ function positive(value, max = 3600) {
35
+ const n = Number(value);
36
+ if (!/^\d+$/.test(value ?? '') || !Number.isInteger(n) || n < 1 || n > max) throw new Error(`Use an integer from 1 to ${max}.`);
37
+ return n;
38
+ }
39
+
40
+ async function guard(agent, scope, flags, provided) {
41
+ const payment = Object.keys(paymentLimits), runtime = Object.keys(runtimeLimits);
42
+ const accepted = { payment: [...payment, ...runtime, 'owner-key'], execution: executionFlags, feed: ['stale-seconds', 'feed-failures'] }[scope];
43
+ if (!accepted) throw new Error('Use guard set payment, execution or feed.');
44
+ for (const flag of provided) if (!accepted.includes(flag)) throw new Error(`--${flag} does not apply to the ${scope} guard.`);
45
+ if (!provided.length) throw new Error('Supply at least one guardrail. See --help.');
46
+ return withAgentState(agent, async state => {
47
+ if (scope === 'execution') return { execution: state.executionPolicy = executionPolicy(flags, state.executionPolicy) };
48
+ if (scope === 'feed') {
49
+ const prior = state.feedGuard ?? { staleSeconds: 45, failures: 3 };
50
+ return { feed: state.feedGuard = {
51
+ staleSeconds: flags['stale-seconds'] ? positive(flags['stale-seconds']) : prior.staleSeconds,
52
+ failures: flags['feed-failures'] ? positive(flags['feed-failures'], 100) : prior.failures,
53
+ } };
54
+ }
55
+ const financial = {}, local = {};
56
+ for (const flag of payment.filter(k => flags[k] !== undefined)) {
57
+ financial[paymentLimits[flag]] = flag === 'recipient' ? flags[flag].split(',') : flag === 'max-duration' ? positive(flags[flag]) : usdc(flags[flag]);
58
+ }
59
+ for (const flag of runtime.filter(k => flags[k] !== undefined)) {
60
+ if (['reuse', 'close-on-exit'].includes(flag)) {
61
+ if (!['true', 'false'].includes(flags[flag])) throw new Error(`Use true or false for --${flag}.`);
62
+ local[runtimeLimits[flag]] = flags[flag] === 'true';
63
+ } else local[runtimeLimits[flag]] = positive(flags[flag], 1000000);
64
+ }
65
+ if (Object.keys(financial).length && Object.keys(local).length) throw new Error('Set on-chain limits and local runtime thresholds in separate commands.');
66
+ if (Object.keys(financial).length) return setLimits(agent.path, financial, flags['owner-key']);
67
+ return setRuntime(agent.path, local);
68
+ });
69
+ }
70
+
71
+ async function vaultBalance(project) {
72
+ const { profile, config } = await settings(project);
73
+ const { client } = await inspectNearProfile(profile.configPath, profile.stateRoot);
74
+ const [vault, ownerWallet] = await Promise.all([client.getVault(), client.tokenBalance(config.ownerId, config.tokenId)]);
75
+ return { network: config.network, vaultId: config.vaultId, units: 'raw USDC (1 USDC = 1000000)', ownerWallet, vault };
76
+ }
77
+
78
+ async function withdrawal(agent, project, amount, keyFile) {
79
+ if (!keyFile || !amount) throw new Error('Use withdraw AMOUNT --owner-key PATH.');
80
+ const raw = usdc(amount);
81
+ if (raw === '0') throw new Error('Withdrawal amount must be positive.');
82
+ const { profile, config } = await settings(project);
83
+ const { client: reader } = await inspectNearProfile(profile.configPath, profile.stateRoot);
84
+ const owner = await readKey(resolve(keyFile));
85
+ const vault = await reader.getVault();
86
+ if (owner.accountId !== config.ownerId || owner.accountId !== vault.owner_id) throw new Error('OWNER_CREDENTIAL_MISMATCH');
87
+ if (vault.active_session) throw new Error('CLOSE_STREAM_BEFORE_WITHDRAWAL');
88
+ const client = new NearAgentPayClient({ vaultId: config.vaultId, rpcUrl: config.rpcUrl, accountId: owner.accountId, privateKey: owner.privateKey });
89
+ if (agent) return { amountRawUSDC: raw, ...await payout(agent, client, 'withdraw_available', { amount: raw }) };
90
+ const receipt = await client.withdraw(raw);
91
+ if (receipt.receiptFailures.length) throw new Error(`WITHDRAWAL_RECEIPT_FAILED: ${txLink(receipt.hash)}`);
92
+ return { amountRawUSDC: raw, transactionHash: receipt.hash, explorerUrl: txLink(receipt.hash), result: receipt.value };
93
+ }
94
+
95
+ async function earnings(agent, action, flags) {
96
+ const items = await subscribers(agent);
97
+ if (action === 'show') {
98
+ const sessions = await Promise.all(items.map(async item => {
99
+ const client = new NearAgentPayClient({ rpcUrl: item.rpcUrl, vaultId: item.vaultId });
100
+ const stream = await client.getStream(item.sessionId);
101
+ return { vaultId: item.vaultId, sessionId: item.sessionId, recipient: item.recipient, stream };
102
+ }));
103
+ const vaults = await Promise.all([...new Map(items.map(item => [`${item.vaultId}:${item.recipient}`, item])).values()].map(async item => {
104
+ const client = new NearAgentPayClient({ rpcUrl: item.rpcUrl, vaultId: item.vaultId });
105
+ return { vaultId: item.vaultId, recipient: item.recipient, claimableRawUSDC: await client.getClaim(item.recipient) };
106
+ }));
107
+ return { network: 'testnet', sessions, vaults };
108
+ }
109
+ const item = items.find(sub => sub.vaultId === flags.from);
110
+ if (!item || !flags['recipient-key']) throw new Error('Use earnings claim --from a vault listed by earnings show --recipient-key PATH.');
111
+ const recipient = await readKey(resolve(flags['recipient-key']));
112
+ if (!items.some(sub => sub.vaultId === item.vaultId && sub.recipient === recipient.accountId)) throw new Error('RECIPIENT_CREDENTIAL_MISMATCH');
113
+ const client = new NearAgentPayClient({ vaultId: item.vaultId, rpcUrl: item.rpcUrl, accountId: recipient.accountId, privateKey: recipient.privateKey });
114
+ if (BigInt(await client.getClaim(recipient.accountId)) === 0n) return { reason: 'NOTHING_CLAIMABLE' };
115
+ return payout(agent, client, 'claim');
116
+ }
117
+
118
+ export async function runCopyCommand(command, p, v, workspace, agent, emit, provided) {
119
+ const arg = p[2];
120
+ switch (command) {
121
+ case 'agent:create': return createAgent(workspace, arg);
122
+ case 'agent:list': return listAgents(workspace);
123
+ case 'agent:use': if (!arg) throw new Error('Supply an agent name.'); return selectAgent(workspace, arg);
124
+ case 'balance': return vaultBalance(agent?.path ?? workspace);
125
+ case 'withdraw': return withdrawal(agent, agent?.path ?? workspace, p[1], v['owner-key']);
126
+ }
127
+ if (!agent) throw new Error('Run agentpay agent create NAME or select an existing agent.');
128
+ const state = await readAgentState(agent);
129
+ switch (command) {
130
+ case 'agent:show': {
131
+ const { path, ...identity } = agent;
132
+ return { ...identity, setup: await agentSetupState(agent) };
133
+ }
134
+ case 'agent:setup': {
135
+ if (v.network && v.network !== 'testnet') throw new Error('Only native NEAR testnet is supported.');
136
+ if (v.deployment && v.deploy) throw new Error('Choose --deployment or --deploy.');
137
+ if (v.deployment) return initProject(agent.path, { deployment: v.deployment });
138
+ const suffix = agent.id.slice(3, 11);
139
+ const result = await setupTestnet(agent.path, {
140
+ ownerId: v['owner-id'], ownerKeyFile: v['owner-key'], recipientId: v.recipient,
141
+ vaultLabel: provided.includes('vault-name') ? v['vault-name'] : 'v' + suffix,
142
+ agentLabel: provided.includes('agent-name') ? v['agent-name'] : 'a' + suffix,
143
+ rpcUrl: v.rpc, serviceUrl: v.service, deploy: v.deploy, setupCommand: 'agentpay agent setup',
144
+ });
145
+ if (result.readyToDeploy === false) process.exitCode = 1;
146
+ return result;
147
+ }
148
+ case 'agent:run': return runCopyAgent(agent, { once: v.once, rpc: v.rpc, emit });
149
+ case 'task:list': return { activities: Object.entries(activities).map(([id, description]) => ({ id, description, status: id !== spot ? 'planned' : state.tasks[id] ? 'enabled' : 'available', live: state.tasks[id]?.live ?? false })) };
150
+ case 'task:enable':
151
+ case 'task:disable':
152
+ if (!Object.hasOwn(activities, arg ?? '')) throw new Error('Choose an activity from agentpay task list.');
153
+ if (arg !== spot) throw new Error(`${arg} is planned. The executable adapter is spot-trading.`);
154
+ return withAgentState(agent, current => {
155
+ if (current.tradePending) throw new Error('TRADE_RECONCILIATION_REQUIRED');
156
+ if (command === 'task:enable') current.tasks[arg] = intentsConfig(v);
157
+ else { delete current.tasks[arg]; if (current.follow) current.follow.mode = 'observe'; }
158
+ return { activity: arg, enabled: Boolean(current.tasks[arg]), config: current.tasks[arg] ?? null };
159
+ });
160
+ case 'task:run':
161
+ if (arg !== spot) throw new Error('Only spot-trading is executable.');
162
+ return runSpotTask(agent, spotOrder({ from: v.from, to: v.to, amount: v['amount-raw'] }), v.execute);
163
+ case 'publish:set':
164
+ return configurePublish(agent, { ...v, port: provided.includes('port') ? v.port : undefined, recipient: v.recipient ?? (await agentSetupState(agent)).ownerId });
165
+ case 'publish:start':
166
+ case 'publish:stop':
167
+ return withAgentState(agent, current => {
168
+ if (!current.publish) throw new Error('Configure publish set first.');
169
+ current.publish.enabled = command === 'publish:start';
170
+ return { enabled: current.publish.enabled, id: agent.id, origin: current.publish.origin, ...(current.publish.enabled ? { next: 'Run agentpay agent run to serve the listing and gated journal.' } : {}) };
171
+ });
172
+ case 'publish:status': return { agentId: agent.id, publication: state.publish ?? null, entries: state.journal?.length ?? 0 };
173
+ case 'publish:history': return publicationHistory(agent);
174
+ case 'earnings:show': return earnings(agent, 'show', v);
175
+ case 'earnings:claim': return earnings(agent, 'claim', v);
176
+ case 'discover': {
177
+ if (v.activity && !Object.hasOwn(activities, v.activity)) throw new Error('Unknown activity.');
178
+ if (Boolean(v.id) !== Boolean(v.url)) throw new Error('Provide --id and --url together to verify and remember a publisher.');
179
+ if (v.url) {
180
+ const listing = await getListing(v.id, originOf(v.url));
181
+ await withAgentState(agent, current => { current.directory[listing.id] = listing.origin; });
182
+ return { listings: !v.activity || v.activity === listing.activity ? [listing] : [] };
183
+ }
184
+ if (v.activity && v.activity !== spot) return { listings: [], unavailable: [] };
185
+ const results = await Promise.allSettled(Object.entries(state.directory).map(([id, origin]) => getListing(id, origin)));
186
+ const ids = Object.keys(state.directory);
187
+ return { listings: results.flatMap(item => item.status === 'fulfilled' ? [item.value] : []), unavailable: results.flatMap((item, i) => item.status === 'rejected' ? [ids[i]] : []) };
188
+ }
189
+ case 'copy:preview': return previewCopy(agent, p[1], v.url);
190
+ case 'copy:start': if (!arg) throw new Error('Supply a publisher agent ID.'); return startCopy(agent, { id: arg, url: v.url, activity: v.activity });
191
+ case 'copy:resume': return startCopy(agent, { resume: true });
192
+ case 'copy:pause': return stopCopy(agent, 'paused');
193
+ case 'copy:stop': return stopCopy(agent, 'stopped');
194
+ case 'copy:mode': return changeMode(agent, arg);
195
+ case 'copy:approve': return decideAction(agent, arg, 'approved');
196
+ case 'copy:reject': return decideAction(agent, arg, 'rejected');
197
+ case 'copy:status': return { subscription: state.follow ?? null, tradePending: Boolean(state.tradePending) };
198
+ case 'copy:pending': return { actions: Object.values(state.pending ?? {}).filter(item => item.sessionId === state.follow?.sessionId && item.status === 'pending' && item.action.expiresAt > Date.now()) };
199
+ case 'copy:history': return { history: state.history };
200
+ case 'guard:set': return guard(agent, arg, v, provided);
201
+ case 'guard:show': {
202
+ const setup = await agentSetupState(agent);
203
+ return { payment: setup.configured ? await vaultBalance(agent.path).then(x => x.vault.policy) : null, runtime: setup.configured ? (await settings(agent.path)).config.runtimePolicy : null, execution: state.executionPolicy ?? null, feed: state.feedGuard ?? { staleSeconds: 45, failures: 3 } };
204
+ }
205
+ case 'status': return { name: agent.name, id: agent.id, setup: await agentSetupState(agent), publicationEnabled: Boolean(state.publish?.enabled), subscription: state.follow ?? null, pendingTrade: Boolean(state.tradePending) };
206
+ case 'reconcile': {
207
+ const copy = await reconcileCopy(agent);
208
+ const payouts = await reconcilePayouts(agent);
209
+ if (!(await agentSetupState(agent)).configured) return { resolved: copy.resolved && payouts.resolved, copy, payouts };
210
+ const { gatewayFor } = await import('./config.mjs');
211
+ const client = await gatewayFor(agent.path);
212
+ try {
213
+ const payment = await client.reconcile();
214
+ const policy = await reconcileLimits(agent.path);
215
+ return { resolved: copy.resolved && payment.resolved && policy.resolved && payouts.resolved, copy, payment, policy, payouts };
216
+ } finally { await client.dispose(); }
217
+ }
218
+ default: throw new Error('Unknown command. Run agentpay --help.');
219
+ }
220
+ }
@@ -0,0 +1,278 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { mkdir, readFile, readdir } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { PublicKey, baseEncode } from 'near-api-js';
6
+ import { NearAgentPayClient, USDC } from '@ola-893/agentpay-core/near';
7
+ import { verifySession } from '@ola-893/agentpay-core/internal-session';
8
+ import { canonical } from '@ola-893/agentpay-core/internal-evidence-audit';
9
+ import { atomicWrite, readOptional } from './files.mjs';
10
+ import { feedKey, fingerprint, readAgentState, withAgentState } from './agents.mjs';
11
+ import { assetId, rawAmount, spot } from './copy-activity.mjs';
12
+ import { credential } from './copy-intents.mjs';
13
+ import { bundledWasm } from './setup.mjs';
14
+ import { usdc } from './settings.mjs';
15
+
16
+ export const agentIdPattern = /^ap_[a-f0-9]{64}$/;
17
+ export const accountId = value => typeof value === 'string' && /^(?=.{2,64}$)[a-z0-9]+(?:[-_.][a-z0-9]+)*$/.test(value);
18
+ export const quotePath = (vaultId, expiresAt) => `/copy/quote/${vaultId}` + (expiresAt ? `/${expiresAt}` : "");
19
+ export const listingPath = '/.well-known/agentpay.json';
20
+ export const feedPath = '/copy/actions';
21
+ export const healthPath = '/copy/health';
22
+ export const quoteHash = quote => fingerprint(JSON.stringify(quote));
23
+
24
+ export function originOf(value) {
25
+ const url = new URL(value);
26
+ if (url.username || url.password || url.pathname !== '/' || url.search || url.hash || (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === '127.0.0.1'))) throw new Error('Use an HTTPS feed origin or http://127.0.0.1 for a local feed.');
27
+ return url.origin;
28
+ }
29
+
30
+ export async function readJsonResponse(response, max = 131072) {
31
+ const parts = [];
32
+ let size = 0;
33
+ for await (const part of response.body ?? []) {
34
+ size += part.length;
35
+ if (size > max) throw new Error('FEED_RESPONSE_TOO_LARGE');
36
+ parts.push(part);
37
+ }
38
+ return JSON.parse(Buffer.concat(parts).toString('utf8'));
39
+ }
40
+
41
+ export async function fetchFeed(origin, path, headers) {
42
+ const response = await fetch(`${origin}${path}`, { headers, redirect: 'error', signal: AbortSignal.timeout(10000) });
43
+ if (!response.ok) throw new Error(`FEED_HTTP_${response.status}`);
44
+ return readJsonResponse(response);
45
+ }
46
+
47
+ function signed(key, domain, body) {
48
+ const signature = key.sign(Buffer.from(canonical({ domain, body }))).signature;
49
+ return { body, signature: Buffer.from(signature).toString('base64') };
50
+ }
51
+
52
+ function verified(envelope, key, domain) {
53
+ return envelope?.body && typeof envelope.signature === 'string' && envelope.signature.length === 88 &&
54
+ PublicKey.fromString(key).verify(Buffer.from(canonical({ domain, body: envelope.body })), Buffer.from(envelope.signature, 'base64'));
55
+ }
56
+
57
+ export async function configurePublish(agent, flags) {
58
+ const selected = flags.activities?.split(',') ?? [spot];
59
+ if (selected.length !== 1 || selected[0] !== spot) throw new Error('The executable publication adapter is spot-trading. See agentpay task list for the catalog.');
60
+ const origin = originOf(flags.url ?? 'http://127.0.0.1:5380');
61
+ const port = Number(flags.port ?? (new URL(origin).protocol === 'http:' ? new URL(origin).port || 80 : 5380));
62
+ const duration = Number(flags.duration ?? 600);
63
+ const rate = usdc(flags.rate);
64
+ if (BigInt(rate) < 1n || !accountId(flags.recipient) || !flags.recipient.endsWith('.testnet') || !Number.isInteger(duration) || duration < 30 || duration > 3600 || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Provide a positive --rate, .testnet --recipient, --duration 30–3600 seconds and a valid --port.');
65
+ const nova = flags['nova-group'] ? { group: flags['nova-group'], account: flags['nova-account'], keyEnv: flags['nova-key-env'] ?? 'NOVA_API_KEY', network: flags['nova-network'] ?? 'testnet' } : null;
66
+ if (nova && (!/^[A-Za-z0-9._-]{1,64}$/.test(nova.group) || !accountId(nova.account) || !/^[A-Z][A-Z0-9_]{0,127}$/.test(nova.keyEnv) || !['testnet', 'mainnet'].includes(nova.network))) throw new Error('Provide a valid --nova-group, --nova-account and --nova-network.');
67
+ const performance = flags['performance-file'] ? JSON.parse(await readFile(flags['performance-file'], 'utf8')) : null;
68
+ if (performance && (!Array.isArray(performance) || performance.length > 90 || !performance.every(p => Object.keys(p).length === 2 && /^\d{4}-\d{2}-\d{2}$/.test(p.date) && Number.isFinite(p.pnl)))) throw new Error('Performance file must contain up to 90 { date, pnl } points.');
69
+ return withAgentState(agent, state => {
70
+ if (state.publish?.enabled) throw new Error('Stop publication before changing the listing.');
71
+ state.publish = { enabled: false, origin, port, duration, rate, recipient: flags.recipient, nova, performance };
72
+ return { origin, rateRawUSDCPerSecond: rate, durationSeconds: duration, recipient: flags.recipient, journal: nova ? 'nova' : 'local-encrypted', enabled: false };
73
+ });
74
+ }
75
+
76
+ function listing(agent, state) {
77
+ const p = state.publish;
78
+ return {
79
+ version: 1, id: agent.id, name: agent.name, publicKey: agent.publicKey, origin: p.origin, network: 'testnet',
80
+ activity: spot, recipient: p.recipient, rate: p.rate, duration: p.duration,
81
+ journal: p.nova ? 'nova' : 'local-encrypted',
82
+ ...(p.performance ? { performance: { provenance: 'publisher-reported', points: p.performance, journalHead: state.journal?.at(-1)?.hash ?? null } } : {}),
83
+ };
84
+ }
85
+
86
+ export async function getListing(id, origin) {
87
+ const envelope = await fetchFeed(origin, listingPath);
88
+ const b = envelope?.body;
89
+ if (!b || b.version !== 1 || b.id !== id || !agentIdPattern.test(id) || typeof b.publicKey !== 'string' || b.publicKey.length > 70 ||
90
+ `ap_${fingerprint(b.publicKey)}` !== id || b.origin !== origin || b.network !== 'testnet' || b.activity !== spot ||
91
+ !accountId(b.recipient) || !b.recipient.endsWith('.testnet') || !rawAmount(b.rate) || !Number.isInteger(b.duration) || b.duration < 30 || b.duration > 3600 ||
92
+ !verified(envelope, b.publicKey, 'agentpay.listing.v1')) throw new Error('INVALID_SIGNED_LISTING');
93
+ return b;
94
+ }
95
+
96
+ function quoteFor(b, vaultId, tokenId, expiresAt) {
97
+ return {
98
+ version: 1, vault_id: vaultId, token_id: tokenId, recipient_id: b.recipient, tier: 'basic', rate: b.rate,
99
+ duration_seconds: b.duration, expires_at: expiresAt, publisher: b.id, origin: b.origin, activity: b.activity,
100
+ };
101
+ }
102
+
103
+ export function checkListingQuote(b, vaultId, tokenId, quote) {
104
+ if (!Number.isSafeInteger(quote?.expires_at) || quote.expires_at <= Date.now() / 1000 || quote.expires_at > Date.now() / 1000 + 300 ||
105
+ canonical(quote) !== canonical(quoteFor(b, vaultId, tokenId, quote.expires_at))) throw new Error('QUOTE_DOES_NOT_MATCH_SIGNED_LISTING');
106
+ return quoteHash(quote);
107
+ }
108
+
109
+ function quoteMatchesStream(b, vault, stream, vaultId) {
110
+ if (stream?.recipient_id !== b.recipient || stream.rate !== b.rate || stream.duration_seconds !== b.duration) return false;
111
+ const bucket = Math.floor(Number(stream.started_at) / 60) * 60;
112
+ // The quote lasts 60–120 seconds. Include the preceding buckets for a transaction crossing a boundary.
113
+ return [0, 60, 120].some(offset => quoteHash(quoteFor(b, vaultId, vault.token_id, bucket + 120 - offset)) === stream.quote_hash);
114
+ }
115
+
116
+ function localJournalKey(key) {
117
+ return createHash('sha256').update('agentpay.local.journal.v1\0').update(key.toString()).digest();
118
+ }
119
+
120
+ async function novaSdk(nova) {
121
+ const { NovaSdk } = await import('nova-sdk-js');
122
+ const settings = nova.network === 'testnet' ? { contractId: 'nova-sdk-7.testnet', rpcUrl: 'https://test.rpc.fastnear.com' } : {};
123
+ return new NovaSdk(nova.account, { ...settings, apiKey: credential(nova.keyEnv) });
124
+ }
125
+
126
+ async function storeEntry(agent, key, entry, nova) {
127
+ const data = Buffer.from(JSON.stringify(entry));
128
+ const hash = fingerprint(data);
129
+ if (nova) {
130
+ const sdk = await novaSdk(nova);
131
+ const result = await sdk.upload(nova.group, data, `${hash}.json`);
132
+ if (result.file_hash !== hash || typeof result.cid !== 'string') throw new Error('NOVA_JOURNAL_HASH_MISMATCH');
133
+ return { nova, ref: result.cid, hash };
134
+ }
135
+ const iv = randomBytes(12);
136
+ const cipher = createCipheriv('aes-256-gcm', localJournalKey(key), iv);
137
+ const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
138
+ const file = join(agent.path, 'journal', `${hash}.json`);
139
+ await atomicWrite(file, JSON.stringify({ iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ciphertext: ciphertext.toString('base64') }));
140
+ return { hash };
141
+ }
142
+
143
+ async function loadEntry(agent, key, pointer) {
144
+ let data;
145
+ if (pointer.nova) {
146
+ const sdk = await novaSdk(pointer.nova);
147
+ data = (await sdk.retrieve(pointer.nova.group, pointer.ref)).data;
148
+ } else {
149
+ const content = JSON.parse(await readFile(join(agent.path, 'journal', `${pointer.hash}.json`), 'utf8'));
150
+ const cipher = createDecipheriv('aes-256-gcm', localJournalKey(key), Buffer.from(content.iv, 'base64'));
151
+ cipher.setAuthTag(Buffer.from(content.tag, 'base64'));
152
+ data = Buffer.concat([cipher.update(Buffer.from(content.ciphertext, 'base64')), cipher.final()]);
153
+ }
154
+ if (fingerprint(data) !== pointer.hash) throw new Error('JOURNAL_HASH_MISMATCH');
155
+ return JSON.parse(data);
156
+ }
157
+
158
+ export async function appendFill(agent, state, fill) {
159
+ if (!state.publish?.enabled) return null;
160
+ const journal = state.journal ??= [];
161
+ if (journal.some(item => item.id === fill.id)) return null;
162
+ const key = await feedKey(agent);
163
+ const action = { ...fill, sequence: (journal.at(-1)?.sequence ?? 0) + 1, publisher: agent.id, activity: spot };
164
+ const entry = signed(key, 'agentpay.action.v1', action);
165
+ const pointer = await storeEntry(agent, key, entry, state.publish.nova);
166
+ journal.push({ ...pointer, id: action.id, sequence: action.sequence });
167
+ return { id: action.id, sequence: action.sequence, journalHash: pointer.hash };
168
+ }
169
+
170
+ export function verifyAction(envelope, listing) {
171
+ const b = envelope?.body;
172
+ if (!b || b.publisher !== listing.id || b.activity !== listing.activity || !/^[a-f0-9]{64}$/.test(b.id ?? '') ||
173
+ !Number.isSafeInteger(b.sequence) || b.sequence < 1 || !Number.isSafeInteger(b.at) || !Number.isSafeInteger(b.expiresAt) || b.expiresAt <= b.at ||
174
+ !assetId(b.from) || !assetId(b.to) || b.from === b.to || !rawAmount(b.amountIn) || !rawAmount(b.amountOut) ||
175
+ typeof b.settlementRef !== 'string' || !b.settlementRef.length || b.settlementRef.length > 160 ||
176
+ !verified(envelope, listing.publicKey, 'agentpay.action.v1')) throw new Error('INVALID_SIGNED_ACTION');
177
+ return b;
178
+ }
179
+
180
+ export async function publicationHistory(agent) {
181
+ const state = await readAgentState(agent);
182
+ const key = await feedKey(agent);
183
+ return { actions: await Promise.all((state.journal ?? []).slice(-20).map(p => loadEntry(agent, key, p))) };
184
+ }
185
+
186
+ export async function subscribers(agent) {
187
+ const directory = join(agent.path, 'subscribers');
188
+ let files;
189
+ try { files = await readdir(directory); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
190
+ return Promise.all(files.filter(name => /^[a-f0-9]{64}\.json$/.test(name)).map(async name => JSON.parse(await readFile(join(directory, name), 'utf8'))));
191
+ }
192
+
193
+ export async function serveFeed(agent, { rpcUrl = 'https://test.rpc.fastnear.com', network = 'testnet', token = USDC.testnet, codeFile = bundledWasm, port } = {}) {
194
+ const { publish } = await readAgentState(agent);
195
+ if (!publish) throw new Error('Configure publish set first.');
196
+ const key = await feedKey(agent);
197
+ const codeHash = baseEncode(createHash('sha256').update(await readFile(codeFile)).digest());
198
+ const rpc = new NearAgentPayClient({ rpcUrl });
199
+ if ((await rpc.provider.viewNodeStatus()).chain_id !== network) throw new Error('FEED_RPC_NETWORK_MISMATCH');
200
+ const server = createServer(async (req, res) => {
201
+ const send = (status, value) => {
202
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
203
+ res.end(JSON.stringify(value));
204
+ };
205
+ try {
206
+ if (req.method !== 'GET') return send(405, { error: 'METHOD_NOT_ALLOWED' });
207
+ const path = new URL(req.url, publish.origin);
208
+ const state = await readAgentState(agent);
209
+ if (!state.publish?.enabled || state.publish.origin !== publish.origin || state.publish.port !== publish.port) return send(503, { error: 'PUBLICATION_STOPPED' });
210
+ const b = listing(agent, state);
211
+ if (path.pathname === listingPath) return send(200, signed(key, 'agentpay.listing.v1', b));
212
+ if (path.pathname === healthPath) return send(200, { ok: true, observed_at: Math.floor(Date.now() / 1000) });
213
+ if (path.pathname.startsWith('/copy/quote/')) {
214
+ const [vaultId, expiry, ...extra] = path.pathname.slice('/copy/quote/'.length).split('/');
215
+ const now = Math.floor(Date.now() / 1000);
216
+ const next = Math.floor(now / 60) * 60 + 120;
217
+ const expiresAt = expiry === undefined ? next : Number(expiry);
218
+ if (!accountId(vaultId) || path.searchParams.get('tier') !== 'basic' || extra.length ||
219
+ ![next, next - 60].includes(expiresAt) || expiresAt <= now) return send(400, { error: 'INVALID_QUOTE_REQUEST' });
220
+ return send(200, quoteFor(b, vaultId, token, expiresAt));
221
+ }
222
+ if (path.pathname !== feedPath) return send(404, { error: 'NOT_FOUND' });
223
+ const header = req.headers['x-agentpay-session'];
224
+ if (!header) return send(402, { protocol: 'agentpay-near-v1' });
225
+ if (typeof header !== 'string' || header.length > 2048) return send(403, { error: 'SESSION_INVALID' });
226
+ let untrusted;
227
+ try { untrusted = JSON.parse(Buffer.from(header, 'base64url').toString('utf8')); } catch { return send(403, { error: 'SESSION_INVALID' }); }
228
+ if (!accountId(untrusted?.vault_id) || !/^[a-zA-Z0-9_-]{1,64}$/.test(untrusted?.session_id ?? '')) return send(403, { error: 'SESSION_INVALID' });
229
+ const client = new NearAgentPayClient({ rpcUrl, vaultId: untrusted.vault_id });
230
+ const authenticate = async () => {
231
+ const [account, vault, stream] = await Promise.all([
232
+ client.provider.viewAccount({ accountId: untrusted.vault_id, blockQuery: { finality: 'final' } }), client.getVault(), client.getStream(untrusted.session_id),
233
+ ]);
234
+ const now = Math.floor(Date.now() / 1000);
235
+ if (account.code_hash !== codeHash || vault.token_id !== token || Math.abs(now - Number(vault.now)) > 30 || !quoteMatchesStream(b, vault, stream, untrusted.vault_id)) throw new Error('UNTRUSTED_PAYMENT');
236
+ return { session: verifySession(header, { config: { serviceUrl: b.origin, vaultId: untrusted.vault_id }, vault, stream, now: Math.max(now, Number(vault.now)) }), stream };
237
+ };
238
+ let access;
239
+ try { access = await authenticate(); } catch { return send(403, { error: 'STREAM_ACCESS_DENIED' }); }
240
+ const after = Number(path.searchParams.get('after') ?? 0);
241
+ if (!Number.isSafeInteger(after) || after < 0) return send(400, { error: 'INVALID_CURSOR' });
242
+ const pointers = (state.journal ?? []).filter(p => p.sequence > after).slice(0, 8);
243
+ const entries = [];
244
+ for (const pointer of pointers) {
245
+ const entry = await loadEntry(agent, key, pointer);
246
+ if (entry.body.at >= Number(access.stream.started_at) * 1000) entries.push(entry);
247
+ }
248
+ // A close may finalize while NOVA is retrieving the ciphertext. Check again before releasing entries.
249
+ if (pointers.length) try { await authenticate(); } catch { return send(403, { error: 'STREAM_ACCESS_DENIED' }); }
250
+ if (!(await readAgentState(agent)).publish?.enabled) return send(503, { error: 'PUBLICATION_STOPPED' });
251
+ const sub = { vaultId: untrusted.vault_id, sessionId: access.session.session_id, recipient: b.recipient, rpcUrl };
252
+ const file = join(agent.path, 'subscribers', `${fingerprint(`${sub.vaultId}:${sub.sessionId}`)}.json`);
253
+ if (!await readOptional(file)) {
254
+ await mkdir(join(agent.path, 'subscribers'), { recursive: true, mode: 0o700 });
255
+ await atomicWrite(file, JSON.stringify(sub));
256
+ }
257
+ const cursor = pointers.at(-1)?.sequence ?? after;
258
+ return send(200, signed(key, 'agentpay.page.v1', { sessionId: access.session.session_id, observedAt: Date.now(), after, cursor, entries }));
259
+ } catch { return send(503, { error: 'FEED_TEMPORARILY_UNAVAILABLE' }); }
260
+ });
261
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port ?? publish.port, '127.0.0.1', resolve); });
262
+ return { port: server.address().port, close: () => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }) };
263
+ }
264
+
265
+ export function verifyPage(envelope, listing, sessionId, after, freshness) {
266
+ const page = envelope?.body;
267
+ if (!page || page.sessionId !== sessionId || page.after !== after || !Number.isSafeInteger(page.cursor) || page.cursor < after || page.cursor > after + 8 ||
268
+ !Number.isSafeInteger(page.observedAt) || page.observedAt > Date.now() + 5000 || Date.now() - page.observedAt > freshness * 1000 ||
269
+ !Array.isArray(page.entries) || page.entries.length > 8 || !verified(envelope, listing.publicKey, 'agentpay.page.v1')) throw new Error('FEED_STALE_OR_INVALID');
270
+ let previous = after;
271
+ const actions = page.entries.map(entry => {
272
+ const action = verifyAction(entry, listing);
273
+ if (action.sequence <= previous || action.sequence > page.cursor) throw new Error('INVALID_ACTION_ORDER');
274
+ previous = action.sequence;
275
+ return action;
276
+ });
277
+ return { actions, cursor: page.cursor };
278
+ }
@@ -0,0 +1,83 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { rawAmount } from './copy-activity.mjs';
4
+
5
+ const api = 'https://api.agentsonintents.com';
6
+ const final = new Set(['SUCCESS', 'FAILED', 'REFUNDED']);
7
+ const pending = new Set(['QUEUED', 'PROCESSING']);
8
+
9
+ export function credential(name) {
10
+ const value = process.env[name];
11
+ if (!value || value.length > 8192 || /[^\x21-\x7e]/.test(value)) throw new Error(`Set ${name} in the agent process environment.`);
12
+ return value;
13
+ }
14
+
15
+ export function intentsConfig(flags) {
16
+ if (!/^[a-z0-9_-]{1,128}$/i.test(flags['intents-agent-id'] ?? '')) throw new Error('Provide --intents-agent-id for an owner-provisioned Agent Connect account.');
17
+ const result = { id: flags['intents-agent-id'], keyEnv: flags['intents-key-env'] ?? 'NEAR_INTENTS_AGENT_API_KEY', grantEnv: flags['intents-grant-env'] ?? 'NEAR_INTENTS_GRANT_TOKEN', live: flags.live === true, confidential: flags.confidential === true };
18
+ if (![result.keyEnv, result.grantEnv].every(value => /^[A-Z][A-Z0-9_]{0,127}$/.test(value))) throw new Error('Credential options accept environment variable names.');
19
+ return result;
20
+ }
21
+
22
+ export function intentsAdapter(config, fetchImpl = fetch) {
23
+ const basePath = `/v1/agents/${encodeURIComponent(config.id)}`;
24
+ async function request(path, { body, key, grant = false } = {}) {
25
+ const headers = { 'X-API-Key': credential(config.keyEnv) };
26
+ if (grant) headers['X-Grant-Token'] = credential(config.grantEnv);
27
+ if (key) headers['Idempotency-Key'] = key;
28
+ if (body) headers['Content-Type'] = 'application/json';
29
+ const response = await fetchImpl(`${api}${path}`, { headers, redirect: 'error', signal: AbortSignal.timeout(15000), ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) });
30
+ const data = await response.json();
31
+ if (!response.ok) {
32
+ const code = data.code ?? data.errors?.[0]?.code;
33
+ throw new Error(`INTENTS_HTTP_${response.status}${/^[a-z0-9_]+$/.test(code ?? '') ? ` ${code}` : ''}`);
34
+ }
35
+ return data;
36
+ }
37
+
38
+ return {
39
+ async quote(order) {
40
+ const result = await request(`${basePath}/swap`, { key: randomUUID(), body: { ...order, confidential: config.confidential, dry: true } });
41
+ if (result.dry !== true || result.type !== 'swap') throw new Error('INTENTS_EXPECTED_DRY_QUOTE');
42
+ const quote = result.quote?.quote ?? result.quote;
43
+ const amountOut = quote?.amountOut ?? quote?.amount_out;
44
+ const amountIn = quote?.amountIn ?? quote?.amount_in;
45
+ if (!rawAmount(amountOut) || !rawAmount(amountIn)) throw new Error('INTENTS_QUOTE_AMOUNTS_MISSING');
46
+ return { amountIn, amountOut, amountInUsd: result.spend?.amount_usd ?? null, warnings: (result.warnings ?? []).map(item => item.code), ownerApproval: result.owner_approval === true };
47
+ },
48
+ async execute(order, key) {
49
+ if (!config.live) throw new Error('INTENTS_LIVE_DISABLED');
50
+ return request(`${basePath}/swap`, { key, grant: true, body: { ...order, confidential: config.confidential } });
51
+ },
52
+ async status(correlationId) {
53
+ if (typeof correlationId !== 'string' || !correlationId.length || correlationId.length > 128) throw new Error('INTENTS_CORRELATION_MISSING');
54
+ return request(`/v1/status?${new URLSearchParams({ correlation_id: correlationId })}`);
55
+ },
56
+ async settle(result, order, { attempts = 8 } = {}) {
57
+ const correlationId = result.correlation_id;
58
+ const validate = value => {
59
+ if (!/^[a-z0-9_-]{1,128}$/i.test(correlationId ?? '') || value.correlation_id !== correlationId ||
60
+ value.agent_id !== config.id || value.type !== 'swap') throw new Error('INTENTS_STATUS_IDENTITY_MISMATCH');
61
+ };
62
+ validate(result);
63
+ for (let i = 0; i < attempts && pending.has(result.status); i++) {
64
+ await delay(1500);
65
+ result = await this.status(correlationId);
66
+ validate(result);
67
+ }
68
+ if (result.status === 'SUCCESS') {
69
+ const details = result.details, requested = details?.request, at = Date.parse(result.updated_at);
70
+ if (!requested || !['origin_asset', 'destination_asset', 'amount'].every(key => requested[key] === order[key]) ||
71
+ !rawAmount(details.amount_out) || BigInt(details.amount_out) < BigInt(order.min_amount_out) ||
72
+ !Number.isSafeInteger(at) || at > Date.now() + 5000) throw new Error('INTENTS_SETTLEMENT_MISMATCH');
73
+ }
74
+ return { status: result.status, final: final.has(result.status), correlationId, details: result.details, updatedAt: Date.parse(result.updated_at) };
75
+ },
76
+ };
77
+ }
78
+
79
+ export function minimumOutput(action, order, policy) {
80
+ const numerator = BigInt(action.amountOut) * BigInt(order.amount) * BigInt(10000 - policy.slippageBps);
81
+ const denominator = BigInt(action.amountIn) * 10000n;
82
+ return ((numerator + denominator - 1n) / denominator).toString();
83
+ }