@stellar-agent/cli 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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3606 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3606 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { EXIT_CODES, StellarAgentError, configSchema, createDefaultConfig, fail, formatStroops, ok, parseAmount, paymentRequestSchema, redactSensitive, resolvePath, serializeError } from "@stellar-agent/core";
|
|
3
|
+
import { latestLedger, lookupTransaction, parseStellarCliTransactionHash, resolveNetworkProfile } from "@stellar-agent/stellar";
|
|
4
|
+
import { appendEvent, latestReceipt, listReceipts, readReceipt, spendHistoryFromReceipts, verifyReceipt, writeReceipt } from "@stellar-agent/ledger-logger";
|
|
5
|
+
import { DEFAULT_MAINNET_POLICY, DEFAULT_TESTNET_POLICY, defaultPolicyForNetwork, evaluateDefiBlendRequest, evaluateMarketLiquidityRequest, evaluatePaymentRequest, parsePolicyYaml, policyToYaml } from "@stellar-agent/policy";
|
|
6
|
+
import { createTestnetHarness, ensureWallet, importPublicWallet, initTestnetWorkspace, listWalletPublicViews, loadWallet, loadWalletPublic, walletBalances, walletTrustlines } from "@stellar-agent/testnet-suite";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { realpathSync } from "node:fs";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const { version: VERSION } = require("../package.json");
|
|
16
|
+
export function buildProgram() {
|
|
17
|
+
const program = new Command();
|
|
18
|
+
program
|
|
19
|
+
.name("stellar-agent")
|
|
20
|
+
.description("Stellar Agent Bridge\n\nSafe agentic payments on Stellar from your terminal.")
|
|
21
|
+
.version(VERSION)
|
|
22
|
+
.option("--profile <name>", "Network profile to use: testnet, mainnet, local")
|
|
23
|
+
.option("--config <path>", "Path to config file")
|
|
24
|
+
.option("--policy <path>", "Path to policy file")
|
|
25
|
+
.option("--json", "Print machine-readable JSON")
|
|
26
|
+
.option("--no-color", "Disable colored output")
|
|
27
|
+
.option("--verbose", "Print additional diagnostics")
|
|
28
|
+
.option("--quiet", "Suppress nonessential output")
|
|
29
|
+
.option("--no-cache", "Disable session caches for network lookups")
|
|
30
|
+
.addHelpText("after", `
|
|
31
|
+
|
|
32
|
+
Quick start:
|
|
33
|
+
stellar-agent testnet init
|
|
34
|
+
stellar-agent testnet smoke-test
|
|
35
|
+
stellar-agent testnet scenario basic-payment
|
|
36
|
+
stellar-agent receipts latest
|
|
37
|
+
|
|
38
|
+
Common commands:
|
|
39
|
+
stellar-agent testnet doctor
|
|
40
|
+
stellar-agent wallet balance
|
|
41
|
+
stellar-agent wallet trustline add --asset USD:G... --account merchant
|
|
42
|
+
stellar-agent pay quote --to G... --amount 1 --asset XLM --fee-strategy medium
|
|
43
|
+
stellar-agent pay send --to G... --amount 1 --asset XLM
|
|
44
|
+
stellar-agent pay batch --file ./payments.json
|
|
45
|
+
stellar-agent tx submit-approval appr_...
|
|
46
|
+
stellar-agent claimable create --to G... --amount 1
|
|
47
|
+
stellar-agent cache inspect
|
|
48
|
+
stellar-agent contract invoke --id C... --source agent --fn hello --arg to=world
|
|
49
|
+
stellar-agent market pools list --asset-a XLM --asset-b USDC:G... --json
|
|
50
|
+
stellar-agent market lp preflight --pool 0123... --max-a 1 --max-b 1 --min-price 0.9 --max-price 1.1 --json
|
|
51
|
+
stellar-agent policy explain --request ./payment-request.json`);
|
|
52
|
+
addProfileCommands(program);
|
|
53
|
+
addTestnetCommands(program);
|
|
54
|
+
addWalletCommands(program);
|
|
55
|
+
addApprovalCommands(program);
|
|
56
|
+
addTransactionCommands(program);
|
|
57
|
+
addPayCommands(program);
|
|
58
|
+
addClaimableCommands(program);
|
|
59
|
+
addContractCommands(program);
|
|
60
|
+
addMarketCommands(program);
|
|
61
|
+
addStrategyCommands(program);
|
|
62
|
+
addDefiCommands(program);
|
|
63
|
+
addPolicyCommands(program);
|
|
64
|
+
addLedgerCommands(program);
|
|
65
|
+
addReceiptCommands(program);
|
|
66
|
+
addCacheCommands(program);
|
|
67
|
+
addMainnetCommands(program);
|
|
68
|
+
return program;
|
|
69
|
+
}
|
|
70
|
+
function addCacheCommands(program) {
|
|
71
|
+
const cache = program.command("cache").description("Inspect and clear in-process session caches.");
|
|
72
|
+
cache
|
|
73
|
+
.command("inspect")
|
|
74
|
+
.description("Show cached Horizon and tool-readiness lookups for this CLI process.")
|
|
75
|
+
.action(withContext(async () => {
|
|
76
|
+
const { stellarSessionCacheSnapshot } = await import("@stellar-agent/stellar");
|
|
77
|
+
return { entries: stellarSessionCacheSnapshot() };
|
|
78
|
+
}, "Session cache inspected."));
|
|
79
|
+
cache
|
|
80
|
+
.command("clear")
|
|
81
|
+
.description("Clear in-process session caches.")
|
|
82
|
+
.action(withContext(async () => {
|
|
83
|
+
const { clearStellarSessionCache } = await import("@stellar-agent/stellar");
|
|
84
|
+
clearStellarSessionCache();
|
|
85
|
+
return { cleared: true };
|
|
86
|
+
}, "Session cache cleared."));
|
|
87
|
+
}
|
|
88
|
+
function addProfileCommands(program) {
|
|
89
|
+
const profile = program.command("profile").description("Inspect and manage network profiles.");
|
|
90
|
+
profile
|
|
91
|
+
.command("list")
|
|
92
|
+
.description("List configured profiles.")
|
|
93
|
+
.action(withContext(async (context) => context.config.profiles, "Configured profiles listed."));
|
|
94
|
+
profile
|
|
95
|
+
.command("inspect")
|
|
96
|
+
.description("Inspect a network profile.")
|
|
97
|
+
.argument("[name]", "Profile name")
|
|
98
|
+
.action(withContext(async (context, name) => {
|
|
99
|
+
const target = name ?? context.profileName;
|
|
100
|
+
return resolveNetworkProfile(target, context.config.profiles);
|
|
101
|
+
}, "Profile inspected."));
|
|
102
|
+
profile
|
|
103
|
+
.command("use")
|
|
104
|
+
.description("Set the active profile.")
|
|
105
|
+
.argument("<name>", "Profile name")
|
|
106
|
+
.action(withContext(async (context, name) => {
|
|
107
|
+
const target = resolveNetworkProfile(name, context.config.profiles);
|
|
108
|
+
context.config.activeProfile = target.name;
|
|
109
|
+
await writeConfig(context.config, context.options);
|
|
110
|
+
return { activeProfile: target.name, realFunds: target.realFunds };
|
|
111
|
+
}, "Active profile updated."));
|
|
112
|
+
}
|
|
113
|
+
function addTestnetCommands(program) {
|
|
114
|
+
const testnet = program.command("testnet").description("Run safe Testnet workflows.");
|
|
115
|
+
testnet
|
|
116
|
+
.command("doctor")
|
|
117
|
+
.description("Check local Testnet readiness.")
|
|
118
|
+
.option("--live", "Also check network endpoints")
|
|
119
|
+
.action(withContext(async (context, options) => {
|
|
120
|
+
const checks = [
|
|
121
|
+
{ name: "node", ok: Number(process.versions.node.split(".")[0]) >= 22, detail: process.version },
|
|
122
|
+
{ name: "profile", ok: Boolean(context.config.profiles.testnet), detail: "testnet profile configured" },
|
|
123
|
+
{ name: "storage", ok: Boolean(context.config.storage.rootDir), detail: context.config.storage.rootDir },
|
|
124
|
+
{
|
|
125
|
+
name: "network",
|
|
126
|
+
ok: options.live ? Boolean(context.config.profiles.testnet?.horizonUrl) : true,
|
|
127
|
+
detail: options.live ? context.config.profiles.testnet?.horizonUrl : "skipped"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "stellar-cli",
|
|
131
|
+
ok: true,
|
|
132
|
+
detail: await import("@stellar-agent/stellar").then(({ checkStellarCli }) => checkStellarCli())
|
|
133
|
+
}
|
|
134
|
+
];
|
|
135
|
+
return { checks, ok: checks.every((check) => check.ok) };
|
|
136
|
+
}, "Doctor checks complete."));
|
|
137
|
+
testnet
|
|
138
|
+
.command("init")
|
|
139
|
+
.description("Create local Testnet wallets, policy, config, logs, and receipts directories.")
|
|
140
|
+
.option("--no-fund", "Create local state without calling Friendbot")
|
|
141
|
+
.option("--overwrite-policy", "Overwrite the default Testnet policy")
|
|
142
|
+
.action(withContext(async (context, options) => {
|
|
143
|
+
const result = await initTestnetWorkspace(context.config, {
|
|
144
|
+
...(options.fund === undefined ? {} : { fund: options.fund }),
|
|
145
|
+
...(options.overwritePolicy === undefined ? {} : { overwritePolicy: options.overwritePolicy })
|
|
146
|
+
});
|
|
147
|
+
if (context.options.config)
|
|
148
|
+
await writeConfig(context.config, context.options);
|
|
149
|
+
return {
|
|
150
|
+
...result,
|
|
151
|
+
configPath: context.options.config ? resolvePath(context.options.config) : result.configPath
|
|
152
|
+
};
|
|
153
|
+
}, "Testnet workspace initialized."));
|
|
154
|
+
testnet
|
|
155
|
+
.command("reset")
|
|
156
|
+
.description("Plan a reset of generated Testnet state.")
|
|
157
|
+
.option("--soft", "Preserve wallets and policies")
|
|
158
|
+
.option("--wallets", "Also reset generated Testnet wallets")
|
|
159
|
+
.option("--yes", "Confirm destructive reset")
|
|
160
|
+
.action(withContext(async (_context, options) => {
|
|
161
|
+
if (options.wallets && !options.yes) {
|
|
162
|
+
throw new StellarAgentError({
|
|
163
|
+
code: "INVALID_INPUT",
|
|
164
|
+
message: "Resetting wallets requires --yes.",
|
|
165
|
+
docs: "docs/quickstart-testnet.md#reset"
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return { planned: true, soft: Boolean(options.soft), wallets: Boolean(options.wallets) };
|
|
169
|
+
}, "Reset plan generated."));
|
|
170
|
+
const friendbot = testnet
|
|
171
|
+
.command("friendbot")
|
|
172
|
+
.alias("fund")
|
|
173
|
+
.description("Fund a Testnet account with Friendbot. Alias: testnet fund.")
|
|
174
|
+
.option("--account <name>", "Local wallet name", "agent")
|
|
175
|
+
.option("--address <address>", "Raw Stellar public key");
|
|
176
|
+
friendbot.action(withContext(async (context, options) => {
|
|
177
|
+
const { fundWithFriendbot } = await import("@stellar-agent/stellar");
|
|
178
|
+
const address = options.address ?? (await loadWallet(context.config, options.account)).publicKey;
|
|
179
|
+
const result = await fundWithFriendbot(address, context.config.profiles.testnet);
|
|
180
|
+
return { address, ...result };
|
|
181
|
+
}, "Friendbot funding complete."));
|
|
182
|
+
testnet
|
|
183
|
+
.command("create-pair")
|
|
184
|
+
.description("Create local agent and merchant Testnet wallets if missing.")
|
|
185
|
+
.action(withContext(async (context) => ({
|
|
186
|
+
agent: await ensureWallet(context.config, "agent"),
|
|
187
|
+
merchant: await ensureWallet(context.config, "merchant")
|
|
188
|
+
}), "Testnet pair ready."));
|
|
189
|
+
testnet
|
|
190
|
+
.command("smoke-test")
|
|
191
|
+
.description("Run the install-to-Testnet-payment workflow.")
|
|
192
|
+
.option("--dry-run", "Evaluate locally without submitting a transaction")
|
|
193
|
+
.action(withContext(async (context, options) => {
|
|
194
|
+
const harness = createTestnetHarness(context.config);
|
|
195
|
+
return harness.runBasicPayment(options.dryRun === undefined ? {} : { dryRun: options.dryRun });
|
|
196
|
+
}, "Smoke test complete."));
|
|
197
|
+
const scenario = testnet.command("scenario").description("Run canned Testnet scenarios.");
|
|
198
|
+
scenario
|
|
199
|
+
.command("basic-payment")
|
|
200
|
+
.description("Run a canned Testnet XLM payment scenario.")
|
|
201
|
+
.option("--dry-run", "Evaluate locally without submitting a transaction")
|
|
202
|
+
.action(withContext(async (context, options) => {
|
|
203
|
+
return createTestnetHarness(context.config).runBasicPayment({
|
|
204
|
+
amount: "1",
|
|
205
|
+
memo: "basic-payment scenario",
|
|
206
|
+
...(options.dryRun === undefined ? {} : { dryRun: options.dryRun })
|
|
207
|
+
});
|
|
208
|
+
}, "Basic payment scenario complete."));
|
|
209
|
+
scenario
|
|
210
|
+
.command("issued-asset-payment")
|
|
211
|
+
.description("Run a canned issued-asset trustline and payment scenario.")
|
|
212
|
+
.option("--issuer <name>", "Local issuer wallet name", "issuer")
|
|
213
|
+
.option("--recipient <name>", "Local recipient wallet name", "merchant")
|
|
214
|
+
.option("--asset-code <code>", "Issued asset code; generated when omitted")
|
|
215
|
+
.option("--amount <amount>", "Issued asset amount", "0.0000001")
|
|
216
|
+
.option("--memo <memo>", "Payment memo", "issued-asset scenario")
|
|
217
|
+
.option("--no-fund", "Do not call Friendbot before submitting")
|
|
218
|
+
.option("--dry-run", "Plan the scenario without funding, trustline, or payment submission")
|
|
219
|
+
.action(withContext(async (context, options) => {
|
|
220
|
+
return createTestnetHarness(context.config).runIssuedAssetPaymentScenario({
|
|
221
|
+
issuer: options.issuer,
|
|
222
|
+
recipient: options.recipient,
|
|
223
|
+
amount: options.amount,
|
|
224
|
+
memo: options.memo,
|
|
225
|
+
...(options.assetCode === undefined ? {} : { assetCode: options.assetCode }),
|
|
226
|
+
...(options.fund === undefined ? {} : { fund: options.fund }),
|
|
227
|
+
...(options.dryRun === undefined ? {} : { dryRun: options.dryRun })
|
|
228
|
+
});
|
|
229
|
+
}, "Issued-asset scenario complete."));
|
|
230
|
+
scenario
|
|
231
|
+
.command("contract-asset-smoke")
|
|
232
|
+
.description("Run a Stellar CLI-backed asset contract smoke scenario.")
|
|
233
|
+
.option("--source <source>", "Local wallet name, Stellar CLI identity, raw public key, or Testnet secret key", "agent")
|
|
234
|
+
.option("--asset <asset>", "Asset as native or CODE:G... issuer", "native")
|
|
235
|
+
.option("--alias <alias>", "Stellar CLI alias for deployed asset contract")
|
|
236
|
+
.option("--ledgers-to-extend <n>", "Number of ledgers to extend", parseLedgersToExtendOption, 535679)
|
|
237
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary", "stellar")
|
|
238
|
+
.option("--stellar-config-dir <path>", "Stellar CLI config directory")
|
|
239
|
+
.option("--stellar-no-cache", "Pass --no-cache to Stellar CLI")
|
|
240
|
+
.option("--no-fund", "Do not Friendbot-fund a local Testnet source wallet")
|
|
241
|
+
.option("--dry-run", "Plan the scenario without submitting contract transactions")
|
|
242
|
+
.action(withContext(async (context, options) => createTestnetHarness(context.config).runContractAssetSmokeScenario({
|
|
243
|
+
source: options.source,
|
|
244
|
+
asset: options.asset,
|
|
245
|
+
...(options.alias === undefined ? {} : { alias: options.alias }),
|
|
246
|
+
ledgersToExtend: options.ledgersToExtend,
|
|
247
|
+
stellarBinary: options.stellarBinary,
|
|
248
|
+
...(options.stellarConfigDir === undefined ? {} : { stellarConfigDir: options.stellarConfigDir }),
|
|
249
|
+
...(options.stellarNoCache === undefined ? {} : { noCache: options.stellarNoCache }),
|
|
250
|
+
...(options.fund === undefined ? {} : { fund: options.fund }),
|
|
251
|
+
...(options.dryRun === undefined ? {} : { dryRun: options.dryRun })
|
|
252
|
+
}), "Contract asset smoke scenario complete."));
|
|
253
|
+
scenario
|
|
254
|
+
.command("policy-denied")
|
|
255
|
+
.description("Verify a policy denial without signing or submitting.")
|
|
256
|
+
.action(withContext(async (context) => createTestnetHarness(context.config).runPolicyDeniedScenario(), "Policy-denied scenario complete."));
|
|
257
|
+
scenario
|
|
258
|
+
.command("approval-required")
|
|
259
|
+
.description("Verify approval-required behavior.")
|
|
260
|
+
.option("--auto-deny", "Complete without signing")
|
|
261
|
+
.option("--approve-testnet", "Proceed on Testnet after approval")
|
|
262
|
+
.action(withContext(async (context, options) => {
|
|
263
|
+
return createTestnetHarness(context.config).runApprovalRequiredScenario(options);
|
|
264
|
+
}, "Approval-required scenario complete."));
|
|
265
|
+
scenario
|
|
266
|
+
.command("x402-payment")
|
|
267
|
+
.description("Run a local x402-style Testnet payment scenario.")
|
|
268
|
+
.option("--dry-run", "Evaluate the 402 requirement without submitting payment")
|
|
269
|
+
.action(withContext(async (context, options) => {
|
|
270
|
+
const { runX402Payment, startPaidApiDemo } = await import("@stellar-agent/x402-client");
|
|
271
|
+
const source = await loadWallet(context.config, "agent");
|
|
272
|
+
const merchant = await loadWallet(context.config, "merchant");
|
|
273
|
+
const demo = await startPaidApiDemo({ recipient: merchant.publicKey });
|
|
274
|
+
try {
|
|
275
|
+
const policy = enableX402ForUrl(DEFAULT_TESTNET_POLICY, demo.url);
|
|
276
|
+
return await runX402Payment({
|
|
277
|
+
url: demo.url,
|
|
278
|
+
source,
|
|
279
|
+
policy,
|
|
280
|
+
profile: context.config.profiles.testnet,
|
|
281
|
+
receiptsDir: context.config.storage.receiptsDir,
|
|
282
|
+
eventLog: join(context.config.storage.logsDir, "events.jsonl"),
|
|
283
|
+
command: "testnet scenario x402-payment",
|
|
284
|
+
...(options.dryRun === undefined ? {} : { dryRun: options.dryRun })
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
await demo.close();
|
|
289
|
+
}
|
|
290
|
+
}, "x402 payment scenario complete."));
|
|
291
|
+
testnet
|
|
292
|
+
.command("export-report")
|
|
293
|
+
.description("Export a local Testnet report with wallets, receipts, and event-log paths.")
|
|
294
|
+
.option("--output <path>", "Output JSON path")
|
|
295
|
+
.action(withContext(async (context, options) => {
|
|
296
|
+
return writeLocalReport(context, options.output ?? join(context.config.storage.ledgersDir, "testnet-report.json"));
|
|
297
|
+
}, "Testnet report exported."));
|
|
298
|
+
}
|
|
299
|
+
function addWalletCommands(program) {
|
|
300
|
+
const wallet = program.command("wallet").description("Manage local and connected wallets.");
|
|
301
|
+
wallet
|
|
302
|
+
.command("create")
|
|
303
|
+
.description("Create a Testnet wallet. Mainnet secret storage is not implemented.")
|
|
304
|
+
.option("--fund", "Fund the Testnet wallet with Friendbot after creation")
|
|
305
|
+
.action(withContext(async (context, options) => createTestnetWalletResult(context, "agent", Boolean(options.fund)), "Wallet created."));
|
|
306
|
+
wallet
|
|
307
|
+
.command("create-testnet")
|
|
308
|
+
.description("Create a local Testnet wallet.")
|
|
309
|
+
.argument("[name]", "Wallet name", "agent")
|
|
310
|
+
.option("--fund", "Fund the Testnet wallet with Friendbot after creation")
|
|
311
|
+
.action(withContext(async (context, name, options) => createTestnetWalletResult(context, name, Boolean(options.fund)), "Testnet wallet created."));
|
|
312
|
+
wallet
|
|
313
|
+
.command("balance")
|
|
314
|
+
.description("Show wallet balances.")
|
|
315
|
+
.option("--account <name>", "Local wallet name", "agent")
|
|
316
|
+
.action(withContext(async (context, options) => ({
|
|
317
|
+
account: options.account,
|
|
318
|
+
balances: await walletBalances(context.config, options.account)
|
|
319
|
+
}), "Wallet balance loaded."));
|
|
320
|
+
wallet
|
|
321
|
+
.command("address")
|
|
322
|
+
.description("Show a local wallet public address.")
|
|
323
|
+
.option("--account <name>", "Local wallet name", "agent")
|
|
324
|
+
.action(withContext(async (context, options) => {
|
|
325
|
+
const wallet = await loadWalletPublic(context.config, options.account);
|
|
326
|
+
return { account: options.account, publicKey: wallet.publicKey, network: wallet.network, hasSecret: wallet.hasSecret };
|
|
327
|
+
}, "Wallet address loaded."));
|
|
328
|
+
wallet
|
|
329
|
+
.command("export-public")
|
|
330
|
+
.description("Export public wallet metadata.")
|
|
331
|
+
.option("--account <name>", "Local wallet name", "agent")
|
|
332
|
+
.action(withContext(async (context, options) => {
|
|
333
|
+
const wallet = await loadWalletPublic(context.config, options.account);
|
|
334
|
+
return redactSensitive(wallet);
|
|
335
|
+
}, "Public wallet metadata exported."));
|
|
336
|
+
wallet
|
|
337
|
+
.command("import-public")
|
|
338
|
+
.description("Import a watch-only public wallet for balance and ledger inspection.")
|
|
339
|
+
.requiredOption("--name <name>", "Local watch-only wallet name")
|
|
340
|
+
.requiredOption("--address <address>", "Stellar public key")
|
|
341
|
+
.option("--network <network>", "Network name: testnet, mainnet, local", "mainnet")
|
|
342
|
+
.action(withContext(async (context, options) => {
|
|
343
|
+
const wallet = await importPublicWallet({
|
|
344
|
+
config: context.config,
|
|
345
|
+
name: options.name,
|
|
346
|
+
publicKey: options.address,
|
|
347
|
+
network: options.network
|
|
348
|
+
});
|
|
349
|
+
return { ...wallet, hasSecret: false, realFunds: options.network === "mainnet" };
|
|
350
|
+
}, "Watch-only wallet imported."));
|
|
351
|
+
wallet
|
|
352
|
+
.command("use-env")
|
|
353
|
+
.description("Report whether STELLAR_SECRET_KEY is configured without printing it.")
|
|
354
|
+
.action(withContext(async () => ({ configured: Boolean(process.env.STELLAR_SECRET_KEY), secretPrinted: false }), "Environment wallet checked."));
|
|
355
|
+
wallet
|
|
356
|
+
.command("status")
|
|
357
|
+
.description("Show local wallet status.")
|
|
358
|
+
.action(withContext(async (context) => {
|
|
359
|
+
const wallets = await listWalletPublicViews(context.config);
|
|
360
|
+
return { wallets };
|
|
361
|
+
}, "Wallet status loaded."));
|
|
362
|
+
wallet
|
|
363
|
+
.command("connect-freighter")
|
|
364
|
+
.description("Show local approval bridge details for Freighter-backed approval flows.")
|
|
365
|
+
.action(withContext(async (context) => {
|
|
366
|
+
const { listApprovalRequests } = await import("@stellar-agent/freighter-bridge");
|
|
367
|
+
const requests = await listApprovalRequests(context.config.storage.approvalsDir);
|
|
368
|
+
return {
|
|
369
|
+
implemented: true,
|
|
370
|
+
mode: "local_approval_bridge",
|
|
371
|
+
approvalsDir: context.config.storage.approvalsDir,
|
|
372
|
+
pendingRequests: requests.filter((request) => request.status === "pending").length,
|
|
373
|
+
startServer: "stellar-agent approval serve",
|
|
374
|
+
ui: "Open the approval bridge URL in a browser with Freighter installed.",
|
|
375
|
+
secretsHandled: false
|
|
376
|
+
};
|
|
377
|
+
}, "Freighter approval bridge details loaded."));
|
|
378
|
+
const trustline = wallet.command("trustline").description("List, add, or remove issued-asset trustlines.");
|
|
379
|
+
trustline
|
|
380
|
+
.command("list")
|
|
381
|
+
.description("List issued-asset trustlines for an account.")
|
|
382
|
+
.option("--account <name>", "Local wallet name", "merchant")
|
|
383
|
+
.action(withContext(async (context, options) => {
|
|
384
|
+
const wallet = await loadWalletPublic(context.config, options.account);
|
|
385
|
+
return {
|
|
386
|
+
account: options.account,
|
|
387
|
+
publicKey: wallet.publicKey,
|
|
388
|
+
trustlines: await walletTrustlines(context.config, options.account)
|
|
389
|
+
};
|
|
390
|
+
}, "Trustlines listed."));
|
|
391
|
+
trustline
|
|
392
|
+
.command("add")
|
|
393
|
+
.description("Add or update a trustline for an issued asset.")
|
|
394
|
+
.requiredOption("--asset <asset>", "Issued asset as CODE:G... issuer")
|
|
395
|
+
.option("--account <name>", "Local wallet name", "merchant")
|
|
396
|
+
.option("--limit <amount>", "Trustline limit", "922337203685.4775807")
|
|
397
|
+
.action(withContext(async (context, options) => {
|
|
398
|
+
const { changeTrustline } = await import("@stellar-agent/stellar");
|
|
399
|
+
const source = await loadWallet(context.config, options.account);
|
|
400
|
+
const transaction = await changeTrustline({
|
|
401
|
+
source,
|
|
402
|
+
asset: options.asset,
|
|
403
|
+
limit: options.limit,
|
|
404
|
+
profile: context.config.profiles.testnet
|
|
405
|
+
});
|
|
406
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
407
|
+
event: "transaction_confirmed",
|
|
408
|
+
status: "trustline_added",
|
|
409
|
+
command: "wallet trustline add",
|
|
410
|
+
profile: "testnet",
|
|
411
|
+
data: { account: options.account, asset: options.asset, transaction }
|
|
412
|
+
});
|
|
413
|
+
const receiptPath = await writeOperationReceipt(context, {
|
|
414
|
+
command: "wallet trustline add",
|
|
415
|
+
operation: {
|
|
416
|
+
type: "trustline.add",
|
|
417
|
+
account: options.account,
|
|
418
|
+
source: source.publicKey,
|
|
419
|
+
asset: options.asset,
|
|
420
|
+
limit: transaction.limit
|
|
421
|
+
},
|
|
422
|
+
transaction
|
|
423
|
+
});
|
|
424
|
+
return { account: options.account, publicKey: source.publicKey, transaction, receiptPath };
|
|
425
|
+
}, "Trustline added."));
|
|
426
|
+
trustline
|
|
427
|
+
.command("remove")
|
|
428
|
+
.description("Remove a trustline by setting its limit to 0.")
|
|
429
|
+
.requiredOption("--asset <asset>", "Issued asset as CODE:G... issuer")
|
|
430
|
+
.option("--account <name>", "Local wallet name", "merchant")
|
|
431
|
+
.action(withContext(async (context, options) => {
|
|
432
|
+
const { removeTrustline } = await import("@stellar-agent/stellar");
|
|
433
|
+
const source = await loadWallet(context.config, options.account);
|
|
434
|
+
const transaction = await removeTrustline({
|
|
435
|
+
source,
|
|
436
|
+
asset: options.asset,
|
|
437
|
+
profile: context.config.profiles.testnet
|
|
438
|
+
});
|
|
439
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
440
|
+
event: "transaction_confirmed",
|
|
441
|
+
status: "trustline_removed",
|
|
442
|
+
command: "wallet trustline remove",
|
|
443
|
+
profile: "testnet",
|
|
444
|
+
data: { account: options.account, asset: options.asset, transaction }
|
|
445
|
+
});
|
|
446
|
+
const receiptPath = await writeOperationReceipt(context, {
|
|
447
|
+
command: "wallet trustline remove",
|
|
448
|
+
operation: {
|
|
449
|
+
type: "trustline.remove",
|
|
450
|
+
account: options.account,
|
|
451
|
+
source: source.publicKey,
|
|
452
|
+
asset: options.asset,
|
|
453
|
+
limit: transaction.limit
|
|
454
|
+
},
|
|
455
|
+
transaction
|
|
456
|
+
});
|
|
457
|
+
return { account: options.account, publicKey: source.publicKey, transaction, receiptPath };
|
|
458
|
+
}, "Trustline removed."));
|
|
459
|
+
}
|
|
460
|
+
function addApprovalCommands(program) {
|
|
461
|
+
const approval = program.command("approval").description("Create, inspect, decide, and serve local approval requests.");
|
|
462
|
+
approval
|
|
463
|
+
.command("create-payment")
|
|
464
|
+
.description("Create a local payment approval request.")
|
|
465
|
+
.requiredOption("--to <address>", "Destination public key")
|
|
466
|
+
.requiredOption("--amount <amount>", "Payment amount")
|
|
467
|
+
.option("--asset <asset>", "Asset", "XLM")
|
|
468
|
+
.option("--from <account>", "Local source wallet name", "agent")
|
|
469
|
+
.option("--memo <memo>", "Memo")
|
|
470
|
+
.action(withContext(async (context, options) => {
|
|
471
|
+
const { createPaymentApprovalRequest } = await import("@stellar-agent/freighter-bridge");
|
|
472
|
+
const source = await loadWalletPublic(context.config, options.from);
|
|
473
|
+
const request = paymentRequestSchema.parse({
|
|
474
|
+
source: source.publicKey,
|
|
475
|
+
destination: options.to,
|
|
476
|
+
amount: options.amount,
|
|
477
|
+
asset: options.asset,
|
|
478
|
+
memo: options.memo,
|
|
479
|
+
network: context.profileName
|
|
480
|
+
});
|
|
481
|
+
const approval = await createPaymentApprovalRequest({
|
|
482
|
+
approvalsDir: context.config.storage.approvalsDir,
|
|
483
|
+
payment: request
|
|
484
|
+
});
|
|
485
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
486
|
+
event: "approval_requested",
|
|
487
|
+
status: "pending",
|
|
488
|
+
command: "approval create-payment",
|
|
489
|
+
profile: context.profileName,
|
|
490
|
+
requestId: approval.id,
|
|
491
|
+
data: approval
|
|
492
|
+
});
|
|
493
|
+
return approval;
|
|
494
|
+
}, "Approval request created."));
|
|
495
|
+
approval
|
|
496
|
+
.command("create-transaction")
|
|
497
|
+
.description("Create a local transaction-XDR approval request for browser-wallet signing.")
|
|
498
|
+
.requiredOption("--xdr <base64>", "Unsigned transaction XDR")
|
|
499
|
+
.option("--summary <summary>", "Human-readable signing summary", "Approve transaction XDR")
|
|
500
|
+
.option("--network <network>", "Network name: testnet, mainnet, local")
|
|
501
|
+
.action(withContext(async (context, options) => {
|
|
502
|
+
const { createTransactionXdrApprovalRequest } = await import("@stellar-agent/freighter-bridge");
|
|
503
|
+
const approval = await createTransactionXdrApprovalRequest({
|
|
504
|
+
approvalsDir: context.config.storage.approvalsDir,
|
|
505
|
+
network: options.network ?? context.profileName,
|
|
506
|
+
transactionXdr: options.xdr,
|
|
507
|
+
summary: options.summary
|
|
508
|
+
});
|
|
509
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
510
|
+
event: "approval_requested",
|
|
511
|
+
status: "pending",
|
|
512
|
+
command: "approval create-transaction",
|
|
513
|
+
profile: approval.network,
|
|
514
|
+
requestId: approval.id,
|
|
515
|
+
data: approval
|
|
516
|
+
});
|
|
517
|
+
return approval;
|
|
518
|
+
}, "Transaction approval request created."));
|
|
519
|
+
approval
|
|
520
|
+
.command("list")
|
|
521
|
+
.description("List local approval requests.")
|
|
522
|
+
.action(withContext(async (context) => {
|
|
523
|
+
const { listApprovalRequests } = await import("@stellar-agent/freighter-bridge");
|
|
524
|
+
return { approvals: await listApprovalRequests(context.config.storage.approvalsDir) };
|
|
525
|
+
}, "Approval requests listed."));
|
|
526
|
+
approval
|
|
527
|
+
.command("show")
|
|
528
|
+
.description("Show a local approval request.")
|
|
529
|
+
.argument("<id>", "Approval request id")
|
|
530
|
+
.action(withContext(async (context, id) => {
|
|
531
|
+
const { readApprovalRequest } = await import("@stellar-agent/freighter-bridge");
|
|
532
|
+
return readApprovalRequest(context.config.storage.approvalsDir, id);
|
|
533
|
+
}, "Approval request loaded."));
|
|
534
|
+
approval
|
|
535
|
+
.command("decide")
|
|
536
|
+
.description("Approve or deny a local approval request.")
|
|
537
|
+
.argument("<id>", "Approval request id")
|
|
538
|
+
.option("--approve", "Approve the request")
|
|
539
|
+
.option("--deny", "Deny the request")
|
|
540
|
+
.option("--reason <reason>", "Decision reason")
|
|
541
|
+
.option("--signer-public-key <address>", "Signer public key for signed transaction XDR")
|
|
542
|
+
.option("--signed-transaction-xdr <base64>", "Signed transaction XDR returned by Freighter")
|
|
543
|
+
.action(withContext(async (context, id, options) => {
|
|
544
|
+
const signing = Boolean(options.signedTransactionXdr);
|
|
545
|
+
if (!signing && Boolean(options.approve) === Boolean(options.deny)) {
|
|
546
|
+
throw new StellarAgentError({
|
|
547
|
+
code: "INVALID_INPUT",
|
|
548
|
+
message: "Pass exactly one of --approve or --deny.",
|
|
549
|
+
docs: "docs/mainnet-safety.md#local-approval-bridge"
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
const { decideApprovalRequest } = await import("@stellar-agent/freighter-bridge");
|
|
553
|
+
const decided = await decideApprovalRequest({
|
|
554
|
+
approvalsDir: context.config.storage.approvalsDir,
|
|
555
|
+
id,
|
|
556
|
+
approved: signing || Boolean(options.approve),
|
|
557
|
+
...(options.reason === undefined ? {} : { reason: options.reason }),
|
|
558
|
+
...(options.signerPublicKey === undefined ? {} : { signerPublicKey: options.signerPublicKey }),
|
|
559
|
+
...(options.signedTransactionXdr === undefined ? {} : { signedTransactionXdr: options.signedTransactionXdr })
|
|
560
|
+
});
|
|
561
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
562
|
+
event: decided.status === "denied" ? "approval_denied" : "approval_granted",
|
|
563
|
+
status: decided.status,
|
|
564
|
+
command: "approval decide",
|
|
565
|
+
profile: context.profileName,
|
|
566
|
+
requestId: decided.id,
|
|
567
|
+
data: decided
|
|
568
|
+
});
|
|
569
|
+
return decided;
|
|
570
|
+
}, "Approval request decided."));
|
|
571
|
+
approval
|
|
572
|
+
.command("serve")
|
|
573
|
+
.description("Start the local approval bridge HTTP server.")
|
|
574
|
+
.option("--host <host>", "Host", "127.0.0.1")
|
|
575
|
+
.option("--port <port>", "Port", parsePortOption, 0)
|
|
576
|
+
.action(async (options, command) => {
|
|
577
|
+
const parent = command.optsWithGlobals();
|
|
578
|
+
const config = await loadConfig(parent);
|
|
579
|
+
const profileName = (parent.profile ?? process.env.STELLAR_AGENT_PROFILE ?? config.activeProfile ?? "testnet");
|
|
580
|
+
const context = { options: parent, config, profileName };
|
|
581
|
+
const { startApprovalBridge } = await import("@stellar-agent/freighter-bridge");
|
|
582
|
+
const bridge = await startApprovalBridge({
|
|
583
|
+
approvalsDir: context.config.storage.approvalsDir,
|
|
584
|
+
host: options.host,
|
|
585
|
+
port: options.port
|
|
586
|
+
});
|
|
587
|
+
if (parent.json) {
|
|
588
|
+
process.stdout.write(`${JSON.stringify(ok({ url: bridge.url, authToken: bridge.authToken, approvalsDir: context.config.storage.approvalsDir }))}\n`);
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
process.stdout.write(`Approval bridge listening at ${bridge.url}\n`);
|
|
592
|
+
process.stdout.write("Approval bridge API requires the printed session token for non-browser requests.\n");
|
|
593
|
+
process.stdout.write(`Session token: ${bridge.authToken}\n`);
|
|
594
|
+
}
|
|
595
|
+
await new Promise((resolveStop) => {
|
|
596
|
+
process.once("SIGINT", resolveStop);
|
|
597
|
+
process.once("SIGTERM", resolveStop);
|
|
598
|
+
});
|
|
599
|
+
await bridge.close();
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
function addTransactionCommands(program) {
|
|
603
|
+
const tx = program.command("tx").description("Build and submit transaction XDR on Testnet or guarded Mainnet.");
|
|
604
|
+
tx
|
|
605
|
+
.command("build-payment")
|
|
606
|
+
.description("Build unsigned payment transaction XDR for browser-wallet signing.")
|
|
607
|
+
.requiredOption("--to <address>", "Destination public key")
|
|
608
|
+
.requiredOption("--amount <amount>", "Payment amount")
|
|
609
|
+
.option("--asset <asset>", "Asset", "XLM")
|
|
610
|
+
.option("--from <accountOrAddress>", "Local wallet name, watch-only wallet name, or source public key", "agent")
|
|
611
|
+
.option("--memo <memo>", "Memo")
|
|
612
|
+
.option("--fee-strategy <strategy>", "Fee strategy: base, low, medium, high, p95", "medium")
|
|
613
|
+
.option("--allow-real-funds", "Permit guarded Mainnet payment-XDR building")
|
|
614
|
+
.option("--i-understand-real-funds", "Acknowledge this payment uses real funds")
|
|
615
|
+
.action(withContext(async (context, options) => {
|
|
616
|
+
const profile = resolveNetworkProfile(context.profileName, context.config.profiles);
|
|
617
|
+
assertGuardedRealFundsProfile(context, profile, {
|
|
618
|
+
allowRealFunds: Boolean(options.allowRealFunds),
|
|
619
|
+
acknowledgeRealFunds: Boolean(options.iUnderstandRealFunds),
|
|
620
|
+
action: "payment-XDR building"
|
|
621
|
+
});
|
|
622
|
+
const { buildPaymentTransactionXdr } = await import("@stellar-agent/stellar");
|
|
623
|
+
const sourcePublicKey = await resolvePaymentSourcePublicKey(context, options.from, { realFunds: profile.realFunds });
|
|
624
|
+
const request = paymentRequestSchema.parse({
|
|
625
|
+
source: sourcePublicKey,
|
|
626
|
+
destination: options.to,
|
|
627
|
+
amount: options.amount,
|
|
628
|
+
asset: options.asset,
|
|
629
|
+
memo: options.memo,
|
|
630
|
+
network: profile.name
|
|
631
|
+
});
|
|
632
|
+
const policy = await loadPolicy(context);
|
|
633
|
+
const history = await loadSpendHistory(context, request);
|
|
634
|
+
const policyDecision = evaluatePaymentRequest(policy, request, history);
|
|
635
|
+
if (policyDecision.status === "denied")
|
|
636
|
+
throw policyDeniedError();
|
|
637
|
+
if (profile.realFunds && policyDecision.status === "requires_approval") {
|
|
638
|
+
throw new StellarAgentError({
|
|
639
|
+
code: "APPROVAL_REQUIRED",
|
|
640
|
+
message: "Mainnet payment-XDR building requires a transaction approval request.",
|
|
641
|
+
hint: "Use tx request-payment-signature so the unsigned XDR is recorded for human approval.",
|
|
642
|
+
docs: "docs/mainnet-safety.md#signed-xdr-submission"
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
const built = await buildPaymentTransactionXdr({
|
|
646
|
+
sourcePublicKey,
|
|
647
|
+
destination: options.to,
|
|
648
|
+
amount: options.amount,
|
|
649
|
+
asset: options.asset,
|
|
650
|
+
...(options.memo === undefined ? {} : { memo: options.memo }),
|
|
651
|
+
profile,
|
|
652
|
+
allowRealFunds: profile.realFunds,
|
|
653
|
+
feeStrategy: parseFeeStrategy(options.feeStrategy),
|
|
654
|
+
noCache: context.options.noCache
|
|
655
|
+
});
|
|
656
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
657
|
+
event: "transaction_built",
|
|
658
|
+
status: "unsigned_payment_xdr",
|
|
659
|
+
command: "tx build-payment",
|
|
660
|
+
profile: profile.name,
|
|
661
|
+
data: { source: sourcePublicKey, destination: options.to, asset: options.asset, amount: built.amount, policyDecision }
|
|
662
|
+
});
|
|
663
|
+
return { ...built, policyDecision, spendHistory: history, realFunds: profile.realFunds };
|
|
664
|
+
}, "Unsigned payment transaction built."));
|
|
665
|
+
tx
|
|
666
|
+
.command("request-payment-signature")
|
|
667
|
+
.description("Build unsigned payment XDR and create a local transaction approval request.")
|
|
668
|
+
.requiredOption("--to <address>", "Destination public key")
|
|
669
|
+
.requiredOption("--amount <amount>", "Payment amount")
|
|
670
|
+
.option("--asset <asset>", "Asset", "XLM")
|
|
671
|
+
.option("--from <accountOrAddress>", "Local wallet name, watch-only wallet name, or source public key", "agent")
|
|
672
|
+
.option("--memo <memo>", "Memo")
|
|
673
|
+
.option("--summary <summary>", "Human-readable signing summary")
|
|
674
|
+
.option("--fee-strategy <strategy>", "Fee strategy: base, low, medium, high, p95", "medium")
|
|
675
|
+
.option("--allow-real-funds", "Permit a guarded Mainnet payment signature request")
|
|
676
|
+
.option("--i-understand-real-funds", "Acknowledge this payment uses real funds")
|
|
677
|
+
.action(withContext(async (context, options) => {
|
|
678
|
+
const profile = resolveNetworkProfile(context.profileName, context.config.profiles);
|
|
679
|
+
assertGuardedRealFundsProfile(context, profile, {
|
|
680
|
+
allowRealFunds: Boolean(options.allowRealFunds),
|
|
681
|
+
acknowledgeRealFunds: Boolean(options.iUnderstandRealFunds),
|
|
682
|
+
action: "payment signature request"
|
|
683
|
+
});
|
|
684
|
+
const { createTransactionXdrApprovalRequest } = await import("@stellar-agent/freighter-bridge");
|
|
685
|
+
const { buildPaymentTransactionXdr } = await import("@stellar-agent/stellar");
|
|
686
|
+
const sourcePublicKey = await resolvePaymentSourcePublicKey(context, options.from, { realFunds: profile.realFunds });
|
|
687
|
+
const request = paymentRequestSchema.parse({
|
|
688
|
+
source: sourcePublicKey,
|
|
689
|
+
destination: options.to,
|
|
690
|
+
amount: options.amount,
|
|
691
|
+
asset: options.asset,
|
|
692
|
+
memo: options.memo,
|
|
693
|
+
network: profile.name
|
|
694
|
+
});
|
|
695
|
+
const policy = await loadPolicy(context);
|
|
696
|
+
const history = await loadSpendHistory(context, request);
|
|
697
|
+
const policyDecision = evaluatePaymentRequest(policy, request, history);
|
|
698
|
+
if (policyDecision.status === "denied")
|
|
699
|
+
throw policyDeniedError();
|
|
700
|
+
const built = await buildPaymentTransactionXdr({
|
|
701
|
+
sourcePublicKey,
|
|
702
|
+
destination: options.to,
|
|
703
|
+
amount: options.amount,
|
|
704
|
+
asset: options.asset,
|
|
705
|
+
...(options.memo === undefined ? {} : { memo: options.memo }),
|
|
706
|
+
profile,
|
|
707
|
+
allowRealFunds: profile.realFunds,
|
|
708
|
+
feeStrategy: parseFeeStrategy(options.feeStrategy),
|
|
709
|
+
noCache: context.options.noCache
|
|
710
|
+
});
|
|
711
|
+
const approval = await createTransactionXdrApprovalRequest({
|
|
712
|
+
approvalsDir: context.config.storage.approvalsDir,
|
|
713
|
+
network: profile.name,
|
|
714
|
+
transactionXdr: built.xdr,
|
|
715
|
+
summary: options.summary ?? `Sign ${built.amount} ${built.asset} payment to ${options.to} on ${profile.name}`
|
|
716
|
+
});
|
|
717
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
718
|
+
event: "approval_requested",
|
|
719
|
+
status: "pending",
|
|
720
|
+
command: "tx request-payment-signature",
|
|
721
|
+
profile: profile.name,
|
|
722
|
+
requestId: approval.id,
|
|
723
|
+
data: { approval, source: sourcePublicKey, destination: options.to, asset: options.asset, amount: built.amount, policyDecision }
|
|
724
|
+
});
|
|
725
|
+
return { approval, built, policyDecision, spendHistory: history, realFunds: profile.realFunds };
|
|
726
|
+
}, "Payment signature approval request created."));
|
|
727
|
+
tx
|
|
728
|
+
.command("submit-xdr")
|
|
729
|
+
.description("Submit signed transaction XDR to Horizon.")
|
|
730
|
+
.requiredOption("--xdr <base64>", "Signed transaction XDR")
|
|
731
|
+
.option("--allow-real-funds", "Permit guarded Mainnet signed-XDR submission")
|
|
732
|
+
.option("--i-understand-real-funds", "Acknowledge this transaction uses real funds")
|
|
733
|
+
.action(withContext(async (context, options) => {
|
|
734
|
+
const profile = resolveNetworkProfile(context.profileName, context.config.profiles);
|
|
735
|
+
assertGuardedRealFundsProfile(context, profile, {
|
|
736
|
+
allowRealFunds: Boolean(options.allowRealFunds),
|
|
737
|
+
acknowledgeRealFunds: Boolean(options.iUnderstandRealFunds),
|
|
738
|
+
action: "signed-XDR submission"
|
|
739
|
+
});
|
|
740
|
+
const { submitTransactionXdr } = await import("@stellar-agent/stellar");
|
|
741
|
+
const transaction = await submitTransactionXdr({
|
|
742
|
+
xdr: options.xdr,
|
|
743
|
+
profile,
|
|
744
|
+
allowRealFunds: profile.realFunds
|
|
745
|
+
});
|
|
746
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
747
|
+
event: "transaction_confirmed",
|
|
748
|
+
status: "signed_xdr_submitted",
|
|
749
|
+
command: "tx submit-xdr",
|
|
750
|
+
profile: profile.name,
|
|
751
|
+
data: { transaction }
|
|
752
|
+
});
|
|
753
|
+
const receiptPath = await writeSubmittedXdrReceipt(context, {
|
|
754
|
+
command: "tx submit-xdr",
|
|
755
|
+
profile,
|
|
756
|
+
operation: {
|
|
757
|
+
type: "tx.submit_xdr",
|
|
758
|
+
details: {
|
|
759
|
+
source: "external_signed_xdr",
|
|
760
|
+
realFundsAcknowledged: profile.realFunds
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
transaction
|
|
764
|
+
});
|
|
765
|
+
return { transaction, receiptPath, realFunds: profile.realFunds };
|
|
766
|
+
}, "Signed transaction submitted."));
|
|
767
|
+
tx
|
|
768
|
+
.command("submit-approval")
|
|
769
|
+
.description("Submit signed transaction XDR recorded on a local approval request.")
|
|
770
|
+
.argument("<id>", "Approval request id")
|
|
771
|
+
.option("--allow-real-funds", "Permit guarded Mainnet signed approval submission")
|
|
772
|
+
.option("--i-understand-real-funds", "Acknowledge this transaction uses real funds")
|
|
773
|
+
.action(withContext(async (context, id, options) => {
|
|
774
|
+
const { readApprovalRequest } = await import("@stellar-agent/freighter-bridge");
|
|
775
|
+
const approval = await readApprovalRequest(context.config.storage.approvalsDir, id);
|
|
776
|
+
if (approval.kind !== "transaction_xdr") {
|
|
777
|
+
throw new StellarAgentError({
|
|
778
|
+
code: "INVALID_INPUT",
|
|
779
|
+
message: "Approval request must be a transaction-XDR request.",
|
|
780
|
+
docs: "docs/mainnet-safety.md#signed-xdr-submission"
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
if (approval.network !== context.profileName) {
|
|
784
|
+
throw new StellarAgentError({
|
|
785
|
+
code: "MAINNET_NOT_ENABLED",
|
|
786
|
+
message: "The active profile must match the approval request network.",
|
|
787
|
+
docs: "docs/mainnet-safety.md#signed-xdr-submission"
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
if (approval.status !== "signed" || !approval.decision?.signedTransactionXdr) {
|
|
791
|
+
throw new StellarAgentError({
|
|
792
|
+
code: "APPROVAL_REQUIRED",
|
|
793
|
+
message: "Approval request does not contain signed transaction XDR.",
|
|
794
|
+
hint: "Open the approval bridge with Freighter installed, sign the request, then retry.",
|
|
795
|
+
docs: "docs/mainnet-safety.md#local-approval-bridge"
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
const profile = resolveNetworkProfile(approval.network, context.config.profiles);
|
|
799
|
+
assertGuardedRealFundsProfile(context, profile, {
|
|
800
|
+
allowRealFunds: Boolean(options.allowRealFunds),
|
|
801
|
+
acknowledgeRealFunds: Boolean(options.iUnderstandRealFunds),
|
|
802
|
+
action: "signed approval submission"
|
|
803
|
+
});
|
|
804
|
+
const { submitTransactionXdr } = await import("@stellar-agent/stellar");
|
|
805
|
+
const transaction = await submitTransactionXdr({
|
|
806
|
+
xdr: approval.decision.signedTransactionXdr,
|
|
807
|
+
profile,
|
|
808
|
+
allowRealFunds: profile.realFunds
|
|
809
|
+
});
|
|
810
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
811
|
+
event: "transaction_confirmed",
|
|
812
|
+
status: "signed_approval_submitted",
|
|
813
|
+
command: "tx submit-approval",
|
|
814
|
+
profile: profile.name,
|
|
815
|
+
requestId: approval.id,
|
|
816
|
+
data: {
|
|
817
|
+
approvalId: approval.id,
|
|
818
|
+
signerPublicKey: approval.decision.signerPublicKey,
|
|
819
|
+
transaction
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
const receiptPath = await writeSubmittedXdrReceipt(context, {
|
|
823
|
+
command: "tx submit-approval",
|
|
824
|
+
profile,
|
|
825
|
+
operation: {
|
|
826
|
+
type: "tx.submit_approval",
|
|
827
|
+
details: {
|
|
828
|
+
approvalId: approval.id,
|
|
829
|
+
signerPublicKey: approval.decision.signerPublicKey,
|
|
830
|
+
realFundsAcknowledged: profile.realFunds
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
transaction
|
|
834
|
+
});
|
|
835
|
+
return { approvalId: approval.id, signerPublicKey: approval.decision.signerPublicKey, transaction, receiptPath, realFunds: profile.realFunds };
|
|
836
|
+
}, "Signed approval transaction submitted."));
|
|
837
|
+
}
|
|
838
|
+
function addPayCommands(program) {
|
|
839
|
+
const pay = program.command("pay").description("Quote and send payments.");
|
|
840
|
+
pay
|
|
841
|
+
.command("quote")
|
|
842
|
+
.description("Evaluate a payment without signing or submitting.")
|
|
843
|
+
.requiredOption("--to <address>", "Destination public key")
|
|
844
|
+
.requiredOption("--amount <amount>", "Payment amount")
|
|
845
|
+
.option("--asset <asset>", "Asset", "XLM")
|
|
846
|
+
.option("--memo <memo>", "Memo")
|
|
847
|
+
.option("--fee-strategy <strategy>", "Fee strategy: base, low, medium, high, p95", "medium")
|
|
848
|
+
.action(withContext(async (context, options) => {
|
|
849
|
+
const policy = await loadPolicy(context);
|
|
850
|
+
const request = paymentRequestSchema.parse({
|
|
851
|
+
destination: options.to,
|
|
852
|
+
amount: options.amount,
|
|
853
|
+
asset: options.asset,
|
|
854
|
+
memo: options.memo,
|
|
855
|
+
network: context.profileName
|
|
856
|
+
});
|
|
857
|
+
const history = await loadSpendHistory(context, request);
|
|
858
|
+
const { estimateTransactionFee } = await import("@stellar-agent/stellar");
|
|
859
|
+
const profile = resolveNetworkProfile(context.profileName, context.config.profiles);
|
|
860
|
+
return {
|
|
861
|
+
request,
|
|
862
|
+
estimatedFee: await estimateTransactionFee({
|
|
863
|
+
profile,
|
|
864
|
+
operationCount: 1,
|
|
865
|
+
strategy: parseFeeStrategy(options.feeStrategy),
|
|
866
|
+
noCache: context.options.noCache
|
|
867
|
+
}),
|
|
868
|
+
policyDecision: evaluatePaymentRequest(policy, request, history),
|
|
869
|
+
spendHistory: history,
|
|
870
|
+
profile: context.profileName
|
|
871
|
+
};
|
|
872
|
+
}, "Payment quote complete."));
|
|
873
|
+
pay
|
|
874
|
+
.command("send")
|
|
875
|
+
.description("Send an XLM or issued-asset payment on Testnet when policy allows.")
|
|
876
|
+
.requiredOption("--to <address>", "Destination public key")
|
|
877
|
+
.requiredOption("--amount <amount>", "Payment amount")
|
|
878
|
+
.option("--asset <asset>", "Asset", "XLM")
|
|
879
|
+
.option("--from <account>", "Local source wallet name", "agent")
|
|
880
|
+
.option("--memo <memo>", "Memo")
|
|
881
|
+
.option("--fee-strategy <strategy>", "Fee strategy: base, low, medium, high, p95", "medium")
|
|
882
|
+
.option("--approval-id <id>", "Approved local approval request id")
|
|
883
|
+
.option("--dry-run", "Evaluate locally without submitting")
|
|
884
|
+
.action(withContext(async (context, options) => {
|
|
885
|
+
if (context.profileName === "mainnet") {
|
|
886
|
+
throw new StellarAgentError({
|
|
887
|
+
code: "MAINNET_NOT_ENABLED",
|
|
888
|
+
message: "Mainnet payment submission is blocked in v0.",
|
|
889
|
+
docs: "docs/mainnet-safety.md"
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
const policy = await loadPolicy(context);
|
|
893
|
+
const source = await loadWallet(context.config, options.from);
|
|
894
|
+
const request = paymentRequestSchema.parse({
|
|
895
|
+
source: source.publicKey,
|
|
896
|
+
destination: options.to,
|
|
897
|
+
amount: options.amount,
|
|
898
|
+
asset: options.asset,
|
|
899
|
+
memo: options.memo,
|
|
900
|
+
network: "testnet"
|
|
901
|
+
});
|
|
902
|
+
const history = await loadSpendHistory(context, request);
|
|
903
|
+
const decision = evaluatePaymentRequest(policy, request, history);
|
|
904
|
+
if (options.dryRun)
|
|
905
|
+
return { request, policyDecision: decision, spendHistory: history, dryRun: true };
|
|
906
|
+
if (decision.status === "denied") {
|
|
907
|
+
throw new StellarAgentError({
|
|
908
|
+
code: "POLICY_DENIED",
|
|
909
|
+
message: "Payment request was denied by policy.",
|
|
910
|
+
hint: "Run policy explain to inspect the matched rules.",
|
|
911
|
+
docs: "docs/troubleshooting.md#policy-denied"
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
if (decision.status === "requires_approval") {
|
|
915
|
+
const { assertPaymentApproval, createPaymentApprovalRequest } = await import("@stellar-agent/freighter-bridge");
|
|
916
|
+
if (options.approvalId) {
|
|
917
|
+
await assertPaymentApproval({
|
|
918
|
+
approvalsDir: context.config.storage.approvalsDir,
|
|
919
|
+
approvalId: options.approvalId,
|
|
920
|
+
payment: request
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
else {
|
|
924
|
+
const approval = await createPaymentApprovalRequest({
|
|
925
|
+
approvalsDir: context.config.storage.approvalsDir,
|
|
926
|
+
payment: request
|
|
927
|
+
});
|
|
928
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
929
|
+
event: "approval_requested",
|
|
930
|
+
status: "pending",
|
|
931
|
+
command: "pay send",
|
|
932
|
+
profile: "testnet",
|
|
933
|
+
requestId: approval.id,
|
|
934
|
+
data: approval
|
|
935
|
+
});
|
|
936
|
+
throw new StellarAgentError({
|
|
937
|
+
code: "APPROVAL_REQUIRED",
|
|
938
|
+
message: "Payment requires approval.",
|
|
939
|
+
hint: `Review approval '${approval.id}', then rerun with --approval-id ${approval.id}.`,
|
|
940
|
+
docs: "docs/mainnet-safety.md#local-approval-bridge",
|
|
941
|
+
details: { approvalId: approval.id, approval }
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
const { sendPayment } = await import("@stellar-agent/stellar");
|
|
946
|
+
const transaction = await sendPayment({
|
|
947
|
+
source,
|
|
948
|
+
destination: options.to,
|
|
949
|
+
amount: options.amount,
|
|
950
|
+
asset: options.asset,
|
|
951
|
+
...(options.memo === undefined ? {} : { memo: options.memo }),
|
|
952
|
+
profile: context.config.profiles.testnet,
|
|
953
|
+
feeStrategy: parseFeeStrategy(options.feeStrategy),
|
|
954
|
+
noCache: context.options.noCache
|
|
955
|
+
});
|
|
956
|
+
const eventLog = join(context.config.storage.logsDir, "events.jsonl");
|
|
957
|
+
await appendEvent(eventLog, {
|
|
958
|
+
event: "transaction_confirmed",
|
|
959
|
+
status: "successful",
|
|
960
|
+
command: "pay send",
|
|
961
|
+
profile: "testnet",
|
|
962
|
+
data: { transaction, source: options.from, destination: options.to, asset: options.asset }
|
|
963
|
+
});
|
|
964
|
+
const { path: receiptPath } = await writeReceipt(context.config.storage.receiptsDir, {
|
|
965
|
+
command: "pay send",
|
|
966
|
+
profile: "testnet",
|
|
967
|
+
networkPassphrase: context.config.profiles.testnet.networkPassphrase,
|
|
968
|
+
realFunds: false,
|
|
969
|
+
payment: {
|
|
970
|
+
source: source.publicKey,
|
|
971
|
+
destination: options.to,
|
|
972
|
+
asset: options.asset,
|
|
973
|
+
amount: request.amount,
|
|
974
|
+
...(options.memo === undefined ? {} : { memo: options.memo })
|
|
975
|
+
},
|
|
976
|
+
policyDecision: decision,
|
|
977
|
+
transaction,
|
|
978
|
+
...(transaction.ledger === undefined ? {} : { ledger: { confirmedLedger: transaction.ledger } }),
|
|
979
|
+
eventLog
|
|
980
|
+
});
|
|
981
|
+
await appendEvent(eventLog, {
|
|
982
|
+
event: "receipt_written",
|
|
983
|
+
status: "success",
|
|
984
|
+
command: "pay send",
|
|
985
|
+
profile: "testnet",
|
|
986
|
+
data: { receiptPath }
|
|
987
|
+
});
|
|
988
|
+
return {
|
|
989
|
+
request,
|
|
990
|
+
policyDecision: decision,
|
|
991
|
+
transaction,
|
|
992
|
+
receiptPath
|
|
993
|
+
};
|
|
994
|
+
}, "Payment send complete."));
|
|
995
|
+
pay
|
|
996
|
+
.command("batch")
|
|
997
|
+
.description("Submit multiple Testnet payments in one guarded transaction.")
|
|
998
|
+
.requiredOption("--file <path>", "JSON file containing a payment array")
|
|
999
|
+
.option("--from <account>", "Local source wallet name", "agent")
|
|
1000
|
+
.option("--memo <memo>", "Transaction memo")
|
|
1001
|
+
.option("--fee-strategy <strategy>", "Fee strategy: base, low, medium, high, p95", "medium")
|
|
1002
|
+
.option("--dry-run", "Evaluate locally without submitting")
|
|
1003
|
+
.action(withContext(async (context, options) => {
|
|
1004
|
+
if (context.profileName === "mainnet") {
|
|
1005
|
+
throw new StellarAgentError({
|
|
1006
|
+
code: "MAINNET_NOT_ENABLED",
|
|
1007
|
+
message: "Mainnet batch payment submission is blocked in v0.",
|
|
1008
|
+
hint: "Use guarded signed-XDR flows for Mainnet; stellar-agent will not auto-sign Mainnet payments.",
|
|
1009
|
+
docs: "docs/mainnet-safety.md"
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
const source = await loadWallet(context.config, options.from);
|
|
1013
|
+
const payments = parseBatchPaymentsFile(await readFile(resolvePath(options.file), "utf8"));
|
|
1014
|
+
const policy = await loadPolicy(context);
|
|
1015
|
+
const checked = await evaluateBatchPaymentPolicy(context, {
|
|
1016
|
+
source: source.publicKey,
|
|
1017
|
+
payments,
|
|
1018
|
+
policy,
|
|
1019
|
+
...(options.memo === undefined ? {} : { memo: options.memo })
|
|
1020
|
+
});
|
|
1021
|
+
if (checked.aggregate.status === "denied") {
|
|
1022
|
+
throw new StellarAgentError({
|
|
1023
|
+
code: "POLICY_DENIED",
|
|
1024
|
+
message: "One or more batch payments were denied by policy.",
|
|
1025
|
+
hint: "Run pay quote for each denied destination and adjust the batch or policy intentionally.",
|
|
1026
|
+
docs: "docs/troubleshooting.md#policy-denied",
|
|
1027
|
+
details: checked
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
if (checked.aggregate.status === "requires_approval") {
|
|
1031
|
+
throw new StellarAgentError({
|
|
1032
|
+
code: "APPROVAL_REQUIRED",
|
|
1033
|
+
message: "One or more batch payments require approval.",
|
|
1034
|
+
hint: "Split approval-required payments into explicit pay send approval flows.",
|
|
1035
|
+
docs: "docs/mainnet-safety.md#local-approval-bridge",
|
|
1036
|
+
details: checked
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
if (options.dryRun)
|
|
1040
|
+
return { ...checked, dryRun: true };
|
|
1041
|
+
const { sendPaymentBatch } = await import("@stellar-agent/stellar");
|
|
1042
|
+
const transaction = await sendPaymentBatch({
|
|
1043
|
+
source,
|
|
1044
|
+
payments,
|
|
1045
|
+
...(options.memo === undefined ? {} : { memo: options.memo }),
|
|
1046
|
+
profile: context.config.profiles.testnet,
|
|
1047
|
+
feeStrategy: parseFeeStrategy(options.feeStrategy),
|
|
1048
|
+
noCache: context.options.noCache
|
|
1049
|
+
});
|
|
1050
|
+
const receiptPath = await writeOperationReceipt(context, {
|
|
1051
|
+
command: "pay batch",
|
|
1052
|
+
operation: {
|
|
1053
|
+
type: "pay.batch",
|
|
1054
|
+
source: source.publicKey,
|
|
1055
|
+
details: {
|
|
1056
|
+
operationCount: transaction.operationCount,
|
|
1057
|
+
payments: transaction.payments,
|
|
1058
|
+
aggregatePolicyDecision: checked.aggregate
|
|
1059
|
+
}
|
|
1060
|
+
},
|
|
1061
|
+
policyDecision: {
|
|
1062
|
+
status: checked.aggregate.status,
|
|
1063
|
+
network: "testnet",
|
|
1064
|
+
realFunds: false,
|
|
1065
|
+
matchedRules: checked.aggregate.matchedRules,
|
|
1066
|
+
reasons: ["Batch payment policy aggregate."]
|
|
1067
|
+
},
|
|
1068
|
+
transaction
|
|
1069
|
+
});
|
|
1070
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
1071
|
+
event: "transaction_confirmed",
|
|
1072
|
+
status: "batch_successful",
|
|
1073
|
+
command: "pay batch",
|
|
1074
|
+
profile: "testnet",
|
|
1075
|
+
data: { transaction, receiptPath }
|
|
1076
|
+
});
|
|
1077
|
+
return {
|
|
1078
|
+
...checked,
|
|
1079
|
+
transaction,
|
|
1080
|
+
receiptPath
|
|
1081
|
+
};
|
|
1082
|
+
}, "Batch payment transaction submitted."));
|
|
1083
|
+
pay
|
|
1084
|
+
.command("x402")
|
|
1085
|
+
.description("Pay a local x402-style HTTP 402 resource on Testnet.")
|
|
1086
|
+
.argument("<url>", "Paid URL")
|
|
1087
|
+
.option("--from <account>", "Local source wallet name", "agent")
|
|
1088
|
+
.option("--allow-localhost-demo", "Temporarily allow localhost x402 requirements without editing policy")
|
|
1089
|
+
.option("--dry-run", "Evaluate the 402 requirement without submitting payment")
|
|
1090
|
+
.action(withContext(async (context, url, options) => {
|
|
1091
|
+
const { runX402Payment } = await import("@stellar-agent/x402-client");
|
|
1092
|
+
const source = await loadWallet(context.config, options.from);
|
|
1093
|
+
const basePolicy = await loadPolicy(context);
|
|
1094
|
+
const policy = options.allowLocalhostDemo ? enableX402ForUrl(basePolicy, url) : basePolicy;
|
|
1095
|
+
return runX402Payment({
|
|
1096
|
+
url,
|
|
1097
|
+
source,
|
|
1098
|
+
policy,
|
|
1099
|
+
profile: context.config.profiles.testnet,
|
|
1100
|
+
receiptsDir: context.config.storage.receiptsDir,
|
|
1101
|
+
eventLog: join(context.config.storage.logsDir, "events.jsonl"),
|
|
1102
|
+
command: "pay x402",
|
|
1103
|
+
loadSpendHistory: (request) => loadSpendHistory(context, request),
|
|
1104
|
+
...(options.dryRun === undefined ? {} : { dryRun: options.dryRun })
|
|
1105
|
+
});
|
|
1106
|
+
}, "x402 payment complete."));
|
|
1107
|
+
pay
|
|
1108
|
+
.command("mpp")
|
|
1109
|
+
.description("Pay a local MPP one-time charge resource on Testnet.")
|
|
1110
|
+
.argument("<url>", "MPP URL")
|
|
1111
|
+
.option("--from <account>", "Local source wallet name", "agent")
|
|
1112
|
+
.option("--allow-localhost-demo", "Temporarily allow localhost MPP charges without editing policy")
|
|
1113
|
+
.option("--dry-run", "Evaluate the MPP charge without submitting payment")
|
|
1114
|
+
.action(withContext(async (context, url, options) => {
|
|
1115
|
+
const { runMppPayment } = await import("@stellar-agent/mpp-client");
|
|
1116
|
+
const source = await loadWallet(context.config, options.from);
|
|
1117
|
+
const basePolicy = await loadPolicy(context);
|
|
1118
|
+
const policy = options.allowLocalhostDemo ? enableX402ForUrl(basePolicy, url) : basePolicy;
|
|
1119
|
+
return runMppPayment({
|
|
1120
|
+
url,
|
|
1121
|
+
source,
|
|
1122
|
+
policy,
|
|
1123
|
+
profile: context.config.profiles.testnet,
|
|
1124
|
+
receiptsDir: context.config.storage.receiptsDir,
|
|
1125
|
+
eventLog: join(context.config.storage.logsDir, "events.jsonl"),
|
|
1126
|
+
command: "pay mpp",
|
|
1127
|
+
loadSpendHistory: (request) => loadSpendHistory(context, request),
|
|
1128
|
+
...(options.dryRun === undefined ? {} : { dryRun: options.dryRun })
|
|
1129
|
+
});
|
|
1130
|
+
}, "MPP payment complete."));
|
|
1131
|
+
pay
|
|
1132
|
+
.command("mpp-session")
|
|
1133
|
+
.description("Open and use a local MPP session-budget resource on Testnet.")
|
|
1134
|
+
.argument("<url>", "MPP session URL")
|
|
1135
|
+
.option("--from <account>", "Local source wallet name", "agent")
|
|
1136
|
+
.option("--requests <n>", "Number of session requests to perform", parsePositiveIntegerOption, 2)
|
|
1137
|
+
.option("--allow-localhost-demo", "Temporarily allow localhost MPP session charges without editing policy")
|
|
1138
|
+
.option("--dry-run", "Evaluate the MPP session budget without submitting payment")
|
|
1139
|
+
.action(withContext(async (context, url, options) => {
|
|
1140
|
+
const { runMppSession } = await import("@stellar-agent/mpp-client");
|
|
1141
|
+
const source = await loadWallet(context.config, options.from);
|
|
1142
|
+
const basePolicy = await loadPolicy(context);
|
|
1143
|
+
const policy = options.allowLocalhostDemo ? enableX402ForUrl(basePolicy, url) : basePolicy;
|
|
1144
|
+
return runMppSession({
|
|
1145
|
+
url,
|
|
1146
|
+
source,
|
|
1147
|
+
policy,
|
|
1148
|
+
profile: context.config.profiles.testnet,
|
|
1149
|
+
receiptsDir: context.config.storage.receiptsDir,
|
|
1150
|
+
eventLog: join(context.config.storage.logsDir, "events.jsonl"),
|
|
1151
|
+
command: "pay mpp-session",
|
|
1152
|
+
requestCount: options.requests,
|
|
1153
|
+
loadSpendHistory: (request) => loadSpendHistory(context, request),
|
|
1154
|
+
...(options.dryRun === undefined ? {} : { dryRun: options.dryRun })
|
|
1155
|
+
});
|
|
1156
|
+
}, "MPP session complete."));
|
|
1157
|
+
}
|
|
1158
|
+
function addClaimableCommands(program) {
|
|
1159
|
+
const claimable = program.command("claimable").description("Create, list, and claim claimable balances.");
|
|
1160
|
+
claimable
|
|
1161
|
+
.command("create")
|
|
1162
|
+
.description("Create a Testnet claimable balance.")
|
|
1163
|
+
.requiredOption("--to <address>", "Claimant public key")
|
|
1164
|
+
.requiredOption("--amount <amount>", "Amount")
|
|
1165
|
+
.option("--asset <asset>", "Asset", "XLM")
|
|
1166
|
+
.option("--from <account>", "Local source wallet name", "agent")
|
|
1167
|
+
.option("--claimant <address>", "Additional claimant public key; repeat for multiple claimants", collectArg, [])
|
|
1168
|
+
.option("--claimable-after <time>", "Only allow claiming at or after this Unix timestamp or ISO date/time")
|
|
1169
|
+
.option("--claimable-before <time>", "Only allow claiming before this Unix timestamp or ISO date/time")
|
|
1170
|
+
.action(withContext(async (context, options) => {
|
|
1171
|
+
const { createClaimableBalance } = await import("@stellar-agent/stellar");
|
|
1172
|
+
const source = await loadWallet(context.config, options.from);
|
|
1173
|
+
const result = await createClaimableBalance({
|
|
1174
|
+
source,
|
|
1175
|
+
claimant: options.to,
|
|
1176
|
+
claimants: options.claimant,
|
|
1177
|
+
amount: options.amount,
|
|
1178
|
+
asset: options.asset,
|
|
1179
|
+
...(options.claimableAfter === undefined ? {} : { claimableAfter: options.claimableAfter }),
|
|
1180
|
+
...(options.claimableBefore === undefined ? {} : { claimableBefore: options.claimableBefore }),
|
|
1181
|
+
profile: context.config.profiles.testnet
|
|
1182
|
+
});
|
|
1183
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
1184
|
+
event: "transaction_confirmed",
|
|
1185
|
+
status: "claimable_created",
|
|
1186
|
+
command: "claimable create",
|
|
1187
|
+
profile: "testnet",
|
|
1188
|
+
data: result
|
|
1189
|
+
});
|
|
1190
|
+
const receiptPath = await writeOperationReceipt(context, {
|
|
1191
|
+
command: "claimable create",
|
|
1192
|
+
operation: {
|
|
1193
|
+
type: "claimable.create",
|
|
1194
|
+
source: source.publicKey,
|
|
1195
|
+
claimant: result.claimant,
|
|
1196
|
+
claimants: result.claimants,
|
|
1197
|
+
asset: result.asset,
|
|
1198
|
+
amount: result.amount,
|
|
1199
|
+
predicate: result.predicate
|
|
1200
|
+
},
|
|
1201
|
+
transaction: result
|
|
1202
|
+
});
|
|
1203
|
+
return { ...result, receiptPath };
|
|
1204
|
+
}, "Claimable balance created."));
|
|
1205
|
+
claimable
|
|
1206
|
+
.command("list")
|
|
1207
|
+
.description("List claimable balances for an account or address.")
|
|
1208
|
+
.option("--account <name>", "Local wallet name")
|
|
1209
|
+
.option("--address <address>", "Raw claimant address")
|
|
1210
|
+
.action(withContext(async (context, options) => {
|
|
1211
|
+
const { listClaimableBalances } = await import("@stellar-agent/stellar");
|
|
1212
|
+
const address = options.address ?? (options.account ? (await loadWallet(context.config, options.account)).publicKey : undefined);
|
|
1213
|
+
if (!address) {
|
|
1214
|
+
throw new StellarAgentError({
|
|
1215
|
+
code: "INVALID_INPUT",
|
|
1216
|
+
message: "Provide --account or --address.",
|
|
1217
|
+
docs: "docs/claimable-balances.md"
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
return { address, claimableBalances: await listClaimableBalances(address, context.config.profiles.testnet) };
|
|
1221
|
+
}, "Claimable balances listed."));
|
|
1222
|
+
claimable
|
|
1223
|
+
.command("claim")
|
|
1224
|
+
.description("Claim a claimable balance by balance id.")
|
|
1225
|
+
.requiredOption("--balance-id <id>", "Claimable balance id")
|
|
1226
|
+
.option("--account <name>", "Local wallet name", "merchant")
|
|
1227
|
+
.action(withContext(async (context, options) => {
|
|
1228
|
+
const { claimClaimableBalance } = await import("@stellar-agent/stellar");
|
|
1229
|
+
const source = await loadWallet(context.config, options.account);
|
|
1230
|
+
const result = await claimClaimableBalance({
|
|
1231
|
+
source,
|
|
1232
|
+
balanceId: options.balanceId,
|
|
1233
|
+
profile: context.config.profiles.testnet
|
|
1234
|
+
});
|
|
1235
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
1236
|
+
event: "transaction_confirmed",
|
|
1237
|
+
status: "claimable_claimed",
|
|
1238
|
+
command: "claimable claim",
|
|
1239
|
+
profile: "testnet",
|
|
1240
|
+
data: result
|
|
1241
|
+
});
|
|
1242
|
+
const receiptPath = await writeOperationReceipt(context, {
|
|
1243
|
+
command: "claimable claim",
|
|
1244
|
+
operation: {
|
|
1245
|
+
type: "claimable.claim",
|
|
1246
|
+
account: options.account,
|
|
1247
|
+
source: source.publicKey,
|
|
1248
|
+
balanceId: options.balanceId
|
|
1249
|
+
},
|
|
1250
|
+
transaction: result
|
|
1251
|
+
});
|
|
1252
|
+
return { ...result, receiptPath };
|
|
1253
|
+
}, "Claimable balance claimed."));
|
|
1254
|
+
}
|
|
1255
|
+
function addContractCommands(program) {
|
|
1256
|
+
const contract = program.command("contract").description("Invoke Soroban smart contracts via Stellar CLI.");
|
|
1257
|
+
contract
|
|
1258
|
+
.command("doctor")
|
|
1259
|
+
.description("Check whether the Stellar CLI is available for contract execution.")
|
|
1260
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary", "stellar")
|
|
1261
|
+
.action(withContext(async (_context, options) => {
|
|
1262
|
+
const { checkStellarCli } = await import("@stellar-agent/stellar");
|
|
1263
|
+
return checkStellarCli(options.stellarBinary);
|
|
1264
|
+
}, "Stellar CLI readiness checked."));
|
|
1265
|
+
contract
|
|
1266
|
+
.command("invoke")
|
|
1267
|
+
.description("Invoke a deployed contract using the installed stellar CLI.")
|
|
1268
|
+
.requiredOption("--id <contractId>", "Contract id")
|
|
1269
|
+
.requiredOption("--source <source>", "Stellar CLI identity, local wallet name, raw public key, or Testnet secret key")
|
|
1270
|
+
.requiredOption("--fn <functionName>", "Contract function name")
|
|
1271
|
+
.option("--arg <key=value>", "Contract argument; repeat for multiple args", collectArg, [])
|
|
1272
|
+
.option("--network <network>", "Stellar CLI network name", "testnet")
|
|
1273
|
+
.option("--allow-real-funds", "Permit a guarded Mainnet contract operation")
|
|
1274
|
+
.option("--i-understand-real-funds", "Acknowledge this contract operation may use real funds")
|
|
1275
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary", "stellar")
|
|
1276
|
+
.option("--stellar-config-dir <path>", "Stellar CLI config directory")
|
|
1277
|
+
.option("--stellar-no-cache", "Pass --no-cache to Stellar CLI")
|
|
1278
|
+
.action(withContext(async (context, options) => {
|
|
1279
|
+
const { invokeContractWithStellarCli } = await import("@stellar-agent/stellar");
|
|
1280
|
+
const contractContext = resolveContractExecutionContext(context, options.network, {
|
|
1281
|
+
mutating: true,
|
|
1282
|
+
allowRealFunds: Boolean(options.allowRealFunds),
|
|
1283
|
+
acknowledgeRealFunds: Boolean(options.iUnderstandRealFunds)
|
|
1284
|
+
});
|
|
1285
|
+
const source = await resolveContractSource(context, options.source, { realFunds: contractContext.realFunds });
|
|
1286
|
+
const contractArgs = parseKeyValueArgs(options.arg);
|
|
1287
|
+
const result = await invokeContractWithStellarCli({
|
|
1288
|
+
contractId: options.id,
|
|
1289
|
+
source,
|
|
1290
|
+
functionName: options.fn,
|
|
1291
|
+
contractArgs,
|
|
1292
|
+
network: options.network,
|
|
1293
|
+
rpcUrl: contractContext.rpcUrl,
|
|
1294
|
+
networkPassphrase: contractContext.networkPassphrase,
|
|
1295
|
+
stellarBinary: options.stellarBinary,
|
|
1296
|
+
...(options.stellarConfigDir === undefined ? {} : { stellarConfigDir: options.stellarConfigDir }),
|
|
1297
|
+
...(options.stellarNoCache === undefined ? {} : { noCache: options.stellarNoCache })
|
|
1298
|
+
});
|
|
1299
|
+
return attachContractSubmissionReceipt(context, {
|
|
1300
|
+
command: "contract invoke",
|
|
1301
|
+
network: options.network,
|
|
1302
|
+
result,
|
|
1303
|
+
operation: {
|
|
1304
|
+
type: "contract.invoke",
|
|
1305
|
+
source: options.source,
|
|
1306
|
+
details: {
|
|
1307
|
+
contractId: options.id,
|
|
1308
|
+
functionName: options.fn,
|
|
1309
|
+
args: contractArgs
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
}, "Contract invocation complete."));
|
|
1314
|
+
contract
|
|
1315
|
+
.command("deploy")
|
|
1316
|
+
.description("Deploy a Wasm contract using the installed stellar CLI.")
|
|
1317
|
+
.requiredOption("--source <source>", "Stellar CLI identity, local wallet name, raw public key, or Testnet secret key")
|
|
1318
|
+
.option("--wasm <path>", "Wasm file path")
|
|
1319
|
+
.option("--wasm-hash <hash>", "Uploaded Wasm hash")
|
|
1320
|
+
.option("--alias <alias>", "Stellar CLI alias for deployed contract")
|
|
1321
|
+
.option("--arg <key=value>", "Constructor argument; repeat for multiple args", collectArg, [])
|
|
1322
|
+
.option("--network <network>", "Stellar CLI network name", "testnet")
|
|
1323
|
+
.option("--allow-real-funds", "Permit a guarded Mainnet contract operation")
|
|
1324
|
+
.option("--i-understand-real-funds", "Acknowledge this contract operation may use real funds")
|
|
1325
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary", "stellar")
|
|
1326
|
+
.option("--stellar-config-dir <path>", "Stellar CLI config directory")
|
|
1327
|
+
.option("--stellar-no-cache", "Pass --no-cache to Stellar CLI")
|
|
1328
|
+
.action(withContext(async (context, options) => {
|
|
1329
|
+
if (!options.wasm && !options.wasmHash) {
|
|
1330
|
+
throw new StellarAgentError({
|
|
1331
|
+
code: "INVALID_INPUT",
|
|
1332
|
+
message: "Provide --wasm or --wasm-hash for contract deploy.",
|
|
1333
|
+
docs: "docs/smart-contracts.md#deploy"
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
const { deployContractWithStellarCli } = await import("@stellar-agent/stellar");
|
|
1337
|
+
const contractContext = resolveContractExecutionContext(context, options.network, {
|
|
1338
|
+
mutating: true,
|
|
1339
|
+
allowRealFunds: Boolean(options.allowRealFunds),
|
|
1340
|
+
acknowledgeRealFunds: Boolean(options.iUnderstandRealFunds)
|
|
1341
|
+
});
|
|
1342
|
+
const source = await resolveContractSource(context, options.source, { realFunds: contractContext.realFunds });
|
|
1343
|
+
const constructorArgs = parseKeyValueArgs(options.arg);
|
|
1344
|
+
const result = await deployContractWithStellarCli({
|
|
1345
|
+
source,
|
|
1346
|
+
...(options.wasm === undefined ? {} : { wasm: options.wasm }),
|
|
1347
|
+
...(options.wasmHash === undefined ? {} : { wasmHash: options.wasmHash }),
|
|
1348
|
+
...(options.alias === undefined ? {} : { alias: options.alias }),
|
|
1349
|
+
constructorArgs,
|
|
1350
|
+
network: options.network,
|
|
1351
|
+
rpcUrl: contractContext.rpcUrl,
|
|
1352
|
+
networkPassphrase: contractContext.networkPassphrase,
|
|
1353
|
+
stellarBinary: options.stellarBinary,
|
|
1354
|
+
...(options.stellarConfigDir === undefined ? {} : { stellarConfigDir: options.stellarConfigDir }),
|
|
1355
|
+
...(options.stellarNoCache === undefined ? {} : { noCache: options.stellarNoCache })
|
|
1356
|
+
});
|
|
1357
|
+
return attachContractSubmissionReceipt(context, {
|
|
1358
|
+
command: "contract deploy",
|
|
1359
|
+
network: options.network,
|
|
1360
|
+
result,
|
|
1361
|
+
operation: {
|
|
1362
|
+
type: "contract.deploy",
|
|
1363
|
+
source: options.source,
|
|
1364
|
+
details: {
|
|
1365
|
+
contractId: result.stdout.trim() || undefined,
|
|
1366
|
+
wasm: options.wasm,
|
|
1367
|
+
wasmHash: options.wasmHash,
|
|
1368
|
+
alias: options.alias,
|
|
1369
|
+
constructorArgs
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
}, "Contract deployed."));
|
|
1374
|
+
contract
|
|
1375
|
+
.command("upload")
|
|
1376
|
+
.description("Upload contract Wasm bytecode using the installed stellar CLI.")
|
|
1377
|
+
.requiredOption("--source <source>", "Stellar CLI identity, local wallet name, raw public key, or Testnet secret key")
|
|
1378
|
+
.requiredOption("--wasm <path>", "Wasm file path")
|
|
1379
|
+
.option("--network <network>", "Stellar CLI network name", "testnet")
|
|
1380
|
+
.option("--allow-real-funds", "Permit a guarded Mainnet contract operation")
|
|
1381
|
+
.option("--i-understand-real-funds", "Acknowledge this contract operation may use real funds")
|
|
1382
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary", "stellar")
|
|
1383
|
+
.option("--stellar-config-dir <path>", "Stellar CLI config directory")
|
|
1384
|
+
.option("--stellar-no-cache", "Pass --no-cache to Stellar CLI")
|
|
1385
|
+
.action(withContext(async (context, options) => {
|
|
1386
|
+
const { uploadContractWasmWithStellarCli } = await import("@stellar-agent/stellar");
|
|
1387
|
+
const contractContext = resolveContractExecutionContext(context, options.network, {
|
|
1388
|
+
mutating: true,
|
|
1389
|
+
allowRealFunds: Boolean(options.allowRealFunds),
|
|
1390
|
+
acknowledgeRealFunds: Boolean(options.iUnderstandRealFunds)
|
|
1391
|
+
});
|
|
1392
|
+
const source = await resolveContractSource(context, options.source, { realFunds: contractContext.realFunds });
|
|
1393
|
+
const result = await uploadContractWasmWithStellarCli({
|
|
1394
|
+
source,
|
|
1395
|
+
wasm: options.wasm,
|
|
1396
|
+
network: options.network,
|
|
1397
|
+
rpcUrl: contractContext.rpcUrl,
|
|
1398
|
+
networkPassphrase: contractContext.networkPassphrase,
|
|
1399
|
+
stellarBinary: options.stellarBinary,
|
|
1400
|
+
...(options.stellarConfigDir === undefined ? {} : { stellarConfigDir: options.stellarConfigDir }),
|
|
1401
|
+
...(options.stellarNoCache === undefined ? {} : { noCache: options.stellarNoCache })
|
|
1402
|
+
});
|
|
1403
|
+
return attachContractSubmissionReceipt(context, {
|
|
1404
|
+
command: "contract upload",
|
|
1405
|
+
network: options.network,
|
|
1406
|
+
result,
|
|
1407
|
+
operation: {
|
|
1408
|
+
type: "contract.upload",
|
|
1409
|
+
source: options.source,
|
|
1410
|
+
details: {
|
|
1411
|
+
wasm: options.wasm,
|
|
1412
|
+
wasmHash: result.stdout.trim() || undefined
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
}, "Contract Wasm uploaded."));
|
|
1417
|
+
contract
|
|
1418
|
+
.command("asset-deploy")
|
|
1419
|
+
.description("Deploy a Stellar Asset Contract using the installed stellar CLI.")
|
|
1420
|
+
.requiredOption("--source <source>", "Stellar CLI identity, local wallet name, raw public key, or Testnet secret key")
|
|
1421
|
+
.requiredOption("--asset <asset>", "Asset as native or CODE:G... issuer")
|
|
1422
|
+
.option("--alias <alias>", "Stellar CLI alias for deployed asset contract")
|
|
1423
|
+
.option("--network <network>", "Stellar CLI network name", "testnet")
|
|
1424
|
+
.option("--allow-real-funds", "Permit a guarded Mainnet contract operation")
|
|
1425
|
+
.option("--i-understand-real-funds", "Acknowledge this contract operation may use real funds")
|
|
1426
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary", "stellar")
|
|
1427
|
+
.option("--stellar-config-dir <path>", "Stellar CLI config directory")
|
|
1428
|
+
.option("--stellar-no-cache", "Pass --no-cache to Stellar CLI")
|
|
1429
|
+
.action(withContext(async (context, options) => {
|
|
1430
|
+
const { deployAssetContractWithStellarCli } = await import("@stellar-agent/stellar");
|
|
1431
|
+
const contractContext = resolveContractExecutionContext(context, options.network, {
|
|
1432
|
+
mutating: true,
|
|
1433
|
+
allowRealFunds: Boolean(options.allowRealFunds),
|
|
1434
|
+
acknowledgeRealFunds: Boolean(options.iUnderstandRealFunds)
|
|
1435
|
+
});
|
|
1436
|
+
const source = await resolveContractSource(context, options.source, { realFunds: contractContext.realFunds });
|
|
1437
|
+
const result = await deployAssetContractWithStellarCli({
|
|
1438
|
+
source,
|
|
1439
|
+
asset: options.asset,
|
|
1440
|
+
...(options.alias === undefined ? {} : { alias: options.alias }),
|
|
1441
|
+
network: options.network,
|
|
1442
|
+
rpcUrl: contractContext.rpcUrl,
|
|
1443
|
+
networkPassphrase: contractContext.networkPassphrase,
|
|
1444
|
+
stellarBinary: options.stellarBinary,
|
|
1445
|
+
...(options.stellarConfigDir === undefined ? {} : { stellarConfigDir: options.stellarConfigDir }),
|
|
1446
|
+
...(options.stellarNoCache === undefined ? {} : { noCache: options.stellarNoCache })
|
|
1447
|
+
});
|
|
1448
|
+
return attachContractSubmissionReceipt(context, {
|
|
1449
|
+
command: "contract asset-deploy",
|
|
1450
|
+
network: options.network,
|
|
1451
|
+
result,
|
|
1452
|
+
operation: {
|
|
1453
|
+
type: "contract.asset-deploy",
|
|
1454
|
+
source: options.source,
|
|
1455
|
+
asset: options.asset,
|
|
1456
|
+
details: {
|
|
1457
|
+
contractId: result.stdout.trim() || undefined,
|
|
1458
|
+
alias: options.alias
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
}, "Asset contract deployed."));
|
|
1463
|
+
contract
|
|
1464
|
+
.command("asset-id")
|
|
1465
|
+
.description("Calculate the Stellar Asset Contract id for an asset.")
|
|
1466
|
+
.requiredOption("--asset <asset>", "Asset as native or CODE:G... issuer")
|
|
1467
|
+
.option("--network <network>", "Stellar CLI network name", "testnet")
|
|
1468
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary", "stellar")
|
|
1469
|
+
.option("--stellar-config-dir <path>", "Stellar CLI config directory")
|
|
1470
|
+
.option("--stellar-no-cache", "Pass --no-cache to Stellar CLI")
|
|
1471
|
+
.action(withContext(async (context, options) => {
|
|
1472
|
+
const { assetContractIdWithStellarCli } = await import("@stellar-agent/stellar");
|
|
1473
|
+
const contractContext = resolveContractExecutionContext(context, options.network, { mutating: false });
|
|
1474
|
+
return assetContractIdWithStellarCli({
|
|
1475
|
+
asset: options.asset,
|
|
1476
|
+
network: options.network,
|
|
1477
|
+
rpcUrl: contractContext.rpcUrl,
|
|
1478
|
+
networkPassphrase: contractContext.networkPassphrase,
|
|
1479
|
+
stellarBinary: options.stellarBinary,
|
|
1480
|
+
...(options.stellarConfigDir === undefined ? {} : { stellarConfigDir: options.stellarConfigDir }),
|
|
1481
|
+
...(options.stellarNoCache === undefined ? {} : { noCache: options.stellarNoCache })
|
|
1482
|
+
});
|
|
1483
|
+
}, "Asset contract id calculated."));
|
|
1484
|
+
contract
|
|
1485
|
+
.command("info")
|
|
1486
|
+
.description("Read contract info using the installed stellar CLI.")
|
|
1487
|
+
.option("--kind <kind>", "Info kind: interface, meta, env-meta, build, hash", "interface")
|
|
1488
|
+
.option("--id <contractId>", "Contract id")
|
|
1489
|
+
.option("--wasm <path>", "Wasm file path")
|
|
1490
|
+
.option("--wasm-hash <hash>", "Uploaded Wasm hash")
|
|
1491
|
+
.option("--network <network>", "Stellar CLI network name", "testnet")
|
|
1492
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary", "stellar")
|
|
1493
|
+
.option("--stellar-config-dir <path>", "Stellar CLI config directory")
|
|
1494
|
+
.option("--stellar-no-cache", "Pass --no-cache to Stellar CLI")
|
|
1495
|
+
.action(withContext(async (context, options) => {
|
|
1496
|
+
if (!["interface", "meta", "env-meta", "build", "hash"].includes(options.kind)) {
|
|
1497
|
+
throw new StellarAgentError({
|
|
1498
|
+
code: "INVALID_INPUT",
|
|
1499
|
+
message: "Info kind must be one of: interface, meta, env-meta, build, hash.",
|
|
1500
|
+
docs: "docs/smart-contracts.md#contract-info"
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
if (!options.id && !options.wasm && !options.wasmHash) {
|
|
1504
|
+
throw new StellarAgentError({
|
|
1505
|
+
code: "INVALID_INPUT",
|
|
1506
|
+
message: "Provide --id, --wasm, or --wasm-hash for contract info.",
|
|
1507
|
+
docs: "docs/smart-contracts.md#contract-info"
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
const { contractInfoWithStellarCli } = await import("@stellar-agent/stellar");
|
|
1511
|
+
const contractContext = resolveContractExecutionContext(context, options.network, { mutating: false });
|
|
1512
|
+
return contractInfoWithStellarCli({
|
|
1513
|
+
kind: options.kind,
|
|
1514
|
+
...(options.id === undefined ? {} : { contractId: options.id }),
|
|
1515
|
+
...(options.wasm === undefined ? {} : { wasm: options.wasm }),
|
|
1516
|
+
...(options.wasmHash === undefined ? {} : { wasmHash: options.wasmHash }),
|
|
1517
|
+
network: options.network,
|
|
1518
|
+
rpcUrl: contractContext.rpcUrl,
|
|
1519
|
+
networkPassphrase: contractContext.networkPassphrase,
|
|
1520
|
+
stellarBinary: options.stellarBinary,
|
|
1521
|
+
...(options.stellarConfigDir === undefined ? {} : { stellarConfigDir: options.stellarConfigDir }),
|
|
1522
|
+
...(options.stellarNoCache === undefined ? {} : { noCache: options.stellarNoCache })
|
|
1523
|
+
});
|
|
1524
|
+
}, "Contract info loaded."));
|
|
1525
|
+
contract
|
|
1526
|
+
.command("read")
|
|
1527
|
+
.description("Read contract instance, storage, or Wasm ledger data using the installed stellar CLI.")
|
|
1528
|
+
.option("--id <contractId>", "Contract id")
|
|
1529
|
+
.option("--key <key>", "Storage key")
|
|
1530
|
+
.option("--key-xdr <xdr>", "Storage key XDR")
|
|
1531
|
+
.option("--wasm <path>", "Wasm file path")
|
|
1532
|
+
.option("--wasm-hash <hash>", "Wasm hash")
|
|
1533
|
+
.option("--durability <durability>", "persistent or temporary", "persistent")
|
|
1534
|
+
.option("--output <output>", "Output type: string, json, or xdr", "string")
|
|
1535
|
+
.option("--network <network>", "Stellar CLI network name", "testnet")
|
|
1536
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary", "stellar")
|
|
1537
|
+
.option("--stellar-config-dir <path>", "Stellar CLI config directory")
|
|
1538
|
+
.option("--stellar-no-cache", "Pass --no-cache to Stellar CLI")
|
|
1539
|
+
.action(withContext(async (context, options) => {
|
|
1540
|
+
validateDurability(options.durability);
|
|
1541
|
+
validateContractReadOutput(options.output);
|
|
1542
|
+
const { readContractWithStellarCli } = await import("@stellar-agent/stellar");
|
|
1543
|
+
const contractContext = resolveContractExecutionContext(context, options.network, { mutating: false });
|
|
1544
|
+
return readContractWithStellarCli({
|
|
1545
|
+
...(options.id === undefined ? {} : { contractId: options.id }),
|
|
1546
|
+
...(options.key === undefined ? {} : { key: options.key }),
|
|
1547
|
+
...(options.keyXdr === undefined ? {} : { keyXdr: options.keyXdr }),
|
|
1548
|
+
...(options.wasm === undefined ? {} : { wasm: options.wasm }),
|
|
1549
|
+
...(options.wasmHash === undefined ? {} : { wasmHash: options.wasmHash }),
|
|
1550
|
+
durability: options.durability,
|
|
1551
|
+
output: options.output,
|
|
1552
|
+
network: options.network,
|
|
1553
|
+
rpcUrl: contractContext.rpcUrl,
|
|
1554
|
+
networkPassphrase: contractContext.networkPassphrase,
|
|
1555
|
+
stellarBinary: options.stellarBinary,
|
|
1556
|
+
...(options.stellarConfigDir === undefined ? {} : { stellarConfigDir: options.stellarConfigDir }),
|
|
1557
|
+
...(options.stellarNoCache === undefined ? {} : { noCache: options.stellarNoCache })
|
|
1558
|
+
});
|
|
1559
|
+
}, "Contract data read."));
|
|
1560
|
+
contract
|
|
1561
|
+
.command("fetch")
|
|
1562
|
+
.description("Fetch contract Wasm bytecode using the installed stellar CLI.")
|
|
1563
|
+
.option("--id <contractId>", "Contract id")
|
|
1564
|
+
.option("--wasm-hash <hash>", "Wasm hash")
|
|
1565
|
+
.option("--out-file <path>", "Output file path; stdout is used when omitted")
|
|
1566
|
+
.option("--network <network>", "Stellar CLI network name", "testnet")
|
|
1567
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary", "stellar")
|
|
1568
|
+
.option("--stellar-config-dir <path>", "Stellar CLI config directory")
|
|
1569
|
+
.option("--stellar-no-cache", "Pass --no-cache to Stellar CLI")
|
|
1570
|
+
.action(withContext(async (context, options) => {
|
|
1571
|
+
if (Boolean(options.id) === Boolean(options.wasmHash)) {
|
|
1572
|
+
throw new StellarAgentError({
|
|
1573
|
+
code: "INVALID_INPUT",
|
|
1574
|
+
message: "Provide exactly one of --id or --wasm-hash for contract fetch.",
|
|
1575
|
+
docs: "docs/smart-contracts.md#fetch"
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
const { fetchContractWasmWithStellarCli } = await import("@stellar-agent/stellar");
|
|
1579
|
+
const contractContext = resolveContractExecutionContext(context, options.network, { mutating: false });
|
|
1580
|
+
return fetchContractWasmWithStellarCli({
|
|
1581
|
+
...(options.id === undefined ? {} : { contractId: options.id }),
|
|
1582
|
+
...(options.wasmHash === undefined ? {} : { wasmHash: options.wasmHash }),
|
|
1583
|
+
...(options.outFile === undefined ? {} : { outFile: options.outFile }),
|
|
1584
|
+
network: options.network,
|
|
1585
|
+
rpcUrl: contractContext.rpcUrl,
|
|
1586
|
+
networkPassphrase: contractContext.networkPassphrase,
|
|
1587
|
+
stellarBinary: options.stellarBinary,
|
|
1588
|
+
...(options.stellarConfigDir === undefined ? {} : { stellarConfigDir: options.stellarConfigDir }),
|
|
1589
|
+
...(options.stellarNoCache === undefined ? {} : { noCache: options.stellarNoCache })
|
|
1590
|
+
});
|
|
1591
|
+
}, "Contract Wasm fetched."));
|
|
1592
|
+
contract
|
|
1593
|
+
.command("extend")
|
|
1594
|
+
.description("Extend contract instance, storage, or Wasm TTL using the installed stellar CLI.")
|
|
1595
|
+
.requiredOption("--source <source>", "Stellar CLI identity, local wallet name, raw public key, or Testnet secret key")
|
|
1596
|
+
.requiredOption("--ledgers-to-extend <n>", "Number of ledgers to extend", parseLedgersToExtendOption)
|
|
1597
|
+
.option("--id <contractId>", "Contract id")
|
|
1598
|
+
.option("--key <key>", "Storage key")
|
|
1599
|
+
.option("--key-xdr <xdr>", "Storage key XDR")
|
|
1600
|
+
.option("--wasm <path>", "Wasm file path")
|
|
1601
|
+
.option("--wasm-hash <hash>", "Wasm hash")
|
|
1602
|
+
.option("--durability <durability>", "persistent or temporary", "persistent")
|
|
1603
|
+
.option("--ttl-ledger-only", "Only print the new TTL ledger")
|
|
1604
|
+
.option("--network <network>", "Stellar CLI network name", "testnet")
|
|
1605
|
+
.option("--allow-real-funds", "Permit a guarded Mainnet contract operation")
|
|
1606
|
+
.option("--i-understand-real-funds", "Acknowledge this contract operation may use real funds")
|
|
1607
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary", "stellar")
|
|
1608
|
+
.option("--stellar-config-dir <path>", "Stellar CLI config directory")
|
|
1609
|
+
.option("--stellar-no-cache", "Pass --no-cache to Stellar CLI")
|
|
1610
|
+
.action(withContext(async (context, options) => {
|
|
1611
|
+
validateDurability(options.durability);
|
|
1612
|
+
const { extendContractWithStellarCli } = await import("@stellar-agent/stellar");
|
|
1613
|
+
const contractContext = resolveContractExecutionContext(context, options.network, {
|
|
1614
|
+
mutating: true,
|
|
1615
|
+
allowRealFunds: Boolean(options.allowRealFunds),
|
|
1616
|
+
acknowledgeRealFunds: Boolean(options.iUnderstandRealFunds)
|
|
1617
|
+
});
|
|
1618
|
+
const source = await resolveContractSource(context, options.source, { realFunds: contractContext.realFunds });
|
|
1619
|
+
const result = await extendContractWithStellarCli({
|
|
1620
|
+
source,
|
|
1621
|
+
ledgersToExtend: options.ledgersToExtend,
|
|
1622
|
+
...(options.id === undefined ? {} : { contractId: options.id }),
|
|
1623
|
+
...(options.key === undefined ? {} : { key: options.key }),
|
|
1624
|
+
...(options.keyXdr === undefined ? {} : { keyXdr: options.keyXdr }),
|
|
1625
|
+
...(options.wasm === undefined ? {} : { wasm: options.wasm }),
|
|
1626
|
+
...(options.wasmHash === undefined ? {} : { wasmHash: options.wasmHash }),
|
|
1627
|
+
durability: options.durability,
|
|
1628
|
+
...(options.ttlLedgerOnly === undefined ? {} : { ttlLedgerOnly: options.ttlLedgerOnly }),
|
|
1629
|
+
network: options.network,
|
|
1630
|
+
rpcUrl: contractContext.rpcUrl,
|
|
1631
|
+
networkPassphrase: contractContext.networkPassphrase,
|
|
1632
|
+
stellarBinary: options.stellarBinary,
|
|
1633
|
+
...(options.stellarConfigDir === undefined ? {} : { stellarConfigDir: options.stellarConfigDir }),
|
|
1634
|
+
...(options.stellarNoCache === undefined ? {} : { noCache: options.stellarNoCache })
|
|
1635
|
+
});
|
|
1636
|
+
return attachContractSubmissionReceipt(context, {
|
|
1637
|
+
command: "contract extend",
|
|
1638
|
+
network: options.network,
|
|
1639
|
+
result,
|
|
1640
|
+
operation: {
|
|
1641
|
+
type: "contract.extend",
|
|
1642
|
+
source: options.source,
|
|
1643
|
+
details: {
|
|
1644
|
+
contractId: options.id,
|
|
1645
|
+
key: options.key,
|
|
1646
|
+
keyXdr: options.keyXdr,
|
|
1647
|
+
wasm: options.wasm,
|
|
1648
|
+
wasmHash: options.wasmHash,
|
|
1649
|
+
durability: options.durability,
|
|
1650
|
+
ledgersToExtend: options.ledgersToExtend,
|
|
1651
|
+
ttlLedgerOnly: Boolean(options.ttlLedgerOnly)
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
});
|
|
1655
|
+
}, "Contract TTL extended."));
|
|
1656
|
+
contract
|
|
1657
|
+
.command("restore")
|
|
1658
|
+
.description("Restore archived contract instance, storage, or Wasm using the installed stellar CLI.")
|
|
1659
|
+
.requiredOption("--source <source>", "Stellar CLI identity, local wallet name, raw public key, or Testnet secret key")
|
|
1660
|
+
.option("--id <contractId>", "Contract id")
|
|
1661
|
+
.option("--key <key>", "Storage key")
|
|
1662
|
+
.option("--key-xdr <xdr>", "Storage key XDR")
|
|
1663
|
+
.option("--wasm <path>", "Wasm file path")
|
|
1664
|
+
.option("--wasm-hash <hash>", "Wasm hash")
|
|
1665
|
+
.option("--durability <durability>", "persistent or temporary", "persistent")
|
|
1666
|
+
.option("--network <network>", "Stellar CLI network name", "testnet")
|
|
1667
|
+
.option("--allow-real-funds", "Permit a guarded Mainnet contract operation")
|
|
1668
|
+
.option("--i-understand-real-funds", "Acknowledge this contract operation may use real funds")
|
|
1669
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary", "stellar")
|
|
1670
|
+
.option("--stellar-config-dir <path>", "Stellar CLI config directory")
|
|
1671
|
+
.option("--stellar-no-cache", "Pass --no-cache to Stellar CLI")
|
|
1672
|
+
.action(withContext(async (context, options) => {
|
|
1673
|
+
validateDurability(options.durability);
|
|
1674
|
+
const { restoreContractWithStellarCli } = await import("@stellar-agent/stellar");
|
|
1675
|
+
const contractContext = resolveContractExecutionContext(context, options.network, {
|
|
1676
|
+
mutating: true,
|
|
1677
|
+
allowRealFunds: Boolean(options.allowRealFunds),
|
|
1678
|
+
acknowledgeRealFunds: Boolean(options.iUnderstandRealFunds)
|
|
1679
|
+
});
|
|
1680
|
+
const source = await resolveContractSource(context, options.source, { realFunds: contractContext.realFunds });
|
|
1681
|
+
const result = await restoreContractWithStellarCli({
|
|
1682
|
+
source,
|
|
1683
|
+
...(options.id === undefined ? {} : { contractId: options.id }),
|
|
1684
|
+
...(options.key === undefined ? {} : { key: options.key }),
|
|
1685
|
+
...(options.keyXdr === undefined ? {} : { keyXdr: options.keyXdr }),
|
|
1686
|
+
...(options.wasm === undefined ? {} : { wasm: options.wasm }),
|
|
1687
|
+
...(options.wasmHash === undefined ? {} : { wasmHash: options.wasmHash }),
|
|
1688
|
+
durability: options.durability,
|
|
1689
|
+
network: options.network,
|
|
1690
|
+
rpcUrl: contractContext.rpcUrl,
|
|
1691
|
+
networkPassphrase: contractContext.networkPassphrase,
|
|
1692
|
+
stellarBinary: options.stellarBinary,
|
|
1693
|
+
...(options.stellarConfigDir === undefined ? {} : { stellarConfigDir: options.stellarConfigDir }),
|
|
1694
|
+
...(options.stellarNoCache === undefined ? {} : { noCache: options.stellarNoCache })
|
|
1695
|
+
});
|
|
1696
|
+
return attachContractSubmissionReceipt(context, {
|
|
1697
|
+
command: "contract restore",
|
|
1698
|
+
network: options.network,
|
|
1699
|
+
result,
|
|
1700
|
+
operation: {
|
|
1701
|
+
type: "contract.restore",
|
|
1702
|
+
source: options.source,
|
|
1703
|
+
details: {
|
|
1704
|
+
contractId: options.id,
|
|
1705
|
+
key: options.key,
|
|
1706
|
+
keyXdr: options.keyXdr,
|
|
1707
|
+
wasm: options.wasm,
|
|
1708
|
+
wasmHash: options.wasmHash,
|
|
1709
|
+
durability: options.durability
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
});
|
|
1713
|
+
}, "Contract restored."));
|
|
1714
|
+
}
|
|
1715
|
+
function addMarketCommands(program) {
|
|
1716
|
+
const market = program.command("market").description("Inspect markets, liquidity pools, and market-aware alerts.");
|
|
1717
|
+
const pools = market.command("pools").description("Inspect built-in Stellar AMM liquidity pools.");
|
|
1718
|
+
pools
|
|
1719
|
+
.command("list")
|
|
1720
|
+
.description("List core Stellar liquidity pools, optionally filtered by exact reserve assets or participant account.")
|
|
1721
|
+
.option("--asset-a <asset>", "First reserve asset, such as XLM or USD:G...")
|
|
1722
|
+
.option("--asset-b <asset>", "Second reserve asset, such as XLM or USD:G...")
|
|
1723
|
+
.option("--account <account>", "Local wallet name or public key participating in pools")
|
|
1724
|
+
.option("--network <name>", "Network profile to inspect: testnet or mainnet")
|
|
1725
|
+
.option("--limit <n>", "Number of records", parseIntegerOption, 10)
|
|
1726
|
+
.action(withContext(async (context, options) => {
|
|
1727
|
+
if (Boolean(options.assetA) !== Boolean(options.assetB)) {
|
|
1728
|
+
throw new StellarAgentError({
|
|
1729
|
+
code: "INVALID_INPUT",
|
|
1730
|
+
message: "Pool asset filtering requires both --asset-a and --asset-b.",
|
|
1731
|
+
docs: "docs/market-liquidity.md#core-pool-inspection"
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
const { listLiquidityPools } = await import("@stellar-agent/stellar");
|
|
1735
|
+
const profile = resolveMarketProfile(context, options.network);
|
|
1736
|
+
return listLiquidityPools({
|
|
1737
|
+
profile,
|
|
1738
|
+
...(options.assetA === undefined ? {} : { assetA: options.assetA }),
|
|
1739
|
+
...(options.assetB === undefined ? {} : { assetB: options.assetB }),
|
|
1740
|
+
...(options.account === undefined ? {} : { account: await resolvePublicAccount(context, options.account) }),
|
|
1741
|
+
limit: options.limit
|
|
1742
|
+
});
|
|
1743
|
+
}, "Liquidity pools listed."));
|
|
1744
|
+
const pool = market.command("pool").description("Inspect one core Stellar AMM liquidity pool.");
|
|
1745
|
+
pool
|
|
1746
|
+
.command("inspect")
|
|
1747
|
+
.description("Load reserves, shares, fee, and trustline count for a core liquidity pool.")
|
|
1748
|
+
.requiredOption("--pool <poolId>", "Liquidity pool id")
|
|
1749
|
+
.option("--network <name>", "Network profile to inspect: testnet or mainnet")
|
|
1750
|
+
.action(withContext(async (context, options) => {
|
|
1751
|
+
const { inspectLiquidityPool } = await import("@stellar-agent/stellar");
|
|
1752
|
+
return inspectLiquidityPool({ poolId: options.pool, profile: resolveMarketProfile(context, options.network) });
|
|
1753
|
+
}, "Liquidity pool inspected."));
|
|
1754
|
+
pool
|
|
1755
|
+
.command("trades")
|
|
1756
|
+
.description("Fetch recent Horizon trades for a core liquidity pool.")
|
|
1757
|
+
.requiredOption("--pool <poolId>", "Liquidity pool id")
|
|
1758
|
+
.option("--network <name>", "Network profile to inspect: testnet or mainnet")
|
|
1759
|
+
.option("--limit <n>", "Number of records", parseIntegerOption, 10)
|
|
1760
|
+
.action(withContext(async (context, options) => {
|
|
1761
|
+
const { liquidityPoolTrades } = await import("@stellar-agent/stellar");
|
|
1762
|
+
return liquidityPoolTrades({ poolId: options.pool, profile: resolveMarketProfile(context, options.network), limit: options.limit });
|
|
1763
|
+
}, "Liquidity pool trades loaded."));
|
|
1764
|
+
pool
|
|
1765
|
+
.command("position")
|
|
1766
|
+
.description("Inspect pool-share positions for a local wallet or public key.")
|
|
1767
|
+
.option("--account <account>", "Local wallet name or public key", "agent")
|
|
1768
|
+
.option("--pool <poolId>", "Limit to one liquidity pool id")
|
|
1769
|
+
.option("--network <name>", "Network profile to inspect: testnet or mainnet")
|
|
1770
|
+
.action(withContext(async (context, options) => {
|
|
1771
|
+
const { inspectLiquidityPoolPosition } = await import("@stellar-agent/stellar");
|
|
1772
|
+
return inspectLiquidityPoolPosition({
|
|
1773
|
+
account: await resolvePublicAccount(context, options.account),
|
|
1774
|
+
...(options.pool === undefined ? {} : { poolId: options.pool }),
|
|
1775
|
+
profile: resolveMarketProfile(context, options.network)
|
|
1776
|
+
});
|
|
1777
|
+
}, "Liquidity pool position inspected."));
|
|
1778
|
+
const lp = market.command("lp").description("Preflight and submit guarded core Stellar liquidity pool actions.");
|
|
1779
|
+
lp
|
|
1780
|
+
.command("preflight")
|
|
1781
|
+
.description("Preflight a core liquidity pool deposit or withdrawal with policy context.")
|
|
1782
|
+
.requiredOption("--pool <poolId>", "Liquidity pool id")
|
|
1783
|
+
.option("--account <account>", "Local wallet name or public key", "agent")
|
|
1784
|
+
.option("--action <action>", "deposit or withdraw", "deposit")
|
|
1785
|
+
.option("--max-a <amount>", "Deposit max amount for reserve A")
|
|
1786
|
+
.option("--max-b <amount>", "Deposit max amount for reserve B")
|
|
1787
|
+
.option("--min-price <price>", "Deposit minimum reserve B per reserve A price")
|
|
1788
|
+
.option("--max-price <price>", "Deposit maximum reserve B per reserve A price")
|
|
1789
|
+
.option("--shares <amount>", "Pool shares to withdraw")
|
|
1790
|
+
.option("--min-a <amount>", "Withdrawal minimum reserve A amount", "0")
|
|
1791
|
+
.option("--min-b <amount>", "Withdrawal minimum reserve B amount", "0")
|
|
1792
|
+
.option("--network <name>", "Network profile to inspect: testnet or mainnet")
|
|
1793
|
+
.action(withContext(async (context, options) => {
|
|
1794
|
+
const profile = resolveMarketProfile(context, options.network);
|
|
1795
|
+
const preflight = await runLiquidityPreflight(context, profile, options);
|
|
1796
|
+
const policy = await loadPolicy(context);
|
|
1797
|
+
const policyDecision = evaluateMarketLiquidityRequest(policy, liquidityPolicyRequest(profile, preflight));
|
|
1798
|
+
return { policyDecision, preflight };
|
|
1799
|
+
}, "Liquidity pool preflight complete."));
|
|
1800
|
+
lp
|
|
1801
|
+
.command("deposit")
|
|
1802
|
+
.description("Submit a guarded Testnet core liquidity pool deposit.")
|
|
1803
|
+
.requiredOption("--pool <poolId>", "Liquidity pool id")
|
|
1804
|
+
.requiredOption("--max-a <amount>", "Deposit max amount for reserve A")
|
|
1805
|
+
.requiredOption("--max-b <amount>", "Deposit max amount for reserve B")
|
|
1806
|
+
.requiredOption("--min-price <price>", "Deposit minimum reserve B per reserve A price")
|
|
1807
|
+
.requiredOption("--max-price <price>", "Deposit maximum reserve B per reserve A price")
|
|
1808
|
+
.option("--source <account>", "Local Testnet wallet name", "agent")
|
|
1809
|
+
.option("--fee-strategy <strategy>", "Fee strategy: base, low, medium, high, p95", parseFeeStrategy, "medium")
|
|
1810
|
+
.action(withContext(async (context, options) => {
|
|
1811
|
+
return runLiquiditySubmitCommand(context, { command: "market lp deposit", action: "deposit", ...options });
|
|
1812
|
+
}, "Liquidity pool deposit submitted."));
|
|
1813
|
+
lp
|
|
1814
|
+
.command("withdraw")
|
|
1815
|
+
.description("Submit a guarded Testnet core liquidity pool withdrawal.")
|
|
1816
|
+
.requiredOption("--pool <poolId>", "Liquidity pool id")
|
|
1817
|
+
.requiredOption("--shares <amount>", "Pool shares to withdraw")
|
|
1818
|
+
.option("--min-a <amount>", "Withdrawal minimum reserve A amount", "0")
|
|
1819
|
+
.option("--min-b <amount>", "Withdrawal minimum reserve B amount", "0")
|
|
1820
|
+
.option("--source <account>", "Local Testnet wallet name", "agent")
|
|
1821
|
+
.option("--fee-strategy <strategy>", "Fee strategy: base, low, medium, high, p95", parseFeeStrategy, "medium")
|
|
1822
|
+
.action(withContext(async (context, options) => {
|
|
1823
|
+
return runLiquiditySubmitCommand(context, { command: "market lp withdraw", action: "withdraw", ...options });
|
|
1824
|
+
}, "Liquidity pool withdrawal submitted."));
|
|
1825
|
+
const trustline = lp.command("trustline").description("Manage core liquidity pool share trustlines.");
|
|
1826
|
+
trustline
|
|
1827
|
+
.command("add")
|
|
1828
|
+
.description("Create a Testnet trustline for a liquidity pool share asset.")
|
|
1829
|
+
.option("--pool <poolId>", "Existing liquidity pool id")
|
|
1830
|
+
.option("--asset-a <asset>", "First reserve asset when deriving a pool id")
|
|
1831
|
+
.option("--asset-b <asset>", "Second reserve asset when deriving a pool id")
|
|
1832
|
+
.option("--account <account>", "Local Testnet wallet name", "agent")
|
|
1833
|
+
.option("--limit <amount>", "Pool share trustline limit")
|
|
1834
|
+
.action(withContext(async (context, options) => {
|
|
1835
|
+
const profile = resolveNetworkProfile(context.profileName, context.config.profiles);
|
|
1836
|
+
if (profile.realFunds) {
|
|
1837
|
+
throw new StellarAgentError({
|
|
1838
|
+
code: "MAINNET_NOT_ENABLED",
|
|
1839
|
+
message: "Mainnet liquidity pool trustline creation requires an external signer.",
|
|
1840
|
+
docs: "docs/mainnet-safety.md#mainnet-liquidity"
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
const wallet = await loadWallet(context.config, options.account);
|
|
1844
|
+
const { changeLiquidityPoolTrustline } = await import("@stellar-agent/stellar");
|
|
1845
|
+
const transaction = await changeLiquidityPoolTrustline({
|
|
1846
|
+
source: wallet,
|
|
1847
|
+
...(options.pool === undefined ? {} : { poolId: options.pool }),
|
|
1848
|
+
...(options.assetA === undefined ? {} : { assetA: options.assetA }),
|
|
1849
|
+
...(options.assetB === undefined ? {} : { assetB: options.assetB }),
|
|
1850
|
+
...(options.limit === undefined ? {} : { limit: options.limit }),
|
|
1851
|
+
profile
|
|
1852
|
+
});
|
|
1853
|
+
const receiptPath = await writeOperationReceipt(context, {
|
|
1854
|
+
command: "market lp trustline add",
|
|
1855
|
+
operation: {
|
|
1856
|
+
type: "market.lp.trustline.add",
|
|
1857
|
+
account: wallet.publicKey,
|
|
1858
|
+
details: { liquidityPool: { poolId: transaction.poolId, limit: transaction.limit } }
|
|
1859
|
+
},
|
|
1860
|
+
transaction
|
|
1861
|
+
});
|
|
1862
|
+
return { status: "trustline_added", poolId: transaction.poolId, transaction, receiptPath };
|
|
1863
|
+
}, "Liquidity pool trustline added."));
|
|
1864
|
+
const listen = market.command("listen").description("Evaluate one or more market alert checks and return JSON events.");
|
|
1865
|
+
listen
|
|
1866
|
+
.command("price")
|
|
1867
|
+
.description("Alert when a core pool reserve price crosses a threshold.")
|
|
1868
|
+
.requiredOption("--pool <poolId>", "Liquidity pool id")
|
|
1869
|
+
.option("--above <price>", "Trigger when reserve B per reserve A is above this price")
|
|
1870
|
+
.option("--below <price>", "Trigger when reserve B per reserve A is below this price")
|
|
1871
|
+
.option("--network <name>", "Network profile to inspect: testnet or mainnet")
|
|
1872
|
+
.option("--polls <n>", "Number of checks", parsePositiveIntegerOption, 1)
|
|
1873
|
+
.option("--interval-ms <n>", "Delay between checks", parsePositiveIntegerOption, 1000)
|
|
1874
|
+
.action(withContext(async (context, options) => {
|
|
1875
|
+
return listenForPoolPrice(context, options);
|
|
1876
|
+
}, "Market price listener evaluated."));
|
|
1877
|
+
listen
|
|
1878
|
+
.command("position")
|
|
1879
|
+
.description("Alert when a pool-share position crosses a local threshold.")
|
|
1880
|
+
.requiredOption("--pool <poolId>", "Liquidity pool id")
|
|
1881
|
+
.option("--account <account>", "Local wallet name or public key", "agent")
|
|
1882
|
+
.option("--shares-below <amount>", "Trigger when pool shares are below this amount")
|
|
1883
|
+
.option("--network <name>", "Network profile to inspect: testnet or mainnet")
|
|
1884
|
+
.action(withContext(async (context, options) => {
|
|
1885
|
+
const { inspectLiquidityPoolPosition } = await import("@stellar-agent/stellar");
|
|
1886
|
+
const account = await resolvePublicAccount(context, options.account);
|
|
1887
|
+
const position = await inspectLiquidityPoolPosition({
|
|
1888
|
+
account,
|
|
1889
|
+
poolId: options.pool,
|
|
1890
|
+
profile: resolveMarketProfile(context, options.network)
|
|
1891
|
+
});
|
|
1892
|
+
const shares = position.positions[0]?.shares ?? "0.0000000";
|
|
1893
|
+
const sharesBelow = options.sharesBelow === undefined ? undefined : parseNonnegativeDecimalInput(options.sharesBelow, "shares-below");
|
|
1894
|
+
const triggered = sharesBelow === undefined ? false : Number(shares) < sharesBelow;
|
|
1895
|
+
return {
|
|
1896
|
+
type: "market.alert",
|
|
1897
|
+
rule: "lp-position-shares-below",
|
|
1898
|
+
status: triggered ? "triggered" : "not_triggered",
|
|
1899
|
+
observed: shares,
|
|
1900
|
+
threshold: options.sharesBelow ?? null,
|
|
1901
|
+
position
|
|
1902
|
+
};
|
|
1903
|
+
}, "Market position listener evaluated."));
|
|
1904
|
+
const soroban = market.command("soroban").description("Inspect Soroban AMM contracts without submitting liquidity actions.");
|
|
1905
|
+
const sorobanPool = soroban.command("pool").description("Read-only Soroban pool investigation.");
|
|
1906
|
+
sorobanPool
|
|
1907
|
+
.command("inspect")
|
|
1908
|
+
.description("Inspect Soroban contract interface metadata for a potential AMM pool.")
|
|
1909
|
+
.requiredOption("--id <contractId>", "Soroban contract id")
|
|
1910
|
+
.option("--network <network>", "Stellar CLI network", "testnet")
|
|
1911
|
+
.option("--stellar-binary <path>", "Path to stellar CLI binary")
|
|
1912
|
+
.option("--stellar-config-dir <path>", "Stellar CLI config directory")
|
|
1913
|
+
.option("--stellar-no-cache", "Pass --no-cache to stellar CLI")
|
|
1914
|
+
.action(withContext(async (context, options) => {
|
|
1915
|
+
const profile = resolveContractExecutionContext(context, options.network, {
|
|
1916
|
+
mutating: false
|
|
1917
|
+
});
|
|
1918
|
+
const { contractInfoWithStellarCli } = await import("@stellar-agent/stellar");
|
|
1919
|
+
const result = await contractInfoWithStellarCli({
|
|
1920
|
+
kind: "interface",
|
|
1921
|
+
contractId: options.id,
|
|
1922
|
+
network: options.network,
|
|
1923
|
+
rpcUrl: profile.rpcUrl,
|
|
1924
|
+
networkPassphrase: profile.networkPassphrase,
|
|
1925
|
+
...(options.stellarBinary === undefined ? {} : { stellarBinary: options.stellarBinary }),
|
|
1926
|
+
...(options.stellarConfigDir === undefined ? {} : { stellarConfigDir: options.stellarConfigDir }),
|
|
1927
|
+
...(options.stellarNoCache === undefined ? {} : { noCache: options.stellarNoCache })
|
|
1928
|
+
});
|
|
1929
|
+
return {
|
|
1930
|
+
contractId: options.id,
|
|
1931
|
+
boundary: "read_only",
|
|
1932
|
+
mutationSupported: false,
|
|
1933
|
+
result
|
|
1934
|
+
};
|
|
1935
|
+
}, "Soroban pool inspected."));
|
|
1936
|
+
sorobanPool
|
|
1937
|
+
.command("preflight")
|
|
1938
|
+
.description("Explain why Soroban AMM mutation requires a protocol-specific adapter before submission.")
|
|
1939
|
+
.requiredOption("--id <contractId>", "Soroban contract id")
|
|
1940
|
+
.requiredOption("--action <action>", "deposit or withdraw")
|
|
1941
|
+
.action(withContext(async (_context, options) => {
|
|
1942
|
+
if (options.action !== "deposit" && options.action !== "withdraw") {
|
|
1943
|
+
throw new StellarAgentError({
|
|
1944
|
+
code: "INVALID_INPUT",
|
|
1945
|
+
message: "Soroban pool action must be deposit or withdraw.",
|
|
1946
|
+
docs: "docs/market-liquidity.md#soroban-pool-boundary"
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
return {
|
|
1950
|
+
contractId: options.id,
|
|
1951
|
+
action: options.action,
|
|
1952
|
+
status: "adapter_required",
|
|
1953
|
+
mutationSupported: false,
|
|
1954
|
+
requirements: [
|
|
1955
|
+
"Use a protocol-specific adapter with documented contract interfaces.",
|
|
1956
|
+
"Add policy controls before mutation.",
|
|
1957
|
+
"Require simulation, external Mainnet signing, and receipts for submitted transactions."
|
|
1958
|
+
]
|
|
1959
|
+
};
|
|
1960
|
+
}, "Soroban pool preflight boundary explained."));
|
|
1961
|
+
}
|
|
1962
|
+
function addStrategyCommands(program) {
|
|
1963
|
+
const strategy = program.command("strategy").description("Explain and simulate market-aware strategy proposals without hidden signing.");
|
|
1964
|
+
strategy
|
|
1965
|
+
.command("explain")
|
|
1966
|
+
.description("Validate and explain a local strategy file.")
|
|
1967
|
+
.argument("<file>", "Strategy JSON or YAML file")
|
|
1968
|
+
.action(withContext(async (_context, file) => {
|
|
1969
|
+
const parsed = await readStrategyFile(file);
|
|
1970
|
+
return explainStrategy(parsed);
|
|
1971
|
+
}, "Strategy explained."));
|
|
1972
|
+
strategy
|
|
1973
|
+
.command("simulate")
|
|
1974
|
+
.description("Simulate a local strategy file without submitting transactions.")
|
|
1975
|
+
.argument("<file>", "Strategy JSON or YAML file")
|
|
1976
|
+
.action(withContext(async (_context, file) => {
|
|
1977
|
+
const parsed = await readStrategyFile(file);
|
|
1978
|
+
return { ...explainStrategy(parsed), simulation: { submitted: false, signing: false, mode: "dry_run" } };
|
|
1979
|
+
}, "Strategy simulation complete."));
|
|
1980
|
+
const investigate = strategy.command("investigate").description("Investigate market options without execution.");
|
|
1981
|
+
investigate
|
|
1982
|
+
.command("liquidity")
|
|
1983
|
+
.description("Compare liquidity-pool context for an asset pair or explicit pool.")
|
|
1984
|
+
.option("--pair <assetA/assetB>", "Asset pair, such as XLM/USD:G...")
|
|
1985
|
+
.option("--pool <poolId>", "Specific liquidity pool id")
|
|
1986
|
+
.option("--network <name>", "Network profile to inspect: testnet or mainnet")
|
|
1987
|
+
.option("--limit <n>", "Number of records", parseIntegerOption, 5)
|
|
1988
|
+
.action(withContext(async (context, options) => {
|
|
1989
|
+
const { inspectLiquidityPool, liquidityPoolTrades, listLiquidityPools } = await import("@stellar-agent/stellar");
|
|
1990
|
+
const profile = resolveMarketProfile(context, options.network);
|
|
1991
|
+
if (options.pool) {
|
|
1992
|
+
const pool = await inspectLiquidityPool({ poolId: options.pool, profile });
|
|
1993
|
+
const trades = await liquidityPoolTrades({ poolId: options.pool, profile, limit: options.limit });
|
|
1994
|
+
return liquidityInvestigation({ pool, trades: trades.records, profile });
|
|
1995
|
+
}
|
|
1996
|
+
if (!options.pair) {
|
|
1997
|
+
throw new StellarAgentError({
|
|
1998
|
+
code: "INVALID_INPUT",
|
|
1999
|
+
message: "Provide --pool or --pair for liquidity investigation.",
|
|
2000
|
+
docs: "docs/market-liquidity.md#strategy-investigation"
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
const [assetA, assetB] = parsePairOption(options.pair);
|
|
2004
|
+
const pools = await listLiquidityPools({ assetA, assetB, profile, limit: options.limit });
|
|
2005
|
+
return {
|
|
2006
|
+
pair: { assetA, assetB },
|
|
2007
|
+
pools: pools.records.map((pool) => liquidityInvestigation({ pool, trades: [], profile }))
|
|
2008
|
+
};
|
|
2009
|
+
}, "Liquidity strategy investigation complete."));
|
|
2010
|
+
}
|
|
2011
|
+
function addDefiCommands(program) {
|
|
2012
|
+
const defi = program.command("defi").description("Inspect and preflight guarded DeFi workflows.");
|
|
2013
|
+
const blend = defi.command("blend").description("Inspect Blend pools and preflight Blend pool requests.");
|
|
2014
|
+
blend
|
|
2015
|
+
.command("deployments")
|
|
2016
|
+
.description("Show known Blend deployment contracts, assets, pools, and canonical trustline issuers.")
|
|
2017
|
+
.option("--network <name>", "Blend deployment network: testnet or mainnet")
|
|
2018
|
+
.option("--refresh", "Fetch current Blend deployment maps from blend-utils and blend-ui")
|
|
2019
|
+
.action(withContext(async (context, options) => {
|
|
2020
|
+
const { blendDeployment, fetchBlendDeployment } = await loadDefi();
|
|
2021
|
+
const network = resolveBlendNetworkOption(context, options.network);
|
|
2022
|
+
return options.refresh ? await fetchBlendDeployment(network) : blendDeployment(network);
|
|
2023
|
+
}, "Blend deployments loaded."));
|
|
2024
|
+
const pool = blend.command("pool").description("Inspect Blend pools.");
|
|
2025
|
+
pool
|
|
2026
|
+
.command("inspect")
|
|
2027
|
+
.description("Load a Blend pool, reserves, APYs, and estimated aggregate pool values.")
|
|
2028
|
+
.requiredOption("--pool <pool>", "Pool contract id or deployment alias")
|
|
2029
|
+
.option("--network <name>", "Network profile to inspect: testnet or mainnet")
|
|
2030
|
+
.option("--version <version>", "Pool version: v1 or v2")
|
|
2031
|
+
.action(withContext(async (context, options) => {
|
|
2032
|
+
const { blendDeployment, inspectBlendPool, resolveBlendPool } = await loadDefi();
|
|
2033
|
+
const profile = resolveBlendProfile(context, options.network);
|
|
2034
|
+
const deployment = blendDeployment(blendNetworkForProfile(profile));
|
|
2035
|
+
const poolDeployment = resolveBlendPool(deployment, options.pool);
|
|
2036
|
+
const poolVersion = resolveBlendPoolVersion(options.version ?? poolDeployment.version);
|
|
2037
|
+
return inspectBlendPool({
|
|
2038
|
+
profile,
|
|
2039
|
+
poolId: poolDeployment.contractId,
|
|
2040
|
+
...(poolVersion === undefined ? {} : { poolVersion })
|
|
2041
|
+
});
|
|
2042
|
+
}, "Blend pool inspected."));
|
|
2043
|
+
const position = blend.command("position").description("Inspect Blend user positions.");
|
|
2044
|
+
position
|
|
2045
|
+
.command("inspect")
|
|
2046
|
+
.description("Load a user's Blend supply, collateral, liabilities, health factor, and APYs.")
|
|
2047
|
+
.requiredOption("--pool <pool>", "Pool contract id or deployment alias")
|
|
2048
|
+
.option("--account <account>", "Local wallet name or public key", "agent")
|
|
2049
|
+
.option("--network <name>", "Network profile to inspect: testnet or mainnet")
|
|
2050
|
+
.option("--version <version>", "Pool version: v1 or v2")
|
|
2051
|
+
.action(withContext(async (context, options) => {
|
|
2052
|
+
const { blendDeployment, inspectBlendPosition, resolveBlendPool } = await loadDefi();
|
|
2053
|
+
const profile = resolveBlendProfile(context, options.network);
|
|
2054
|
+
const deployment = blendDeployment(blendNetworkForProfile(profile));
|
|
2055
|
+
const poolDeployment = resolveBlendPool(deployment, options.pool);
|
|
2056
|
+
const account = await resolvePublicAccount(context, options.account);
|
|
2057
|
+
const poolVersion = resolveBlendPoolVersion(options.version ?? poolDeployment.version);
|
|
2058
|
+
return inspectBlendPosition({
|
|
2059
|
+
profile,
|
|
2060
|
+
poolId: poolDeployment.contractId,
|
|
2061
|
+
userId: account,
|
|
2062
|
+
...(poolVersion === undefined ? {} : { poolVersion })
|
|
2063
|
+
});
|
|
2064
|
+
}, "Blend position inspected."));
|
|
2065
|
+
blend
|
|
2066
|
+
.command("preflight")
|
|
2067
|
+
.description("Preflight Blend requests with decoded request types, expected b/d tokens, position estimates, and policy.")
|
|
2068
|
+
.requiredOption("--pool <pool>", "Pool contract id or deployment alias")
|
|
2069
|
+
.option("--account <account>", "Local wallet name or public key", "agent")
|
|
2070
|
+
.requiredOption("--request <type:asset:amount...>", "Blend request, repeatable", collectOption, [])
|
|
2071
|
+
.option("--network <name>", "Network profile to inspect: testnet or mainnet")
|
|
2072
|
+
.option("--version <version>", "Pool version: v1 or v2")
|
|
2073
|
+
.action(withContext(async (context, options) => {
|
|
2074
|
+
const { blendDeployment, parseBlendRequest, preflightBlendActions, resolveBlendAsset, resolveBlendPool } = await loadDefi();
|
|
2075
|
+
const profile = resolveBlendProfile(context, options.network);
|
|
2076
|
+
const deployment = blendDeployment(blendNetworkForProfile(profile));
|
|
2077
|
+
const poolDeployment = resolveBlendPool(deployment, options.pool);
|
|
2078
|
+
const account = await resolvePublicAccount(context, options.account);
|
|
2079
|
+
const actions = options.request.map((entry) => {
|
|
2080
|
+
const parsed = parseBlendRequest(entry);
|
|
2081
|
+
const asset = resolveBlendAsset(deployment, parsed.asset);
|
|
2082
|
+
return { ...parsed, asset: asset.contractId };
|
|
2083
|
+
});
|
|
2084
|
+
const poolVersion = resolveBlendPoolVersion(options.version ?? poolDeployment.version);
|
|
2085
|
+
const preflight = await preflightBlendActions({
|
|
2086
|
+
profile,
|
|
2087
|
+
poolId: poolDeployment.contractId,
|
|
2088
|
+
userId: account,
|
|
2089
|
+
actions,
|
|
2090
|
+
...(poolVersion === undefined ? {} : { poolVersion })
|
|
2091
|
+
});
|
|
2092
|
+
const policy = await loadPolicy(context);
|
|
2093
|
+
const borrowValue = preflight.actions
|
|
2094
|
+
.filter((action) => action.type === "borrow")
|
|
2095
|
+
.reduce((total, action) => total + (action.value ?? 0), 0);
|
|
2096
|
+
const protocolExposureValue = (preflight.after?.totalSupplied ?? preflight.before?.totalSupplied ?? 0) +
|
|
2097
|
+
(preflight.after?.totalBorrowed ?? preflight.before?.totalBorrowed ?? 0);
|
|
2098
|
+
const policyDecision = evaluateDefiBlendRequest(policy, {
|
|
2099
|
+
network: profile.realFunds ? "mainnet" : "testnet",
|
|
2100
|
+
pool: poolDeployment.contractId,
|
|
2101
|
+
requestTypes: preflight.actions.map((action) => action.type),
|
|
2102
|
+
borrowValue: String(borrowValue),
|
|
2103
|
+
protocolExposureValue: String(protocolExposureValue),
|
|
2104
|
+
...(preflight.after?.healthFactor === undefined ? {} : { healthFactorAfter: preflight.after.healthFactor })
|
|
2105
|
+
});
|
|
2106
|
+
return {
|
|
2107
|
+
deployment: {
|
|
2108
|
+
network: deployment.network,
|
|
2109
|
+
pool: poolDeployment
|
|
2110
|
+
},
|
|
2111
|
+
policyDecision,
|
|
2112
|
+
preflight
|
|
2113
|
+
};
|
|
2114
|
+
}, "Blend preflight complete."));
|
|
2115
|
+
blend
|
|
2116
|
+
.command("supply")
|
|
2117
|
+
.description("Submit a guarded Testnet Blend supply request.")
|
|
2118
|
+
.requiredOption("--pool <pool>", "Pool contract id or deployment alias")
|
|
2119
|
+
.requiredOption("--asset <asset>", "Blend asset symbol or contract id")
|
|
2120
|
+
.requiredOption("--amount <amount>", "Asset amount")
|
|
2121
|
+
.option("--source <account>", "Local Testnet wallet name", "agent")
|
|
2122
|
+
.option("--collateral", "Supply as collateral")
|
|
2123
|
+
.action(withContext(async (context, options) => {
|
|
2124
|
+
return runBlendSubmitCommand(context, {
|
|
2125
|
+
command: "defi blend supply",
|
|
2126
|
+
pool: options.pool,
|
|
2127
|
+
source: options.source,
|
|
2128
|
+
actions: [
|
|
2129
|
+
{
|
|
2130
|
+
type: options.collateral ? "supply_collateral" : "supply",
|
|
2131
|
+
asset: options.asset,
|
|
2132
|
+
amount: options.amount
|
|
2133
|
+
}
|
|
2134
|
+
]
|
|
2135
|
+
});
|
|
2136
|
+
}, "Blend supply submitted."));
|
|
2137
|
+
blend
|
|
2138
|
+
.command("borrow")
|
|
2139
|
+
.description("Submit a guarded Testnet Blend borrow request.")
|
|
2140
|
+
.requiredOption("--pool <pool>", "Pool contract id or deployment alias")
|
|
2141
|
+
.requiredOption("--asset <asset>", "Blend asset symbol or contract id")
|
|
2142
|
+
.requiredOption("--amount <amount>", "Asset amount")
|
|
2143
|
+
.option("--source <account>", "Local Testnet wallet name", "agent")
|
|
2144
|
+
.action(withContext(async (context, options) => {
|
|
2145
|
+
return runBlendSubmitCommand(context, {
|
|
2146
|
+
command: "defi blend borrow",
|
|
2147
|
+
pool: options.pool,
|
|
2148
|
+
source: options.source,
|
|
2149
|
+
actions: [{ type: "borrow", asset: options.asset, amount: options.amount }]
|
|
2150
|
+
});
|
|
2151
|
+
}, "Blend borrow submitted."));
|
|
2152
|
+
blend
|
|
2153
|
+
.command("repay")
|
|
2154
|
+
.description("Submit a guarded Testnet Blend repay request.")
|
|
2155
|
+
.requiredOption("--pool <pool>", "Pool contract id or deployment alias")
|
|
2156
|
+
.requiredOption("--asset <asset>", "Blend asset symbol or contract id")
|
|
2157
|
+
.requiredOption("--amount <amount>", "Asset amount")
|
|
2158
|
+
.option("--source <account>", "Local Testnet wallet name", "agent")
|
|
2159
|
+
.action(withContext(async (context, options) => {
|
|
2160
|
+
return runBlendSubmitCommand(context, {
|
|
2161
|
+
command: "defi blend repay",
|
|
2162
|
+
pool: options.pool,
|
|
2163
|
+
source: options.source,
|
|
2164
|
+
actions: [{ type: "repay", asset: options.asset, amount: options.amount }]
|
|
2165
|
+
});
|
|
2166
|
+
}, "Blend repay submitted."));
|
|
2167
|
+
blend
|
|
2168
|
+
.command("withdraw")
|
|
2169
|
+
.description("Submit a guarded Testnet Blend withdraw request.")
|
|
2170
|
+
.requiredOption("--pool <pool>", "Pool contract id or deployment alias")
|
|
2171
|
+
.requiredOption("--asset <asset>", "Blend asset symbol or contract id")
|
|
2172
|
+
.requiredOption("--amount <amount>", "Asset amount")
|
|
2173
|
+
.option("--source <account>", "Local Testnet wallet name", "agent")
|
|
2174
|
+
.option("--collateral", "Withdraw from collateral")
|
|
2175
|
+
.action(withContext(async (context, options) => {
|
|
2176
|
+
return runBlendSubmitCommand(context, {
|
|
2177
|
+
command: "defi blend withdraw",
|
|
2178
|
+
pool: options.pool,
|
|
2179
|
+
source: options.source,
|
|
2180
|
+
actions: [
|
|
2181
|
+
{
|
|
2182
|
+
type: options.collateral ? "withdraw_collateral" : "withdraw",
|
|
2183
|
+
asset: options.asset,
|
|
2184
|
+
amount: options.amount
|
|
2185
|
+
}
|
|
2186
|
+
]
|
|
2187
|
+
});
|
|
2188
|
+
}, "Blend withdraw submitted."));
|
|
2189
|
+
blend
|
|
2190
|
+
.command("batch")
|
|
2191
|
+
.description("Submit a guarded Testnet batch of Blend requests in one transaction.")
|
|
2192
|
+
.requiredOption("--pool <pool>", "Pool contract id or deployment alias")
|
|
2193
|
+
.option("--source <account>", "Local Testnet wallet name", "agent")
|
|
2194
|
+
.requiredOption("--request <type:asset:amount...>", "Blend request, repeatable", collectOption, [])
|
|
2195
|
+
.action(withContext(async (context, options) => {
|
|
2196
|
+
const { parseBlendRequest } = await loadDefi();
|
|
2197
|
+
return runBlendSubmitCommand(context, {
|
|
2198
|
+
command: "defi blend batch",
|
|
2199
|
+
pool: options.pool,
|
|
2200
|
+
source: options.source,
|
|
2201
|
+
actions: options.request.map(parseBlendRequest)
|
|
2202
|
+
});
|
|
2203
|
+
}, "Blend batch submitted."));
|
|
2204
|
+
const trustline = blend.command("trustline").description("Resolve and create trustlines for Blend non-native reserves.");
|
|
2205
|
+
trustline
|
|
2206
|
+
.command("guide")
|
|
2207
|
+
.description("Show the classic asset backing a Blend reserve and the trustline command to run.")
|
|
2208
|
+
.requiredOption("--asset <asset>", "Blend asset symbol or contract id")
|
|
2209
|
+
.option("--account <account>", "Local wallet name", "agent")
|
|
2210
|
+
.option("--network <name>", "Network profile to inspect: testnet or mainnet")
|
|
2211
|
+
.action(withContext(async (context, options) => {
|
|
2212
|
+
return blendTrustlineGuide(context, options);
|
|
2213
|
+
}, "Blend trustline guidance loaded."));
|
|
2214
|
+
trustline
|
|
2215
|
+
.command("add")
|
|
2216
|
+
.description("Create the classic issued-asset trustline for a Blend reserve on Testnet.")
|
|
2217
|
+
.requiredOption("--asset <asset>", "Blend asset symbol or contract id")
|
|
2218
|
+
.option("--account <account>", "Local Testnet wallet name", "agent")
|
|
2219
|
+
.option("--limit <amount>", "Trustline limit")
|
|
2220
|
+
.action(withContext(async (context, options) => {
|
|
2221
|
+
return addBlendTrustline(context, options);
|
|
2222
|
+
}, "Blend trustline added."));
|
|
2223
|
+
}
|
|
2224
|
+
function addPolicyCommands(program) {
|
|
2225
|
+
const policy = program.command("policy").description("Manage and evaluate spend policies.");
|
|
2226
|
+
policy
|
|
2227
|
+
.command("init")
|
|
2228
|
+
.description("Write a safe default policy.")
|
|
2229
|
+
.option("--network <network>", "Policy network", "testnet")
|
|
2230
|
+
.option("--output <path>", "Output path")
|
|
2231
|
+
.action(withContext(async (context, options) => {
|
|
2232
|
+
const target = defaultPolicyForNetwork(options.network);
|
|
2233
|
+
const path = options.output ??
|
|
2234
|
+
join(context.config.storage.policiesDir, options.network === "mainnet" ? "default-mainnet.yaml" : "default-testnet.yaml");
|
|
2235
|
+
await writeFile(resolvePath(path), policyToYaml(target), { mode: 0o600 });
|
|
2236
|
+
return { path: resolvePath(path), policy: target };
|
|
2237
|
+
}, "Policy initialized."));
|
|
2238
|
+
policy
|
|
2239
|
+
.command("check")
|
|
2240
|
+
.description("Validate a policy YAML file.")
|
|
2241
|
+
.argument("[path]", "Policy path")
|
|
2242
|
+
.action(withContext(async (context, path) => {
|
|
2243
|
+
const policy = await loadPolicy(context, path);
|
|
2244
|
+
return { valid: true, name: policy.name, network: policy.network };
|
|
2245
|
+
}, "Policy valid."));
|
|
2246
|
+
policy
|
|
2247
|
+
.command("explain")
|
|
2248
|
+
.description("Explain a payment request against a policy.")
|
|
2249
|
+
.option("--request <path>", "JSON payment request file")
|
|
2250
|
+
.option("--to <address>", "Destination public key")
|
|
2251
|
+
.option("--amount <amount>", "Payment amount")
|
|
2252
|
+
.option("--asset <asset>", "Asset", "XLM")
|
|
2253
|
+
.option("--memo <memo>", "Memo")
|
|
2254
|
+
.action(withContext(async (context, options) => {
|
|
2255
|
+
const policy = await loadPolicy(context);
|
|
2256
|
+
const raw = options.request
|
|
2257
|
+
? JSON.parse(await readFile(resolvePath(options.request), "utf8"))
|
|
2258
|
+
: { destination: options.to, amount: options.amount, asset: options.asset, memo: options.memo, network: context.profileName };
|
|
2259
|
+
const request = paymentRequestSchema.parse(raw);
|
|
2260
|
+
const history = await loadSpendHistory(context, request);
|
|
2261
|
+
return { ...evaluatePaymentRequest(policy, request, history), spendHistory: history };
|
|
2262
|
+
}, "Policy explanation complete."));
|
|
2263
|
+
policy
|
|
2264
|
+
.command("test")
|
|
2265
|
+
.description("Run bundled policy fixture checks.")
|
|
2266
|
+
.action(withContext(async () => {
|
|
2267
|
+
const fixtures = [
|
|
2268
|
+
evaluatePaymentRequest(DEFAULT_TESTNET_POLICY, fixtureRequest("1")),
|
|
2269
|
+
evaluatePaymentRequest(DEFAULT_TESTNET_POLICY, fixtureRequest("11")),
|
|
2270
|
+
evaluatePaymentRequest(DEFAULT_TESTNET_POLICY, fixtureRequest("6")),
|
|
2271
|
+
evaluatePaymentRequest(DEFAULT_MAINNET_POLICY, { ...fixtureRequest("0.01"), network: "mainnet" })
|
|
2272
|
+
];
|
|
2273
|
+
return { passed: fixtures.length, decisions: fixtures.map((fixture) => fixture.status) };
|
|
2274
|
+
}, "Policy fixtures passed."));
|
|
2275
|
+
}
|
|
2276
|
+
function addLedgerCommands(program) {
|
|
2277
|
+
const ledger = program.command("ledger").description("Inspect Stellar ledger data.");
|
|
2278
|
+
ledger
|
|
2279
|
+
.command("latest")
|
|
2280
|
+
.description("Fetch the latest ledger from Horizon.")
|
|
2281
|
+
.action(withContext(async (context) => latestLedger(resolveNetworkProfile(context.profileName, context.config.profiles)), "Latest ledger loaded."));
|
|
2282
|
+
ledger
|
|
2283
|
+
.command("tx")
|
|
2284
|
+
.description("Fetch a transaction by hash.")
|
|
2285
|
+
.argument("<hash>", "Transaction hash")
|
|
2286
|
+
.action(withContext(async (context, hash) => lookupTransaction(hash, resolveNetworkProfile(context.profileName, context.config.profiles)), "Transaction loaded."));
|
|
2287
|
+
ledger
|
|
2288
|
+
.command("account")
|
|
2289
|
+
.description("Show local account balances.")
|
|
2290
|
+
.argument("[name]", "Wallet name", "agent")
|
|
2291
|
+
.action(withContext(async (context, name) => ({
|
|
2292
|
+
account: name,
|
|
2293
|
+
balances: await walletBalances(context.config, name)
|
|
2294
|
+
}), "Account ledger data loaded."));
|
|
2295
|
+
ledger
|
|
2296
|
+
.command("payments")
|
|
2297
|
+
.description("Fetch recent payment operations for an account from Horizon.")
|
|
2298
|
+
.option("--account <name>", "Local wallet name", "agent")
|
|
2299
|
+
.option("--address <address>", "Raw Stellar public key")
|
|
2300
|
+
.option("--limit <n>", "Number of records", parseIntegerOption, 10)
|
|
2301
|
+
.action(withContext(async (context, options) => {
|
|
2302
|
+
const { accountPayments } = await import("@stellar-agent/stellar");
|
|
2303
|
+
const address = options.address ?? (await loadWallet(context.config, options.account)).publicKey;
|
|
2304
|
+
return {
|
|
2305
|
+
address,
|
|
2306
|
+
...(await accountPayments({
|
|
2307
|
+
address,
|
|
2308
|
+
profile: resolveNetworkProfile(context.profileName, context.config.profiles),
|
|
2309
|
+
limit: options.limit
|
|
2310
|
+
}))
|
|
2311
|
+
};
|
|
2312
|
+
}, "Ledger payments loaded."));
|
|
2313
|
+
ledger
|
|
2314
|
+
.command("effects")
|
|
2315
|
+
.description("Fetch recent effects for an account or transaction from Horizon.")
|
|
2316
|
+
.option("--account <name>", "Local wallet name", "agent")
|
|
2317
|
+
.option("--address <address>", "Raw Stellar public key")
|
|
2318
|
+
.option("--tx <hash>", "Transaction hash")
|
|
2319
|
+
.option("--limit <n>", "Number of records", parseIntegerOption, 10)
|
|
2320
|
+
.action(withContext(async (context, options) => {
|
|
2321
|
+
const { accountEffects, transactionEffects } = await import("@stellar-agent/stellar");
|
|
2322
|
+
const profile = resolveNetworkProfile(context.profileName, context.config.profiles);
|
|
2323
|
+
if (options.tx) {
|
|
2324
|
+
return {
|
|
2325
|
+
transaction: options.tx,
|
|
2326
|
+
...(await transactionEffects({ hash: options.tx, profile, limit: options.limit }))
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
const address = options.address ?? (await loadWallet(context.config, options.account)).publicKey;
|
|
2330
|
+
return {
|
|
2331
|
+
address,
|
|
2332
|
+
...(await accountEffects({ address, profile, limit: options.limit }))
|
|
2333
|
+
};
|
|
2334
|
+
}, "Ledger effects loaded."));
|
|
2335
|
+
ledger
|
|
2336
|
+
.command("export")
|
|
2337
|
+
.description("Export local receipt and event-log metadata as a JSON report.")
|
|
2338
|
+
.option("--output <path>", "Output JSON path")
|
|
2339
|
+
.action(withContext(async (context, options) => {
|
|
2340
|
+
return writeLocalReport(context, options.output ?? join(context.config.storage.ledgersDir, "ledger-report.json"));
|
|
2341
|
+
}, "Ledger report exported."));
|
|
2342
|
+
}
|
|
2343
|
+
function addReceiptCommands(program) {
|
|
2344
|
+
const receipts = program.command("receipts").description("List, show, verify, and export receipts.");
|
|
2345
|
+
receipts
|
|
2346
|
+
.command("list")
|
|
2347
|
+
.description("List local receipts.")
|
|
2348
|
+
.action(withContext(async (context) => ({ receipts: await listReceipts(context.config.storage.receiptsDir) }), "Receipts listed."));
|
|
2349
|
+
receipts
|
|
2350
|
+
.command("latest")
|
|
2351
|
+
.description("Show the latest local receipt.")
|
|
2352
|
+
.action(withContext(async (context) => {
|
|
2353
|
+
const latest = await latestReceipt(context.config.storage.receiptsDir);
|
|
2354
|
+
if (!latest)
|
|
2355
|
+
return { receipt: null };
|
|
2356
|
+
return latest;
|
|
2357
|
+
}, "Latest receipt loaded."));
|
|
2358
|
+
receipts
|
|
2359
|
+
.command("show")
|
|
2360
|
+
.description("Show a receipt file.")
|
|
2361
|
+
.argument("<path>", "Receipt path")
|
|
2362
|
+
.action(withContext(async (_context, path) => readReceipt(path), "Receipt loaded."));
|
|
2363
|
+
receipts
|
|
2364
|
+
.command("verify")
|
|
2365
|
+
.description("Verify a receipt file.")
|
|
2366
|
+
.argument("<path>", "Receipt path")
|
|
2367
|
+
.option("--ledger", "Also verify the receipt transaction against Horizon")
|
|
2368
|
+
.action(withContext(async (context, path, options) => {
|
|
2369
|
+
const receipt = await readReceipt(path);
|
|
2370
|
+
verifyReceipt(receipt);
|
|
2371
|
+
const ledger = options.ledger ? await verifyReceiptAgainstLedger(context, receipt) : undefined;
|
|
2372
|
+
return { valid: true, path: resolvePath(path), ...(ledger === undefined ? {} : { ledger }) };
|
|
2373
|
+
}, "Receipt valid."));
|
|
2374
|
+
receipts
|
|
2375
|
+
.command("export")
|
|
2376
|
+
.description("Export receipt paths.")
|
|
2377
|
+
.action(withContext(async (context) => ({ receipts: await listReceipts(context.config.storage.receiptsDir) }), "Receipts exported."));
|
|
2378
|
+
}
|
|
2379
|
+
function addMainnetCommands(program) {
|
|
2380
|
+
const mainnet = program.command("mainnet").description("Guarded Mainnet readiness and enablement.");
|
|
2381
|
+
mainnet
|
|
2382
|
+
.command("status")
|
|
2383
|
+
.description("Show Mainnet guard status.")
|
|
2384
|
+
.action(withContext(async (context) => ({
|
|
2385
|
+
realFunds: true,
|
|
2386
|
+
enabled: Boolean(context.config.profiles.mainnet?.enabled),
|
|
2387
|
+
requiresExplicitApproval: true
|
|
2388
|
+
}), "Mainnet status loaded."));
|
|
2389
|
+
mainnet
|
|
2390
|
+
.command("enable")
|
|
2391
|
+
.description("Enable guarded Mainnet profile metadata.")
|
|
2392
|
+
.option("--i-understand-real-funds", "Required acknowledgement")
|
|
2393
|
+
.action(withContext(async (context, options) => {
|
|
2394
|
+
if (!options.iUnderstandRealFunds) {
|
|
2395
|
+
throw new StellarAgentError({
|
|
2396
|
+
code: "MAINNET_NOT_ENABLED",
|
|
2397
|
+
message: "Enabling Mainnet requires --i-understand-real-funds.",
|
|
2398
|
+
docs: "docs/mainnet-safety.md"
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
const mainnet = context.config.profiles.mainnet;
|
|
2402
|
+
if (mainnet)
|
|
2403
|
+
mainnet.enabled = true;
|
|
2404
|
+
await writeConfig(context.config, context.options);
|
|
2405
|
+
return { enabled: true, realFunds: true, autoApproval: false };
|
|
2406
|
+
}, "Mainnet enabled with guarded settings."));
|
|
2407
|
+
mainnet
|
|
2408
|
+
.command("disable")
|
|
2409
|
+
.description("Disable Mainnet profile metadata.")
|
|
2410
|
+
.action(withContext(async (context) => {
|
|
2411
|
+
const mainnet = context.config.profiles.mainnet;
|
|
2412
|
+
if (mainnet)
|
|
2413
|
+
mainnet.enabled = false;
|
|
2414
|
+
await writeConfig(context.config, context.options);
|
|
2415
|
+
return { enabled: false, realFunds: true };
|
|
2416
|
+
}, "Mainnet disabled."));
|
|
2417
|
+
mainnet
|
|
2418
|
+
.command("readiness")
|
|
2419
|
+
.description("Show a Mainnet readiness checklist.")
|
|
2420
|
+
.action(withContext(async (context) => ({
|
|
2421
|
+
realFunds: true,
|
|
2422
|
+
enabled: Boolean(context.config.profiles.mainnet?.enabled),
|
|
2423
|
+
checklist: [
|
|
2424
|
+
"Freighter or another human approval flow is required.",
|
|
2425
|
+
"Mainnet auto-approval is disabled.",
|
|
2426
|
+
"Policy must require explicit approval.",
|
|
2427
|
+
"Receipts must mark realFunds: true."
|
|
2428
|
+
]
|
|
2429
|
+
}), "Mainnet readiness checked."));
|
|
2430
|
+
}
|
|
2431
|
+
function withContext(handler, humanMessage) {
|
|
2432
|
+
return async (...args) => {
|
|
2433
|
+
const command = args.at(-1);
|
|
2434
|
+
const options = command.optsWithGlobals();
|
|
2435
|
+
try {
|
|
2436
|
+
const config = await loadConfig(options);
|
|
2437
|
+
const profileName = (options.profile ??
|
|
2438
|
+
process.env.STELLAR_AGENT_PROFILE ??
|
|
2439
|
+
config.activeProfile ??
|
|
2440
|
+
"testnet");
|
|
2441
|
+
const context = { options, config, profileName };
|
|
2442
|
+
const data = await handler(context, ...args.slice(0, -1));
|
|
2443
|
+
printSuccess(options, data, humanMessage);
|
|
2444
|
+
}
|
|
2445
|
+
catch (error) {
|
|
2446
|
+
printError(options, error);
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
}
|
|
2450
|
+
async function loadConfig(options) {
|
|
2451
|
+
const configPath = options.config ?? process.env.STELLAR_AGENT_CONFIG;
|
|
2452
|
+
const resolvedConfigPath = configPath ? resolvePath(configPath) : undefined;
|
|
2453
|
+
const defaultConfig = createDefaultConfig(resolvedConfigPath ? dirname(resolvedConfigPath) : undefined);
|
|
2454
|
+
if (!configPath)
|
|
2455
|
+
return configSchema.parse(defaultConfig);
|
|
2456
|
+
try {
|
|
2457
|
+
const raw = await readFile(resolvedConfigPath, "utf8");
|
|
2458
|
+
const parsed = parseYaml(raw);
|
|
2459
|
+
return configSchema.parse(deepMerge(defaultConfig, parsed));
|
|
2460
|
+
}
|
|
2461
|
+
catch (error) {
|
|
2462
|
+
if (error?.code === "ENOENT") {
|
|
2463
|
+
return configSchema.parse(defaultConfig);
|
|
2464
|
+
}
|
|
2465
|
+
throw new StellarAgentError({
|
|
2466
|
+
code: "CONFIG_INVALID",
|
|
2467
|
+
message: "Config file is invalid.",
|
|
2468
|
+
details: error,
|
|
2469
|
+
docs: "docs/troubleshooting.md#invalid-wallet"
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
async function writeConfig(config, options) {
|
|
2474
|
+
const path = resolvePath(options.config ?? process.env.STELLAR_AGENT_CONFIG ?? join(config.storage.rootDir, "config.yaml"));
|
|
2475
|
+
await import("node:fs/promises").then(({ mkdir }) => mkdir(dirname(path), { recursive: true }));
|
|
2476
|
+
await writeFile(path, stringifyYaml(config), { mode: 0o600 });
|
|
2477
|
+
}
|
|
2478
|
+
async function createTestnetWalletResult(context, name, fund) {
|
|
2479
|
+
const wallet = await ensureWallet(context.config, name);
|
|
2480
|
+
if (!fund)
|
|
2481
|
+
return { wallet };
|
|
2482
|
+
const { fundWithFriendbot } = await import("@stellar-agent/stellar");
|
|
2483
|
+
const funding = await fundWithFriendbot(wallet.publicKey, context.config.profiles.testnet);
|
|
2484
|
+
await appendEvent(join(context.config.storage.logsDir, "events.jsonl"), {
|
|
2485
|
+
event: "command_finished",
|
|
2486
|
+
status: "funded",
|
|
2487
|
+
command: "wallet create-testnet",
|
|
2488
|
+
profile: "testnet",
|
|
2489
|
+
data: { account: name, publicKey: wallet.publicKey, funding }
|
|
2490
|
+
});
|
|
2491
|
+
return { wallet, funding };
|
|
2492
|
+
}
|
|
2493
|
+
async function loadPolicy(context, explicitPath) {
|
|
2494
|
+
const policyPath = explicitPath ??
|
|
2495
|
+
context.options.policy ??
|
|
2496
|
+
process.env.STELLAR_AGENT_POLICY ??
|
|
2497
|
+
join(context.config.storage.policiesDir, context.profileName === "mainnet" ? "default-mainnet.yaml" : "default-testnet.yaml");
|
|
2498
|
+
try {
|
|
2499
|
+
return parsePolicyYaml(await readFile(resolvePath(policyPath), "utf8"));
|
|
2500
|
+
}
|
|
2501
|
+
catch (error) {
|
|
2502
|
+
if (error?.code === "ENOENT")
|
|
2503
|
+
return defaultPolicyForNetwork(context.profileName);
|
|
2504
|
+
throw error;
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
function resolveBlendNetworkOption(context, network) {
|
|
2508
|
+
if (!network)
|
|
2509
|
+
return blendNetworkForProfile(resolveNetworkProfile(context.profileName, context.config.profiles));
|
|
2510
|
+
const normalized = network.toLowerCase();
|
|
2511
|
+
if (normalized === "testnet" || normalized === "mainnet")
|
|
2512
|
+
return normalized;
|
|
2513
|
+
throw new StellarAgentError({
|
|
2514
|
+
code: "INVALID_INPUT",
|
|
2515
|
+
message: "Blend network must be testnet or mainnet.",
|
|
2516
|
+
docs: "docs/defi-blend.md"
|
|
2517
|
+
});
|
|
2518
|
+
}
|
|
2519
|
+
async function loadDefi() {
|
|
2520
|
+
return await import("@stellar-agent/defi");
|
|
2521
|
+
}
|
|
2522
|
+
function blendNetworkForProfile(profile) {
|
|
2523
|
+
return profile.realFunds || profile.name === "mainnet" ? "mainnet" : "testnet";
|
|
2524
|
+
}
|
|
2525
|
+
function resolveBlendProfile(context, network) {
|
|
2526
|
+
return resolveNetworkProfile(resolveBlendNetworkOption(context, network), context.config.profiles);
|
|
2527
|
+
}
|
|
2528
|
+
function resolveBlendPoolVersion(version) {
|
|
2529
|
+
if (version === undefined)
|
|
2530
|
+
return undefined;
|
|
2531
|
+
const normalized = version.toLowerCase();
|
|
2532
|
+
if (normalized === "v1" || normalized === "v2")
|
|
2533
|
+
return normalized;
|
|
2534
|
+
throw new StellarAgentError({
|
|
2535
|
+
code: "INVALID_INPUT",
|
|
2536
|
+
message: "Blend pool version must be v1 or v2.",
|
|
2537
|
+
docs: "docs/defi-blend.md"
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
async function resolvePublicAccount(context, account) {
|
|
2541
|
+
if (/^G[A-Z2-7]{55}$/.test(account))
|
|
2542
|
+
return account;
|
|
2543
|
+
return (await loadWalletPublic(context.config, account)).publicKey;
|
|
2544
|
+
}
|
|
2545
|
+
function resolveMarketProfile(context, network) {
|
|
2546
|
+
if (!network)
|
|
2547
|
+
return resolveNetworkProfile(context.profileName, context.config.profiles);
|
|
2548
|
+
const normalized = network.toLowerCase();
|
|
2549
|
+
if (normalized !== "testnet" && normalized !== "mainnet") {
|
|
2550
|
+
throw new StellarAgentError({
|
|
2551
|
+
code: "INVALID_INPUT",
|
|
2552
|
+
message: "Market network must be testnet or mainnet.",
|
|
2553
|
+
docs: "docs/market-liquidity.md"
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
return resolveNetworkProfile(normalized, context.config.profiles);
|
|
2557
|
+
}
|
|
2558
|
+
async function runLiquidityPreflight(context, profile, options) {
|
|
2559
|
+
const action = parseLiquidityAction(options.action);
|
|
2560
|
+
const account = await resolvePublicAccount(context, options.account);
|
|
2561
|
+
const { preflightLiquidityPoolDeposit, preflightLiquidityPoolWithdraw } = await import("@stellar-agent/stellar");
|
|
2562
|
+
if (action === "deposit") {
|
|
2563
|
+
if (!options.maxA || !options.maxB || !options.minPrice || !options.maxPrice) {
|
|
2564
|
+
throw new StellarAgentError({
|
|
2565
|
+
code: "INVALID_INPUT",
|
|
2566
|
+
message: "Liquidity deposit preflight requires --max-a, --max-b, --min-price, and --max-price.",
|
|
2567
|
+
docs: "docs/market-liquidity.md#lp-preflight"
|
|
2568
|
+
});
|
|
2569
|
+
}
|
|
2570
|
+
validatePriceBounds(options.minPrice, options.maxPrice);
|
|
2571
|
+
return preflightLiquidityPoolDeposit({
|
|
2572
|
+
poolId: options.pool,
|
|
2573
|
+
maxAmountA: options.maxA,
|
|
2574
|
+
maxAmountB: options.maxB,
|
|
2575
|
+
minPrice: options.minPrice,
|
|
2576
|
+
maxPrice: options.maxPrice,
|
|
2577
|
+
account,
|
|
2578
|
+
profile
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
if (!options.shares) {
|
|
2582
|
+
throw new StellarAgentError({
|
|
2583
|
+
code: "INVALID_INPUT",
|
|
2584
|
+
message: "Liquidity withdrawal preflight requires --shares.",
|
|
2585
|
+
docs: "docs/market-liquidity.md#lp-preflight"
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
return preflightLiquidityPoolWithdraw({
|
|
2589
|
+
poolId: options.pool,
|
|
2590
|
+
shares: options.shares,
|
|
2591
|
+
minAmountA: options.minA,
|
|
2592
|
+
minAmountB: options.minB,
|
|
2593
|
+
account,
|
|
2594
|
+
profile
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
function parseLiquidityAction(action) {
|
|
2598
|
+
if (action === "deposit" || action === "withdraw")
|
|
2599
|
+
return action;
|
|
2600
|
+
throw new StellarAgentError({
|
|
2601
|
+
code: "INVALID_INPUT",
|
|
2602
|
+
message: "Liquidity action must be deposit or withdraw.",
|
|
2603
|
+
docs: "docs/market-liquidity.md#lp-preflight"
|
|
2604
|
+
});
|
|
2605
|
+
}
|
|
2606
|
+
async function runLiquiditySubmitCommand(context, args) {
|
|
2607
|
+
const profile = resolveNetworkProfile(context.profileName, context.config.profiles);
|
|
2608
|
+
if (profile.realFunds) {
|
|
2609
|
+
throw new StellarAgentError({
|
|
2610
|
+
code: "MAINNET_NOT_ENABLED",
|
|
2611
|
+
message: "Mainnet liquidity pool mutation requires an external signer and is not available through local auto-signing.",
|
|
2612
|
+
hint: "Use Testnet for local liquidity workflows.",
|
|
2613
|
+
docs: "docs/mainnet-safety.md#mainnet-liquidity"
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
const wallet = await loadWallet(context.config, args.source);
|
|
2617
|
+
if (args.action === "deposit")
|
|
2618
|
+
validatePriceBounds(args.minPrice, args.maxPrice);
|
|
2619
|
+
const { preflightLiquidityPoolDeposit, preflightLiquidityPoolWithdraw, submitLiquidityPoolDeposit, submitLiquidityPoolWithdraw } = await import("@stellar-agent/stellar");
|
|
2620
|
+
const preflight = args.action === "deposit"
|
|
2621
|
+
? await preflightLiquidityPoolDeposit({
|
|
2622
|
+
poolId: args.pool,
|
|
2623
|
+
maxAmountA: args.maxA,
|
|
2624
|
+
maxAmountB: args.maxB,
|
|
2625
|
+
minPrice: args.minPrice,
|
|
2626
|
+
maxPrice: args.maxPrice,
|
|
2627
|
+
account: wallet.publicKey,
|
|
2628
|
+
profile
|
|
2629
|
+
})
|
|
2630
|
+
: await preflightLiquidityPoolWithdraw({
|
|
2631
|
+
poolId: args.pool,
|
|
2632
|
+
shares: args.shares,
|
|
2633
|
+
minAmountA: args.minA,
|
|
2634
|
+
minAmountB: args.minB,
|
|
2635
|
+
account: wallet.publicKey,
|
|
2636
|
+
profile
|
|
2637
|
+
});
|
|
2638
|
+
const policy = await loadPolicy(context);
|
|
2639
|
+
const policyDecision = evaluateMarketLiquidityRequest(policy, liquidityPolicyRequest(profile, preflight));
|
|
2640
|
+
if (policyDecision.status !== "allowed")
|
|
2641
|
+
throw marketPolicyDeniedError(policyDecision);
|
|
2642
|
+
const submitted = args.action === "deposit"
|
|
2643
|
+
? await submitLiquidityPoolDeposit({
|
|
2644
|
+
source: wallet,
|
|
2645
|
+
poolId: args.pool,
|
|
2646
|
+
maxAmountA: args.maxA,
|
|
2647
|
+
maxAmountB: args.maxB,
|
|
2648
|
+
minPrice: args.minPrice,
|
|
2649
|
+
maxPrice: args.maxPrice,
|
|
2650
|
+
profile,
|
|
2651
|
+
feeStrategy: args.feeStrategy,
|
|
2652
|
+
...(context.options.noCache === undefined ? {} : { noCache: context.options.noCache })
|
|
2653
|
+
})
|
|
2654
|
+
: await submitLiquidityPoolWithdraw({
|
|
2655
|
+
source: wallet,
|
|
2656
|
+
poolId: args.pool,
|
|
2657
|
+
shares: args.shares,
|
|
2658
|
+
minAmountA: args.minA,
|
|
2659
|
+
minAmountB: args.minB,
|
|
2660
|
+
profile,
|
|
2661
|
+
feeStrategy: args.feeStrategy,
|
|
2662
|
+
...(context.options.noCache === undefined ? {} : { noCache: context.options.noCache })
|
|
2663
|
+
});
|
|
2664
|
+
const receiptPath = await writeOperationReceipt(context, {
|
|
2665
|
+
command: args.command,
|
|
2666
|
+
policyDecision,
|
|
2667
|
+
operation: {
|
|
2668
|
+
type: `market.lp.${args.action}`,
|
|
2669
|
+
source: args.source,
|
|
2670
|
+
account: wallet.publicKey,
|
|
2671
|
+
details: { liquidityPool: submitted.preflight }
|
|
2672
|
+
},
|
|
2673
|
+
transaction: {
|
|
2674
|
+
hash: submitted.hash,
|
|
2675
|
+
...(submitted.ledger === undefined ? {} : { ledger: submitted.ledger }),
|
|
2676
|
+
successful: submitted.successful,
|
|
2677
|
+
...(submitted.feeCharged === undefined ? {} : { feeCharged: submitted.feeCharged })
|
|
2678
|
+
}
|
|
2679
|
+
});
|
|
2680
|
+
return {
|
|
2681
|
+
status: "submitted",
|
|
2682
|
+
transaction: {
|
|
2683
|
+
hash: submitted.hash,
|
|
2684
|
+
...(submitted.ledger === undefined ? {} : { ledger: submitted.ledger }),
|
|
2685
|
+
successful: submitted.successful,
|
|
2686
|
+
...(submitted.feeCharged === undefined ? {} : { feeCharged: submitted.feeCharged })
|
|
2687
|
+
},
|
|
2688
|
+
receiptPath,
|
|
2689
|
+
policyDecision,
|
|
2690
|
+
preflight: submitted.preflight
|
|
2691
|
+
};
|
|
2692
|
+
}
|
|
2693
|
+
function liquidityPolicyRequest(profile, preflight) {
|
|
2694
|
+
return {
|
|
2695
|
+
network: profile.realFunds ? "mainnet" : "testnet",
|
|
2696
|
+
pool: preflight.pool.id,
|
|
2697
|
+
assets: preflight.assets,
|
|
2698
|
+
action: preflight.action,
|
|
2699
|
+
exposureValue: liquidityExposureValue(preflight),
|
|
2700
|
+
priceBoundsProvided: preflight.action === "deposit" ? Boolean(preflight.deposit?.minPrice && preflight.deposit.maxPrice) : true
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
function liquidityExposureValue(preflight) {
|
|
2704
|
+
if (preflight.deposit) {
|
|
2705
|
+
return String(Number(preflight.deposit.maxAmountA) + Number(preflight.deposit.maxAmountB));
|
|
2706
|
+
}
|
|
2707
|
+
return "0";
|
|
2708
|
+
}
|
|
2709
|
+
function marketPolicyDeniedError(decision) {
|
|
2710
|
+
return new StellarAgentError({
|
|
2711
|
+
code: "POLICY_DENIED",
|
|
2712
|
+
message: "Market liquidity request was denied by policy.",
|
|
2713
|
+
hint: "Run market lp preflight to inspect the matched rules.",
|
|
2714
|
+
docs: "docs/market-liquidity.md#policy",
|
|
2715
|
+
details: decision
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
async function listenForPoolPrice(context, options) {
|
|
2719
|
+
if (options.above === undefined && options.below === undefined) {
|
|
2720
|
+
throw new StellarAgentError({
|
|
2721
|
+
code: "INVALID_INPUT",
|
|
2722
|
+
message: "Price listener requires --above or --below.",
|
|
2723
|
+
docs: "docs/market-liquidity.md#market-listeners"
|
|
2724
|
+
});
|
|
2725
|
+
}
|
|
2726
|
+
const above = options.above === undefined ? undefined : parsePositiveDecimalInput(options.above, "above");
|
|
2727
|
+
const below = options.below === undefined ? undefined : parsePositiveDecimalInput(options.below, "below");
|
|
2728
|
+
const { inspectLiquidityPool } = await import("@stellar-agent/stellar");
|
|
2729
|
+
const profile = resolveMarketProfile(context, options.network);
|
|
2730
|
+
const events = [];
|
|
2731
|
+
for (let poll = 0; poll < options.polls; poll += 1) {
|
|
2732
|
+
if (poll > 0)
|
|
2733
|
+
await sleep(options.intervalMs);
|
|
2734
|
+
const pool = await inspectLiquidityPool({ poolId: options.pool, profile });
|
|
2735
|
+
const price = poolReservePrice(pool);
|
|
2736
|
+
const aboveTriggered = above === undefined ? false : price > above;
|
|
2737
|
+
const belowTriggered = below === undefined ? false : price < below;
|
|
2738
|
+
events.push({
|
|
2739
|
+
type: "market.alert",
|
|
2740
|
+
rule: "core-pool-price",
|
|
2741
|
+
status: aboveTriggered || belowTriggered ? "triggered" : "not_triggered",
|
|
2742
|
+
pool: options.pool,
|
|
2743
|
+
observed: price.toFixed(7),
|
|
2744
|
+
thresholds: {
|
|
2745
|
+
...(options.above === undefined ? {} : { above: options.above }),
|
|
2746
|
+
...(options.below === undefined ? {} : { below: options.below })
|
|
2747
|
+
},
|
|
2748
|
+
assets: pool.reserves.map((reserve) => reserve.asset)
|
|
2749
|
+
});
|
|
2750
|
+
}
|
|
2751
|
+
return { events, triggered: events.some((event) => event.status === "triggered") };
|
|
2752
|
+
}
|
|
2753
|
+
function poolReservePrice(pool) {
|
|
2754
|
+
if (pool.reserves.length !== 2 || !pool.reserves[0] || !pool.reserves[1]) {
|
|
2755
|
+
throw new StellarAgentError({
|
|
2756
|
+
code: "LEDGER_LOOKUP_FAILED",
|
|
2757
|
+
message: "Liquidity pool record did not include exactly two reserves.",
|
|
2758
|
+
docs: "docs/market-liquidity.md#core-pool-inspection"
|
|
2759
|
+
});
|
|
2760
|
+
}
|
|
2761
|
+
const reserveA = Number(pool.reserves[0].amount);
|
|
2762
|
+
const reserveB = Number(pool.reserves[1].amount);
|
|
2763
|
+
if (!Number.isFinite(reserveA) || !Number.isFinite(reserveB) || reserveA <= 0) {
|
|
2764
|
+
throw new StellarAgentError({
|
|
2765
|
+
code: "LEDGER_LOOKUP_FAILED",
|
|
2766
|
+
message: "Liquidity pool reserves did not contain a usable price.",
|
|
2767
|
+
docs: "docs/market-liquidity.md#market-listeners"
|
|
2768
|
+
});
|
|
2769
|
+
}
|
|
2770
|
+
return reserveB / reserveA;
|
|
2771
|
+
}
|
|
2772
|
+
function validatePriceBounds(minPrice, maxPrice) {
|
|
2773
|
+
const min = parsePositiveDecimalInput(minPrice, "min-price");
|
|
2774
|
+
const max = parsePositiveDecimalInput(maxPrice, "max-price");
|
|
2775
|
+
if (min > max) {
|
|
2776
|
+
throw new StellarAgentError({
|
|
2777
|
+
code: "INVALID_INPUT",
|
|
2778
|
+
message: "Liquidity deposit --min-price must be less than or equal to --max-price.",
|
|
2779
|
+
docs: "docs/market-liquidity.md#lp-preflight"
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
function parsePositiveDecimalInput(input, label) {
|
|
2784
|
+
const value = parseNonnegativeDecimalInput(input, label);
|
|
2785
|
+
if (value <= 0) {
|
|
2786
|
+
throw new StellarAgentError({
|
|
2787
|
+
code: "INVALID_INPUT",
|
|
2788
|
+
message: `${label} must be greater than zero.`,
|
|
2789
|
+
docs: "docs/market-liquidity.md"
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2792
|
+
return value;
|
|
2793
|
+
}
|
|
2794
|
+
function parseNonnegativeDecimalInput(input, label) {
|
|
2795
|
+
if (!/^(0|[1-9]\d*)(\.\d+)?$/.test(input.trim())) {
|
|
2796
|
+
throw new StellarAgentError({
|
|
2797
|
+
code: "INVALID_INPUT",
|
|
2798
|
+
message: `${label} must be a decimal number.`,
|
|
2799
|
+
docs: "docs/market-liquidity.md"
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2802
|
+
const value = Number(input);
|
|
2803
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
2804
|
+
throw new StellarAgentError({
|
|
2805
|
+
code: "INVALID_INPUT",
|
|
2806
|
+
message: `${label} must be a finite nonnegative number.`,
|
|
2807
|
+
docs: "docs/market-liquidity.md"
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
return value;
|
|
2811
|
+
}
|
|
2812
|
+
async function readStrategyFile(file) {
|
|
2813
|
+
const raw = await readFile(resolvePath(file), "utf8");
|
|
2814
|
+
const parsed = file.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw);
|
|
2815
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2816
|
+
throw new StellarAgentError({
|
|
2817
|
+
code: "INVALID_INPUT",
|
|
2818
|
+
message: "Strategy file must contain a JSON or YAML object.",
|
|
2819
|
+
docs: "docs/market-liquidity.md#strategy-investigation"
|
|
2820
|
+
});
|
|
2821
|
+
}
|
|
2822
|
+
return parsed;
|
|
2823
|
+
}
|
|
2824
|
+
function explainStrategy(strategy) {
|
|
2825
|
+
const kind = typeof strategy.kind === "string" ? strategy.kind : "liquidity";
|
|
2826
|
+
const actions = Array.isArray(strategy.actions) ? strategy.actions : [];
|
|
2827
|
+
return {
|
|
2828
|
+
status: "explained",
|
|
2829
|
+
kind,
|
|
2830
|
+
actionCount: actions.length,
|
|
2831
|
+
submitted: false,
|
|
2832
|
+
signing: false,
|
|
2833
|
+
requiredControls: [
|
|
2834
|
+
"Run market lp preflight before any core LP mutation.",
|
|
2835
|
+
"Require policy approval for any request outside configured market.liquidity limits.",
|
|
2836
|
+
"Use external signing for Mainnet.",
|
|
2837
|
+
"Write receipts for submitted Testnet liquidity actions."
|
|
2838
|
+
],
|
|
2839
|
+
riskNotes: [
|
|
2840
|
+
"Strategy files are proposals, not profitability guarantees.",
|
|
2841
|
+
"Testnet liquidity does not prove Mainnet profitability.",
|
|
2842
|
+
"Quotes and pool snapshots can change before submission."
|
|
2843
|
+
],
|
|
2844
|
+
strategy
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
function parsePairOption(pair) {
|
|
2848
|
+
const separator = pair.indexOf("/");
|
|
2849
|
+
if (separator <= 0 || separator === pair.length - 1) {
|
|
2850
|
+
throw new StellarAgentError({
|
|
2851
|
+
code: "INVALID_INPUT",
|
|
2852
|
+
message: "Pair must use assetA/assetB format.",
|
|
2853
|
+
docs: "docs/market-liquidity.md#strategy-investigation"
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
return [pair.slice(0, separator), pair.slice(separator + 1)];
|
|
2857
|
+
}
|
|
2858
|
+
function sleep(ms) {
|
|
2859
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2860
|
+
}
|
|
2861
|
+
function liquidityInvestigation(args) {
|
|
2862
|
+
return {
|
|
2863
|
+
network: args.profile.name,
|
|
2864
|
+
pool: args.pool,
|
|
2865
|
+
currentPrice: poolReservePrice(args.pool).toFixed(7),
|
|
2866
|
+
recentTrades: args.trades,
|
|
2867
|
+
execution: {
|
|
2868
|
+
submitted: false,
|
|
2869
|
+
mutationSupported: args.profile.realFunds ? "external_signer_required" : "testnet_preflight_required"
|
|
2870
|
+
},
|
|
2871
|
+
riskNotes: [
|
|
2872
|
+
"Liquidity fees are not guaranteed profit.",
|
|
2873
|
+
"LP positions can lose relative value versus holding reserve assets.",
|
|
2874
|
+
"Inspect issuer risk and trustlines before depositing."
|
|
2875
|
+
]
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
2878
|
+
function collectOption(value, previous) {
|
|
2879
|
+
previous.push(value);
|
|
2880
|
+
return previous;
|
|
2881
|
+
}
|
|
2882
|
+
async function runBlendSubmitCommand(context, args) {
|
|
2883
|
+
const { blendDeployment, preflightBlendActions, resolveBlendAsset, resolveBlendPool, submitBlendActions } = await loadDefi();
|
|
2884
|
+
const profile = resolveNetworkProfile(context.profileName, context.config.profiles);
|
|
2885
|
+
if (profile.realFunds) {
|
|
2886
|
+
throw new StellarAgentError({
|
|
2887
|
+
code: "MAINNET_NOT_ENABLED",
|
|
2888
|
+
message: "Mainnet Blend mutation requires an external signer and is not available through local auto-signing.",
|
|
2889
|
+
hint: "Use Testnet for local Blend mutation workflows.",
|
|
2890
|
+
docs: "docs/mainnet-safety.md#mainnet-defi"
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
const deployment = blendDeployment(blendNetworkForProfile(profile));
|
|
2894
|
+
const pool = resolveBlendPool(deployment, args.pool);
|
|
2895
|
+
const wallet = await loadWallet(context.config, args.source);
|
|
2896
|
+
const actions = args.actions.map((action) => {
|
|
2897
|
+
const asset = resolveBlendAsset(deployment, action.asset);
|
|
2898
|
+
return { ...action, asset: asset.contractId };
|
|
2899
|
+
});
|
|
2900
|
+
const preflight = await preflightBlendActions({
|
|
2901
|
+
profile,
|
|
2902
|
+
poolId: pool.contractId,
|
|
2903
|
+
userId: wallet.publicKey,
|
|
2904
|
+
actions,
|
|
2905
|
+
poolVersion: pool.version
|
|
2906
|
+
});
|
|
2907
|
+
const policy = await loadPolicy(context);
|
|
2908
|
+
const policyDecision = evaluateDefiBlendRequest(policy, blendPolicyRequest(profile, pool.contractId, preflight));
|
|
2909
|
+
if (policyDecision.status !== "allowed")
|
|
2910
|
+
throw defiPolicyDeniedError(policyDecision);
|
|
2911
|
+
const submitted = await submitBlendActions({
|
|
2912
|
+
profile,
|
|
2913
|
+
poolId: pool.contractId,
|
|
2914
|
+
userId: wallet.publicKey,
|
|
2915
|
+
actions,
|
|
2916
|
+
poolVersion: pool.version,
|
|
2917
|
+
sourceSecretKey: wallet.secretKey
|
|
2918
|
+
});
|
|
2919
|
+
const receiptPath = await writeBlendReceipt(context, {
|
|
2920
|
+
command: args.command,
|
|
2921
|
+
policyDecision,
|
|
2922
|
+
operation: {
|
|
2923
|
+
type: "defi.blend.submit",
|
|
2924
|
+
source: args.source,
|
|
2925
|
+
account: wallet.publicKey,
|
|
2926
|
+
details: {
|
|
2927
|
+
blend: {
|
|
2928
|
+
pool,
|
|
2929
|
+
actions: submitted.preflight.actions,
|
|
2930
|
+
before: submitted.preflight.before,
|
|
2931
|
+
after: submitted.preflight.after,
|
|
2932
|
+
simulation: submitted.simulation,
|
|
2933
|
+
decodedEvents: submitted.decodedEvents
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
},
|
|
2937
|
+
transaction: {
|
|
2938
|
+
hash: submitted.hash,
|
|
2939
|
+
...(submitted.ledger === undefined ? {} : { ledger: submitted.ledger }),
|
|
2940
|
+
successful: submitted.successful,
|
|
2941
|
+
...(submitted.feeCharged === undefined ? {} : { feeCharged: submitted.feeCharged })
|
|
2942
|
+
}
|
|
2943
|
+
});
|
|
2944
|
+
return {
|
|
2945
|
+
status: "submitted",
|
|
2946
|
+
transaction: {
|
|
2947
|
+
hash: submitted.hash,
|
|
2948
|
+
...(submitted.ledger === undefined ? {} : { ledger: submitted.ledger }),
|
|
2949
|
+
successful: submitted.successful,
|
|
2950
|
+
...(submitted.feeCharged === undefined ? {} : { feeCharged: submitted.feeCharged })
|
|
2951
|
+
},
|
|
2952
|
+
receiptPath,
|
|
2953
|
+
policyDecision,
|
|
2954
|
+
preflight: submitted.preflight,
|
|
2955
|
+
simulation: submitted.simulation,
|
|
2956
|
+
decodedEvents: submitted.decodedEvents
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
function blendPolicyRequest(profile, pool, preflight) {
|
|
2960
|
+
const borrowValue = preflight.actions
|
|
2961
|
+
.filter((action) => action.type === "borrow")
|
|
2962
|
+
.reduce((total, action) => total + (action.value ?? 0), 0);
|
|
2963
|
+
const protocolExposureValue = (preflight.after?.totalSupplied ?? preflight.before?.totalSupplied ?? 0) +
|
|
2964
|
+
(preflight.after?.totalBorrowed ?? preflight.before?.totalBorrowed ?? 0);
|
|
2965
|
+
return {
|
|
2966
|
+
network: profile.realFunds ? "mainnet" : "testnet",
|
|
2967
|
+
pool,
|
|
2968
|
+
requestTypes: preflight.actions.map((action) => action.type),
|
|
2969
|
+
borrowValue: String(borrowValue),
|
|
2970
|
+
protocolExposureValue: String(protocolExposureValue),
|
|
2971
|
+
...(preflight.after?.healthFactor === undefined ? {} : { healthFactorAfter: preflight.after.healthFactor })
|
|
2972
|
+
};
|
|
2973
|
+
}
|
|
2974
|
+
function defiPolicyDeniedError(decision) {
|
|
2975
|
+
return new StellarAgentError({
|
|
2976
|
+
code: "POLICY_DENIED",
|
|
2977
|
+
message: "Blend DeFi request was denied by policy.",
|
|
2978
|
+
hint: "Run defi blend preflight to inspect the matched rules.",
|
|
2979
|
+
docs: "docs/defi-blend.md#policy",
|
|
2980
|
+
details: decision
|
|
2981
|
+
});
|
|
2982
|
+
}
|
|
2983
|
+
async function blendTrustlineGuide(context, options) {
|
|
2984
|
+
const { blendDeployment, resolveBlendAsset } = await loadDefi();
|
|
2985
|
+
const profile = resolveBlendProfile(context, options.network);
|
|
2986
|
+
const deployment = blendDeployment(blendNetworkForProfile(profile));
|
|
2987
|
+
const asset = resolveBlendAsset(deployment, options.asset);
|
|
2988
|
+
const account = await resolvePublicAccount(context, options.account);
|
|
2989
|
+
const rawPublicAccount = /^G[A-Z2-7]{55}$/.test(options.account);
|
|
2990
|
+
const trustlines = profile.realFunds || asset.classicAsset === undefined || rawPublicAccount
|
|
2991
|
+
? []
|
|
2992
|
+
: await walletTrustlines(context.config, options.account);
|
|
2993
|
+
const hasTrustline = asset.classicAsset
|
|
2994
|
+
? trustlines.some((trustline) => trustline.asset.toUpperCase() === asset.classicAsset.toUpperCase())
|
|
2995
|
+
: false;
|
|
2996
|
+
return {
|
|
2997
|
+
network: deployment.network,
|
|
2998
|
+
account,
|
|
2999
|
+
asset,
|
|
3000
|
+
requiresTrustline: asset.classicAsset !== undefined,
|
|
3001
|
+
hasTrustline,
|
|
3002
|
+
command: asset.classicAsset === undefined || profile.realFunds || rawPublicAccount
|
|
3003
|
+
? null
|
|
3004
|
+
: `stellar-agent defi blend trustline add --account ${options.account} --asset ${asset.symbol}`
|
|
3005
|
+
};
|
|
3006
|
+
}
|
|
3007
|
+
async function addBlendTrustline(context, options) {
|
|
3008
|
+
const { blendDeployment, resolveBlendAsset } = await loadDefi();
|
|
3009
|
+
const profile = resolveNetworkProfile(context.profileName, context.config.profiles);
|
|
3010
|
+
if (profile.realFunds) {
|
|
3011
|
+
throw new StellarAgentError({
|
|
3012
|
+
code: "MAINNET_NOT_ENABLED",
|
|
3013
|
+
message: "Mainnet trustline creation requires an external signer.",
|
|
3014
|
+
docs: "docs/mainnet-safety.md#mainnet-defi"
|
|
3015
|
+
});
|
|
3016
|
+
}
|
|
3017
|
+
const deployment = blendDeployment(blendNetworkForProfile(profile));
|
|
3018
|
+
const asset = resolveBlendAsset(deployment, options.asset);
|
|
3019
|
+
if (!asset.classicAsset) {
|
|
3020
|
+
return {
|
|
3021
|
+
status: "not_required",
|
|
3022
|
+
asset,
|
|
3023
|
+
message: "This Blend reserve does not require a classic issued-asset trustline."
|
|
3024
|
+
};
|
|
3025
|
+
}
|
|
3026
|
+
const wallet = await loadWallet(context.config, options.account);
|
|
3027
|
+
const { changeTrustline } = await import("@stellar-agent/stellar");
|
|
3028
|
+
const transaction = await changeTrustline({
|
|
3029
|
+
source: wallet,
|
|
3030
|
+
asset: asset.classicAsset,
|
|
3031
|
+
profile,
|
|
3032
|
+
...(options.limit === undefined ? {} : { limit: options.limit })
|
|
3033
|
+
});
|
|
3034
|
+
const receiptPath = await writeOperationReceipt(context, {
|
|
3035
|
+
command: "defi blend trustline add",
|
|
3036
|
+
operation: {
|
|
3037
|
+
type: "defi.blend.trustline.add",
|
|
3038
|
+
account: options.account,
|
|
3039
|
+
asset: asset.classicAsset,
|
|
3040
|
+
limit: transaction.limit,
|
|
3041
|
+
details: { blend: { asset } }
|
|
3042
|
+
},
|
|
3043
|
+
transaction
|
|
3044
|
+
});
|
|
3045
|
+
return {
|
|
3046
|
+
status: "trustline_added",
|
|
3047
|
+
asset,
|
|
3048
|
+
account: wallet.publicKey,
|
|
3049
|
+
transaction,
|
|
3050
|
+
receiptPath
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
async function loadSpendHistory(context, request) {
|
|
3054
|
+
return spendHistoryFromReceipts(context.config.storage.receiptsDir, {
|
|
3055
|
+
profile: request.network,
|
|
3056
|
+
asset: request.asset
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
function parseFeeStrategy(value) {
|
|
3060
|
+
if (value === "base" || value === "low" || value === "medium" || value === "high" || value === "p95")
|
|
3061
|
+
return value;
|
|
3062
|
+
throw new StellarAgentError({
|
|
3063
|
+
code: "INVALID_INPUT",
|
|
3064
|
+
message: "Fee strategy must be base, low, medium, high, or p95.",
|
|
3065
|
+
hint: "Use --fee-strategy medium unless you intentionally need a different fee posture.",
|
|
3066
|
+
docs: "docs/troubleshooting.md#fee-too-low"
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3069
|
+
function parseBatchPaymentsFile(raw) {
|
|
3070
|
+
let parsed;
|
|
3071
|
+
try {
|
|
3072
|
+
parsed = JSON.parse(raw);
|
|
3073
|
+
}
|
|
3074
|
+
catch (error) {
|
|
3075
|
+
throw new StellarAgentError({
|
|
3076
|
+
code: "INVALID_INPUT",
|
|
3077
|
+
message: "Batch payment file must be valid JSON.",
|
|
3078
|
+
hint: "Use an array like [{\"destination\":\"G...\",\"amount\":\"1\",\"asset\":\"XLM\"}].",
|
|
3079
|
+
docs: "docs/troubleshooting.md#batch-transaction-failed",
|
|
3080
|
+
details: String(error)
|
|
3081
|
+
});
|
|
3082
|
+
}
|
|
3083
|
+
if (!Array.isArray(parsed) || parsed.length < 1 || parsed.length > 100) {
|
|
3084
|
+
throw new StellarAgentError({
|
|
3085
|
+
code: "INVALID_INPUT",
|
|
3086
|
+
message: "Batch payment file must contain 1 to 100 payments.",
|
|
3087
|
+
docs: "docs/troubleshooting.md#batch-transaction-failed"
|
|
3088
|
+
});
|
|
3089
|
+
}
|
|
3090
|
+
return parsed.map((item, index) => {
|
|
3091
|
+
if (!item || typeof item !== "object") {
|
|
3092
|
+
throw new StellarAgentError({
|
|
3093
|
+
code: "INVALID_INPUT",
|
|
3094
|
+
message: `Batch payment ${index + 1} must be an object.`,
|
|
3095
|
+
docs: "docs/troubleshooting.md#batch-transaction-failed"
|
|
3096
|
+
});
|
|
3097
|
+
}
|
|
3098
|
+
const candidate = item;
|
|
3099
|
+
if (typeof candidate.destination !== "string" || typeof candidate.amount !== "string") {
|
|
3100
|
+
throw new StellarAgentError({
|
|
3101
|
+
code: "INVALID_INPUT",
|
|
3102
|
+
message: `Batch payment ${index + 1} requires destination and amount strings.`,
|
|
3103
|
+
docs: "docs/troubleshooting.md#batch-transaction-failed"
|
|
3104
|
+
});
|
|
3105
|
+
}
|
|
3106
|
+
const asset = typeof candidate.asset === "string" ? candidate.asset : "XLM";
|
|
3107
|
+
const amount = parseAmount(candidate.amount, asset).value;
|
|
3108
|
+
return { destination: candidate.destination, amount, asset };
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
async function evaluateBatchPaymentPolicy(context, args) {
|
|
3112
|
+
const histories = new Map();
|
|
3113
|
+
const results = [];
|
|
3114
|
+
for (const [index, payment] of args.payments.entries()) {
|
|
3115
|
+
const request = paymentRequestSchema.parse({
|
|
3116
|
+
source: args.source,
|
|
3117
|
+
destination: payment.destination,
|
|
3118
|
+
amount: payment.amount,
|
|
3119
|
+
asset: payment.asset,
|
|
3120
|
+
memo: args.memo,
|
|
3121
|
+
network: "testnet"
|
|
3122
|
+
});
|
|
3123
|
+
const assetKey = request.asset.toUpperCase();
|
|
3124
|
+
let history = histories.get(assetKey);
|
|
3125
|
+
if (!history) {
|
|
3126
|
+
history = await loadSpendHistory(context, request);
|
|
3127
|
+
histories.set(assetKey, { ...history });
|
|
3128
|
+
}
|
|
3129
|
+
const decision = evaluatePaymentRequest(args.policy, request, history);
|
|
3130
|
+
results.push({ index, request, spendHistory: history, policyDecision: decision });
|
|
3131
|
+
if (decision.status === "allowed") {
|
|
3132
|
+
histories.set(assetKey, incrementBatchSpendHistory(history, request));
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
const status = results.some((result) => result.policyDecision.status === "denied")
|
|
3136
|
+
? "denied"
|
|
3137
|
+
: results.some((result) => result.policyDecision.status === "requires_approval")
|
|
3138
|
+
? "requires_approval"
|
|
3139
|
+
: "allowed";
|
|
3140
|
+
return {
|
|
3141
|
+
aggregate: {
|
|
3142
|
+
status,
|
|
3143
|
+
matchedRules: [...new Set(results.flatMap((result) => result.policyDecision.matchedRules))]
|
|
3144
|
+
},
|
|
3145
|
+
payments: results
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
function incrementBatchSpendHistory(history, request) {
|
|
3149
|
+
if (history.unreadable)
|
|
3150
|
+
return history;
|
|
3151
|
+
const amount = parseAmount(request.amount, request.asset).stroops;
|
|
3152
|
+
const add = (value) => formatStroops(parseNonnegativeStroops(value, request.asset) + amount);
|
|
3153
|
+
return {
|
|
3154
|
+
...history,
|
|
3155
|
+
dailyTotal: add(history.dailyTotal),
|
|
3156
|
+
monthlyTotal: add(history.monthlyTotal),
|
|
3157
|
+
knownRecipients: [...new Set([...(history.knownRecipients ?? []), request.destination])],
|
|
3158
|
+
...(request.domain
|
|
3159
|
+
? { knownDomains: [...new Set([...(history.knownDomains ?? []), request.domain])] }
|
|
3160
|
+
: history.knownDomains === undefined
|
|
3161
|
+
? {}
|
|
3162
|
+
: { knownDomains: history.knownDomains })
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
3165
|
+
function parseNonnegativeStroops(value, asset) {
|
|
3166
|
+
const raw = value ?? "0";
|
|
3167
|
+
if (/^0(?:\.0{0,7})?$/.test(raw))
|
|
3168
|
+
return 0n;
|
|
3169
|
+
return parseAmount(raw, asset).stroops;
|
|
3170
|
+
}
|
|
3171
|
+
function policyDeniedError() {
|
|
3172
|
+
return new StellarAgentError({
|
|
3173
|
+
code: "POLICY_DENIED",
|
|
3174
|
+
message: "Payment request was denied by policy.",
|
|
3175
|
+
hint: "Run policy explain to inspect the matched rules.",
|
|
3176
|
+
docs: "docs/troubleshooting.md#policy-denied"
|
|
3177
|
+
});
|
|
3178
|
+
}
|
|
3179
|
+
function assertGuardedRealFundsProfile(context, profile, options) {
|
|
3180
|
+
if (!profile.realFunds)
|
|
3181
|
+
return;
|
|
3182
|
+
if (!context.config.profiles.mainnet?.enabled) {
|
|
3183
|
+
throw new StellarAgentError({
|
|
3184
|
+
code: "MAINNET_NOT_ENABLED",
|
|
3185
|
+
message: `Mainnet ${options.action} requires mainnet enablement.`,
|
|
3186
|
+
hint: "Run stellar-agent mainnet enable --i-understand-real-funds first.",
|
|
3187
|
+
docs: "docs/mainnet-safety.md#signed-xdr-submission"
|
|
3188
|
+
});
|
|
3189
|
+
}
|
|
3190
|
+
if (!options.allowRealFunds || !options.acknowledgeRealFunds) {
|
|
3191
|
+
throw new StellarAgentError({
|
|
3192
|
+
code: "MAINNET_NOT_ENABLED",
|
|
3193
|
+
message: `Mainnet ${options.action} requires --allow-real-funds and --i-understand-real-funds.`,
|
|
3194
|
+
hint: "Use signed XDR from a human-controlled Mainnet wallet; stellar-agent will not auto-sign Mainnet transactions.",
|
|
3195
|
+
docs: "docs/mainnet-safety.md#signed-xdr-submission"
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
async function verifyReceiptAgainstLedger(context, receipt) {
|
|
3200
|
+
const transaction = (await lookupTransaction(receipt.transaction.hash, resolveNetworkProfile(receipt.profile, context.config.profiles)));
|
|
3201
|
+
if (transaction.hash !== receipt.transaction.hash) {
|
|
3202
|
+
throw new StellarAgentError({
|
|
3203
|
+
code: "LEDGER_LOOKUP_FAILED",
|
|
3204
|
+
message: "Ledger transaction hash did not match the receipt.",
|
|
3205
|
+
docs: "docs/ledger-logging.md#receipt-verification"
|
|
3206
|
+
});
|
|
3207
|
+
}
|
|
3208
|
+
if (receipt.transaction.ledger !== undefined && transaction.ledger !== receipt.transaction.ledger) {
|
|
3209
|
+
throw new StellarAgentError({
|
|
3210
|
+
code: "LEDGER_LOOKUP_FAILED",
|
|
3211
|
+
message: "Ledger transaction number did not match the receipt.",
|
|
3212
|
+
docs: "docs/ledger-logging.md#receipt-verification"
|
|
3213
|
+
});
|
|
3214
|
+
}
|
|
3215
|
+
if (receipt.transaction.successful !== undefined && transaction.successful !== receipt.transaction.successful) {
|
|
3216
|
+
throw new StellarAgentError({
|
|
3217
|
+
code: "LEDGER_LOOKUP_FAILED",
|
|
3218
|
+
message: "Ledger transaction success status did not match the receipt.",
|
|
3219
|
+
docs: "docs/ledger-logging.md#receipt-verification"
|
|
3220
|
+
});
|
|
3221
|
+
}
|
|
3222
|
+
return {
|
|
3223
|
+
verified: true,
|
|
3224
|
+
hash: transaction.hash,
|
|
3225
|
+
ledger: transaction.ledger,
|
|
3226
|
+
successful: transaction.successful,
|
|
3227
|
+
feeCharged: transaction.fee_charged?.toString()
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
function printSuccess(options, data, humanMessage) {
|
|
3231
|
+
process.exitCode = EXIT_CODES.success;
|
|
3232
|
+
if (options.json) {
|
|
3233
|
+
process.stdout.write(`${JSON.stringify(ok(redactSensitive(data)))}\n`);
|
|
3234
|
+
return;
|
|
3235
|
+
}
|
|
3236
|
+
if (!options.quiet) {
|
|
3237
|
+
process.stdout.write(`${humanMessage}\n`);
|
|
3238
|
+
if (data !== undefined)
|
|
3239
|
+
process.stdout.write(`${JSON.stringify(redactSensitive(data), null, 2)}\n`);
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
function printError(options, error) {
|
|
3243
|
+
const serialized = serializeError(error);
|
|
3244
|
+
process.exitCode = error instanceof StellarAgentError ? error.exitCode : EXIT_CODES.general;
|
|
3245
|
+
if (options.json) {
|
|
3246
|
+
process.stdout.write(`${JSON.stringify(fail(serialized))}\n`);
|
|
3247
|
+
return;
|
|
3248
|
+
}
|
|
3249
|
+
process.stderr.write(`${serialized.code}: ${serialized.message}\n`);
|
|
3250
|
+
if (serialized.hint)
|
|
3251
|
+
process.stderr.write(`Hint: ${serialized.hint}\n`);
|
|
3252
|
+
if (serialized.docs)
|
|
3253
|
+
process.stderr.write(`Docs: ${serialized.docs}\n`);
|
|
3254
|
+
}
|
|
3255
|
+
function fixtureRequest(amount) {
|
|
3256
|
+
return {
|
|
3257
|
+
destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
|
|
3258
|
+
amount,
|
|
3259
|
+
asset: "XLM",
|
|
3260
|
+
network: "testnet"
|
|
3261
|
+
};
|
|
3262
|
+
}
|
|
3263
|
+
function deepMerge(base, overlay) {
|
|
3264
|
+
if (!isPlainObject(base) || !isPlainObject(overlay))
|
|
3265
|
+
return overlay;
|
|
3266
|
+
const output = { ...base };
|
|
3267
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
3268
|
+
output[key] = key in output ? deepMerge(output[key], value) : value;
|
|
3269
|
+
}
|
|
3270
|
+
return output;
|
|
3271
|
+
}
|
|
3272
|
+
function isPlainObject(value) {
|
|
3273
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
3274
|
+
}
|
|
3275
|
+
function collectArg(value, previous) {
|
|
3276
|
+
return [...previous, value];
|
|
3277
|
+
}
|
|
3278
|
+
function parseKeyValueArgs(values) {
|
|
3279
|
+
const parsed = {};
|
|
3280
|
+
for (const value of values) {
|
|
3281
|
+
const separator = value.indexOf("=");
|
|
3282
|
+
if (separator <= 0) {
|
|
3283
|
+
throw new StellarAgentError({
|
|
3284
|
+
code: "INVALID_INPUT",
|
|
3285
|
+
message: `Contract arg '${value}' must use key=value format.`,
|
|
3286
|
+
docs: "docs/smart-contracts.md"
|
|
3287
|
+
});
|
|
3288
|
+
}
|
|
3289
|
+
parsed[value.slice(0, separator)] = value.slice(separator + 1);
|
|
3290
|
+
}
|
|
3291
|
+
return parsed;
|
|
3292
|
+
}
|
|
3293
|
+
function parseIntegerOption(value) {
|
|
3294
|
+
const parsed = Number.parseInt(value, 10);
|
|
3295
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 200) {
|
|
3296
|
+
throw new StellarAgentError({
|
|
3297
|
+
code: "INVALID_INPUT",
|
|
3298
|
+
message: "Limit must be an integer between 1 and 200.",
|
|
3299
|
+
docs: "docs/ledger-logging.md"
|
|
3300
|
+
});
|
|
3301
|
+
}
|
|
3302
|
+
return parsed;
|
|
3303
|
+
}
|
|
3304
|
+
function parsePortOption(value) {
|
|
3305
|
+
const parsed = Number.parseInt(value, 10);
|
|
3306
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
|
|
3307
|
+
throw new StellarAgentError({
|
|
3308
|
+
code: "INVALID_INPUT",
|
|
3309
|
+
message: "Port must be an integer between 0 and 65535."
|
|
3310
|
+
});
|
|
3311
|
+
}
|
|
3312
|
+
return parsed;
|
|
3313
|
+
}
|
|
3314
|
+
function parsePositiveIntegerOption(value) {
|
|
3315
|
+
const parsed = Number.parseInt(value, 10);
|
|
3316
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
3317
|
+
throw new StellarAgentError({
|
|
3318
|
+
code: "INVALID_INPUT",
|
|
3319
|
+
message: "Value must be a positive integer."
|
|
3320
|
+
});
|
|
3321
|
+
}
|
|
3322
|
+
return parsed;
|
|
3323
|
+
}
|
|
3324
|
+
function parseLedgersToExtendOption(value) {
|
|
3325
|
+
const parsed = Number.parseInt(value, 10);
|
|
3326
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
3327
|
+
throw new StellarAgentError({
|
|
3328
|
+
code: "INVALID_INPUT",
|
|
3329
|
+
message: "Ledgers to extend must be a positive integer.",
|
|
3330
|
+
docs: "docs/smart-contracts.md#extend-and-restore"
|
|
3331
|
+
});
|
|
3332
|
+
}
|
|
3333
|
+
return parsed;
|
|
3334
|
+
}
|
|
3335
|
+
function validateDurability(value) {
|
|
3336
|
+
if (value !== "persistent" && value !== "temporary") {
|
|
3337
|
+
throw new StellarAgentError({
|
|
3338
|
+
code: "INVALID_INPUT",
|
|
3339
|
+
message: "Durability must be persistent or temporary.",
|
|
3340
|
+
docs: "docs/smart-contracts.md"
|
|
3341
|
+
});
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
function validateContractReadOutput(value) {
|
|
3345
|
+
if (value !== "string" && value !== "json" && value !== "xdr") {
|
|
3346
|
+
throw new StellarAgentError({
|
|
3347
|
+
code: "INVALID_INPUT",
|
|
3348
|
+
message: "Contract read output must be string, json, or xdr.",
|
|
3349
|
+
docs: "docs/smart-contracts.md#read"
|
|
3350
|
+
});
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
async function writeOperationReceipt(context, args) {
|
|
3354
|
+
const eventLog = join(context.config.storage.logsDir, "events.jsonl");
|
|
3355
|
+
const profile = resolveNetworkProfile("testnet", context.config.profiles);
|
|
3356
|
+
const { path: receiptPath } = await writeReceipt(context.config.storage.receiptsDir, {
|
|
3357
|
+
command: args.command,
|
|
3358
|
+
profile: "testnet",
|
|
3359
|
+
networkPassphrase: profile.networkPassphrase,
|
|
3360
|
+
realFunds: false,
|
|
3361
|
+
operation: args.operation,
|
|
3362
|
+
policyDecision: args.policyDecision
|
|
3363
|
+
? { status: args.policyDecision.status, matchedRules: args.policyDecision.matchedRules }
|
|
3364
|
+
: { status: "allowed", matchedRules: ["testnet_operation"] },
|
|
3365
|
+
transaction: args.transaction,
|
|
3366
|
+
...(args.transaction.ledger === undefined ? {} : { ledger: { confirmedLedger: args.transaction.ledger } }),
|
|
3367
|
+
eventLog
|
|
3368
|
+
});
|
|
3369
|
+
await appendEvent(eventLog, {
|
|
3370
|
+
event: "receipt_written",
|
|
3371
|
+
status: "success",
|
|
3372
|
+
command: args.command,
|
|
3373
|
+
profile: "testnet",
|
|
3374
|
+
data: { receiptPath }
|
|
3375
|
+
});
|
|
3376
|
+
return receiptPath;
|
|
3377
|
+
}
|
|
3378
|
+
async function writeBlendReceipt(context, args) {
|
|
3379
|
+
const eventLog = join(context.config.storage.logsDir, "events.jsonl");
|
|
3380
|
+
const profile = resolveNetworkProfile("testnet", context.config.profiles);
|
|
3381
|
+
const { path: receiptPath } = await writeReceipt(context.config.storage.receiptsDir, {
|
|
3382
|
+
command: args.command,
|
|
3383
|
+
profile: "testnet",
|
|
3384
|
+
networkPassphrase: profile.networkPassphrase,
|
|
3385
|
+
realFunds: false,
|
|
3386
|
+
operation: args.operation,
|
|
3387
|
+
policyDecision: {
|
|
3388
|
+
status: args.policyDecision.status,
|
|
3389
|
+
matchedRules: args.policyDecision.matchedRules
|
|
3390
|
+
},
|
|
3391
|
+
transaction: args.transaction,
|
|
3392
|
+
...(args.transaction.ledger === undefined ? {} : { ledger: { confirmedLedger: args.transaction.ledger } }),
|
|
3393
|
+
eventLog
|
|
3394
|
+
});
|
|
3395
|
+
await appendEvent(eventLog, {
|
|
3396
|
+
event: "receipt_written",
|
|
3397
|
+
status: "success",
|
|
3398
|
+
command: args.command,
|
|
3399
|
+
profile: "testnet",
|
|
3400
|
+
data: { receiptPath }
|
|
3401
|
+
});
|
|
3402
|
+
return receiptPath;
|
|
3403
|
+
}
|
|
3404
|
+
async function writeSubmittedXdrReceipt(context, args) {
|
|
3405
|
+
const eventLog = join(context.config.storage.logsDir, "events.jsonl");
|
|
3406
|
+
const { path: receiptPath } = await writeReceipt(context.config.storage.receiptsDir, {
|
|
3407
|
+
command: args.command,
|
|
3408
|
+
profile: args.profile.name,
|
|
3409
|
+
networkPassphrase: args.profile.networkPassphrase,
|
|
3410
|
+
realFunds: args.profile.realFunds,
|
|
3411
|
+
operation: args.operation,
|
|
3412
|
+
policyDecision: {
|
|
3413
|
+
status: args.profile.realFunds ? "requires_approval" : "allowed",
|
|
3414
|
+
matchedRules: args.profile.realFunds
|
|
3415
|
+
? ["signed_xdr_external_wallet", "real_funds_acknowledged"]
|
|
3416
|
+
: ["signed_xdr_external_wallet"]
|
|
3417
|
+
},
|
|
3418
|
+
transaction: args.transaction,
|
|
3419
|
+
...(args.transaction.ledger === undefined ? {} : { ledger: { confirmedLedger: args.transaction.ledger } }),
|
|
3420
|
+
eventLog
|
|
3421
|
+
});
|
|
3422
|
+
await appendEvent(eventLog, {
|
|
3423
|
+
event: "receipt_written",
|
|
3424
|
+
status: "success",
|
|
3425
|
+
command: args.command,
|
|
3426
|
+
profile: args.profile.name,
|
|
3427
|
+
data: { receiptPath }
|
|
3428
|
+
});
|
|
3429
|
+
return receiptPath;
|
|
3430
|
+
}
|
|
3431
|
+
async function attachContractSubmissionReceipt(context, args) {
|
|
3432
|
+
if (args.network.toLowerCase() !== "testnet")
|
|
3433
|
+
return args.result;
|
|
3434
|
+
const transactionHash = extractStellarCliTransactionHash(`${args.result.stderr}\n${args.result.stdout}`);
|
|
3435
|
+
if (!transactionHash)
|
|
3436
|
+
return args.result;
|
|
3437
|
+
const receiptPath = await writeOperationReceipt(context, {
|
|
3438
|
+
command: args.command,
|
|
3439
|
+
operation: args.operation,
|
|
3440
|
+
transaction: {
|
|
3441
|
+
hash: transactionHash,
|
|
3442
|
+
successful: true
|
|
3443
|
+
}
|
|
3444
|
+
});
|
|
3445
|
+
return { ...args.result, transactionHash, receiptPath };
|
|
3446
|
+
}
|
|
3447
|
+
function extractStellarCliTransactionHash(output) {
|
|
3448
|
+
return parseStellarCliTransactionHash(output);
|
|
3449
|
+
}
|
|
3450
|
+
async function writeLocalReport(context, outputPath) {
|
|
3451
|
+
const path = resolvePath(outputPath);
|
|
3452
|
+
await mkdir(dirname(path), { recursive: true });
|
|
3453
|
+
const report = await buildLocalReport(context);
|
|
3454
|
+
await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
|
|
3455
|
+
return { path, report };
|
|
3456
|
+
}
|
|
3457
|
+
async function buildLocalReport(context) {
|
|
3458
|
+
const receiptPaths = await listReceipts(context.config.storage.receiptsDir);
|
|
3459
|
+
const receipts = await Promise.all(receiptPaths.map(async (receiptPath) => ({
|
|
3460
|
+
path: receiptPath,
|
|
3461
|
+
receipt: await readReceipt(receiptPath)
|
|
3462
|
+
})));
|
|
3463
|
+
const wallets = await Promise.all(["agent", "merchant", "auditor"].map(async (name) => {
|
|
3464
|
+
try {
|
|
3465
|
+
const wallet = await loadWallet(context.config, name);
|
|
3466
|
+
return { name, exists: true, publicKey: wallet.publicKey };
|
|
3467
|
+
}
|
|
3468
|
+
catch {
|
|
3469
|
+
return { name, exists: false };
|
|
3470
|
+
}
|
|
3471
|
+
}));
|
|
3472
|
+
return {
|
|
3473
|
+
schemaVersion: "stellar-agent.report.v1",
|
|
3474
|
+
generatedAt: new Date().toISOString(),
|
|
3475
|
+
profile: context.profileName,
|
|
3476
|
+
storage: context.config.storage,
|
|
3477
|
+
wallets,
|
|
3478
|
+
receipts,
|
|
3479
|
+
eventLog: join(context.config.storage.logsDir, "events.jsonl"),
|
|
3480
|
+
redactions: {
|
|
3481
|
+
secretKeysIncluded: false
|
|
3482
|
+
}
|
|
3483
|
+
};
|
|
3484
|
+
}
|
|
3485
|
+
function enableX402ForUrl(policy, url) {
|
|
3486
|
+
const host = new URL(url).host;
|
|
3487
|
+
if (!/^localhost(?::\d+)?$/.test(host) && !/^127\.0\.0\.1(?::\d+)?$/.test(host)) {
|
|
3488
|
+
return policy;
|
|
3489
|
+
}
|
|
3490
|
+
return {
|
|
3491
|
+
...policy,
|
|
3492
|
+
x402: {
|
|
3493
|
+
...policy.x402,
|
|
3494
|
+
enabled: true,
|
|
3495
|
+
allowDomains: Array.from(new Set([...policy.x402.allowDomains, host])),
|
|
3496
|
+
maxPricePerRequest: policy.x402.maxPricePerRequest || "1 XLM"
|
|
3497
|
+
}
|
|
3498
|
+
};
|
|
3499
|
+
}
|
|
3500
|
+
function resolveContractExecutionContext(context, network, options) {
|
|
3501
|
+
const realFunds = isRealFundsContractNetwork(network);
|
|
3502
|
+
const profile = realFunds ? context.config.profiles.mainnet : context.config.profiles.testnet;
|
|
3503
|
+
if (!profile) {
|
|
3504
|
+
throw new StellarAgentError({
|
|
3505
|
+
code: "PROFILE_NOT_FOUND",
|
|
3506
|
+
message: `${realFunds ? "Mainnet" : "Testnet"} profile is not configured.`
|
|
3507
|
+
});
|
|
3508
|
+
}
|
|
3509
|
+
if (realFunds) {
|
|
3510
|
+
if (!profile.enabled) {
|
|
3511
|
+
throw new StellarAgentError({
|
|
3512
|
+
code: "MAINNET_NOT_ENABLED",
|
|
3513
|
+
message: "Mainnet contract operations require mainnet enablement.",
|
|
3514
|
+
hint: "Run stellar-agent mainnet enable --i-understand-real-funds first.",
|
|
3515
|
+
docs: "docs/mainnet-safety.md#mainnet-contracts"
|
|
3516
|
+
});
|
|
3517
|
+
}
|
|
3518
|
+
if (options.mutating && (!options.allowRealFunds || !options.acknowledgeRealFunds)) {
|
|
3519
|
+
throw new StellarAgentError({
|
|
3520
|
+
code: "MAINNET_NOT_ENABLED",
|
|
3521
|
+
message: "Mainnet contract operations require --allow-real-funds and --i-understand-real-funds.",
|
|
3522
|
+
hint: "Use an external Stellar CLI identity or browser-wallet signing flow; stellar-agent will not import Mainnet secret keys.",
|
|
3523
|
+
docs: "docs/mainnet-safety.md#mainnet-contracts"
|
|
3524
|
+
});
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
return {
|
|
3528
|
+
realFunds,
|
|
3529
|
+
rpcUrl: profile.rpcUrl,
|
|
3530
|
+
networkPassphrase: profile.networkPassphrase
|
|
3531
|
+
};
|
|
3532
|
+
}
|
|
3533
|
+
function isRealFundsContractNetwork(network) {
|
|
3534
|
+
return ["mainnet", "public", "pubnet"].includes(network.toLowerCase());
|
|
3535
|
+
}
|
|
3536
|
+
async function resolveContractSource(context, source, options = {}) {
|
|
3537
|
+
if (options.realFunds) {
|
|
3538
|
+
if (/^S[A-Z2-7]{55}$/.test(source)) {
|
|
3539
|
+
throw new StellarAgentError({
|
|
3540
|
+
code: "SECRET_KEY_BLOCKED",
|
|
3541
|
+
message: "Raw secret keys are not accepted for Mainnet contract operations.",
|
|
3542
|
+
hint: "Use a Stellar CLI identity or browser-wallet signing flow for Mainnet.",
|
|
3543
|
+
docs: "docs/mainnet-safety.md#mainnet-contracts"
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
try {
|
|
3547
|
+
const wallet = await loadWalletPublic(context.config, source);
|
|
3548
|
+
if (wallet.hasSecret) {
|
|
3549
|
+
throw new StellarAgentError({
|
|
3550
|
+
code: "MAINNET_NOT_ENABLED",
|
|
3551
|
+
message: "Local generated Testnet wallets cannot be used for Mainnet contract operations.",
|
|
3552
|
+
hint: "Use a Stellar CLI identity or a watch-only Mainnet public wallet.",
|
|
3553
|
+
docs: "docs/mainnet-safety.md#mainnet-contracts"
|
|
3554
|
+
});
|
|
3555
|
+
}
|
|
3556
|
+
return wallet.publicKey;
|
|
3557
|
+
}
|
|
3558
|
+
catch (error) {
|
|
3559
|
+
if (error instanceof StellarAgentError && error.code !== "WALLET_NOT_FOUND")
|
|
3560
|
+
throw error;
|
|
3561
|
+
}
|
|
3562
|
+
return source;
|
|
3563
|
+
}
|
|
3564
|
+
if (/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(source)) {
|
|
3565
|
+
try {
|
|
3566
|
+
const wallet = await loadWallet(context.config, source);
|
|
3567
|
+
return wallet.secretKey;
|
|
3568
|
+
}
|
|
3569
|
+
catch {
|
|
3570
|
+
return source;
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
return source;
|
|
3574
|
+
}
|
|
3575
|
+
async function resolvePaymentSourcePublicKey(context, source, options = {}) {
|
|
3576
|
+
if (/^G[A-Z2-7]{55}$/.test(source))
|
|
3577
|
+
return source;
|
|
3578
|
+
if (options.realFunds && /^S[A-Z2-7]{55}$/.test(source)) {
|
|
3579
|
+
throw new StellarAgentError({
|
|
3580
|
+
code: "SECRET_KEY_BLOCKED",
|
|
3581
|
+
message: "Raw secret keys are not accepted for Mainnet payment-XDR workflows.",
|
|
3582
|
+
hint: "Import a watch-only Mainnet public wallet or pass a raw public key.",
|
|
3583
|
+
docs: "docs/mainnet-safety.md#signed-xdr-submission"
|
|
3584
|
+
});
|
|
3585
|
+
}
|
|
3586
|
+
const wallet = await loadWalletPublic(context.config, source);
|
|
3587
|
+
if (options.realFunds && wallet.hasSecret) {
|
|
3588
|
+
throw new StellarAgentError({
|
|
3589
|
+
code: "MAINNET_NOT_ENABLED",
|
|
3590
|
+
message: "Local generated Testnet wallets cannot be used for Mainnet payment-XDR workflows.",
|
|
3591
|
+
hint: "Import a watch-only Mainnet public wallet or pass a raw public key.",
|
|
3592
|
+
docs: "docs/mainnet-safety.md#signed-xdr-submission"
|
|
3593
|
+
});
|
|
3594
|
+
}
|
|
3595
|
+
return wallet.publicKey;
|
|
3596
|
+
}
|
|
3597
|
+
if (isCliEntrypoint()) {
|
|
3598
|
+
const argv = process.argv[2] === "--" ? [process.argv[0] ?? "node", process.argv[1] ?? "stellar-agent", ...process.argv.slice(3)] : process.argv;
|
|
3599
|
+
await buildProgram().parseAsync(argv);
|
|
3600
|
+
}
|
|
3601
|
+
export function isCliEntrypoint(argvPath = process.argv[1], moduleUrl = import.meta.url) {
|
|
3602
|
+
if (!argvPath)
|
|
3603
|
+
return false;
|
|
3604
|
+
return realpathSync(argvPath) === realpathSync(fileURLToPath(moduleUrl));
|
|
3605
|
+
}
|
|
3606
|
+
//# sourceMappingURL=index.js.map
|