perp-cli 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -25
- package/dist/_stderr-filter.d.ts +0 -0
- package/dist/_stderr-filter.js +30 -0
- package/dist/agent-wallet/expiry.d.ts +15 -0
- package/dist/agent-wallet/expiry.js +26 -0
- package/dist/agent-wallet/lighter-keystore.d.ts +34 -0
- package/dist/agent-wallet/lighter-keystore.js +232 -0
- package/dist/agent-wallet/passphrase.d.ts +13 -0
- package/dist/agent-wallet/passphrase.js +54 -0
- package/dist/agent-wallet/signing-strategy.d.ts +67 -0
- package/dist/agent-wallet/signing-strategy.js +51 -0
- package/dist/agent-wallet/store.d.ts +37 -0
- package/dist/agent-wallet/store.js +172 -0
- package/dist/api/public/_http.d.ts +15 -0
- package/dist/api/public/_http.js +24 -0
- package/dist/api/public/hyperliquid.js +36 -24
- package/dist/api/public/lighter.d.ts +7 -1
- package/dist/api/public/lighter.js +85 -37
- package/dist/api/public/pacifica.js +47 -18
- package/dist/bridge-engine.js +102 -49
- package/dist/cli-spec.js +1 -2
- package/dist/commands/account.js +2 -500
- package/dist/commands/agent.d.ts +8 -2
- package/dist/commands/agent.js +1903 -366
- package/dist/commands/alerts.js +3 -1
- package/dist/commands/arb/index.js +37 -7
- package/dist/commands/arb-auto.js +44 -11
- package/dist/commands/arb.js +83 -34
- package/dist/commands/bot.d.ts +1 -1
- package/dist/commands/bot.js +18 -14
- package/dist/commands/bridge.d.ts +1 -1
- package/dist/commands/bridge.js +4 -4
- package/dist/commands/funds.d.ts +1 -1
- package/dist/commands/funds.js +18 -68
- package/dist/commands/jobs.d.ts +1 -1
- package/dist/commands/jobs.js +2 -2
- package/dist/commands/manage.d.ts +1 -1
- package/dist/commands/manage.js +12 -84
- package/dist/commands/plan.d.ts +1 -1
- package/dist/commands/plan.js +2 -2
- package/dist/commands/portfolio.d.ts +19 -3
- package/dist/commands/portfolio.js +747 -163
- package/dist/commands/rebalance.d.ts +1 -1
- package/dist/commands/rebalance.js +18 -12
- package/dist/commands/risk.js +2 -6
- package/dist/commands/settings.js +30 -53
- package/dist/commands/trade.js +16 -15
- package/dist/commands/wallet.d.ts +2 -1
- package/dist/commands/wallet.js +756 -28
- package/dist/errors.d.ts +64 -0
- package/dist/errors.js +82 -2
- package/dist/event-stream.js +12 -1
- package/dist/exchanges/aster-typed-data.d.ts +156 -0
- package/dist/exchanges/aster-typed-data.js +189 -0
- package/dist/exchanges/aster.d.ts +60 -11
- package/dist/exchanges/aster.js +278 -136
- package/dist/exchanges/hyperliquid.d.ts +14 -0
- package/dist/exchanges/hyperliquid.js +84 -34
- package/dist/exchanges/lighter-spot.js +14 -3
- package/dist/exchanges/lighter.d.ts +66 -0
- package/dist/exchanges/lighter.js +199 -27
- package/dist/exchanges/pacifica-typed-data.d.ts +75 -0
- package/dist/exchanges/pacifica-typed-data.js +74 -0
- package/dist/exchanges/pacifica.d.ts +29 -1
- package/dist/exchanges/pacifica.js +94 -6
- package/dist/funding/rates.js +59 -21
- package/dist/guardrail/perp-guardrail.js +9 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +255 -360
- package/dist/jobs.js +3 -1
- package/dist/mcp-server.js +85 -83
- package/dist/position-history.js +47 -4
- package/dist/settings.d.ts +67 -3
- package/dist/settings.js +1 -2
- package/dist/signer/ows-evm.d.ts +14 -0
- package/dist/signer/ows-evm.js +28 -9
- package/dist/smart-order.d.ts +8 -2
- package/dist/smart-order.js +46 -11
- package/dist/strategies/funding-arb.js +29 -8
- package/dist/trade-validator.js +30 -5
- package/dist/utils.d.ts +10 -0
- package/dist/utils.js +19 -0
- package/package.json +2 -2
- package/skills/perp-cli/SKILL.md +23 -14
- package/skills/perp-cli/references/agent-operations.md +6 -4
- package/skills/perp-cli/references/commands.md +55 -34
- package/skills/perp-cli/scripts/preflight.sh +4 -4
- package/skills/perp-cli/scripts/spot-perp-scan.sh +2 -2
- package/dist/__tests__/alert-logic.test.d.ts +0 -1
- package/dist/__tests__/alert-logic.test.js +0 -107
- package/dist/__tests__/arb-auto-3dex.test.d.ts +0 -1
- package/dist/__tests__/arb-auto-3dex.test.js +0 -397
- package/dist/__tests__/arb-history-stats.test.d.ts +0 -1
- package/dist/__tests__/arb-history-stats.test.js +0 -176
- package/dist/__tests__/arb-logic.test.d.ts +0 -1
- package/dist/__tests__/arb-logic.test.js +0 -84
- package/dist/__tests__/arb-manage.test.d.ts +0 -1
- package/dist/__tests__/arb-manage.test.js +0 -253
- package/dist/__tests__/arb-new-features.test.d.ts +0 -1
- package/dist/__tests__/arb-new-features.test.js +0 -457
- package/dist/__tests__/arb-sizing.test.d.ts +0 -1
- package/dist/__tests__/arb-sizing.test.js +0 -48
- package/dist/__tests__/arb-state.test.d.ts +0 -1
- package/dist/__tests__/arb-state.test.js +0 -284
- package/dist/__tests__/arb-userflow.test.d.ts +0 -1
- package/dist/__tests__/arb-userflow.test.js +0 -945
- package/dist/__tests__/arb-utils.test.d.ts +0 -1
- package/dist/__tests__/arb-utils.test.js +0 -264
- package/dist/__tests__/bot-conditions.test.d.ts +0 -1
- package/dist/__tests__/bot-conditions.test.js +0 -341
- package/dist/__tests__/client-id-tracker.test.d.ts +0 -1
- package/dist/__tests__/client-id-tracker.test.js +0 -137
- package/dist/__tests__/commands/new-atomic-commands.test.d.ts +0 -1
- package/dist/__tests__/commands/new-atomic-commands.test.js +0 -502
- package/dist/__tests__/commands/order-intent.test.d.ts +0 -1
- package/dist/__tests__/commands/order-intent.test.js +0 -600
- package/dist/__tests__/commands/trade-commands.test.d.ts +0 -1
- package/dist/__tests__/commands/trade-commands.test.js +0 -821
- package/dist/__tests__/config.test.d.ts +0 -1
- package/dist/__tests__/config.test.js +0 -86
- package/dist/__tests__/cross-chain-margin.test.d.ts +0 -1
- package/dist/__tests__/cross-chain-margin.test.js +0 -287
- package/dist/__tests__/dex-asset-map.test.d.ts +0 -1
- package/dist/__tests__/dex-asset-map.test.js +0 -191
- package/dist/__tests__/errors.test.d.ts +0 -1
- package/dist/__tests__/errors.test.js +0 -110
- package/dist/__tests__/event-stream.test.d.ts +0 -1
- package/dist/__tests__/event-stream.test.js +0 -276
- package/dist/__tests__/exchanges/interface.test.d.ts +0 -1
- package/dist/__tests__/exchanges/interface.test.js +0 -132
- package/dist/__tests__/exchanges/mock-adapter.d.ts +0 -69
- package/dist/__tests__/exchanges/mock-adapter.js +0 -137
- package/dist/__tests__/execution-log.test.d.ts +0 -1
- package/dist/__tests__/execution-log.test.js +0 -106
- package/dist/__tests__/funding-calc.test.d.ts +0 -1
- package/dist/__tests__/funding-calc.test.js +0 -71
- package/dist/__tests__/funding-history.test.d.ts +0 -1
- package/dist/__tests__/funding-history.test.js +0 -343
- package/dist/__tests__/funding-rates.test.d.ts +0 -1
- package/dist/__tests__/funding-rates.test.js +0 -342
- package/dist/__tests__/funding.test.d.ts +0 -1
- package/dist/__tests__/funding.test.js +0 -173
- package/dist/__tests__/gap-logic.test.d.ts +0 -1
- package/dist/__tests__/gap-logic.test.js +0 -43
- package/dist/__tests__/hip3-dex.test.d.ts +0 -1
- package/dist/__tests__/hip3-dex.test.js +0 -234
- package/dist/__tests__/integration/agent-features.integration.test.d.ts +0 -1
- package/dist/__tests__/integration/agent-features.integration.test.js +0 -553
- package/dist/__tests__/integration/atomic-commands.integration.test.d.ts +0 -13
- package/dist/__tests__/integration/atomic-commands.integration.test.js +0 -246
- package/dist/__tests__/integration/bridge-simulation.integration.test.d.ts +0 -1
- package/dist/__tests__/integration/bridge-simulation.integration.test.js +0 -453
- package/dist/__tests__/integration/bridge-strict.integration.test.d.ts +0 -1
- package/dist/__tests__/integration/bridge-strict.integration.test.js +0 -812
- package/dist/__tests__/integration/bridge.integration.test.d.ts +0 -1
- package/dist/__tests__/integration/bridge.integration.test.js +0 -309
- package/dist/__tests__/integration/cli-e2e.integration.test.d.ts +0 -1
- package/dist/__tests__/integration/cli-e2e.integration.test.js +0 -202
- package/dist/__tests__/integration/dex-arb.integration.test.d.ts +0 -1
- package/dist/__tests__/integration/dex-arb.integration.test.js +0 -116
- package/dist/__tests__/integration/envelope-consistency.integration.test.d.ts +0 -13
- package/dist/__tests__/integration/envelope-consistency.integration.test.js +0 -205
- package/dist/__tests__/integration/hip3-dex.integration.test.d.ts +0 -1
- package/dist/__tests__/integration/hip3-dex.integration.test.js +0 -147
- package/dist/__tests__/integration/hyperliquid.integration.test.d.ts +0 -1
- package/dist/__tests__/integration/hyperliquid.integration.test.js +0 -79
- package/dist/__tests__/integration/lighter.integration.test.d.ts +0 -1
- package/dist/__tests__/integration/lighter.integration.test.js +0 -53
- package/dist/__tests__/integration/new-commands-e2e.integration.test.d.ts +0 -9
- package/dist/__tests__/integration/new-commands-e2e.integration.test.js +0 -236
- package/dist/__tests__/integration/order-verification.integration.test.d.ts +0 -1
- package/dist/__tests__/integration/order-verification.integration.test.js +0 -321
- package/dist/__tests__/integration/pacifica.integration.test.d.ts +0 -1
- package/dist/__tests__/integration/pacifica.integration.test.js +0 -75
- package/dist/__tests__/integration/response-shapes.integration.test.d.ts +0 -1
- package/dist/__tests__/integration/response-shapes.integration.test.js +0 -278
- package/dist/__tests__/liquidity.test.d.ts +0 -1
- package/dist/__tests__/liquidity.test.js +0 -225
- package/dist/__tests__/plan-executor.test.d.ts +0 -1
- package/dist/__tests__/plan-executor.test.js +0 -314
- package/dist/__tests__/position-history.test.d.ts +0 -1
- package/dist/__tests__/position-history.test.js +0 -367
- package/dist/__tests__/retry.test.d.ts +0 -1
- package/dist/__tests__/retry.test.js +0 -310
- package/dist/__tests__/risk-assessment.test.d.ts +0 -1
- package/dist/__tests__/risk-assessment.test.js +0 -288
- package/dist/__tests__/security-adversarial.test.d.ts +0 -1
- package/dist/__tests__/security-adversarial.test.js +0 -574
- package/dist/__tests__/strategies.test.d.ts +0 -1
- package/dist/__tests__/strategies.test.js +0 -539
- package/dist/__tests__/trade-execution.test.d.ts +0 -1
- package/dist/__tests__/trade-execution.test.js +0 -129
- package/dist/__tests__/trade-validator.test.d.ts +0 -1
- package/dist/__tests__/trade-validator.test.js +0 -655
- package/dist/__tests__/utils.test.d.ts +0 -1
- package/dist/__tests__/utils.test.js +0 -76
- package/dist/arb/reconcile.d.ts +0 -28
- package/dist/arb/reconcile.js +0 -162
- package/dist/commands/alert.d.ts +0 -2
- package/dist/commands/alert.js +0 -421
- package/dist/commands/analytics.d.ts +0 -3
- package/dist/commands/analytics.js +0 -311
- package/dist/commands/arb-manage.d.ts +0 -5
- package/dist/commands/arb-manage.js +0 -5
- package/dist/commands/dashboard.d.ts +0 -3
- package/dist/commands/dashboard.js +0 -176
- package/dist/commands/deposit.d.ts +0 -4
- package/dist/commands/deposit.js +0 -573
- package/dist/commands/dex.d.ts +0 -3
- package/dist/commands/dex.js +0 -120
- package/dist/commands/env.d.ts +0 -2
- package/dist/commands/env.js +0 -136
- package/dist/commands/funding.d.ts +0 -3
- package/dist/commands/funding.js +0 -554
- package/dist/commands/gap.d.ts +0 -2
- package/dist/commands/gap.js +0 -305
- package/dist/commands/health.d.ts +0 -2
- package/dist/commands/health.js +0 -67
- package/dist/commands/multileg.d.ts +0 -16
- package/dist/commands/multileg.js +0 -174
- package/dist/commands/ows.d.ts +0 -2
- package/dist/commands/ows.js +0 -887
- package/dist/commands/pnl.d.ts +0 -12
- package/dist/commands/pnl.js +0 -243
- package/dist/commands/run.d.ts +0 -3
- package/dist/commands/run.js +0 -202
- package/dist/commands/stream.d.ts +0 -5
- package/dist/commands/stream.js +0 -200
- package/dist/commands/withdraw.d.ts +0 -3
- package/dist/commands/withdraw.js +0 -187
- package/dist/exchanges/edgex.d.ts +0 -62
- package/dist/exchanges/edgex.js +0 -552
- package/dist/exchanges/nado.d.ts +0 -52
- package/dist/exchanges/nado.js +0 -223
- package/dist/funding/index.d.ts +0 -3
- package/dist/funding/index.js +0 -3
- package/dist/retry.d.ts +0 -74
- package/dist/retry.js +0 -129
- package/dist/ws/market-feed.d.ts +0 -74
- package/dist/ws/market-feed.js +0 -227
package/dist/commands/agent.js
CHANGED
|
@@ -1,403 +1,1940 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
+
import { printJson, jsonOk } from "../utils.js";
|
|
5
|
+
import { PerpError } from "../errors.js";
|
|
4
6
|
import chalk from "chalk";
|
|
7
|
+
import bs58 from "bs58";
|
|
8
|
+
import { loadSettings } from "../settings.js";
|
|
9
|
+
import { getAgent, setAgent, deleteAgent, listAgents, acquireLock, releaseLock } from "../agent-wallet/store.js";
|
|
10
|
+
import { resolvePassphrase } from "../agent-wallet/passphrase.js";
|
|
11
|
+
import { OwsEvmSigner } from "../signer/ows-evm.js";
|
|
12
|
+
import { OwsSolanaSigner } from "../signer/ows-solana.js";
|
|
13
|
+
import { loadOws } from "../signer/ows-loader.js";
|
|
14
|
+
import { buildApproveAgentTypedData, buildDelAgentTypedData, buildApproveBuilderTypedData, } from "../exchanges/aster-typed-data.js";
|
|
15
|
+
/**
|
|
16
|
+
* Emit a structured JSON error envelope to stdout and exit non-zero.
|
|
17
|
+
* Stdout (not stderr) so machine consumers can read the envelope from a
|
|
18
|
+
* single stream — matches the rest of the CLI's --json error path.
|
|
19
|
+
*/
|
|
20
|
+
function reportErrorAndExit(err) {
|
|
21
|
+
const ts = new Date().toISOString();
|
|
22
|
+
const envelope = err instanceof PerpError
|
|
23
|
+
? { ok: false, error: { ...err.structured }, meta: { timestamp: ts } }
|
|
24
|
+
: { ok: false, error: { code: "UNKNOWN", message: err instanceof Error ? err.message : String(err) }, meta: { timestamp: ts } };
|
|
25
|
+
process.stdout.write(JSON.stringify(envelope) + "\n");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
5
28
|
const _require = createRequire(import.meta.url);
|
|
6
29
|
const _pkg = _require("../../package.json");
|
|
7
|
-
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (pipeMatch) {
|
|
23
|
-
const values = pipeMatch[1].split(/\s*[|,]\s*/).map(v => v.trim()).filter(Boolean);
|
|
24
|
-
if (values.length >= 2 && values.every(v => v.length < 20))
|
|
25
|
-
return values;
|
|
26
|
-
}
|
|
27
|
-
return undefined;
|
|
30
|
+
// ── Per-DEX verify implementations ───────────────────────────────────────
|
|
31
|
+
async function verifyAster(_opts) {
|
|
32
|
+
// No-fallback policy (CLAUDE.md SSOT rule #2): Aster has no verified
|
|
33
|
+
// master-signing path for `/fapi/v3/agent`. The HypurrQuant_FE reference
|
|
34
|
+
// confirms Aster has no REST endpoint to list approved agents at all —
|
|
35
|
+
// master signing is used only for one-time approveAgent/approveBuilder.
|
|
36
|
+
//
|
|
37
|
+
// Returning local cache as if it were verified live state would be a
|
|
38
|
+
// fallback (silent substitution masquerading as success). The SSOT rule
|
|
39
|
+
// bans that pattern: failure must propagate, not get hidden behind a
|
|
40
|
+
// synthesized success envelope. Throw NOT_IMPLEMENTED with explicit
|
|
41
|
+
// remediation pointing to local-cache surfaces.
|
|
42
|
+
throw new PerpError("NOT_IMPLEMENTED", "Aster live agent verify is not supported (no verified master-signing path; FE reference also lacks this endpoint).", {
|
|
43
|
+
remediation: "Use 'perp wallet agent list aster' to inspect local cache, or 'perp wallet agent rotate aster <name>' to refresh on-chain.",
|
|
44
|
+
});
|
|
28
45
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Verify Hyperliquid agent registration.
|
|
48
|
+
*
|
|
49
|
+
* Cross-check (AC-28, v3.4): When agentName provided AND persisted in
|
|
50
|
+
* settings.agents.hyperliquid[name], asserts live response contains expected
|
|
51
|
+
* agentEvmAddress. Mismatch → meta.warnings populated (non-fatal).
|
|
52
|
+
*/
|
|
53
|
+
async function verifyHyperliquid(opts) {
|
|
54
|
+
const settings = loadSettings();
|
|
55
|
+
// Resolve master EVM address: flag > named agent's userEvmAddress > first registered agent
|
|
56
|
+
let masterAddress = opts.masterAddress;
|
|
57
|
+
if (!masterAddress) {
|
|
58
|
+
const hlMap = settings.agents?.hyperliquid;
|
|
59
|
+
if (hlMap) {
|
|
60
|
+
if (opts.agentName && hlMap[opts.agentName]?.userEvmAddress) {
|
|
61
|
+
masterAddress = hlMap[opts.agentName].userEvmAddress;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const first = Object.values(hlMap)[0];
|
|
65
|
+
if (first?.userEvmAddress)
|
|
66
|
+
masterAddress = first.userEvmAddress;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!masterAddress) {
|
|
71
|
+
throw new PerpError("INVALID_PARAMS", "Master EVM address required for Hyperliquid verify. Use --master-address.", {
|
|
72
|
+
remediation: "perp wallet agent verify hyperliquid --master-address 0xYOUR_ADDRESS",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const user = masterAddress.toLowerCase();
|
|
76
|
+
const resp = await fetch("https://api.hyperliquid.xyz/info", {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: { "Content-Type": "application/json" },
|
|
79
|
+
body: JSON.stringify({ type: "extraAgents", user }),
|
|
42
80
|
});
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
81
|
+
if (!resp.ok) {
|
|
82
|
+
throw new PerpError("EXCHANGE_ERROR", `Hyperliquid verify failed: HTTP ${resp.status}`, {
|
|
83
|
+
remediation: "Check master EVM address. Run: perp wallet agent verify hyperliquid --master-address 0xADDR",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const data = await resp.json();
|
|
87
|
+
const items = Array.isArray(data) ? data : [];
|
|
88
|
+
const warnings = [];
|
|
89
|
+
// Cross-check: AC-28 — verify persisted agentEvmAddress matches live response
|
|
90
|
+
if (opts.agentName) {
|
|
91
|
+
const agentMeta = settings.agents?.hyperliquid?.[opts.agentName];
|
|
92
|
+
if (agentMeta?.agentEvmAddress) {
|
|
93
|
+
const expected = agentMeta.agentEvmAddress.toLowerCase();
|
|
94
|
+
const found = items.some((item) => typeof item === "object" &&
|
|
95
|
+
item !== null &&
|
|
96
|
+
"address" in item &&
|
|
97
|
+
typeof item.address === "string" &&
|
|
98
|
+
item.address.toLowerCase() === expected);
|
|
99
|
+
if (!found) {
|
|
100
|
+
warnings.push(`Agent "${opts.agentName}" (expected address ${agentMeta.agentEvmAddress}) not found in Hyperliquid live response.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { registered: items.length > 0, count: items.length, items, warnings: warnings.length > 0 ? warnings : undefined };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Verify Pacifica agent registration.
|
|
108
|
+
*
|
|
109
|
+
* NOTE (v3.3 limitation): Cross-check semantics from AC-23 are inactive for
|
|
110
|
+
* this DEX because settings.agents.pacifica type slot does not exist yet
|
|
111
|
+
* (AgentsByExchange has only `aster?`). When Phase 2c extends AgentsByExchange
|
|
112
|
+
* + AgentMeta with per-DEX fields (accountIndex, publicKey, etc.), the
|
|
113
|
+
* cross-check branch below becomes live.
|
|
114
|
+
*/
|
|
115
|
+
async function verifyPacifica(opts) {
|
|
116
|
+
const settings = loadSettings();
|
|
117
|
+
const masterName = opts.master ?? settings.owsActiveWallet;
|
|
118
|
+
if (!masterName) {
|
|
119
|
+
throw new PerpError("INVALID_PARAMS", "Master wallet name required for Pacifica verify. Use --master.", {
|
|
120
|
+
remediation: "perp wallet agent verify pacifica --master <walletName> --passphrase $PP",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const passphrase = opts.passphrase ?? "";
|
|
124
|
+
const solanaSigner = OwsSolanaSigner.create(masterName, passphrase);
|
|
125
|
+
const account = solanaSigner.getPublicKeyBase58();
|
|
126
|
+
const timestamp = Date.now();
|
|
127
|
+
const expiryWindow = 5000;
|
|
128
|
+
const type = "list_api_keys";
|
|
129
|
+
// Canonical JSON: sort keys lexicographically, no whitespace
|
|
130
|
+
const signHeader = { expiry_window: expiryWindow, timestamp, type };
|
|
131
|
+
const canonicalJson = JSON.stringify(signHeader);
|
|
132
|
+
// Sign canonical JSON with Ed25519 (OWS Solana signer)
|
|
133
|
+
const msgBytes = new TextEncoder().encode(canonicalJson);
|
|
134
|
+
const sigBytes = await solanaSigner.signMessage(msgBytes);
|
|
135
|
+
// Base58-encode the signature
|
|
136
|
+
const sigBase58 = bs58.encode(sigBytes);
|
|
137
|
+
const body = {
|
|
138
|
+
account,
|
|
139
|
+
agent_wallet: null,
|
|
140
|
+
signature: sigBase58,
|
|
141
|
+
timestamp,
|
|
142
|
+
expiry_window: expiryWindow,
|
|
143
|
+
type,
|
|
63
144
|
};
|
|
145
|
+
const resp = await fetch("https://api.pacifica.fi/api/v1/account/api_keys", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
body: JSON.stringify(body),
|
|
149
|
+
});
|
|
150
|
+
if (!resp.ok) {
|
|
151
|
+
const body = await resp.text().catch(() => "");
|
|
152
|
+
throw new PerpError("EXCHANGE_ERROR", `Pacifica verify failed: HTTP ${resp.status}: ${body.slice(0, 200)}`, {
|
|
153
|
+
remediation: "Check master wallet name and passphrase. Run: perp wallet agent verify pacifica --master <name> --passphrase $PP",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const data = await resp.json();
|
|
157
|
+
const apiKeys = (data &&
|
|
158
|
+
typeof data === "object" &&
|
|
159
|
+
"data" in data &&
|
|
160
|
+
typeof data.data === "object" &&
|
|
161
|
+
data.data !== null &&
|
|
162
|
+
"api_keys" in data.data)
|
|
163
|
+
? data.data.api_keys
|
|
164
|
+
: [];
|
|
165
|
+
const items = Array.isArray(apiKeys) ? apiKeys : [];
|
|
166
|
+
return { registered: items.length > 0, count: items.length, items };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Verify Lighter agent registration.
|
|
170
|
+
*
|
|
171
|
+
* NOTE (v3.3 limitation): Cross-check semantics from AC-23 are inactive for
|
|
172
|
+
* this DEX because settings.agents.lighter type slot does not exist yet
|
|
173
|
+
* (AgentsByExchange has only `aster?`). When Phase 2d extends AgentsByExchange
|
|
174
|
+
* + AgentMeta with per-DEX fields (accountIndex, publicKey, etc.), the
|
|
175
|
+
* cross-check branch below becomes live.
|
|
176
|
+
*/
|
|
177
|
+
async function verifyLighter(opts) {
|
|
178
|
+
const settings = loadSettings();
|
|
179
|
+
const accountIndexStr = opts.accountIndex;
|
|
180
|
+
// Resolve accountIndex: flag > named agent > first registered agent
|
|
181
|
+
let accountIndex;
|
|
182
|
+
if (accountIndexStr !== undefined) {
|
|
183
|
+
accountIndex = parseInt(accountIndexStr, 10);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const ltMap = settings.agents?.lighter;
|
|
187
|
+
if (ltMap) {
|
|
188
|
+
if (opts.agentName && ltMap[opts.agentName]?.accountIndex !== undefined) {
|
|
189
|
+
accountIndex = ltMap[opts.agentName].accountIndex;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
const first = Object.values(ltMap)[0];
|
|
193
|
+
if (first?.accountIndex !== undefined)
|
|
194
|
+
accountIndex = first.accountIndex;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (accountIndex === undefined || isNaN(accountIndex)) {
|
|
199
|
+
throw new PerpError("INVALID_PARAMS", "Account index required for Lighter verify. Use --account-index.", {
|
|
200
|
+
remediation: "perp wallet agent verify lighter --account-index <n>",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const resp = await fetch(`https://mainnet.zklighter.elliot.ai/api/v1/apikeys?account_index=${accountIndex}`, { method: "GET" });
|
|
204
|
+
if (!resp.ok) {
|
|
205
|
+
throw new PerpError("EXCHANGE_ERROR", `Lighter verify failed: HTTP ${resp.status}`, {
|
|
206
|
+
remediation: `Check account index. Run: perp wallet agent verify lighter --account-index ${accountIndex}`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const data = await resp.json();
|
|
210
|
+
const apiKeys = (data &&
|
|
211
|
+
typeof data === "object" &&
|
|
212
|
+
"api_keys" in data)
|
|
213
|
+
? data.api_keys
|
|
214
|
+
: [];
|
|
215
|
+
const items = Array.isArray(apiKeys) ? apiKeys : [];
|
|
216
|
+
const warnings = [];
|
|
217
|
+
// Cross-check if agentName provided
|
|
218
|
+
if (opts.agentName) {
|
|
219
|
+
const ltAgents = settings.agents;
|
|
220
|
+
const ltMap = (ltAgents && typeof ltAgents === "object" && "lighter" in ltAgents)
|
|
221
|
+
? ltAgents.lighter
|
|
222
|
+
: undefined;
|
|
223
|
+
const agentMeta = ltMap?.[opts.agentName];
|
|
224
|
+
if (agentMeta?.publicKey) {
|
|
225
|
+
const expected = agentMeta.publicKey;
|
|
226
|
+
const found = items.some((item) => typeof item === "object" &&
|
|
227
|
+
item !== null &&
|
|
228
|
+
"public_key" in item &&
|
|
229
|
+
item.public_key === expected);
|
|
230
|
+
if (!found) {
|
|
231
|
+
warnings.push(`Agent "${opts.agentName}" (expected public_key ${agentMeta.publicKey}) not found in Lighter live response.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { registered: items.length > 0, count: items.length, items, warnings: warnings.length > 0 ? warnings : undefined };
|
|
64
236
|
}
|
|
65
|
-
|
|
66
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Register the per-DEX agent-wallet management subtree (`approve` /
|
|
239
|
+
* `list` / `revoke` / `rotate` / `verify`) under the supplied parent
|
|
240
|
+
* `wallet` command. The function previously registered `agent` as a
|
|
241
|
+
* top-level command on `program`; the hard-cut consolidation in v0.11+
|
|
242
|
+
* relocates it to `wallet agent ...`.
|
|
243
|
+
*/
|
|
244
|
+
export function registerWalletAgentCommands(walletCmd, isJson) {
|
|
245
|
+
const agent = walletCmd
|
|
67
246
|
.command("agent")
|
|
68
|
-
.description("Agent-
|
|
69
|
-
// ── agent
|
|
247
|
+
.description("Agent wallet management — register/revoke/rotate/verify per-DEX delegation keys");
|
|
248
|
+
// ── agent approve <exchange> ── register an agent wallet ──────────────────
|
|
70
249
|
agent
|
|
71
|
-
.command("
|
|
72
|
-
.description("
|
|
73
|
-
.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
250
|
+
.command("approve <exchange>")
|
|
251
|
+
.description("Register an agent wallet for an exchange (Phase 2a/b/c/d: aster, hyperliquid, pacifica, lighter)")
|
|
252
|
+
.option("--master <name>", "Master OWS wallet name")
|
|
253
|
+
.option("--agent-name <name>", "Agent name (default: perp-cli-aster)")
|
|
254
|
+
.option("--expires-in <duration>", "Expiry: 30d, 90d, 180d, 1y, or ISO-8601 datetime (default: 90d)")
|
|
255
|
+
.option("--can-perp", "Allow perp trading (default: on)", true)
|
|
256
|
+
.option("--no-perp", "Disallow perp trading")
|
|
257
|
+
.option("--can-spot", "Allow spot trading (default: off)", false)
|
|
258
|
+
.option("--no-spot", "Disallow spot trading")
|
|
259
|
+
.option("--can-withdraw", "Allow withdrawals (default: off)", false)
|
|
260
|
+
.option("--no-withdraw", "Disallow withdrawals")
|
|
261
|
+
.option("--rotate", "Allow re-approving an existing agent name")
|
|
262
|
+
.option("--passphrase <pp>", "Master OWS passphrase (fallback: OWS_PASSPHRASE env / stdin)")
|
|
263
|
+
.option("--builder <addr>", "Builder address for optional builder approval")
|
|
264
|
+
.option("--max-fee-rate <bps>", "Max fee rate bps (required with --builder)")
|
|
265
|
+
.option("--builder-name <name>", "Optional builder name")
|
|
266
|
+
.option("--ip-whitelist <list>", "Comma-separated IP whitelist (pass empty string to include empty)")
|
|
267
|
+
.option("--api-key-index <n>", "Lighter slot index (4-254). Default: next free slot.")
|
|
268
|
+
.option("--json", "Machine-readable output")
|
|
269
|
+
.action(async (exchange, opts) => {
|
|
270
|
+
const useJson = opts.json ?? isJson();
|
|
271
|
+
// Normalize aliases
|
|
272
|
+
const exchangeNorm = exchange === "lt" ? "lighter"
|
|
273
|
+
: exchange === "hl" ? "hyperliquid"
|
|
274
|
+
: exchange === "pac" ? "pacifica"
|
|
275
|
+
: exchange === "ast" ? "aster"
|
|
276
|
+
: exchange;
|
|
277
|
+
// Phase 2d: Aster + HL + PAC + LT all implemented.
|
|
278
|
+
if (exchangeNorm !== "aster" &&
|
|
279
|
+
exchangeNorm !== "hyperliquid" &&
|
|
280
|
+
exchangeNorm !== "pacifica" &&
|
|
281
|
+
exchangeNorm !== "lighter") {
|
|
282
|
+
reportErrorAndExit(new PerpError("NOT_IMPLEMENTED", `${exchange} agent approve not implemented`, {
|
|
283
|
+
remediation: "Use one of: aster, hyperliquid, pacifica, lighter",
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
// ── Lighter approve branch (Phase 2d) ───────────────────────────────
|
|
287
|
+
if (exchangeNorm === "lighter") {
|
|
288
|
+
const masterName = opts.master ?? loadSettings().owsActiveWallet;
|
|
289
|
+
const agentName = opts.agentName ?? "perp-cli-lt";
|
|
290
|
+
const expiresIn = opts.expiresIn ?? "90d";
|
|
291
|
+
let expiresAt;
|
|
292
|
+
try {
|
|
293
|
+
expiresAt = parseExpiresIn(expiresIn);
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
297
|
+
reportErrorAndExit(new PerpError("INVALID_PARAMS", msg, {
|
|
298
|
+
remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
if (!masterName) {
|
|
302
|
+
reportErrorAndExit(new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
|
|
303
|
+
remediation: "Run: perp setup or use --master <name>",
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
// Slot validation: explicit value must be 4..254. Otherwise pick free slot.
|
|
307
|
+
let chosenSlot;
|
|
308
|
+
if (opts.apiKeyIndex !== undefined) {
|
|
309
|
+
const n = parseInt(opts.apiKeyIndex, 10);
|
|
310
|
+
if (!Number.isInteger(n) || n < 4 || n > 254) {
|
|
311
|
+
reportErrorAndExit(new PerpError("INVALID_PARAMS", `--api-key-index must be an integer in [4, 254]; got ${opts.apiKeyIndex}`, {
|
|
312
|
+
remediation: "Slots 0-3 are reserved by the Lighter frontend. Use 4-254.",
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
chosenSlot = n;
|
|
316
|
+
}
|
|
317
|
+
const passphrase = await resolvePassphrase({ flag: opts.passphrase });
|
|
318
|
+
if (passphrase === null) {
|
|
319
|
+
reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
|
|
320
|
+
remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
acquireLock("lighter");
|
|
325
|
+
try {
|
|
326
|
+
const result = await runLtApproveFlow({
|
|
327
|
+
masterName,
|
|
328
|
+
passphrase: passphrase ?? "",
|
|
329
|
+
agentName,
|
|
330
|
+
expiresAt,
|
|
331
|
+
expiresAtIso: expiresAt.toISOString(),
|
|
332
|
+
nowIso: new Date().toISOString(),
|
|
333
|
+
canPerp: opts.canPerp ?? true,
|
|
334
|
+
canSpot: opts.canSpot ?? false,
|
|
335
|
+
canWithdraw: opts.canWithdraw ?? false,
|
|
336
|
+
apiKeyIndex: chosenSlot,
|
|
337
|
+
});
|
|
338
|
+
if (useJson) {
|
|
339
|
+
printJson(jsonOk(result));
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
console.log(chalk.green.bold("\n Lighter Agent approved successfully!\n"));
|
|
343
|
+
console.log(` Name: ${chalk.cyan(agentName)}`);
|
|
344
|
+
console.log(` API Key Index: ${chalk.cyan(String(result.apiKeyIndex))}`);
|
|
345
|
+
console.log(` Public Key: ${chalk.cyan(result.publicKey)}`);
|
|
346
|
+
console.log(` Account Index: ${chalk.cyan(String(result.accountIndex))}`);
|
|
347
|
+
console.log(` Expires: ${chalk.cyan(expiresAt.toISOString())}`);
|
|
348
|
+
console.log();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
releaseLock("lighter");
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
reportErrorAndExit(err);
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// ── Pacifica approve branch (Phase 2c) ──────────────────────────────
|
|
361
|
+
if (exchangeNorm === "pacifica") {
|
|
362
|
+
const masterName = opts.master ?? loadSettings().owsActiveWallet;
|
|
363
|
+
const agentName = opts.agentName ?? "perp-cli-pac";
|
|
364
|
+
const expiresIn = opts.expiresIn ?? "90d";
|
|
365
|
+
let expiresAt;
|
|
366
|
+
try {
|
|
367
|
+
expiresAt = parseExpiresIn(expiresIn);
|
|
368
|
+
}
|
|
369
|
+
catch (e) {
|
|
370
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
371
|
+
reportErrorAndExit(new PerpError("INVALID_PARAMS", msg, {
|
|
372
|
+
remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
if (!masterName) {
|
|
376
|
+
reportErrorAndExit(new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
|
|
377
|
+
remediation: "Run: perp setup or use --master <name>",
|
|
378
|
+
}));
|
|
379
|
+
}
|
|
380
|
+
const passphrase = await resolvePassphrase({ flag: opts.passphrase });
|
|
381
|
+
if (passphrase === null) {
|
|
382
|
+
reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
|
|
383
|
+
remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
|
|
384
|
+
}));
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
acquireLock("pacifica");
|
|
388
|
+
try {
|
|
389
|
+
const result = await runPacApproveFlow({
|
|
390
|
+
masterName,
|
|
391
|
+
passphrase: passphrase ?? "",
|
|
392
|
+
agentName,
|
|
393
|
+
expiresAt,
|
|
394
|
+
expiresAtIso: expiresAt.toISOString(),
|
|
395
|
+
nowIso: new Date().toISOString(),
|
|
396
|
+
canPerp: opts.canPerp ?? true,
|
|
397
|
+
canSpot: opts.canSpot ?? false,
|
|
398
|
+
canWithdraw: opts.canWithdraw ?? false,
|
|
399
|
+
});
|
|
400
|
+
if (useJson) {
|
|
401
|
+
printJson(jsonOk(result));
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
console.log(chalk.green.bold("\n Pacifica Agent approved successfully!\n"));
|
|
405
|
+
console.log(` Name: ${chalk.cyan(agentName)}`);
|
|
406
|
+
console.log(` Agent Address: ${chalk.cyan(result.agentSolanaAddress)}`);
|
|
407
|
+
console.log(` Master Address: ${chalk.cyan(result.userSolanaAddress)}`);
|
|
408
|
+
console.log(` Expires: ${chalk.cyan(expiresAt.toISOString())}`);
|
|
409
|
+
console.log();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
releaseLock("pacifica");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch (err) {
|
|
417
|
+
reportErrorAndExit(err);
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// ── Hyperliquid approve branch ──────────────────────────────────────
|
|
422
|
+
if (exchangeNorm === "hyperliquid") {
|
|
423
|
+
const masterName = opts.master ?? loadSettings().owsActiveWallet;
|
|
424
|
+
const agentName = opts.agentName ?? "perp-cli-hl";
|
|
425
|
+
const expiresIn = opts.expiresIn ?? "90d";
|
|
426
|
+
let expiresAt;
|
|
427
|
+
try {
|
|
428
|
+
expiresAt = parseExpiresIn(expiresIn);
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
432
|
+
reportErrorAndExit(new PerpError("INVALID_PARAMS", msg, {
|
|
433
|
+
remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
|
|
434
|
+
}));
|
|
435
|
+
}
|
|
436
|
+
if (!masterName) {
|
|
437
|
+
reportErrorAndExit(new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
|
|
438
|
+
remediation: "Run: perp setup or use --master <name>",
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
const passphrase = await resolvePassphrase({ flag: opts.passphrase });
|
|
442
|
+
if (passphrase === null) {
|
|
443
|
+
reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
|
|
444
|
+
remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
|
|
445
|
+
}));
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
acquireLock("hyperliquid");
|
|
449
|
+
try {
|
|
450
|
+
const result = await runHlApproveFlow({
|
|
451
|
+
masterName,
|
|
452
|
+
passphrase: passphrase ?? "",
|
|
453
|
+
agentName,
|
|
454
|
+
expiresAt,
|
|
455
|
+
expiresAtIso: expiresAt.toISOString(),
|
|
456
|
+
nowIso: new Date().toISOString(),
|
|
457
|
+
canPerp: opts.canPerp ?? true,
|
|
458
|
+
canSpot: opts.canSpot ?? false,
|
|
459
|
+
canWithdraw: opts.canWithdraw ?? false,
|
|
460
|
+
});
|
|
461
|
+
if (useJson) {
|
|
462
|
+
printJson(jsonOk(result));
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
console.log(chalk.green.bold("\n Hyperliquid Agent approved successfully!\n"));
|
|
466
|
+
console.log(` Name: ${chalk.cyan(agentName)}`);
|
|
467
|
+
console.log(` Agent Address: ${chalk.cyan(result.agentAddress)}`);
|
|
468
|
+
console.log(` Expires: ${chalk.cyan(expiresAt.toISOString())}`);
|
|
469
|
+
console.log();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
releaseLock("hyperliquid");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
reportErrorAndExit(err);
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
// Wizard mode: enter only when ALL THREE conditions hold
|
|
482
|
+
const wizardMode = opts.master === undefined &&
|
|
483
|
+
opts.agentName === undefined &&
|
|
484
|
+
opts.expiresIn === undefined &&
|
|
485
|
+
process.stdin.isTTY === true &&
|
|
486
|
+
process.env["OWS_PASSPHRASE"] === undefined;
|
|
487
|
+
let masterName;
|
|
488
|
+
let agentName;
|
|
489
|
+
let expiresIn;
|
|
490
|
+
let canPerp;
|
|
491
|
+
let canSpot;
|
|
492
|
+
let canWithdraw;
|
|
493
|
+
let passphrase;
|
|
494
|
+
if (wizardMode) {
|
|
495
|
+
const rl = createInterface({ input, output });
|
|
496
|
+
try {
|
|
497
|
+
const settings = loadSettings();
|
|
498
|
+
const defMaster = settings.owsActiveWallet || "main";
|
|
499
|
+
const rawMaster = await rl.question(`Master wallet name [${defMaster}]: `);
|
|
500
|
+
masterName = rawMaster.trim() || defMaster;
|
|
501
|
+
const rawAgent = await rl.question(`Agent name [perp-cli-aster]: `);
|
|
502
|
+
agentName = rawAgent.trim() || "perp-cli-aster";
|
|
503
|
+
const rawExpiry = await rl.question(`Expires-in [90d]: `);
|
|
504
|
+
expiresIn = rawExpiry.trim() || "90d";
|
|
505
|
+
const rawPerp = await rl.question(`Can perp trade? [Y/n]: `);
|
|
506
|
+
canPerp = rawPerp.trim().toLowerCase() !== "n";
|
|
507
|
+
const rawSpot = await rl.question(`Can spot trade? [y/N]: `);
|
|
508
|
+
canSpot = rawSpot.trim().toLowerCase() === "y";
|
|
509
|
+
const rawWithdraw = await rl.question(`Can withdraw? [y/N]: `);
|
|
510
|
+
canWithdraw = rawWithdraw.trim().toLowerCase() === "y";
|
|
511
|
+
// Hidden passphrase prompt — readline doesn't support hidden natively;
|
|
512
|
+
// we use muted output pattern compatible with node:readline/promises
|
|
513
|
+
process.stdout.write("Master passphrase: ");
|
|
514
|
+
const rawPp = await rl.question("");
|
|
515
|
+
passphrase = rawPp.trim() || null;
|
|
516
|
+
}
|
|
517
|
+
finally {
|
|
518
|
+
rl.close();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
masterName = opts.master ?? loadSettings().owsActiveWallet;
|
|
523
|
+
agentName = opts.agentName ?? "perp-cli-aster";
|
|
524
|
+
expiresIn = opts.expiresIn ?? "90d";
|
|
525
|
+
canPerp = opts.canPerp ?? true;
|
|
526
|
+
canSpot = opts.canSpot ?? false;
|
|
527
|
+
canWithdraw = opts.canWithdraw ?? false;
|
|
528
|
+
// 3-path passphrase resolver (non-TTY path)
|
|
529
|
+
passphrase = await resolvePassphrase({ flag: opts.passphrase });
|
|
530
|
+
if (passphrase === null) {
|
|
531
|
+
// TTY but non-wizard (some flags were supplied) — throw PASSPHRASE_REQUIRED
|
|
532
|
+
reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY or wizard skipped", {
|
|
533
|
+
remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
|
|
534
|
+
}));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (!masterName) {
|
|
538
|
+
reportErrorAndExit(new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
|
|
539
|
+
remediation: "Run: perp setup or use --master <name>",
|
|
540
|
+
}));
|
|
541
|
+
}
|
|
542
|
+
// Parse expires-in
|
|
543
|
+
let expiresAt;
|
|
544
|
+
try {
|
|
545
|
+
expiresAt = parseExpiresIn(expiresIn);
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
549
|
+
reportErrorAndExit(new PerpError("INVALID_PARAMS", msg, {
|
|
550
|
+
remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
|
|
551
|
+
}));
|
|
552
|
+
}
|
|
553
|
+
const expiredMs = expiresAt.getTime();
|
|
554
|
+
const expiresAtIso = expiresAt.toISOString();
|
|
555
|
+
const nonceMicros = Date.now() * 1000 + (Math.floor(Math.random() * 1000));
|
|
556
|
+
const nowIso = new Date().toISOString();
|
|
557
|
+
const asterChain = "Mainnet";
|
|
558
|
+
try {
|
|
559
|
+
// Step 1: Acquire lock at the START of the flow (before any OWS resources
|
|
560
|
+
// are created) so that concurrent approves are fully serialized, not just
|
|
561
|
+
// the final settings write.
|
|
562
|
+
acquireLock("aster");
|
|
563
|
+
try {
|
|
564
|
+
const result = await runApproveFlow({
|
|
565
|
+
masterName,
|
|
566
|
+
passphrase: passphrase ?? "",
|
|
567
|
+
agentName,
|
|
568
|
+
expiresAt,
|
|
569
|
+
expiredMs,
|
|
570
|
+
expiresAtIso,
|
|
571
|
+
nonceMicros,
|
|
572
|
+
nowIso,
|
|
573
|
+
canPerp,
|
|
574
|
+
canSpot,
|
|
575
|
+
canWithdraw,
|
|
576
|
+
asterChain,
|
|
577
|
+
builder: opts.builder,
|
|
578
|
+
maxFeeRate: opts.maxFeeRate,
|
|
579
|
+
builderName: opts.builderName,
|
|
580
|
+
ipWhitelist: opts.ipWhitelist,
|
|
581
|
+
});
|
|
582
|
+
if (useJson) {
|
|
583
|
+
printJson(jsonOk(result));
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
console.log(chalk.green.bold("\n Agent approved successfully!\n"));
|
|
587
|
+
console.log(` Name: ${chalk.cyan(agentName)}`);
|
|
588
|
+
console.log(` Agent Address: ${chalk.cyan(result.agentAddress)}`);
|
|
589
|
+
console.log(` Expires: ${chalk.cyan(expiresAtIso)}`);
|
|
590
|
+
console.log(` API Key ID: ${chalk.gray(result.owsApiKeyId)}`);
|
|
591
|
+
console.log(` Policy ID: ${chalk.gray(result.policyId)}`);
|
|
592
|
+
console.log();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
releaseLock("aster");
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
reportErrorAndExit(err);
|
|
601
|
+
}
|
|
89
602
|
});
|
|
90
|
-
// ──
|
|
91
|
-
|
|
92
|
-
.command("
|
|
93
|
-
.description("
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
.
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
603
|
+
// ── agent list [<exchange>] ──────────────────────────────────────────────
|
|
604
|
+
agent
|
|
605
|
+
.command("list [exchange]")
|
|
606
|
+
.description("List registered agent wallets")
|
|
607
|
+
.option("--json", "Machine-readable output")
|
|
608
|
+
.action((exchange, opts) => {
|
|
609
|
+
const useJson = opts.json ?? isJson();
|
|
610
|
+
const all = listAgents(exchange);
|
|
611
|
+
const now = Date.now();
|
|
612
|
+
const rows = all.map(({ exchange: ex, meta }) => {
|
|
613
|
+
let status;
|
|
614
|
+
if (meta.status === "partial") {
|
|
615
|
+
status = "partial";
|
|
616
|
+
}
|
|
617
|
+
else if (new Date(meta.expiresAt).getTime() < now) {
|
|
618
|
+
status = "expired";
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
status = "active";
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
name: meta.agentName,
|
|
625
|
+
exchange: ex,
|
|
626
|
+
evmAddress: meta.agentEvmAddress,
|
|
627
|
+
expiresAt: meta.expiresAt,
|
|
628
|
+
status,
|
|
629
|
+
permissions: meta.permissions,
|
|
630
|
+
};
|
|
631
|
+
});
|
|
632
|
+
if (useJson) {
|
|
633
|
+
printJson(jsonOk(rows));
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (rows.length === 0) {
|
|
637
|
+
console.log("No agents registered. Run `perp wallet agent approve <exchange> ...` to create one.");
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
console.log(chalk.cyan.bold("\n Registered Agent Wallets\n"));
|
|
641
|
+
const header = ["NAME", "EXCHANGE", "EVM ADDR", "EXPIRES", "STATUS", "PERP", "SPOT", "WITHDRAW"];
|
|
642
|
+
const colWidths = [20, 10, 14, 26, 9, 6, 6, 10];
|
|
643
|
+
console.log(" " + header.map((h, i) => chalk.bold(h.padEnd(colWidths[i]))).join(" "));
|
|
644
|
+
console.log(" " + header.map((_, i) => "─".repeat(colWidths[i])).join(" "));
|
|
645
|
+
for (const r of rows) {
|
|
646
|
+
const addr = r.evmAddress ? r.evmAddress.slice(0, 6) + "…" + r.evmAddress.slice(-4) : "";
|
|
647
|
+
const statusColor = r.status === "active" ? chalk.green : r.status === "expired" ? chalk.red : chalk.yellow;
|
|
648
|
+
const cols = [
|
|
649
|
+
r.name.slice(0, 19),
|
|
650
|
+
r.exchange,
|
|
651
|
+
addr,
|
|
652
|
+
new Date(r.expiresAt).toLocaleDateString(),
|
|
653
|
+
statusColor(r.status),
|
|
654
|
+
r.permissions.canPerpTrade ? chalk.green("Y") : chalk.gray("N"),
|
|
655
|
+
r.permissions.canSpotTrade ? chalk.green("Y") : chalk.gray("N"),
|
|
656
|
+
r.permissions.canWithdraw ? chalk.green("Y") : chalk.gray("N"),
|
|
657
|
+
];
|
|
658
|
+
console.log(" " + cols.map((c, i) => String(c).padEnd(colWidths[i])).join(" "));
|
|
659
|
+
}
|
|
660
|
+
console.log();
|
|
112
661
|
});
|
|
113
|
-
// ── agent
|
|
662
|
+
// ── agent revoke <exchange> <agentName> ─────────────────────────────────
|
|
114
663
|
agent
|
|
115
|
-
.command("
|
|
116
|
-
.description("
|
|
117
|
-
.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
"
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
"
|
|
188
|
-
"
|
|
189
|
-
"
|
|
190
|
-
"
|
|
191
|
-
"
|
|
192
|
-
"
|
|
193
|
-
]
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
{
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
{
|
|
230
|
-
category: "discovery",
|
|
231
|
-
commands: [
|
|
232
|
-
"perp schema",
|
|
233
|
-
"perp agent schema",
|
|
234
|
-
"perp agent capabilities",
|
|
235
|
-
"perp agent ping",
|
|
236
|
-
"perp dex list --json",
|
|
237
|
-
"perp dex markets <name> --json",
|
|
238
|
-
],
|
|
239
|
-
description: "CLI schema discovery, connectivity checks, HIP-3 dex discovery",
|
|
240
|
-
},
|
|
241
|
-
],
|
|
242
|
-
agentFeatures: [
|
|
243
|
-
"Structured JSON output (--json) on all commands",
|
|
244
|
-
"Structured error codes with retryable flag (INSUFFICIENT_BALANCE, RATE_LIMITED, etc.)",
|
|
245
|
-
"Client order IDs for idempotent retries (--client-id / --auto-id)",
|
|
246
|
-
"Pre-trade validation (perp trade check) before execution",
|
|
247
|
-
"Multi-step execution plans with rollback (perp plan execute)",
|
|
248
|
-
"NDJSON event streaming for real-time monitoring",
|
|
249
|
-
"HIP-3 cross-dex arbitrage scanning",
|
|
250
|
-
],
|
|
251
|
-
notes: [
|
|
252
|
-
"Always use --json flag for machine-readable output",
|
|
253
|
-
"Use -e <exchange> to switch between pacifica, hyperliquid, lighter, aster",
|
|
254
|
-
"Use --dex <name> for HIP-3 deployed perp dexes on Hyperliquid",
|
|
255
|
-
"Use -n testnet for testnet mode",
|
|
256
|
-
"Use --client-id or --auto-id on trade commands for retry safety",
|
|
257
|
-
"Use perp trade check before execution for pre-flight validation",
|
|
258
|
-
"Errors include { code, retryable, status } for automated handling",
|
|
259
|
-
"Exit code 0 = success, 1 = error",
|
|
260
|
-
],
|
|
261
|
-
}));
|
|
664
|
+
.command("revoke <exchange> <agentName>")
|
|
665
|
+
.description("Revoke an agent wallet registration")
|
|
666
|
+
.option("--force", "Skip Aster POST, only clear local state")
|
|
667
|
+
.option("--passphrase <pp>", "Master OWS passphrase")
|
|
668
|
+
.option("--json", "Machine-readable output")
|
|
669
|
+
.action(async (exchange, agentName, opts) => {
|
|
670
|
+
const useJson = opts.json ?? isJson();
|
|
671
|
+
try {
|
|
672
|
+
// Step 1: Check local meta (idempotent — absent = already revoked)
|
|
673
|
+
const meta = getAgent(exchange, agentName);
|
|
674
|
+
if (!meta) {
|
|
675
|
+
const result = { ok: true, data: { alreadyRevoked: true } };
|
|
676
|
+
if (useJson) {
|
|
677
|
+
printJson(result);
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
console.log(`Agent "${agentName}" on ${exchange} not found locally — already revoked.`);
|
|
681
|
+
}
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (!opts.force) {
|
|
685
|
+
// Step 2: Resolve passphrase
|
|
686
|
+
const passphrase = await resolvePassphrase({ flag: opts.passphrase });
|
|
687
|
+
if (passphrase === null) {
|
|
688
|
+
throw new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
|
|
689
|
+
remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
if (exchange === "hyperliquid") {
|
|
693
|
+
// HL revoke: POST approveAgent with zero-address (best-effort, idempotent)
|
|
694
|
+
const masterName = meta.masterWalletName;
|
|
695
|
+
const masterSigner = OwsEvmSigner.create(masterName, passphrase);
|
|
696
|
+
try {
|
|
697
|
+
await revokeHlAgent(masterSigner);
|
|
698
|
+
}
|
|
699
|
+
catch { /* best effort — HL revoke is idempotent */ }
|
|
700
|
+
}
|
|
701
|
+
else if (exchange === "pacifica") {
|
|
702
|
+
// PAC revoke: signed unbind_agent_wallet with the agent base58 address
|
|
703
|
+
const masterName = meta.masterWalletName;
|
|
704
|
+
const solanaSigner = OwsSolanaSigner.create(masterName, passphrase);
|
|
705
|
+
try {
|
|
706
|
+
await revokePacAgent(solanaSigner, meta.agentSolanaAddress ?? "");
|
|
707
|
+
}
|
|
708
|
+
catch { /* best effort — Pacifica revoke is idempotent */ }
|
|
709
|
+
}
|
|
710
|
+
else if (exchange === "lighter" || exchange === "lt") {
|
|
711
|
+
// LT revoke: Lighter has no on-chain revoke action; revoke is local-only.
|
|
712
|
+
// Slots persist on the L2 server until overwritten by a fresh ChangePubKey
|
|
713
|
+
// at the same index. revokeLtAgent is a no-op stub that exists for
|
|
714
|
+
// symmetry with the other DEX revoke helpers.
|
|
715
|
+
try {
|
|
716
|
+
await revokeLtAgent();
|
|
717
|
+
}
|
|
718
|
+
catch { /* best effort — LT revoke is idempotent */ }
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
// Aster revoke: Step 3: Build DelAgent EIP-712
|
|
722
|
+
const masterName = meta.masterWalletName;
|
|
723
|
+
const masterSigner = OwsEvmSigner.create(masterName, passphrase);
|
|
724
|
+
const userEvmAddress = masterSigner.getAddress();
|
|
725
|
+
const nonceMicros = Date.now() * 1000 + Math.floor(Math.random() * 1000);
|
|
726
|
+
const delTypedData = buildDelAgentTypedData({
|
|
727
|
+
user: userEvmAddress,
|
|
728
|
+
agentAddress: meta.agentEvmAddress,
|
|
729
|
+
nonceMicros,
|
|
730
|
+
asterChain: "Mainnet",
|
|
731
|
+
});
|
|
732
|
+
const signature = await masterSigner.signTypedData(delTypedData.domain, delTypedData.types, delTypedData.message);
|
|
733
|
+
// Step 4: POST DELETE /fapi/v3/agent
|
|
734
|
+
const baseUrl = "https://fapi.asterdex.com";
|
|
735
|
+
const qsEntries = [
|
|
736
|
+
["agentAddress", meta.agentEvmAddress],
|
|
737
|
+
["asterChain", "Mainnet"],
|
|
738
|
+
["user", userEvmAddress],
|
|
739
|
+
["nonce", String(nonceMicros)],
|
|
740
|
+
["signature", signature],
|
|
741
|
+
["signatureChainId", "56"],
|
|
742
|
+
];
|
|
743
|
+
const qs = new URLSearchParams(Object.fromEntries(qsEntries));
|
|
744
|
+
const revokeRes = await fetch(`${baseUrl}/fapi/v3/agent?${qs.toString()}`, {
|
|
745
|
+
method: "DELETE",
|
|
746
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
747
|
+
body: "",
|
|
748
|
+
});
|
|
749
|
+
// Idempotent: 404 = already revoked server-side, treat as success
|
|
750
|
+
if (!revokeRes.ok && revokeRes.status !== 404) {
|
|
751
|
+
const errText = await revokeRes.text().catch(() => "");
|
|
752
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster agent delete failed (${revokeRes.status}): ${errText.slice(0, 200)}`, {
|
|
753
|
+
remediation: "Use --force to skip Aster POST and only clear local state",
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
// Step 5: Revoke OWS API key (Aster only — HL has no OWS key)
|
|
759
|
+
if (meta.owsApiKeyId) {
|
|
760
|
+
try {
|
|
761
|
+
const ows = loadOws();
|
|
762
|
+
ows.revokeApiKey(meta.owsApiKeyId);
|
|
763
|
+
}
|
|
764
|
+
catch { /* best effort */ }
|
|
765
|
+
}
|
|
766
|
+
// Step 6: Delete local agent entry
|
|
767
|
+
deleteAgent(exchange, agentName);
|
|
768
|
+
if (useJson) {
|
|
769
|
+
printJson(jsonOk({ revoked: true }));
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
console.log(chalk.green(`Agent "${agentName}" on ${exchange} revoked successfully.`));
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
catch (err) {
|
|
776
|
+
reportErrorAndExit(err);
|
|
777
|
+
}
|
|
262
778
|
});
|
|
263
|
-
// ── agent
|
|
779
|
+
// ── agent rotate <exchange> [<oldAgentName>] ─────────────────────────────
|
|
264
780
|
agent
|
|
265
|
-
.command("
|
|
266
|
-
.description("
|
|
267
|
-
.
|
|
268
|
-
.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
781
|
+
.command("rotate <exchange> [oldAgentName]")
|
|
782
|
+
.description("Rotate an agent wallet: revoke existing, then approve with same name")
|
|
783
|
+
.option("--passphrase <pp>", "Master OWS passphrase")
|
|
784
|
+
.option("--master <name>", "Master OWS wallet name")
|
|
785
|
+
.option("--agent-name <name>", "Agent name to rotate (default: first registered agent)")
|
|
786
|
+
.option("--expires-in <duration>", "New expiry (default: 90d)")
|
|
787
|
+
.option("--can-perp", "Allow perp trading (default: inherit or on)")
|
|
788
|
+
.option("--no-can-perp", "Disallow perp trading")
|
|
789
|
+
.option("--can-spot", "Allow spot trading (default: inherit or off)")
|
|
790
|
+
.option("--no-can-spot", "Disallow spot trading")
|
|
791
|
+
.option("--can-withdraw", "Allow withdrawals (default: inherit or off)")
|
|
792
|
+
.option("--no-can-withdraw", "Disallow withdrawals")
|
|
793
|
+
.option("--json", "Machine-readable output")
|
|
794
|
+
.action(async (exchange, oldAgentNameArg, opts) => {
|
|
795
|
+
const useJson = opts.json ?? isJson();
|
|
272
796
|
try {
|
|
273
|
-
|
|
797
|
+
// Determine which agent to rotate
|
|
798
|
+
const agentNameToRotate = oldAgentNameArg ?? opts.agentName ?? getAgent(exchange)?.agentName;
|
|
799
|
+
if (!agentNameToRotate) {
|
|
800
|
+
throw new PerpError("AGENT_NOT_REGISTERED", `No agent found for ${exchange}`, {
|
|
801
|
+
remediation: `Run: perp wallet agent approve ${exchange} to create one`,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
// Resolve passphrase early (needed for both revoke and approve phases)
|
|
805
|
+
const passphrase = await resolvePassphrase({ flag: opts.passphrase });
|
|
806
|
+
if (passphrase === null) {
|
|
807
|
+
throw new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
|
|
808
|
+
remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
const settings = loadSettings();
|
|
812
|
+
const masterName = opts.master ?? settings.owsActiveWallet;
|
|
813
|
+
// Capture existing agent meta before revoke (for permission inheritance)
|
|
814
|
+
const existing = getAgent(exchange, agentNameToRotate);
|
|
815
|
+
// Resolve permissions: explicit flags > old agent's permissions > defaults.
|
|
816
|
+
// With --no-can-perp/spot/withdraw pattern, opts values are undefined when not passed.
|
|
817
|
+
const permFlagsProvided = opts.canPerp !== undefined || opts.canSpot !== undefined || opts.canWithdraw !== undefined;
|
|
818
|
+
let canPerp;
|
|
819
|
+
let canSpot;
|
|
820
|
+
let canWithdraw;
|
|
821
|
+
if (permFlagsProvided) {
|
|
822
|
+
canPerp = opts.canPerp ?? true;
|
|
823
|
+
canSpot = opts.canSpot ?? false;
|
|
824
|
+
canWithdraw = opts.canWithdraw ?? false;
|
|
825
|
+
}
|
|
826
|
+
else if (existing?.permissions) {
|
|
827
|
+
// Preserve old agent's permission set when flags are absent
|
|
828
|
+
canPerp = existing.permissions.canPerpTrade;
|
|
829
|
+
canSpot = existing.permissions.canSpotTrade;
|
|
830
|
+
canWithdraw = existing.permissions.canWithdraw;
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
canPerp = true;
|
|
834
|
+
canSpot = false;
|
|
835
|
+
canWithdraw = false;
|
|
836
|
+
}
|
|
837
|
+
// Revoke existing (if present) — best effort
|
|
838
|
+
if (existing) {
|
|
839
|
+
try {
|
|
840
|
+
if (exchange === "pacifica") {
|
|
841
|
+
// Pacifica master is a Solana keypair; can't reuse the EVM master signer.
|
|
842
|
+
const revokePacSigner = OwsSolanaSigner.create(existing.masterWalletName, passphrase);
|
|
843
|
+
await revokePacAgent(revokePacSigner, existing.agentSolanaAddress ?? "");
|
|
844
|
+
if (existing.owsApiKeyId) {
|
|
845
|
+
try {
|
|
846
|
+
loadOws().revokeApiKey(existing.owsApiKeyId);
|
|
847
|
+
}
|
|
848
|
+
catch { /* best effort */ }
|
|
849
|
+
}
|
|
850
|
+
deleteAgent(exchange, agentNameToRotate);
|
|
851
|
+
// Continue to the shared approve flow below.
|
|
852
|
+
// (skip the remaining EVM-revoke branch via early continuation)
|
|
853
|
+
}
|
|
854
|
+
else if (exchange === "lighter" || exchange === "lt") {
|
|
855
|
+
// LT rotate: no on-chain revoke; just clear local entry. The new
|
|
856
|
+
// approve picks the next free slot, leaving the old slot orphaned
|
|
857
|
+
// on the L2 server (it cannot be used again until overwritten via
|
|
858
|
+
// a fresh ChangePubKey at that index — which the next approve does
|
|
859
|
+
// implicitly when it picks max+1).
|
|
860
|
+
await revokeLtAgent();
|
|
861
|
+
if (existing.owsApiKeyId) {
|
|
862
|
+
try {
|
|
863
|
+
loadOws().revokeApiKey(existing.owsApiKeyId);
|
|
864
|
+
}
|
|
865
|
+
catch { /* best effort */ }
|
|
866
|
+
}
|
|
867
|
+
deleteAgent(exchange, agentNameToRotate);
|
|
868
|
+
// Continue to the shared approve flow below.
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
const revokeMs = OwsEvmSigner.create(existing.masterWalletName, passphrase);
|
|
872
|
+
if (exchange === "hyperliquid") {
|
|
873
|
+
// HL: revoke via approveAgent with zero-address
|
|
874
|
+
await revokeHlAgent(revokeMs);
|
|
875
|
+
}
|
|
876
|
+
else {
|
|
877
|
+
// Aster: DELETE /fapi/v3/agent
|
|
878
|
+
const userEvmAddress = revokeMs.getAddress();
|
|
879
|
+
const nonceMicros = Date.now() * 1000 + Math.floor(Math.random() * 1000);
|
|
880
|
+
const delTypedData = buildDelAgentTypedData({
|
|
881
|
+
user: userEvmAddress,
|
|
882
|
+
agentAddress: existing.agentEvmAddress,
|
|
883
|
+
nonceMicros,
|
|
884
|
+
asterChain: "Mainnet",
|
|
885
|
+
});
|
|
886
|
+
const sig = await revokeMs.signTypedData(delTypedData.domain, delTypedData.types, delTypedData.message);
|
|
887
|
+
const baseUrl = "https://fapi.asterdex.com";
|
|
888
|
+
const qs = new URLSearchParams(Object.fromEntries([
|
|
889
|
+
["agentAddress", existing.agentEvmAddress],
|
|
890
|
+
["asterChain", "Mainnet"],
|
|
891
|
+
["user", userEvmAddress],
|
|
892
|
+
["nonce", String(nonceMicros)],
|
|
893
|
+
["signature", sig],
|
|
894
|
+
["signatureChainId", "56"],
|
|
895
|
+
]));
|
|
896
|
+
await fetch(`${baseUrl}/fapi/v3/agent?${qs.toString()}`, {
|
|
897
|
+
method: "DELETE",
|
|
898
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
899
|
+
body: "",
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
if (existing.owsApiKeyId) {
|
|
903
|
+
try {
|
|
904
|
+
loadOws().revokeApiKey(existing.owsApiKeyId);
|
|
905
|
+
}
|
|
906
|
+
catch { /* best effort */ }
|
|
907
|
+
}
|
|
908
|
+
deleteAgent(exchange, agentNameToRotate);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
catch { /* best effort on revoke — continue to approve */ }
|
|
912
|
+
}
|
|
913
|
+
const expiresIn = opts.expiresIn ?? "90d";
|
|
914
|
+
const expiresAt = parseExpiresIn(expiresIn);
|
|
915
|
+
const expiresAtIso = expiresAt.toISOString();
|
|
916
|
+
const nowIso = new Date().toISOString();
|
|
917
|
+
// Acquire lock and run the shared approve flow
|
|
918
|
+
acquireLock(exchange);
|
|
919
|
+
try {
|
|
920
|
+
let rotatedAgentAddress;
|
|
921
|
+
if (exchange === "hyperliquid") {
|
|
922
|
+
const result = await runHlApproveFlow({
|
|
923
|
+
masterName,
|
|
924
|
+
passphrase,
|
|
925
|
+
agentName: agentNameToRotate,
|
|
926
|
+
expiresAt,
|
|
927
|
+
expiresAtIso,
|
|
928
|
+
nowIso,
|
|
929
|
+
canPerp,
|
|
930
|
+
canSpot,
|
|
931
|
+
canWithdraw,
|
|
932
|
+
});
|
|
933
|
+
rotatedAgentAddress = result.agentAddress;
|
|
934
|
+
}
|
|
935
|
+
else if (exchange === "pacifica") {
|
|
936
|
+
const result = await runPacApproveFlow({
|
|
937
|
+
masterName,
|
|
938
|
+
passphrase,
|
|
939
|
+
agentName: agentNameToRotate,
|
|
940
|
+
expiresAt,
|
|
941
|
+
expiresAtIso,
|
|
942
|
+
nowIso,
|
|
943
|
+
canPerp,
|
|
944
|
+
canSpot,
|
|
945
|
+
canWithdraw,
|
|
946
|
+
});
|
|
947
|
+
rotatedAgentAddress = result.agentSolanaAddress;
|
|
948
|
+
}
|
|
949
|
+
else if (exchange === "lighter" || exchange === "lt") {
|
|
950
|
+
const result = await runLtApproveFlow({
|
|
951
|
+
masterName,
|
|
952
|
+
passphrase,
|
|
953
|
+
agentName: agentNameToRotate,
|
|
954
|
+
expiresAt,
|
|
955
|
+
expiresAtIso,
|
|
956
|
+
nowIso,
|
|
957
|
+
canPerp,
|
|
958
|
+
canSpot,
|
|
959
|
+
canWithdraw,
|
|
960
|
+
// No apiKeyIndex override on rotate — picks next free slot.
|
|
961
|
+
});
|
|
962
|
+
rotatedAgentAddress = `slot=${result.apiKeyIndex} pubkey=${result.publicKey}`;
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
const expiredMs = expiresAt.getTime();
|
|
966
|
+
const nonceMicros = Date.now() * 1000 + Math.floor(Math.random() * 1000);
|
|
967
|
+
const result = await runApproveFlow({
|
|
968
|
+
masterName,
|
|
969
|
+
passphrase,
|
|
970
|
+
agentName: agentNameToRotate,
|
|
971
|
+
expiresAt,
|
|
972
|
+
expiredMs,
|
|
973
|
+
expiresAtIso,
|
|
974
|
+
nonceMicros,
|
|
975
|
+
nowIso,
|
|
976
|
+
canPerp,
|
|
977
|
+
canSpot,
|
|
978
|
+
canWithdraw,
|
|
979
|
+
asterChain: "Mainnet",
|
|
980
|
+
});
|
|
981
|
+
rotatedAgentAddress = result.agentAddress;
|
|
982
|
+
}
|
|
983
|
+
if (useJson) {
|
|
984
|
+
printJson(jsonOk({ rotated: true, agentName: agentNameToRotate, agentEvmAddress: rotatedAgentAddress, expiresAt: expiresAtIso }));
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
console.log(chalk.green(`Agent "${agentNameToRotate}" on ${exchange} rotated successfully.`));
|
|
988
|
+
console.log(` New agent address: ${chalk.cyan(rotatedAgentAddress)}`);
|
|
989
|
+
console.log(` Expires: ${chalk.cyan(expiresAtIso)}`);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
finally {
|
|
993
|
+
releaseLock(exchange);
|
|
994
|
+
}
|
|
274
995
|
}
|
|
275
996
|
catch (err) {
|
|
276
|
-
|
|
277
|
-
console.error(JSON.stringify(jsonError("EXEC_FAILED", msg)));
|
|
278
|
-
process.exit(1);
|
|
997
|
+
reportErrorAndExit(err);
|
|
279
998
|
}
|
|
280
999
|
});
|
|
281
|
-
// ── agent
|
|
1000
|
+
// ── agent verify [exchange] [agentName] ──────────────────────────────────
|
|
282
1001
|
agent
|
|
283
|
-
.command("
|
|
284
|
-
.description("
|
|
285
|
-
.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
http_status: hlPing.status,
|
|
1002
|
+
.command("verify [exchange] [agentName]")
|
|
1003
|
+
.description("Verify agent wallet registration via DEX query API")
|
|
1004
|
+
.option("--master <name>", "Master OWS wallet name (Aster/PAC). Defaults to settings.owsActiveWallet")
|
|
1005
|
+
.option("--master-address <addr>", "Master EVM address (HL only)")
|
|
1006
|
+
.option("--account-index <n>", "Account index (Lighter only)")
|
|
1007
|
+
.option("--passphrase <pp>", "Master OWS passphrase (Aster/PAC). Falls back to OWS_PASSPHRASE env.")
|
|
1008
|
+
.option("--json", "Machine-readable output")
|
|
1009
|
+
.action(async (exchange, agentName, opts) => {
|
|
1010
|
+
const useJson = opts.json ?? isJson();
|
|
1011
|
+
const ts = new Date().toISOString();
|
|
1012
|
+
// Resolve passphrase via 3-path: flag > env > (no stdin prompt in verify)
|
|
1013
|
+
const passphrase = opts.passphrase ?? process.env["OWS_PASSPHRASE"] ?? "";
|
|
1014
|
+
const verifyOpts = {
|
|
1015
|
+
agentName,
|
|
1016
|
+
master: opts.master,
|
|
1017
|
+
masterAddress: opts.masterAddress,
|
|
1018
|
+
accountIndex: opts.accountIndex,
|
|
1019
|
+
passphrase,
|
|
302
1020
|
};
|
|
303
|
-
|
|
1021
|
+
if (!exchange) {
|
|
1022
|
+
// Aggregate mode: all 4 DEXs in parallel
|
|
1023
|
+
const [asterResult, hlResult, pacResult, ltResult] = await Promise.allSettled([
|
|
1024
|
+
verifyAster(verifyOpts),
|
|
1025
|
+
verifyHyperliquid(verifyOpts),
|
|
1026
|
+
verifyPacifica(verifyOpts),
|
|
1027
|
+
verifyLighter(verifyOpts),
|
|
1028
|
+
]);
|
|
1029
|
+
function slotResult(r) {
|
|
1030
|
+
if (r.status === "fulfilled") {
|
|
1031
|
+
const slot = { registered: r.value.registered, count: r.value.count, items: r.value.items };
|
|
1032
|
+
if (r.value.warnings && r.value.warnings.length > 0)
|
|
1033
|
+
slot.warnings = r.value.warnings;
|
|
1034
|
+
return slot;
|
|
1035
|
+
}
|
|
1036
|
+
const err = r.reason instanceof PerpError ? r.reason : null;
|
|
1037
|
+
return {
|
|
1038
|
+
error: {
|
|
1039
|
+
code: err ? err.structured.code : "EXCHANGE_ERROR",
|
|
1040
|
+
message: err ? err.structured.message : (r.reason instanceof Error ? r.reason.message : String(r.reason)),
|
|
1041
|
+
remediation: err ? err.structured.remediation : undefined,
|
|
1042
|
+
},
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
const data = {
|
|
1046
|
+
aster: slotResult(asterResult),
|
|
1047
|
+
hyperliquid: slotResult(hlResult),
|
|
1048
|
+
pacifica: slotResult(pacResult),
|
|
1049
|
+
lighter: slotResult(ltResult),
|
|
1050
|
+
};
|
|
1051
|
+
if (useJson) {
|
|
1052
|
+
printJson({ ok: true, data, meta: { timestamp: ts } });
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
console.log(chalk.bold("Agent Verify — All DEXs"));
|
|
1056
|
+
for (const [dex, slot] of Object.entries(data)) {
|
|
1057
|
+
if ("error" in slot) {
|
|
1058
|
+
console.log(chalk.yellow(` ${dex}: ERROR — ${slot.error.message}`));
|
|
1059
|
+
}
|
|
1060
|
+
else {
|
|
1061
|
+
const s = slot;
|
|
1062
|
+
const icon = s.registered ? chalk.green("✓") : chalk.gray("○");
|
|
1063
|
+
console.log(` ${icon} ${dex}: ${s.count} agent(s) registered`);
|
|
1064
|
+
for (const w of s.warnings ?? []) {
|
|
1065
|
+
console.log(chalk.yellow(` [warn] ${w}`));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
// Single-DEX mode
|
|
304
1073
|
try {
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
1074
|
+
let result;
|
|
1075
|
+
const ex = exchange.toLowerCase();
|
|
1076
|
+
if (ex === "aster") {
|
|
1077
|
+
result = await verifyAster(verifyOpts);
|
|
1078
|
+
}
|
|
1079
|
+
else if (ex === "hyperliquid" || ex === "hl") {
|
|
1080
|
+
result = await verifyHyperliquid(verifyOpts);
|
|
1081
|
+
}
|
|
1082
|
+
else if (ex === "pacifica" || ex === "pac") {
|
|
1083
|
+
result = await verifyPacifica(verifyOpts);
|
|
1084
|
+
}
|
|
1085
|
+
else if (ex === "lighter" || ex === "lt") {
|
|
1086
|
+
result = await verifyLighter(verifyOpts);
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
throw new PerpError("INVALID_PARAMS", `Unknown exchange "${exchange}". Use: aster, hyperliquid, pacifica, lighter`, {
|
|
1090
|
+
remediation: "perp wallet agent verify <aster|hyperliquid|pacifica|lighter> [agentName]",
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
if (useJson) {
|
|
1094
|
+
printJson({
|
|
1095
|
+
ok: true,
|
|
1096
|
+
data: { exchange: ex, registered: result.registered, count: result.count, items: result.items },
|
|
1097
|
+
meta: { timestamp: ts, ...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}) },
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
const icon = result.registered ? chalk.green("✓") : chalk.gray("○");
|
|
1102
|
+
console.log(`${icon} ${chalk.bold(ex)}: ${result.count} agent(s) registered`);
|
|
1103
|
+
if (result.count > 0) {
|
|
1104
|
+
if (ex === "aster") {
|
|
1105
|
+
console.log(chalk.dim(" agentAddress | agentName | expired | canPerp | canSpot | canWithdraw"));
|
|
1106
|
+
for (const item of result.items) {
|
|
1107
|
+
const i = item;
|
|
1108
|
+
const addr = String(i["agentAddress"] ?? "").substring(0, 20) + "…";
|
|
1109
|
+
const name = String(i["agentName"] ?? "").padEnd(16);
|
|
1110
|
+
const exp = i["expired"] !== undefined ? String(i["expired"]) : "—";
|
|
1111
|
+
const cp = i["canPerpTrade"] ? "yes" : "no";
|
|
1112
|
+
const cs = i["canSpotTrade"] ? "yes" : "no";
|
|
1113
|
+
const cw = i["canWithdraw"] ? "yes" : "no";
|
|
1114
|
+
console.log(` ${addr} | ${name} | ${exp} | ${cp.padEnd(7)} | ${cs.padEnd(7)} | ${cw}`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
else if (ex === "hyperliquid" || ex === "hl") {
|
|
1118
|
+
console.log(chalk.dim(" address | validUntil | name"));
|
|
1119
|
+
for (const item of result.items) {
|
|
1120
|
+
const i = item;
|
|
1121
|
+
const addr = String(i["address"] ?? "").substring(0, 20) + "…";
|
|
1122
|
+
const vu = i["validUntil"] !== undefined ? String(i["validUntil"]) : "—";
|
|
1123
|
+
const name = String(i["name"] ?? "");
|
|
1124
|
+
console.log(` ${addr} | ${vu.padEnd(11)} | ${name}`);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
else if (ex === "lighter" || ex === "lt") {
|
|
1128
|
+
console.log(chalk.dim(" api_key_index | public_key (truncated) | nonce | transaction_time"));
|
|
1129
|
+
for (const item of result.items) {
|
|
1130
|
+
const i = item;
|
|
1131
|
+
const idx = String(i["api_key_index"] ?? "");
|
|
1132
|
+
const pk = String(i["public_key"] ?? "").substring(0, 20) + "…";
|
|
1133
|
+
const nonce = String(i["nonce"] ?? "");
|
|
1134
|
+
const tt = String(i["transaction_time"] ?? "");
|
|
1135
|
+
console.log(` ${idx.padEnd(13)} | ${pk.padEnd(23)} | ${nonce.padEnd(5)} | ${tt}`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
else if (ex === "pacifica" || ex === "pac") {
|
|
1139
|
+
console.log(chalk.dim(" api_key (truncated) | created_at"));
|
|
1140
|
+
for (const item of result.items) {
|
|
1141
|
+
const i = item;
|
|
1142
|
+
const key = String(i["api_key"] ?? "").substring(0, 20) + "…";
|
|
1143
|
+
const created = String(i["created_at"] ?? "");
|
|
1144
|
+
console.log(` ${key.padEnd(24)} | ${created}`);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
if (result.warnings && result.warnings.length > 0) {
|
|
1149
|
+
for (const w of result.warnings) {
|
|
1150
|
+
process.stderr.write(chalk.yellow(`[warn] ${w}\n`));
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
catch (err) {
|
|
1156
|
+
reportErrorAndExit(err);
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Core approve flow: Steps 2-12 (OWS wallet + policy + api-key + EIP-712 + Aster POST + persist).
|
|
1162
|
+
* Caller MUST hold the exchange lock before calling this function.
|
|
1163
|
+
* The function is re-entrant safe with setAgent because callerHoldsLock() will
|
|
1164
|
+
* return true when called from within a locked context.
|
|
1165
|
+
*/
|
|
1166
|
+
async function runApproveFlow(opts) {
|
|
1167
|
+
const { masterName, passphrase, agentName, expiredMs, expiresAtIso, nonceMicros, nowIso, canPerp, canSpot, canWithdraw, asterChain, } = opts;
|
|
1168
|
+
const baseUrl = "https://fapi.asterdex.com";
|
|
1169
|
+
let owsApiKeyId;
|
|
1170
|
+
let owsPolicyId;
|
|
1171
|
+
let agentEvmAddress;
|
|
1172
|
+
let agentWalletName;
|
|
1173
|
+
// Steps 2-3: Resolve master signer
|
|
1174
|
+
const masterSigner = OwsEvmSigner.create(masterName, passphrase);
|
|
1175
|
+
const userEvmAddress = masterSigner.getAddress();
|
|
1176
|
+
// Step 4: Generate agent OWS wallet
|
|
1177
|
+
const ows = loadOws();
|
|
1178
|
+
agentWalletName = `agent-aster-${masterName}`;
|
|
1179
|
+
const agentWallet = ows.createWallet(agentWalletName, passphrase);
|
|
1180
|
+
const agentEvmAccount = agentWallet.accounts.find((a) => a.chainId.startsWith("eip155:"));
|
|
1181
|
+
if (!agentEvmAccount) {
|
|
1182
|
+
throw new PerpError("INVALID_PARAMS", `Agent wallet "${agentWalletName}" has no EVM account`, {
|
|
1183
|
+
remediation: "Check OWS vault configuration",
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
agentEvmAddress = agentEvmAccount.address;
|
|
1187
|
+
// Step 5: Build OWS policy
|
|
1188
|
+
const policyId = `aster-perp-${Date.now()}`;
|
|
1189
|
+
owsPolicyId = policyId;
|
|
1190
|
+
ows.createPolicy(JSON.stringify({
|
|
1191
|
+
id: policyId,
|
|
1192
|
+
name: `perp-cli-aster-${agentName}`,
|
|
1193
|
+
version: 1,
|
|
1194
|
+
created_at: nowIso,
|
|
1195
|
+
rules: [{ type: "allowed_chains", chain_ids: ["eip155:56"] }],
|
|
1196
|
+
expires_at: expiresAtIso,
|
|
1197
|
+
executable: null,
|
|
1198
|
+
action: "deny",
|
|
1199
|
+
}));
|
|
1200
|
+
// Step 6: Build OWS API key — store only the id, never the token
|
|
1201
|
+
const apiKeyResult = ows.createApiKey(`api-aster-${agentName}`, [agentWallet.id], [policyId], passphrase, expiresAtIso);
|
|
1202
|
+
owsApiKeyId = apiKeyResult.id;
|
|
1203
|
+
// Step 7 (Optional): Builder approval BEFORE approveAgent
|
|
1204
|
+
if (opts.builder) {
|
|
1205
|
+
if (!opts.maxFeeRate) {
|
|
1206
|
+
throw new PerpError("INVALID_PARAMS", "--max-fee-rate is required when --builder is set", {
|
|
1207
|
+
remediation: "Add --max-fee-rate <bps> to your command",
|
|
308
1208
|
});
|
|
309
|
-
results.relayer = {
|
|
310
|
-
status: res.ok ? "ok" : "error",
|
|
311
|
-
latency_ms: Date.now() - start,
|
|
312
|
-
};
|
|
313
1209
|
}
|
|
314
|
-
|
|
315
|
-
|
|
1210
|
+
const builderTypedData = buildApproveBuilderTypedData({
|
|
1211
|
+
builder: opts.builder,
|
|
1212
|
+
maxFeeRate: opts.maxFeeRate,
|
|
1213
|
+
builderName: opts.builderName,
|
|
1214
|
+
user: userEvmAddress,
|
|
1215
|
+
nonceMicros: nonceMicros - 1,
|
|
1216
|
+
asterChain,
|
|
1217
|
+
});
|
|
1218
|
+
const builderSig = await masterSigner.signTypedData(builderTypedData.domain, builderTypedData.types, builderTypedData.message);
|
|
1219
|
+
const builderQsEntries = [
|
|
1220
|
+
["builder", opts.builder],
|
|
1221
|
+
["maxFeeRate", opts.maxFeeRate],
|
|
1222
|
+
...(opts.builderName !== undefined ? [["builderName", opts.builderName]] : []),
|
|
1223
|
+
["asterChain", asterChain],
|
|
1224
|
+
["user", userEvmAddress],
|
|
1225
|
+
["nonce", String(nonceMicros - 1)],
|
|
1226
|
+
["signature", builderSig],
|
|
1227
|
+
["signatureChainId", "56"],
|
|
1228
|
+
];
|
|
1229
|
+
const builderRes = await fetch(`${baseUrl}/fapi/v3/approveBuilder?${new URLSearchParams(Object.fromEntries(builderQsEntries)).toString()}`, {
|
|
1230
|
+
method: "POST",
|
|
1231
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1232
|
+
body: "",
|
|
1233
|
+
});
|
|
1234
|
+
if (!builderRes.ok) {
|
|
1235
|
+
const errText = await builderRes.text().catch(() => "");
|
|
1236
|
+
// Aster builder POST failed — rollback api key (no Aster agent registered yet)
|
|
1237
|
+
if (owsApiKeyId) {
|
|
1238
|
+
try {
|
|
1239
|
+
ows.revokeApiKey(owsApiKeyId);
|
|
1240
|
+
}
|
|
1241
|
+
catch { /* best effort */ }
|
|
1242
|
+
}
|
|
1243
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster approveBuilder failed (${builderRes.status}): ${errText.slice(0, 200)}`, {
|
|
1244
|
+
remediation: "Check builder address and max-fee-rate parameters",
|
|
1245
|
+
});
|
|
316
1246
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
1247
|
+
const builderResp = await builderRes.json();
|
|
1248
|
+
const builderOk = builderResp.code === "000000" || builderResp.code === 200;
|
|
1249
|
+
if (!builderOk) {
|
|
1250
|
+
if (owsApiKeyId) {
|
|
1251
|
+
try {
|
|
1252
|
+
ows.revokeApiKey(owsApiKeyId);
|
|
1253
|
+
}
|
|
1254
|
+
catch { /* best effort */ }
|
|
324
1255
|
}
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
1256
|
+
const errMsg = builderResp.msg ?? builderResp.message ?? String(builderResp.code);
|
|
1257
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster approveBuilder failed: ${errMsg}`, {
|
|
1258
|
+
remediation: "Check builder address and max-fee-rate parameters",
|
|
1259
|
+
});
|
|
329
1260
|
}
|
|
330
|
-
|
|
1261
|
+
}
|
|
1262
|
+
// Step 8: Build EIP-712 ApproveAgent payload
|
|
1263
|
+
const approveTypedData = buildApproveAgentTypedData({
|
|
1264
|
+
user: userEvmAddress,
|
|
1265
|
+
agentAddress: agentEvmAddress,
|
|
1266
|
+
agentName,
|
|
1267
|
+
expiredMs,
|
|
1268
|
+
canPerpTrade: canPerp,
|
|
1269
|
+
canSpotTrade: canSpot,
|
|
1270
|
+
canWithdraw,
|
|
1271
|
+
ipWhitelist: opts.ipWhitelist,
|
|
1272
|
+
builder: opts.builder,
|
|
1273
|
+
maxFeeRate: opts.maxFeeRate,
|
|
1274
|
+
builderName: opts.builderName,
|
|
1275
|
+
nonceMicros,
|
|
1276
|
+
asterChain,
|
|
331
1277
|
});
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
else
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
steps.push({ step: 1, command: `perp market book ${symbol} --json`, description: `${symbol} orderbook` }, { step: 2, command: `perp market funding ${symbol} --json`, description: `${symbol} funding history` }, { step: 3, command: `perp market kline ${symbol} 1h --json`, description: `${symbol} hourly candles` });
|
|
1278
|
+
// Step 9: Sign
|
|
1279
|
+
const signature = await masterSigner.signTypedData(approveTypedData.domain, approveTypedData.types, approveTypedData.message);
|
|
1280
|
+
// Step 10: Build query string
|
|
1281
|
+
const qsEntries = [
|
|
1282
|
+
["agentName", agentName],
|
|
1283
|
+
["agentAddress", agentEvmAddress],
|
|
1284
|
+
];
|
|
1285
|
+
if (opts.ipWhitelist !== undefined && opts.ipWhitelist !== null) {
|
|
1286
|
+
qsEntries.push(["ipWhitelist", opts.ipWhitelist]);
|
|
1287
|
+
}
|
|
1288
|
+
qsEntries.push(["expired", String(expiredMs)], ["canSpotTrade", String(canSpot)], ["canPerpTrade", String(canPerp)], ["canWithdraw", String(canWithdraw)]);
|
|
1289
|
+
if (opts.builder !== undefined && opts.builder !== null) {
|
|
1290
|
+
qsEntries.push(["builder", opts.builder]);
|
|
1291
|
+
qsEntries.push(["maxFeeRate", opts.maxFeeRate ?? ""]);
|
|
1292
|
+
if (opts.builderName !== undefined) {
|
|
1293
|
+
qsEntries.push(["builderName", opts.builderName]);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
qsEntries.push(["asterChain", asterChain], ["user", userEvmAddress], ["nonce", String(nonceMicros)], ["signature", signature], ["signatureChainId", "56"]);
|
|
1297
|
+
// Step 11: POST /fapi/v3/approveAgent
|
|
1298
|
+
let asterPostSucceeded = false;
|
|
1299
|
+
const agentRes = await fetch(`${baseUrl}/fapi/v3/approveAgent?${new URLSearchParams(Object.fromEntries(qsEntries)).toString()}`, {
|
|
1300
|
+
method: "POST",
|
|
1301
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1302
|
+
body: "",
|
|
1303
|
+
});
|
|
1304
|
+
if (agentRes.ok) {
|
|
1305
|
+
const resp = await agentRes.json();
|
|
1306
|
+
if (resp.code === "000000" || resp.code === 200) {
|
|
1307
|
+
asterPostSucceeded = true;
|
|
1308
|
+
}
|
|
1309
|
+
else {
|
|
1310
|
+
// Aster POST body indicated failure — rollback api key (Aster did not register)
|
|
1311
|
+
if (owsApiKeyId) {
|
|
1312
|
+
try {
|
|
1313
|
+
ows.revokeApiKey(owsApiKeyId);
|
|
1314
|
+
}
|
|
1315
|
+
catch { /* best effort */ }
|
|
371
1316
|
}
|
|
372
|
-
|
|
373
|
-
|
|
1317
|
+
const errMsg = resp.msg ?? resp.message ?? String(resp.code);
|
|
1318
|
+
throw new PerpError("APPROVE_PARTIAL", `Aster approveAgent failed: ${errMsg}`, {
|
|
1319
|
+
remediation: `Agent address for cleanup: ${agentEvmAddress}`,
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
else {
|
|
1324
|
+
// HTTP error — rollback api key (Aster did not register)
|
|
1325
|
+
if (owsApiKeyId) {
|
|
1326
|
+
try {
|
|
1327
|
+
ows.revokeApiKey(owsApiKeyId);
|
|
1328
|
+
}
|
|
1329
|
+
catch { /* best effort */ }
|
|
1330
|
+
}
|
|
1331
|
+
const errText = await agentRes.text().catch(() => "");
|
|
1332
|
+
throw new PerpError("APPROVE_PARTIAL", `Aster approveAgent failed (${agentRes.status}): ${errText.slice(0, 200)}`, {
|
|
1333
|
+
remediation: `Agent address for cleanup: ${agentEvmAddress}`,
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
// Step 12: Persist agent meta
|
|
1337
|
+
// Note: setAgent is re-entrant — if caller holds the lock, it writes directly.
|
|
1338
|
+
try {
|
|
1339
|
+
setAgent("aster", {
|
|
1340
|
+
agentName,
|
|
1341
|
+
agentWalletName,
|
|
1342
|
+
agentEvmAddress,
|
|
1343
|
+
userEvmAddress,
|
|
1344
|
+
masterWalletName: masterName,
|
|
1345
|
+
owsApiKeyId,
|
|
1346
|
+
owsPolicyId,
|
|
1347
|
+
expiresAt: expiresAtIso,
|
|
1348
|
+
approvedAt: nowIso,
|
|
1349
|
+
permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
|
|
1350
|
+
asterApprovalNonce: String(nonceMicros),
|
|
1351
|
+
status: "active",
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
catch (persistErr) {
|
|
1355
|
+
// Settings persist failed AFTER Aster registration succeeded.
|
|
1356
|
+
// CRITICAL-2 fix: attempt BOTH remote DELETE and local revokeApiKey.
|
|
1357
|
+
// Only if BOTH succeed → APPROVE_FAILED (clean). Otherwise → APPROVE_PARTIAL.
|
|
1358
|
+
let deleteOk = false;
|
|
1359
|
+
let revokeOk = false;
|
|
1360
|
+
// Attempt remote DELETE /fapi/v3/agent
|
|
1361
|
+
try {
|
|
1362
|
+
const delNonce = Date.now() * 1000 + Math.floor(Math.random() * 1000);
|
|
1363
|
+
const delTypedData = buildDelAgentTypedData({
|
|
1364
|
+
user: userEvmAddress,
|
|
1365
|
+
agentAddress: agentEvmAddress,
|
|
1366
|
+
nonceMicros: delNonce,
|
|
1367
|
+
asterChain,
|
|
1368
|
+
});
|
|
1369
|
+
const delSig = await masterSigner.signTypedData(delTypedData.domain, delTypedData.types, delTypedData.message);
|
|
1370
|
+
const delQs = new URLSearchParams(Object.fromEntries([
|
|
1371
|
+
["agentAddress", agentEvmAddress],
|
|
1372
|
+
["asterChain", asterChain],
|
|
1373
|
+
["user", userEvmAddress],
|
|
1374
|
+
["nonce", String(delNonce)],
|
|
1375
|
+
["signature", delSig],
|
|
1376
|
+
["signatureChainId", "56"],
|
|
1377
|
+
]));
|
|
1378
|
+
const delRes = await fetch(`${baseUrl}/fapi/v3/agent?${delQs.toString()}`, {
|
|
1379
|
+
method: "DELETE",
|
|
1380
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1381
|
+
body: "",
|
|
1382
|
+
});
|
|
1383
|
+
deleteOk = delRes.ok || delRes.status === 404;
|
|
1384
|
+
}
|
|
1385
|
+
catch { /* best effort */ }
|
|
1386
|
+
// Attempt local API key revocation
|
|
1387
|
+
if (owsApiKeyId) {
|
|
1388
|
+
try {
|
|
1389
|
+
ows.revokeApiKey(owsApiKeyId);
|
|
1390
|
+
revokeOk = true;
|
|
1391
|
+
}
|
|
1392
|
+
catch { /* best effort */ }
|
|
1393
|
+
}
|
|
1394
|
+
else {
|
|
1395
|
+
revokeOk = true; // nothing to revoke
|
|
1396
|
+
}
|
|
1397
|
+
if (deleteOk && revokeOk) {
|
|
1398
|
+
// Clean rollback — throw APPROVE_FAILED (not partial)
|
|
1399
|
+
throw new PerpError("APPROVE_FAILED", `Agent approve failed: settings persist error after clean rollback. ${persistErr instanceof Error ? persistErr.message : String(persistErr)}`, {
|
|
1400
|
+
remediation: `Both remote Aster agent and local OWS API key have been cleaned up. Retry: perp wallet agent approve aster`,
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
// Partial state — at least one cleanup failed. Save partial record for recovery.
|
|
1404
|
+
const failedSteps = [];
|
|
1405
|
+
if (!deleteOk)
|
|
1406
|
+
failedSteps.push("Aster DELETE /fapi/v3/agent");
|
|
1407
|
+
if (!revokeOk)
|
|
1408
|
+
failedSteps.push("OWS revokeApiKey");
|
|
1409
|
+
try {
|
|
1410
|
+
setAgent("aster", {
|
|
1411
|
+
agentName,
|
|
1412
|
+
agentWalletName: agentWalletName ?? "",
|
|
1413
|
+
agentEvmAddress: agentEvmAddress,
|
|
1414
|
+
userEvmAddress,
|
|
1415
|
+
masterWalletName: masterName,
|
|
1416
|
+
owsApiKeyId: owsApiKeyId ?? "",
|
|
1417
|
+
owsPolicyId: owsPolicyId ?? "",
|
|
1418
|
+
expiresAt: expiresAtIso,
|
|
1419
|
+
approvedAt: nowIso,
|
|
1420
|
+
permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
|
|
1421
|
+
asterApprovalNonce: String(nonceMicros),
|
|
1422
|
+
status: "partial",
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
catch { /* best effort — already in a bad state */ }
|
|
1426
|
+
throw new PerpError("APPROVE_PARTIAL", `Agent registered on Aster but persist+cleanup failed. Failed steps: ${failedSteps.join(", ")}. Persist error: ${persistErr instanceof Error ? persistErr.message : String(persistErr)}`, {
|
|
1427
|
+
remediation: `Aster agent address: ${agentEvmAddress}. Run: perp wallet agent approve aster --rotate to re-register`,
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
return {
|
|
1431
|
+
agentAddress: agentEvmAddress,
|
|
1432
|
+
agentName,
|
|
1433
|
+
expiresAt: expiresAtIso,
|
|
1434
|
+
owsApiKeyId,
|
|
1435
|
+
policyId: owsPolicyId,
|
|
1436
|
+
asterApprovalNonce: String(nonceMicros),
|
|
1437
|
+
userEvmAddress,
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Parse an expires-in string to a Date.
|
|
1442
|
+
* Accepts: "30d", "90d", "180d", "1y", or any ISO-8601 datetime string.
|
|
1443
|
+
* Throws if malformed.
|
|
1444
|
+
*/
|
|
1445
|
+
function parseExpiresIn(value) {
|
|
1446
|
+
const v = value.trim();
|
|
1447
|
+
// Match Nd (days) or Ny (years)
|
|
1448
|
+
const daysMatch = v.match(/^(\d+)d$/i);
|
|
1449
|
+
if (daysMatch) {
|
|
1450
|
+
const days = parseInt(daysMatch[1], 10);
|
|
1451
|
+
return new Date(Date.now() + days * 24 * 3600 * 1000);
|
|
1452
|
+
}
|
|
1453
|
+
const yearsMatch = v.match(/^(\d+)y$/i);
|
|
1454
|
+
if (yearsMatch) {
|
|
1455
|
+
const years = parseInt(yearsMatch[1], 10);
|
|
1456
|
+
return new Date(Date.now() + years * 365 * 24 * 3600 * 1000);
|
|
1457
|
+
}
|
|
1458
|
+
// Try ISO-8601
|
|
1459
|
+
const d = new Date(v);
|
|
1460
|
+
if (!isNaN(d.getTime())) {
|
|
1461
|
+
return d;
|
|
1462
|
+
}
|
|
1463
|
+
throw new PerpError("INVALID_PARAMS", `Invalid expires-in value: "${v}". Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime`, {
|
|
1464
|
+
remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
// ── Hyperliquid agent approve/revoke helpers (Phase 2b) ──────────────────────
|
|
1468
|
+
/** EIP-712 domain for HL user-signed actions (chainId 42161 = Arbitrum) */
|
|
1469
|
+
const HL_USER_SIGNED_DOMAIN = {
|
|
1470
|
+
name: "HyperliquidSignTransaction",
|
|
1471
|
+
version: "1",
|
|
1472
|
+
chainId: 42161,
|
|
1473
|
+
verifyingContract: "0x0000000000000000000000000000000000000000",
|
|
1474
|
+
};
|
|
1475
|
+
/** EIP-712 types for HyperliquidTransaction:ApproveAgent */
|
|
1476
|
+
const HL_APPROVE_AGENT_TYPES = {
|
|
1477
|
+
"HyperliquidTransaction:ApproveAgent": [
|
|
1478
|
+
{ name: "hyperliquidChain", type: "string" },
|
|
1479
|
+
{ name: "agentAddress", type: "address" },
|
|
1480
|
+
{ name: "agentName", type: "string" },
|
|
1481
|
+
{ name: "nonce", type: "uint64" },
|
|
1482
|
+
],
|
|
1483
|
+
};
|
|
1484
|
+
/**
|
|
1485
|
+
* Core HL approve flow.
|
|
1486
|
+
* Caller MUST hold the "hyperliquid" lock before calling this function.
|
|
1487
|
+
*
|
|
1488
|
+
* Steps:
|
|
1489
|
+
* 1. Resolve master via OwsEvmSigner.create
|
|
1490
|
+
* 2. Generate agent OWS wallet (eip155-only, name `agent-hl-<masterName>`)
|
|
1491
|
+
* 3. Build HL approveAgent action + EIP-712 message
|
|
1492
|
+
* 4. Master signs EIP-712 (user-signed domain, chainId 42161)
|
|
1493
|
+
* 5. POST https://api.hyperliquid.xyz/exchange
|
|
1494
|
+
* 6. On success: persist AgentMeta to settings.agents.hyperliquid[name]
|
|
1495
|
+
*/
|
|
1496
|
+
async function runHlApproveFlow(opts) {
|
|
1497
|
+
const { masterName, passphrase, agentName, expiresAtIso, nowIso, canPerp, canSpot, canWithdraw } = opts;
|
|
1498
|
+
const { ethers } = await import("ethers");
|
|
1499
|
+
// Step 1: Resolve master signer
|
|
1500
|
+
const masterSigner = OwsEvmSigner.create(masterName, passphrase);
|
|
1501
|
+
const userEvmAddress = masterSigner.getAddress();
|
|
1502
|
+
// Step 2: Generate agent OWS wallet
|
|
1503
|
+
const ows = loadOws();
|
|
1504
|
+
const agentWalletName = `agent-hl-${masterName}`;
|
|
1505
|
+
// HL agent wallet created with empty passphrase. This is intentional:
|
|
1506
|
+
// (1) Tier 1 hot-path must be prompt-free (P1 from agent_first_design.md).
|
|
1507
|
+
// (2) OWS provides at-rest encryption regardless of per-wallet passphrase.
|
|
1508
|
+
// (3) Agent blast-radius is limited (perp-only, no withdraw, no master ops).
|
|
1509
|
+
// At runtime, src/index.ts opens this wallet with "" matching the create-time empty.
|
|
1510
|
+
const agentWallet = ows.createWallet(agentWalletName, "");
|
|
1511
|
+
const agentEvmAccount = agentWallet.accounts.find((a) => a.chainId.startsWith("eip155:"));
|
|
1512
|
+
if (!agentEvmAccount) {
|
|
1513
|
+
throw new PerpError("INVALID_PARAMS", `Agent wallet "${agentWalletName}" has no EVM account`, {
|
|
1514
|
+
remediation: "Check OWS vault configuration",
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
const agentEvmAddress = agentEvmAccount.address;
|
|
1518
|
+
// Step 3: Build nonce (monotonic ms)
|
|
1519
|
+
const now = Date.now();
|
|
1520
|
+
const nonce = now;
|
|
1521
|
+
const sigChainIdHex = "0xa4b1"; // 42161 hex
|
|
1522
|
+
const action = {
|
|
1523
|
+
type: "approveAgent",
|
|
1524
|
+
hyperliquidChain: "Mainnet",
|
|
1525
|
+
signatureChainId: sigChainIdHex,
|
|
1526
|
+
agentAddress: agentEvmAddress,
|
|
1527
|
+
agentName,
|
|
1528
|
+
nonce,
|
|
1529
|
+
};
|
|
1530
|
+
// Step 4: Master signs EIP-712 (user-signed domain)
|
|
1531
|
+
const message = {
|
|
1532
|
+
hyperliquidChain: "Mainnet",
|
|
1533
|
+
agentAddress: agentEvmAddress,
|
|
1534
|
+
agentName,
|
|
1535
|
+
nonce,
|
|
1536
|
+
};
|
|
1537
|
+
const sigRaw = await masterSigner.signTypedData(HL_USER_SIGNED_DOMAIN, HL_APPROVE_AGENT_TYPES, message);
|
|
1538
|
+
const parsed = ethers.Signature.from(sigRaw);
|
|
1539
|
+
const signature = { r: parsed.r, s: parsed.s, v: parsed.v };
|
|
1540
|
+
// Step 5: POST /exchange
|
|
1541
|
+
const baseUrl = "https://api.hyperliquid.xyz";
|
|
1542
|
+
const payload = { action, nonce, signature, vaultAddress: null };
|
|
1543
|
+
const agentRes = await fetch(`${baseUrl}/exchange`, {
|
|
1544
|
+
method: "POST",
|
|
1545
|
+
headers: { "Content-Type": "application/json" },
|
|
1546
|
+
body: JSON.stringify(payload),
|
|
1547
|
+
});
|
|
1548
|
+
const text = await agentRes.text();
|
|
1549
|
+
let resp;
|
|
1550
|
+
try {
|
|
1551
|
+
resp = JSON.parse(text);
|
|
1552
|
+
}
|
|
1553
|
+
catch {
|
|
1554
|
+
throw new PerpError("EXCHANGE_ERROR", `HL exchange API parse error (${agentRes.status}): ${text.slice(0, 200)}`, {
|
|
1555
|
+
remediation: "Check Hyperliquid API status",
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
if (resp?.status === "err") {
|
|
1559
|
+
throw new PerpError("APPROVE_PARTIAL", `HL approveAgent failed: ${typeof resp.response === "string" ? resp.response : JSON.stringify(resp)}`, {
|
|
1560
|
+
remediation: `Agent address for cleanup: ${agentEvmAddress}`,
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
// Step 6: Persist
|
|
1564
|
+
setAgent("hyperliquid", {
|
|
1565
|
+
agentName,
|
|
1566
|
+
agentWalletName,
|
|
1567
|
+
agentEvmAddress,
|
|
1568
|
+
userEvmAddress,
|
|
1569
|
+
masterWalletName: masterName,
|
|
1570
|
+
owsApiKeyId: "", // HL has no OWS API key model
|
|
1571
|
+
owsPolicyId: "", // HL has no OWS Policy model
|
|
1572
|
+
expiresAt: expiresAtIso,
|
|
1573
|
+
approvedAt: nowIso,
|
|
1574
|
+
permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
|
|
1575
|
+
asterApprovalNonce: String(nonce), // reused field: stores HL nonce for diagnostics
|
|
1576
|
+
status: "active",
|
|
1577
|
+
});
|
|
1578
|
+
return { agentAddress: agentEvmAddress, agentName, expiresAt: expiresAtIso, userEvmAddress };
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Post a HL revoke action for the given agent.
|
|
1582
|
+
* Uses approveAgent with agentAddress set to the zero address (empty = revoke).
|
|
1583
|
+
* Per HL convention: sending approveAgent with an empty/zero agentAddress
|
|
1584
|
+
* clears the agent slot.
|
|
1585
|
+
*/
|
|
1586
|
+
async function revokeHlAgent(masterSigner) {
|
|
1587
|
+
const { ethers } = await import("ethers");
|
|
1588
|
+
const nonce = Date.now();
|
|
1589
|
+
const sigChainIdHex = "0xa4b1";
|
|
1590
|
+
// Zero-address revoke: approveAgent with empty agentAddress (validUntil:0 convention)
|
|
1591
|
+
const revokeAddress = "0x0000000000000000000000000000000000000000";
|
|
1592
|
+
const action = {
|
|
1593
|
+
type: "approveAgent",
|
|
1594
|
+
hyperliquidChain: "Mainnet",
|
|
1595
|
+
signatureChainId: sigChainIdHex,
|
|
1596
|
+
agentAddress: revokeAddress,
|
|
1597
|
+
agentName: "",
|
|
1598
|
+
nonce,
|
|
1599
|
+
};
|
|
1600
|
+
const message = {
|
|
1601
|
+
hyperliquidChain: "Mainnet",
|
|
1602
|
+
agentAddress: revokeAddress,
|
|
1603
|
+
agentName: "",
|
|
1604
|
+
nonce,
|
|
1605
|
+
};
|
|
1606
|
+
const sigRaw = await masterSigner.signTypedData(HL_USER_SIGNED_DOMAIN, HL_APPROVE_AGENT_TYPES, message);
|
|
1607
|
+
const parsed = ethers.Signature.from(sigRaw);
|
|
1608
|
+
const signature = { r: parsed.r, s: parsed.s, v: parsed.v };
|
|
1609
|
+
const payload = { action, nonce, signature, vaultAddress: null };
|
|
1610
|
+
await fetch("https://api.hyperliquid.xyz/exchange", {
|
|
1611
|
+
method: "POST",
|
|
1612
|
+
headers: { "Content-Type": "application/json" },
|
|
1613
|
+
body: JSON.stringify(payload),
|
|
1614
|
+
});
|
|
1615
|
+
// Revoke is best-effort — don't throw on HTTP errors
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Core Pacifica approve flow.
|
|
1619
|
+
* Caller MUST hold the "pacifica" lock before calling this function.
|
|
1620
|
+
*
|
|
1621
|
+
* Steps:
|
|
1622
|
+
* 1. Resolve master via OwsSolanaSigner.create
|
|
1623
|
+
* 2. Generate agent OWS wallet (`agent-pac-<masterName>`) with empty passphrase
|
|
1624
|
+
* 3. Build canonical bind_agent_wallet message + master Ed25519 signature
|
|
1625
|
+
* 4. POST https://api.pacifica.fi/api/v1/agent/bind
|
|
1626
|
+
* 5. On success: persist AgentMeta to settings.agents.pacifica[name]
|
|
1627
|
+
* 6. On failure: rollback agent OWS wallet + emit APPROVE_PARTIAL/APPROVE_FAILED
|
|
1628
|
+
*
|
|
1629
|
+
* Off-chain auth — no on-chain transaction, no gas required.
|
|
1630
|
+
*/
|
|
1631
|
+
async function runPacApproveFlow(opts) {
|
|
1632
|
+
const { masterName, passphrase, agentName, expiresAtIso, nowIso, canPerp, canSpot, canWithdraw, } = opts;
|
|
1633
|
+
const { buildBindAgentMessage } = await import("../exchanges/pacifica-typed-data.js");
|
|
1634
|
+
// Step 1: Resolve master Solana signer
|
|
1635
|
+
const masterSigner = OwsSolanaSigner.create(masterName, passphrase);
|
|
1636
|
+
const userSolanaAddress = masterSigner.getPublicKeyBase58();
|
|
1637
|
+
// Step 2: Generate agent OWS wallet (empty passphrase mirrors HL pattern;
|
|
1638
|
+
// the runtime opens with "" so the hot-path is prompt-free).
|
|
1639
|
+
const ows = loadOws();
|
|
1640
|
+
const agentWalletName = `agent-pac-${masterName}`;
|
|
1641
|
+
const agentWallet = ows.createWallet(agentWalletName, "");
|
|
1642
|
+
const agentSolAccount = agentWallet.accounts.find((a) => a.chainId.startsWith("solana:"));
|
|
1643
|
+
if (!agentSolAccount) {
|
|
1644
|
+
throw new PerpError("INVALID_PARAMS", `Agent wallet "${agentWalletName}" has no Solana account`, {
|
|
1645
|
+
remediation: "Check OWS vault configuration",
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
const agentSolanaAddress = agentSolAccount.address;
|
|
1649
|
+
// Step 3: Build canonical bind_agent_wallet payload + Ed25519 signature
|
|
1650
|
+
const bind = buildBindAgentMessage({
|
|
1651
|
+
account: userSolanaAddress,
|
|
1652
|
+
agentWallet: agentSolanaAddress,
|
|
1653
|
+
});
|
|
1654
|
+
const msgBytes = new TextEncoder().encode(bind.canonicalJson);
|
|
1655
|
+
const sigBytes = await masterSigner.signMessage(msgBytes);
|
|
1656
|
+
const signature = bs58.encode(sigBytes);
|
|
1657
|
+
// Step 4: POST /api/v1/agent/bind
|
|
1658
|
+
// FIXME(2c-spike): unverified — confirm against live mainnet. Pacifica's
|
|
1659
|
+
// public API documentation does not enumerate `agent/bind` explicitly; the
|
|
1660
|
+
// shape below mirrors `buildSignedRequest()` (account+signature+timestamp+
|
|
1661
|
+
// expiry_window+payload-flattened) which IS the documented Pacifica REST
|
|
1662
|
+
// signing pattern. See HypurrQuant_FE PacificaPerpAdapter.ts for the
|
|
1663
|
+
// production reference once available.
|
|
1664
|
+
const requestBody = {
|
|
1665
|
+
account: userSolanaAddress,
|
|
1666
|
+
signature,
|
|
1667
|
+
timestamp: bind.header.timestamp,
|
|
1668
|
+
expiry_window: bind.header.expiry_window,
|
|
1669
|
+
type: bind.header.type,
|
|
1670
|
+
...bind.payload, // adds agent_wallet
|
|
1671
|
+
};
|
|
1672
|
+
let pacPostSucceeded = false;
|
|
1673
|
+
let resp;
|
|
1674
|
+
try {
|
|
1675
|
+
const httpRes = await fetch("https://api.pacifica.fi/api/v1/agent/bind", {
|
|
1676
|
+
method: "POST",
|
|
1677
|
+
headers: { "Content-Type": "application/json" },
|
|
1678
|
+
body: JSON.stringify(requestBody),
|
|
1679
|
+
});
|
|
1680
|
+
if (httpRes.ok) {
|
|
1681
|
+
resp = await httpRes.json();
|
|
1682
|
+
// Pacifica REST envelope: success === true OR explicit data field
|
|
1683
|
+
if (resp.success === false || resp.error) {
|
|
1684
|
+
const errMsg = typeof resp.error === "string" ? resp.error : JSON.stringify(resp);
|
|
1685
|
+
throw new PerpError("APPROVE_PARTIAL", `Pacifica bind_agent_wallet failed: ${errMsg}`, {
|
|
1686
|
+
remediation: `Agent address for cleanup: ${agentSolanaAddress}`,
|
|
1687
|
+
});
|
|
374
1688
|
}
|
|
1689
|
+
pacPostSucceeded = true;
|
|
375
1690
|
}
|
|
376
1691
|
else {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
})
|
|
1692
|
+
const errText = await httpRes.text().catch(() => "");
|
|
1693
|
+
throw new PerpError("APPROVE_PARTIAL", `Pacifica bind_agent_wallet failed (${httpRes.status}): ${errText.slice(0, 200)}`, {
|
|
1694
|
+
remediation: `Agent address for cleanup: ${agentSolanaAddress}`,
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
catch (httpErr) {
|
|
1699
|
+
if (httpErr instanceof PerpError)
|
|
1700
|
+
throw httpErr;
|
|
1701
|
+
throw new PerpError("APPROVE_PARTIAL", `Pacifica bind_agent_wallet network error: ${httpErr instanceof Error ? httpErr.message : String(httpErr)}`, {
|
|
1702
|
+
remediation: `Agent address for cleanup: ${agentSolanaAddress}`,
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
// Step 5: Persist
|
|
1706
|
+
// PAC has no OWS API key/Policy model — leave those as empty strings.
|
|
1707
|
+
// agentEvmAddress is set to the zero address; the canonical address lives
|
|
1708
|
+
// in agentSolanaAddress.
|
|
1709
|
+
try {
|
|
1710
|
+
setAgent("pacifica", {
|
|
1711
|
+
agentName,
|
|
1712
|
+
agentWalletName,
|
|
1713
|
+
agentEvmAddress: "0x0000000000000000000000000000000000000000",
|
|
1714
|
+
userEvmAddress: "0x0000000000000000000000000000000000000000",
|
|
1715
|
+
agentSolanaAddress,
|
|
1716
|
+
userSolanaAddress,
|
|
1717
|
+
masterWalletName: masterName,
|
|
1718
|
+
owsApiKeyId: "",
|
|
1719
|
+
owsPolicyId: "",
|
|
1720
|
+
expiresAt: expiresAtIso,
|
|
1721
|
+
approvedAt: nowIso,
|
|
1722
|
+
permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
|
|
1723
|
+
asterApprovalNonce: String(bind.header.timestamp),
|
|
1724
|
+
status: "active",
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
catch (persistErr) {
|
|
1728
|
+
// Settings persist failed AFTER Pacifica registration succeeded.
|
|
1729
|
+
// Best-effort revoke + APPROVE_PARTIAL.
|
|
1730
|
+
if (pacPostSucceeded) {
|
|
1731
|
+
try {
|
|
1732
|
+
await revokePacAgent(masterSigner, agentSolanaAddress);
|
|
1733
|
+
}
|
|
1734
|
+
catch { /* best effort */ }
|
|
1735
|
+
}
|
|
1736
|
+
throw new PerpError("APPROVE_PARTIAL", `Pacifica agent registered but persist failed: ${persistErr instanceof Error ? persistErr.message : String(persistErr)}`, {
|
|
1737
|
+
remediation: `Pacifica agent address: ${agentSolanaAddress}. Run: perp wallet agent approve pacifica --rotate to re-register.`,
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
return {
|
|
1741
|
+
agentSolanaAddress,
|
|
1742
|
+
userSolanaAddress,
|
|
1743
|
+
agentName,
|
|
1744
|
+
expiresAt: expiresAtIso,
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Revoke a Pacifica agent wallet (best-effort, idempotent).
|
|
1749
|
+
* Sends signed unbind_agent_wallet to /api/v1/agent/bind. Pacifica accepts
|
|
1750
|
+
* either a specific agent base58 or an empty string (revoke all) — we pass
|
|
1751
|
+
* the specific agent address when available.
|
|
1752
|
+
*/
|
|
1753
|
+
async function revokePacAgent(masterSigner, agentSolanaAddress) {
|
|
1754
|
+
const { buildUnbindAgentMessage } = await import("../exchanges/pacifica-typed-data.js");
|
|
1755
|
+
const userSolanaAddress = masterSigner.getPublicKeyBase58();
|
|
1756
|
+
const unbind = buildUnbindAgentMessage({
|
|
1757
|
+
account: userSolanaAddress,
|
|
1758
|
+
agentWallet: agentSolanaAddress,
|
|
1759
|
+
});
|
|
1760
|
+
const msgBytes = new TextEncoder().encode(unbind.canonicalJson);
|
|
1761
|
+
const sigBytes = await masterSigner.signMessage(msgBytes);
|
|
1762
|
+
const signature = bs58.encode(sigBytes);
|
|
1763
|
+
const requestBody = {
|
|
1764
|
+
account: userSolanaAddress,
|
|
1765
|
+
signature,
|
|
1766
|
+
timestamp: unbind.header.timestamp,
|
|
1767
|
+
expiry_window: unbind.header.expiry_window,
|
|
1768
|
+
type: unbind.header.type,
|
|
1769
|
+
...unbind.payload,
|
|
1770
|
+
};
|
|
1771
|
+
// FIXME(2c-spike): unverified endpoint — Pacifica may expose a separate
|
|
1772
|
+
// `/api/v1/agent/unbind` or accept unbind via the same /agent/bind. We POST
|
|
1773
|
+
// to /agent/bind because the action type discriminates server-side per the
|
|
1774
|
+
// signing convention. Confirm against live mainnet.
|
|
1775
|
+
await fetch("https://api.pacifica.fi/api/v1/agent/bind", {
|
|
1776
|
+
method: "POST",
|
|
1777
|
+
headers: { "Content-Type": "application/json" },
|
|
1778
|
+
body: JSON.stringify(requestBody),
|
|
1779
|
+
});
|
|
1780
|
+
// Revoke is best-effort — don't throw on HTTP errors
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* Pick the next free Lighter slot in [4, 254].
|
|
1784
|
+
*
|
|
1785
|
+
* Strategy: scan settings.agents.lighter; pick `max(usedSlots) + 1` capped to
|
|
1786
|
+
* 254. If none used, default to 4. If 4 itself is used (env-driven auto-setup
|
|
1787
|
+
* default), advance to next.
|
|
1788
|
+
*/
|
|
1789
|
+
function pickNextFreeLtSlot(settings) {
|
|
1790
|
+
const ltMap = settings.agents?.lighter ?? {};
|
|
1791
|
+
const used = new Set();
|
|
1792
|
+
for (const meta of Object.values(ltMap)) {
|
|
1793
|
+
if (typeof meta.apiKeyIndex === "number")
|
|
1794
|
+
used.add(meta.apiKeyIndex);
|
|
1795
|
+
}
|
|
1796
|
+
// Slot 4 is the default Lighter env-key auto-setup index — treat as used so
|
|
1797
|
+
// agent-managed slots don't collide with the env-driven legacy path.
|
|
1798
|
+
used.add(4);
|
|
1799
|
+
for (let i = 5; i <= 254; i++) {
|
|
1800
|
+
if (!used.has(i))
|
|
1801
|
+
return i;
|
|
1802
|
+
}
|
|
1803
|
+
throw new PerpError("INVALID_PARAMS", "All Lighter slots [4, 254] are in use", {
|
|
1804
|
+
remediation: "Revoke an existing agent: perp wallet agent revoke lighter <name>",
|
|
389
1805
|
});
|
|
390
1806
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
1807
|
+
/**
|
|
1808
|
+
* Core Lighter approve flow.
|
|
1809
|
+
* Caller MUST hold the "lighter" lock before calling this function.
|
|
1810
|
+
*
|
|
1811
|
+
* Steps:
|
|
1812
|
+
* 1. Resolve master via OwsEvmSigner.create
|
|
1813
|
+
* 2. Pick free slot in [4, 254] (or use explicit apiKeyIndex when supplied)
|
|
1814
|
+
* 3. Spin up a temporary LighterAdapter bound to the master signer; init() to
|
|
1815
|
+
* resolve accountIndex from L1 EVM address
|
|
1816
|
+
* 4. Call LighterAdapter.setupApiKey(slot) — generates secp256k1 keypair via
|
|
1817
|
+
* WASM generateAPIKey, signs L1 EIP-712 + L2 ChangePubKey, POSTs sendTx
|
|
1818
|
+
* 5. On success: create agent OWS wallet (empty passphrase) to securely store
|
|
1819
|
+
* the agent secp256k1 private key for hot-path reload at trade time
|
|
1820
|
+
* 6. Persist AgentMeta with apiKeyIndex/publicKey/accountIndex to settings
|
|
1821
|
+
* 7. On failure: emit APPROVE_PARTIAL/APPROVE_FAILED. Lighter has no on-chain
|
|
1822
|
+
* rollback — the slot stays orphaned on the L2 server until overwritten.
|
|
1823
|
+
*
|
|
1824
|
+
* Off-chain auth — no on-chain BNB/ETH gas required.
|
|
1825
|
+
*/
|
|
1826
|
+
async function runLtApproveFlow(opts) {
|
|
1827
|
+
const { masterName, passphrase, agentName, expiresAtIso, nowIso, canPerp, canSpot, canWithdraw, apiKeyIndex: explicitSlot, } = opts;
|
|
1828
|
+
const { LighterAdapter } = await import("../exchanges/lighter.js");
|
|
1829
|
+
// Step 1: Resolve master EVM signer (Lighter L1 ChangePubKey is EVM EIP-712 + EIP-191).
|
|
1830
|
+
const masterSigner = OwsEvmSigner.create(masterName, passphrase);
|
|
1831
|
+
const userEvmAddress = masterSigner.getAddress();
|
|
1832
|
+
// Step 2: Pick slot.
|
|
1833
|
+
const settings = loadSettings();
|
|
1834
|
+
let chosenSlot;
|
|
1835
|
+
if (explicitSlot !== undefined) {
|
|
1836
|
+
// Validate range (defensive — caller should have already checked, but the
|
|
1837
|
+
// helper is also used from rotate where no validation runs upstream).
|
|
1838
|
+
if (!Number.isInteger(explicitSlot) || explicitSlot < 4 || explicitSlot > 254) {
|
|
1839
|
+
throw new PerpError("INVALID_PARAMS", `apiKeyIndex must be an integer in [4, 254]; got ${explicitSlot}`, {
|
|
1840
|
+
remediation: "Slots 0-3 are reserved by the Lighter frontend.",
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
chosenSlot = explicitSlot;
|
|
1844
|
+
}
|
|
1845
|
+
else {
|
|
1846
|
+
chosenSlot = pickNextFreeLtSlot(settings);
|
|
1847
|
+
}
|
|
1848
|
+
// Step 3: Spin up a LighterAdapter bound to the master EVM signer.
|
|
1849
|
+
// Constructor takes evmKey (empty since we'll inject signer); init() resolves
|
|
1850
|
+
// accountIndex from the L1 EVM address via REST.
|
|
1851
|
+
const adapter = new LighterAdapter("", false);
|
|
1852
|
+
adapter.setSigner(masterSigner);
|
|
1853
|
+
await adapter.init();
|
|
1854
|
+
if (adapter.accountIndex < 0) {
|
|
1855
|
+
throw new PerpError("INVALID_PARAMS", `No Lighter account found for master EVM address ${userEvmAddress}`, {
|
|
1856
|
+
remediation: "Deposit USDC to the master address on Lighter to create an account, then retry.",
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
// Step 4: Generate keypair + ChangePubKey via existing production infra.
|
|
1860
|
+
// setupApiKey already does:
|
|
1861
|
+
// - generateAPIKey() (WASM)
|
|
1862
|
+
// - createClient with new key
|
|
1863
|
+
// - signChangePubKey
|
|
1864
|
+
// - signMessage(messageToSign) via EVM signer (EIP-191)
|
|
1865
|
+
// - append L1Sig to txInfo
|
|
1866
|
+
// - POST /api/v1/sendTx with retry on invalid-nonce
|
|
1867
|
+
let registered;
|
|
1868
|
+
try {
|
|
1869
|
+
registered = await adapter.setupApiKey(chosenSlot);
|
|
1870
|
+
}
|
|
1871
|
+
catch (e) {
|
|
1872
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1873
|
+
throw new PerpError("APPROVE_PARTIAL", `Lighter ChangePubKey failed: ${msg}`, {
|
|
1874
|
+
remediation: `Slot ${chosenSlot} for cleanup: this slot may be partially registered on the L2 server. Retry with a different --api-key-index, or rotate.`,
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
// Step 5: Persist the agent's 40-byte L2 private key (SSOT Rule #3 —
|
|
1878
|
+
// encrypted at-rest in `~/.perp/lighter-agents/<accountIndex>-<slot>.json`).
|
|
1879
|
+
//
|
|
1880
|
+
// Lighter's L2 secp256k1 key is a non-OWS-native curve, so it cannot live in
|
|
1881
|
+
// `~/.ows/wallets/`. The keystore module mirrors OWS's AES-256-GCM + scrypt
|
|
1882
|
+
// scheme and uses an empty passphrase — same threat model as HL/PAC agents
|
|
1883
|
+
// (file mode 0600 + obfuscation, NOT cryptographic strength against a local
|
|
1884
|
+
// attacker). `accountIndex` and `apiKeyIndex` already live in
|
|
1885
|
+
// settings.agents.lighter[name], so they don't need to be duplicated in env.
|
|
1886
|
+
const agentWalletName = `agent-lt-${masterName}`;
|
|
1887
|
+
void agentWalletName; // wallet bookkeeping only — not used to derive the L2 key
|
|
1888
|
+
const { saveLighterKey } = await import("../agent-wallet/lighter-keystore.js");
|
|
1889
|
+
saveLighterKey(adapter.accountIndex, chosenSlot, registered.privateKey, "");
|
|
1890
|
+
// Step 6: Persist
|
|
1891
|
+
try {
|
|
1892
|
+
setAgent("lighter", {
|
|
1893
|
+
agentName,
|
|
1894
|
+
agentWalletName,
|
|
1895
|
+
// Lighter agent's identity on the L2 is publicKey + apiKeyIndex.
|
|
1896
|
+
// We store the master EVM address as agentEvmAddress for diagnostic
|
|
1897
|
+
// continuity (Aster/HL strict typing); the agent's real identity is
|
|
1898
|
+
// captured in the new optional fields.
|
|
1899
|
+
agentEvmAddress: userEvmAddress,
|
|
1900
|
+
userEvmAddress,
|
|
1901
|
+
apiKeyIndex: chosenSlot,
|
|
1902
|
+
publicKey: registered.publicKey.replace(/^0x/, ""),
|
|
1903
|
+
accountIndex: adapter.accountIndex,
|
|
1904
|
+
masterWalletName: masterName,
|
|
1905
|
+
owsApiKeyId: "", // Lighter has no OWS API key model
|
|
1906
|
+
owsPolicyId: "", // Lighter has no OWS Policy model
|
|
1907
|
+
expiresAt: expiresAtIso,
|
|
1908
|
+
approvedAt: nowIso,
|
|
1909
|
+
permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
|
|
1910
|
+
asterApprovalNonce: String(Date.now()), // reused field: not used by LT
|
|
1911
|
+
status: "active",
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
catch (persistErr) {
|
|
1915
|
+
throw new PerpError("APPROVE_PARTIAL", `Lighter ChangePubKey succeeded but persist failed: ${persistErr instanceof Error ? persistErr.message : String(persistErr)}`, {
|
|
1916
|
+
remediation: `Lighter slot ${chosenSlot} is registered on the L2 server. Manually update settings.agents.lighter or re-run: perp wallet agent approve lighter --api-key-index <other slot>.`,
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
return {
|
|
1920
|
+
apiKeyIndex: chosenSlot,
|
|
1921
|
+
publicKey: registered.publicKey.replace(/^0x/, ""),
|
|
1922
|
+
accountIndex: adapter.accountIndex,
|
|
1923
|
+
agentName,
|
|
1924
|
+
expiresAt: expiresAtIso,
|
|
1925
|
+
userEvmAddress,
|
|
1926
|
+
};
|
|
399
1927
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
1928
|
+
/**
|
|
1929
|
+
* Revoke a Lighter agent (local-only, idempotent).
|
|
1930
|
+
*
|
|
1931
|
+
* Lighter's L2 has no on-chain revoke action — slots persist on the server
|
|
1932
|
+
* until overwritten by a fresh ChangePubKey at the same index. This helper
|
|
1933
|
+
* is a no-op stub that exists for symmetry with the other DEX revoke helpers
|
|
1934
|
+
* (revokeHlAgent, revokePacAgent) so the dispatch reads consistently. Caller
|
|
1935
|
+
* is responsible for `deleteAgent("lighter", name)` to clear local state.
|
|
1936
|
+
*/
|
|
1937
|
+
async function revokeLtAgent() {
|
|
1938
|
+
// No-op. See AC-35: Lighter revoke is local-only.
|
|
1939
|
+
return;
|
|
403
1940
|
}
|