perp-cli 0.12.15 → 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 +278 -31
- 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 +74 -84
- 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 +157 -682
- package/dist/jobs.d.ts +84 -6
- package/dist/jobs.js +239 -41
- package/dist/landing.d.ts +14 -0
- package/dist/landing.js +36 -0
- 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 +338 -134
- package/skills/perp-cli/references/agent-operations.md +181 -92
- package/skills/perp-cli/references/commands.md +475 -132
- package/skills/perp-cli/references/strategies.md +44 -38
- package/skills/perp-cli/scripts/arb-monitor.sh +22 -17
- package/skills/perp-cli/scripts/funding-analysis.sh +20 -10
- 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 +33 -16
- 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
package/dist/bridge-engine.js
CHANGED
|
@@ -9,6 +9,182 @@
|
|
|
9
9
|
* Handles USDC bridging between Solana ↔ Arbitrum ↔ Base ↔ HyperCore
|
|
10
10
|
* for cross-exchange rebalancing in funding arb.
|
|
11
11
|
*/
|
|
12
|
+
import { PerpError, isConnectFailure, isTransportFailure, orderOutcomeUnknown, parsePositiveDecimal, sendFailure } from "./errors.js";
|
|
13
|
+
import { loadSettings } from "./settings.js";
|
|
14
|
+
import { getVenue, networkConfig } from "./exchanges/venue.js";
|
|
15
|
+
import "./exchanges/venues/index.js";
|
|
16
|
+
import { HL_USER_SIGNED } from "./exchanges/venues/hyperliquid.js";
|
|
17
|
+
import { ERC20, assertRpcChainId, erc20Allowance, evmProvider, sendMasterTx } from "./evm-master-tx.js";
|
|
18
|
+
import { isBridgeEvmSigner, isSolanaSigner, pkEvmSigner, pkSolanaSigner, signTypedDataChecked, signVersionedSolanaTx, } from "./bridge-signers.js";
|
|
19
|
+
/**
|
|
20
|
+
* Canonical USDC decimal string ("5", "5.25") of `amount`. INVALID_PARAMS for anything that is
|
|
21
|
+
* not a positive plain decimal with at most 6 decimals (USDC has 6).
|
|
22
|
+
*/
|
|
23
|
+
export function parseUsdcAmount(amount, what = "USDC amount") {
|
|
24
|
+
const raw = typeof amount === "number" ? (Number.isFinite(amount) ? String(amount) : "NaN") : amount;
|
|
25
|
+
const s = parsePositiveDecimal(raw, what);
|
|
26
|
+
const frac = s.includes(".") ? s.slice(s.indexOf(".") + 1) : "";
|
|
27
|
+
if (frac.length > 6) {
|
|
28
|
+
throw new PerpError("INVALID_PARAMS", `${what} ${s} has more than 6 decimals (USDC has 6) — nothing was sent`, {
|
|
29
|
+
remediation: "Pass the amount with at most 6 decimals, e.g. 10.123456",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return formatUnitsPlain(usdcUnitsOf(s), 6);
|
|
33
|
+
}
|
|
34
|
+
/** Exact USDC base units (6 decimals) of a validated decimal string. */
|
|
35
|
+
function usdcUnitsOf(s) {
|
|
36
|
+
const [whole, frac = ""] = s.split(".");
|
|
37
|
+
return BigInt(whole === "" ? "0" : whole) * 1000000n + BigInt((frac + "000000").slice(0, 6));
|
|
38
|
+
}
|
|
39
|
+
/** Exact USDC base units of `amount` (validated by parseUsdcAmount). */
|
|
40
|
+
export function usdcUnits(amount, what) {
|
|
41
|
+
return usdcUnitsOf(parseUsdcAmount(amount, what));
|
|
42
|
+
}
|
|
43
|
+
/** Display-only number of `amount` (never used to build a signed amount). */
|
|
44
|
+
function usdcDisplay(amount) {
|
|
45
|
+
return Number(parseUsdcAmount(amount));
|
|
46
|
+
}
|
|
47
|
+
function evmSignerOf(k) {
|
|
48
|
+
if (typeof k === "string") {
|
|
49
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(k)) {
|
|
50
|
+
throw new PerpError("NO_SIGNER_AVAILABLE", "This bridge leg is signed on an EVM chain, and no EVM master signer was supplied", {
|
|
51
|
+
remediation: "Select the master wallet (perp wallet use <name> or --ows <name>) and run perp funds bridge send / exchange",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return pkEvmSigner(k);
|
|
55
|
+
}
|
|
56
|
+
if (isBridgeEvmSigner(k))
|
|
57
|
+
return k;
|
|
58
|
+
throw new PerpError("NO_SIGNER_AVAILABLE", "This bridge leg is signed on an EVM chain, but a Solana signer was supplied", {
|
|
59
|
+
remediation: "Pass the master wallet's EVM signer for EVM source chains",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async function solanaSignerOf(k) {
|
|
63
|
+
if (typeof k === "string") {
|
|
64
|
+
if (k.startsWith("ows:")) {
|
|
65
|
+
throw new PerpError("NO_SIGNER_AVAILABLE", "This bridge leg is signed on Solana, and an OWS wallet reference (not a signer) was supplied", {
|
|
66
|
+
remediation: "Select the master wallet (perp wallet use <name> or --ows <name>) and run perp funds bridge send / exchange",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return pkSolanaSigner(k);
|
|
70
|
+
}
|
|
71
|
+
if (isSolanaSigner(k))
|
|
72
|
+
return k;
|
|
73
|
+
throw new PerpError("NO_SIGNER_AVAILABLE", "This bridge leg is signed on Solana, but an EVM signer was supplied", {
|
|
74
|
+
remediation: "Pass the master wallet's Solana signer for Solana source chains",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** The signer's address must be the address the quote / order was built for. */
|
|
78
|
+
function assertSignerIs(chain, signerAddress, expected) {
|
|
79
|
+
const same = chain === "solana" ? signerAddress === expected : signerAddress.toLowerCase() === expected.toLowerCase();
|
|
80
|
+
if (same)
|
|
81
|
+
return;
|
|
82
|
+
throw new PerpError("INVALID_PARAMS", `The bridge order was built for sender ${expected}, not the signing wallet's ${chain} address ${signerAddress} — nothing was sent`, {
|
|
83
|
+
remediation: "Omit --sender (the master wallet's own address is used)",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Chain-checked RPC for an EVM source chain (a wrong endpoint never receives a signed tx). */
|
|
87
|
+
async function bridgeEvmRpc(chain) {
|
|
88
|
+
const chainId = chain === "solana" ? undefined : CHAIN_IDS[chain];
|
|
89
|
+
if (chainId === undefined) {
|
|
90
|
+
throw new PerpError("NO_BRIDGE_ROUTE", `No EVM chain id configured for "${chain}" — nothing was sent`, {
|
|
91
|
+
remediation: "List supported chains with: perp funds bridge chains",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const url = requireRpcUrl(chain);
|
|
95
|
+
const rpc = evmProvider(url, chainId);
|
|
96
|
+
await assertRpcChainId(rpc, url, chainId, "bridge");
|
|
97
|
+
return { rpc, chainId };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* One master-wallet EVM transaction through sendMasterTx: simulate → gas check → sign → verify
|
|
101
|
+
* sender → broadcast → receipt. An unreadable outcome after the broadcast is ORDER_PENDING.
|
|
102
|
+
*/
|
|
103
|
+
async function sendBridgeEvmTx(rpc, chainId, chain, signer, provider, label, to, data, value = 0n) {
|
|
104
|
+
const { txHash } = await sendMasterTx(rpc, {
|
|
105
|
+
venue: provider, chainLabel: chain, chainId, from: signer.getAddress(), to, data, value, label,
|
|
106
|
+
}, tx => signer.signTransaction(tx));
|
|
107
|
+
return txHash;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Approve the pinned `spender` for EXACTLY `amount` USDC when the current allowance is below it.
|
|
111
|
+
* Never an unlimited approval: a compromised / mis-targeted spender can take at most this transfer.
|
|
112
|
+
*/
|
|
113
|
+
async function ensureUsdcAllowance(rpc, chainId, chain, signer, provider, spender, amount) {
|
|
114
|
+
const usdc = USDC_ADDRESSES[chain];
|
|
115
|
+
if (!usdc)
|
|
116
|
+
throw new PerpError("NO_BRIDGE_ROUTE", `No USDC address for ${chain} — nothing was sent`);
|
|
117
|
+
const allowance = await erc20Allowance(rpc, usdc, signer.getAddress(), spender);
|
|
118
|
+
if (allowance >= amount)
|
|
119
|
+
return;
|
|
120
|
+
await sendBridgeEvmTx(rpc, chainId, chain, signer, provider, "USDC approve", usdc, usdcApproveData(spender, amount));
|
|
121
|
+
}
|
|
122
|
+
/** Calldata of the exact USDC approval ensureUsdcAllowance sends (exported for tests). */
|
|
123
|
+
export function usdcApproveData(spender, amount) {
|
|
124
|
+
return ERC20.encodeFunctionData("approve", [spender, amount]);
|
|
125
|
+
}
|
|
126
|
+
function solanaOutcomeUnknown(signature, what, e) {
|
|
127
|
+
const pending = orderOutcomeUnknown("bridge", `${what} transaction ${signature}`, e, `Do NOT resend — the transaction was broadcast and may still confirm. Check signature ${signature} on a Solana explorer, then the balances, before doing anything else`);
|
|
128
|
+
return new PerpError("ORDER_PENDING", pending.message, { exchange: "bridge", signature, txHash: signature, remediation: pending.structured.remediation });
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Broadcast a fully signed Solana transaction and wait for confirmation. The signature is known
|
|
132
|
+
* before the send: a transport failure after the request left, or any confirmation failure, is
|
|
133
|
+
* ORDER_PENDING carrying it (never "failed", never resubmitted).
|
|
134
|
+
*/
|
|
135
|
+
async function sendBridgeSolanaTx(connection, tx, what) {
|
|
136
|
+
const bs58 = (await import("bs58")).default;
|
|
137
|
+
const signature = bs58.encode(tx.signatures[0]);
|
|
138
|
+
try {
|
|
139
|
+
await connection.sendRawTransaction(tx.serialize(), { skipPreflight: false });
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
if (isConnectFailure(e)) {
|
|
143
|
+
throw new PerpError("EXCHANGE_UNREACHABLE", `${what}: could not connect to the Solana RPC — nothing was sent`, { exchange: "bridge" });
|
|
144
|
+
}
|
|
145
|
+
if (isTransportFailure(e))
|
|
146
|
+
throw solanaOutcomeUnknown(signature, what, e);
|
|
147
|
+
throw new PerpError("ACTION_REJECTED", `${what}: the Solana RPC refused the transaction (${e instanceof Error ? e.message : String(e)}) — nothing was sent`, { exchange: "bridge" });
|
|
148
|
+
}
|
|
149
|
+
let confirmation;
|
|
150
|
+
try {
|
|
151
|
+
confirmation = await connection.confirmTransaction(signature, "confirmed");
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
throw solanaOutcomeUnknown(signature, what, e);
|
|
155
|
+
}
|
|
156
|
+
if (confirmation.value.err !== null && confirmation.value.err !== undefined) {
|
|
157
|
+
throw new PerpError("ACTION_REJECTED", `${what}: transaction ${signature} failed on-chain (${JSON.stringify(confirmation.value.err)}) — nothing was moved`, {
|
|
158
|
+
exchange: "bridge", signature, txHash: signature,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return signature;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Every route in this module is mainnet-only: the RPCs, USDC / CCTP contract
|
|
165
|
+
* addresses, Circle Iris, Relay and deBridge endpoints below are mainnet
|
|
166
|
+
* deployments with no testnet equivalent wired in. A testnet run must never
|
|
167
|
+
* sign or send through them (it would move real mainnet funds), so it throws
|
|
168
|
+
* NO_BRIDGE_ROUTE before any key is used.
|
|
169
|
+
*/
|
|
170
|
+
export function assertBridgeNetwork(network) {
|
|
171
|
+
if (network === "mainnet")
|
|
172
|
+
return;
|
|
173
|
+
throw new PerpError("NO_BRIDGE_ROUTE", `Cross-chain bridging has no ${network} route — every bridge provider here (CCTP, Relay, deBridge) is wired to mainnet chains only`, {
|
|
174
|
+
remediation: "Run without -n testnet to bridge on mainnet, or fund testnet accounts from each venue's testnet faucet",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A bridge moves funds between two different chains. Same source and destination is a caller
|
|
179
|
+
* error, refused before any quote API is called or any key is used.
|
|
180
|
+
*/
|
|
181
|
+
export function assertDistinctBridgeChains(srcChain, dstChain) {
|
|
182
|
+
if (srcChain !== dstChain)
|
|
183
|
+
return;
|
|
184
|
+
throw new PerpError("INVALID_PARAMS", `Bridge source and destination chain are the same (${srcChain}) — nothing to bridge`, {
|
|
185
|
+
remediation: "Pick two different chains with --from / --to (list them with: perp funds bridge chains)",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
12
188
|
// ── Chain constants ──
|
|
13
189
|
export const CHAIN_IDS = {
|
|
14
190
|
solana: 7565164,
|
|
@@ -67,7 +243,7 @@ const CCTP_FORWARD_HOOK_DATA = "0x636374702d666f72776172640000000000000000000000
|
|
|
67
243
|
* @param amountUsdc - Transfer amount in USDC (needed to calculate bps-based fee).
|
|
68
244
|
* Returns maxFee in USDC subunits (6 decimals).
|
|
69
245
|
*/
|
|
70
|
-
async function getCctpRelayFee(srcDomain, dstDomain, finalityThreshold = 2000, useForwarding = false, amountUsdc = 100) {
|
|
246
|
+
async function getCctpRelayFee(srcDomain, dstDomain, finalityThreshold = 2000, useForwarding = false, amountUsdc = "100") {
|
|
71
247
|
// SSOT rule #2: Circle's CCTP fee schedule is the SSOT for bridge cost.
|
|
72
248
|
// Previously a fetch failure returned a hardcoded $0.50 / $0.25 fallback —
|
|
73
249
|
// that silently set maxFee at a guess, which can either burn user dollars
|
|
@@ -94,7 +270,7 @@ async function getCctpRelayFee(srcDomain, dstDomain, finalityThreshold = 2000, u
|
|
|
94
270
|
throw new Error(`CCTP fee API ${srcDomain}→${dstDomain} returned no fee schedule`);
|
|
95
271
|
}
|
|
96
272
|
// Protocol fee: minimumFee is in basis points → actual fee = amount × bps / 10000
|
|
97
|
-
const amountSubunits =
|
|
273
|
+
const amountSubunits = usdcUnits(amountUsdc);
|
|
98
274
|
const bpsRounded = BigInt(Math.round(schedule.minimumFee * 100));
|
|
99
275
|
const protocolFee = (amountSubunits * bpsRounded) / 1000000n;
|
|
100
276
|
// Add 20% buffer per Circle docs recommendation
|
|
@@ -272,14 +448,147 @@ async function getHyperCoreCctpFees(srcDomain, amount) {
|
|
|
272
448
|
}
|
|
273
449
|
// ── deBridge DLN API ──
|
|
274
450
|
const DLN_API = "https://dln.debridge.finance/v1.0/dln/order";
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
451
|
+
// deBridge DLN pinned contracts. Source: https://docs.debridge.com/dln-details/overview/deployed-contracts
|
|
452
|
+
// (read 2026-10-13): DlnSource is 0xeF4fB24aD0916217251F553c0596F8Edc630EB66 on Arbitrum and Base;
|
|
453
|
+
// Solana DlnSource program src5qyZHqTqecJV4aY6Cb6zDZLMDzrDKKezs22MPHr4. The create-tx transaction
|
|
454
|
+
// must target this contract; it is also the only spender ever approved (exact amount).
|
|
455
|
+
const DLN_SOURCE_EVM = {
|
|
456
|
+
arbitrum: "0xeF4fB24aD0916217251F553c0596F8Edc630EB66",
|
|
457
|
+
base: "0xeF4fB24aD0916217251F553c0596F8Edc630EB66",
|
|
458
|
+
};
|
|
459
|
+
// DlnSource ABI (github.com/debridge-finance/dln-contracts, contracts/DLN/DlnSource.sol and
|
|
460
|
+
// contracts/libraries/DlnOrderLib.sol, read 2026-10-13). The contract pulls exactly
|
|
461
|
+
// OrderCreation.giveAmount of giveTokenAddress from msg.sender (_pullTokens, before the protocol
|
|
462
|
+
// and affiliate fees are deducted from it), requires msg.value == globalFixedNativeFee for an
|
|
463
|
+
// ERC-20 give token, and decodes _affiliateFee as 52 bytes: beneficiary (20) ‖ amount (32).
|
|
464
|
+
const DLN_ORDER_CREATION = "tuple(address giveTokenAddress, uint256 giveAmount, bytes takeTokenAddress, uint256 takeAmount, uint256 takeChainId, bytes receiverDst, address givePatchAuthoritySrc, bytes orderAuthorityAddressDst, bytes allowedTakerDst, bytes externalCall, bytes allowedCancelBeneficiarySrc)";
|
|
465
|
+
const DLN_SOURCE_ABI = [
|
|
466
|
+
`function createOrder(${DLN_ORDER_CREATION} _orderCreation, bytes _affiliateFee, uint32 _referralCode, bytes _permitEnvelope) payable returns (bytes32)`,
|
|
467
|
+
`function createSaltedOrder(${DLN_ORDER_CREATION} _orderCreation, uint64 _salt, bytes _affiliateFee, uint32 _referralCode, bytes _permitEnvelope, bytes _metadata) payable returns (bytes32)`,
|
|
468
|
+
];
|
|
469
|
+
function debridgeRefused(msg) {
|
|
470
|
+
return new PerpError("POLICY_DENIED", `deBridge transaction refused: ${msg}. Nothing was signed or sent.`, {
|
|
471
|
+
exchange: "debridge",
|
|
472
|
+
remediation: "Use another provider: --provider cctp | relay",
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Solana-origin deBridge is refused: the create-tx answer is a full VersionedTransaction (lookup
|
|
477
|
+
* tables, several instructions) that this CLI cannot decode and check against the order it asked
|
|
478
|
+
* for, and SSOT forbids signing a payload blindly.
|
|
479
|
+
*/
|
|
480
|
+
export function assertDebridgeSourceSupported(srcChain) {
|
|
481
|
+
if (srcChain !== "solana")
|
|
482
|
+
return;
|
|
483
|
+
throw new PerpError("NO_BRIDGE_ROUTE", "deBridge from Solana is not supported: its Solana transaction cannot be verified locally before signing — nothing was sent", {
|
|
484
|
+
exchange: "debridge",
|
|
485
|
+
remediation: "Use Circle CCTP from Solana: perp funds bridge send --from solana --to <chain> --amount <n> --provider cctp",
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
/** Raw bytes of an address on `chain` as DLN encodes it (EVM: 20 bytes, Solana: the 32-byte key). */
|
|
489
|
+
async function dlnAddressBytes(chain, address) {
|
|
490
|
+
if (chain === "solana") {
|
|
491
|
+
const { PublicKey } = await import("@solana/web3.js");
|
|
492
|
+
return "0x" + Buffer.from(new PublicKey(address).toBytes()).toString("hex");
|
|
493
|
+
}
|
|
494
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(address))
|
|
495
|
+
throw debridgeRefused(`${JSON.stringify(address)} is not an EVM address`);
|
|
496
|
+
return address.toLowerCase();
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Verify a deBridge create-tx EVM transaction before anything is signed: pinned DlnSource target,
|
|
500
|
+
* createOrder / createSaltedOrder decoded, the give token / amount, take token / chain / amount,
|
|
501
|
+
* the receiver and destination order authority (the user's own address), source patch authority,
|
|
502
|
+
* no external call / permit / taker restriction, the affiliate fee, and value ≤ the quoted fixFee.
|
|
503
|
+
* Returns the verified { to, data, value }.
|
|
504
|
+
*/
|
|
505
|
+
export async function verifyDebridgeEvmTx(tx, x) {
|
|
506
|
+
const pinned = DLN_SOURCE_EVM[x.srcChain];
|
|
507
|
+
if (pinned === undefined)
|
|
508
|
+
throw debridgeRefused(`no pinned DlnSource contract for ${x.srcChain}`);
|
|
509
|
+
const to = typeof tx.to === "string" ? tx.to : "";
|
|
510
|
+
if (to.toLowerCase() !== pinned.toLowerCase())
|
|
511
|
+
throw debridgeRefused(`the transaction targets ${JSON.stringify(tx.to)}, not the pinned DlnSource ${pinned} on ${x.srcChain}`);
|
|
512
|
+
const data = typeof tx.data === "string" ? tx.data : "";
|
|
513
|
+
if (!/^0x[0-9a-fA-F]+$/.test(data))
|
|
514
|
+
throw debridgeRefused("the transaction has no calldata");
|
|
515
|
+
const valueRaw = tx.value === undefined || tx.value === null ? "0" : String(tx.value);
|
|
516
|
+
if (!/^\d+$/.test(valueRaw))
|
|
517
|
+
throw debridgeRefused(`unreadable tx value ${JSON.stringify(tx.value)}`);
|
|
518
|
+
const value = BigInt(valueRaw);
|
|
519
|
+
if (value > x.fixFee)
|
|
520
|
+
throw debridgeRefused(`the transaction sends ${value} wei, more than the quoted fixFee ${x.fixFee}`);
|
|
521
|
+
const { Interface } = await import("ethers");
|
|
522
|
+
const iface = new Interface(DLN_SOURCE_ABI);
|
|
523
|
+
let parsed;
|
|
524
|
+
try {
|
|
525
|
+
parsed = iface.parseTransaction({ data, value });
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
parsed = null;
|
|
529
|
+
}
|
|
530
|
+
if (parsed === null)
|
|
531
|
+
throw debridgeRefused("the calldata is neither DlnSource.createOrder nor createSaltedOrder");
|
|
532
|
+
const salted = parsed.name === "createSaltedOrder";
|
|
533
|
+
const oc = parsed.args[0];
|
|
534
|
+
const affiliateFee = String(parsed.args[salted ? 2 : 1]).toLowerCase();
|
|
535
|
+
const permitEnvelope = String(parsed.args[salted ? 4 : 3]);
|
|
536
|
+
const usdcSrc = USDC_ADDRESSES[x.srcChain];
|
|
537
|
+
const usdcDst = USDC_ADDRESSES[x.dstChain];
|
|
538
|
+
const takeChainId = CHAIN_IDS[x.dstChain];
|
|
539
|
+
if (usdcSrc === undefined || usdcDst === undefined || takeChainId === undefined)
|
|
540
|
+
throw debridgeRefused(`no pinned USDC / chain id for ${x.srcChain} → ${x.dstChain}`);
|
|
541
|
+
if (oc.giveTokenAddress.toLowerCase() !== usdcSrc.toLowerCase())
|
|
542
|
+
throw debridgeRefused(`the order gives token ${oc.giveTokenAddress}, not USDC ${usdcSrc}`);
|
|
543
|
+
if (oc.giveAmount !== x.giveAmount)
|
|
544
|
+
throw debridgeRefused(`the order takes ${oc.giveAmount} USDC units, not the requested ${x.giveAmount}`);
|
|
545
|
+
if (oc.takeChainId !== BigInt(takeChainId))
|
|
546
|
+
throw debridgeRefused(`the order pays out on chain ${oc.takeChainId}, not ${x.dstChain} (${takeChainId})`);
|
|
547
|
+
if (oc.takeTokenAddress.toLowerCase() !== await dlnAddressBytes(x.dstChain, usdcDst))
|
|
548
|
+
throw debridgeRefused(`the order pays out token ${oc.takeTokenAddress}, not USDC on ${x.dstChain}`);
|
|
549
|
+
if (oc.takeAmount < x.takeAmount)
|
|
550
|
+
throw debridgeRefused(`the order pays out ${oc.takeAmount} USDC units, less than the requested ${x.takeAmount}`);
|
|
551
|
+
const recipientBytes = await dlnAddressBytes(x.dstChain, x.recipient);
|
|
552
|
+
if (oc.receiverDst.toLowerCase() !== recipientBytes)
|
|
553
|
+
throw debridgeRefused(`the order pays ${oc.receiverDst}, not the recipient ${x.recipient}`);
|
|
554
|
+
if (oc.orderAuthorityAddressDst.toLowerCase() !== recipientBytes)
|
|
555
|
+
throw debridgeRefused(`the destination order authority is ${oc.orderAuthorityAddressDst}, not the recipient ${x.recipient}`);
|
|
556
|
+
if (oc.givePatchAuthoritySrc.toLowerCase() !== x.sender.toLowerCase())
|
|
557
|
+
throw debridgeRefused(`the source patch authority is ${oc.givePatchAuthoritySrc}, not the sender ${x.sender}`);
|
|
558
|
+
const cancel = oc.allowedCancelBeneficiarySrc.toLowerCase();
|
|
559
|
+
if (cancel !== "0x" && cancel !== x.sender.toLowerCase())
|
|
560
|
+
throw debridgeRefused(`a cancelled order refunds ${oc.allowedCancelBeneficiarySrc}, not the sender ${x.sender}`);
|
|
561
|
+
if (oc.externalCall !== "0x")
|
|
562
|
+
throw debridgeRefused("the order carries a destination external call");
|
|
563
|
+
if (oc.allowedTakerDst !== "0x")
|
|
564
|
+
throw debridgeRefused("the order restricts the destination taker");
|
|
565
|
+
if (permitEnvelope !== "0x")
|
|
566
|
+
throw debridgeRefused("the order carries a permit envelope (only an exact approve is signed)");
|
|
567
|
+
if (x.affiliate === null) {
|
|
568
|
+
if (affiliateFee !== "0x")
|
|
569
|
+
throw debridgeRefused("the order attaches an affiliate fee, but none is configured (settings bridge.debridgeAffiliate)");
|
|
570
|
+
}
|
|
571
|
+
else if (affiliateFee !== "0x") {
|
|
572
|
+
// 52 bytes: beneficiary (20) ‖ amount (32) — see the ABI note above.
|
|
573
|
+
if (affiliateFee.length !== 2 + 104)
|
|
574
|
+
throw debridgeRefused("the affiliate fee is not 52 bytes");
|
|
575
|
+
const beneficiary = "0x" + affiliateFee.slice(2, 42);
|
|
576
|
+
const feeAmount = BigInt("0x" + affiliateFee.slice(42));
|
|
577
|
+
if (beneficiary !== x.affiliate.feeRecipient.toLowerCase())
|
|
578
|
+
throw debridgeRefused(`the affiliate fee goes to ${beneficiary}, not the configured ${x.affiliate.feeRecipient}`);
|
|
579
|
+
// feePercent of the give amount (+1 unit rounding).
|
|
580
|
+
const cap = (x.giveAmount * BigInt(Math.round(x.affiliate.feePercent * 1e6))) / 100000000n + 1n;
|
|
581
|
+
if (feeAmount > cap)
|
|
582
|
+
throw debridgeRefused(`the affiliate fee ${feeAmount} exceeds ${x.affiliate.feePercent}% of the amount`);
|
|
583
|
+
}
|
|
584
|
+
return { to: pinned, data, value };
|
|
585
|
+
}
|
|
279
586
|
/**
|
|
280
587
|
* Get a bridge quote via deBridge DLN.
|
|
281
588
|
*/
|
|
282
589
|
export async function getDebridgeQuote(srcChain, dstChain, amountUsdc, senderAddress, recipientAddress) {
|
|
590
|
+
assertDistinctBridgeChains(srcChain, dstChain);
|
|
591
|
+
assertDebridgeSourceSupported(srcChain);
|
|
283
592
|
const srcChainId = CHAIN_IDS[srcChain];
|
|
284
593
|
const dstChainId = CHAIN_IDS[dstChain];
|
|
285
594
|
if (!srcChainId || !dstChainId)
|
|
@@ -288,7 +597,8 @@ export async function getDebridgeQuote(srcChain, dstChain, amountUsdc, senderAdd
|
|
|
288
597
|
const dstToken = USDC_ADDRESSES[dstChain];
|
|
289
598
|
if (!srcToken || !dstToken)
|
|
290
599
|
throw new Error(`No USDC address for ${srcChain} or ${dstChain}`);
|
|
291
|
-
const amountRaw =
|
|
600
|
+
const amountRaw = usdcUnits(amountUsdc).toString();
|
|
601
|
+
const amountIn = usdcDisplay(amountUsdc);
|
|
292
602
|
// Step 1: Get quote
|
|
293
603
|
const quoteParams = new URLSearchParams({
|
|
294
604
|
srcChainId: String(srcChainId),
|
|
@@ -296,7 +606,10 @@ export async function getDebridgeQuote(srcChain, dstChain, amountUsdc, senderAdd
|
|
|
296
606
|
srcChainTokenInAmount: amountRaw,
|
|
297
607
|
dstChainId: String(dstChainId),
|
|
298
608
|
dstChainTokenOut: dstToken,
|
|
299
|
-
|
|
609
|
+
// false: the operating expenses come out of the amount, so exactly --amount USDC leaves the
|
|
610
|
+
// wallet. "true" ADDS them to the input (live 2026-10-13: --amount 5 spent 5.939652 USDC while
|
|
611
|
+
// the quote reported amountIn 5 / fee $0.004).
|
|
612
|
+
prependOperatingExpenses: "false",
|
|
300
613
|
});
|
|
301
614
|
appendDebridgeBuilderParams(quoteParams);
|
|
302
615
|
const quoteRes = await fetch(`${DLN_API}/quote?${quoteParams}`);
|
|
@@ -317,6 +630,21 @@ export async function getDebridgeQuote(srcChain, dstChain, amountUsdc, senderAdd
|
|
|
317
630
|
`Raw estimation=${JSON.stringify(estimation)}`);
|
|
318
631
|
}
|
|
319
632
|
const amountOut = dstAmountNumber / 1e6;
|
|
633
|
+
// The USDC actually taken from the wallet must equal the request (prependOperatingExpenses=false).
|
|
634
|
+
const srcInRaw = estimation?.srcChainTokenIn?.amount;
|
|
635
|
+
if (String(srcInRaw) !== amountRaw) {
|
|
636
|
+
throw new PerpError("EXCHANGE_ERROR", `deBridge quote takes ${JSON.stringify(srcInRaw)} USDC units, not the requested ${amountRaw}`, {
|
|
637
|
+
remediation: "Use another provider: --provider cctp | relay",
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
// fixFee: the protocol's flat fee in the source chain's native token (wei / lamports), paid as tx value.
|
|
641
|
+
const fixFeeRaw = quote.fixFee;
|
|
642
|
+
if (typeof fixFeeRaw !== "string" || !/^\d+$/.test(fixFeeRaw)) {
|
|
643
|
+
throw new PerpError("EXCHANGE_ERROR", `deBridge quote has no readable fixFee (${JSON.stringify(fixFeeRaw)})`);
|
|
644
|
+
}
|
|
645
|
+
const nativeSymbol = srcChain === "solana" ? "SOL" : "ETH";
|
|
646
|
+
const nativeDecimals = srcChain === "solana" ? 9 : 18;
|
|
647
|
+
const nativeFee = BigInt(fixFeeRaw) === 0n ? null : { amount: formatUnitsPlain(BigInt(fixFeeRaw), nativeDecimals), symbol: nativeSymbol, usd: null };
|
|
320
648
|
const order = quote.order;
|
|
321
649
|
const fulfillDelayRaw = Number(order?.approximateFulfillmentDelay);
|
|
322
650
|
// approximateFulfillmentDelay is documented as nice-to-have telemetry;
|
|
@@ -328,22 +656,33 @@ export async function getDebridgeQuote(srcChain, dstChain, amountUsdc, senderAdd
|
|
|
328
656
|
provider: "debridge",
|
|
329
657
|
srcChain,
|
|
330
658
|
dstChain,
|
|
331
|
-
amountIn
|
|
659
|
+
amountIn,
|
|
332
660
|
amountOut,
|
|
333
|
-
fee:
|
|
661
|
+
fee: amountIn - amountOut,
|
|
334
662
|
estimatedTime: fulfillDelay,
|
|
335
663
|
gasIncluded: true,
|
|
336
|
-
gasNote: "DLN market maker fulfills on destination"
|
|
337
|
-
|
|
664
|
+
gasNote: nativeFee === null ? "DLN market maker fulfills on destination" : `DLN market maker fulfills on destination; plus a flat ${nativeFee.amount} ${nativeFee.symbol} protocol fee (tx value)`,
|
|
665
|
+
nativeFee,
|
|
666
|
+
raw: { quote, senderAddress, recipientAddress, amountRaw, fixFee: fixFeeRaw },
|
|
338
667
|
};
|
|
339
668
|
}
|
|
340
669
|
/**
|
|
341
|
-
* Execute a deBridge bridge transaction.
|
|
342
|
-
*
|
|
670
|
+
* Execute a deBridge bridge transaction (EVM origin only). The create-tx answer is verified
|
|
671
|
+
* against the request (verifyDebridgeEvmTx) before the exact USDC approval and the order are
|
|
672
|
+
* signed. Returns the TX hash after signing and submitting.
|
|
343
673
|
*/
|
|
344
|
-
export async function executeDebridgeBridge(bridgeQuote, signerKey) {
|
|
674
|
+
export async function executeDebridgeBridge(network, bridgeQuote, signerKey) {
|
|
675
|
+
assertBridgeNetwork(network);
|
|
676
|
+
assertDistinctBridgeChains(bridgeQuote.srcChain, bridgeQuote.dstChain);
|
|
345
677
|
const { srcChain, dstChain, amountIn, amountOut } = bridgeQuote;
|
|
346
|
-
|
|
678
|
+
assertDebridgeSourceSupported(srcChain);
|
|
679
|
+
const { senderAddress, recipientAddress, amountRaw, fixFee } = bridgeQuote.raw;
|
|
680
|
+
// Resolve the signer before any create-tx call: the order is built for senderAddress only.
|
|
681
|
+
const evmSigner = evmSignerOf(signerKey);
|
|
682
|
+
assertSignerIs(srcChain, evmSigner.getAddress(), senderAddress);
|
|
683
|
+
if (!/^\d+$/.test(String(amountRaw)) || !/^\d+$/.test(String(fixFee))) {
|
|
684
|
+
throw new PerpError("EXCHANGE_ERROR", "deBridge quote is missing its amount / fixFee — nothing was sent", { remediation: "Fetch a fresh quote" });
|
|
685
|
+
}
|
|
347
686
|
const srcChainId = CHAIN_IDS[srcChain];
|
|
348
687
|
const dstChainId = CHAIN_IDS[dstChain];
|
|
349
688
|
const srcToken = USDC_ADDRESSES[srcChain];
|
|
@@ -352,7 +691,11 @@ export async function executeDebridgeBridge(bridgeQuote, signerKey) {
|
|
|
352
691
|
const quote = bridgeQuote.raw.quote;
|
|
353
692
|
const estimation = quote.estimation;
|
|
354
693
|
const dstOut = estimation?.dstChainTokenOut;
|
|
355
|
-
const
|
|
694
|
+
const dstAmountRaw = dstOut?.recommendedAmount ?? dstOut?.amount;
|
|
695
|
+
if (dstAmountRaw === undefined || !/^\d+$/.test(String(dstAmountRaw))) {
|
|
696
|
+
throw new PerpError("EXCHANGE_ERROR", `deBridge quote has no readable dstChainTokenOut amount (${JSON.stringify(dstAmountRaw)}) — nothing sent`);
|
|
697
|
+
}
|
|
698
|
+
const dstAmount = String(dstAmountRaw);
|
|
356
699
|
// Step 2: Create TX
|
|
357
700
|
const createParams = new URLSearchParams({
|
|
358
701
|
srcChainId: String(srcChainId),
|
|
@@ -364,7 +707,7 @@ export async function executeDebridgeBridge(bridgeQuote, signerKey) {
|
|
|
364
707
|
dstChainTokenOutRecipient: recipientAddress,
|
|
365
708
|
srcChainOrderAuthorityAddress: senderAddress,
|
|
366
709
|
dstChainOrderAuthorityAddress: recipientAddress,
|
|
367
|
-
prependOperatingExpenses: "
|
|
710
|
+
prependOperatingExpenses: "false", // same mode as the quote: exactly amountRaw leaves the wallet
|
|
368
711
|
});
|
|
369
712
|
appendDebridgeBuilderParams(createParams);
|
|
370
713
|
const createRes = await fetch(`${DLN_API}/create-tx?${createParams}`);
|
|
@@ -374,14 +717,14 @@ export async function executeDebridgeBridge(bridgeQuote, signerKey) {
|
|
|
374
717
|
const tx = createTx.tx;
|
|
375
718
|
if (!tx)
|
|
376
719
|
throw new Error("deBridge returned no transaction data");
|
|
377
|
-
// Step 3:
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
720
|
+
// Step 3: verify the payload against the request, then approve (exact) + sign + submit.
|
|
721
|
+
const aff = loadSettings().bridge.debridgeAffiliate;
|
|
722
|
+
const verified = await verifyDebridgeEvmTx(tx, {
|
|
723
|
+
srcChain, dstChain, sender: senderAddress, recipient: recipientAddress,
|
|
724
|
+
giveAmount: BigInt(amountRaw), takeAmount: BigInt(dstAmount), fixFee: BigInt(fixFee),
|
|
725
|
+
affiliate: aff !== null && aff.feePercent !== null && aff.feeRecipient !== null ? { feePercent: aff.feePercent, feeRecipient: aff.feeRecipient } : null,
|
|
726
|
+
});
|
|
727
|
+
const txHash = await submitEvmTransaction(verified, BigInt(amountRaw), evmSigner, srcChain);
|
|
385
728
|
return {
|
|
386
729
|
provider: "debridge",
|
|
387
730
|
txHash,
|
|
@@ -392,71 +735,13 @@ export async function executeDebridgeBridge(bridgeQuote, signerKey) {
|
|
|
392
735
|
};
|
|
393
736
|
}
|
|
394
737
|
/**
|
|
395
|
-
* Submit a
|
|
738
|
+
* Submit a verified deBridge DlnSource order: exact USDC approval of the pinned DlnSource, then
|
|
739
|
+
* the order transaction.
|
|
396
740
|
*/
|
|
397
|
-
async function
|
|
398
|
-
const {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// Decode the keypair
|
|
402
|
-
let keypair;
|
|
403
|
-
try {
|
|
404
|
-
// Try base58 first (standard Solana private key format)
|
|
405
|
-
keypair = Keypair.fromSecretKey(bs58.default.decode(signerKey));
|
|
406
|
-
}
|
|
407
|
-
catch {
|
|
408
|
-
// Try JSON array (Solana CLI format)
|
|
409
|
-
try {
|
|
410
|
-
keypair = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(signerKey)));
|
|
411
|
-
}
|
|
412
|
-
catch {
|
|
413
|
-
throw new Error("Invalid Solana private key format");
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
// deBridge returns hex-encoded VersionedTransaction for Solana
|
|
417
|
-
const txData = String(tx.data);
|
|
418
|
-
const txBytes = hexToBytes(txData.startsWith("0x") ? txData.slice(2) : txData);
|
|
419
|
-
const transaction = VersionedTransaction.deserialize(txBytes);
|
|
420
|
-
// Update blockhash and sign
|
|
421
|
-
const { blockhash } = await connection.getLatestBlockhash();
|
|
422
|
-
transaction.message.recentBlockhash = blockhash;
|
|
423
|
-
transaction.sign([keypair]);
|
|
424
|
-
const signature = await connection.sendTransaction(transaction, { skipPreflight: false });
|
|
425
|
-
await connection.confirmTransaction(signature, "confirmed");
|
|
426
|
-
return signature;
|
|
427
|
-
}
|
|
428
|
-
/**
|
|
429
|
-
* Submit an EVM transaction from deBridge create-tx response.
|
|
430
|
-
*/
|
|
431
|
-
async function submitEvmTransaction(tx, privateKey, chain) {
|
|
432
|
-
const { ethers } = await import("ethers");
|
|
433
|
-
const rpc = requireRpcUrl(chain);
|
|
434
|
-
const provider = new ethers.JsonRpcProvider(rpc);
|
|
435
|
-
const wallet = new ethers.Wallet(privateKey, provider);
|
|
436
|
-
// Check and approve USDC allowance if needed
|
|
437
|
-
const usdcAddr = USDC_ADDRESSES[chain];
|
|
438
|
-
const spenderAddr = String(tx.to);
|
|
439
|
-
const value = BigInt(String(tx.value ?? "0"));
|
|
440
|
-
if (usdcAddr && spenderAddr) {
|
|
441
|
-
const usdc = new ethers.Contract(usdcAddr, [
|
|
442
|
-
"function allowance(address,address) view returns (uint256)",
|
|
443
|
-
"function approve(address,uint256) returns (bool)",
|
|
444
|
-
], wallet);
|
|
445
|
-
const allowance = await usdc.allowance(wallet.address, spenderAddr);
|
|
446
|
-
// Approve max if allowance insufficient
|
|
447
|
-
if (allowance < ethers.parseUnits("1000000", 6)) {
|
|
448
|
-
const approveTx = await usdc.approve(spenderAddr, ethers.MaxUint256);
|
|
449
|
-
await approveTx.wait();
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
// Send the bridge transaction
|
|
453
|
-
const txResponse = await wallet.sendTransaction({
|
|
454
|
-
to: String(tx.to),
|
|
455
|
-
data: String(tx.data),
|
|
456
|
-
value,
|
|
457
|
-
});
|
|
458
|
-
const receipt = await txResponse.wait();
|
|
459
|
-
return receipt.hash;
|
|
741
|
+
async function submitEvmTransaction(tx, giveAmount, signer, chain) {
|
|
742
|
+
const { rpc, chainId } = await bridgeEvmRpc(chain);
|
|
743
|
+
await ensureUsdcAllowance(rpc, chainId, chain, signer, "debridge", tx.to, giveAmount);
|
|
744
|
+
return sendBridgeEvmTx(rpc, chainId, chain, signer, "debridge", "deBridge order", tx.to, tx.data, tx.value);
|
|
460
745
|
}
|
|
461
746
|
// ── Circle CCTP V2 (EVM + Solana) ──
|
|
462
747
|
export const CCTP_DOMAINS = {
|
|
@@ -480,6 +765,23 @@ const USDC_MINT_SOLANA = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
|
480
765
|
const SPL_TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
|
481
766
|
const ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
|
|
482
767
|
const SYSTEM_PROGRAM = "11111111111111111111111111111111";
|
|
768
|
+
/**
|
|
769
|
+
* HyperCore → Solana is refused. The sendToEvmWithData action built below is EVM-only: it sends
|
|
770
|
+
* the recipient with addressEncoding "hex" and signs with signatureChainId = the destination's
|
|
771
|
+
* chain id. Hyperliquid documents addressEncoding "hex" | "base58" and signatureChainId as "the id
|
|
772
|
+
* of the chain used when signing" (hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/
|
|
773
|
+
* exchange-endpoint, read 2026-10-13) — a base58 Solana recipient sent as "hex", signed for the
|
|
774
|
+
* deBridge pseudo chain id 7565164, would not be what HyperCore expects. The Solana encoding is
|
|
775
|
+
* not implemented against documentation, so the route is refused before anything is signed.
|
|
776
|
+
*/
|
|
777
|
+
export function assertCctpRouteSupported(srcChain, dstChain) {
|
|
778
|
+
if (!(srcChain === "hyperliquid" && dstChain === "solana"))
|
|
779
|
+
return;
|
|
780
|
+
throw new PerpError("NO_BRIDGE_ROUTE", "HyperCore → Solana (CCTP sendToEvmWithData) is not supported: the Solana recipient encoding is not implemented — nothing was sent", {
|
|
781
|
+
exchange: "bridge",
|
|
782
|
+
remediation: "Go through Arbitrum: perp funds bridge exchange --from hyperliquid --to lighter (HyperCore → Arbitrum), then perp funds bridge send --from arbitrum --to solana --amount <n> --provider cctp",
|
|
783
|
+
});
|
|
784
|
+
}
|
|
483
785
|
/**
|
|
484
786
|
* Check if CCTP is supported for a given chain.
|
|
485
787
|
*/
|
|
@@ -496,11 +798,14 @@ function isCctpSupported(chain) {
|
|
|
496
798
|
* @param fast - If true, use fast finality (1000): ~1-2 min, $1-1.3 + $0.20 forwarding.
|
|
497
799
|
* If false (default), use standard finality (2000): ~2-5 min, ~$0.20 forwarding.
|
|
498
800
|
*/
|
|
499
|
-
export async function getCctpQuote(srcChain, dstChain,
|
|
801
|
+
export async function getCctpQuote(srcChain, dstChain, amount, fast = false) {
|
|
802
|
+
const amountUsdc = usdcDisplay(amount); // display / fee estimates only — signed amounts use usdcUnits(amount)
|
|
803
|
+
assertDistinctBridgeChains(srcChain, dstChain);
|
|
500
804
|
if (!isCctpSupported(srcChain))
|
|
501
805
|
throw new Error(`CCTP not supported on ${srcChain}`);
|
|
502
806
|
if (!isCctpSupported(dstChain))
|
|
503
807
|
throw new Error(`CCTP not supported on ${dstChain}`);
|
|
808
|
+
assertCctpRouteSupported(srcChain, dstChain);
|
|
504
809
|
// HyperCore → EVM: HL withdrawal + CCTP forwarding (~0.20 USDC fee)
|
|
505
810
|
if (srcChain === "hyperliquid") {
|
|
506
811
|
const forwardingFee = 0.20; // CCTP forwarding fee for Arbitrum
|
|
@@ -544,7 +849,7 @@ export async function getCctpQuote(srcChain, dstChain, amountUsdc, fast = false)
|
|
|
544
849
|
const dstDomain = CCTP_DOMAINS[dstChain];
|
|
545
850
|
// Use Forwarding Service when dst is NOT Solana (Solana dst doesn't support forwarding)
|
|
546
851
|
const canForward = dstChain !== "solana";
|
|
547
|
-
const { maxFee, feeUsdc, forwardingAvailable } = await getCctpRelayFee(srcDomain, dstDomain, finality, canForward,
|
|
852
|
+
const { maxFee, feeUsdc, forwardingAvailable } = await getCctpRelayFee(srcDomain, dstDomain, finality, canForward, amount);
|
|
548
853
|
const estimatedTime = fast
|
|
549
854
|
? ((srcChain === "solana" || dstChain === "solana") ? 90 : 60)
|
|
550
855
|
: (forwardingAvailable
|
|
@@ -574,46 +879,39 @@ export async function getCctpQuote(srcChain, dstChain, amountUsdc, fast = false)
|
|
|
574
879
|
* @param fast - If true, use fast finality (1000): Circle auto-relays, no manual receiveMessage.
|
|
575
880
|
* If false (default), use standard finality (2000): cheaper but requires manual relay.
|
|
576
881
|
*/
|
|
577
|
-
export async function executeCctpBridge(srcChain, dstChain,
|
|
882
|
+
export async function executeCctpBridge(network, srcChain, dstChain, amount, signerKey, recipientAddress, dstSignerKey, // destination-chain signer for receiveMessage (EVM→Solana standard finality)
|
|
578
883
|
fast = false) {
|
|
884
|
+
usdcUnits(amount); // INVALID_PARAMS for a malformed / over-precise amount before anything else
|
|
885
|
+
assertBridgeNetwork(network);
|
|
886
|
+
assertDistinctBridgeChains(srcChain, dstChain);
|
|
887
|
+
assertCctpRouteSupported(srcChain, dstChain);
|
|
579
888
|
// HyperCore route: depositForBurnWithHook via CctpForwarder
|
|
580
889
|
if (dstChain === "hyperliquid") {
|
|
581
890
|
if (srcChain === "solana") {
|
|
582
|
-
return executeCctpSolanaToHyperCore(
|
|
891
|
+
return executeCctpSolanaToHyperCore(amount, signerKey, recipientAddress);
|
|
583
892
|
}
|
|
584
|
-
return executeCctpEvmToHyperCore(srcChain,
|
|
893
|
+
return executeCctpEvmToHyperCore(srcChain, amount, signerKey, recipientAddress);
|
|
585
894
|
}
|
|
586
895
|
// HyperCore → EVM: sendToEvmWithData via HL exchange API
|
|
587
896
|
if (srcChain === "hyperliquid") {
|
|
588
|
-
return executeCctpHyperCoreToEvm(dstChain,
|
|
897
|
+
return executeCctpHyperCoreToEvm(network, dstChain, amount, signerKey, recipientAddress);
|
|
589
898
|
}
|
|
590
899
|
if (srcChain === "solana") {
|
|
591
|
-
return executeCctpSolanaToEvm(dstChain,
|
|
900
|
+
return executeCctpSolanaToEvm(dstChain, amount, signerKey, recipientAddress, dstSignerKey, fast);
|
|
592
901
|
}
|
|
593
902
|
if (dstChain === "solana") {
|
|
594
|
-
return executeCctpEvmToSolana(srcChain,
|
|
903
|
+
return executeCctpEvmToSolana(srcChain, amount, signerKey, recipientAddress, dstSignerKey, fast);
|
|
595
904
|
}
|
|
596
|
-
return executeCctpEvmToEvm(srcChain, dstChain,
|
|
905
|
+
return executeCctpEvmToEvm(srcChain, dstChain, amount, signerKey, recipientAddress, fast);
|
|
597
906
|
}
|
|
598
907
|
// ── CCTP: Solana → HyperCore (depositForBurnWithHook) ──
|
|
599
|
-
async function executeCctpSolanaToHyperCore(
|
|
908
|
+
async function executeCctpSolanaToHyperCore(amount, signerKey, recipientAddress) {
|
|
909
|
+
const amountUsdc = usdcDisplay(amount); // display / fee estimates only — signed amounts use usdcUnits(amount)
|
|
600
910
|
const { Connection, PublicKey, Keypair, TransactionMessage, VersionedTransaction, SystemProgram } = await import("@solana/web3.js");
|
|
601
|
-
const bs58 = await import("bs58");
|
|
602
911
|
const { createHash } = await import("crypto");
|
|
603
912
|
const connection = new Connection(RPC_URLS.solana, "confirmed");
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
try {
|
|
607
|
-
keypair = Keypair.fromSecretKey(bs58.default.decode(signerKey));
|
|
608
|
-
}
|
|
609
|
-
catch {
|
|
610
|
-
try {
|
|
611
|
-
keypair = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(signerKey)));
|
|
612
|
-
}
|
|
613
|
-
catch {
|
|
614
|
-
throw new Error("Invalid Solana private key format");
|
|
615
|
-
}
|
|
616
|
-
}
|
|
913
|
+
const signer = await solanaSignerOf(signerKey);
|
|
914
|
+
const keypair = { publicKey: new PublicKey(signer.getPublicKeyBase58()) };
|
|
617
915
|
const tokenMessengerMinter = new PublicKey(CCTP_SOLANA_TOKEN_MESSENGER_MINTER);
|
|
618
916
|
const messageTransmitterProgram = new PublicKey(CCTP_SOLANA_MESSAGE_TRANSMITTER);
|
|
619
917
|
const usdcMint = new PublicKey(USDC_MINT_SOLANA);
|
|
@@ -640,7 +938,7 @@ async function executeCctpSolanaToHyperCore(amountUsdc, signerKey, recipientAddr
|
|
|
640
938
|
// Anchor discriminator: sha256("global:deposit_for_burn_with_hook")[0..8]
|
|
641
939
|
const discriminator = createHash("sha256").update("global:deposit_for_burn_with_hook").digest().subarray(0, 8);
|
|
642
940
|
const amountBuf = Buffer.alloc(8);
|
|
643
|
-
amountBuf.writeBigUInt64LE(
|
|
941
|
+
amountBuf.writeBigUInt64LE(usdcUnits(amount));
|
|
644
942
|
// Domain as u32 LE for instruction data (different from PDA seed which uses string)
|
|
645
943
|
const domainBuf = Buffer.alloc(4);
|
|
646
944
|
domainBuf.writeUInt32LE(HYPERCORE_CCTP_DOMAIN);
|
|
@@ -700,9 +998,8 @@ async function executeCctpSolanaToHyperCore(amountUsdc, signerKey, recipientAddr
|
|
|
700
998
|
instructions: [instruction],
|
|
701
999
|
}).compileToV0Message();
|
|
702
1000
|
const transaction = new VersionedTransaction(messageV0);
|
|
703
|
-
|
|
704
|
-
const signature = await connection
|
|
705
|
-
await connection.confirmTransaction(signature, "confirmed");
|
|
1001
|
+
await signVersionedSolanaTx(signer, transaction, [eventDataKeypair]);
|
|
1002
|
+
const signature = await sendBridgeSolanaTx(connection, transaction, "CCTP depositForBurnWithHook (Solana → HyperCore)");
|
|
706
1003
|
return {
|
|
707
1004
|
provider: "cctp (HyperCore)",
|
|
708
1005
|
txHash: signature,
|
|
@@ -728,9 +1025,14 @@ const USDC_EIP712_VERSION = {
|
|
|
728
1025
|
base: "2",
|
|
729
1026
|
};
|
|
730
1027
|
// ── CCTP: HyperCore → EVM (sendToEvmWithData via HL exchange API) ──
|
|
731
|
-
async function executeCctpHyperCoreToEvm(dstChain,
|
|
732
|
-
const
|
|
733
|
-
|
|
1028
|
+
async function executeCctpHyperCoreToEvm(network, dstChain, amount, signerKey, recipientAddress) {
|
|
1029
|
+
const amountUsdc = usdcDisplay(amount); // display / fee estimates only — signed amounts use usdcUnits(amount)
|
|
1030
|
+
assertBridgeNetwork(network);
|
|
1031
|
+
const { Signature: EthSig } = await import("ethers");
|
|
1032
|
+
// HL environment + /exchange host from the venue definition (never a hardcoded "Mainnet").
|
|
1033
|
+
const { hyperliquidChain } = HL_USER_SIGNED[network];
|
|
1034
|
+
const hlExchangeUrl = `${networkConfig(getVenue("hyperliquid"), network).restUrl}/exchange`;
|
|
1035
|
+
const signer = evmSignerOf(signerKey);
|
|
734
1036
|
const dstDomain = CCTP_DOMAINS[dstChain];
|
|
735
1037
|
if (dstDomain === undefined)
|
|
736
1038
|
throw new Error(`No CCTP domain for ${dstChain}`);
|
|
@@ -742,10 +1044,10 @@ async function executeCctpHyperCoreToEvm(dstChain, amountUsdc, signerKey, recipi
|
|
|
742
1044
|
// Build action payload
|
|
743
1045
|
const action = {
|
|
744
1046
|
type: "sendToEvmWithData",
|
|
745
|
-
hyperliquidChain
|
|
1047
|
+
hyperliquidChain,
|
|
746
1048
|
signatureChainId,
|
|
747
1049
|
token: "USDC",
|
|
748
|
-
amount:
|
|
1050
|
+
amount: parseUsdcAmount(amount),
|
|
749
1051
|
sourceDex: "spot", // unified accounts hold funds in spot
|
|
750
1052
|
destinationRecipient: recipientAddress,
|
|
751
1053
|
addressEncoding: "hex",
|
|
@@ -776,32 +1078,39 @@ async function executeCctpHyperCoreToEvm(dstChain, amountUsdc, signerKey, recipi
|
|
|
776
1078
|
],
|
|
777
1079
|
};
|
|
778
1080
|
const message = {
|
|
779
|
-
hyperliquidChain
|
|
1081
|
+
hyperliquidChain,
|
|
780
1082
|
token: "USDC",
|
|
781
|
-
amount:
|
|
1083
|
+
amount: parseUsdcAmount(amount),
|
|
782
1084
|
sourceDex: "spot",
|
|
783
1085
|
destinationRecipient: recipientAddress,
|
|
784
1086
|
addressEncoding: "hex",
|
|
785
1087
|
destinationChainId: dstDomain,
|
|
786
|
-
|
|
1088
|
+
// Plain numbers (not BigInt): the OWS signer serializes typed data as JSON. Both are safe integers.
|
|
1089
|
+
gasLimit: 200000,
|
|
787
1090
|
data: "0x",
|
|
788
|
-
nonce:
|
|
1091
|
+
nonce: timestamp,
|
|
789
1092
|
};
|
|
790
|
-
const sigHex = await
|
|
1093
|
+
const sigHex = await signTypedDataChecked(signer, domain, types, message, "HyperCore sendToEvmWithData");
|
|
791
1094
|
const sig = EthSig.from(sigHex);
|
|
792
|
-
// Submit to HL exchange API
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1095
|
+
// Submit to HL exchange API. A failure after the request left is an unknown outcome.
|
|
1096
|
+
let result;
|
|
1097
|
+
try {
|
|
1098
|
+
const resp = await fetch(hlExchangeUrl, {
|
|
1099
|
+
method: "POST",
|
|
1100
|
+
headers: { "Content-Type": "application/json" },
|
|
1101
|
+
body: JSON.stringify({
|
|
1102
|
+
action,
|
|
1103
|
+
nonce: timestamp,
|
|
1104
|
+
signature: { r: sig.r, s: sig.s, v: sig.v },
|
|
1105
|
+
}),
|
|
1106
|
+
});
|
|
1107
|
+
result = await resp.json();
|
|
1108
|
+
}
|
|
1109
|
+
catch (e) {
|
|
1110
|
+
throw sendFailure("hyperliquid", "sendToEvmWithData (HyperCore → EVM bridge)", e, "Do NOT resend — the withdrawal may have been accepted. Check the Hyperliquid account history and the destination balance first");
|
|
1111
|
+
}
|
|
803
1112
|
if (result.status !== "ok") {
|
|
804
|
-
throw new
|
|
1113
|
+
throw new PerpError("ACTION_REJECTED", `HyperCore withdrawal rejected: ${JSON.stringify(result)}`, { exchange: "hyperliquid" });
|
|
805
1114
|
}
|
|
806
1115
|
return {
|
|
807
1116
|
provider: "cctp (HyperCore → EVM via sendToEvmWithData)",
|
|
@@ -816,11 +1125,11 @@ async function executeCctpHyperCoreToEvm(dstChain, amountUsdc, signerKey, recipi
|
|
|
816
1125
|
// ── CCTP: EVM → HyperCore ──
|
|
817
1126
|
// Arbitrum: CctpExtension.batchDepositForBurnWithAuth (1-TX, no approve)
|
|
818
1127
|
// Other EVM chains: approve + TokenMessengerV2.depositForBurnWithHook (2-TX fallback)
|
|
819
|
-
async function executeCctpEvmToHyperCore(srcChain,
|
|
1128
|
+
async function executeCctpEvmToHyperCore(srcChain, amount, signerKey, recipientAddress) {
|
|
1129
|
+
const amountUsdc = usdcDisplay(amount); // display / fee estimates only — signed amounts use usdcUnits(amount)
|
|
820
1130
|
const { ethers } = await import("ethers");
|
|
821
|
-
const
|
|
822
|
-
const
|
|
823
|
-
const srcWallet = new ethers.Wallet(signerKey, srcProvider);
|
|
1131
|
+
const signer = evmSignerOf(signerKey);
|
|
1132
|
+
const { rpc, chainId: rpcChainId } = await bridgeEvmRpc(srcChain);
|
|
824
1133
|
const usdcAddr = USDC_ADDRESSES[srcChain];
|
|
825
1134
|
if (!usdcAddr)
|
|
826
1135
|
throw new Error(`No USDC address for ${srcChain}`);
|
|
@@ -831,12 +1140,12 @@ async function executeCctpEvmToHyperCore(srcChain, amountUsdc, signerKey, recipi
|
|
|
831
1140
|
if (!chainId)
|
|
832
1141
|
throw new Error(`No chain ID for ${srcChain}`);
|
|
833
1142
|
const fees = await getHyperCoreCctpFees(srcDomain, amountUsdc);
|
|
834
|
-
const amountRaw =
|
|
1143
|
+
const amountRaw = usdcUnits(amount);
|
|
835
1144
|
// mintRecipient & destinationCaller = CctpForwarder (bytes32)
|
|
836
1145
|
const forwarderBytes32 = ethers.zeroPadValue(HYPERCORE_CCTP_FORWARDER, 32);
|
|
837
1146
|
const hookData = encodeHyperCoreHookData(recipientAddress, HYPERCORE_DEX_PERPS);
|
|
838
1147
|
const cctpExtensionAddr = CCTP_EXTENSION[srcChain];
|
|
839
|
-
let
|
|
1148
|
+
let txHash;
|
|
840
1149
|
let providerLabel;
|
|
841
1150
|
if (cctpExtensionAddr) {
|
|
842
1151
|
// ── Path A: CctpExtension (Arbitrum) — single TX, no approve ──
|
|
@@ -861,47 +1170,42 @@ async function executeCctpEvmToHyperCore(srcChain, amountUsdc, signerKey, recipi
|
|
|
861
1170
|
],
|
|
862
1171
|
};
|
|
863
1172
|
const receiveAuthValue = {
|
|
864
|
-
from:
|
|
1173
|
+
from: signer.getAddress(),
|
|
865
1174
|
to: cctpExtensionAddr,
|
|
866
|
-
|
|
1175
|
+
// Decimal string (not BigInt): the OWS signer serializes typed data as JSON.
|
|
1176
|
+
value: amountRaw.toString(),
|
|
867
1177
|
validAfter,
|
|
868
1178
|
validBefore,
|
|
869
1179
|
nonce: authNonce,
|
|
870
1180
|
};
|
|
871
|
-
const sig = await
|
|
1181
|
+
const sig = await signTypedDataChecked(signer, usdcDomain, receiveAuthTypes, receiveAuthValue, "USDC ReceiveWithAuthorization");
|
|
872
1182
|
const { v, r, s } = ethers.Signature.from(sig);
|
|
873
1183
|
const authParams = [amountRaw, validAfter, validBefore, authNonce, v, r, s];
|
|
874
1184
|
const burnParams = [amountRaw, HYPERCORE_CCTP_DOMAIN, forwarderBytes32, forwarderBytes32, BigInt(fees.maxFee), 1000, hookData];
|
|
875
|
-
const cctpExtension = new ethers.
|
|
1185
|
+
const cctpExtension = new ethers.Interface([
|
|
876
1186
|
"function batchDepositForBurnWithAuth(tuple(uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) authParams, tuple(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold, bytes hookData) burnParams)",
|
|
877
|
-
]
|
|
878
|
-
|
|
1187
|
+
]);
|
|
1188
|
+
txHash = await sendBridgeEvmTx(rpc, rpcChainId, srcChain, signer, "cctp", "CCTP batchDepositForBurnWithAuth", cctpExtensionAddr, cctpExtension.encodeFunctionData("batchDepositForBurnWithAuth", [authParams, burnParams]));
|
|
879
1189
|
providerLabel = "cctp (HyperCore via CctpExtension)";
|
|
880
1190
|
}
|
|
881
1191
|
else {
|
|
882
1192
|
// ── Path B: approve + depositForBurnWithHook (Base, other EVM chains) ──
|
|
883
1193
|
const tokenMessengerAddr = CCTP_TOKEN_MESSENGER[srcChain];
|
|
884
1194
|
if (!tokenMessengerAddr)
|
|
885
|
-
throw new
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
"function approve(address,uint256) returns (bool)",
|
|
889
|
-
], srcWallet);
|
|
890
|
-
const allowance = await usdc.allowance(srcWallet.address, tokenMessengerAddr);
|
|
891
|
-
if (allowance < amountRaw) {
|
|
892
|
-
const approveTx = await usdc.approve(tokenMessengerAddr, ethers.MaxUint256);
|
|
893
|
-
await approveTx.wait();
|
|
894
|
-
}
|
|
895
|
-
const tokenMessenger = new ethers.Contract(tokenMessengerAddr, [
|
|
1195
|
+
throw new PerpError("NO_BRIDGE_ROUTE", `CCTP not configured for ${srcChain}`);
|
|
1196
|
+
await ensureUsdcAllowance(rpc, rpcChainId, srcChain, signer, "cctp", tokenMessengerAddr, amountRaw);
|
|
1197
|
+
const tokenMessenger = new ethers.Interface([
|
|
896
1198
|
"function depositForBurnWithHook(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold, bytes hookData) returns (uint64 nonce)",
|
|
897
|
-
]
|
|
898
|
-
|
|
1199
|
+
]);
|
|
1200
|
+
txHash = await sendBridgeEvmTx(rpc, rpcChainId, srcChain, signer, "cctp", "CCTP depositForBurnWithHook", tokenMessengerAddr, tokenMessenger.encodeFunctionData("depositForBurnWithHook", [
|
|
1201
|
+
amountRaw, HYPERCORE_CCTP_DOMAIN, forwarderBytes32, usdcAddr,
|
|
1202
|
+
forwarderBytes32, BigInt(fees.maxFee), 1000, hookData,
|
|
1203
|
+
]));
|
|
899
1204
|
providerLabel = "cctp (HyperCore via depositForBurnWithHook)";
|
|
900
1205
|
}
|
|
901
|
-
const receipt = await depositTx.wait();
|
|
902
1206
|
return {
|
|
903
1207
|
provider: providerLabel,
|
|
904
|
-
txHash
|
|
1208
|
+
txHash,
|
|
905
1209
|
srcChain,
|
|
906
1210
|
dstChain: "hyperliquid",
|
|
907
1211
|
amountIn: amountUsdc,
|
|
@@ -909,79 +1213,65 @@ async function executeCctpEvmToHyperCore(srcChain, amountUsdc, signerKey, recipi
|
|
|
909
1213
|
};
|
|
910
1214
|
}
|
|
911
1215
|
// ── CCTP: EVM → EVM ──
|
|
912
|
-
async function executeCctpEvmToEvm(srcChain, dstChain,
|
|
1216
|
+
async function executeCctpEvmToEvm(srcChain, dstChain, amount, signerKey, recipientAddress, fast = false) {
|
|
1217
|
+
const amountUsdc = usdcDisplay(amount); // display / fee estimates only — signed amounts use usdcUnits(amount)
|
|
913
1218
|
const { ethers } = await import("ethers");
|
|
914
|
-
const
|
|
915
|
-
const srcProvider = new ethers.JsonRpcProvider(srcRpc);
|
|
916
|
-
const srcWallet = new ethers.Wallet(signerKey, srcProvider);
|
|
1219
|
+
const signer = evmSignerOf(signerKey);
|
|
917
1220
|
const usdcAddr = USDC_ADDRESSES[srcChain];
|
|
918
1221
|
const tokenMessengerAddr = CCTP_TOKEN_MESSENGER[srcChain];
|
|
919
1222
|
const dstDomain = CCTP_DOMAINS[dstChain];
|
|
920
1223
|
const srcDomain = CCTP_DOMAINS[srcChain];
|
|
921
1224
|
if (!usdcAddr || !tokenMessengerAddr || dstDomain === undefined || srcDomain === undefined) {
|
|
922
|
-
throw new
|
|
1225
|
+
throw new PerpError("NO_BRIDGE_ROUTE", `CCTP not configured for ${srcChain} → ${dstChain}`);
|
|
923
1226
|
}
|
|
1227
|
+
const { rpc, chainId } = await bridgeEvmRpc(srcChain);
|
|
924
1228
|
// Step 0: Check USDC balance
|
|
925
|
-
const balance = await getEvmUsdcBalance(srcChain,
|
|
1229
|
+
const balance = await getEvmUsdcBalance(srcChain, signer.getAddress());
|
|
926
1230
|
if (balance < amountUsdc) {
|
|
927
|
-
throw new
|
|
1231
|
+
throw new PerpError("INSUFFICIENT_BALANCE", `Insufficient USDC on ${srcChain}: have ${balance.toFixed(2)}, need ${amountUsdc} — nothing was sent`, {
|
|
1232
|
+
remediation: `Fund ${signer.getAddress()} with USDC on ${srcChain}, or lower --amount`,
|
|
1233
|
+
});
|
|
928
1234
|
}
|
|
929
|
-
const amountRaw =
|
|
1235
|
+
const amountRaw = usdcUnits(amount);
|
|
930
1236
|
// Step 1: Approve USDC
|
|
931
|
-
|
|
932
|
-
"function allowance(address,address) view returns (uint256)",
|
|
933
|
-
"function approve(address,uint256) returns (bool)",
|
|
934
|
-
], srcWallet);
|
|
935
|
-
const allowance = await usdc.allowance(srcWallet.address, tokenMessengerAddr);
|
|
936
|
-
if (allowance < amountRaw) {
|
|
937
|
-
const approveTx = await usdc.approve(tokenMessengerAddr, ethers.MaxUint256);
|
|
938
|
-
await approveTx.wait();
|
|
939
|
-
}
|
|
1237
|
+
await ensureUsdcAllowance(rpc, chainId, srcChain, signer, "cctp", tokenMessengerAddr, amountRaw);
|
|
940
1238
|
// Step 2: Get fee (with forwarding — Circle handles dst mint)
|
|
941
1239
|
const finality = fast ? 1000 : 2000;
|
|
942
|
-
const { maxFee } = await getCctpRelayFee(srcDomain, dstDomain, finality, true,
|
|
1240
|
+
const { maxFee } = await getCctpRelayFee(srcDomain, dstDomain, finality, true, amount);
|
|
943
1241
|
// Step 3: depositForBurnWithHook + Forwarding Service
|
|
944
1242
|
// Circle Forwarding: include forward hook data → Circle broadcasts receiveMessage on dst.
|
|
945
1243
|
// No manual relay needed. No dst chain gas needed.
|
|
946
1244
|
const mintRecipient = ethers.zeroPadValue(recipientAddress, 32);
|
|
947
1245
|
const destinationCaller = ethers.ZeroHash; // must be zero for forwarding
|
|
948
|
-
const tokenMessenger = new ethers.
|
|
1246
|
+
const tokenMessenger = new ethers.Interface([
|
|
949
1247
|
"function depositForBurnWithHook(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold, bytes hookData) returns (uint64 nonce)",
|
|
950
|
-
]
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
1248
|
+
]);
|
|
1249
|
+
const txHash = await sendBridgeEvmTx(rpc, chainId, srcChain, signer, "cctp", "CCTP depositForBurnWithHook", tokenMessengerAddr, tokenMessenger.encodeFunctionData("depositForBurnWithHook", [
|
|
1250
|
+
amountRaw, dstDomain, mintRecipient, usdcAddr,
|
|
1251
|
+
destinationCaller, maxFee, finality, CCTP_FORWARD_HOOK_DATA,
|
|
1252
|
+
]));
|
|
954
1253
|
const feeUsdc = Number(maxFee) / 1e6;
|
|
955
1254
|
const providerLabel = fast ? "cctp (fast+forward)" : "cctp (forward)";
|
|
956
1255
|
return { provider: providerLabel, txHash, srcChain, dstChain, amountIn: amountUsdc, amountOut: amountUsdc - feeUsdc };
|
|
957
1256
|
}
|
|
958
1257
|
// ── CCTP: Solana → EVM ──
|
|
959
|
-
async function executeCctpSolanaToEvm(dstChain,
|
|
1258
|
+
async function executeCctpSolanaToEvm(dstChain, amount, signerKey, recipientAddress, _dstSignerKey, // unused: Circle forwarding handles the destination mint
|
|
960
1259
|
fast = false) {
|
|
1260
|
+
const amountUsdc = usdcDisplay(amount); // display / fee estimates only — signed amounts use usdcUnits(amount)
|
|
961
1261
|
const { Connection, PublicKey, Keypair, TransactionMessage, VersionedTransaction, SystemProgram } = await import("@solana/web3.js");
|
|
962
|
-
const bs58 = await import("bs58");
|
|
963
1262
|
const { createHash } = await import("crypto");
|
|
964
1263
|
const connection = new Connection(RPC_URLS.solana, "confirmed");
|
|
965
1264
|
const dstDomain = CCTP_DOMAINS[dstChain];
|
|
966
1265
|
if (dstDomain === undefined)
|
|
967
|
-
throw new
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
try {
|
|
971
|
-
keypair = Keypair.fromSecretKey(bs58.default.decode(signerKey));
|
|
972
|
-
}
|
|
973
|
-
catch {
|
|
974
|
-
try {
|
|
975
|
-
keypair = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(signerKey)));
|
|
976
|
-
}
|
|
977
|
-
catch {
|
|
978
|
-
throw new Error("Invalid Solana private key format");
|
|
979
|
-
}
|
|
980
|
-
}
|
|
1266
|
+
throw new PerpError("NO_BRIDGE_ROUTE", `Unknown CCTP domain for ${dstChain}`);
|
|
1267
|
+
const signer = await solanaSignerOf(signerKey);
|
|
1268
|
+
const keypair = { publicKey: new PublicKey(signer.getPublicKeyBase58()) };
|
|
981
1269
|
// Check USDC balance
|
|
982
1270
|
const balance = await getSolanaUsdcBalance(keypair.publicKey.toBase58());
|
|
983
1271
|
if (balance < amountUsdc) {
|
|
984
|
-
throw new
|
|
1272
|
+
throw new PerpError("INSUFFICIENT_BALANCE", `Insufficient USDC on Solana: have ${balance.toFixed(2)}, need ${amountUsdc} — nothing was sent`, {
|
|
1273
|
+
remediation: `Fund ${keypair.publicKey.toBase58()} with USDC on Solana, or lower --amount`,
|
|
1274
|
+
});
|
|
985
1275
|
}
|
|
986
1276
|
const tokenMessengerMinter = new PublicKey(CCTP_SOLANA_TOKEN_MESSENGER_MINTER);
|
|
987
1277
|
const messageTransmitterProgram = new PublicKey(CCTP_SOLANA_MESSAGE_TRANSMITTER);
|
|
@@ -1008,7 +1298,7 @@ fast = false) {
|
|
|
1008
1298
|
// Use deposit_for_burn_with_hook for Forwarding Service (Circle handles dst mint)
|
|
1009
1299
|
const discriminator = createHash("sha256").update("global:deposit_for_burn_with_hook").digest().subarray(0, 8);
|
|
1010
1300
|
const amountBuf = Buffer.alloc(8);
|
|
1011
|
-
amountBuf.writeBigUInt64LE(
|
|
1301
|
+
amountBuf.writeBigUInt64LE(usdcUnits(amount));
|
|
1012
1302
|
// Domain as u32 LE for instruction data (different from PDA seed which uses string)
|
|
1013
1303
|
const domainBuf = Buffer.alloc(4);
|
|
1014
1304
|
domainBuf.writeUInt32LE(dstDomain);
|
|
@@ -1019,7 +1309,7 @@ fast = false) {
|
|
|
1019
1309
|
// destinationCaller: all zeros (required for forwarding)
|
|
1020
1310
|
const destinationCaller = Buffer.alloc(32);
|
|
1021
1311
|
const finality = fast ? 1000 : 2000;
|
|
1022
|
-
const { maxFee: relayFee } = await getCctpRelayFee(CCTP_DOMAINS.solana, dstDomain, finality, true,
|
|
1312
|
+
const { maxFee: relayFee } = await getCctpRelayFee(CCTP_DOMAINS.solana, dstDomain, finality, true, amount);
|
|
1023
1313
|
const maxFeeBuf = Buffer.alloc(8);
|
|
1024
1314
|
maxFeeBuf.writeBigUInt64LE(relayFee);
|
|
1025
1315
|
const minFinalityBuf = Buffer.alloc(4);
|
|
@@ -1068,9 +1358,8 @@ fast = false) {
|
|
|
1068
1358
|
instructions: [instruction],
|
|
1069
1359
|
}).compileToV0Message();
|
|
1070
1360
|
const transaction = new VersionedTransaction(messageV0);
|
|
1071
|
-
|
|
1072
|
-
const signature = await connection
|
|
1073
|
-
await connection.confirmTransaction(signature, "confirmed");
|
|
1361
|
+
await signVersionedSolanaTx(signer, transaction, [eventDataKeypair]);
|
|
1362
|
+
const signature = await sendBridgeSolanaTx(connection, transaction, "CCTP depositForBurnWithHook (Solana → EVM)");
|
|
1074
1363
|
const feeUsdc = Number(relayFee) / 1e6;
|
|
1075
1364
|
const providerLabel = fast ? "cctp (fast+forward)" : "cctp (forward)";
|
|
1076
1365
|
// Forwarding Service: Circle handles dst chain mint — no manual relay needed.
|
|
@@ -1079,38 +1368,40 @@ fast = false) {
|
|
|
1079
1368
|
// ── CCTP: EVM → Solana ──
|
|
1080
1369
|
// NOTE: Circle Forwarding Service does NOT support Solana as destination.
|
|
1081
1370
|
// Must use depositForBurn + manual receiveMessage (or fast finality for auto-relay).
|
|
1082
|
-
async function executeCctpEvmToSolana(srcChain,
|
|
1083
|
-
solanaPayerKey, // Solana
|
|
1371
|
+
async function executeCctpEvmToSolana(srcChain, amount, signerKey, recipientAddress, // Solana base58 pubkey
|
|
1372
|
+
solanaPayerKey, // Solana signer for the receiveMessage relay (standard finality)
|
|
1084
1373
|
fast = false) {
|
|
1374
|
+
const amountUsdc = usdcDisplay(amount); // display / fee estimates only — signed amounts use usdcUnits(amount)
|
|
1085
1375
|
const { ethers } = await import("ethers");
|
|
1086
|
-
const
|
|
1087
|
-
const srcProvider = new ethers.JsonRpcProvider(srcRpc);
|
|
1088
|
-
const srcWallet = new ethers.Wallet(signerKey, srcProvider);
|
|
1376
|
+
const signer = evmSignerOf(signerKey);
|
|
1089
1377
|
const usdcAddr = USDC_ADDRESSES[srcChain];
|
|
1090
1378
|
const tokenMessengerAddr = CCTP_TOKEN_MESSENGER[srcChain];
|
|
1091
1379
|
const solanaDomain = CCTP_DOMAINS.solana; // 5
|
|
1092
1380
|
if (!usdcAddr || !tokenMessengerAddr)
|
|
1093
|
-
throw new
|
|
1381
|
+
throw new PerpError("NO_BRIDGE_ROUTE", `CCTP not configured for ${srcChain}`);
|
|
1382
|
+
// Standard finality needs our own Solana receiveMessage after the burn: without the Solana
|
|
1383
|
+
// signer the burn would leave the USDC unminted. Refused before anything is sent.
|
|
1384
|
+
if (!fast && solanaPayerKey === undefined) {
|
|
1385
|
+
throw new PerpError("NO_SIGNER_AVAILABLE", `CCTP ${srcChain} → Solana (standard finality) mints with a Solana receiveMessage signed by the master's Solana key, and none was supplied — nothing was sent`, {
|
|
1386
|
+
exchange: "bridge",
|
|
1387
|
+
remediation: "Select a master wallet that has a Solana account (perp wallet use <name> / --ows <name>), or pass --fast",
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
const { rpc, chainId } = await bridgeEvmRpc(srcChain);
|
|
1094
1391
|
// Step 0: Check USDC balance
|
|
1095
|
-
const balance = await getEvmUsdcBalance(srcChain,
|
|
1392
|
+
const balance = await getEvmUsdcBalance(srcChain, signer.getAddress());
|
|
1096
1393
|
if (balance < amountUsdc) {
|
|
1097
|
-
throw new
|
|
1394
|
+
throw new PerpError("INSUFFICIENT_BALANCE", `Insufficient USDC on ${srcChain}: have ${balance.toFixed(2)}, need ${amountUsdc} — nothing was sent`, {
|
|
1395
|
+
remediation: `Fund ${signer.getAddress()} with USDC on ${srcChain}, or lower --amount`,
|
|
1396
|
+
});
|
|
1098
1397
|
}
|
|
1099
|
-
const amountRaw =
|
|
1398
|
+
const amountRaw = usdcUnits(amount);
|
|
1100
1399
|
// Step 1: Approve USDC
|
|
1101
|
-
|
|
1102
|
-
"function allowance(address,address) view returns (uint256)",
|
|
1103
|
-
"function approve(address,uint256) returns (bool)",
|
|
1104
|
-
], srcWallet);
|
|
1105
|
-
const allowance = await usdc.allowance(srcWallet.address, tokenMessengerAddr);
|
|
1106
|
-
if (allowance < amountRaw) {
|
|
1107
|
-
const approveTx = await usdc.approve(tokenMessengerAddr, ethers.MaxUint256);
|
|
1108
|
-
await approveTx.wait();
|
|
1109
|
-
}
|
|
1400
|
+
await ensureUsdcAllowance(rpc, chainId, srcChain, signer, "cctp", tokenMessengerAddr, amountRaw);
|
|
1110
1401
|
// Step 2: Get relay fee (no forwarding — Solana dst not supported)
|
|
1111
1402
|
const finality = fast ? 1000 : 2000;
|
|
1112
1403
|
const srcDomain = CCTP_DOMAINS[srcChain];
|
|
1113
|
-
const { maxFee: relayFee, feeUsdc } = await getCctpRelayFee(srcDomain, solanaDomain, finality, false,
|
|
1404
|
+
const { maxFee: relayFee, feeUsdc } = await getCctpRelayFee(srcDomain, solanaDomain, finality, false, amount);
|
|
1114
1405
|
// Step 3: depositForBurn — mintRecipient is the Solana ATA for the recipient
|
|
1115
1406
|
const { PublicKey } = await import("@solana/web3.js");
|
|
1116
1407
|
const recipientPubkey = new PublicKey(recipientAddress);
|
|
@@ -1121,53 +1412,69 @@ fast = false) {
|
|
|
1121
1412
|
// Pad the Solana ATA (32 bytes) to bytes32 for EVM
|
|
1122
1413
|
const mintRecipient = "0x" + Buffer.from(recipientAta.toBytes()).toString("hex");
|
|
1123
1414
|
const destinationCaller = ethers.ZeroHash; // permissionless relay
|
|
1124
|
-
const tokenMessenger = new ethers.
|
|
1415
|
+
const tokenMessenger = new ethers.Interface([
|
|
1125
1416
|
"function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) returns (uint64 nonce)",
|
|
1126
|
-
]
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1129
|
-
|
|
1417
|
+
]);
|
|
1418
|
+
const txHash = await sendBridgeEvmTx(rpc, chainId, srcChain, signer, "cctp", "CCTP depositForBurn", tokenMessengerAddr, tokenMessenger.encodeFunctionData("depositForBurn", [
|
|
1419
|
+
amountRaw, solanaDomain, mintRecipient, usdcAddr,
|
|
1420
|
+
destinationCaller, relayFee, finality,
|
|
1421
|
+
]));
|
|
1130
1422
|
// Fast finality (1000): Circle auto-relays — done.
|
|
1131
1423
|
if (fast) {
|
|
1132
1424
|
return { provider: "cctp (fast)", txHash, srcChain, dstChain: "solana", amountIn: amountUsdc, amountOut: amountUsdc - feeUsdc };
|
|
1133
1425
|
}
|
|
1134
|
-
// Standard finality (2000): poll attestation + manual Solana receiveMessage
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1426
|
+
// Standard finality (2000): poll attestation + manual Solana receiveMessage. The burn is final:
|
|
1427
|
+
// anything short of a confirmed mint is ORDER_PENDING (never a normal result, never a resend).
|
|
1428
|
+
const receiveTxHash = await completeEvmToSolanaMint(srcDomain, txHash, recipientAddress, solanaPayerKey);
|
|
1429
|
+
return { provider: "cctp", txHash, receiveTxHash, srcChain, dstChain: "solana", amountIn: amountUsdc, amountOut: amountUsdc - feeUsdc };
|
|
1430
|
+
}
|
|
1431
|
+
/** Circle Iris V2 message lookup for a source-chain burn. */
|
|
1432
|
+
function irisMessageUrl(srcDomain, txHash) {
|
|
1433
|
+
return `https://iris-api.circle.com/v2/messages/${srcDomain}?transactionHash=${txHash}`;
|
|
1434
|
+
}
|
|
1435
|
+
function burnedMintUnconfirmed(srcDomain, burnTxHash, why) {
|
|
1436
|
+
const iris = irisMessageUrl(srcDomain, burnTxHash);
|
|
1437
|
+
return new PerpError("ORDER_PENDING", `CCTP: USDC burned (tx ${burnTxHash}); mint on Solana not confirmed — ${why}`, {
|
|
1438
|
+
exchange: "bridge", txHash: burnTxHash, attestationUrl: iris,
|
|
1439
|
+
remediation: `Do NOT resend the bridge — the USDC is already burned. Check the attestation at ${iris}; once it is complete the mint can be claimed with a Solana receiveMessage (CCTP is idempotent per message nonce). Then check the Solana USDC balance.`,
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* After an EVM → Solana standard-finality burn: wait for Circle's attestation, then mint on
|
|
1444
|
+
* Solana with receiveMessage. Returns the mint signature. Timeout or a failed / unconfirmed
|
|
1445
|
+
* receiveMessage throws ORDER_PENDING carrying the burn tx hash and the Iris URL.
|
|
1446
|
+
* `opts` exists for tests (poll count / interval, receiveMessage implementation).
|
|
1447
|
+
*/
|
|
1448
|
+
export async function completeEvmToSolanaMint(srcDomain, burnTxHash, recipientAddress, payer, opts = { attempts: 80, intervalMs: 15_000, receive: executeSolanaReceiveMessage }) {
|
|
1449
|
+
const url = irisMessageUrl(srcDomain, burnTxHash);
|
|
1450
|
+
let message = null;
|
|
1451
|
+
let attestation = null;
|
|
1452
|
+
for (let i = 0; i < opts.attempts && message === null; i++) {
|
|
1453
|
+
await sleep(opts.intervalMs);
|
|
1454
|
+
try {
|
|
1455
|
+
const res = await fetch(url);
|
|
1456
|
+
if (!res.ok)
|
|
1457
|
+
continue; // 404 until Iris indexes the burn: keep polling (read-only)
|
|
1458
|
+
const body = await res.json();
|
|
1459
|
+
const msg = body.messages?.[0];
|
|
1460
|
+
if (msg?.status === "complete" && msg.message && msg.attestation && msg.attestation !== "PENDING") {
|
|
1461
|
+
message = msg.message;
|
|
1462
|
+
attestation = msg.attestation;
|
|
1152
1463
|
}
|
|
1153
|
-
catch { /* retry */ }
|
|
1154
1464
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
const receiveSig = await executeSolanaReceiveMessage(messageBytes, attestationBytes, recipientAddress, solanaPayerKey);
|
|
1158
|
-
return { provider: "cctp", txHash, receiveTxHash: receiveSig, srcChain, dstChain: "solana", amountIn: amountUsdc, amountOut: amountUsdc - feeUsdc };
|
|
1159
|
-
}
|
|
1160
|
-
catch { /* may fail if already relayed */ }
|
|
1465
|
+
catch {
|
|
1466
|
+
// A failed read of the attestation is retried until the poll budget ends (read-only).
|
|
1161
1467
|
}
|
|
1162
1468
|
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1469
|
+
if (message === null || attestation === null) {
|
|
1470
|
+
throw burnedMintUnconfirmed(srcDomain, burnTxHash, `no complete attestation after ${Math.round(opts.attempts * opts.intervalMs / 1000)}s`);
|
|
1471
|
+
}
|
|
1472
|
+
try {
|
|
1473
|
+
return await opts.receive(message, attestation, recipientAddress, payer);
|
|
1474
|
+
}
|
|
1475
|
+
catch (e) {
|
|
1476
|
+
throw burnedMintUnconfirmed(srcDomain, burnTxHash, `receiveMessage did not confirm (${e instanceof Error ? e.message : String(e)})`);
|
|
1477
|
+
}
|
|
1171
1478
|
}
|
|
1172
1479
|
// ── CCTP: Solana receiveMessage (for EVM→Solana relay) ──
|
|
1173
1480
|
/**
|
|
@@ -1178,23 +1485,11 @@ export async function executeSolanaReceiveMessage(messageHex, // "0x..." CCTP me
|
|
|
1178
1485
|
attestationHex, // "0x..." attestation from Iris API
|
|
1179
1486
|
recipientAddress, // Solana wallet pubkey (base58)
|
|
1180
1487
|
payerKey) {
|
|
1181
|
-
const { Connection, PublicKey,
|
|
1182
|
-
const bs58 = await import("bs58");
|
|
1488
|
+
const { Connection, PublicKey, TransactionMessage, VersionedTransaction, SystemProgram, ComputeBudgetProgram } = await import("@solana/web3.js");
|
|
1183
1489
|
const { createHash } = await import("crypto");
|
|
1184
1490
|
const connection = new Connection(RPC_URLS.solana, "confirmed");
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
try {
|
|
1188
|
-
payer = Keypair.fromSecretKey(bs58.default.decode(payerKey));
|
|
1189
|
-
}
|
|
1190
|
-
catch {
|
|
1191
|
-
try {
|
|
1192
|
-
payer = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(payerKey)));
|
|
1193
|
-
}
|
|
1194
|
-
catch {
|
|
1195
|
-
throw new Error("Invalid Solana private key format");
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1491
|
+
const payerSigner = await solanaSignerOf(payerKey);
|
|
1492
|
+
const payer = { publicKey: new PublicKey(payerSigner.getPublicKeyBase58()) };
|
|
1198
1493
|
// Parse raw bytes
|
|
1199
1494
|
const messageBytes = Buffer.from(messageHex.replace("0x", ""), "hex");
|
|
1200
1495
|
const attestationBytes = Buffer.from(attestationHex.replace("0x", ""), "hex");
|
|
@@ -1293,10 +1588,8 @@ payerKey) {
|
|
|
1293
1588
|
instructions: [computeBudgetIx, instruction],
|
|
1294
1589
|
}).compileToV0Message(lookupTables);
|
|
1295
1590
|
const transaction = new VersionedTransaction(messageV0);
|
|
1296
|
-
transaction
|
|
1297
|
-
|
|
1298
|
-
await connection.confirmTransaction(signature, "confirmed");
|
|
1299
|
-
return signature;
|
|
1591
|
+
await signVersionedSolanaTx(payerSigner, transaction);
|
|
1592
|
+
return sendBridgeSolanaTx(connection, transaction, "CCTP receiveMessage (Solana)");
|
|
1300
1593
|
}
|
|
1301
1594
|
// ── Shared CCTP helpers ──
|
|
1302
1595
|
/**
|
|
@@ -1324,121 +1617,56 @@ async function pollCctpV2Attestation(sourceDomain, txHash, maxAttempts) {
|
|
|
1324
1617
|
return { message: null, attestation: null };
|
|
1325
1618
|
}
|
|
1326
1619
|
// ── Relay Bridge API ──
|
|
1327
|
-
|
|
1328
|
-
//
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
};
|
|
1334
|
-
|
|
1335
|
-
const
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
throw new Error(`Relay: unsupported chain ${srcChain} or ${dstChain}`);
|
|
1339
|
-
const srcToken = USDC_ADDRESSES[srcChain];
|
|
1340
|
-
const dstToken = USDC_ADDRESSES[dstChain];
|
|
1341
|
-
if (!srcToken || !dstToken)
|
|
1342
|
-
throw new Error(`No USDC for ${srcChain} or ${dstChain}`);
|
|
1343
|
-
const amountRaw = String(Math.round(amountUsdc * 1e6));
|
|
1344
|
-
const body = {
|
|
1345
|
-
user: senderAddress,
|
|
1346
|
-
recipient: recipientAddress,
|
|
1347
|
-
originChainId: srcChainId,
|
|
1348
|
-
destinationChainId: dstChainId,
|
|
1349
|
-
originCurrency: srcToken,
|
|
1350
|
-
destinationCurrency: dstToken,
|
|
1351
|
-
amount: amountRaw,
|
|
1352
|
-
tradeType: "EXACT_INPUT",
|
|
1353
|
-
};
|
|
1354
|
-
const res = await fetch(`${RELAY_API}/quote`, {
|
|
1355
|
-
method: "POST",
|
|
1356
|
-
headers: { "Content-Type": "application/json" },
|
|
1357
|
-
body: JSON.stringify(body),
|
|
1620
|
+
// Quote verification, step validation and execution live in ./relay-swap.ts (generic assets,
|
|
1621
|
+
// EVM-origin deposits). These two functions are the USDC → USDC entry used by getAllQuotes /
|
|
1622
|
+
// executeBestBridge.
|
|
1623
|
+
export async function getRelayQuote(srcChain, dstChain, amount, senderAddress, recipientAddress) {
|
|
1624
|
+
const amountUsdc = usdcDisplay(amount); // display / fee estimates only — signed amounts use usdcUnits(amount)
|
|
1625
|
+
assertDistinctBridgeChains(srcChain, dstChain);
|
|
1626
|
+
const { quoteRelaySwap } = await import("./relay-swap.js");
|
|
1627
|
+
// Verified locally (order id, depository, amounts, recipient) — see relay-swap.ts.
|
|
1628
|
+
const q = await quoteRelaySwap({
|
|
1629
|
+
from: srcChain, to: dstChain, fromAsset: "USDC", toAsset: "USDC",
|
|
1630
|
+
amount: parseUsdcAmount(amount), sender: senderAddress, recipient: recipientAddress,
|
|
1358
1631
|
});
|
|
1359
|
-
if (!res.ok) {
|
|
1360
|
-
const errText = await res.text();
|
|
1361
|
-
throw new Error(`Relay quote failed: ${res.status} ${errText}`);
|
|
1362
|
-
}
|
|
1363
|
-
const data = await res.json();
|
|
1364
|
-
const details = data.details;
|
|
1365
|
-
const fees = data.fees;
|
|
1366
|
-
// SSOT rule #2: refuse to fabricate amountOut/fee from `0 || subtractFee`
|
|
1367
|
-
// back-fill. If Relay didn't quote a real amount-out + relayer fee, the
|
|
1368
|
-
// user's bridge cost is unknown — surface the gap.
|
|
1369
|
-
const currencyOut = details?.currencyOut;
|
|
1370
|
-
const amountFormattedRaw = currencyOut?.amountFormatted;
|
|
1371
|
-
const amountOut = Number(amountFormattedRaw);
|
|
1372
|
-
if (amountFormattedRaw === undefined || !Number.isFinite(amountOut)) {
|
|
1373
|
-
throw new Error(`Relay quote missing details.currencyOut.amountFormatted ` +
|
|
1374
|
-
`(received ${JSON.stringify(amountFormattedRaw)}); refusing silent 0 fallback. ` +
|
|
1375
|
-
`Raw details=${JSON.stringify(details)}`);
|
|
1376
|
-
}
|
|
1377
|
-
const relayerFee = fees?.relayer;
|
|
1378
|
-
const feeUsdRaw = relayerFee?.amountUsd;
|
|
1379
|
-
const feeUsd = Number(feeUsdRaw);
|
|
1380
|
-
if (feeUsdRaw === undefined || !Number.isFinite(feeUsd)) {
|
|
1381
|
-
throw new Error(`Relay quote missing fees.relayer.amountUsd ` +
|
|
1382
|
-
`(received ${JSON.stringify(feeUsdRaw)}); refusing silent 0 fallback. ` +
|
|
1383
|
-
`Raw fees=${JSON.stringify(fees)}`);
|
|
1384
|
-
}
|
|
1385
|
-
// timeEstimate is telemetry only; default 30s is documented Relay median
|
|
1386
|
-
// for healthy routes. Telemetry default, not error fallback.
|
|
1387
|
-
const timeEstimateRaw = Number(details?.timeEstimate);
|
|
1388
|
-
const timeEstimate = Number.isFinite(timeEstimateRaw) ? timeEstimateRaw : 30;
|
|
1389
1632
|
return {
|
|
1390
1633
|
provider: "relay",
|
|
1391
1634
|
srcChain,
|
|
1392
1635
|
dstChain,
|
|
1393
1636
|
amountIn: amountUsdc,
|
|
1394
|
-
amountOut,
|
|
1395
|
-
fee:
|
|
1396
|
-
|
|
1637
|
+
amountOut: Number(q.expectedOut),
|
|
1638
|
+
fee: Number(q.fees.relayerUsd),
|
|
1639
|
+
// timeEstimate is telemetry only; 30s is the documented Relay median for healthy routes.
|
|
1640
|
+
estimatedTime: q.timeEstimateSec === null ? 30 : q.timeEstimateSec,
|
|
1397
1641
|
gasIncluded: true,
|
|
1398
1642
|
gasNote: "Relay solver handles destination execution",
|
|
1399
|
-
raw:
|
|
1643
|
+
raw: q,
|
|
1400
1644
|
};
|
|
1401
1645
|
}
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
if (!itemData)
|
|
1417
|
-
continue;
|
|
1418
|
-
if (kind === "transaction") {
|
|
1419
|
-
if (srcChain === "solana") {
|
|
1420
|
-
// Solana transaction
|
|
1421
|
-
const txData = String(itemData.data ?? "");
|
|
1422
|
-
if (txData) {
|
|
1423
|
-
txHash = await submitSolanaTransaction({ data: txData }, signerKey);
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
else {
|
|
1427
|
-
// EVM transaction
|
|
1428
|
-
txHash = await submitEvmTransaction({ to: itemData.to, data: itemData.data, value: itemData.value ?? "0" }, signerKey, srcChain);
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1646
|
+
/**
|
|
1647
|
+
* Execute a verified Relay USDC quote with the master's EVM signer (OWS vault signer or a raw
|
|
1648
|
+
* key). Only the verified approve (exact amount, pinned depository) + deposit are signed — never
|
|
1649
|
+
* an unlimited approval, never an unchecked target.
|
|
1650
|
+
*/
|
|
1651
|
+
export async function executeRelayBridge(network, bridgeQuote, signerKey) {
|
|
1652
|
+
assertBridgeNetwork(network);
|
|
1653
|
+
assertDistinctBridgeChains(bridgeQuote.srcChain, bridgeQuote.dstChain);
|
|
1654
|
+
const { executeRelaySwap, assertRelayMinOut } = await import("./relay-swap.js");
|
|
1655
|
+
const q = bridgeQuote.raw;
|
|
1656
|
+
assertRelayMinOut(q, null);
|
|
1657
|
+
const signer = evmSignerOf(signerKey);
|
|
1658
|
+
if (signer.getAddress().toLowerCase() !== q.sender.toLowerCase()) {
|
|
1659
|
+
throw new PerpError("INVALID_PARAMS", `The Relay quote was built for sender ${q.sender}, not the signing wallet's address ${signer.getAddress()} — nothing was sent`);
|
|
1432
1660
|
}
|
|
1433
|
-
|
|
1434
|
-
throw new Error("Relay: no transaction executed");
|
|
1661
|
+
const res = await executeRelaySwap(network, q, tx => signer.signTransaction(tx));
|
|
1435
1662
|
return {
|
|
1436
1663
|
provider: "relay",
|
|
1437
|
-
txHash,
|
|
1438
|
-
srcChain,
|
|
1439
|
-
dstChain,
|
|
1440
|
-
amountIn,
|
|
1441
|
-
amountOut,
|
|
1664
|
+
txHash: res.depositTxHash,
|
|
1665
|
+
srcChain: bridgeQuote.srcChain,
|
|
1666
|
+
dstChain: bridgeQuote.dstChain,
|
|
1667
|
+
amountIn: bridgeQuote.amountIn,
|
|
1668
|
+
amountOut: bridgeQuote.amountOut,
|
|
1669
|
+
...(res.destinationTxHashes.length > 0 ? { receiveTxHash: res.destinationTxHashes[0] } : {}),
|
|
1442
1670
|
};
|
|
1443
1671
|
}
|
|
1444
1672
|
/**
|
|
@@ -1450,26 +1678,75 @@ export async function executeRelayBridge(bridgeQuote, signerKey) {
|
|
|
1450
1678
|
* Get quotes from ALL available providers in parallel.
|
|
1451
1679
|
* Returns sorted by amountOut (best first).
|
|
1452
1680
|
*/
|
|
1453
|
-
|
|
1681
|
+
/** Plain decimal string of `raw` / 10^decimals (no exponent, trailing zeros trimmed). */
|
|
1682
|
+
function formatUnitsPlain(raw, decimals) {
|
|
1683
|
+
const base = 10n ** BigInt(decimals);
|
|
1684
|
+
const whole = raw / base;
|
|
1685
|
+
const frac = (raw % base).toString().padStart(decimals, "0").replace(/0+$/, "");
|
|
1686
|
+
return frac === "" ? whole.toString() : `${whole}.${frac}`;
|
|
1687
|
+
}
|
|
1688
|
+
/** USD mid of native tokens (ETH / SOL) from Hyperliquid allMids; a failed read leaves them unpriced. */
|
|
1689
|
+
async function nativeUsdPrices(symbols) {
|
|
1690
|
+
const out = new Map();
|
|
1691
|
+
try {
|
|
1692
|
+
const res = await fetch("https://api.hyperliquid.xyz/info", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "allMids" }) });
|
|
1693
|
+
if (!res.ok)
|
|
1694
|
+
return out;
|
|
1695
|
+
const mids = await res.json();
|
|
1696
|
+
for (const sym of symbols) {
|
|
1697
|
+
const px = Number(mids[sym]);
|
|
1698
|
+
if (Number.isFinite(px) && px > 0)
|
|
1699
|
+
out.set(sym, px);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
catch {
|
|
1703
|
+
// Unpriced is reported as usd: null and ranks the quote last — never treated as a zero fee.
|
|
1704
|
+
}
|
|
1705
|
+
return out;
|
|
1706
|
+
}
|
|
1707
|
+
export async function getAllQuotes(srcChain, dstChain, amount, senderAddress, recipientAddress) {
|
|
1708
|
+
usdcUnits(amount); // INVALID_PARAMS before the fan-out (allSettled would hide it as "no route")
|
|
1709
|
+
assertDistinctBridgeChains(srcChain, dstChain);
|
|
1454
1710
|
const results = await Promise.allSettled([
|
|
1455
|
-
getCctpQuote(srcChain, dstChain,
|
|
1456
|
-
getRelayQuote(srcChain, dstChain,
|
|
1457
|
-
getDebridgeQuote(srcChain, dstChain,
|
|
1711
|
+
getCctpQuote(srcChain, dstChain, amount),
|
|
1712
|
+
getRelayQuote(srcChain, dstChain, amount, senderAddress, recipientAddress),
|
|
1713
|
+
getDebridgeQuote(srcChain, dstChain, amount, senderAddress, recipientAddress),
|
|
1458
1714
|
]);
|
|
1459
1715
|
const quotes = [];
|
|
1460
1716
|
for (const r of results) {
|
|
1461
1717
|
if (r.status === "fulfilled")
|
|
1462
1718
|
quotes.push(r.value);
|
|
1463
1719
|
}
|
|
1464
|
-
//
|
|
1465
|
-
|
|
1720
|
+
// Price native fees (deBridge fixFee) so quotes compare on the same basis; an unpriced native
|
|
1721
|
+
// fee ranks that quote last (its total cost is unknown), never as free.
|
|
1722
|
+
const symbols = [...new Set(quotes.flatMap(q => (q.nativeFee ? [q.nativeFee.symbol] : [])))];
|
|
1723
|
+
if (symbols.length > 0) {
|
|
1724
|
+
const prices = await nativeUsdPrices(symbols);
|
|
1725
|
+
for (const q of quotes) {
|
|
1726
|
+
if (!q.nativeFee)
|
|
1727
|
+
continue;
|
|
1728
|
+
const px = prices.get(q.nativeFee.symbol);
|
|
1729
|
+
q.nativeFee = { ...q.nativeFee, usd: px === undefined ? null : Number(q.nativeFee.amount) * px };
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
const net = (q) => (q.nativeFee ? (q.nativeFee.usd === null ? null : q.amountOut - q.nativeFee.usd) : q.amountOut);
|
|
1733
|
+
quotes.sort((a, b) => {
|
|
1734
|
+
const na = net(a), nb = net(b);
|
|
1735
|
+
if (na === null && nb === null)
|
|
1736
|
+
return b.amountOut - a.amountOut;
|
|
1737
|
+
if (na === null)
|
|
1738
|
+
return 1;
|
|
1739
|
+
if (nb === null)
|
|
1740
|
+
return -1;
|
|
1741
|
+
return nb - na;
|
|
1742
|
+
});
|
|
1466
1743
|
return quotes;
|
|
1467
1744
|
}
|
|
1468
1745
|
/**
|
|
1469
1746
|
* Get the best quote across all providers.
|
|
1470
1747
|
*/
|
|
1471
|
-
export async function getBestQuote(srcChain, dstChain,
|
|
1472
|
-
const quotes = await getAllQuotes(srcChain, dstChain,
|
|
1748
|
+
export async function getBestQuote(srcChain, dstChain, amount, senderAddress, recipientAddress) {
|
|
1749
|
+
const quotes = await getAllQuotes(srcChain, dstChain, amount, senderAddress, recipientAddress);
|
|
1473
1750
|
if (quotes.length === 0) {
|
|
1474
1751
|
throw new Error(`No bridge available for ${srcChain} → ${dstChain}`);
|
|
1475
1752
|
}
|
|
@@ -1479,31 +1756,45 @@ export async function getBestQuote(srcChain, dstChain, amountUsdc, senderAddress
|
|
|
1479
1756
|
* Execute a bridge using the specified or best available provider.
|
|
1480
1757
|
* @param provider - Optional: force a specific provider ("cctp" | "relay" | "debridge")
|
|
1481
1758
|
*/
|
|
1482
|
-
export async function executeBestBridge(srcChain, dstChain,
|
|
1759
|
+
export async function executeBestBridge(network, srcChain, dstChain, amount, signerKey, senderAddress, recipientAddress, dstSignerKey, // Optional destination-chain signer for manual receiveMessage (standard finality)
|
|
1483
1760
|
provider, fast = false) {
|
|
1761
|
+
// Before quoting: a testnet run must not even look up mainnet routes to sign.
|
|
1762
|
+
assertBridgeNetwork(network);
|
|
1763
|
+
assertDistinctBridgeChains(srcChain, dstChain);
|
|
1764
|
+
usdcUnits(amount); // INVALID_PARAMS (> 6 decimals, malformed) before any quote
|
|
1765
|
+
// Specific refusals (the quote fan-out below would only report "not available").
|
|
1766
|
+
assertCctpRouteSupported(srcChain, dstChain);
|
|
1767
|
+
if (provider === "debridge")
|
|
1768
|
+
assertDebridgeSourceSupported(srcChain);
|
|
1484
1769
|
let quote;
|
|
1485
1770
|
if (provider) {
|
|
1486
1771
|
// User specified a provider — get that specific quote
|
|
1487
|
-
const quotes = await getAllQuotes(srcChain, dstChain,
|
|
1772
|
+
const quotes = await getAllQuotes(srcChain, dstChain, amount, senderAddress, recipientAddress);
|
|
1488
1773
|
const match = quotes.find(q => q.provider === provider);
|
|
1489
|
-
if (!match)
|
|
1490
|
-
throw new
|
|
1774
|
+
if (!match) {
|
|
1775
|
+
throw new PerpError("NO_BRIDGE_ROUTE", `Provider "${provider}" not available for ${srcChain} → ${dstChain} — nothing was sent`, {
|
|
1776
|
+
remediation: "Drop --provider to use the best available route (see: perp funds bridge quote)",
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1491
1779
|
quote = match;
|
|
1492
1780
|
}
|
|
1493
1781
|
else {
|
|
1494
1782
|
// Default: prefer deBridge DLN (fastest, ~2s), fallback to best available
|
|
1495
|
-
const quotes = await getAllQuotes(srcChain, dstChain,
|
|
1496
|
-
if (quotes.length === 0)
|
|
1497
|
-
throw new
|
|
1783
|
+
const quotes = await getAllQuotes(srcChain, dstChain, amount, senderAddress, recipientAddress);
|
|
1784
|
+
if (quotes.length === 0) {
|
|
1785
|
+
throw new PerpError("NO_BRIDGE_ROUTE", `No bridge available for ${srcChain} → ${dstChain} — nothing was sent`, {
|
|
1786
|
+
remediation: "Check supported routes: perp funds bridge chains",
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1498
1789
|
quote = quotes.find(q => q.provider === "debridge") ?? quotes[0];
|
|
1499
1790
|
}
|
|
1500
1791
|
if (quote.provider === "cctp") {
|
|
1501
|
-
return executeCctpBridge(srcChain, dstChain,
|
|
1792
|
+
return executeCctpBridge(network, srcChain, dstChain, amount, signerKey, recipientAddress, dstSignerKey, fast);
|
|
1502
1793
|
}
|
|
1503
1794
|
if (quote.provider === "relay") {
|
|
1504
|
-
return executeRelayBridge(quote, signerKey);
|
|
1795
|
+
return executeRelayBridge(network, quote, signerKey);
|
|
1505
1796
|
}
|
|
1506
|
-
return executeDebridgeBridge(quote, signerKey);
|
|
1797
|
+
return executeDebridgeBridge(network, quote, signerKey);
|
|
1507
1798
|
}
|
|
1508
1799
|
/**
|
|
1509
1800
|
* Check bridge order status via deBridge.
|
|
@@ -1516,23 +1807,20 @@ export async function checkDebridgeStatus(orderId) {
|
|
|
1516
1807
|
}
|
|
1517
1808
|
// ── Helpers ──
|
|
1518
1809
|
/**
|
|
1519
|
-
* Append deBridge
|
|
1520
|
-
*
|
|
1810
|
+
* Append deBridge referral / affiliate params when the user configured them in
|
|
1811
|
+
* settings `bridge.debridgeAffiliate` (`perp settings set bridge.debridgeAffiliate.*`).
|
|
1812
|
+
* Absent (the shipped state) → nothing is sent. No env var is read.
|
|
1521
1813
|
*/
|
|
1522
1814
|
function appendDebridgeBuilderParams(params) {
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
params.set("
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
const bytes = new Uint8Array(hex.length / 2);
|
|
1532
|
-
for (let i = 0; i < hex.length; i += 2) {
|
|
1533
|
-
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
|
1815
|
+
const aff = loadSettings().bridge.debridgeAffiliate;
|
|
1816
|
+
if (aff === null)
|
|
1817
|
+
return;
|
|
1818
|
+
if (aff.referralCode !== null)
|
|
1819
|
+
params.set("referralCode", aff.referralCode);
|
|
1820
|
+
if (aff.feePercent !== null && aff.feeRecipient !== null) {
|
|
1821
|
+
params.set("affiliateFeePercent", String(aff.feePercent));
|
|
1822
|
+
params.set("affiliateFeeRecipient", aff.feeRecipient);
|
|
1534
1823
|
}
|
|
1535
|
-
return bytes;
|
|
1536
1824
|
}
|
|
1537
1825
|
function sleep(ms) {
|
|
1538
1826
|
return new Promise((resolve) => setTimeout(resolve, ms));
|