perp-cli 0.14.0 → 0.14.2

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.
Files changed (75) hide show
  1. package/dist/arb/sizing.js +29 -7
  2. package/dist/bot/conditions.d.ts +5 -1
  3. package/dist/bot/conditions.js +16 -11
  4. package/dist/bot/engine.d.ts +12 -0
  5. package/dist/bot/engine.js +55 -44
  6. package/dist/bot/strategies/claude-agent.js +2 -2
  7. package/dist/bot/strategies/funding-arb-strategy.js +16 -20
  8. package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
  9. package/dist/bot/strategies/funding-arb-utils.js +44 -0
  10. package/dist/bot/strategies/funding-auto.js +61 -37
  11. package/dist/bot/strategies/grid-strategy.d.ts +2 -0
  12. package/dist/bot/strategies/grid-strategy.js +21 -2
  13. package/dist/bot/strategies/spot-perp-arb-strategy.js +28 -20
  14. package/dist/bot/tui/BotDashboard.d.ts +13 -2
  15. package/dist/bot/tui/BotDashboard.js +8 -3
  16. package/dist/bridge-engine.d.ts +2 -0
  17. package/dist/bridge-engine.js +68 -31
  18. package/dist/cli-program.js +19 -2
  19. package/dist/commands/account.js +2 -2
  20. package/dist/commands/arb/index.d.ts +20 -0
  21. package/dist/commands/arb/index.js +121 -36
  22. package/dist/commands/arb-auto.d.ts +36 -0
  23. package/dist/commands/arb-auto.js +202 -76
  24. package/dist/commands/arb.js +51 -35
  25. package/dist/commands/bot.d.ts +6 -0
  26. package/dist/commands/bot.js +55 -45
  27. package/dist/commands/funds-onchain.d.ts +1 -1
  28. package/dist/commands/funds-onchain.js +12 -49
  29. package/dist/commands/funds.js +51 -52
  30. package/dist/commands/history.js +165 -62
  31. package/dist/commands/portfolio.d.ts +5 -0
  32. package/dist/commands/portfolio.js +58 -20
  33. package/dist/commands/rebalance.js +46 -10
  34. package/dist/commands/settings.js +2 -1
  35. package/dist/commands/trade.js +8 -4
  36. package/dist/commands/wallet.js +74 -155
  37. package/dist/cross-chain-margin.d.ts +29 -2
  38. package/dist/cross-chain-margin.js +30 -4
  39. package/dist/dashboard/server.d.ts +45 -8
  40. package/dist/dashboard/server.js +113 -42
  41. package/dist/dashboard/ui.js +46 -27
  42. package/dist/dashboard/ws-feeds.d.ts +25 -3
  43. package/dist/dashboard/ws-feeds.js +219 -82
  44. package/dist/dex-asset-map.d.ts +23 -4
  45. package/dist/dex-asset-map.js +39 -13
  46. package/dist/exchanges/arcus/adapter.js +33 -6
  47. package/dist/exchanges/arcus/public.d.ts +6 -0
  48. package/dist/exchanges/arcus/public.js +12 -4
  49. package/dist/exchanges/aster.d.ts +33 -0
  50. package/dist/exchanges/aster.js +162 -55
  51. package/dist/exchanges/hyperliquid.d.ts +30 -0
  52. package/dist/exchanges/hyperliquid.js +159 -34
  53. package/dist/exchanges/lighter.d.ts +1 -1
  54. package/dist/exchanges/lighter.js +11 -9
  55. package/dist/exchanges/ondo/adapter.d.ts +4 -18
  56. package/dist/exchanges/ondo/adapter.js +5 -29
  57. package/dist/exchanges/ondo/funds.d.ts +1 -29
  58. package/dist/exchanges/ondo/funds.js +2 -94
  59. package/dist/exchanges/pacifica.d.ts +12 -0
  60. package/dist/exchanges/pacifica.js +78 -35
  61. package/dist/exchanges/qfex/adapter.js +4 -1
  62. package/dist/exchanges/venues/ondo.d.ts +0 -5
  63. package/dist/exchanges/venues/ondo.js +2 -6
  64. package/dist/exchanges/venues/qfex.d.ts +11 -0
  65. package/dist/exchanges/venues/qfex.js +10 -2
  66. package/dist/mcp-server.js +2 -2
  67. package/dist/onchain-balances.d.ts +40 -0
  68. package/dist/onchain-balances.js +173 -0
  69. package/dist/rebalance.d.ts +20 -1
  70. package/dist/rebalance.js +31 -5
  71. package/dist/strategies/funding-arb.js +38 -7
  72. package/package.json +1 -1
  73. package/skills/perp-cli/SKILL.md +20 -3
  74. package/skills/perp-cli/references/agent-operations.md +2 -2
  75. package/skills/perp-cli/references/commands.md +12 -9
@@ -162,17 +162,10 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
162
162
  .option("--background", "Run in background (tmux)")
163
163
  .option("--headless", "Run without TUI dashboard")
164
164
  .option("--job-id <id>", "Job ID")
165
- .action(async (opts) => {
166
- const exchangeNames = opts.exchanges.split(",").map(e => e.trim());
167
- const adapters = new Map();
168
- for (const name of exchangeNames) {
169
- try {
170
- adapters.set(name, await getAdapterFor(name));
171
- }
172
- catch {
173
- // skip unavailable
174
- }
175
- }
165
+ .action(async (opts, cmd) => {
166
+ const adapters = await loadBotVenueAdapters(opts.exchanges.split(",").map(e => e.trim()), cmd.getOptionValueSource("exchanges") === "cli", getAdapterFor);
167
+ // The strategy scans only the venues that loaded.
168
+ const exchangeNames = [...adapters.keys()];
176
169
  if (adapters.size < 2) {
177
170
  console.error(chalk.red("\n Need at least 2 exchanges for arbitrage.\n"));
178
171
  return;
@@ -207,7 +200,8 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
207
200
  `strategy`, `quick-arb`,
208
201
  `--min-spread`, opts.minSpread, `--close-spread`, opts.closeSpread,
209
202
  `--size`, opts.size, `--max-positions`, opts.maxPositions,
210
- `--exchanges`, opts.exchanges, `--interval`, opts.interval,
203
+ // The venues the parent loaded (a skipped default venue is not re-tried as "explicit").
204
+ `--exchanges`, exchangeNames.join(","), `--interval`, opts.interval,
211
205
  `--max-drawdown`, opts.maxDrawdown,
212
206
  ];
213
207
  const job = startJob({
@@ -241,17 +235,10 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
241
235
  .option("--background", "Run in background (tmux)")
242
236
  .option("--headless", "Run without TUI dashboard")
243
237
  .option("--job-id <id>", "Job ID")
244
- .action(async (opts) => {
245
- const exchangeNames = opts.exchanges.split(",").map(e => e.trim());
246
- const adapters = new Map();
247
- for (const name of exchangeNames) {
248
- try {
249
- adapters.set(name, await getAdapterFor(name));
250
- }
251
- catch {
252
- // skip unavailable
253
- }
254
- }
238
+ .action(async (opts, cmd) => {
239
+ const adapters = await loadBotVenueAdapters(opts.exchanges.split(",").map(e => e.trim()), cmd.getOptionValueSource("exchanges") === "cli", getAdapterFor);
240
+ // The strategy trades only the venues that loaded.
241
+ const exchangeNames = [...adapters.keys()];
255
242
  if (adapters.size < 1) {
256
243
  console.error(chalk.red("\n Need at least 1 exchange for delta-neutral.\n"));
257
244
  return;
@@ -287,7 +274,7 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
287
274
  `strategy`, `delta-neutral`,
288
275
  `--min-rate`, opts.minRate, `--close-rate`, opts.closeRate,
289
276
  `--size`, opts.size, `--max-positions`, opts.maxPositions,
290
- `--leverage`, opts.leverage, `--exchanges`, opts.exchanges,
277
+ `--leverage`, opts.leverage, `--exchanges`, exchangeNames.join(","),
291
278
  `--interval`, opts.interval, `--max-drawdown`, opts.maxDrawdown,
292
279
  ];
293
280
  const job = startJob({
@@ -355,15 +342,9 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
355
342
  const built = preset.buildConfig("multi", sym);
356
343
  const config = opts.size !== undefined ? withPresetSize(built, opts.size) : built;
357
344
  const arbStrategy = config.strategy;
358
- const adapters = new Map();
359
- for (const exName of arbStrategy.exchanges) {
360
- try {
361
- adapters.set(exName, await getAdapterFor(exName));
362
- }
363
- catch {
364
- // skip unavailable
365
- }
366
- }
345
+ // The preset's venue list is a default (not user-passed): a failing venue is skipped with a warning.
346
+ const adapters = await loadBotVenueAdapters(arbStrategy.exchanges, false, getAdapterFor);
347
+ arbStrategy.exchanges = [...adapters.keys()];
367
348
  if (adapters.size < 2) {
368
349
  console.error(chalk.red("\n Need at least 2 exchanges for arbitrage.\n"));
369
350
  return;
@@ -756,6 +737,44 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
756
737
  // Nested scripted plans: `perp strategy plan {validate|execute|example}`
757
738
  registerStrategyPlanCommands(bot, getAdapter, isJson);
758
739
  }
740
+ /**
741
+ * Adapters for a multi-venue bot. An explicitly passed list (--exchanges on the command line)
742
+ * must load in full: a venue that fails refuses the start with a PerpError naming it. A default
743
+ * list skips a failing venue with a stderr warning naming it and the cause.
744
+ */
745
+ export async function loadBotVenueAdapters(names, explicit, getAdapterFor) {
746
+ const adapters = new Map();
747
+ for (const name of names) {
748
+ try {
749
+ const a = await getAdapterFor(name);
750
+ // A venue without credentials gets a read-only adapter: it can neither trade nor report
751
+ // the bot's balances, so it is "not configured" — refused when listed explicitly.
752
+ if (a.isReadOnly === true) {
753
+ if (explicit) {
754
+ throw new PerpError("NO_SIGNER_AVAILABLE", `--exchanges venue ${name} has no credentials configured`, {
755
+ exchange: name, remediation: `Set it up (perp setup) or remove it from --exchanges`,
756
+ });
757
+ }
758
+ process.stderr.write(`[info] ${name} not configured (no credentials) — the bot runs without it\n`);
759
+ continue;
760
+ }
761
+ adapters.set(name, a);
762
+ }
763
+ catch (e) {
764
+ if (e instanceof PerpError && e.structured.code === "NO_SIGNER_AVAILABLE" && explicit)
765
+ throw e;
766
+ const cause = e instanceof Error ? e.message : String(e);
767
+ if (explicit) {
768
+ throw new PerpError(e instanceof PerpError ? e.structured.code : "EXCHANGE_ERROR", `--exchanges venue ${name} could not be initialised: ${cause}`, {
769
+ exchange: name,
770
+ remediation: `Fix ${name}'s setup (credentials / network: perp -e ${name} portfolio), or remove it from --exchanges`,
771
+ });
772
+ }
773
+ process.stderr.write(`[warn] ${name} unavailable (${cause}) — the bot runs without it\n`);
774
+ }
775
+ }
776
+ return adapters;
777
+ }
759
778
  function makeLog() {
760
779
  return (msg) => {
761
780
  const ts = new Date().toLocaleTimeString();
@@ -898,19 +917,10 @@ function registerRunSubcommands(parent, getAdapter, getAdapterFor, isJson) {
898
917
  .option("--rebalance-threshold <usd>", "Rebalance when available < this ($)", "100")
899
918
  .option("--max-drawdown <usd>", "Close all positions if total uPnL exceeds this loss ($)")
900
919
  .option("--job-id <id>", "Job ID (set automatically for background jobs)")
901
- .action(async (opts) => {
920
+ .action(async (opts, cmd) => {
902
921
  const exchangeNames = opts.exchanges.split(",").map((e) => e.trim());
903
- const adapters = new Map();
904
- for (const name of exchangeNames) {
905
- try {
906
- const a = await getAdapterFor(name);
907
- adapters.set(name, a);
908
- }
909
- catch {
910
- if (!isJson())
911
- console.log(chalk.yellow(` Skipping ${name} (no credentials)`));
912
- }
913
- }
922
+ // An explicitly listed venue that fails to load refuses the start; a default one is skipped with a warning.
923
+ const adapters = await loadBotVenueAdapters(exchangeNames, cmd.getOptionValueSource("exchanges") === "cli", getAdapterFor);
914
924
  if (adapters.size < 2) {
915
925
  console.error(chalk.red("Need at least 2 exchanges for arbitrage. Set credentials for 2+ exchanges."));
916
926
  process.exit(1);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `funds deposit aster|ondo`, `funds withdraw aster`, `funds address-book add ondo` — fund movements
2
+ * `funds deposit aster|ondo`, `funds withdraw aster` — fund movements
3
3
  * whose on-chain leg is signed by the OWS master wallet (src/evm-master-tx.ts).
4
4
  *
5
5
  * Every command: `--dry-run` prints the plan and returns before any key, adapter or network is
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `funds deposit aster|ondo`, `funds withdraw aster`, `funds address-book add ondo` — fund movements
2
+ * `funds deposit aster|ondo`, `funds withdraw aster` — fund movements
3
3
  * whose on-chain leg is signed by the OWS master wallet (src/evm-master-tx.ts).
4
4
  *
5
5
  * Every command: `--dry-run` prints the plan and returns before any key, adapter or network is
@@ -12,7 +12,7 @@ import { cliCredentialOptsFrom } from "../config.js";
12
12
  import { PerpError, parsePositiveDecimal } from "../errors.js";
13
13
  import { logExecution } from "../execution-log.js";
14
14
  import { unsupported } from "../exchanges/trading-v2.js";
15
- import { getVenue, networkConfig } from "../exchanges/venue.js";
15
+ import { getVenue } from "../exchanges/venue.js";
16
16
  import { ASTER_FUNDS_CHAINS } from "../exchanges/venues/aster.js";
17
17
  import { ONDO_DEPOSIT_CHAINS } from "../exchanges/venues/ondo.js";
18
18
  import { jsonOk, printJson } from "../utils.js";
@@ -235,52 +235,15 @@ export function registerOnchainFundsCommands(ctx) {
235
235
  }
236
236
  });
237
237
  // ═══ Address book (Ondo withdrawal destinations) ═══
238
- const addressBook = funds.command("address-book").description("Withdrawal address book (Ondo Perps only withdraws to listed addresses)");
239
- supportsDryRun(addressBook.command("add <venue> <address>"))
240
- .description("Add the master wallet's own address to the Ondo Perps withdrawal address book (SIWE challenge signed by the OWS master wallet; supports --dry-run)")
241
- .option("--label <text>", "Label of the address book entry")
242
- .action(async (venue, address, opts) => {
243
- const def = getVenue(venue);
244
- if (def.id !== "ondo") {
245
- throw unsupported(def.displayName, "address-book", "only Ondo Perps has a withdrawal address book", "perp funds address-book add ondo <address>");
246
- }
247
- const net = getNetwork();
248
- if (!/^0x[0-9a-fA-F]{40}$/.test(address))
249
- throw new PerpError("INVALID_PARAMS", `"${address}" is not an EVM address`);
250
- const plan = {
251
- venue: "ondo", network: net, address, label: opts.label === undefined ? null : opts.label,
252
- steps: "POST /v1/auth/erc-4361/address_book/get_challenge → personal_sign with the OWS master wallet → POST …/complete_challenge",
253
- };
254
- if (isDryRun()) {
255
- if (isJson())
256
- return printJson(jsonOk({ dryRun: true, sent: false, action: "address-book add", ...plan }));
257
- console.log(chalk.yellow(`\n [dry-run] Would add ${address} to the Ondo Perps address book (${net})`));
258
- console.log(` steps: ${plan.steps}`);
259
- console.log(chalk.gray(" Nothing was signed or sent.\n"));
260
- return;
261
- }
262
- const { loadMasterEvmSigner } = await import("../evm-master-tx.js");
263
- const signer = await loadMasterEvmSigner("ondo", creds(), "Adding an Ondo address book entry");
264
- if (signer.getAddress().toLowerCase() !== address.toLowerCase()) {
265
- throw new PerpError("INVALID_PARAMS", `The CLI only adds the master wallet's own address (${signer.getAddress()}) to the Ondo address book; ${address} is a different address`, {
266
- remediation: "Add other addresses in the Ondo Perps web app (Withdraw → address book)",
267
- });
268
- }
269
- const { OndoAdapter } = await import("../exchanges/ondo/adapter.js");
270
- const adapter = await getAdapterForExchange("ondo");
271
- if (!(adapter instanceof OndoAdapter))
272
- throw new Error("ondo venue did not produce an OndoAdapter");
273
- const { addOndoAddressBookEntry } = await import("../exchanges/ondo/funds.js");
274
- const res = await addOndoAddressBookEntry(adapter.restClient, {
275
- wallet: signer.getAddress(), restUrl: networkConfig(def, net).restUrl, label: plan.label, signMessage: m => signer.signMessage(m),
276
- });
277
- if (isJson())
278
- return printJson(jsonOk({ venue: "ondo", network: net, ...res }));
279
- if (res.alreadyListed)
280
- console.log(chalk.green(`\n ${res.address} is already on the Ondo Perps address book.\n`));
281
- else if (res.listed)
282
- console.log(chalk.green(`\n Added ${res.address} to the Ondo Perps address book.\n`));
283
- else
284
- console.log(chalk.yellow(`\n Ondo accepted the challenge, but ${res.address} is not listed yet (a cooldown may apply). Check the Ondo Perps app before withdrawing.\n`));
238
+ // Ondo Perps withdrawals are web-app only (the API does not allow them), so an address-book
239
+ // entry made here would serve nothing. The command stays so the refusal is explicit.
240
+ const addressBook = funds.command("address-book").description("Not available: the Ondo Perps withdrawal address book is managed in the web app");
241
+ // Optional arguments: a bare `address-book add` gets the same explicit refusal, not a usage error.
242
+ supportsDryRun(addressBook.command("add [venue] [address]"))
243
+ .description("Not available: no venue offers a withdrawal address book over the CLI (Ondo Perps: web app only; refused, also with --dry-run)")
244
+ .option("--label <text>", "Ignored")
245
+ // Venue-neutral: no venue has a CLI address book, so the arguments are not even resolved.
246
+ .action(async () => {
247
+ throw unsupported("perp-cli", "address-book", "perp-cli has no withdrawal address book for any venue (Ondo Perps withdrawals are web-app only)", "Manage withdrawals and address books in the venue's web app");
285
248
  });
286
249
  }
@@ -10,8 +10,7 @@ import { cliCredentialOptsFrom, requireMasterWallet } from "../config.js";
10
10
  import { getVenue, requireCapabilities, READ_ONLY_CREDENTIALS } from "../exchanges/venue.js";
11
11
  import { unsupported } from "../exchanges/trading-v2.js";
12
12
  import { registerOnchainFundsCommands } from "./funds-onchain.js";
13
- import { ONDO_WITHDRAW_NETWORK } from "../exchanges/venues/ondo.js";
14
- import { QFEX_BANKER_NETWORK, QFEX_WITHDRAW_NETWORK } from "../exchanges/venues/qfex.js";
13
+ import { QFEX_BANKER_NETWORK, QFEX_WITHDRAW_DISABLED, QFEX_WITHDRAW_ENABLED, QFEX_WITHDRAW_NETWORK } from "../exchanges/venues/qfex.js";
15
14
  import { loadSettings } from "../settings.js";
16
15
  import { assertWithdrawAllowed, forgetWithdrawAttempt, recordWithdrawAttempt } from "../withdraw-gate.js";
17
16
  import "../exchanges/venues/index.js";
@@ -95,6 +94,13 @@ export function chainLabel(chain) {
95
94
  throw new Error(`no display label for chain "${chain}" (add it to CHAIN_LABELS)`);
96
95
  return label;
97
96
  }
97
+ /** Circle forwarding fee per HL CCTP destination, from HL_CCTP_ROUTES (null = not published). */
98
+ async function hlCctpFeeText() {
99
+ const { HL_CCTP_ROUTES } = await import("../exchanges/hyperliquid.js");
100
+ return Object.entries(HL_CCTP_ROUTES)
101
+ .map(([chain, r]) => `${chain}: ${r.forwardingFeeUsdc === null ? "fee not published" : `${r.forwardingFeeUsdc} USDC`}`)
102
+ .join(", ");
103
+ }
98
104
  /** Withdraw asset declared by the venue definition (capabilities.withdrawAssets[0]). */
99
105
  function withdrawAssetOf(def) {
100
106
  const assets = requireCapabilities(def).withdrawAssets;
@@ -105,10 +111,9 @@ function withdrawAssetOf(def) {
105
111
  }
106
112
  /** Withdrawal chain of venues that settle off-chain and pay out on a public chain. */
107
113
  const OFFCHAIN_WITHDRAW_CHAIN = {
108
- ondo: ONDO_WITHDRAW_NETWORK,
109
114
  qfex: QFEX_WITHDRAW_NETWORK,
110
115
  };
111
- /** Chain a venue's withdrawals arrive on (Ondo / QFEX settle off-chain and pay out on Ethereum / Arbitrum). */
116
+ /** Chain a venue's withdrawals arrive on (QFEX settles off-chain and pays out on Arbitrum). */
112
117
  function withdrawChainOf(def, net) {
113
118
  const payout = OFFCHAIN_WITHDRAW_CHAIN[def.id];
114
119
  if (payout !== undefined)
@@ -1044,14 +1049,20 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
1044
1049
  throw err;
1045
1050
  }
1046
1051
  });
1047
- // ── Ondo Perps / Arcus — uniform surface (design §13.1): same <amount> + --to,
1052
+ // ── Arcus — uniform surface (design §13.1): same <amount> + --to,
1048
1053
  // same jsonOk(result), global --dry-run plans without sending. ──
1049
- for (const venueId of ["ondo", "arcus"]) {
1054
+ // Ondo Perps: the API does not allow withdrawals (address-book destinations only; adding one is
1055
+ // refused for API keys). The command stays so the refusal is explicit — also under --dry-run.
1056
+ supportsDryRun(withdraw.command("ondo [amount]"))
1057
+ .description("Not available: Ondo Perps withdrawals are web-app only (refused, also with --dry-run)")
1058
+ .option("--to <address>", "Ignored — the API cannot withdraw")
1059
+ .action(async () => {
1060
+ throw unsupported("Ondo Perps", "withdraw", "the Ondo Perps API does not allow withdrawals", "Withdraw from the Ondo Perps web app");
1061
+ });
1062
+ for (const venueId of ["arcus"]) {
1050
1063
  const def = getVenue(venueId);
1051
1064
  const asset = withdrawAssetOf(def);
1052
- const toHelp = venueId === "ondo"
1053
- ? "Destination EVM address (required; must be on the Ondo address book — see: perp funds address-book add ondo <address>)"
1054
- : "Destination EVM address (must equal the Arcus account owner — the default)";
1065
+ const toHelp = "Destination EVM address (must equal the Arcus account owner — the default)";
1055
1066
  supportsDryRun(withdraw.command(`${venueId} <amount>`))
1056
1067
  .description(`Withdraw ${asset} from ${def.displayName} to your ${chainLabel(withdrawChainOf(def, "mainnet"))} wallet (supports --dry-run)`)
1057
1068
  .option("--to <address>", toHelp)
@@ -1065,27 +1076,16 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
1065
1076
  const adapter = await getAdapterForExchange(venueId);
1066
1077
  if (!isWithdrawCapable(adapter))
1067
1078
  throw new Error(`${def.id} adapter has no withdraw()`);
1068
- let destination;
1069
- if (venueId === "ondo") {
1070
- if (opts.to === undefined) {
1071
- throw new PerpError("INVALID_PARAMS", "Ondo Perps withdraw needs a destination address (the API key has no owner address)", {
1072
- remediation: `perp funds withdraw ondo ${amount} --to <0x address on your Ondo address book>`,
1073
- });
1074
- }
1075
- destination = opts.to;
1076
- }
1077
- else {
1078
- const { ArcusAdapter } = await import("../exchanges/arcus/adapter.js");
1079
- if (!(adapter instanceof ArcusAdapter))
1080
- throw new Error("arcus venue did not produce an ArcusAdapter");
1081
- const owner = adapter.withdrawDestination;
1082
- if (opts.to !== undefined && opts.to.toLowerCase() !== owner.toLowerCase()) {
1083
- throw new PerpError("INVALID_PARAMS", `Arcus only withdraws to the account owner ${owner}`, {
1084
- remediation: `Omit --to (defaults to ${owner})`,
1085
- });
1086
- }
1087
- destination = owner;
1079
+ const { ArcusAdapter } = await import("../exchanges/arcus/adapter.js");
1080
+ if (!(adapter instanceof ArcusAdapter))
1081
+ throw new Error("arcus venue did not produce an ArcusAdapter");
1082
+ const owner = adapter.withdrawDestination;
1083
+ if (opts.to !== undefined && opts.to.toLowerCase() !== owner.toLowerCase()) {
1084
+ throw new PerpError("INVALID_PARAMS", `Arcus only withdraws to the account owner ${owner}`, {
1085
+ remediation: `Omit --to (defaults to ${owner})`,
1086
+ });
1088
1087
  }
1088
+ const destination = owner;
1089
1089
  const plan = { venue: def.id, network: net, asset, amount: String(amountNum), destination, chain };
1090
1090
  if (isDryRun()) {
1091
1091
  logExecution({
@@ -1100,16 +1100,6 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
1100
1100
  console.log(chalk.gray(" Nothing was sent.\n"));
1101
1101
  return;
1102
1102
  }
1103
- if (venueId === "ondo") {
1104
- // Address-book preflight (read-only): Ondo refuses destinations that are not listed.
1105
- const { OndoAdapter } = await import("../exchanges/ondo/adapter.js");
1106
- if (!(adapter instanceof OndoAdapter))
1107
- throw new Error("ondo venue did not produce an OndoAdapter");
1108
- const book = await adapter.addressBook();
1109
- if (!book.some(a => a.toLowerCase() === destination.toLowerCase())) {
1110
- throw (await import("../exchanges/ondo/funds.js")).ondoAddressNotOnBook(destination, book);
1111
- }
1112
- }
1113
1103
  if (!isJson()) {
1114
1104
  console.log(chalk.cyan(`\n Withdrawing ${formatUsd(amountNum)} ${asset} from ${def.displayName}...\n`));
1115
1105
  console.log(` Destination: ${destination} (${chain})`);
@@ -1139,7 +1129,7 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
1139
1129
  });
1140
1130
  }
1141
1131
  // ── QFEX (design §14.6, §14.12 #8) — custodial; deposit = address display, withdraw = banker POST
1142
- // gated by settings.withdraw.qfex inside QfexAdapter.withdraw(). ──
1132
+ // gated by settings.withdraw.qfex inside QfexAdapter.withdraw(); switched off (QFEX_WITHDRAW_ENABLED). ──
1143
1133
  /** QFEX adapter for the active network — the default imported key, or the one named by --agent. */
1144
1134
  const qfexAdapterFor = async (agentName) => {
1145
1135
  const { QfexAdapter: Cls } = await import("../exchanges/qfex/adapter.js");
@@ -1164,11 +1154,15 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
1164
1154
  throw new Error("qfex venue did not produce a QfexAdapter");
1165
1155
  return adapter;
1166
1156
  };
1167
- supportsDryRun(withdraw.command("qfex <amount>"))
1168
- .description("Withdraw USDC from QFEX to an allowlisted Arbitrum One address (needs settings withdraw.qfex and a key with the \"Deposit and withdraw\" permission; supports --dry-run)")
1157
+ supportsDryRun(withdraw.command(QFEX_WITHDRAW_ENABLED ? "qfex <amount>" : "qfex [amount]"))
1158
+ .description(QFEX_WITHDRAW_ENABLED
1159
+ ? "Withdraw USDC from QFEX to an allowlisted Arbitrum One address (needs settings withdraw.qfex and a key with the \"Deposit and withdraw\" permission; supports --dry-run)"
1160
+ : "Not available: QFEX withdrawals are disabled in perp-cli for now — use the QFEX web app (refused, also with --dry-run)")
1169
1161
  .option("--to <address>", "Destination EVM address on Arbitrum One (required; must be on the settings withdraw.qfex allowlist)")
1170
1162
  .option("--agent <name>", "Imported QFEX key to use (a key with the \"Deposit and withdraw\" permission, bound to the primary account)")
1171
1163
  .action(async (amount, opts) => {
1164
+ if (!QFEX_WITHDRAW_ENABLED)
1165
+ throw unsupported("QFEX", "withdraw", QFEX_WITHDRAW_DISABLED.reason, QFEX_WITHDRAW_DISABLED.remediation);
1172
1166
  const def = getVenue("qfex");
1173
1167
  const net = getNetwork();
1174
1168
  if (!/^\d+(\.\d+)?$/.test(amount) || !(Number(amount) > 0)) {
@@ -1247,7 +1241,7 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
1247
1241
  console.log(chalk.yellow(`\n ${info.warning}`));
1248
1242
  console.log(chalk.gray(" perp-cli did not send anything — transfer from your own wallet or exchange.\n"));
1249
1243
  });
1250
- // ── Aster deposit / withdraw, Ondo deposit, Ondo address book (OWS-master on-chain legs) ──
1244
+ // ── Aster deposit / withdraw, Ondo deposit (OWS-master on-chain legs; address book refused) ──
1251
1245
  registerOnchainFundsCommands({
1252
1246
  program, funds, deposit, withdraw, isJson, isDryRun, getNetwork, getAdapterForExchange,
1253
1247
  requireMaster, printFundsPlan, failStatus: fundsFailStatus,
@@ -1358,11 +1352,13 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
1358
1352
  },
1359
1353
  withdraw: {
1360
1354
  pacifica: { chain: "Solana", token: "USDC", speed: "~10s", fee: "none" },
1361
- hyperliquid: { chain: "Arbitrum", token: "USDC", speed: "minutes", fee: "$1" },
1355
+ hyperliquid: { chain: "Arbitrum (default) / Ethereum / Base", token: "USDC", speed: "minutes (CCTP attestation)", method: "CCTP (sendToEvmWithData)", fee: await hlCctpFeeText(), command: "perp funds withdraw hyperliquid <amount> [--chain arbitrum|ethereum|base]" },
1362
1356
  lighter: { chain: "Ethereum L1", token: "USDC", speed: "~12 hours (standard), minutes (fast)", fee: "varies" },
1363
1357
  "lighter-rh": { chain: "Robinhood Chain", token: "USDG", command: "perp -e lighter-rh funds withdraw lighter <amount>" },
1364
- ondo: { chain: "Ethereum", token: "USDC", command: "perp funds withdraw ondo <amount> --to <address>", addressBook: "destination must be listed: perp funds address-book add ondo <master address>" },
1365
- qfex: { chain: "Arbitrum", token: "USDC", command: "perp funds withdraw qfex <amount> --to <allowlisted address>", gate: "settings withdraw.qfex (allowlist, maxPerTx, maxPerDay)", note: "needs a key with the \"Deposit and withdraw\" permission (All accounts access)" },
1358
+ ondo: { supported: false, note: "the Ondo Perps API does not allow withdrawals — withdraw in the Ondo Perps web app" },
1359
+ qfex: QFEX_WITHDRAW_ENABLED
1360
+ ? { chain: chainLabel(QFEX_WITHDRAW_NETWORK), token: "USDC", command: "perp funds withdraw qfex <amount> --to <allowlisted address>", gate: "settings withdraw.qfex (allowlist, maxPerTx, maxPerDay)", note: "needs a key with the \"Deposit and withdraw\" permission" }
1361
+ : { supported: false, note: `${QFEX_WITHDRAW_DISABLED.reason} — ${QFEX_WITHDRAW_DISABLED.remediation}` },
1366
1362
  arcus: { chain: "Robinhood Chain", token: "USDG", command: "perp funds withdraw arcus <amount>" },
1367
1363
  aster: { chain: "BNB Chain (default) / Arbitrum", token: "USDT (default) / USDC", command: "perp funds withdraw aster <amount> [--chain bsc|arbitrum] [--asset USDT|USDC]", fee: "POST /fapi/v3/aster/user-withdraw-info (taken from the amount)", requires: "mainnet; API wallet approved with --can-withdraw --ip-whitelist; destination = the account's own address" },
1368
1364
  },
@@ -1403,19 +1399,22 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
1403
1399
  console.log(chalk.white.bold(" Pacifica") + chalk.gray(" → Solana"));
1404
1400
  console.log(` Speed: ~10s | Fee: none`);
1405
1401
  console.log(` Command: ${chalk.green("perp funds withdraw pacifica <amount>")}`);
1406
- console.log(chalk.white.bold("\n Hyperliquid") + chalk.gray(" → Arbitrum"));
1407
- console.log(` Speed: minutes | Fee: ~$1`);
1408
- console.log(` Command: ${chalk.green("perp -e hl funds withdraw hyperliquid <amount>")}`);
1402
+ console.log(chalk.white.bold("\n Hyperliquid") + chalk.gray(" → Arbitrum (default) / Ethereum / Base over CCTP"));
1403
+ console.log(` Fee: ${await hlCctpFeeText()}`);
1404
+ console.log(` Command: ${chalk.green("perp funds withdraw hyperliquid <amount> [--chain arbitrum|ethereum|base]")}`);
1409
1405
  console.log(chalk.white.bold("\n Lighter") + chalk.gray(" → Ethereum L1"));
1410
1406
  console.log(` Speed: ~12h (standard) | Fee: varies`);
1411
1407
  console.log(` Command: ${chalk.green("perp -e lt funds withdraw lighter <amount>")}`);
1412
1408
  console.log(chalk.white.bold("\n Lighter RH") + chalk.gray(" → Robinhood Chain (USDG)"));
1413
1409
  console.log(` Command: ${chalk.green("perp -e lighter-rh funds withdraw lighter <amount>")}`);
1414
- console.log(chalk.white.bold("\n Ondo Perps") + chalk.gray(" → Ethereum (USDC)"));
1415
- console.log(` Command: ${chalk.green("perp funds withdraw ondo <amount> --to <address>")}`);
1416
- console.log(` Address book: ${chalk.green("perp funds address-book add ondo <your master address>")}`);
1410
+ console.log(chalk.white.bold("\n Ondo Perps") + chalk.gray(" — not available over the API"));
1411
+ console.log(chalk.gray(" Withdraw in the Ondo Perps web app."));
1417
1412
  console.log(chalk.white.bold("\n Arcus") + chalk.gray(" → Robinhood Chain (USDG, to the account owner)"));
1418
1413
  console.log(` Command: ${chalk.green("perp funds withdraw arcus <amount>")}`);
1414
+ console.log(chalk.white.bold("\n QFEX") + chalk.gray(QFEX_WITHDRAW_ENABLED ? ` → ${chainLabel(QFEX_WITHDRAW_NETWORK)} (USDC, allowlisted address)` : " — disabled in perp-cli for now"));
1415
+ console.log(QFEX_WITHDRAW_ENABLED
1416
+ ? ` Command: ${chalk.green("perp funds withdraw qfex <amount> --to <allowlisted address>")}`
1417
+ : chalk.gray(` ${QFEX_WITHDRAW_DISABLED.remediation}.`));
1419
1418
  console.log(chalk.white.bold("\n Aster") + chalk.gray(" → BNB Chain / Arbitrum (USDT, to the account's own address; mainnet only)"));
1420
1419
  console.log(` Needs an API wallet approved with --can-withdraw --ip-whitelist; fee from user-withdraw-info`);
1421
1420
  console.log(` Command: ${chalk.green("perp funds withdraw aster <amount> [--chain bsc|arbitrum] [--asset USDT|USDC]")}`);