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,630 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay cross-chain swaps (https://api.relay.link, docs https://docs.relay.link) with local
|
|
3
|
+
* verification of every quote before the master wallet signs anything.
|
|
4
|
+
*
|
|
5
|
+
* Flow: POST /quote (EXACT_INPUT, includeProtocolData) → verify → execute the EVM-origin steps
|
|
6
|
+
* (ERC-20 approve for the exact amount + depository deposit, signed by the OWS master) →
|
|
7
|
+
* poll GET /intents/status/v3 until success / failure (bounded; unknown → ORDER_PENDING with
|
|
8
|
+
* the request id).
|
|
9
|
+
*
|
|
10
|
+
* Verification follows Relay's "Input Validation" guide
|
|
11
|
+
* (docs.relay.link/references/api/api_core_concepts/input-validation):
|
|
12
|
+
* 1. the protocol order (`protocol.v2.orderData`) pays the expected recipient the expected
|
|
13
|
+
* currency on the expected chain, takes exactly the requested input, has no extra calls,
|
|
14
|
+
* refunds only to the sender / recipient and is not expired;
|
|
15
|
+
* 2. the order id is recomputed locally (EIP-712 struct hash of the order — a port of
|
|
16
|
+
* `getOrderId` from @relay-protocol/settlement-sdk) and must equal `protocol.v2.orderId`;
|
|
17
|
+
* 3. the deposit transaction pays Relay's canonical depository — PINNED below per chain, never
|
|
18
|
+
* taken from the quote — commits to that order id, credits the sender as depositor and moves
|
|
19
|
+
* exactly the order's input; an approve step may only approve that depository for the exact
|
|
20
|
+
* amount on the origin token. Any other step, target, spender, amount or chain id is refused.
|
|
21
|
+
*
|
|
22
|
+
* Mainnet only. Origin must be an EVM chain (the master EVM wallet signs); the destination may be
|
|
23
|
+
* EVM or Solana. The recipient is always the master wallet's own address — enforced by the CLI.
|
|
24
|
+
*/
|
|
25
|
+
import { Interface, TypedDataEncoder, formatUnits, getBytes, hexlify, parseUnits } from "ethers";
|
|
26
|
+
import bs58 from "bs58";
|
|
27
|
+
import { PerpError } from "./errors.js";
|
|
28
|
+
import { ERC20, EVM_ADDRESS_RE, assertRpcChainId, erc20Balance, erc20Decimals, evmProvider, sameAddress, sendMasterTx, } from "./evm-master-tx.js";
|
|
29
|
+
export const RELAY_API = "https://api.relay.link";
|
|
30
|
+
const EVM_NATIVE = "0x0000000000000000000000000000000000000000";
|
|
31
|
+
const SOLANA_NATIVE = "11111111111111111111111111111111";
|
|
32
|
+
/** Same depository address on every EVM chain below (GET https://api.relay.link/chains, 2026-10-05). */
|
|
33
|
+
const EVM_DEPOSITORY = "0x4cD00E387622C35bDDB9b4c962C136462338BC31";
|
|
34
|
+
/**
|
|
35
|
+
* Chains + assets the CLI bridges with Relay. Chain ids, protocol chain ids, depository and token
|
|
36
|
+
* addresses verified 2026-10-05 against GET https://api.relay.link/chains (4663 "robinhood" with
|
|
37
|
+
* USDG, 792703809 "solana", 56 "bnb", 42161 "arbitrum", 8453 "base") and the token issuers'
|
|
38
|
+
* address lists (Circle USDC, Tether USDT, Paxos USDG on Robinhood Chain).
|
|
39
|
+
*/
|
|
40
|
+
export const RELAY_CHAINS = {
|
|
41
|
+
arbitrum: {
|
|
42
|
+
chainId: 42161, protocolChainId: "arbitrum", vm: "evm", label: "Arbitrum",
|
|
43
|
+
rpcUrl: "https://arb1.arbitrum.io/rpc", depository: EVM_DEPOSITORY,
|
|
44
|
+
assets: {
|
|
45
|
+
USDC: { address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", decimals: 6, kind: "stable" },
|
|
46
|
+
USDT: { address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", decimals: 6, kind: "stable" },
|
|
47
|
+
ETH: { address: EVM_NATIVE, decimals: 18, kind: "native" },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
robinhood: {
|
|
51
|
+
chainId: 4663, protocolChainId: "robinhood", vm: "evm", label: "Robinhood Chain",
|
|
52
|
+
rpcUrl: "https://rpc.mainnet.chain.robinhood.com", depository: EVM_DEPOSITORY,
|
|
53
|
+
assets: {
|
|
54
|
+
USDG: { address: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", decimals: 6, kind: "stable" },
|
|
55
|
+
ETH: { address: EVM_NATIVE, decimals: 18, kind: "native" },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
bsc: {
|
|
59
|
+
chainId: 56, protocolChainId: "bnb", vm: "evm", label: "BNB Chain",
|
|
60
|
+
rpcUrl: "https://bsc-dataseed.bnbchain.org", depository: EVM_DEPOSITORY,
|
|
61
|
+
assets: {
|
|
62
|
+
USDT: { address: "0x55d398326f99059fF775485246999027B3197955", decimals: 18, kind: "stable" },
|
|
63
|
+
USDC: { address: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", decimals: 18, kind: "stable" },
|
|
64
|
+
BNB: { address: EVM_NATIVE, decimals: 18, kind: "native" },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
base: {
|
|
68
|
+
chainId: 8453, protocolChainId: "base", vm: "evm", label: "Base",
|
|
69
|
+
rpcUrl: "https://mainnet.base.org", depository: EVM_DEPOSITORY,
|
|
70
|
+
assets: {
|
|
71
|
+
USDC: { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, kind: "stable" },
|
|
72
|
+
ETH: { address: EVM_NATIVE, decimals: 18, kind: "native" },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
solana: {
|
|
76
|
+
chainId: 792703809, protocolChainId: "solana", vm: "svm", label: "Solana",
|
|
77
|
+
rpcUrl: null, depository: null,
|
|
78
|
+
assets: {
|
|
79
|
+
USDC: { address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", decimals: 6, kind: "stable" },
|
|
80
|
+
SOL: { address: SOLANA_NATIVE, decimals: 9, kind: "native" },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
/** Default slippage floor for same-value pairs: expected out ≥ amount in × (1 − 3 %). */
|
|
85
|
+
export const RELAY_MAX_SLIPPAGE_BPS = 300n;
|
|
86
|
+
/**
|
|
87
|
+
* Floor on the protocol-GUARANTEED minimum (docs.relay.link: "Compare `minimumAmount`, not
|
|
88
|
+
* `expectedAmount`"): the order's minimum may be at most this far below the amount in.
|
|
89
|
+
*/
|
|
90
|
+
export const RELAY_MAX_GUARANTEED_LOSS_BPS = 600n;
|
|
91
|
+
export const RELAY_STATUS_TIMEOUT_MS = 300_000;
|
|
92
|
+
export const RELAY_STATUS_POLL_MS = 3_000;
|
|
93
|
+
function noRoute(msg, remediation) {
|
|
94
|
+
return new PerpError("NO_BRIDGE_ROUTE", msg, { remediation });
|
|
95
|
+
}
|
|
96
|
+
function refuse(msg) {
|
|
97
|
+
return new PerpError("POLICY_DENIED", `Relay quote refused: ${msg}. Nothing was signed or sent.`, {
|
|
98
|
+
exchange: "relay", remediation: "Do not retry blindly — the quote does not match what was requested. Re-run with --dry-run and compare, or bridge in the Relay app",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export function relayChain(name) {
|
|
102
|
+
const def = RELAY_CHAINS[name.toLowerCase()];
|
|
103
|
+
if (def === undefined) {
|
|
104
|
+
throw noRoute(`Relay: unsupported chain "${name}"`, `Supported chains: ${Object.keys(RELAY_CHAINS).join(", ")}`);
|
|
105
|
+
}
|
|
106
|
+
return def;
|
|
107
|
+
}
|
|
108
|
+
export function relayAsset(chain, symbol) {
|
|
109
|
+
const def = relayChain(chain);
|
|
110
|
+
const key = Object.keys(def.assets).find(k => k.toLowerCase() === symbol.toLowerCase());
|
|
111
|
+
if (key === undefined) {
|
|
112
|
+
throw noRoute(`Relay: ${symbol} is not a supported asset on ${def.label}`, `Assets on ${chain.toLowerCase()}: ${Object.keys(def.assets).join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
return def.assets[key];
|
|
115
|
+
}
|
|
116
|
+
function sameOnChain(vm, a, b) {
|
|
117
|
+
return vm === "evm" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
118
|
+
}
|
|
119
|
+
function validAddress(vm, a) {
|
|
120
|
+
if (vm === "evm")
|
|
121
|
+
return EVM_ADDRESS_RE.test(a);
|
|
122
|
+
try {
|
|
123
|
+
return bs58.decode(a).length === 32;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// ── Order id (port of @relay-protocol/settlement-sdk getOrderId, v1 order) ──
|
|
130
|
+
const ORDER_TYPES = {
|
|
131
|
+
Order: [
|
|
132
|
+
{ name: "version", type: "string" }, { name: "solverChainId", type: "string" }, { name: "solver", type: "address" },
|
|
133
|
+
{ name: "salt", type: "uint256" }, { name: "inputs", type: "Input[]" }, { name: "output", type: "Output" }, { name: "fees", type: "Fee[]" },
|
|
134
|
+
],
|
|
135
|
+
Input: [{ name: "payment", type: "InputPayment" }, { name: "refunds", type: "InputRefund[]" }],
|
|
136
|
+
InputPayment: [
|
|
137
|
+
{ name: "chainId", type: "string" }, { name: "currency", type: "bytes" }, { name: "amount", type: "uint256" }, { name: "weight", type: "uint256" },
|
|
138
|
+
],
|
|
139
|
+
InputRefund: [
|
|
140
|
+
{ name: "chainId", type: "string" }, { name: "recipient", type: "bytes" }, { name: "currency", type: "bytes" },
|
|
141
|
+
{ name: "minimumAmount", type: "uint256" }, { name: "deadline", type: "uint32" }, { name: "extraData", type: "bytes" },
|
|
142
|
+
],
|
|
143
|
+
Output: [
|
|
144
|
+
{ name: "chainId", type: "string" }, { name: "payments", type: "OutputPayment[]" }, { name: "deadline", type: "uint32" },
|
|
145
|
+
{ name: "calls", type: "bytes[]" }, { name: "extraData", type: "bytes" },
|
|
146
|
+
],
|
|
147
|
+
OutputPayment: [
|
|
148
|
+
{ name: "recipient", type: "bytes" }, { name: "currency", type: "bytes" }, { name: "minimumAmount", type: "uint256" }, { name: "expectedAmount", type: "uint256" },
|
|
149
|
+
],
|
|
150
|
+
Fee: [
|
|
151
|
+
{ name: "recipientChainId", type: "string" }, { name: "recipient", type: "bytes" }, { name: "currencyChainId", type: "string" },
|
|
152
|
+
{ name: "currency", type: "bytes" }, { name: "amount", type: "uint256" },
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
function vmOfProtocolChain(protocolChainId) {
|
|
156
|
+
const def = Object.values(RELAY_CHAINS).find(c => c.protocolChainId === protocolChainId);
|
|
157
|
+
if (def === undefined)
|
|
158
|
+
throw refuse(`the order references chain "${protocolChainId}", which is not in the pinned chain table`);
|
|
159
|
+
return def.vm;
|
|
160
|
+
}
|
|
161
|
+
/** Address → the bytes the order hash commits to: 20 bytes (EVM) / 32-byte public key (Solana). */
|
|
162
|
+
function addressBytes(address, protocolChainId) {
|
|
163
|
+
const vm = vmOfProtocolChain(protocolChainId);
|
|
164
|
+
if (!validAddress(vm, address))
|
|
165
|
+
throw refuse(`the order carries an invalid ${protocolChainId} address ${JSON.stringify(address)}`);
|
|
166
|
+
return vm === "evm" ? address.toLowerCase() : hexlify(bs58.decode(address));
|
|
167
|
+
}
|
|
168
|
+
/** EIP-712 struct hash of a Relay v1 order — must equal `protocol.v2.orderId`. */
|
|
169
|
+
export function relayOrderId(o) {
|
|
170
|
+
return TypedDataEncoder.hashStruct("Order", ORDER_TYPES, {
|
|
171
|
+
version: o.version, solverChainId: o.solverChainId, solver: o.solver, salt: BigInt(o.salt),
|
|
172
|
+
inputs: o.inputs.map(i => ({
|
|
173
|
+
payment: { chainId: i.payment.chainId, currency: addressBytes(i.payment.currency, i.payment.chainId), amount: BigInt(i.payment.amount), weight: BigInt(i.payment.weight) },
|
|
174
|
+
refunds: i.refunds.map(r => ({
|
|
175
|
+
chainId: r.chainId, recipient: addressBytes(r.recipient, r.chainId), currency: addressBytes(r.currency, r.chainId),
|
|
176
|
+
minimumAmount: BigInt(r.minimumAmount), deadline: r.deadline, extraData: hexlify(getBytes(r.extraData)),
|
|
177
|
+
})),
|
|
178
|
+
})),
|
|
179
|
+
output: {
|
|
180
|
+
chainId: o.output.chainId,
|
|
181
|
+
payments: o.output.payments.map(p => ({
|
|
182
|
+
recipient: addressBytes(p.recipient, o.output.chainId), currency: addressBytes(p.currency, o.output.chainId),
|
|
183
|
+
minimumAmount: BigInt(p.minimumAmount), expectedAmount: BigInt(p.expectedAmount),
|
|
184
|
+
})),
|
|
185
|
+
deadline: o.output.deadline, calls: o.output.calls.map(c => hexlify(getBytes(c))), extraData: hexlify(getBytes(o.output.extraData)),
|
|
186
|
+
},
|
|
187
|
+
fees: o.fees.map(f => ({
|
|
188
|
+
recipientChainId: f.recipientChainId, recipient: addressBytes(f.recipient, f.recipientChainId),
|
|
189
|
+
currencyChainId: f.currencyChainId, currency: addressBytes(f.currency, f.currencyChainId), amount: BigInt(f.amount),
|
|
190
|
+
})),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const str = (v) => (typeof v === "string" ? v : null);
|
|
194
|
+
const rec = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : null);
|
|
195
|
+
const DEPOSITORY = new Interface([
|
|
196
|
+
"function depositNative(address depositor, bytes32 id)",
|
|
197
|
+
"function depositErc20(address depositor, address token, uint256 amount, bytes32 id)",
|
|
198
|
+
]);
|
|
199
|
+
/**
|
|
200
|
+
* The quote's transactions, each checked: chain id = origin, `from` = sender, the approve targets
|
|
201
|
+
* the origin token and approves only the pinned depository for exactly the input, the deposit pays
|
|
202
|
+
* the pinned depository, commits to the verified order id, credits the sender and moves exactly
|
|
203
|
+
* the input. Anything else → POLICY_DENIED.
|
|
204
|
+
*/
|
|
205
|
+
export function verifyRelaySteps(steps, ctx) {
|
|
206
|
+
if (!Array.isArray(steps) || steps.length === 0)
|
|
207
|
+
throw refuse("the quote has no steps");
|
|
208
|
+
const depository = ctx.origin.depository;
|
|
209
|
+
if (depository === null)
|
|
210
|
+
throw new Error(`relay: ${ctx.origin.label} has no pinned depository`);
|
|
211
|
+
const native = ctx.asset.kind === "native";
|
|
212
|
+
const txs = [];
|
|
213
|
+
for (const s of steps) {
|
|
214
|
+
const step = rec(s);
|
|
215
|
+
if (step === null || step.kind !== "transaction" || !Array.isArray(step.items)) {
|
|
216
|
+
throw refuse(`unsupported step ${JSON.stringify(step?.id)} of kind ${JSON.stringify(step?.kind)} (only approve / deposit transactions are executed)`);
|
|
217
|
+
}
|
|
218
|
+
for (const it of step.items) {
|
|
219
|
+
const d = rec(rec(it)?.data);
|
|
220
|
+
const to = str(d?.to);
|
|
221
|
+
const data = str(d?.data);
|
|
222
|
+
if (d === null || to === null || data === null || !EVM_ADDRESS_RE.test(to))
|
|
223
|
+
throw refuse(`step ${String(step.id)} has no transaction data`);
|
|
224
|
+
if (d.chainId !== ctx.origin.chainId)
|
|
225
|
+
throw refuse(`step ${String(step.id)} targets chainId ${JSON.stringify(d.chainId)}, expected ${ctx.origin.chainId} (${ctx.origin.label})`);
|
|
226
|
+
const from = str(d.from);
|
|
227
|
+
if (from !== null && !sameAddress(from, ctx.sender))
|
|
228
|
+
throw refuse(`step ${String(step.id)} is sent from ${from}, not the sender ${ctx.sender}`);
|
|
229
|
+
const value = BigInt(typeof d.value === "string" || typeof d.value === "number" ? d.value : 0);
|
|
230
|
+
if (sameAddress(to, depository)) {
|
|
231
|
+
let fn;
|
|
232
|
+
let args;
|
|
233
|
+
try {
|
|
234
|
+
const parsed = DEPOSITORY.parseTransaction({ data });
|
|
235
|
+
if (parsed === null)
|
|
236
|
+
throw new Error("unknown selector");
|
|
237
|
+
fn = parsed.name;
|
|
238
|
+
args = parsed.args;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
throw refuse(`the depository call of step ${String(step.id)} is neither depositNative nor depositErc20`);
|
|
242
|
+
}
|
|
243
|
+
if (String(args[args.length - 1]).toLowerCase() !== ctx.orderId.toLowerCase())
|
|
244
|
+
throw refuse("the deposit does not commit to the verified order id");
|
|
245
|
+
if (!sameAddress(String(args[0]), ctx.sender))
|
|
246
|
+
throw refuse(`the deposit credits depositor ${String(args[0])}, not the sender ${ctx.sender}`);
|
|
247
|
+
if (native !== (fn === "depositNative"))
|
|
248
|
+
throw refuse(`the deposit function ${fn} does not match the input asset`);
|
|
249
|
+
if (native) {
|
|
250
|
+
if (value !== ctx.amountInRaw)
|
|
251
|
+
throw refuse(`the deposit sends ${value} wei, expected exactly ${ctx.amountInRaw}`);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
if (!sameAddress(String(args[1]), ctx.asset.address))
|
|
255
|
+
throw refuse(`the deposit token ${String(args[1])} is not the input token ${ctx.asset.address}`);
|
|
256
|
+
if (BigInt(args[2]) !== ctx.amountInRaw)
|
|
257
|
+
throw refuse(`the deposit amount ${String(args[2])} is not the input amount ${ctx.amountInRaw}`);
|
|
258
|
+
if (value !== 0n)
|
|
259
|
+
throw refuse("the ERC-20 deposit carries native value");
|
|
260
|
+
}
|
|
261
|
+
txs.push({ kind: "deposit", to: depository, data, value, chainId: ctx.origin.chainId });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (!native && sameAddress(to, ctx.asset.address)) {
|
|
265
|
+
let spender;
|
|
266
|
+
let amount;
|
|
267
|
+
try {
|
|
268
|
+
const parsed = ERC20.parseTransaction({ data });
|
|
269
|
+
if (parsed === null || parsed.name !== "approve")
|
|
270
|
+
throw new Error("not approve");
|
|
271
|
+
spender = String(parsed.args[0]);
|
|
272
|
+
amount = BigInt(parsed.args[1]);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
throw refuse(`step ${String(step.id)} calls the input token with something other than approve(spender, amount)`);
|
|
276
|
+
}
|
|
277
|
+
if (!sameAddress(spender, depository))
|
|
278
|
+
throw refuse(`the approval spender ${spender} is not the Relay depository ${depository}`);
|
|
279
|
+
if (amount !== ctx.amountInRaw)
|
|
280
|
+
throw refuse(`the approval amount ${amount} is not the exact input amount ${ctx.amountInRaw}`);
|
|
281
|
+
if (value !== 0n)
|
|
282
|
+
throw refuse("the approval carries native value");
|
|
283
|
+
txs.push({ kind: "approve", to: ctx.asset.address, data, value: 0n, chainId: ctx.origin.chainId });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
throw refuse(`step ${String(step.id)} sends a transaction to ${to}, which is neither the Relay depository ${depository} nor the input token`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const deposits = txs.filter(t => t.kind === "deposit").length;
|
|
290
|
+
const approves = txs.filter(t => t.kind === "approve").length;
|
|
291
|
+
if (deposits !== 1 || approves > 1 || txs[txs.length - 1].kind !== "deposit") {
|
|
292
|
+
throw refuse(`expected [approve,] deposit — got ${txs.map(t => t.kind).join(", ") || "nothing"}`);
|
|
293
|
+
}
|
|
294
|
+
return txs;
|
|
295
|
+
}
|
|
296
|
+
/** Verify a raw POST /quote response against the request; returns the typed, checked quote. */
|
|
297
|
+
export function verifyRelayQuote(raw, req, nowSec) {
|
|
298
|
+
const origin = relayChain(req.from);
|
|
299
|
+
const dest = relayChain(req.to);
|
|
300
|
+
const inAsset = relayAsset(req.from, req.fromAsset);
|
|
301
|
+
const outAsset = relayAsset(req.to, req.toAsset);
|
|
302
|
+
const amountInRaw = parseUnits(req.amount, inAsset.decimals);
|
|
303
|
+
const q = rec(raw);
|
|
304
|
+
const v2 = rec(rec(q?.protocol)?.v2);
|
|
305
|
+
const orderId = str(v2?.orderId);
|
|
306
|
+
const order = rec(v2?.orderData);
|
|
307
|
+
const requestId = str(rec(Array.isArray(q?.steps) ? q.steps[q.steps.length - 1] : null)?.requestId) ?? str(q?.requestId);
|
|
308
|
+
if (q === null || v2 === null || orderId === null || order === null)
|
|
309
|
+
throw refuse("the response carries no protocol.v2 order to verify");
|
|
310
|
+
if (requestId === null)
|
|
311
|
+
throw refuse("the response carries no requestId");
|
|
312
|
+
// 1. Order properties.
|
|
313
|
+
if (!Array.isArray(order.inputs) || order.inputs.length !== 1 || !Array.isArray(order.output?.payments) || order.output.payments.length !== 1) {
|
|
314
|
+
throw refuse("the order does not have exactly one input and one output payment");
|
|
315
|
+
}
|
|
316
|
+
const pay = order.inputs[0].payment;
|
|
317
|
+
const out = order.output.payments[0];
|
|
318
|
+
if (pay.chainId !== origin.protocolChainId)
|
|
319
|
+
throw refuse(`the order is paid on "${pay.chainId}", not ${origin.protocolChainId}`);
|
|
320
|
+
if (!sameOnChain(origin.vm, pay.currency, inAsset.address))
|
|
321
|
+
throw refuse(`the order input currency ${pay.currency} is not ${req.fromAsset} (${inAsset.address})`);
|
|
322
|
+
if (BigInt(pay.amount) !== amountInRaw)
|
|
323
|
+
throw refuse(`the order input amount ${pay.amount} is not the requested ${amountInRaw}`);
|
|
324
|
+
if (order.output.chainId !== dest.protocolChainId)
|
|
325
|
+
throw refuse(`the order pays out on "${order.output.chainId}", not ${dest.protocolChainId}`);
|
|
326
|
+
if (!sameOnChain(dest.vm, out.currency, outAsset.address))
|
|
327
|
+
throw refuse(`the order output currency ${out.currency} is not ${req.toAsset} (${outAsset.address})`);
|
|
328
|
+
if (!sameOnChain(dest.vm, out.recipient, req.recipient))
|
|
329
|
+
throw refuse(`the order pays ${out.recipient}, not the recipient ${req.recipient}`);
|
|
330
|
+
if (!Array.isArray(order.output.calls) || order.output.calls.length !== 0)
|
|
331
|
+
throw refuse("the order carries extra destination calls");
|
|
332
|
+
// Refund options. Relay docs (api_core_concepts/refunds): "Refunds are paid on the origin chain
|
|
333
|
+
// to the `refundTo` address supplied on the quote" / "Relay will always attempt to refund the user
|
|
334
|
+
// in the original currency they sent". Real /quote orders (fixtures captured 2026-10-05) list two
|
|
335
|
+
// entries: the origin chain in the origin currency to the sender, and the destination chain in
|
|
336
|
+
// the output currency to the recipient. Exactly those two shapes are accepted — any other
|
|
337
|
+
// chain, currency or recipient is refused.
|
|
338
|
+
const refunds = order.inputs[0].refunds;
|
|
339
|
+
if (!Array.isArray(refunds))
|
|
340
|
+
throw refuse("the order input carries no refunds list");
|
|
341
|
+
for (const r of refunds) {
|
|
342
|
+
if (r === null || typeof r !== "object" || typeof r.chainId !== "string" || typeof r.recipient !== "string" || typeof r.currency !== "string") {
|
|
343
|
+
throw refuse(`the order carries a malformed refund entry ${JSON.stringify(r)}`);
|
|
344
|
+
}
|
|
345
|
+
const onOrigin = r.chainId === origin.protocolChainId && sameOnChain(origin.vm, r.currency, inAsset.address) && sameOnChain(origin.vm, r.recipient, req.sender);
|
|
346
|
+
const onDestination = r.chainId === dest.protocolChainId && sameOnChain(dest.vm, r.currency, outAsset.address) && sameOnChain(dest.vm, r.recipient, req.recipient);
|
|
347
|
+
if (!onOrigin && !onDestination) {
|
|
348
|
+
throw refuse(`the order refunds ${r.currency} to ${r.recipient} on "${r.chainId}" — neither ${req.fromAsset} to the sender on ${origin.protocolChainId} nor ${req.toAsset} to the recipient on ${dest.protocolChainId}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (!(order.output.deadline > nowSec))
|
|
352
|
+
throw refuse(`the order expired (deadline ${order.output.deadline})`);
|
|
353
|
+
const minimumOutRaw = BigInt(out.minimumAmount);
|
|
354
|
+
const expectedOutRaw = BigInt(out.expectedAmount);
|
|
355
|
+
if (minimumOutRaw <= 0n || expectedOutRaw < minimumOutRaw)
|
|
356
|
+
throw refuse(`the order amounts are inconsistent (minimum ${out.minimumAmount}, expected ${out.expectedAmount})`);
|
|
357
|
+
// 2. Order id.
|
|
358
|
+
const computed = relayOrderId(order);
|
|
359
|
+
if (computed.toLowerCase() !== orderId.toLowerCase())
|
|
360
|
+
throw refuse(`the order id ${orderId} does not match the order data (computed ${computed})`);
|
|
361
|
+
// 3. Display data must agree with the pinned table (decimals drive every printed amount).
|
|
362
|
+
const details = rec(q.details);
|
|
363
|
+
const cIn = rec(details?.currencyIn);
|
|
364
|
+
const cOut = rec(details?.currencyOut);
|
|
365
|
+
const checkCurrency = (c, chain, asset, what) => {
|
|
366
|
+
const cur = rec(c?.currency);
|
|
367
|
+
if (cur === null || cur.chainId !== chain.chainId || typeof cur.address !== "string" || !sameOnChain(chain.vm, cur.address, asset.address) || cur.decimals !== asset.decimals) {
|
|
368
|
+
throw refuse(`details.${what} (${JSON.stringify(cur?.symbol)}, ${JSON.stringify(cur?.address)}, decimals ${JSON.stringify(cur?.decimals)}) does not match the pinned ${chain.label} asset ${asset.address} / ${asset.decimals} decimals`);
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
checkCurrency(cIn, origin, inAsset, "currencyIn");
|
|
372
|
+
checkCurrency(cOut, dest, outAsset, "currencyOut");
|
|
373
|
+
const fees = rec(q.fees);
|
|
374
|
+
const usd = (name) => str(rec(fees?.[name])?.amountUsd);
|
|
375
|
+
const relayerUsd = usd("relayer");
|
|
376
|
+
if (relayerUsd === null || !Number.isFinite(Number(relayerUsd)))
|
|
377
|
+
throw refuse("the quote has no fees.relayer.amountUsd");
|
|
378
|
+
// 4. Transactions (EVM origins only; a Solana-origin quote can be shown but not executed here).
|
|
379
|
+
let txs = [];
|
|
380
|
+
if (origin.vm === "evm") {
|
|
381
|
+
if (origin.depository === null)
|
|
382
|
+
throw new Error(`relay: ${origin.label} has no pinned depository`);
|
|
383
|
+
const named = str(rec(v2.paymentDetails)?.depository);
|
|
384
|
+
if (named === null || !sameAddress(named, origin.depository))
|
|
385
|
+
throw refuse(`the quote names depository ${String(named)}, not the pinned ${origin.depository}`);
|
|
386
|
+
txs = verifyRelaySteps(q.steps, { origin, asset: inAsset, amountInRaw, sender: req.sender, orderId });
|
|
387
|
+
}
|
|
388
|
+
const time = details?.timeEstimate;
|
|
389
|
+
return {
|
|
390
|
+
provider: "relay", requestId, orderId,
|
|
391
|
+
from: req.from.toLowerCase(), to: req.to.toLowerCase(), fromAsset: req.fromAsset.toUpperCase(), toAsset: req.toAsset.toUpperCase(),
|
|
392
|
+
sender: req.sender, recipient: req.recipient,
|
|
393
|
+
amountIn: req.amount, amountInRaw: amountInRaw.toString(),
|
|
394
|
+
expectedOut: formatUnits(expectedOutRaw, outAsset.decimals), expectedOutRaw: expectedOutRaw.toString(),
|
|
395
|
+
minimumOut: formatUnits(minimumOutRaw, outAsset.decimals), minimumOutRaw: minimumOutRaw.toString(),
|
|
396
|
+
amountInUsd: str(cIn?.amountUsd), expectedOutUsd: str(cOut?.amountUsd),
|
|
397
|
+
fees: { originGasUsd: usd("gas"), relayerUsd, relayerGasUsd: usd("relayerGas"), relayerServiceUsd: usd("relayerService"), appUsd: usd("app") },
|
|
398
|
+
totalImpactPercent: str(rec(details?.totalImpact)?.percent),
|
|
399
|
+
timeEstimateSec: typeof time === "number" && Number.isFinite(time) ? time : null,
|
|
400
|
+
deadline: order.output.deadline,
|
|
401
|
+
txs,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/** POST /quote (a read) + local verification. */
|
|
405
|
+
export async function quoteRelaySwap(req) {
|
|
406
|
+
const origin = relayChain(req.from);
|
|
407
|
+
const dest = relayChain(req.to);
|
|
408
|
+
const inAsset = relayAsset(req.from, req.fromAsset);
|
|
409
|
+
const outAsset = relayAsset(req.to, req.toAsset);
|
|
410
|
+
if (origin.chainId === dest.chainId)
|
|
411
|
+
throw noRoute("Relay: source and destination chain are the same", "Pick two different chains");
|
|
412
|
+
if (!/^\d+(\.\d+)?$/.test(req.amount) || !(Number(req.amount) > 0))
|
|
413
|
+
throw new PerpError("INVALID_PARAMS", `Bridge amount must be a positive decimal (got "${req.amount}")`);
|
|
414
|
+
let amountInRaw;
|
|
415
|
+
try {
|
|
416
|
+
amountInRaw = parseUnits(req.amount, inAsset.decimals);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
throw new PerpError("INVALID_PARAMS", `Bridge amount ${req.amount} has more than ${inAsset.decimals} decimals (${req.fromAsset} on ${origin.label})`);
|
|
420
|
+
}
|
|
421
|
+
if (!validAddress(origin.vm, req.sender))
|
|
422
|
+
throw new PerpError("INVALID_PARAMS", `Sender ${JSON.stringify(req.sender)} is not a ${origin.label} address`);
|
|
423
|
+
if (!validAddress(dest.vm, req.recipient))
|
|
424
|
+
throw new PerpError("INVALID_PARAMS", `Recipient ${JSON.stringify(req.recipient)} is not a ${dest.label} address`);
|
|
425
|
+
const res = await fetch(`${RELAY_API}/quote`, {
|
|
426
|
+
method: "POST",
|
|
427
|
+
headers: { "Content-Type": "application/json" },
|
|
428
|
+
body: JSON.stringify({
|
|
429
|
+
user: req.sender, recipient: req.recipient,
|
|
430
|
+
originChainId: origin.chainId, destinationChainId: dest.chainId,
|
|
431
|
+
originCurrency: inAsset.address, destinationCurrency: outAsset.address,
|
|
432
|
+
amount: amountInRaw.toString(), tradeType: "EXACT_INPUT", includeProtocolData: true,
|
|
433
|
+
}),
|
|
434
|
+
signal: AbortSignal.timeout(30_000),
|
|
435
|
+
});
|
|
436
|
+
const text = await res.text();
|
|
437
|
+
if (!res.ok) {
|
|
438
|
+
throw new PerpError("NO_BRIDGE_ROUTE", `Relay quote failed (HTTP ${res.status}): ${text.slice(0, 300)}`, {
|
|
439
|
+
exchange: "relay", remediation: "Check the amount (Relay refuses amounts below its fees) and the route; nothing was sent",
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
let raw;
|
|
443
|
+
try {
|
|
444
|
+
raw = JSON.parse(text);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
throw new PerpError("EXCHANGE_ERROR", `Relay quote returned non-JSON: ${text.slice(0, 200)}`, { exchange: "relay" });
|
|
448
|
+
}
|
|
449
|
+
return verifyRelayQuote(raw, req, Math.floor(Date.now() / 1000));
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Output protection:
|
|
453
|
+
* --min-out given → the order's protocol-enforced `minimumAmount` must be ≥ it (Relay docs:
|
|
454
|
+
* "Compare minimumAmount, not expectedAmount");
|
|
455
|
+
* no --min-out, same-value pair (stable → stable, or the same native asset) → the expected
|
|
456
|
+
* output must be ≥ amount in × (1 − 3 %);
|
|
457
|
+
* no --min-out, any other pair (stable → native, …) → refused: an explicit --min-out is required.
|
|
458
|
+
*/
|
|
459
|
+
export function relayMinOutVerdict(q, minOut) {
|
|
460
|
+
const inAsset = relayAsset(q.from, q.fromAsset);
|
|
461
|
+
const outAsset = relayAsset(q.to, q.toAsset);
|
|
462
|
+
if (minOut !== null) {
|
|
463
|
+
if (!/^\d+(\.\d+)?$/.test(minOut) || !(Number(minOut) > 0))
|
|
464
|
+
throw new PerpError("INVALID_PARAMS", `--min-out must be a positive decimal in ${q.toAsset} (got "${minOut}")`);
|
|
465
|
+
let floorRaw;
|
|
466
|
+
try {
|
|
467
|
+
floorRaw = parseUnits(minOut, outAsset.decimals);
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
throw new PerpError("INVALID_PARAMS", `--min-out ${minOut} has more than ${outAsset.decimals} decimals (${q.toAsset})`);
|
|
471
|
+
}
|
|
472
|
+
const ok = BigInt(q.minimumOutRaw) >= floorRaw;
|
|
473
|
+
return {
|
|
474
|
+
ok, rule: "explicit", floor: minOut,
|
|
475
|
+
reason: ok
|
|
476
|
+
? `guaranteed minimum ${q.minimumOut} ${q.toAsset} ≥ --min-out ${minOut}`
|
|
477
|
+
: `the order guarantees only ${q.minimumOut} ${q.toAsset} (expected ${q.expectedOut}), below --min-out ${minOut}`,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const sameValue = (inAsset.kind === "stable" && outAsset.kind === "stable") || (inAsset.kind === "native" && outAsset.kind === "native" && q.fromAsset === q.toAsset);
|
|
481
|
+
if (!sameValue) {
|
|
482
|
+
return {
|
|
483
|
+
ok: false, rule: "required", floor: null,
|
|
484
|
+
reason: `${q.fromAsset} → ${q.toAsset} has no fixed rate: pass --min-out <amount in ${q.toAsset}> (this quote: expected ${q.expectedOut}, guaranteed minimum ${q.minimumOut})`,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
// amount in, scaled to the output asset's decimals, × (1 − 3 %).
|
|
488
|
+
const scaled = outAsset.decimals >= inAsset.decimals
|
|
489
|
+
? BigInt(q.amountInRaw) * 10n ** BigInt(outAsset.decimals - inAsset.decimals)
|
|
490
|
+
: BigInt(q.amountInRaw) / 10n ** BigInt(inAsset.decimals - outAsset.decimals);
|
|
491
|
+
const floorRaw = (scaled * (10000n - RELAY_MAX_SLIPPAGE_BPS)) / 10000n;
|
|
492
|
+
const floor = formatUnits(floorRaw, outAsset.decimals);
|
|
493
|
+
const minFloorRaw = (scaled * (10000n - RELAY_MAX_GUARANTEED_LOSS_BPS)) / 10000n;
|
|
494
|
+
const minFloor = formatUnits(minFloorRaw, outAsset.decimals);
|
|
495
|
+
const expectedOk = BigInt(q.expectedOutRaw) >= floorRaw;
|
|
496
|
+
const guaranteedOk = BigInt(q.minimumOutRaw) >= minFloorRaw;
|
|
497
|
+
const ok = expectedOk && guaranteedOk;
|
|
498
|
+
return {
|
|
499
|
+
ok, rule: "default", floor,
|
|
500
|
+
reason: ok
|
|
501
|
+
? `expected ${q.expectedOut} ${q.toAsset} ≥ ${floor} (amount in − 3 %) and guaranteed minimum ${q.minimumOut} ≥ ${minFloor} (amount in − 6 %)`
|
|
502
|
+
: !expectedOk
|
|
503
|
+
? `expected ${q.expectedOut} ${q.toAsset} is below ${floor} (amount in − 3 %) — fees are too large for this amount; pass --min-out to accept a lower output`
|
|
504
|
+
: `guaranteed minimum ${q.minimumOut} ${q.toAsset} is below ${minFloor} (amount in − 6 %) — the order could settle far below the estimate; pass --min-out to accept a lower output`,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
/** Throws when the quote does not satisfy the output protection (nothing was sent). */
|
|
508
|
+
export function assertRelayMinOut(q, minOut) {
|
|
509
|
+
const v = relayMinOutVerdict(q, minOut);
|
|
510
|
+
if (v.ok)
|
|
511
|
+
return v;
|
|
512
|
+
throw new PerpError(v.rule === "required" ? "INVALID_PARAMS" : "RISK_VIOLATION", `Relay bridge refused: ${v.reason}. Nothing was sent.`, {
|
|
513
|
+
exchange: "relay", remediation: "Re-run with --dry-run to see the quote, then pass --min-out <amount> you accept (in the destination asset)",
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
const DEFAULT_TIMING = {
|
|
517
|
+
timeoutMs: RELAY_STATUS_TIMEOUT_MS, pollMs: RELAY_STATUS_POLL_MS,
|
|
518
|
+
sleep: ms => new Promise(r => setTimeout(r, ms)), now: () => Date.now(),
|
|
519
|
+
};
|
|
520
|
+
/**
|
|
521
|
+
* Poll GET /intents/status/v3 until the intent is terminal. success → destination tx hashes;
|
|
522
|
+
* failure / refund → ACTION_REJECTED (definite: Relay did not fill; a refund returns the input);
|
|
523
|
+
* anything else until the deadline → ORDER_PENDING with the request id and the deposit tx hash.
|
|
524
|
+
*/
|
|
525
|
+
export async function awaitRelayFill(requestId, depositTxHash, timing) {
|
|
526
|
+
const deadline = timing.now() + timing.timeoutMs;
|
|
527
|
+
let last = "no status yet";
|
|
528
|
+
for (;;) {
|
|
529
|
+
try {
|
|
530
|
+
const res = await fetch(`${RELAY_API}/intents/status/v3?requestId=${encodeURIComponent(requestId)}`, { signal: AbortSignal.timeout(15_000) });
|
|
531
|
+
const body = rec(JSON.parse(await res.text()));
|
|
532
|
+
const status = str(body?.status);
|
|
533
|
+
if (res.ok && status !== null) {
|
|
534
|
+
if (status === "success")
|
|
535
|
+
return Array.isArray(body?.txHashes) ? body.txHashes.map(String) : [];
|
|
536
|
+
if (status === "refund") {
|
|
537
|
+
throw new PerpError("ACTION_REJECTED", `Relay did not fill request ${requestId} (status refund${typeof body?.details === "string" ? `: ${body.details}` : ""}) — deposit ${depositTxHash} was refunded to the sender on the origin chain`, {
|
|
538
|
+
exchange: "relay", requestId, txHash: depositTxHash, relayStatus: status,
|
|
539
|
+
remediation: `Check the request on https://relay.link/transaction/${requestId} before bridging again`,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (status === "failure") {
|
|
543
|
+
// The deposit is still held by the depository until the refund lands: not a known end state.
|
|
544
|
+
throw new PerpError("ORDER_PENDING", `Relay did not fill request ${requestId} (status failure${typeof body?.details === "string" ? `: ${body.details}` : ""}) — deposit ${depositTxHash} is held by the Relay depository pending a refund`, {
|
|
545
|
+
exchange: "relay", requestId, txHash: depositTxHash, relayStatus: status,
|
|
546
|
+
remediation: `Do NOT bridge again until the refund is visible: https://relay.link/transaction/${requestId}`,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
last = `status ${status}`;
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
last = `HTTP ${res.status}`;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
catch (e) {
|
|
556
|
+
if (e instanceof PerpError)
|
|
557
|
+
throw e;
|
|
558
|
+
last = e instanceof Error ? e.message : String(e);
|
|
559
|
+
}
|
|
560
|
+
if (timing.now() + timing.pollMs > deadline)
|
|
561
|
+
break;
|
|
562
|
+
await timing.sleep(timing.pollMs);
|
|
563
|
+
}
|
|
564
|
+
throw new PerpError("ORDER_PENDING", `Relay request ${requestId}: the deposit ${depositTxHash} is confirmed on the origin chain but the fill is not confirmed after ${timing.timeoutMs / 1000}s (${last})`, {
|
|
565
|
+
exchange: "relay", requestId, txHash: depositTxHash,
|
|
566
|
+
remediation: `Do NOT bridge again — the request may still fill or refund. Track it: https://relay.link/transaction/${requestId} (GET ${RELAY_API}/intents/status/v3?requestId=${requestId})`,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Execute a verified quote: read-only origin checks (chain id, on-chain decimals, balance), the
|
|
571
|
+
* approve (when the quote has one) and the deposit, then wait for the fill.
|
|
572
|
+
*/
|
|
573
|
+
export async function executeRelaySwap(network, q, sign, timing = DEFAULT_TIMING) {
|
|
574
|
+
if (network !== "mainnet") {
|
|
575
|
+
throw noRoute(`Relay bridging has no ${network} route — the chains, depository and tokens are mainnet deployments`, "Run without -n testnet");
|
|
576
|
+
}
|
|
577
|
+
const origin = relayChain(q.from);
|
|
578
|
+
const asset = relayAsset(q.from, q.fromAsset);
|
|
579
|
+
if (origin.vm !== "evm" || origin.rpcUrl === null) {
|
|
580
|
+
throw noRoute(`Relay: bridging FROM ${origin.label} is not implemented (only EVM-origin deposits are verified and signed)`, "Bridge from an EVM chain, or use --provider cctp / debridge for a Solana-origin USDC bridge");
|
|
581
|
+
}
|
|
582
|
+
if (q.txs.length === 0)
|
|
583
|
+
throw new Error("relay: verified quote carries no transactions");
|
|
584
|
+
if (!(q.deadline > Math.floor(timing.now() / 1000)))
|
|
585
|
+
throw refuse(`the quote expired (deadline ${q.deadline}) — request a new one`);
|
|
586
|
+
const rpc = evmProvider(origin.rpcUrl, origin.chainId);
|
|
587
|
+
await assertRpcChainId(rpc, origin.rpcUrl, origin.chainId, "relay");
|
|
588
|
+
const amountInRaw = BigInt(q.amountInRaw);
|
|
589
|
+
if (asset.kind === "native") {
|
|
590
|
+
const bal = await rpc.getBalance(q.sender);
|
|
591
|
+
if (bal <= amountInRaw) {
|
|
592
|
+
throw new PerpError("INSUFFICIENT_BALANCE", `Wallet ${q.sender} holds ${formatUnits(bal, asset.decimals)} ${q.fromAsset} on ${origin.label}; bridging ${q.amountIn} also needs gas`, { exchange: "relay" });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
const onChain = await erc20Decimals(rpc, asset.address);
|
|
597
|
+
if (onChain !== asset.decimals) {
|
|
598
|
+
throw new PerpError("EXCHANGE_ERROR", `relay: ${q.fromAsset} on ${origin.label} reports ${onChain} decimals on-chain, the pinned table says ${asset.decimals} — nothing was sent`, { exchange: "relay" });
|
|
599
|
+
}
|
|
600
|
+
const [bal, gas] = await Promise.all([erc20Balance(rpc, asset.address, q.sender), rpc.getBalance(q.sender)]);
|
|
601
|
+
if (bal < amountInRaw) {
|
|
602
|
+
throw new PerpError("INSUFFICIENT_BALANCE", `Wallet ${q.sender} holds ${formatUnits(bal, asset.decimals)} ${q.fromAsset} on ${origin.label}; the bridge needs ${q.amountIn}`, { exchange: "relay" });
|
|
603
|
+
}
|
|
604
|
+
if (gas === 0n) {
|
|
605
|
+
throw new PerpError("INSUFFICIENT_BALANCE", `Wallet ${q.sender} has no native gas token on ${origin.label}`, {
|
|
606
|
+
exchange: "relay", remediation: `Fund ${q.sender} with gas on ${origin.label} first (e.g. bridge native gas TO ${q.from} from a chain that has it)`,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
let approveTxHash = null;
|
|
611
|
+
let depositTxHash = "";
|
|
612
|
+
for (const tx of q.txs) {
|
|
613
|
+
if (tx.chainId !== origin.chainId)
|
|
614
|
+
throw new Error(`relay: verified tx chainId ${tx.chainId} != origin ${origin.chainId}`);
|
|
615
|
+
const { txHash } = await sendMasterTx(rpc, {
|
|
616
|
+
venue: "relay", chainLabel: origin.label, chainId: origin.chainId, from: q.sender, to: tx.to, data: tx.data, value: tx.value,
|
|
617
|
+
label: tx.kind === "approve" ? `${q.fromAsset} approve (Relay depository)` : `Relay deposit (${q.amountIn} ${q.fromAsset})`,
|
|
618
|
+
}, sign);
|
|
619
|
+
if (tx.kind === "approve")
|
|
620
|
+
approveTxHash = txHash;
|
|
621
|
+
else
|
|
622
|
+
depositTxHash = txHash;
|
|
623
|
+
}
|
|
624
|
+
const destinationTxHashes = await awaitRelayFill(q.requestId, depositTxHash, timing);
|
|
625
|
+
return {
|
|
626
|
+
provider: "relay", requestId: q.requestId, status: "success", approveTxHash, depositTxHash, destinationTxHashes,
|
|
627
|
+
from: q.from, to: q.to, fromAsset: q.fromAsset, toAsset: q.toAsset,
|
|
628
|
+
amountIn: q.amountIn, expectedOut: q.expectedOut, minimumOut: q.minimumOut, recipient: q.recipient,
|
|
629
|
+
};
|
|
630
|
+
}
|
package/dist/risk.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
2
|
import { resolve } from "path";
|
|
3
|
+
// Loose defaults by design (b8d0c56 "relaxed risk defaults") — a backstop, not
|
|
4
|
+
// a tight policy. Tighten per-account with `perp risk set ...` (persisted to
|
|
5
|
+
// ~/.perp/risk.json). enforceOrderRisk() applies these to manual orders;
|
|
6
|
+
// cross-chain-margin applies them to auto-trading position sizing.
|
|
3
7
|
const DEFAULT_LIMITS = {
|
|
4
8
|
maxDrawdownUsd: 100000,
|
|
5
9
|
maxPositionUsd: 100000,
|
|
@@ -53,7 +57,7 @@ export function calcLiquidationDistance(markPrice, liquidationPrice, side) {
|
|
|
53
57
|
export function getLiquidationDistances(positions, limits) {
|
|
54
58
|
const lim = limits ?? loadRiskLimits();
|
|
55
59
|
return positions
|
|
56
|
-
.filter(({ position: p }) => p.liquidationPrice !==
|
|
60
|
+
.filter(({ position: p }) => p.liquidationPrice !== null && Number(p.liquidationPrice) > 0)
|
|
57
61
|
.map(({ exchange, position: p }) => {
|
|
58
62
|
const markPrice = Number(p.markPrice);
|
|
59
63
|
const liquidationPrice = Number(p.liquidationPrice);
|