perp-cli 0.14.0 → 0.14.1

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.
@@ -1,4 +1,5 @@
1
1
  import { Option } from "commander";
2
+ import { evmOnchainBalances, solanaOnchainBalances, stableTotal } from "../onchain-balances.js";
2
3
  import { PerpError } from "../errors.js";
3
4
  import chalk from "chalk";
4
5
  import bs58 from "bs58";
@@ -98,80 +99,60 @@ export function getWalletKeyByName(name) {
98
99
  const store = loadStore();
99
100
  return store.wallets[name]?.privateKey ?? null;
100
101
  }
102
+ /** SOL + USDC / USDT on Solana (devnet: USDP). */
101
103
  async function getSolanaBalances(address, isTestnet) {
102
- const { Connection, PublicKey } = await import("@solana/web3.js");
103
- const rpcUrl = isTestnet
104
- ? "https://api.devnet.solana.com"
105
- : "https://api.mainnet-beta.solana.com";
106
- const connection = new Connection(rpcUrl);
107
- const pubkey = new PublicKey(address);
108
- const balances = [];
109
- const solLamports = await connection.getBalance(pubkey);
110
- const solBalance = solLamports / 1e9;
111
- balances.push({ token: "SOL", balance: solBalance.toFixed(6), usdValue: "" });
112
- const usdcMint = isTestnet
113
- ? "USDPqRbLidFGufty2s3oizmDEKdqx7ePTqzDMbf5ZKM"
114
- : "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
115
- try {
116
- const tokenAccounts = await connection.getParsedTokenAccountsByOwner(pubkey, {
117
- mint: new PublicKey(usdcMint),
118
- });
119
- let usdcBalance = 0;
120
- for (const { account } of tokenAccounts.value) {
121
- const parsed = account.data.parsed?.info;
122
- if (parsed)
123
- usdcBalance += Number(parsed.tokenAmount?.uiAmount ?? 0);
104
+ return solanaOnchainBalances(address, isTestnet);
105
+ }
106
+ /** Gas token + USDC / USDT (USDG on Robinhood Chain) on every EVM chain perp-cli uses; `only` limits the chains. */
107
+ async function getEvmBalances(address, isTestnet, only) {
108
+ return evmOnchainBalances(address, isTestnet, only);
109
+ }
110
+ /** Human table: non-zero balances and failed reads (JSON keeps every row), then the stablecoin total. */
111
+ function balanceTable(balances) {
112
+ const shown = balances.filter(b => b.error !== null || Number(b.balance) !== 0);
113
+ const rows = shown.map((b) => [
114
+ b.chain,
115
+ chalk.white.bold(b.token),
116
+ b.balance === null ? chalk.red("read failed") : b.balance,
117
+ b.usdValue === null ? chalk.gray("-") : `$${formatUsd(b.usdValue)}`,
118
+ ]);
119
+ const total = stableTotal(balances);
120
+ rows.push([chalk.gray("total"), chalk.white.bold("stablecoins"), "", `$${formatUsd(total.usd)}${total.errors.length > 0 ? chalk.red(" (some reads failed)") : ""}`]);
121
+ return makeTable(["Chain", "Token", "Balance", "USD Value"], rows);
122
+ }
123
+ async function balanceEntry(label, vm, address, testnet) {
124
+ const balances = vm === "solana" ? await getSolanaBalances(address, testnet) : await getEvmBalances(address, testnet);
125
+ const total = stableTotal(balances);
126
+ return { ...label, chain: vm, address, balances, stableUsd: total.usd, errors: total.errors };
127
+ }
128
+ /** Reads each distinct address once (several venues often share one key), in order. */
129
+ async function balanceEntries(items, testnet) {
130
+ const seen = new Map();
131
+ const out = [];
132
+ for (const it of items) {
133
+ const key = `${it.vm}:${it.address.toLowerCase()}`;
134
+ const prev = seen.get(key);
135
+ if (prev !== undefined) {
136
+ if (it.label.exchange !== undefined)
137
+ prev.exchange = prev.exchange === undefined ? it.label.exchange : `${prev.exchange},${it.label.exchange}`;
138
+ continue;
124
139
  }
125
- balances.push({
126
- token: isTestnet ? "USDP" : "USDC",
127
- balance: usdcBalance.toFixed(2),
128
- usdValue: `$${formatUsd(usdcBalance)}`,
129
- });
130
- }
131
- catch {
132
- balances.push({ token: isTestnet ? "USDP" : "USDC", balance: "0.00", usdValue: "$0.00" });
133
- }
134
- try {
135
- const res = await fetch("https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd");
136
- const json = (await res.json());
137
- balances[0].usdValue = `$${formatUsd(solBalance * (json.solana?.usd ?? 0))}`;
140
+ const entry = await balanceEntry(it.label, it.vm, it.address, testnet);
141
+ seen.set(key, entry);
142
+ out.push(entry);
138
143
  }
139
- catch {
140
- balances[0].usdValue = "-";
141
- }
142
- return balances;
144
+ return out;
143
145
  }
144
- async function getEvmBalances(address, isTestnet) {
145
- const { ethers } = await import("ethers");
146
- const rpcUrl = isTestnet
147
- ? "https://sepolia-rollup.arbitrum.io/rpc"
148
- : "https://arb1.arbitrum.io/rpc";
149
- const provider = new ethers.JsonRpcProvider(rpcUrl);
150
- const balances = [];
151
- const ethBal = await provider.getBalance(address);
152
- const ethBalance = Number(ethers.formatEther(ethBal));
153
- balances.push({ token: "ETH", balance: ethBalance.toFixed(6), usdValue: "" });
154
- const usdcAddr = isTestnet
155
- ? "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d"
156
- : "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";
157
- try {
158
- const usdc = new ethers.Contract(usdcAddr, ["function balanceOf(address) view returns (uint256)"], provider);
159
- const rawBal = await usdc.balanceOf(address);
160
- const usdcBalance = Number(ethers.formatUnits(rawBal, 6));
161
- balances.push({ token: "USDC", balance: usdcBalance.toFixed(2), usdValue: `$${formatUsd(usdcBalance)}` });
162
- }
163
- catch {
164
- balances.push({ token: "USDC", balance: "0.00", usdValue: "$0.00" });
165
- }
166
- try {
167
- const res = await fetch("https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd");
168
- const json = (await res.json());
169
- balances[0].usdValue = `$${formatUsd(ethBalance * (json.ethereum?.usd ?? 0))}`;
170
- }
171
- catch {
172
- balances[0].usdValue = "-";
146
+ /** Exactly one JSON document (an array of entries), or one table per entry. */
147
+ function printBalanceEntries(entries, json) {
148
+ if (json)
149
+ return printJson(jsonOk(entries));
150
+ for (const e of entries) {
151
+ const who = [e.wallet, e.exchange].filter(x => x !== undefined).join(" / ");
152
+ console.log(chalk.cyan.bold(`\n ${who} (${e.chain} ${e.address.slice(0, 8)}...)`));
153
+ console.log(balanceTable(e.balances));
173
154
  }
174
- return balances;
155
+ console.log();
175
156
  }
176
157
  // ── Derive address from private key ──────────────────────────
177
158
  async function deriveSolanaAddress(key) {
@@ -816,7 +797,7 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
816
797
  // ── balance (by wallet name) ──
817
798
  wallet
818
799
  .command("balance [name]")
819
- .description("Check on-chain balance for a saved wallet (or active wallet)")
800
+ .description("On-chain balances for a saved wallet (or the active one): gas token + USDC / USDT on Arbitrum, Ethereum, Base, BNB Chain, Robinhood Chain (USDG) and Solana")
820
801
  .option("--testnet", "Use testnet", false)
821
802
  .action(async (name, opts) => {
822
803
  const store = loadStore();
@@ -867,44 +848,25 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
867
848
  if (activeEntries.length === 0) {
868
849
  const settings = loadSettings();
869
850
  if (settings.owsActiveWallet) {
851
+ // Only loading the OWS vault may fall through to the env path; balance reads report
852
+ // their own per-token errors and any other failure propagates.
853
+ let owsAccounts = null;
870
854
  try {
871
855
  const { loadOws } = await import("../signer/ows-loader.js");
872
- const ows = loadOws();
873
- const owsWallet = ows.getWallet(settings.owsActiveWallet);
874
- const owsResults = [];
875
- for (const acct of owsWallet.accounts) {
876
- const isEvm = acct.chainId.startsWith("eip155:");
877
- const isSol = acct.chainId.startsWith("solana:");
878
- if (!isEvm && !isSol)
879
- continue;
880
- const address = acct.address;
881
- const balances = isSol
882
- ? await getSolanaBalances(address, opts.testnet)
883
- : await getEvmBalances(address, opts.testnet);
884
- if (isJson()) {
885
- owsResults.push({
886
- wallet: settings.owsActiveWallet,
887
- chain: isSol ? "solana" : "evm",
888
- address,
889
- balances,
890
- });
891
- }
892
- else {
893
- console.log(chalk.cyan.bold(`\n ${settings.owsActiveWallet} (${isSol ? "solana" : "evm"} ${address.slice(0, 8)}...)`));
894
- const rows = balances.map((b) => [
895
- chalk.white.bold(b.token), b.balance, b.usdValue || chalk.gray("-"),
896
- ]);
897
- console.log(makeTable(["Token", "Balance", "USD Value"], rows));
898
- }
899
- }
900
- if (isJson())
901
- printJson(jsonOk(owsResults));
902
- else
903
- console.log();
904
- return;
856
+ owsAccounts = loadOws().getWallet(settings.owsActiveWallet).accounts;
905
857
  }
906
858
  catch {
907
- // OWS module unavailable or vault missing — fall through to env path
859
+ owsAccounts = null; // OWS module unavailable or vault missing — use the env path below
860
+ }
861
+ if (owsAccounts !== null) {
862
+ const results = [];
863
+ for (const acct of owsAccounts) {
864
+ const vm = acct.chainId.startsWith("solana:") ? "solana" : acct.chainId.startsWith("eip155:") ? "evm" : null;
865
+ if (vm === null)
866
+ continue;
867
+ results.push(await balanceEntry({ wallet: settings.owsActiveWallet }, vm, acct.address, opts.testnet));
868
+ }
869
+ return printBalanceEntries(results, isJson());
908
870
  }
909
871
  }
910
872
  const stored = loadEnvFile();
@@ -928,54 +890,13 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
928
890
  }
929
891
  process.exit(1);
930
892
  }
931
- for (const we of envEntries) {
932
- if (!isJson())
933
- console.log(chalk.cyan.bold(`\n ${we.exchange} (${we.address.slice(0, 8)}...)`));
934
- const balances = we.chain === "solana"
935
- ? await getSolanaBalances(we.address, opts.testnet)
936
- : await getEvmBalances(we.address, opts.testnet);
937
- if (isJson()) {
938
- printJson(jsonOk({ exchange: we.exchange, address: we.address, balances }));
939
- continue;
940
- }
941
- const rows = balances.map((b) => [
942
- chalk.white.bold(b.token), b.balance, b.usdValue || chalk.gray("-"),
943
- ]);
944
- console.log(makeTable(["Token", "Balance", "USD Value"], rows));
945
- }
946
- console.log();
947
- return;
948
- }
949
- for (const ae of activeEntries) {
950
- if (!isJson())
951
- console.log(chalk.cyan.bold(`\n ${ae.name} (${ae.exchange})`));
952
- const balances = ae.type === "solana"
953
- ? await getSolanaBalances(ae.address, opts.testnet)
954
- : await getEvmBalances(ae.address, opts.testnet);
955
- if (isJson()) {
956
- printJson(jsonOk({ wallet: ae.name, exchange: ae.exchange, balances }));
957
- continue;
958
- }
959
- const rows = balances.map((b) => [
960
- chalk.white.bold(b.token), b.balance, b.usdValue || chalk.gray("-"),
961
- ]);
962
- console.log(makeTable(["Token", "Balance", "USD Value"], rows));
893
+ return printBalanceEntries(await balanceEntries(envEntries.map(we => ({ label: { exchange: we.exchange }, vm: we.chain, address: we.address })), opts.testnet), isJson());
963
894
  }
964
- console.log();
965
- return;
895
+ return printBalanceEntries(await balanceEntries(activeEntries.map(ae => ({
896
+ label: { wallet: ae.name, exchange: ae.exchange }, vm: ae.type === "solana" ? "solana" : "evm", address: ae.address,
897
+ })), opts.testnet), isJson());
966
898
  }
967
- if (!isJson())
968
- console.log(chalk.cyan(`\n Fetching balance for "${entry.name}" (${entry.address.slice(0, 8)}...)\n`));
969
- const balances = entry.type === "solana"
970
- ? await getSolanaBalances(entry.address, opts.testnet)
971
- : await getEvmBalances(entry.address, opts.testnet);
972
- if (isJson())
973
- return printJson(jsonOk({ wallet: entry.name, balances }));
974
- const rows = balances.map((b) => [
975
- chalk.white.bold(b.token), b.balance, b.usdValue || chalk.gray("-"),
976
- ]);
977
- console.log(makeTable(["Token", "Balance", "USD Value"], rows));
978
- console.log();
899
+ return printBalanceEntries([await balanceEntry({ wallet: entry.name }, entry.type === "solana" ? "solana" : "evm", entry.address, opts.testnet)], isJson());
979
900
  });
980
901
  // ── direct address balance (kept for convenience) ──
981
902
  wallet
@@ -988,8 +909,7 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
988
909
  const balances = await getSolanaBalances(address, opts.testnet);
989
910
  if (isJson())
990
911
  return printJson(jsonOk(balances));
991
- const rows = balances.map((b) => [chalk.white.bold(b.token), b.balance, b.usdValue || chalk.gray("-")]);
992
- console.log(makeTable(["Token", "Balance", "USD Value"], rows));
912
+ console.log(balanceTable(balances));
993
913
  console.log(chalk.gray(`\n Network: ${opts.testnet ? "Devnet" : "Mainnet"}\n`));
994
914
  });
995
915
  wallet
@@ -999,11 +919,10 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
999
919
  .action(async (address, opts) => {
1000
920
  if (!isJson())
1001
921
  console.log(chalk.cyan(`\n Fetching Arbitrum balances for ${address.slice(0, 8)}...${address.slice(-4)}\n`));
1002
- const balances = await getEvmBalances(address, opts.testnet);
922
+ const balances = await getEvmBalances(address, opts.testnet, [opts.testnet ? "arbitrum-sepolia" : "arbitrum"]);
1003
923
  if (isJson())
1004
924
  return printJson(jsonOk(balances));
1005
- const rows = balances.map((b) => [chalk.white.bold(b.token), b.balance, b.usdValue || chalk.gray("-")]);
1006
- console.log(makeTable(["Token", "Balance", "USD Value"], rows));
925
+ console.log(balanceTable(balances));
1007
926
  console.log(chalk.gray(`\n Network: Arbitrum ${opts.testnet ? "Sepolia" : "One"}\n`));
1008
927
  });
1009
928
  // ── migrate (legacy → OWS) ──
@@ -61,6 +61,8 @@ type ResolvedSigner = {
61
61
  /** EVM address of the owner/master — used as `user` field in Aster requests */
62
62
  userAddress: string;
63
63
  };
64
+ /** Max 1000-row pages of 7-day commission income read for symbol discovery (20 000 fills). */
65
+ export declare const ASTER_INCOME_MAX_PAGES = 20;
64
66
  export declare class AsterAdapter implements ExchangeAdapter {
65
67
  readonly name = "aster";
66
68
  readonly chain = "bnb";
@@ -207,7 +209,26 @@ export declare class AsterAdapter implements ExchangeAdapter {
207
209
  */
208
210
  invalidateOpenOrdersCache(): void;
209
211
  getOpenOrders(): Promise<ExchangeOrder[]>;
212
+ /**
213
+ * Symbols with account activity: open positions, open orders, and every symbol with a
214
+ * COMMISSION income row in the last 7 days (the venue's default window). Aster's userTrades /
215
+ * allOrders both require `symbol`, so this is the per-symbol fan-out set. Income is read in
216
+ * pages of 1000 (the endpoint max; startTime / endTime are inclusive) walking back from now;
217
+ * more than ASTER_INCOME_MAX_PAGES pages → EXCHANGE_ERROR, never a partial set. Orders on a
218
+ * symbol that never filled, is not open and has no position cannot be found (allOrders needs it).
219
+ */
220
+ private _tradedApiSymbols;
221
+ /**
222
+ * Recent orders across the symbols with account activity (see _tradedApiSymbols), newest first.
223
+ * A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
224
+ */
210
225
  getOrderHistory(limit?: number): Promise<ExchangeOrder[]>;
226
+ /**
227
+ * Recent fills. `GET /fapi/v3/userTrades` requires a symbol (asterdex/api-docs V3), so the
228
+ * symbols come from the open positions plus every symbol with a COMMISSION income row in the
229
+ * venue's default window (`GET /fapi/v3/income`: "the recent 7-day data" when no time is sent).
230
+ * A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
231
+ */
211
232
  getTradeHistory(limit?: number): Promise<ExchangeTrade[]>;
212
233
  getFundingPayments(limit?: number): Promise<ExchangeFundingPayment[]>;
213
234
  /**
@@ -152,6 +152,8 @@ export function requireAsterClientOrderId(id) {
152
152
  return id;
153
153
  }
154
154
  // ── AsterAdapter ──────────────────────────────────────────────────────────────
155
+ /** Max 1000-row pages of 7-day commission income read for symbol discovery (20 000 fills). */
156
+ export const ASTER_INCOME_MAX_PAGES = 20;
155
157
  export class AsterAdapter {
156
158
  name = "aster";
157
159
  chain = "bnb";
@@ -715,65 +717,137 @@ export class AsterAdapter {
715
717
  this._ordersCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
716
718
  return result;
717
719
  }
718
- async getOrderHistory(limit = 30) {
720
+ /**
721
+ * Symbols with account activity: open positions, open orders, and every symbol with a
722
+ * COMMISSION income row in the last 7 days (the venue's default window). Aster's userTrades /
723
+ * allOrders both require `symbol`, so this is the per-symbol fan-out set. Income is read in
724
+ * pages of 1000 (the endpoint max; startTime / endTime are inclusive) walking back from now;
725
+ * more than ASTER_INCOME_MAX_PAGES pages → EXCHANGE_ERROR, never a partial set. Orders on a
726
+ * symbol that never filled, is not open and has no position cannot be found (allOrders needs it).
727
+ */
728
+ async _tradedApiSymbols(r) {
719
729
  const positions = await this.getPositions();
720
730
  const apiSymbols = new Set(positions.map(p => this._toApi(p.symbol)));
721
- if (apiSymbols.size === 0) {
722
- for (const s of ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT"]) {
723
- apiSymbols.add(s);
731
+ const open = await this._signedGetEip712("/fapi/v3/openOrders", {}, r);
732
+ if (!Array.isArray(open)) {
733
+ throw new PerpError("EXCHANGE_ERROR", `Aster open orders is not a list (${JSON.stringify(open).slice(0, 120)})`, { exchange: "aster" });
734
+ }
735
+ for (const o of open) {
736
+ if (typeof o.symbol === "string" && o.symbol !== "")
737
+ apiSymbols.add(o.symbol);
738
+ }
739
+ const startTime = Date.now() - 7 * 86_400_000;
740
+ let endTime = Date.now();
741
+ for (let page = 0;; page++) {
742
+ if (page >= ASTER_INCOME_MAX_PAGES) {
743
+ throw new PerpError("EXCHANGE_ERROR", `Aster commission history exceeds ${ASTER_INCOME_MAX_PAGES * 1000} rows in 7 days — the symbol list would be incomplete`, {
744
+ exchange: "aster", remediation: "Check fills per symbol in the Aster web app",
745
+ });
746
+ }
747
+ const income = await this._signedGetEip712("/fapi/v3/income", {
748
+ incomeType: "COMMISSION", startTime: String(startTime), endTime: String(endTime), limit: "1000",
749
+ }, r);
750
+ if (!Array.isArray(income)) {
751
+ throw new PerpError("EXCHANGE_ERROR", `Aster income history is not a list (${JSON.stringify(income).slice(0, 120)})`, { exchange: "aster" });
752
+ }
753
+ let oldest = Infinity;
754
+ for (const row of income) {
755
+ if (typeof row.symbol === "string" && row.symbol !== "")
756
+ apiSymbols.add(row.symbol);
757
+ const t = Number(row.time);
758
+ if (!Number.isFinite(t)) {
759
+ throw new PerpError("EXCHANGE_ERROR", `Aster income row without a numeric time: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "aster" });
760
+ }
761
+ oldest = Math.min(oldest, t);
724
762
  }
763
+ if (income.length < 1000 || oldest <= startTime)
764
+ break;
765
+ endTime = oldest - 1;
725
766
  }
767
+ return apiSymbols;
768
+ }
769
+ /**
770
+ * Recent orders across the symbols with account activity (see _tradedApiSymbols), newest first.
771
+ * A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
772
+ */
773
+ async getOrderHistory(limit = 30) {
726
774
  const r = this._resolveSigner();
727
- const allOrders = [];
775
+ const apiSymbols = await this._tradedApiSymbols(r);
776
+ const field = (o, k) => {
777
+ const v = o[k];
778
+ if ((typeof v === "string" && v !== "") || typeof v === "number")
779
+ return String(v);
780
+ throw new PerpError("EXCHANGE_ERROR", `Aster allOrders row without a readable ${k}: ${JSON.stringify(o).slice(0, 160)}`, { exchange: "aster" });
781
+ };
782
+ const rows = [];
728
783
  for (const sym of apiSymbols) {
729
- try {
730
- const orders = await this._signedGetEip712("/fapi/v3/allOrders", {
731
- symbol: sym,
732
- limit: String(limit),
733
- }, r);
734
- for (const o of orders ?? []) {
735
- allOrders.push({
736
- orderId: String(o.orderId ?? ""),
737
- symbol: this._fromApi(String(o.symbol ?? "")),
738
- side: String(o.side).toLowerCase(),
739
- price: String(o.price ?? "0"),
740
- size: String(o.origQty ?? "0"),
741
- filled: String(o.executedQty ?? "0"),
742
- status: String(o.status ?? ""),
743
- type: String(o.type ?? ""),
744
- triggerPrice: Number(o.stopPrice) > 0 ? String(o.stopPrice) : null,
745
- });
784
+ const orders = await this._signedGetEip712("/fapi/v3/allOrders", { symbol: sym, limit: String(limit) }, r);
785
+ if (!Array.isArray(orders)) {
786
+ throw new PerpError("EXCHANGE_ERROR", `Aster allOrders for ${sym} is not a list (${JSON.stringify(orders).slice(0, 120)})`, { exchange: "aster" });
787
+ }
788
+ for (const o of orders) {
789
+ const side = field(o, "side").toLowerCase();
790
+ if (side !== "buy" && side !== "sell") {
791
+ throw new PerpError("EXCHANGE_ERROR", `Aster allOrders row with unknown side "${side}"`, { exchange: "aster" });
746
792
  }
793
+ const time = Number(field(o, "time"));
794
+ if (!Number.isFinite(time))
795
+ throw new PerpError("EXCHANGE_ERROR", `Aster allOrders row with a non-numeric time`, { exchange: "aster" });
796
+ rows.push({
797
+ time,
798
+ order: {
799
+ orderId: field(o, "orderId"),
800
+ symbol: this._fromApi(field(o, "symbol")),
801
+ side,
802
+ price: field(o, "price"),
803
+ size: field(o, "origQty"),
804
+ filled: field(o, "executedQty"),
805
+ status: field(o, "status"),
806
+ type: field(o, "type"),
807
+ triggerPrice: Number(o.stopPrice) > 0 ? String(o.stopPrice) : null,
808
+ },
809
+ });
747
810
  }
748
- catch { /* skip */ }
749
811
  }
750
- return allOrders.slice(0, limit);
812
+ return rows.sort((x, y) => y.time - x.time).slice(0, limit).map(x => x.order);
751
813
  }
814
+ /**
815
+ * Recent fills. `GET /fapi/v3/userTrades` requires a symbol (asterdex/api-docs V3), so the
816
+ * symbols come from the open positions plus every symbol with a COMMISSION income row in the
817
+ * venue's default window (`GET /fapi/v3/income`: "the recent 7-day data" when no time is sent).
818
+ * A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
819
+ */
752
820
  async getTradeHistory(limit = 30) {
753
- const positions = await this.getPositions();
754
- const apiSymbols = new Set(positions.map(p => this._toApi(p.symbol)));
755
- if (apiSymbols.size === 0)
756
- apiSymbols.add("BTCUSDT");
757
821
  const r = this._resolveSigner();
822
+ const apiSymbols = await this._tradedApiSymbols(r);
823
+ const field = (t, k) => {
824
+ const v = t[k];
825
+ if ((typeof v === "string" && v !== "") || typeof v === "number")
826
+ return String(v);
827
+ throw new PerpError("EXCHANGE_ERROR", `Aster userTrades row without a readable ${k}: ${JSON.stringify(t).slice(0, 160)}`, { exchange: "aster" });
828
+ };
758
829
  const allTrades = [];
759
830
  for (const sym of apiSymbols) {
760
- try {
761
- const trades = await this._signedGetEip712("/fapi/v3/userTrades", {
762
- symbol: sym,
763
- limit: String(limit),
764
- }, r);
765
- for (const t of trades ?? []) {
766
- allTrades.push({
767
- time: Number(t.time ?? 0),
768
- symbol: this._fromApi(String(t.symbol ?? "")),
769
- side: t.buyer ? "buy" : "sell",
770
- price: String(t.price ?? "0"),
771
- size: String(t.qty ?? "0"),
772
- fee: String(t.commission ?? "0"),
773
- });
831
+ const trades = await this._signedGetEip712("/fapi/v3/userTrades", { symbol: sym, limit: String(limit) }, r);
832
+ if (!Array.isArray(trades)) {
833
+ throw new PerpError("EXCHANGE_ERROR", `Aster userTrades for ${sym} is not a list (${JSON.stringify(trades).slice(0, 120)})`, { exchange: "aster" });
834
+ }
835
+ for (const t of trades) {
836
+ if (typeof t.buyer !== "boolean") {
837
+ throw new PerpError("EXCHANGE_ERROR", `Aster userTrades row without a boolean buyer: ${JSON.stringify(t).slice(0, 160)}`, { exchange: "aster" });
774
838
  }
839
+ const time = Number(field(t, "time"));
840
+ if (!Number.isFinite(time))
841
+ throw new PerpError("EXCHANGE_ERROR", `Aster userTrades row with a non-numeric time`, { exchange: "aster" });
842
+ allTrades.push({
843
+ time,
844
+ symbol: this._fromApi(field(t, "symbol")),
845
+ side: t.buyer ? "buy" : "sell",
846
+ price: field(t, "price"),
847
+ size: field(t, "qty"),
848
+ fee: field(t, "commission"),
849
+ });
775
850
  }
776
- catch { /* skip */ }
777
851
  }
778
852
  return allTrades.sort((a, b) => b.time - a.time).slice(0, limit);
779
853
  }
@@ -42,12 +42,6 @@ import type { VenueCreateContext, VenueDefinition } from "../venue.js";
42
42
  import type { AmendRequest, CancelResult, LeverageResult, Network, OrderRef, OrderRequest, OrderResult, VenueMarket } from "../venue-types.js";
43
43
  import { type BuilderFeeGate } from "../builder-fee.js";
44
44
  import { OndoClient } from "./client.js";
45
- interface OndoWithdrawal {
46
- withdrawal_status: string;
47
- withdrawal_id: string;
48
- customer_withdrawal_id: string;
49
- hash?: string;
50
- }
51
45
  export declare class OndoAdapter implements ExchangeAdapter, TradingV2 {
52
46
  readonly name: "ondo";
53
47
  readonly aliases: readonly ["ondo-perps"];
@@ -72,8 +66,6 @@ export declare class OndoAdapter implements ExchangeAdapter, TradingV2 {
72
66
  get restClient(): OndoClient;
73
67
  /** Account id from GET /v1/account — the `from.id`/`to.id` of withdrawals and spot↔perp transfers. */
74
68
  accountId(): Promise<string>;
75
- /** Withdrawal address book (GET /v1/wallet/address_book) — withdrawals go only to these addresses. */
76
- addressBook(): Promise<string[]>;
77
69
  private pairOf;
78
70
  /**
79
71
  * An order the venue returned as "pending" (not yet open / filled) is read back by id
@@ -152,15 +144,9 @@ export declare class OndoAdapter implements ExchangeAdapter, TradingV2 {
152
144
  triggerType?: "mark" | "last" | "mid";
153
145
  }): Promise<OrderResult>;
154
146
  /**
155
- * Withdraw USDC from the perps (margin) wallet to an Ethereum address.
156
- * Requires an API key with the `transfer` scope. The destination may need to
157
- * be on the account's address book (venue error bad_withdrawal_address).
147
+ * Withdrawals are not available over the API (account owner, 2026-10-13): Ondo pays only
148
+ * address-book destinations and refuses API-key auth for adding one
149
+ * (`key_not_allowed_for_action`). Refused before any request — withdraw in the web app.
158
150
  */
159
- withdraw(amount: string, destination: string, opts?: {
160
- assetId?: number;
161
- routeType?: number;
162
- }): Promise<OndoWithdrawal & {
163
- withdrawalFeeUSD: string;
164
- }>;
151
+ withdraw(_amount: string, _destination: string): Promise<never>;
165
152
  }
166
- export {};
@@ -41,7 +41,6 @@ import { alignProtectionPrice, floorSize, formatComputedPrice, requireOnTick, sl
41
41
  import { unsupported, validateLegacyTif, validateOrderRequest } from "../trading-v2.js";
42
42
  import { networkConfig, requireCapabilities } from "../venue.js";
43
43
  import { parseOrderRef, toLegacyMarketInfo } from "../venue-types.js";
44
- import { ONDO_WITHDRAW_NETWORK } from "../venues/ondo.js";
45
44
  import { builderGateOf, builderRejectionOf, NO_BUILDER, requireFeeBps } from "../builder-fee.js";
46
45
  import { loadOndoCredentials, ONDO_APPROVE_REMEDIATION } from "./agent.js";
47
46
  import { OndoClient } from "./client.js";
@@ -217,10 +216,6 @@ export class OndoAdapter {
217
216
  const account = await this.client.get("/v1/account", null);
218
217
  return account.accountID;
219
218
  }
220
- /** Withdrawal address book (GET /v1/wallet/address_book) — withdrawals go only to these addresses. */
221
- async addressBook() {
222
- return (await import("./funds.js")).ondoAddressBook(this.client);
223
- }
224
219
  // ── Helpers ──
225
220
  async pairOf(symbol) {
226
221
  const market = ondoVenueSymbol(symbol);
@@ -806,30 +801,11 @@ export class OndoAdapter {
806
801
  }
807
802
  // ── Withdraw (capabilities.WithdrawCapable) ──
808
803
  /**
809
- * Withdraw USDC from the perps (margin) wallet to an Ethereum address.
810
- * Requires an API key with the `transfer` scope. The destination may need to
811
- * be on the account's address book (venue error bad_withdrawal_address).
804
+ * Withdrawals are not available over the API (account owner, 2026-10-13): Ondo pays only
805
+ * address-book destinations and refuses API-key auth for adding one
806
+ * (`key_not_allowed_for_action`). Refused before any request — withdraw in the web app.
812
807
  */
813
- async withdraw(amount, destination, opts) {
814
- if (opts?.assetId !== undefined || opts?.routeType !== undefined) {
815
- throw new PerpError("INVALID_PARAMS", "Ondo Perps withdraw takes no assetId/routeType (USDC on Ethereum only)");
816
- }
817
- if (!/^0x[0-9a-fA-F]{40}$/.test(destination)) {
818
- throw new PerpError("INVALID_PARAMS", `Ondo Perps withdraw destination must be an Ethereum address (got "${destination}")`);
819
- }
820
- if (!/^\d+(\.\d+)?$/.test(amount) || !isPositive(amount)) {
821
- throw new PerpError("INVALID_PARAMS", `Withdraw amount must be a positive decimal (got "${amount}")`);
822
- }
823
- // AccountInfo.withdrawalFeeUSD (required in the OpenAPI spec) is reported with the result.
824
- const account = await this.client.get("/v1/account", null);
825
- const res = await this.client.post("/v1/withdraw", {
826
- customer_withdrawal_id: randomUUID(),
827
- symbol: "USDC",
828
- network: ONDO_WITHDRAW_NETWORK,
829
- amount,
830
- address: destination,
831
- from: { id: account.accountID, wallet: "margin" },
832
- });
833
- return { ...res, withdrawalFeeUSD: account.withdrawalFeeUSD };
808
+ async withdraw(_amount, _destination) {
809
+ throw unsupported("Ondo Perps", "withdraw", "the Ondo Perps API does not allow withdrawals", "Withdraw from the Ondo Perps web app");
834
810
  }
835
811
  }