perp-cli 0.12.17 → 0.14.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 +247 -27
- package/dist/_stderr-filter.js +0 -1
- package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
- package/dist/agent-wallet/lighter-keystore.js +92 -10
- package/dist/agent-wallet/passphrase.d.ts +1 -1
- package/dist/agent-wallet/passphrase.js +16 -3
- package/dist/agent-wallet/store.d.ts +37 -4
- package/dist/agent-wallet/store.js +132 -0
- package/dist/agent-wallet/venue-keystore.d.ts +14 -0
- package/dist/agent-wallet/venue-keystore.js +121 -0
- package/dist/api/public/hyperliquid.js +4 -4
- package/dist/api/public/lighter.js +5 -5
- package/dist/api/public/pacifica.d.ts +0 -1
- package/dist/api/public/pacifica.js +4 -13
- package/dist/api/public/urls.d.ts +9 -1
- package/dist/api/public/urls.js +35 -13
- package/dist/arb/file-lock.d.ts +11 -0
- package/dist/arb/file-lock.js +74 -0
- package/dist/arb/index.d.ts +2 -2
- package/dist/arb/index.js +2 -2
- package/dist/arb/pending-entry.d.ts +32 -0
- package/dist/arb/pending-entry.js +77 -0
- package/dist/arb/sizing.d.ts +6 -13
- package/dist/arb/sizing.js +12 -51
- package/dist/arb/state.d.ts +45 -12
- package/dist/arb/state.js +214 -42
- package/dist/arb/utils.d.ts +20 -6
- package/dist/arb/utils.js +43 -39
- package/dist/bot/conditions.d.ts +4 -4
- package/dist/bot/conditions.js +11 -6
- package/dist/bot/config.d.ts +17 -1
- package/dist/bot/config.js +48 -12
- package/dist/bot/engine.d.ts +61 -2
- package/dist/bot/engine.js +610 -144
- package/dist/bot/fill-size.d.ts +7 -0
- package/dist/bot/fill-size.js +18 -0
- package/dist/bot/index.d.ts +1 -1
- package/dist/bot/index.js +1 -1
- package/dist/bot/perp-order.d.ts +99 -0
- package/dist/bot/perp-order.js +299 -0
- package/dist/bot/presets.d.ts +7 -0
- package/dist/bot/presets.js +17 -0
- package/dist/bot/quarantine.d.ts +75 -0
- package/dist/bot/quarantine.js +190 -0
- package/dist/bot/quote-rules.d.ts +23 -0
- package/dist/bot/quote-rules.js +69 -0
- package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
- package/dist/bot/strategies/avellaneda-mm.js +72 -70
- package/dist/bot/strategies/basis-arb.js +2 -0
- package/dist/bot/strategies/dca-strategy.d.ts +2 -0
- package/dist/bot/strategies/dca-strategy.js +5 -0
- package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
- package/dist/bot/strategies/funding-arb-strategy.js +571 -380
- package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
- package/dist/bot/strategies/funding-arb-utils.js +159 -195
- package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
- package/dist/bot/strategies/funding-arb-v2.js +258 -47
- package/dist/bot/strategies/funding-auto.js +215 -194
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
- package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
- package/dist/bot/strategies/spot-perp-executor.js +319 -120
- package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
- package/dist/bot/strategies/spot-perp-state.js +70 -0
- package/dist/bot/strategy-types.d.ts +6 -0
- package/dist/bot/tui/BotDashboard.d.ts +7 -2
- package/dist/bot/tui/BotDashboard.js +6 -3
- package/dist/bridge-engine.d.ts +120 -19
- package/dist/bridge-engine.js +710 -422
- package/dist/bridge-signers.d.ts +54 -0
- package/dist/bridge-signers.js +161 -0
- package/dist/cache.d.ts +4 -1
- package/dist/cache.js +11 -0
- package/dist/cli-flags.d.ts +50 -0
- package/dist/cli-flags.js +104 -0
- package/dist/cli-program.d.ts +35 -0
- package/dist/cli-program.js +286 -0
- package/dist/cli-spec.js +1 -1
- package/dist/client-id-tracker.js +11 -2
- package/dist/commands/account.d.ts +7 -1
- package/dist/commands/account.js +70 -92
- package/dist/commands/agent-import.d.ts +114 -0
- package/dist/commands/agent-import.js +794 -0
- package/dist/commands/agent.d.ts +232 -2
- package/dist/commands/agent.js +1858 -955
- package/dist/commands/alerts.d.ts +1 -0
- package/dist/commands/alerts.js +31 -28
- package/dist/commands/arb/index.d.ts +60 -1
- package/dist/commands/arb/index.js +381 -384
- package/dist/commands/arb-auto.d.ts +132 -4
- package/dist/commands/arb-auto.js +1474 -967
- package/dist/commands/arb.d.ts +3 -1
- package/dist/commands/arb.js +62 -30
- package/dist/commands/bot.js +127 -47
- package/dist/commands/bridge.d.ts +2 -1
- package/dist/commands/bridge.js +339 -192
- package/dist/commands/builder.d.ts +55 -0
- package/dist/commands/builder.js +387 -0
- package/dist/commands/funds-onchain.d.ts +32 -0
- package/dist/commands/funds-onchain.js +286 -0
- package/dist/commands/funds.d.ts +25 -0
- package/dist/commands/funds.js +939 -245
- package/dist/commands/history.js +72 -45
- package/dist/commands/init.d.ts +107 -15
- package/dist/commands/init.js +1116 -249
- package/dist/commands/jobs.d.ts +2 -1
- package/dist/commands/jobs.js +77 -15
- package/dist/commands/manage.js +136 -91
- package/dist/commands/market.d.ts +4 -1
- package/dist/commands/market.js +35 -32
- package/dist/commands/outcome.d.ts +10 -0
- package/dist/commands/outcome.js +396 -0
- package/dist/commands/plan.js +52 -12
- package/dist/commands/portfolio.d.ts +1 -0
- package/dist/commands/portfolio.js +60 -72
- package/dist/commands/rebalance.d.ts +2 -1
- package/dist/commands/rebalance.js +33 -43
- package/dist/commands/risk.js +46 -19
- package/dist/commands/settings.d.ts +1 -0
- package/dist/commands/settings.js +246 -51
- package/dist/commands/trade.d.ts +50 -0
- package/dist/commands/trade.js +1943 -695
- package/dist/commands/wallet.d.ts +30 -1
- package/dist/commands/wallet.js +316 -337
- package/dist/config.d.ts +60 -9
- package/dist/config.js +252 -82
- package/dist/constants.d.ts +29 -4
- package/dist/constants.js +62 -20
- package/dist/dashboard/server.d.ts +2 -0
- package/dist/dashboard/server.js +9 -6
- package/dist/dashboard/ui.js +6 -4
- package/dist/dashboard/ws-feeds.js +20 -8
- package/dist/dex-asset-map.js +5 -3
- package/dist/errors.d.ts +122 -0
- package/dist/errors.js +194 -0
- package/dist/event-stream.js +75 -19
- package/dist/evm-master-tx.d.ts +59 -0
- package/dist/evm-master-tx.js +154 -0
- package/dist/exchanges/adapter-network.d.ts +10 -0
- package/dist/exchanges/adapter-network.js +10 -0
- package/dist/exchanges/arcus/adapter.d.ts +196 -0
- package/dist/exchanges/arcus/adapter.js +1092 -0
- package/dist/exchanges/arcus/agent.d.ts +55 -0
- package/dist/exchanges/arcus/agent.js +188 -0
- package/dist/exchanges/arcus/client.d.ts +63 -0
- package/dist/exchanges/arcus/client.js +285 -0
- package/dist/exchanges/arcus/public.d.ts +65 -0
- package/dist/exchanges/arcus/public.js +138 -0
- package/dist/exchanges/arcus/signing.d.ts +176 -0
- package/dist/exchanges/arcus/signing.js +288 -0
- package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
- package/dist/exchanges/arcus/spot-chain.js +241 -0
- package/dist/exchanges/arcus/spot-router.d.ts +177 -0
- package/dist/exchanges/arcus/spot-router.js +183 -0
- package/dist/exchanges/arcus/spot.d.ts +109 -0
- package/dist/exchanges/arcus/spot.js +645 -0
- package/dist/exchanges/aster-funds.d.ts +127 -0
- package/dist/exchanges/aster-funds.js +252 -0
- package/dist/exchanges/aster-nonce.d.ts +2 -0
- package/dist/exchanges/aster-nonce.js +107 -0
- package/dist/exchanges/aster-spot.d.ts +117 -0
- package/dist/exchanges/aster-spot.js +686 -0
- package/dist/exchanges/aster-typed-data.d.ts +133 -7
- package/dist/exchanges/aster-typed-data.js +121 -7
- package/dist/exchanges/aster.d.ts +219 -6
- package/dist/exchanges/aster.js +1023 -153
- package/dist/exchanges/builder-fee.d.ts +254 -0
- package/dist/exchanges/builder-fee.js +421 -0
- package/dist/exchanges/canonical-symbols.d.ts +32 -0
- package/dist/exchanges/canonical-symbols.js +96 -0
- package/dist/exchanges/capabilities.d.ts +7 -1
- package/dist/exchanges/capabilities.js +7 -0
- package/dist/exchanges/hl-signing.d.ts +165 -0
- package/dist/exchanges/hl-signing.js +158 -0
- package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
- package/dist/exchanges/hyperliquid-outcome.js +646 -0
- package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
- package/dist/exchanges/hyperliquid-spot.js +308 -117
- package/dist/exchanges/hyperliquid.d.ts +462 -57
- package/dist/exchanges/hyperliquid.js +1575 -449
- package/dist/exchanges/index.d.ts +6 -0
- package/dist/exchanges/index.js +4 -0
- package/dist/exchanges/interface.d.ts +63 -10
- package/dist/exchanges/interface.js +18 -3
- package/dist/exchanges/ioc-outcome.d.ts +44 -0
- package/dist/exchanges/ioc-outcome.js +57 -0
- package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
- package/dist/exchanges/legacy-trading-v2.js +940 -0
- package/dist/exchanges/lighter-public.d.ts +43 -0
- package/dist/exchanges/lighter-public.js +276 -0
- package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
- package/dist/exchanges/lighter-rh-deposit.js +185 -0
- package/dist/exchanges/lighter-signer.d.ts +123 -0
- package/dist/exchanges/lighter-signer.js +215 -0
- package/dist/exchanges/lighter-spot.d.ts +91 -13
- package/dist/exchanges/lighter-spot.js +595 -233
- package/dist/exchanges/lighter.d.ts +611 -39
- package/dist/exchanges/lighter.js +1890 -649
- package/dist/exchanges/ondo/adapter.d.ts +166 -0
- package/dist/exchanges/ondo/adapter.js +835 -0
- package/dist/exchanges/ondo/agent.d.ts +31 -0
- package/dist/exchanges/ondo/agent.js +101 -0
- package/dist/exchanges/ondo/client.d.ts +60 -0
- package/dist/exchanges/ondo/client.js +297 -0
- package/dist/exchanges/ondo/funds.d.ts +122 -0
- package/dist/exchanges/ondo/funds.js +290 -0
- package/dist/exchanges/ondo/public.d.ts +95 -0
- package/dist/exchanges/ondo/public.js +214 -0
- package/dist/exchanges/ondo/spot.d.ts +127 -0
- package/dist/exchanges/ondo/spot.js +392 -0
- package/dist/exchanges/outcome-interface.d.ts +140 -0
- package/dist/exchanges/outcome-interface.js +16 -0
- package/dist/exchanges/pacifica-spot.d.ts +83 -0
- package/dist/exchanges/pacifica-spot.js +324 -0
- package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
- package/dist/exchanges/pacifica-typed-data.js +57 -9
- package/dist/exchanges/pacifica.d.ts +288 -4
- package/dist/exchanges/pacifica.js +1013 -76
- package/dist/exchanges/precision.d.ts +47 -0
- package/dist/exchanges/precision.js +209 -0
- package/dist/exchanges/qfex/adapter.d.ts +341 -0
- package/dist/exchanges/qfex/adapter.js +1383 -0
- package/dist/exchanges/qfex/agent.d.ts +58 -0
- package/dist/exchanges/qfex/agent.js +188 -0
- package/dist/exchanges/qfex/client.d.ts +54 -0
- package/dist/exchanges/qfex/client.js +138 -0
- package/dist/exchanges/qfex/errors.d.ts +70 -0
- package/dist/exchanges/qfex/errors.js +236 -0
- package/dist/exchanges/qfex/public.d.ts +122 -0
- package/dist/exchanges/qfex/public.js +268 -0
- package/dist/exchanges/qfex/signing.d.ts +160 -0
- package/dist/exchanges/qfex/signing.js +283 -0
- package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
- package/dist/exchanges/qfex/ws-transport.js +798 -0
- package/dist/exchanges/rate-limiter.d.ts +12 -0
- package/dist/exchanges/rate-limiter.js +64 -0
- package/dist/exchanges/registry.d.ts +14 -2
- package/dist/exchanges/registry.js +20 -32
- package/dist/exchanges/slippage.d.ts +37 -0
- package/dist/exchanges/slippage.js +70 -0
- package/dist/exchanges/spot-factory.d.ts +166 -0
- package/dist/exchanges/spot-factory.js +431 -0
- package/dist/exchanges/spot-interface.d.ts +98 -4
- package/dist/exchanges/spot-interface.js +27 -1
- package/dist/exchanges/spot-result.d.ts +79 -0
- package/dist/exchanges/spot-result.js +273 -0
- package/dist/exchanges/trading-v2.d.ts +117 -0
- package/dist/exchanges/trading-v2.js +301 -0
- package/dist/exchanges/venue-types.d.ts +231 -0
- package/dist/exchanges/venue-types.js +87 -0
- package/dist/exchanges/venue.d.ts +211 -0
- package/dist/exchanges/venue.js +132 -0
- package/dist/exchanges/venues/arcus.d.ts +8 -0
- package/dist/exchanges/venues/arcus.js +109 -0
- package/dist/exchanges/venues/aster.d.ts +49 -0
- package/dist/exchanges/venues/aster.js +176 -0
- package/dist/exchanges/venues/create-helpers.d.ts +18 -0
- package/dist/exchanges/venues/create-helpers.js +199 -0
- package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
- package/dist/exchanges/venues/hyperliquid.js +126 -0
- package/dist/exchanges/venues/index.d.ts +16 -0
- package/dist/exchanges/venues/index.js +26 -0
- package/dist/exchanges/venues/lighter-family.d.ts +92 -0
- package/dist/exchanges/venues/lighter-family.js +166 -0
- package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
- package/dist/exchanges/venues/lighter-rh.js +58 -0
- package/dist/exchanges/venues/lighter.d.ts +6 -0
- package/dist/exchanges/venues/lighter.js +55 -0
- package/dist/exchanges/venues/ondo.d.ts +34 -0
- package/dist/exchanges/venues/ondo.js +140 -0
- package/dist/exchanges/venues/pacifica.d.ts +8 -0
- package/dist/exchanges/venues/pacifica.js +95 -0
- package/dist/exchanges/venues/qfex.d.ts +28 -0
- package/dist/exchanges/venues/qfex.js +132 -0
- package/dist/execution-log.js +10 -2
- package/dist/funding/legacy-public.d.ts +31 -0
- package/dist/funding/legacy-public.js +369 -0
- package/dist/funding/normalize.d.ts +28 -16
- package/dist/funding/normalize.js +61 -29
- package/dist/funding/rates.d.ts +46 -11
- package/dist/funding/rates.js +228 -277
- package/dist/guardrail/perp-guardrail.js +0 -0
- package/dist/index.js +156 -684
- package/dist/jobs.d.ts +84 -6
- package/dist/jobs.js +239 -41
- package/dist/landing.d.ts +7 -2
- package/dist/landing.js +19 -3
- package/dist/mcp-server.d.ts +14 -4
- package/dist/mcp-server.js +820 -402
- package/dist/mcp-trade-guard.d.ts +16 -0
- package/dist/mcp-trade-guard.js +59 -0
- package/dist/pacifica/client.d.ts +41 -10
- package/dist/pacifica/client.js +167 -63
- package/dist/pacifica/constants.d.ts +0 -1
- package/dist/pacifica/constants.js +0 -5
- package/dist/pacifica/deposit.d.ts +30 -3
- package/dist/pacifica/deposit.js +119 -2
- package/dist/pacifica/faucet.d.ts +29 -0
- package/dist/pacifica/faucet.js +100 -0
- package/dist/pacifica/sign-tx.d.ts +15 -0
- package/dist/pacifica/sign-tx.js +41 -0
- package/dist/pacifica/types/account.d.ts +28 -4
- package/dist/pacifica/types/lake.d.ts +20 -5
- package/dist/pacifica/types/lake.js +4 -0
- package/dist/pacifica/types/market.d.ts +5 -0
- package/dist/pacifica/types/order.d.ts +4 -1
- package/dist/pacifica/types/position.d.ts +9 -1
- package/dist/plan-executor.d.ts +10 -5
- package/dist/plan-executor.js +165 -54
- package/dist/position-history.js +13 -0
- package/dist/rebalance.d.ts +26 -2
- package/dist/rebalance.js +86 -7
- package/dist/relay-swap.d.ts +232 -0
- package/dist/relay-swap.js +630 -0
- package/dist/risk.js +5 -1
- package/dist/secret-env.d.ts +15 -0
- package/dist/secret-env.js +36 -0
- package/dist/settings.d.ts +169 -6
- package/dist/settings.js +219 -38
- package/dist/signer/ows-evm.d.ts +13 -0
- package/dist/signer/ows-evm.js +22 -0
- package/dist/smart-order.d.ts +9 -0
- package/dist/smart-order.js +53 -8
- package/dist/strategies/dca.d.ts +6 -2
- package/dist/strategies/dca.js +66 -26
- package/dist/strategies/funding-arb.d.ts +11 -1
- package/dist/strategies/funding-arb.js +202 -89
- package/dist/strategies/grid.d.ts +31 -0
- package/dist/strategies/grid.js +89 -11
- package/dist/strategies/pending-stop.d.ts +8 -0
- package/dist/strategies/pending-stop.js +12 -0
- package/dist/strategies/split-order.d.ts +45 -17
- package/dist/strategies/split-order.js +157 -91
- package/dist/strategies/trailing-stop.d.ts +2 -1
- package/dist/strategies/trailing-stop.js +52 -6
- package/dist/strategies/twap.d.ts +6 -1
- package/dist/strategies/twap.js +39 -11
- package/dist/trade-validator.d.ts +22 -0
- package/dist/trade-validator.js +119 -15
- package/dist/utils/numeric.d.ts +46 -0
- package/dist/utils/numeric.js +79 -0
- package/dist/utils.d.ts +19 -0
- package/dist/utils.js +96 -19
- package/dist/withdraw-gate.d.ts +49 -0
- package/dist/withdraw-gate.js +131 -0
- package/package.json +15 -6
- package/skills/perp-cli/SKILL.md +142 -55
- package/skills/perp-cli/references/agent-operations.md +110 -56
- package/skills/perp-cli/references/commands.md +266 -71
- package/skills/perp-cli/references/strategies.md +34 -31
- package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
- package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
- package/skills/perp-cli/scripts/preflight.sh +21 -14
- package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
- package/skills/perp-cli/scripts/validate-arb.sh +31 -15
- package/vendor/lighter-signer/SHA256SUMS +2 -0
- package/vendor/lighter-signer/VERSION +8 -0
- package/vendor/lighter-signer/lighter-signer.wasm +0 -0
- package/vendor/lighter-signer/wasm_exec.js +575 -0
- package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
- package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arcus spot = Stock Token swaps through the Arcus Spot Router (design §12,
|
|
3
|
+
* §12.4 #5 #7). Not an exchange account: balances are the master wallet's
|
|
4
|
+
* ERC-20 balances on Robinhood Chain, and every trade is an exact-input swap
|
|
5
|
+
* settled on-chain (Permit2 `PermitWitnessTransferFrom` + `TakerIntent`).
|
|
6
|
+
*
|
|
7
|
+
* - Markets: every `/tokens` entry vs USDG ("BASE/USDG"); wire = token address.
|
|
8
|
+
* - Orderbook / limit / cancel: CAPABILITY_UNSUPPORTED (router has no book, nothing rests).
|
|
9
|
+
* - Signer: master EVM key (OWS --wallet or pk) from ArcusAdapter; absent → NO_SIGNER_AVAILABLE.
|
|
10
|
+
* - allowWrapped=false is requested and verified in the signed intent.
|
|
11
|
+
* - Permit2 allowance: EIP-2612 permit when the token supports it, else an
|
|
12
|
+
* on-chain approve tx that requires `confirmOnchainApprove` and RH ETH for gas.
|
|
13
|
+
*
|
|
14
|
+
* Signing/submit flow ported from @arcus-xyz/arcus-spot-sdk@1.0.2
|
|
15
|
+
* (src/signing.ts) — Copyright Arcus, Apache License 2.0
|
|
16
|
+
* (http://www.apache.org/licenses/LICENSE-2.0).
|
|
17
|
+
*/
|
|
18
|
+
import { PerpError, orderNotSent } from "../../errors.js";
|
|
19
|
+
import { requirePositive, toUnitsExact, unitsToString } from "../precision.js";
|
|
20
|
+
import { networkConfig } from "../venue.js";
|
|
21
|
+
import { unsupported } from "../trading-v2.js";
|
|
22
|
+
import { ArcusSpotChain, MAX_UINT256 } from "./spot-chain.js";
|
|
23
|
+
import { ARCUS_SPOT_DEPLOYMENTS, ArcusSpotRouter, NATIVE_TOKEN_ADDRESS, PERMIT2_ADDRESS, RouterHttpError, } from "./spot-router.js";
|
|
24
|
+
const QUOTE = "USDG";
|
|
25
|
+
/** /status polling: total wait before returning finality "pending". */
|
|
26
|
+
export const ARCUS_SPOT_STATUS_TIMEOUT_MS = 90_000;
|
|
27
|
+
const STATUS_POLL_INTERVAL_MS = 2_000;
|
|
28
|
+
/** USDG notional of the /price probe behind SpotMarketInfo.markPrice. */
|
|
29
|
+
const MARK_PROBE_USDG = "100";
|
|
30
|
+
/** Parallel /price calls in getSpotMarkets (router rate-limits bursts). */
|
|
31
|
+
const MARKET_PRICE_CONCURRENCY = 4;
|
|
32
|
+
/** EIP-2612 permit validity. */
|
|
33
|
+
const PERMIT_TTL_S = 30 * 60;
|
|
34
|
+
/** avgPrice / markPrice decimals. */
|
|
35
|
+
const PRICE_STEP = "0.0000000001";
|
|
36
|
+
/** Refuse to sign an intent whose deadline is further ahead than this (router intents live ~60 s). */
|
|
37
|
+
const MAX_INTENT_DEADLINE_AHEAD_S = 5 * 60;
|
|
38
|
+
/**
|
|
39
|
+
* Pinned EIP-712 types of the router's Permit2 witness intent. Permit2 canonical
|
|
40
|
+
* `PermitWitnessTransferFrom` / `TokenPermissions`; `TakerIntent` field order per
|
|
41
|
+
* @arcus-xyz/arcus-spot-sdk@1.0.2 `TakerIntent` (types.d.ts). The router-supplied
|
|
42
|
+
* types must match exactly (EIP712Domain aside), else nothing is signed.
|
|
43
|
+
*/
|
|
44
|
+
const PINNED_INTENT_TYPES = {
|
|
45
|
+
PermitWitnessTransferFrom: [
|
|
46
|
+
{ name: "permitted", type: "TokenPermissions" },
|
|
47
|
+
{ name: "spender", type: "address" },
|
|
48
|
+
{ name: "nonce", type: "uint256" },
|
|
49
|
+
{ name: "deadline", type: "uint256" },
|
|
50
|
+
{ name: "witness", type: "TakerIntent" },
|
|
51
|
+
],
|
|
52
|
+
TokenPermissions: [
|
|
53
|
+
{ name: "token", type: "address" },
|
|
54
|
+
{ name: "amount", type: "uint256" },
|
|
55
|
+
],
|
|
56
|
+
TakerIntent: [
|
|
57
|
+
{ name: "taker", type: "address" },
|
|
58
|
+
{ name: "takerSellToken", type: "address" },
|
|
59
|
+
{ name: "takerBuyToken", type: "address" },
|
|
60
|
+
{ name: "sellAmount", type: "uint256" },
|
|
61
|
+
{ name: "minBuyAmount", type: "uint256" },
|
|
62
|
+
{ name: "allowWrapped", type: "bool" },
|
|
63
|
+
{ name: "nonce", type: "uint256" },
|
|
64
|
+
{ name: "deadline", type: "uint256" },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
/** true iff the router's EIP-712 types (EIP712Domain excluded) equal PINNED_INTENT_TYPES field-for-field. */
|
|
68
|
+
function intentTypesMatch(types) {
|
|
69
|
+
const names = Object.keys(types).filter(k => k !== "EIP712Domain");
|
|
70
|
+
const pinned = Object.keys(PINNED_INTENT_TYPES);
|
|
71
|
+
if (names.length !== pinned.length || !pinned.every(k => names.includes(k)))
|
|
72
|
+
return false;
|
|
73
|
+
return pinned.every(k => {
|
|
74
|
+
const got = types[k];
|
|
75
|
+
const want = PINNED_INTENT_TYPES[k];
|
|
76
|
+
return Array.isArray(got) && got.length === want.length && want.every((f, i) => got[i]?.name === f.name && got[i]?.type === f.type);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Router error codes meaning "no executable route for this pair/size right now". */
|
|
80
|
+
const NO_ROUTE_CODES = new Set(["NO_QUOTES", "TRADE_NOTIONAL_BELOW_MINIMUM", "TRADE_BELOW_MINIMUM_FEE"]);
|
|
81
|
+
const ROUTER_ONLY_REMEDIATION = "Arcus spot is a swap router: use `perp -e arcus trade spot-buy|spot-sell` (market swap) instead";
|
|
82
|
+
function stepOf(decimals) {
|
|
83
|
+
return decimals === 0 ? "1" : `0.${"0".repeat(decimals - 1)}1`;
|
|
84
|
+
}
|
|
85
|
+
function sameAddr(a, b) {
|
|
86
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
/** quote-units per base-unit, as a decimal string with PRICE_STEP precision (floored). */
|
|
89
|
+
function priceOf(quoteUnits, quoteDecimals, baseUnits, baseDecimals) {
|
|
90
|
+
if (baseUnits <= 0n)
|
|
91
|
+
throw new PerpError("EXCHANGE_ERROR", "Arcus: zero base amount in price computation", { exchange: "arcus" });
|
|
92
|
+
const scale = 10n ** 10n;
|
|
93
|
+
const q = (quoteUnits * 10n ** BigInt(baseDecimals) * scale) / (baseUnits * 10n ** BigInt(quoteDecimals));
|
|
94
|
+
return unitsToString(q, PRICE_STEP);
|
|
95
|
+
}
|
|
96
|
+
function sleep(ms) {
|
|
97
|
+
return new Promise(r => setTimeout(r, ms));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* EvmSigner-compatible view of router typed data: primary type first (OWS
|
|
101
|
+
* signs `Object.keys(types)[0]`), EIP712Domain stripped, domain keys in
|
|
102
|
+
* canonical order (OWS derives the domain type from key order), and uint
|
|
103
|
+
* values hex-encoded (OWS rejects decimal strings above 2^128 — Permit2
|
|
104
|
+
* nonces are 256-bit). Signature is identical to signing the original.
|
|
105
|
+
*/
|
|
106
|
+
function signable(td) {
|
|
107
|
+
const { EIP712Domain: _omit, ...rest } = td.types;
|
|
108
|
+
const primary = rest[td.primaryType];
|
|
109
|
+
if (!primary)
|
|
110
|
+
throw new PerpError("EXCHANGE_ERROR", `Typed data is missing primary type ${td.primaryType}`, { exchange: "arcus" });
|
|
111
|
+
const types = { [td.primaryType]: primary, ...rest };
|
|
112
|
+
const domain = {};
|
|
113
|
+
for (const k of ["name", "version", "chainId", "verifyingContract", "salt"]) {
|
|
114
|
+
if (td.domain[k] !== undefined)
|
|
115
|
+
domain[k] = td.domain[k];
|
|
116
|
+
}
|
|
117
|
+
const encode = (type, v) => {
|
|
118
|
+
if (/^uint\d*$/.test(type))
|
|
119
|
+
return `0x${BigInt(v).toString(16)}`;
|
|
120
|
+
const fields = types[type];
|
|
121
|
+
if (!fields)
|
|
122
|
+
return v;
|
|
123
|
+
const obj = v;
|
|
124
|
+
const out = {};
|
|
125
|
+
for (const f of fields)
|
|
126
|
+
out[f.name] = encode(f.type, obj[f.name]);
|
|
127
|
+
return out;
|
|
128
|
+
};
|
|
129
|
+
return { domain, types, message: encode(td.primaryType, td.message) };
|
|
130
|
+
}
|
|
131
|
+
async function signTyped(signer, td) {
|
|
132
|
+
const s = signable(td);
|
|
133
|
+
return signer.signTypedData(s.domain, s.types, s.message);
|
|
134
|
+
}
|
|
135
|
+
function splitSig(sig) {
|
|
136
|
+
const hex = sig.startsWith("0x") ? sig.slice(2) : sig;
|
|
137
|
+
if (hex.length !== 130)
|
|
138
|
+
throw new PerpError("SIGNATURE_FAILED", `Expected a 65-byte signature, got ${hex.length / 2} bytes`);
|
|
139
|
+
const raw = Number.parseInt(hex.slice(128, 130), 16);
|
|
140
|
+
const v = raw < 27 ? raw + 27 : raw;
|
|
141
|
+
if (v !== 27 && v !== 28)
|
|
142
|
+
throw new PerpError("SIGNATURE_FAILED", `Unsupported ECDSA recovery byte ${raw}`);
|
|
143
|
+
return { v, r: `0x${hex.slice(0, 64)}`, s: `0x${hex.slice(64, 128)}` };
|
|
144
|
+
}
|
|
145
|
+
export class ArcusSpotAdapter {
|
|
146
|
+
name = "arcus";
|
|
147
|
+
perp;
|
|
148
|
+
opts;
|
|
149
|
+
router;
|
|
150
|
+
chain;
|
|
151
|
+
/** upper-case symbol → token (native ETH sentinel excluded) */
|
|
152
|
+
tokens = new Map();
|
|
153
|
+
usdg = null;
|
|
154
|
+
/** opts default refuses the approve tx; the CLI enables it per invocation (--yes) via setConfirmOnchainApprove. */
|
|
155
|
+
constructor(perp, opts = { confirmOnchainApprove: false }) {
|
|
156
|
+
this.perp = perp;
|
|
157
|
+
this.opts = opts;
|
|
158
|
+
const cfg = networkConfig(perp.venue, perp.network);
|
|
159
|
+
if (cfg.rpcUrl === null)
|
|
160
|
+
throw new Error(`arcus ${perp.network} venue definition is missing rpcUrl`);
|
|
161
|
+
const dep = ARCUS_SPOT_DEPLOYMENTS[perp.network];
|
|
162
|
+
if (cfg.signingChainId !== dep.chainId) {
|
|
163
|
+
throw new Error(`arcus ${perp.network}: signingChainId ${cfg.signingChainId} ≠ spot router chain ${dep.chainId}`);
|
|
164
|
+
}
|
|
165
|
+
this.router = new ArcusSpotRouter(dep);
|
|
166
|
+
this.chain = new ArcusSpotChain(cfg.rpcUrl, dep.chainId);
|
|
167
|
+
}
|
|
168
|
+
/** CLI --yes: allow the one-time on-chain approve(Permit2) tx. */
|
|
169
|
+
setConfirmOnchainApprove(confirm) {
|
|
170
|
+
this.opts = { ...this.opts, confirmOnchainApprove: confirm };
|
|
171
|
+
}
|
|
172
|
+
/** Loads the router's live token list (the canonical market universe). */
|
|
173
|
+
async init() {
|
|
174
|
+
const rows = await this.router.tokens();
|
|
175
|
+
const map = new Map();
|
|
176
|
+
let usdg = null;
|
|
177
|
+
for (const t of rows) {
|
|
178
|
+
if (t.chainId !== this.router.deployment.chainId)
|
|
179
|
+
continue;
|
|
180
|
+
if (sameAddr(t.address, NATIVE_TOKEN_ADDRESS))
|
|
181
|
+
continue; // native ETH: buying/selling it needs a user-broadcast tx (not supported)
|
|
182
|
+
const key = t.symbol.toUpperCase();
|
|
183
|
+
if (map.has(key))
|
|
184
|
+
throw new PerpError("EXCHANGE_ERROR", `Arcus /tokens lists symbol ${t.symbol} twice`, { exchange: "arcus" });
|
|
185
|
+
map.set(key, t);
|
|
186
|
+
if (key === QUOTE)
|
|
187
|
+
usdg = t;
|
|
188
|
+
}
|
|
189
|
+
if (usdg === null)
|
|
190
|
+
throw new PerpError("EXCHANGE_ERROR", `Arcus /tokens has no ${QUOTE}`, { exchange: "arcus" });
|
|
191
|
+
this.tokens = map;
|
|
192
|
+
this.usdg = usdg;
|
|
193
|
+
}
|
|
194
|
+
quoteToken() {
|
|
195
|
+
if (this.usdg === null)
|
|
196
|
+
throw new Error("ArcusSpotAdapter.init() was not called");
|
|
197
|
+
return this.usdg;
|
|
198
|
+
}
|
|
199
|
+
/** bare base or "BASE/USDG" → router token. */
|
|
200
|
+
resolve(symbol) {
|
|
201
|
+
const [base, quote, extra] = symbol.toUpperCase().split("/");
|
|
202
|
+
if (extra !== undefined || (quote !== undefined && quote !== QUOTE)) {
|
|
203
|
+
throw new PerpError("SYMBOL_NOT_FOUND", `Arcus spot only quotes ${QUOTE} (got ${symbol})`, { exchange: "arcus" });
|
|
204
|
+
}
|
|
205
|
+
if (base === "ETH") {
|
|
206
|
+
throw new PerpError("NOT_IMPLEMENTED", "Arcus spot: native ETH swaps need a user-broadcast tx and are not supported", {
|
|
207
|
+
exchange: "arcus", remediation: "Trade WETH/USDG instead",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const t = this.tokens.get(base);
|
|
211
|
+
if (!t || base === QUOTE) {
|
|
212
|
+
throw new PerpError("SYMBOL_NOT_FOUND", `Arcus spot has no token ${base}`, {
|
|
213
|
+
exchange: "arcus", remediation: "Use a symbol from the Arcus router token list (GET /v1/tokens)",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return t;
|
|
217
|
+
}
|
|
218
|
+
bestEntry(r) {
|
|
219
|
+
const e = r.all.find(x => x.venue === r.recommended);
|
|
220
|
+
if (!e)
|
|
221
|
+
throw new PerpError("EXCHANGE_ERROR", `Arcus router recommended venue ${r.recommended} is missing from all[]`, { exchange: "arcus" });
|
|
222
|
+
return e;
|
|
223
|
+
}
|
|
224
|
+
// ── Market data ──
|
|
225
|
+
/**
|
|
226
|
+
* markPrice = all-in indicative price of a MARK_PROBE_USDG buy (/price,
|
|
227
|
+
* USDG sellAmount ÷ net buyAmount, fees included) — the router has no mark.
|
|
228
|
+
* volume24h = null: the router publishes no volume. Tokens the router
|
|
229
|
+
* cannot route at the probe size (422 NO_QUOTES / below-minimum codes) are
|
|
230
|
+
* omitted and listed on stderr; any other error propagates.
|
|
231
|
+
*/
|
|
232
|
+
async getSpotMarkets() {
|
|
233
|
+
const usdg = this.quoteToken();
|
|
234
|
+
return this.priceMarkets([...this.tokens.values()].filter(t => t !== usdg));
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* SpotPricesCapable: markets (same pricing as getSpotMarkets) for the given base
|
|
238
|
+
* tokens only — one /price call per token, for valuing wallet holdings without
|
|
239
|
+
* pricing the whole universe. Tokens the router does not list (native ETH, wrapped
|
|
240
|
+
* placeholders) are not returned; the caller reports them as unpriced.
|
|
241
|
+
*/
|
|
242
|
+
async getSpotPrices(tokens) {
|
|
243
|
+
const usdg = this.quoteToken();
|
|
244
|
+
const wanted = new Set(tokens.map(t => t.toUpperCase()));
|
|
245
|
+
return this.priceMarkets([...this.tokens.values()].filter(t => t !== usdg && wanted.has(t.symbol.toUpperCase())));
|
|
246
|
+
}
|
|
247
|
+
async priceMarkets(bases) {
|
|
248
|
+
const usdg = this.quoteToken();
|
|
249
|
+
const probe = toUnitsExact(MARK_PROBE_USDG, stepOf(usdg.decimals), "probe");
|
|
250
|
+
const out = new Array(bases.length).fill(null);
|
|
251
|
+
const omitted = [];
|
|
252
|
+
let next = 0;
|
|
253
|
+
const worker = async () => {
|
|
254
|
+
while (next < bases.length) {
|
|
255
|
+
const i = next++;
|
|
256
|
+
const t = bases[i];
|
|
257
|
+
let r;
|
|
258
|
+
try {
|
|
259
|
+
r = await this.router.price(usdg.address, t.address, probe);
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
if (e instanceof RouterHttpError && e.status === 422 && e.routerCode !== null && NO_ROUTE_CODES.has(e.routerCode)) {
|
|
263
|
+
omitted.push(`${t.symbol}(${e.routerCode})`);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
throw e;
|
|
267
|
+
}
|
|
268
|
+
const best = this.bestEntry(r);
|
|
269
|
+
out[i] = {
|
|
270
|
+
symbol: `${t.symbol}/${QUOTE}`,
|
|
271
|
+
baseToken: t.symbol,
|
|
272
|
+
quoteToken: QUOTE,
|
|
273
|
+
markPrice: priceOf(BigInt(best.sellAmount), usdg.decimals, BigInt(best.buyAmount), t.decimals),
|
|
274
|
+
volume24h: null,
|
|
275
|
+
sizeDecimals: t.decimals,
|
|
276
|
+
priceDecimals: usdg.decimals,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
await Promise.all(Array.from({ length: MARKET_PRICE_CONCURRENCY }, worker));
|
|
281
|
+
if (omitted.length > 0) {
|
|
282
|
+
console.error(`[arcus-spot] no route at ${MARK_PROBE_USDG} ${QUOTE} for ${omitted.length} token(s), omitted: ${omitted.join(", ")}`);
|
|
283
|
+
}
|
|
284
|
+
return out.filter((m) => m !== null);
|
|
285
|
+
}
|
|
286
|
+
async getSpotOrderbook(_symbol) {
|
|
287
|
+
throw unsupported("Arcus spot", "spotOrderbook", "the router has no orderbook (AMM + RFQ)", ROUTER_ONLY_REMEDIATION);
|
|
288
|
+
}
|
|
289
|
+
// ── Account ──
|
|
290
|
+
/**
|
|
291
|
+
* Wallet balances on Robinhood Chain: native ETH, USDG, and every /tokens
|
|
292
|
+
* token (plus its wrapped placeholder) with a non-zero balance. Wrapped
|
|
293
|
+
* placeholders are reported as "<SYMBOL>(wrapped)" — awaiting maker
|
|
294
|
+
* settlement, non-transferable. shared:false (not perp collateral).
|
|
295
|
+
*/
|
|
296
|
+
async getSpotBalances() {
|
|
297
|
+
const owner = (await this.perp.masterEvmSigner()).getAddress();
|
|
298
|
+
const usdg = this.quoteToken();
|
|
299
|
+
const list = [...this.tokens.values()];
|
|
300
|
+
const targets = [];
|
|
301
|
+
for (const t of list) {
|
|
302
|
+
targets.push({ token: t.address, label: t.symbol, decimals: t.decimals, wrapped: false });
|
|
303
|
+
if (t.wrappedTokenAddress)
|
|
304
|
+
targets.push({ token: t.wrappedTokenAddress, label: `${t.symbol}(wrapped)`, decimals: t.decimals, wrapped: true });
|
|
305
|
+
}
|
|
306
|
+
const [eth, bals] = await Promise.all([
|
|
307
|
+
this.chain.nativeBalance(owner),
|
|
308
|
+
this.chain.erc20Balances(owner, targets.map(x => x.token)),
|
|
309
|
+
]);
|
|
310
|
+
const row = (token, units, decimals) => {
|
|
311
|
+
const total = unitsToString(units, stepOf(decimals));
|
|
312
|
+
return { token, total, available: total, held: "0", shared: false };
|
|
313
|
+
};
|
|
314
|
+
const out = [row("ETH", eth, 18)];
|
|
315
|
+
targets.forEach((x, i) => {
|
|
316
|
+
const b = bals[i];
|
|
317
|
+
if (b === null) {
|
|
318
|
+
// No contract at this address: only legitimate for a not-yet-deployed wrapped placeholder.
|
|
319
|
+
if (!x.wrapped)
|
|
320
|
+
throw new PerpError("EXCHANGE_ERROR", `Token ${x.label} (${x.token}) has no contract on chain ${this.router.deployment.chainId}`, { exchange: "arcus" });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (b > 0n || sameAddr(x.token, usdg.address))
|
|
324
|
+
out.push(row(x.label, b, x.decimals));
|
|
325
|
+
});
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
// ── Trading ──
|
|
329
|
+
/**
|
|
330
|
+
* Market swap. `size` is in BASE units for both sides.
|
|
331
|
+
* - sell: exact-input `size` BASE → USDG.
|
|
332
|
+
* - buy: the router is exact-input only, so `size` BASE is converted to a
|
|
333
|
+
* USDG sellAmount: V = USDG out for selling `size` (/price), B = BASE out
|
|
334
|
+
* for selling V USDG (/price), sellAmount = ceil(size × V / B) — the
|
|
335
|
+
* all-in buy price at that notional. The signed intent must then carry
|
|
336
|
+
* minBuyAmount ≥ size × (1 − slippageBps), else nothing is signed.
|
|
337
|
+
* Both sides also require minBuyAmount ≥ quote.buyAmount × (1 − slippageBps).
|
|
338
|
+
* slippageBps = the caller's resolveSlippage() value (design §13.2); the router
|
|
339
|
+
* takes an integer bound.
|
|
340
|
+
*/
|
|
341
|
+
async spotMarketOrder(symbol, side, size, opts) {
|
|
342
|
+
// Failures before /submit (validation, pricing, allowance, quote, signing) are a
|
|
343
|
+
// definite "not placed" (ORDER_REJECTED); /submit failures are typed by submitError().
|
|
344
|
+
const progress = { submitted: false };
|
|
345
|
+
try {
|
|
346
|
+
return await this.swap(symbol, side, size, opts.slippage.bps, progress);
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
if (progress.submitted)
|
|
350
|
+
throw e;
|
|
351
|
+
// The swap was not submitted, but an approve(Permit2) tx sent on the way may still be
|
|
352
|
+
// pending: keep its ORDER_PENDING ("Do NOT send another approve"), never re-typed as not sent.
|
|
353
|
+
if (e instanceof PerpError && e.structured.code === "ORDER_PENDING")
|
|
354
|
+
throw e;
|
|
355
|
+
throw orderNotSent("arcus", `spot ${side} ${size} ${symbol}`, e);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async swap(symbol, side, size, bps, progress) {
|
|
359
|
+
if (!Number.isInteger(bps) || bps <= 0 || bps >= 10_000) {
|
|
360
|
+
throw new PerpError("INVALID_PARAMS", `slippageBps must be an integer in 1..9999 (got ${bps})`, { exchange: "arcus" });
|
|
361
|
+
}
|
|
362
|
+
if (side !== "buy" && side !== "sell")
|
|
363
|
+
throw new PerpError("INVALID_PARAMS", `side must be buy or sell (got ${String(side)})`);
|
|
364
|
+
requirePositive(size, "size");
|
|
365
|
+
const base = this.resolve(symbol);
|
|
366
|
+
const usdg = this.quoteToken();
|
|
367
|
+
const sizeUnits = toUnitsExact(size, stepOf(base.decimals), "size");
|
|
368
|
+
const signer = await this.perp.masterEvmSigner();
|
|
369
|
+
const taker = signer.getAddress();
|
|
370
|
+
const sellToken = side === "buy" ? usdg : base;
|
|
371
|
+
const buyToken = side === "buy" ? base : usdg;
|
|
372
|
+
let sellAmount;
|
|
373
|
+
if (side === "sell") {
|
|
374
|
+
sellAmount = sizeUnits;
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
const v = BigInt(this.bestEntry(await this.router.price(base.address, usdg.address, sizeUnits)).buyAmount);
|
|
378
|
+
if (v <= 0n)
|
|
379
|
+
throw new PerpError("EXCHANGE_ERROR", `Arcus /price returned 0 ${QUOTE} for ${size} ${base.symbol}`, { exchange: "arcus" });
|
|
380
|
+
const b = BigInt(this.bestEntry(await this.router.price(usdg.address, base.address, v)).buyAmount);
|
|
381
|
+
if (b <= 0n)
|
|
382
|
+
throw new PerpError("EXCHANGE_ERROR", `Arcus /price returned 0 ${base.symbol} for ${v} ${QUOTE} units`, { exchange: "arcus" });
|
|
383
|
+
sellAmount = (sizeUnits * v + b - 1n) / b;
|
|
384
|
+
}
|
|
385
|
+
// Allowance first: an approve tx can outlive the ~60 s intent deadline.
|
|
386
|
+
const permits = await this.ensurePermit2Allowance(sellToken, taker, sellAmount, signer);
|
|
387
|
+
const q = await this.router.quote({ sellToken: sellToken.address, buyToken: buyToken.address, sellAmount, taker, slippageBps: bps });
|
|
388
|
+
const entry = q.all.find(e => e.venue === "arcus");
|
|
389
|
+
if (!entry || !entry.toSign) {
|
|
390
|
+
throw new PerpError("NOT_IMPLEMENTED", `Arcus router offered no arcus-venue intent (recommended: ${q.recommended}); only arcus-venue Permit2 intents are supported`, {
|
|
391
|
+
exchange: "arcus", remediation: "Retry later or trade in the Arcus app",
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const minForSize = side === "buy" ? (sizeUnits * BigInt(10_000 - bps)) / 10000n : null;
|
|
395
|
+
this.verifyIntent(entry, { taker, sellToken, buyToken, sellAmount, bps, minForSize });
|
|
396
|
+
// The USDG side of the signed intent, shown before signing and kept in venueRaw.
|
|
397
|
+
const usdgStep = stepOf(usdg.decimals);
|
|
398
|
+
const minOutUnits = BigInt(entry.toSign.message.witness.minBuyAmount);
|
|
399
|
+
let intentUsdg;
|
|
400
|
+
if (side === "buy") {
|
|
401
|
+
intentUsdg = { usdgSpend: unitsToString(sellAmount, usdgStep) };
|
|
402
|
+
console.error(`[arcus-spot] signing swap: spend ${intentUsdg.usdgSpend} ${QUOTE} for ≥ ${unitsToString(minOutUnits, stepOf(base.decimals))} ${base.symbol}`);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
intentUsdg = { usdgMinReceive: unitsToString(minOutUnits, usdgStep) };
|
|
406
|
+
console.error(`[arcus-spot] signing swap: sell ${size} ${base.symbol} for ≥ ${intentUsdg.usdgMinReceive} ${QUOTE}`);
|
|
407
|
+
}
|
|
408
|
+
const signature = await signTyped(signer, entry.toSign);
|
|
409
|
+
const deadline = Number(entry.toSign.message.witness.deadline);
|
|
410
|
+
let submitted;
|
|
411
|
+
progress.submitted = true;
|
|
412
|
+
try {
|
|
413
|
+
submitted = await this.router.submit({
|
|
414
|
+
venue: "arcus", chainId: this.router.deployment.chainId, taker, typedData: entry.toSign, signature,
|
|
415
|
+
...(permits.length > 0 ? { permits } : {}),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
catch (e) {
|
|
419
|
+
throw this.submitError(e, deadline);
|
|
420
|
+
}
|
|
421
|
+
// From here the intent is submitted: nothing below may throw (a retry would
|
|
422
|
+
// double-trade) — every failure becomes finality "pending" + do NOT resubmit.
|
|
423
|
+
const marketSymbol = `${base.symbol}/${QUOTE}`;
|
|
424
|
+
if (!submitted || typeof submitted.txHash !== "string") {
|
|
425
|
+
const orderId = submitted && typeof submitted.orderId === "string" ? submitted.orderId : null;
|
|
426
|
+
return this.pendingResult(marketSymbol, orderId, null, `/submit accepted but returned no txHash; the intent expires at ${new Date(deadline * 1000).toISOString()}`, { submit: submitted, ...intentUsdg });
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
return await this.awaitFinal(symbol, side, base, usdg, taker, submitted, intentUsdg);
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
return this.pendingResult(marketSymbol, submitted.orderId ?? submitted.txHash, submitted.txHash, `the outcome could not be read (${e instanceof Error ? e.message : String(e)})`, { submit: submitted, ...intentUsdg });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** Submitted-but-unconfirmed result: never a throw, always "do NOT resubmit". */
|
|
436
|
+
pendingResult(marketSymbol, orderId, txHash, why, raw) {
|
|
437
|
+
const check = orderId !== null ? `; status: ${this.router.statusUrl(orderId)}` : "";
|
|
438
|
+
return {
|
|
439
|
+
venue: "arcus", symbol: marketSymbol, orderId, status: "pending", filledSize: null, avgPrice: null,
|
|
440
|
+
finality: "pending", receivedToken: null,
|
|
441
|
+
remediation: `Submitted${txHash !== null ? ` (tx ${txHash})` : ""} but ${why} — do NOT resubmit; check balances with: perp -e arcus trade spot-balance${check}`,
|
|
442
|
+
venueRaw: { ...raw, txHash },
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
verifyIntent(e, x) {
|
|
446
|
+
const td = e.toSign;
|
|
447
|
+
const m = td.message;
|
|
448
|
+
const w = m.witness;
|
|
449
|
+
const dep = this.router.deployment;
|
|
450
|
+
const bad = (what) => {
|
|
451
|
+
throw new PerpError("EXCHANGE_ERROR", `Arcus quote rejected locally (not signed): ${what}`, { exchange: "arcus" });
|
|
452
|
+
};
|
|
453
|
+
if (td.primaryType !== "PermitWitnessTransferFrom")
|
|
454
|
+
bad(`primaryType ${td.primaryType}`);
|
|
455
|
+
if (!intentTypesMatch(td.types))
|
|
456
|
+
bad(`EIP-712 types differ from the pinned Permit2/TakerIntent definitions: ${JSON.stringify(td.types)}`);
|
|
457
|
+
if (td.domain.name !== "Permit2" || Number(td.domain.chainId) !== dep.chainId || !sameAddr(String(td.domain.verifyingContract), PERMIT2_ADDRESS))
|
|
458
|
+
bad("domain is not Permit2 on this chain");
|
|
459
|
+
if (!sameAddr(m.spender, dep.arcusSettlement))
|
|
460
|
+
bad(`spender ${m.spender} is not ArcusSettlement`);
|
|
461
|
+
if (!sameAddr(m.permitted.token, x.sellToken.address) || BigInt(m.permitted.amount) !== x.sellAmount)
|
|
462
|
+
bad("permitted token/amount mismatch");
|
|
463
|
+
if (!sameAddr(w.taker, x.taker))
|
|
464
|
+
bad(`taker ${w.taker}`);
|
|
465
|
+
if (!sameAddr(w.takerSellToken, x.sellToken.address) || !sameAddr(w.takerBuyToken, x.buyToken.address))
|
|
466
|
+
bad("token pair mismatch");
|
|
467
|
+
if (BigInt(w.sellAmount) !== x.sellAmount)
|
|
468
|
+
bad(`sellAmount ${w.sellAmount}`);
|
|
469
|
+
if (w.allowWrapped !== false)
|
|
470
|
+
bad("allowWrapped is not false");
|
|
471
|
+
if (w.nonce !== m.nonce || w.deadline !== m.deadline)
|
|
472
|
+
bad("witness nonce/deadline differ from the permit");
|
|
473
|
+
const nowS = Math.floor(Date.now() / 1000);
|
|
474
|
+
if (Number(w.deadline) <= nowS)
|
|
475
|
+
bad("intent deadline already passed");
|
|
476
|
+
if (Number(w.deadline) > nowS + MAX_INTENT_DEADLINE_AHEAD_S)
|
|
477
|
+
bad(`intent deadline ${w.deadline} is more than ${MAX_INTENT_DEADLINE_AHEAD_S}s ahead`);
|
|
478
|
+
const minOut = BigInt(w.minBuyAmount);
|
|
479
|
+
if (minOut <= 0n)
|
|
480
|
+
bad("minBuyAmount is 0");
|
|
481
|
+
const keep = BigInt(10_000 - x.bps);
|
|
482
|
+
if (minOut < (BigInt(e.buyAmount) * keep) / 10000n)
|
|
483
|
+
bad(`minBuyAmount ${minOut} is looser than ${x.bps} bps below the quoted ${e.buyAmount}`);
|
|
484
|
+
if (x.minForSize !== null && minOut < x.minForSize) {
|
|
485
|
+
throw new PerpError("EXCHANGE_ERROR", `Arcus quote rejected locally (not signed): minBuyAmount ${minOut} < requested size × (1 − ${x.bps} bps) = ${x.minForSize} base units — price moved`, {
|
|
486
|
+
exchange: "arcus", remediation: "Retry, or widen --slippage-bps",
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Ensure the taker's sellToken→Permit2 allowance covers `amount`
|
|
492
|
+
* (SDK buildArcusSellTokenPermitIfNeeded): none needed → []; EIP-2612 token →
|
|
493
|
+
* one signed permit; otherwise an approve tx, only when confirmOnchainApprove.
|
|
494
|
+
*/
|
|
495
|
+
async ensurePermit2Allowance(token, owner, amount, signer) {
|
|
496
|
+
const allowance = await this.chain.permit2Allowance(token.address, owner);
|
|
497
|
+
if (allowance >= amount)
|
|
498
|
+
return [];
|
|
499
|
+
const nonce = await this.chain.eip2612Nonce(token.address, owner);
|
|
500
|
+
if (nonce === null) {
|
|
501
|
+
if (!this.opts.confirmOnchainApprove) {
|
|
502
|
+
throw new PerpError("INVALID_PARAMS", `approve tx required — ${token.symbol} has no EIP-2612 permit; a one-time on-chain approve(Permit2) costs Robinhood Chain ETH gas. Rerun with --yes`, {
|
|
503
|
+
exchange: "arcus", remediation: "Rerun with --yes to send the approve tx",
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
const hash = await this.chain.approvePermit2(token.address, owner, tx => this.perp.signMasterTransaction(tx));
|
|
507
|
+
console.error(`[arcus-spot] approve(Permit2) for ${token.symbol} confirmed: ${hash}`);
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
510
|
+
const { name, version } = await this.chain.eip2612Domain(token.address);
|
|
511
|
+
const deadline = BigInt(Math.floor(Date.now() / 1000) + PERMIT_TTL_S);
|
|
512
|
+
const sig = await signTyped(signer, {
|
|
513
|
+
domain: { name, version, chainId: this.router.deployment.chainId, verifyingContract: token.address },
|
|
514
|
+
types: {
|
|
515
|
+
Permit: [
|
|
516
|
+
{ name: "owner", type: "address" }, { name: "spender", type: "address" }, { name: "value", type: "uint256" },
|
|
517
|
+
{ name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" },
|
|
518
|
+
],
|
|
519
|
+
},
|
|
520
|
+
primaryType: "Permit",
|
|
521
|
+
message: { owner, spender: PERMIT2_ADDRESS, value: MAX_UINT256.toString(), nonce: nonce.toString(), deadline: deadline.toString() },
|
|
522
|
+
});
|
|
523
|
+
return [{ token: token.address, value: MAX_UINT256.toString(), deadline: deadline.toString(), ...splitSig(sig) }];
|
|
524
|
+
}
|
|
525
|
+
submitError(e, deadline) {
|
|
526
|
+
const expires = new Date(deadline * 1000).toISOString();
|
|
527
|
+
if (e instanceof RouterHttpError) {
|
|
528
|
+
if (e.status === 401 || e.status === 403) {
|
|
529
|
+
return new PerpError("NOT_IMPLEMENTED", `Arcus router /submit requires an API key (HTTP ${e.status}); perp-cli has no router key storage`, {
|
|
530
|
+
exchange: "arcus", remediation: "Contact Arcus (vip@arcus.xyz) for a router API key; until supported, trade in the Arcus app",
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
if (e.status === 429)
|
|
534
|
+
return e; // rate-limited before processing
|
|
535
|
+
if (e.status >= 400 && e.status < 500) {
|
|
536
|
+
// Explicit router rejection: definitely not executed. Keep a definite code
|
|
537
|
+
// (SIZE_TOO_SMALL stays); a generic one becomes ORDER_REJECTED.
|
|
538
|
+
return e.structured.code === "EXCHANGE_ERROR"
|
|
539
|
+
? new PerpError("ORDER_REJECTED", e.message, { exchange: "arcus", httpStatus: e.status, routerCode: e.routerCode })
|
|
540
|
+
: e;
|
|
541
|
+
}
|
|
542
|
+
// 5xx: the router may have forwarded the signed intent — the outcome is unknown.
|
|
543
|
+
// ORDER_PENDING is non-retryable (a resend could double-execute).
|
|
544
|
+
return new PerpError("ORDER_PENDING", `Arcus /submit outcome unknown: ${e.message}`, {
|
|
545
|
+
exchange: "arcus",
|
|
546
|
+
remediation: `Outcome unknown — do NOT resubmit before the intent expires (${expires}); then check: perp -e arcus trade spot-balance`,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
if (e instanceof PerpError && (e.structured.code === "TIMEOUT" || e.structured.code === "EXCHANGE_UNREACHABLE")) {
|
|
550
|
+
// Same as 5xx: the intent may have reached the router. Non-retryable.
|
|
551
|
+
return new PerpError("ORDER_PENDING", `Arcus /submit outcome unknown (${e.structured.code}): ${e.message}`, {
|
|
552
|
+
exchange: "arcus",
|
|
553
|
+
remediation: `Outcome unknown — do NOT resubmit before the intent expires (${expires}); then check: perp -e arcus trade spot-balance`,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
if (e instanceof PerpError)
|
|
557
|
+
return e;
|
|
558
|
+
// Not a PerpError: the request was sent (fetch resolved) but reading the response
|
|
559
|
+
// body failed — the intent may have reached the router. Non-retryable.
|
|
560
|
+
return new PerpError("ORDER_PENDING", `Arcus /submit outcome unknown: ${e instanceof Error ? e.message : String(e)}`, {
|
|
561
|
+
exchange: "arcus",
|
|
562
|
+
remediation: `Outcome unknown — do NOT resubmit before the intent expires (${expires}); then check: perp -e arcus trade spot-balance`,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Poll /status until confirmed/failed or ARCUS_SPOT_STATUS_TIMEOUT_MS; fills come from the
|
|
567
|
+
* receipt's Transfer logs. Any inconsistency returns "pending" (never throws for a bad
|
|
568
|
+
* outcome — the caller also converts thrown poll/RPC errors into "pending").
|
|
569
|
+
*/
|
|
570
|
+
async awaitFinal(symbol, side, base, usdg, taker, sub, intentUsdg) {
|
|
571
|
+
const id = sub.orderId ?? sub.txHash;
|
|
572
|
+
const marketSymbol = `${base.symbol}/${QUOTE}`;
|
|
573
|
+
const until = Date.now() + ARCUS_SPOT_STATUS_TIMEOUT_MS;
|
|
574
|
+
let st = null;
|
|
575
|
+
while (Date.now() < until) {
|
|
576
|
+
st = await this.router.status(id);
|
|
577
|
+
if (st.status === "confirmed" || st.status === "failed")
|
|
578
|
+
break;
|
|
579
|
+
await sleep(STATUS_POLL_INTERVAL_MS);
|
|
580
|
+
}
|
|
581
|
+
const raw = { submit: sub, status: st, ...intentUsdg };
|
|
582
|
+
if (st === null || (st.status !== "confirmed" && st.status !== "failed")) {
|
|
583
|
+
return this.pendingResult(marketSymbol, id, sub.txHash, `not final after ${ARCUS_SPOT_STATUS_TIMEOUT_MS / 1000}s`, raw);
|
|
584
|
+
}
|
|
585
|
+
if (st.status === "failed") {
|
|
586
|
+
return {
|
|
587
|
+
venue: "arcus", symbol: marketSymbol, orderId: id, status: "rejected", filledSize: null, avgPrice: null,
|
|
588
|
+
finality: "final", receivedToken: null,
|
|
589
|
+
remediation: `Swap failed${st.reason ? `: ${st.reason}` : ""}${st.errorCode ? ` (${st.errorCode})` : ""}; no funds moved by the settlement`,
|
|
590
|
+
venueRaw: raw,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const txHash = st.txHash ?? sub.txHash;
|
|
594
|
+
const rec = await this.chain.transfersIn(txHash);
|
|
595
|
+
if (rec === null) {
|
|
596
|
+
return this.pendingResult(marketSymbol, id, txHash, "the router reports confirmed while the RPC has no receipt yet (fill amounts unknown)", raw);
|
|
597
|
+
}
|
|
598
|
+
if (rec.status !== 1) {
|
|
599
|
+
return this.pendingResult(marketSymbol, id, txHash, `the router reports confirmed but the receipt status is ${rec.status}`, raw);
|
|
600
|
+
}
|
|
601
|
+
const buyTok = side === "buy" ? base : usdg;
|
|
602
|
+
const sellTok = side === "buy" ? usdg : base;
|
|
603
|
+
const wrapped = buyTok.wrappedTokenAddress ?? null;
|
|
604
|
+
let gotReal = 0n;
|
|
605
|
+
let gotWrapped = 0n;
|
|
606
|
+
let paid = 0n;
|
|
607
|
+
for (const t of rec.transfers) {
|
|
608
|
+
if (sameAddr(t.to, taker) && sameAddr(t.token, buyTok.address))
|
|
609
|
+
gotReal += t.amount;
|
|
610
|
+
if (wrapped !== null && sameAddr(t.to, taker) && sameAddr(t.token, wrapped))
|
|
611
|
+
gotWrapped += t.amount;
|
|
612
|
+
if (sameAddr(t.from, taker) && sameAddr(t.token, sellTok.address))
|
|
613
|
+
paid += t.amount;
|
|
614
|
+
}
|
|
615
|
+
const got = gotReal + gotWrapped;
|
|
616
|
+
if (got === 0n) {
|
|
617
|
+
// Confirmed tx but nothing reached the taker: inconsistent — report, never claim a fill.
|
|
618
|
+
return this.pendingResult(marketSymbol, id, txHash, `the confirmed tx shows no ${buyTok.symbol} transfer to the taker (paid ${paid} ${sellTok.symbol} units)`, raw);
|
|
619
|
+
}
|
|
620
|
+
const receivedToken = gotWrapped > 0n ? `${buyTok.symbol}(wrapped)` : buyTok.symbol;
|
|
621
|
+
const baseUnits = side === "buy" ? got : paid;
|
|
622
|
+
const quoteUnits = side === "buy" ? paid : got;
|
|
623
|
+
const priced = baseUnits > 0n && quoteUnits > 0n;
|
|
624
|
+
return {
|
|
625
|
+
venue: "arcus",
|
|
626
|
+
symbol: marketSymbol,
|
|
627
|
+
orderId: id,
|
|
628
|
+
status: "filled",
|
|
629
|
+
filledSize: baseUnits > 0n ? unitsToString(baseUnits, stepOf(base.decimals)) : null,
|
|
630
|
+
avgPrice: priced ? priceOf(quoteUnits, usdg.decimals, baseUnits, base.decimals) : null,
|
|
631
|
+
finality: "final",
|
|
632
|
+
receivedToken,
|
|
633
|
+
remediation: gotWrapped > 0n
|
|
634
|
+
? `Received wrapped placeholder ${wrapped} despite allowWrapped=false — non-transferable until the maker settles it to ${buyTok.symbol}`
|
|
635
|
+
: null,
|
|
636
|
+
venueRaw: { ...raw, txHash, requestedSymbol: symbol },
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
async spotLimitOrder(_symbol, _side, _price, _size, _opts) {
|
|
640
|
+
throw unsupported("Arcus spot", "spotLimitOrder", "swaps settle atomically; nothing rests", ROUTER_ONLY_REMEDIATION);
|
|
641
|
+
}
|
|
642
|
+
async spotCancelOrder(_symbol, _orderId) {
|
|
643
|
+
throw unsupported("Arcus spot", "spotCancel", "there are no resting orders to cancel", ROUTER_ONLY_REMEDIATION);
|
|
644
|
+
}
|
|
645
|
+
}
|