perp-cli 0.3.3
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/LICENSE +21 -0
- package/README.md +293 -0
- package/dist/__tests__/alert-logic.test.d.ts +1 -0
- package/dist/__tests__/alert-logic.test.js +107 -0
- package/dist/__tests__/arb-auto-3dex.test.d.ts +1 -0
- package/dist/__tests__/arb-auto-3dex.test.js +397 -0
- package/dist/__tests__/arb-history-stats.test.d.ts +1 -0
- package/dist/__tests__/arb-history-stats.test.js +176 -0
- package/dist/__tests__/arb-logic.test.d.ts +1 -0
- package/dist/__tests__/arb-logic.test.js +84 -0
- package/dist/__tests__/arb-manage.test.d.ts +1 -0
- package/dist/__tests__/arb-manage.test.js +253 -0
- package/dist/__tests__/arb-new-features.test.d.ts +1 -0
- package/dist/__tests__/arb-new-features.test.js +457 -0
- package/dist/__tests__/arb-sizing.test.d.ts +1 -0
- package/dist/__tests__/arb-sizing.test.js +48 -0
- package/dist/__tests__/arb-state.test.d.ts +1 -0
- package/dist/__tests__/arb-state.test.js +284 -0
- package/dist/__tests__/arb-userflow.test.d.ts +1 -0
- package/dist/__tests__/arb-userflow.test.js +945 -0
- package/dist/__tests__/arb-utils.test.d.ts +1 -0
- package/dist/__tests__/arb-utils.test.js +264 -0
- package/dist/__tests__/bot-conditions.test.d.ts +1 -0
- package/dist/__tests__/bot-conditions.test.js +341 -0
- package/dist/__tests__/client-id-tracker.test.d.ts +1 -0
- package/dist/__tests__/client-id-tracker.test.js +137 -0
- package/dist/__tests__/commands/new-atomic-commands.test.d.ts +1 -0
- package/dist/__tests__/commands/new-atomic-commands.test.js +502 -0
- package/dist/__tests__/commands/order-intent.test.d.ts +1 -0
- package/dist/__tests__/commands/order-intent.test.js +600 -0
- package/dist/__tests__/commands/trade-commands.test.d.ts +1 -0
- package/dist/__tests__/commands/trade-commands.test.js +821 -0
- package/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +86 -0
- package/dist/__tests__/cross-chain-margin.test.d.ts +1 -0
- package/dist/__tests__/cross-chain-margin.test.js +287 -0
- package/dist/__tests__/dex-asset-map.test.d.ts +1 -0
- package/dist/__tests__/dex-asset-map.test.js +191 -0
- package/dist/__tests__/errors.test.d.ts +1 -0
- package/dist/__tests__/errors.test.js +110 -0
- package/dist/__tests__/event-stream.test.d.ts +1 -0
- package/dist/__tests__/event-stream.test.js +276 -0
- package/dist/__tests__/exchanges/interface.test.d.ts +1 -0
- package/dist/__tests__/exchanges/interface.test.js +132 -0
- package/dist/__tests__/exchanges/mock-adapter.d.ts +69 -0
- package/dist/__tests__/exchanges/mock-adapter.js +137 -0
- package/dist/__tests__/execution-log.test.d.ts +1 -0
- package/dist/__tests__/execution-log.test.js +106 -0
- package/dist/__tests__/funding-calc.test.d.ts +1 -0
- package/dist/__tests__/funding-calc.test.js +71 -0
- package/dist/__tests__/funding-history.test.d.ts +1 -0
- package/dist/__tests__/funding-history.test.js +343 -0
- package/dist/__tests__/funding-rates.test.d.ts +1 -0
- package/dist/__tests__/funding-rates.test.js +342 -0
- package/dist/__tests__/funding.test.d.ts +1 -0
- package/dist/__tests__/funding.test.js +173 -0
- package/dist/__tests__/gap-logic.test.d.ts +1 -0
- package/dist/__tests__/gap-logic.test.js +43 -0
- package/dist/__tests__/hip3-dex.test.d.ts +1 -0
- package/dist/__tests__/hip3-dex.test.js +234 -0
- package/dist/__tests__/integration/agent-features.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/agent-features.integration.test.js +553 -0
- package/dist/__tests__/integration/atomic-commands.integration.test.d.ts +13 -0
- package/dist/__tests__/integration/atomic-commands.integration.test.js +246 -0
- package/dist/__tests__/integration/bridge-simulation.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/bridge-simulation.integration.test.js +453 -0
- package/dist/__tests__/integration/bridge-strict.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/bridge-strict.integration.test.js +812 -0
- package/dist/__tests__/integration/bridge.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/bridge.integration.test.js +309 -0
- package/dist/__tests__/integration/cli-e2e.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/cli-e2e.integration.test.js +202 -0
- package/dist/__tests__/integration/dex-arb.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/dex-arb.integration.test.js +116 -0
- package/dist/__tests__/integration/envelope-consistency.integration.test.d.ts +13 -0
- package/dist/__tests__/integration/envelope-consistency.integration.test.js +205 -0
- package/dist/__tests__/integration/hip3-dex.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/hip3-dex.integration.test.js +147 -0
- package/dist/__tests__/integration/hyperliquid.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/hyperliquid.integration.test.js +79 -0
- package/dist/__tests__/integration/lighter.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/lighter.integration.test.js +53 -0
- package/dist/__tests__/integration/new-commands-e2e.integration.test.d.ts +9 -0
- package/dist/__tests__/integration/new-commands-e2e.integration.test.js +236 -0
- package/dist/__tests__/integration/order-verification.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/order-verification.integration.test.js +321 -0
- package/dist/__tests__/integration/pacifica.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/pacifica.integration.test.js +75 -0
- package/dist/__tests__/integration/response-shapes.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/response-shapes.integration.test.js +278 -0
- package/dist/__tests__/liquidity.test.d.ts +1 -0
- package/dist/__tests__/liquidity.test.js +225 -0
- package/dist/__tests__/plan-executor.test.d.ts +1 -0
- package/dist/__tests__/plan-executor.test.js +314 -0
- package/dist/__tests__/position-history.test.d.ts +1 -0
- package/dist/__tests__/position-history.test.js +367 -0
- package/dist/__tests__/retry.test.d.ts +1 -0
- package/dist/__tests__/retry.test.js +310 -0
- package/dist/__tests__/risk-assessment.test.d.ts +1 -0
- package/dist/__tests__/risk-assessment.test.js +145 -0
- package/dist/__tests__/security-adversarial.test.d.ts +1 -0
- package/dist/__tests__/security-adversarial.test.js +574 -0
- package/dist/__tests__/strategies.test.d.ts +1 -0
- package/dist/__tests__/strategies.test.js +539 -0
- package/dist/__tests__/trade-execution.test.d.ts +1 -0
- package/dist/__tests__/trade-execution.test.js +129 -0
- package/dist/__tests__/trade-validator.test.d.ts +1 -0
- package/dist/__tests__/trade-validator.test.js +655 -0
- package/dist/__tests__/utils.test.d.ts +1 -0
- package/dist/__tests__/utils.test.js +76 -0
- package/dist/api/public/hyperliquid.d.ts +18 -0
- package/dist/api/public/hyperliquid.js +82 -0
- package/dist/api/public/index.d.ts +8 -0
- package/dist/api/public/index.js +8 -0
- package/dist/api/public/lighter.d.ts +24 -0
- package/dist/api/public/lighter.js +100 -0
- package/dist/api/public/pacifica.d.ts +17 -0
- package/dist/api/public/pacifica.js +54 -0
- package/dist/api/public/urls.d.ts +12 -0
- package/dist/api/public/urls.js +33 -0
- package/dist/arb/history-stats.d.ts +44 -0
- package/dist/arb/history-stats.js +135 -0
- package/dist/arb/index.d.ts +4 -0
- package/dist/arb/index.js +4 -0
- package/dist/arb/sizing.d.ts +23 -0
- package/dist/arb/sizing.js +96 -0
- package/dist/arb/state.d.ts +51 -0
- package/dist/arb/state.js +112 -0
- package/dist/arb/utils.d.ts +81 -0
- package/dist/arb/utils.js +267 -0
- package/dist/arb-history-stats.d.ts +5 -0
- package/dist/arb-history-stats.js +5 -0
- package/dist/arb-sizing.d.ts +5 -0
- package/dist/arb-sizing.js +5 -0
- package/dist/arb-state.d.ts +5 -0
- package/dist/arb-state.js +5 -0
- package/dist/arb-utils.d.ts +5 -0
- package/dist/arb-utils.js +5 -0
- package/dist/bot/conditions.d.ts +32 -0
- package/dist/bot/conditions.js +141 -0
- package/dist/bot/config.d.ts +76 -0
- package/dist/bot/config.js +160 -0
- package/dist/bot/engine.d.ts +8 -0
- package/dist/bot/engine.js +519 -0
- package/dist/bot/presets.d.ts +11 -0
- package/dist/bot/presets.js +296 -0
- package/dist/bridge-engine.d.ts +133 -0
- package/dist/bridge-engine.js +1487 -0
- package/dist/cache.d.ts +25 -0
- package/dist/cache.js +99 -0
- package/dist/cli-spec.d.ts +50 -0
- package/dist/cli-spec.js +75 -0
- package/dist/client-id-tracker.d.ts +25 -0
- package/dist/client-id-tracker.js +76 -0
- package/dist/commands/account.d.ts +3 -0
- package/dist/commands/account.js +425 -0
- package/dist/commands/agent.d.ts +3 -0
- package/dist/commands/agent.js +386 -0
- package/dist/commands/alert.d.ts +2 -0
- package/dist/commands/alert.js +421 -0
- package/dist/commands/analytics.d.ts +3 -0
- package/dist/commands/analytics.js +311 -0
- package/dist/commands/arb/index.d.ts +3 -0
- package/dist/commands/arb/index.js +921 -0
- package/dist/commands/arb-auto.d.ts +54 -0
- package/dist/commands/arb-auto.js +1328 -0
- package/dist/commands/arb-manage.d.ts +5 -0
- package/dist/commands/arb-manage.js +5 -0
- package/dist/commands/arb.d.ts +2 -0
- package/dist/commands/arb.js +347 -0
- package/dist/commands/backtest.d.ts +2 -0
- package/dist/commands/backtest.js +327 -0
- package/dist/commands/bot.d.ts +3 -0
- package/dist/commands/bot.js +412 -0
- package/dist/commands/bridge.d.ts +2 -0
- package/dist/commands/bridge.js +396 -0
- package/dist/commands/dashboard.d.ts +3 -0
- package/dist/commands/dashboard.js +176 -0
- package/dist/commands/deposit.d.ts +4 -0
- package/dist/commands/deposit.js +573 -0
- package/dist/commands/dex.d.ts +3 -0
- package/dist/commands/dex.js +114 -0
- package/dist/commands/env.d.ts +2 -0
- package/dist/commands/env.js +136 -0
- package/dist/commands/funding.d.ts +2 -0
- package/dist/commands/funding.js +347 -0
- package/dist/commands/gap.d.ts +2 -0
- package/dist/commands/gap.js +305 -0
- package/dist/commands/health.d.ts +2 -0
- package/dist/commands/health.js +67 -0
- package/dist/commands/history.d.ts +2 -0
- package/dist/commands/history.js +235 -0
- package/dist/commands/init.d.ts +15 -0
- package/dist/commands/init.js +266 -0
- package/dist/commands/jobs.d.ts +2 -0
- package/dist/commands/jobs.js +133 -0
- package/dist/commands/manage.d.ts +4 -0
- package/dist/commands/manage.js +309 -0
- package/dist/commands/market.d.ts +3 -0
- package/dist/commands/market.js +225 -0
- package/dist/commands/plan.d.ts +3 -0
- package/dist/commands/plan.js +95 -0
- package/dist/commands/portfolio.d.ts +3 -0
- package/dist/commands/portfolio.js +169 -0
- package/dist/commands/rebalance.d.ts +3 -0
- package/dist/commands/rebalance.js +293 -0
- package/dist/commands/risk.d.ts +3 -0
- package/dist/commands/risk.js +169 -0
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.js +202 -0
- package/dist/commands/settings.d.ts +2 -0
- package/dist/commands/settings.js +102 -0
- package/dist/commands/stream.d.ts +5 -0
- package/dist/commands/stream.js +123 -0
- package/dist/commands/trade.d.ts +3 -0
- package/dist/commands/trade.js +1273 -0
- package/dist/commands/wallet.d.ts +14 -0
- package/dist/commands/wallet.js +602 -0
- package/dist/commands/withdraw.d.ts +3 -0
- package/dist/commands/withdraw.js +187 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +68 -0
- package/dist/cross-chain-margin.d.ts +46 -0
- package/dist/cross-chain-margin.js +107 -0
- package/dist/dashboard/server.d.ts +80 -0
- package/dist/dashboard/server.js +340 -0
- package/dist/dashboard/ui.d.ts +4 -0
- package/dist/dashboard/ui.js +538 -0
- package/dist/dashboard/ws-feeds.d.ts +29 -0
- package/dist/dashboard/ws-feeds.js +660 -0
- package/dist/dex-asset-map.d.ts +80 -0
- package/dist/dex-asset-map.js +201 -0
- package/dist/errors.d.ts +109 -0
- package/dist/errors.js +84 -0
- package/dist/event-stream.d.ts +25 -0
- package/dist/event-stream.js +168 -0
- package/dist/exchanges/hyperliquid.d.ts +212 -0
- package/dist/exchanges/hyperliquid.js +931 -0
- package/dist/exchanges/interface.d.ts +95 -0
- package/dist/exchanges/interface.js +5 -0
- package/dist/exchanges/lighter.d.ts +159 -0
- package/dist/exchanges/lighter.js +793 -0
- package/dist/exchanges/pacifica.d.ts +51 -0
- package/dist/exchanges/pacifica.js +248 -0
- package/dist/execution-log.d.ts +36 -0
- package/dist/execution-log.js +102 -0
- package/dist/funding/history.d.ts +63 -0
- package/dist/funding/history.js +266 -0
- package/dist/funding/index.d.ts +3 -0
- package/dist/funding/index.js +3 -0
- package/dist/funding/normalize.d.ts +39 -0
- package/dist/funding/normalize.js +66 -0
- package/dist/funding/rates.d.ts +45 -0
- package/dist/funding/rates.js +172 -0
- package/dist/funding-history.d.ts +5 -0
- package/dist/funding-history.js +5 -0
- package/dist/funding-rates.d.ts +5 -0
- package/dist/funding-rates.js +5 -0
- package/dist/funding.d.ts +5 -0
- package/dist/funding.js +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +458 -0
- package/dist/jobs.d.ts +37 -0
- package/dist/jobs.js +152 -0
- package/dist/liquidity.d.ts +34 -0
- package/dist/liquidity.js +100 -0
- package/dist/mcp-server.d.ts +9 -0
- package/dist/mcp-server.js +1206 -0
- package/dist/pacifica/client.d.ts +111 -0
- package/dist/pacifica/client.js +310 -0
- package/dist/pacifica/constants.d.ts +27 -0
- package/dist/pacifica/constants.js +47 -0
- package/dist/pacifica/deposit.d.ts +14 -0
- package/dist/pacifica/deposit.js +78 -0
- package/dist/pacifica/index.d.ts +6 -0
- package/dist/pacifica/index.js +11 -0
- package/dist/pacifica/signing.d.ts +49 -0
- package/dist/pacifica/signing.js +97 -0
- package/dist/pacifica/types/account.d.ts +42 -0
- package/dist/pacifica/types/account.js +1 -0
- package/dist/pacifica/types/index.d.ts +6 -0
- package/dist/pacifica/types/index.js +6 -0
- package/dist/pacifica/types/lake.d.ts +18 -0
- package/dist/pacifica/types/lake.js +1 -0
- package/dist/pacifica/types/market.d.ts +64 -0
- package/dist/pacifica/types/market.js +1 -0
- package/dist/pacifica/types/order.d.ts +92 -0
- package/dist/pacifica/types/order.js +1 -0
- package/dist/pacifica/types/position.d.ts +25 -0
- package/dist/pacifica/types/position.js +1 -0
- package/dist/pacifica/types/ws.d.ts +34 -0
- package/dist/pacifica/types/ws.js +41 -0
- package/dist/pacifica/ws-client.d.ts +42 -0
- package/dist/pacifica/ws-client.js +180 -0
- package/dist/plan-executor.d.ts +48 -0
- package/dist/plan-executor.js +280 -0
- package/dist/position-history.d.ts +68 -0
- package/dist/position-history.js +222 -0
- package/dist/rebalance.d.ts +64 -0
- package/dist/rebalance.js +142 -0
- package/dist/retry.d.ts +74 -0
- package/dist/retry.js +129 -0
- package/dist/risk.d.ts +48 -0
- package/dist/risk.js +156 -0
- package/dist/settings.d.ts +19 -0
- package/dist/settings.js +45 -0
- package/dist/shared-api.d.ts +5 -0
- package/dist/shared-api.js +5 -0
- package/dist/strategies/dca.d.ts +25 -0
- package/dist/strategies/dca.js +114 -0
- package/dist/strategies/funding-arb.d.ts +15 -0
- package/dist/strategies/funding-arb.js +281 -0
- package/dist/strategies/grid.d.ts +34 -0
- package/dist/strategies/grid.js +185 -0
- package/dist/strategies/trailing-stop.d.ts +17 -0
- package/dist/strategies/trailing-stop.js +121 -0
- package/dist/strategies/twap.d.ts +20 -0
- package/dist/strategies/twap.js +78 -0
- package/dist/trade-validator.d.ts +39 -0
- package/dist/trade-validator.js +154 -0
- package/dist/utils.d.ts +38 -0
- package/dist/utils.js +110 -0
- package/package.json +63 -0
- package/skills/perp-cli/SKILL.md +149 -0
- package/skills/perp-cli/references/commands.md +143 -0
|
@@ -0,0 +1,1487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified bridge engine: Circle CCTP V2 (primary, $0 fee) + Relay + deBridge DLN (fallback).
|
|
3
|
+
*
|
|
4
|
+
* CCTP V2 routes (all tested with real TX):
|
|
5
|
+
* Sol→EVM: Circle auto-relay (free)
|
|
6
|
+
* EVM→EVM: attestation poll + manual receiveMessage on dest chain
|
|
7
|
+
* EVM→Sol: attestation poll + Solana receiveMessage (ALT + 400k CU)
|
|
8
|
+
*
|
|
9
|
+
* Handles USDC bridging between Solana ↔ Arbitrum ↔ Base ↔ HyperCore
|
|
10
|
+
* for cross-exchange rebalancing in funding arb.
|
|
11
|
+
*/
|
|
12
|
+
// ── Chain constants ──
|
|
13
|
+
export const CHAIN_IDS = {
|
|
14
|
+
solana: 7565164,
|
|
15
|
+
arbitrum: 42161,
|
|
16
|
+
base: 8453,
|
|
17
|
+
};
|
|
18
|
+
export const USDC_ADDRESSES = {
|
|
19
|
+
solana: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
|
|
20
|
+
arbitrum: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
|
21
|
+
base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
22
|
+
};
|
|
23
|
+
// Exchange → chain mapping
|
|
24
|
+
export const EXCHANGE_TO_CHAIN = {
|
|
25
|
+
pacifica: "solana",
|
|
26
|
+
hyperliquid: "hyperliquid", // HyperCore CCTP: direct deposit to perps via CctpForwarder (domain 19)
|
|
27
|
+
lighter: "arbitrum",
|
|
28
|
+
};
|
|
29
|
+
// Chain → RPC URLs
|
|
30
|
+
const RPC_URLS = {
|
|
31
|
+
solana: "https://api.mainnet-beta.solana.com",
|
|
32
|
+
arbitrum: "https://arb1.arbitrum.io/rpc",
|
|
33
|
+
base: "https://mainnet.base.org",
|
|
34
|
+
hyperliquid: "https://rpc.hyperliquid.xyz/evm",
|
|
35
|
+
};
|
|
36
|
+
// ── CCTP V2 Forwarding Service ──
|
|
37
|
+
// Circle Forwarding Service: Circle handles dst chain mint for you.
|
|
38
|
+
// Use depositForBurnWithHook + forward hook data → no manual receiveMessage needed.
|
|
39
|
+
// Service fee: $0.20 (non-Ethereum), $1.25 (Ethereum).
|
|
40
|
+
// Note: Forwarding NOT supported when dst=Solana (EVM→Solana must use manual relay).
|
|
41
|
+
const CCTP_FEE_API = "https://iris-api.circle.com/v2/burn/USDC/fees";
|
|
42
|
+
// Static forward hook data: magic bytes ("cctp-forward") + version(0) + empty data length(0)
|
|
43
|
+
const CCTP_FORWARD_HOOK_DATA = "0x636374702d666f72776172640000000000000000000000000000000000000000";
|
|
44
|
+
/**
|
|
45
|
+
* Get CCTP V2 fee for a route.
|
|
46
|
+
*
|
|
47
|
+
* Fee structure (from Circle docs):
|
|
48
|
+
* - minimumFee: in basis points (e.g. 1.3 = 0.013%). Protocol fee = amount × bps / 10000.
|
|
49
|
+
* - Standard (finality=2000): minimumFee=0 → FREE protocol fee.
|
|
50
|
+
* - Fast (finality=1000): minimumFee=0-14 bps → e.g. $0.13 per $1000 at 1.3 bps.
|
|
51
|
+
* - forwardFee: in USDC subunits (6 decimals). Covers dst gas + Circle service (~$0.20).
|
|
52
|
+
*
|
|
53
|
+
* @param amountUsdc - Transfer amount in USDC (needed to calculate bps-based fee).
|
|
54
|
+
* Returns maxFee in USDC subunits (6 decimals).
|
|
55
|
+
*/
|
|
56
|
+
async function getCctpRelayFee(srcDomain, dstDomain, finalityThreshold = 2000, useForwarding = false, amountUsdc = 100) {
|
|
57
|
+
try {
|
|
58
|
+
const qs = useForwarding ? "?forward=true" : "";
|
|
59
|
+
const res = await fetch(`${CCTP_FEE_API}/${srcDomain}/${dstDomain}${qs}`);
|
|
60
|
+
if (res.ok) {
|
|
61
|
+
const body = await res.json();
|
|
62
|
+
// Check for forwarding error response
|
|
63
|
+
if (body && typeof body === "object" && "error" in body) {
|
|
64
|
+
if (useForwarding)
|
|
65
|
+
return getCctpRelayFee(srcDomain, dstDomain, finalityThreshold, false, amountUsdc);
|
|
66
|
+
}
|
|
67
|
+
const schedules = body;
|
|
68
|
+
const schedule = schedules.find(s => s.finalityThreshold === finalityThreshold) ?? schedules[0];
|
|
69
|
+
if (schedule) {
|
|
70
|
+
// Protocol fee: minimumFee is in basis points → actual fee = amount × bps / 10000
|
|
71
|
+
const amountSubunits = BigInt(Math.round(amountUsdc * 1e6));
|
|
72
|
+
const bpsRounded = BigInt(Math.round(schedule.minimumFee * 100));
|
|
73
|
+
const protocolFee = (amountSubunits * bpsRounded) / 1000000n;
|
|
74
|
+
// Add 20% buffer per Circle docs recommendation
|
|
75
|
+
const protocolFeeBuffered = (protocolFee * 120n) / 100n;
|
|
76
|
+
if (schedule.forwardFee) {
|
|
77
|
+
// Forwarding: protocol fee + forwarding service fee
|
|
78
|
+
const forwardFeeSubunits = BigInt(schedule.forwardFee.high);
|
|
79
|
+
const totalMaxFee = protocolFeeBuffered + forwardFeeSubunits;
|
|
80
|
+
const totalFeeUsdc = Number(totalMaxFee) / 1e6;
|
|
81
|
+
return { maxFee: totalMaxFee, feeUsdc: totalFeeUsdc, forwardingAvailable: true };
|
|
82
|
+
}
|
|
83
|
+
// No forwarding — protocol fee only (or minimal for standard to incentivize relay)
|
|
84
|
+
if (protocolFeeBuffered > 0n) {
|
|
85
|
+
return { maxFee: protocolFeeBuffered, feeUsdc: Number(protocolFeeBuffered) / 1e6, forwardingAvailable: false };
|
|
86
|
+
}
|
|
87
|
+
// Standard (free): set small maxFee to incentivize relay
|
|
88
|
+
return { maxFee: 10000n, feeUsdc: 0.01, forwardingAvailable: false }; // $0.01
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// fallback
|
|
94
|
+
}
|
|
95
|
+
const fallback = finalityThreshold === 1000 ? 0.50 : 0.25;
|
|
96
|
+
return { maxFee: BigInt(Math.round(fallback * 1e6)), feeUsdc: fallback, forwardingAvailable: useForwarding };
|
|
97
|
+
}
|
|
98
|
+
// ── Balance Check ──
|
|
99
|
+
/**
|
|
100
|
+
* Check USDC balance on an EVM chain.
|
|
101
|
+
* Returns balance in USDC (human-readable).
|
|
102
|
+
*/
|
|
103
|
+
export async function getEvmUsdcBalance(chain, address) {
|
|
104
|
+
const { ethers } = await import("ethers");
|
|
105
|
+
const rpc = RPC_URLS[chain];
|
|
106
|
+
const usdcAddr = USDC_ADDRESSES[chain];
|
|
107
|
+
if (!rpc || !usdcAddr)
|
|
108
|
+
throw new Error(`No RPC or USDC address for ${chain}`);
|
|
109
|
+
const provider = new ethers.JsonRpcProvider(rpc);
|
|
110
|
+
const usdc = new ethers.Contract(usdcAddr, [
|
|
111
|
+
"function balanceOf(address) view returns (uint256)",
|
|
112
|
+
], provider);
|
|
113
|
+
const balance = await usdc.balanceOf(address);
|
|
114
|
+
return Number(balance) / 1e6;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Check USDC balance on Solana.
|
|
118
|
+
* Returns balance in USDC (human-readable).
|
|
119
|
+
*/
|
|
120
|
+
export async function getSolanaUsdcBalance(ownerPubkey) {
|
|
121
|
+
const { Connection, PublicKey } = await import("@solana/web3.js");
|
|
122
|
+
const connection = new Connection(RPC_URLS.solana, "confirmed");
|
|
123
|
+
const owner = new PublicKey(ownerPubkey);
|
|
124
|
+
const usdcMint = new PublicKey(USDC_ADDRESSES.solana);
|
|
125
|
+
const tokenProgram = new PublicKey(SPL_TOKEN_PROGRAM);
|
|
126
|
+
const [ata] = PublicKey.findProgramAddressSync([owner.toBuffer(), tokenProgram.toBuffer(), usdcMint.toBuffer()], new PublicKey(ASSOCIATED_TOKEN_PROGRAM));
|
|
127
|
+
try {
|
|
128
|
+
const info = await connection.getTokenAccountBalance(ata);
|
|
129
|
+
return Number(info.value.uiAmount ?? 0);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return 0; // ATA doesn't exist
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Check USDC balance for a bridge source chain + address.
|
|
137
|
+
*/
|
|
138
|
+
export async function checkBridgeBalance(srcChain, senderAddress, requiredAmount) {
|
|
139
|
+
const balance = srcChain === "solana"
|
|
140
|
+
? await getSolanaUsdcBalance(senderAddress)
|
|
141
|
+
: await getEvmUsdcBalance(srcChain, senderAddress);
|
|
142
|
+
return { balance, sufficient: balance >= requiredAmount };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Get native gas token balance (ETH for EVM, SOL for Solana).
|
|
146
|
+
* Returns balance in human-readable units.
|
|
147
|
+
*/
|
|
148
|
+
export async function getNativeGasBalance(chain, address) {
|
|
149
|
+
if (chain === "solana") {
|
|
150
|
+
const { Connection, PublicKey, LAMPORTS_PER_SOL } = await import("@solana/web3.js");
|
|
151
|
+
const connection = new Connection(RPC_URLS.solana, "confirmed");
|
|
152
|
+
const balance = await connection.getBalance(new PublicKey(address));
|
|
153
|
+
return balance / LAMPORTS_PER_SOL;
|
|
154
|
+
}
|
|
155
|
+
const { ethers } = await import("ethers");
|
|
156
|
+
const rpc = RPC_URLS[chain];
|
|
157
|
+
if (!rpc)
|
|
158
|
+
throw new Error(`No RPC for ${chain}`);
|
|
159
|
+
const provider = new ethers.JsonRpcProvider(rpc);
|
|
160
|
+
const balance = await provider.getBalance(address);
|
|
161
|
+
return Number(ethers.formatEther(balance));
|
|
162
|
+
}
|
|
163
|
+
// Minimum gas thresholds for bridge transactions
|
|
164
|
+
const MIN_GAS = {
|
|
165
|
+
solana: { amount: 0.01, symbol: "SOL" },
|
|
166
|
+
arbitrum: { amount: 0.0001, symbol: "ETH" },
|
|
167
|
+
base: { amount: 0.0001, symbol: "ETH" },
|
|
168
|
+
hyperliquid: { amount: 0.0001, symbol: "ETH" },
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Check gas balance on source (and optionally destination) chains before bridging.
|
|
172
|
+
* Returns errors for any chain with insufficient gas.
|
|
173
|
+
*/
|
|
174
|
+
export async function checkBridgeGasBalance(srcChain, srcAddress, dstChain, dstAddress, needsDstGas) {
|
|
175
|
+
const errors = [];
|
|
176
|
+
// HyperCore → EVM uses HL exchange API (no on-chain gas needed on src)
|
|
177
|
+
if (srcChain !== "hyperliquid") {
|
|
178
|
+
const srcMin = MIN_GAS[srcChain];
|
|
179
|
+
if (srcMin) {
|
|
180
|
+
const srcGas = await getNativeGasBalance(srcChain, srcAddress);
|
|
181
|
+
if (srcGas < srcMin.amount) {
|
|
182
|
+
errors.push(`Source ${srcChain}: ${srcGas.toFixed(6)} ${srcMin.symbol} (need ≥${srcMin.amount} ${srcMin.symbol})`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (needsDstGas) {
|
|
187
|
+
const dstMin = MIN_GAS[dstChain];
|
|
188
|
+
if (dstMin) {
|
|
189
|
+
const dstGas = await getNativeGasBalance(dstChain, dstAddress);
|
|
190
|
+
if (dstGas < dstMin.amount) {
|
|
191
|
+
errors.push(`Destination ${dstChain}: ${dstGas.toFixed(6)} ${dstMin.symbol} (need ≥${dstMin.amount} ${dstMin.symbol})`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { ok: errors.length === 0, errors };
|
|
196
|
+
}
|
|
197
|
+
// ── HyperCore CCTP (Solana/EVM → Hyperliquid perps via CctpForwarder) ──
|
|
198
|
+
const HYPERCORE_CCTP_DOMAIN = 19; // HyperEVM domain in CCTP
|
|
199
|
+
const HYPERCORE_CCTP_FORWARDER = "0xb21D281DEdb17AE5B501F6AA8256fe38C4e45757";
|
|
200
|
+
const HYPERCORE_FEES_API = "https://iris-api.circle.com/v2/burn/USDC/fees";
|
|
201
|
+
const HYPERCORE_DEX_PERPS = 0;
|
|
202
|
+
// const HYPERCORE_DEX_SPOT = 4294967295;
|
|
203
|
+
/**
|
|
204
|
+
* Encode hook data for CctpForwarder → HyperCore deposit.
|
|
205
|
+
* Format: magic(24) + version(4) + dataLength(4) + [address(20) + dexId(4)]
|
|
206
|
+
*/
|
|
207
|
+
function encodeHyperCoreHookData(recipientAddress, dexId = HYPERCORE_DEX_PERPS) {
|
|
208
|
+
const magic = Buffer.from("cctp-forward", "utf-8").toString("hex").padEnd(48, "0");
|
|
209
|
+
const version = "00000000";
|
|
210
|
+
if (!recipientAddress) {
|
|
211
|
+
return `0x${magic}${version}00000000`;
|
|
212
|
+
}
|
|
213
|
+
const addr = recipientAddress.replace("0x", "").toLowerCase();
|
|
214
|
+
const dex = (dexId >>> 0).toString(16).padStart(8, "0");
|
|
215
|
+
return `0x${magic}${version}00000018${addr}${dex}`;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Get CCTP fees for a Solana/EVM → HyperCore transfer.
|
|
219
|
+
*/
|
|
220
|
+
async function getHyperCoreCctpFees(srcDomain, amount) {
|
|
221
|
+
const url = `${HYPERCORE_FEES_API}/${srcDomain}/${HYPERCORE_CCTP_DOMAIN}?forward=true&hyperCoreDeposit=true`;
|
|
222
|
+
try {
|
|
223
|
+
const res = await fetch(url);
|
|
224
|
+
if (res.ok) {
|
|
225
|
+
// API returns array: [{finalityThreshold, minimumFee, forwardFee: {low, med, high}}, ...]
|
|
226
|
+
const schedules = await res.json();
|
|
227
|
+
// Use fast transfer schedule (threshold=1000) if available, else first
|
|
228
|
+
const fast = schedules.find(s => s.finalityThreshold === 1000) ?? schedules[0];
|
|
229
|
+
if (fast) {
|
|
230
|
+
const bps = Number(fast.feeRate ?? 1); // basis points, default 1bp
|
|
231
|
+
const forwardFee = Number(fast.forwardFee?.med ?? fast.forwardFee?.low ?? 200000); // subunits
|
|
232
|
+
const protocolFee = Math.ceil(amount * 1e6 * bps / 10000);
|
|
233
|
+
return {
|
|
234
|
+
protocolFee: protocolFee / 1e6,
|
|
235
|
+
forwardingFee: forwardFee / 1e6,
|
|
236
|
+
totalFee: (protocolFee + forwardFee) / 1e6,
|
|
237
|
+
maxFee: protocolFee + forwardFee, // in subunits for instruction
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// fallback to defaults
|
|
244
|
+
}
|
|
245
|
+
// Default: 1bp + $0.20
|
|
246
|
+
const protocolFee = Math.ceil(amount * 1e6 / 10000);
|
|
247
|
+
const forwardingFee = 200000;
|
|
248
|
+
return {
|
|
249
|
+
protocolFee: protocolFee / 1e6,
|
|
250
|
+
forwardingFee: 0.20,
|
|
251
|
+
totalFee: (protocolFee + forwardingFee) / 1e6,
|
|
252
|
+
maxFee: protocolFee + forwardingFee,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// ── deBridge DLN API ──
|
|
256
|
+
const DLN_API = "https://dln.debridge.finance/v1.0/dln/order";
|
|
257
|
+
// Builder/affiliate config — set via env vars or defaults
|
|
258
|
+
const DEBRIDGE_REFERRAL_CODE = process.env.DEBRIDGE_REFERRAL_CODE ?? "";
|
|
259
|
+
const DEBRIDGE_AFFILIATE_FEE_PERCENT = process.env.DEBRIDGE_AFFILIATE_FEE_PERCENT ?? ""; // e.g. "0.1" for 0.1%
|
|
260
|
+
const DEBRIDGE_AFFILIATE_FEE_RECIPIENT = process.env.DEBRIDGE_AFFILIATE_FEE_RECIPIENT ?? "";
|
|
261
|
+
/**
|
|
262
|
+
* Get a bridge quote via deBridge DLN.
|
|
263
|
+
*/
|
|
264
|
+
export async function getDebridgeQuote(srcChain, dstChain, amountUsdc, senderAddress, recipientAddress) {
|
|
265
|
+
const srcChainId = CHAIN_IDS[srcChain];
|
|
266
|
+
const dstChainId = CHAIN_IDS[dstChain];
|
|
267
|
+
if (!srcChainId || !dstChainId)
|
|
268
|
+
throw new Error(`Unsupported chain: ${srcChain} or ${dstChain}`);
|
|
269
|
+
const srcToken = USDC_ADDRESSES[srcChain];
|
|
270
|
+
const dstToken = USDC_ADDRESSES[dstChain];
|
|
271
|
+
if (!srcToken || !dstToken)
|
|
272
|
+
throw new Error(`No USDC address for ${srcChain} or ${dstChain}`);
|
|
273
|
+
const amountRaw = String(Math.round(amountUsdc * 1e6));
|
|
274
|
+
// Step 1: Get quote
|
|
275
|
+
const quoteParams = new URLSearchParams({
|
|
276
|
+
srcChainId: String(srcChainId),
|
|
277
|
+
srcChainTokenIn: srcToken,
|
|
278
|
+
srcChainTokenInAmount: amountRaw,
|
|
279
|
+
dstChainId: String(dstChainId),
|
|
280
|
+
dstChainTokenOut: dstToken,
|
|
281
|
+
prependOperatingExpenses: "true",
|
|
282
|
+
});
|
|
283
|
+
appendDebridgeBuilderParams(quoteParams);
|
|
284
|
+
const quoteRes = await fetch(`${DLN_API}/quote?${quoteParams}`);
|
|
285
|
+
if (!quoteRes.ok)
|
|
286
|
+
throw new Error(`deBridge quote failed: ${quoteRes.status} ${await quoteRes.text()}`);
|
|
287
|
+
const quote = await quoteRes.json();
|
|
288
|
+
const estimation = quote.estimation;
|
|
289
|
+
const dstOut = estimation?.dstChainTokenOut;
|
|
290
|
+
const amountOut = Number(dstOut?.recommendedAmount ?? dstOut?.amount ?? 0) / 1e6;
|
|
291
|
+
const fulfillDelay = Number(quote.order?.approximateFulfillmentDelay ?? 10);
|
|
292
|
+
return {
|
|
293
|
+
provider: "debridge",
|
|
294
|
+
srcChain,
|
|
295
|
+
dstChain,
|
|
296
|
+
amountIn: amountUsdc,
|
|
297
|
+
amountOut,
|
|
298
|
+
fee: amountUsdc - amountOut,
|
|
299
|
+
estimatedTime: fulfillDelay,
|
|
300
|
+
gasIncluded: true,
|
|
301
|
+
gasNote: "DLN market maker fulfills on destination",
|
|
302
|
+
raw: { quote, senderAddress, recipientAddress, amountRaw },
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Execute a deBridge bridge transaction.
|
|
307
|
+
* Returns the TX hash after signing and submitting.
|
|
308
|
+
*/
|
|
309
|
+
export async function executeDebridgeBridge(bridgeQuote, signerKey) {
|
|
310
|
+
const { srcChain, dstChain, amountIn, amountOut } = bridgeQuote;
|
|
311
|
+
const { senderAddress, recipientAddress, amountRaw } = bridgeQuote.raw;
|
|
312
|
+
const srcChainId = CHAIN_IDS[srcChain];
|
|
313
|
+
const dstChainId = CHAIN_IDS[dstChain];
|
|
314
|
+
const srcToken = USDC_ADDRESSES[srcChain];
|
|
315
|
+
const dstToken = USDC_ADDRESSES[dstChain];
|
|
316
|
+
// Get recommended amount from quote
|
|
317
|
+
const quote = bridgeQuote.raw.quote;
|
|
318
|
+
const estimation = quote.estimation;
|
|
319
|
+
const dstOut = estimation?.dstChainTokenOut;
|
|
320
|
+
const dstAmount = String(dstOut?.recommendedAmount ?? dstOut?.amount ?? "0");
|
|
321
|
+
// Step 2: Create TX
|
|
322
|
+
const createParams = new URLSearchParams({
|
|
323
|
+
srcChainId: String(srcChainId),
|
|
324
|
+
srcChainTokenIn: srcToken,
|
|
325
|
+
srcChainTokenInAmount: amountRaw,
|
|
326
|
+
dstChainId: String(dstChainId),
|
|
327
|
+
dstChainTokenOut: dstToken,
|
|
328
|
+
dstChainTokenOutAmount: dstAmount,
|
|
329
|
+
dstChainTokenOutRecipient: recipientAddress,
|
|
330
|
+
srcChainOrderAuthorityAddress: senderAddress,
|
|
331
|
+
dstChainOrderAuthorityAddress: recipientAddress,
|
|
332
|
+
prependOperatingExpenses: "true",
|
|
333
|
+
});
|
|
334
|
+
appendDebridgeBuilderParams(createParams);
|
|
335
|
+
const createRes = await fetch(`${DLN_API}/create-tx?${createParams}`);
|
|
336
|
+
if (!createRes.ok)
|
|
337
|
+
throw new Error(`deBridge create-tx failed: ${createRes.status} ${await createRes.text()}`);
|
|
338
|
+
const createTx = await createRes.json();
|
|
339
|
+
const tx = createTx.tx;
|
|
340
|
+
if (!tx)
|
|
341
|
+
throw new Error("deBridge returned no transaction data");
|
|
342
|
+
// Step 3: Sign and submit
|
|
343
|
+
let txHash;
|
|
344
|
+
if (srcChain === "solana") {
|
|
345
|
+
txHash = await submitSolanaTransaction(tx, signerKey);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
txHash = await submitEvmTransaction(tx, signerKey, srcChain);
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
provider: "debridge",
|
|
352
|
+
txHash,
|
|
353
|
+
srcChain,
|
|
354
|
+
dstChain,
|
|
355
|
+
amountIn,
|
|
356
|
+
amountOut,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Submit a Solana transaction from deBridge create-tx response.
|
|
361
|
+
*/
|
|
362
|
+
async function submitSolanaTransaction(tx, signerKey) {
|
|
363
|
+
const { Connection, VersionedTransaction, Keypair } = await import("@solana/web3.js");
|
|
364
|
+
const bs58 = await import("bs58");
|
|
365
|
+
const connection = new Connection(RPC_URLS.solana, "confirmed");
|
|
366
|
+
// Decode the keypair
|
|
367
|
+
let keypair;
|
|
368
|
+
try {
|
|
369
|
+
// Try base58 first (standard Solana private key format)
|
|
370
|
+
keypair = Keypair.fromSecretKey(bs58.default.decode(signerKey));
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// Try JSON array (Solana CLI format)
|
|
374
|
+
try {
|
|
375
|
+
keypair = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(signerKey)));
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
throw new Error("Invalid Solana private key format");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// deBridge returns hex-encoded VersionedTransaction for Solana
|
|
382
|
+
const txData = String(tx.data);
|
|
383
|
+
const txBytes = hexToBytes(txData.startsWith("0x") ? txData.slice(2) : txData);
|
|
384
|
+
const transaction = VersionedTransaction.deserialize(txBytes);
|
|
385
|
+
// Update blockhash and sign
|
|
386
|
+
const { blockhash } = await connection.getLatestBlockhash();
|
|
387
|
+
transaction.message.recentBlockhash = blockhash;
|
|
388
|
+
transaction.sign([keypair]);
|
|
389
|
+
const signature = await connection.sendTransaction(transaction, { skipPreflight: false });
|
|
390
|
+
await connection.confirmTransaction(signature, "confirmed");
|
|
391
|
+
return signature;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Submit an EVM transaction from deBridge create-tx response.
|
|
395
|
+
*/
|
|
396
|
+
async function submitEvmTransaction(tx, privateKey, chain) {
|
|
397
|
+
const { ethers } = await import("ethers");
|
|
398
|
+
const rpc = RPC_URLS[chain] ?? RPC_URLS.arbitrum;
|
|
399
|
+
const provider = new ethers.JsonRpcProvider(rpc);
|
|
400
|
+
const wallet = new ethers.Wallet(privateKey, provider);
|
|
401
|
+
// Check and approve USDC allowance if needed
|
|
402
|
+
const usdcAddr = USDC_ADDRESSES[chain];
|
|
403
|
+
const spenderAddr = String(tx.to);
|
|
404
|
+
const value = BigInt(String(tx.value ?? "0"));
|
|
405
|
+
if (usdcAddr && spenderAddr) {
|
|
406
|
+
const usdc = new ethers.Contract(usdcAddr, [
|
|
407
|
+
"function allowance(address,address) view returns (uint256)",
|
|
408
|
+
"function approve(address,uint256) returns (bool)",
|
|
409
|
+
], wallet);
|
|
410
|
+
const allowance = await usdc.allowance(wallet.address, spenderAddr);
|
|
411
|
+
// Approve max if allowance insufficient
|
|
412
|
+
if (allowance < ethers.parseUnits("1000000", 6)) {
|
|
413
|
+
const approveTx = await usdc.approve(spenderAddr, ethers.MaxUint256);
|
|
414
|
+
await approveTx.wait();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Send the bridge transaction
|
|
418
|
+
const txResponse = await wallet.sendTransaction({
|
|
419
|
+
to: String(tx.to),
|
|
420
|
+
data: String(tx.data),
|
|
421
|
+
value,
|
|
422
|
+
});
|
|
423
|
+
const receipt = await txResponse.wait();
|
|
424
|
+
return receipt.hash;
|
|
425
|
+
}
|
|
426
|
+
// ── Circle CCTP V2 (EVM + Solana) ──
|
|
427
|
+
export const CCTP_DOMAINS = {
|
|
428
|
+
arbitrum: 3,
|
|
429
|
+
solana: 5,
|
|
430
|
+
base: 6,
|
|
431
|
+
hyperliquid: 19,
|
|
432
|
+
};
|
|
433
|
+
// EVM V2 contracts — ALL EVM chains use the same V2 proxy addresses (per Circle docs)
|
|
434
|
+
const EVM_TOKEN_MESSENGER_V2 = "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d";
|
|
435
|
+
const EVM_MESSAGE_TRANSMITTER_V2 = "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64";
|
|
436
|
+
export const EVM_TOKEN_MINTER_V2 = "0xfd78EE919681417d192449715b2594ab58f5D002";
|
|
437
|
+
// Populate per-chain lookups (all EVM chains share identical V2 addresses)
|
|
438
|
+
const EVM_CCTP_CHAINS = Object.keys(CCTP_DOMAINS).filter(c => c !== "solana");
|
|
439
|
+
const CCTP_TOKEN_MESSENGER = Object.fromEntries(EVM_CCTP_CHAINS.map(c => [c, EVM_TOKEN_MESSENGER_V2]));
|
|
440
|
+
const CCTP_MESSAGE_TRANSMITTER = Object.fromEntries(EVM_CCTP_CHAINS.map(c => [c, EVM_MESSAGE_TRANSMITTER_V2]));
|
|
441
|
+
// Solana CCTP V2 programs (mainnet)
|
|
442
|
+
const CCTP_SOLANA_TOKEN_MESSENGER_MINTER = "CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe";
|
|
443
|
+
const CCTP_SOLANA_MESSAGE_TRANSMITTER = "CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC";
|
|
444
|
+
// Solana constants
|
|
445
|
+
const USDC_MINT_SOLANA = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
446
|
+
const SPL_TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
|
447
|
+
const ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
|
|
448
|
+
const SYSTEM_PROGRAM = "11111111111111111111111111111111";
|
|
449
|
+
/**
|
|
450
|
+
* Check if CCTP is supported for a given chain.
|
|
451
|
+
*/
|
|
452
|
+
function isCctpSupported(chain) {
|
|
453
|
+
return chain in CCTP_DOMAINS;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Get a CCTP V2 bridge quote. Supports EVM ↔ EVM, Solana ↔ EVM, and → HyperCore.
|
|
457
|
+
*
|
|
458
|
+
* Uses Circle Forwarding Service when available (all routes except →Solana):
|
|
459
|
+
* - Circle handles dst chain mint automatically, no manual receiveMessage needed.
|
|
460
|
+
* - Service fee ~$0.20 (included in maxFee).
|
|
461
|
+
*
|
|
462
|
+
* @param fast - If true, use fast finality (1000): ~1-2 min, $1-1.3 + $0.20 forwarding.
|
|
463
|
+
* If false (default), use standard finality (2000): ~2-5 min, ~$0.20 forwarding.
|
|
464
|
+
*/
|
|
465
|
+
export async function getCctpQuote(srcChain, dstChain, amountUsdc, fast = false) {
|
|
466
|
+
if (!isCctpSupported(srcChain))
|
|
467
|
+
throw new Error(`CCTP not supported on ${srcChain}`);
|
|
468
|
+
if (!isCctpSupported(dstChain))
|
|
469
|
+
throw new Error(`CCTP not supported on ${dstChain}`);
|
|
470
|
+
// HyperCore → EVM: HL withdrawal + CCTP forwarding (~0.20 USDC fee)
|
|
471
|
+
if (srcChain === "hyperliquid") {
|
|
472
|
+
const forwardingFee = 0.20; // CCTP forwarding fee for Arbitrum
|
|
473
|
+
return {
|
|
474
|
+
provider: "cctp",
|
|
475
|
+
srcChain,
|
|
476
|
+
dstChain,
|
|
477
|
+
amountIn: amountUsdc,
|
|
478
|
+
amountOut: amountUsdc - forwardingFee,
|
|
479
|
+
fee: forwardingFee,
|
|
480
|
+
estimatedTime: 60, // ~1 min with fast finality
|
|
481
|
+
gasIncluded: true,
|
|
482
|
+
gasNote: "HyperCore handles forwarding",
|
|
483
|
+
raw: { type: "cctp-hypercore-withdraw", fast },
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const isHyperCore = dstChain === "hyperliquid";
|
|
487
|
+
// HyperCore route has protocol fee + forwarding fee
|
|
488
|
+
if (isHyperCore) {
|
|
489
|
+
const srcDomain = CCTP_DOMAINS[srcChain];
|
|
490
|
+
if (srcDomain === undefined)
|
|
491
|
+
throw new Error(`No CCTP domain for ${srcChain}`);
|
|
492
|
+
const fees = await getHyperCoreCctpFees(srcDomain, amountUsdc);
|
|
493
|
+
const estimatedTime = srcChain === "solana" ? 65 : 60;
|
|
494
|
+
return {
|
|
495
|
+
provider: "cctp",
|
|
496
|
+
srcChain,
|
|
497
|
+
dstChain,
|
|
498
|
+
amountIn: amountUsdc,
|
|
499
|
+
amountOut: amountUsdc - fees.totalFee,
|
|
500
|
+
fee: fees.totalFee,
|
|
501
|
+
estimatedTime,
|
|
502
|
+
gasIncluded: true,
|
|
503
|
+
gasNote: "CctpForwarder auto-deposits to HyperCore",
|
|
504
|
+
raw: { type: "cctp-hypercore", maxFee: fees.maxFee, fast },
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
// Standard CCTP V2
|
|
508
|
+
const finality = fast ? 1000 : 2000;
|
|
509
|
+
const srcDomain = CCTP_DOMAINS[srcChain];
|
|
510
|
+
const dstDomain = CCTP_DOMAINS[dstChain];
|
|
511
|
+
// Use Forwarding Service when dst is NOT Solana (Solana dst doesn't support forwarding)
|
|
512
|
+
const canForward = dstChain !== "solana";
|
|
513
|
+
const { maxFee, feeUsdc, forwardingAvailable } = await getCctpRelayFee(srcDomain, dstDomain, finality, canForward, amountUsdc);
|
|
514
|
+
const estimatedTime = fast
|
|
515
|
+
? ((srcChain === "solana" || dstChain === "solana") ? 90 : 60)
|
|
516
|
+
: (forwardingAvailable
|
|
517
|
+
? ((srcChain === "solana" || dstChain === "solana") ? 120 : 90) // forwarding: Circle handles dst
|
|
518
|
+
: ((srcChain === "solana" || dstChain === "solana") ? 180 : 900) // manual relay
|
|
519
|
+
);
|
|
520
|
+
return {
|
|
521
|
+
provider: "cctp",
|
|
522
|
+
srcChain,
|
|
523
|
+
dstChain,
|
|
524
|
+
amountIn: amountUsdc,
|
|
525
|
+
amountOut: amountUsdc - feeUsdc,
|
|
526
|
+
fee: feeUsdc,
|
|
527
|
+
estimatedTime,
|
|
528
|
+
gasIncluded: fast || forwardingAvailable, // forwarding = Circle handles dst mint
|
|
529
|
+
gasNote: forwardingAvailable
|
|
530
|
+
? `Forwarding Service (maxFee $${feeUsdc.toFixed(2)}, Circle handles dst mint)`
|
|
531
|
+
: fast
|
|
532
|
+
? `Fast auto-relay (maxFee $${feeUsdc.toFixed(2)}, ~1-2 min)`
|
|
533
|
+
: `Standard relay → Solana (maxFee $${feeUsdc.toFixed(2)}, manual receiveMessage)`,
|
|
534
|
+
raw: { type: "cctp", maxFee: Number(maxFee), fast, forwarding: forwardingAvailable },
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Execute a CCTP V2 bridge. Routes to the appropriate implementation.
|
|
539
|
+
*
|
|
540
|
+
* @param fast - If true, use fast finality (1000): Circle auto-relays, no manual receiveMessage.
|
|
541
|
+
* If false (default), use standard finality (2000): cheaper but requires manual relay.
|
|
542
|
+
*/
|
|
543
|
+
export async function executeCctpBridge(srcChain, dstChain, amountUsdc, signerKey, recipientAddress, dstSignerKey, // EVM key for receiveMessage (Solana→EVM) or Solana key for receiveMessage (EVM→Solana)
|
|
544
|
+
fast = false) {
|
|
545
|
+
// HyperCore route: depositForBurnWithHook via CctpForwarder
|
|
546
|
+
if (dstChain === "hyperliquid") {
|
|
547
|
+
if (srcChain === "solana") {
|
|
548
|
+
return executeCctpSolanaToHyperCore(amountUsdc, signerKey, recipientAddress);
|
|
549
|
+
}
|
|
550
|
+
return executeCctpEvmToHyperCore(srcChain, amountUsdc, signerKey, recipientAddress);
|
|
551
|
+
}
|
|
552
|
+
// HyperCore → EVM: sendToEvmWithData via HL exchange API
|
|
553
|
+
if (srcChain === "hyperliquid") {
|
|
554
|
+
return executeCctpHyperCoreToEvm(dstChain, amountUsdc, signerKey, recipientAddress);
|
|
555
|
+
}
|
|
556
|
+
if (srcChain === "solana") {
|
|
557
|
+
return executeCctpSolanaToEvm(dstChain, amountUsdc, signerKey, recipientAddress, dstSignerKey, fast);
|
|
558
|
+
}
|
|
559
|
+
if (dstChain === "solana") {
|
|
560
|
+
return executeCctpEvmToSolana(srcChain, amountUsdc, signerKey, recipientAddress, dstSignerKey, fast);
|
|
561
|
+
}
|
|
562
|
+
return executeCctpEvmToEvm(srcChain, dstChain, amountUsdc, signerKey, recipientAddress, fast);
|
|
563
|
+
}
|
|
564
|
+
// ── CCTP: Solana → HyperCore (depositForBurnWithHook) ──
|
|
565
|
+
async function executeCctpSolanaToHyperCore(amountUsdc, signerKey, recipientAddress) {
|
|
566
|
+
const { Connection, PublicKey, Keypair, TransactionMessage, VersionedTransaction, SystemProgram } = await import("@solana/web3.js");
|
|
567
|
+
const bs58 = await import("bs58");
|
|
568
|
+
const { createHash } = await import("crypto");
|
|
569
|
+
const connection = new Connection(RPC_URLS.solana, "confirmed");
|
|
570
|
+
// Decode Solana keypair
|
|
571
|
+
let keypair;
|
|
572
|
+
try {
|
|
573
|
+
keypair = Keypair.fromSecretKey(bs58.default.decode(signerKey));
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
try {
|
|
577
|
+
keypair = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(signerKey)));
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
throw new Error("Invalid Solana private key format");
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const tokenMessengerMinter = new PublicKey(CCTP_SOLANA_TOKEN_MESSENGER_MINTER);
|
|
584
|
+
const messageTransmitterProgram = new PublicKey(CCTP_SOLANA_MESSAGE_TRANSMITTER);
|
|
585
|
+
const usdcMint = new PublicKey(USDC_MINT_SOLANA);
|
|
586
|
+
const tokenProgram = new PublicKey(SPL_TOKEN_PROGRAM);
|
|
587
|
+
// Calculate fees
|
|
588
|
+
const fees = await getHyperCoreCctpFees(CCTP_DOMAINS.solana, amountUsdc);
|
|
589
|
+
// Derive PDAs — TMM = TokenMessengerMinter, MT = MessageTransmitter
|
|
590
|
+
const [senderAuthority] = PublicKey.findProgramAddressSync([Buffer.from("sender_authority")], tokenMessengerMinter);
|
|
591
|
+
const [tokenMessenger] = PublicKey.findProgramAddressSync([Buffer.from("token_messenger")], tokenMessengerMinter);
|
|
592
|
+
// Circle uses domain as UTF-8 string seed (e.g., "19"), NOT binary buffer
|
|
593
|
+
const [remoteTokenMessenger] = PublicKey.findProgramAddressSync([Buffer.from("remote_token_messenger"), Buffer.from(String(HYPERCORE_CCTP_DOMAIN))], tokenMessengerMinter);
|
|
594
|
+
const [tokenMinter] = PublicKey.findProgramAddressSync([Buffer.from("token_minter")], tokenMessengerMinter);
|
|
595
|
+
const [localToken] = PublicKey.findProgramAddressSync([Buffer.from("local_token"), usdcMint.toBuffer()], tokenMessengerMinter);
|
|
596
|
+
// MessageTransmitter state PDA (owned by MT program)
|
|
597
|
+
const [messageTransmitterAccount] = PublicKey.findProgramAddressSync([Buffer.from("message_transmitter")], messageTransmitterProgram);
|
|
598
|
+
// Denylist account PDA: ["denylist_account", owner] from TMM — may not exist (not denylisted)
|
|
599
|
+
const [denylistAccount] = PublicKey.findProgramAddressSync([Buffer.from("denylist_account"), keypair.publicKey.toBuffer()], tokenMessengerMinter);
|
|
600
|
+
const [burnTokenAccount] = PublicKey.findProgramAddressSync([keypair.publicKey.toBuffer(), tokenProgram.toBuffer(), usdcMint.toBuffer()], new PublicKey(ASSOCIATED_TOKEN_PROGRAM));
|
|
601
|
+
const eventDataKeypair = Keypair.generate();
|
|
602
|
+
// Event authority PDAs — TMM and MT each have their own (for Anchor CPI events)
|
|
603
|
+
const [eventAuthority] = PublicKey.findProgramAddressSync([Buffer.from("__event_authority")], tokenMessengerMinter);
|
|
604
|
+
const [mtEventAuthority] = PublicKey.findProgramAddressSync([Buffer.from("__event_authority")], messageTransmitterProgram);
|
|
605
|
+
// Build instruction data for depositForBurnWithHook
|
|
606
|
+
// Anchor discriminator: sha256("global:deposit_for_burn_with_hook")[0..8]
|
|
607
|
+
const discriminator = createHash("sha256").update("global:deposit_for_burn_with_hook").digest().subarray(0, 8);
|
|
608
|
+
const amountBuf = Buffer.alloc(8);
|
|
609
|
+
amountBuf.writeBigUInt64LE(BigInt(Math.round(amountUsdc * 1e6)));
|
|
610
|
+
// Domain as u32 LE for instruction data (different from PDA seed which uses string)
|
|
611
|
+
const domainBuf = Buffer.alloc(4);
|
|
612
|
+
domainBuf.writeUInt32LE(HYPERCORE_CCTP_DOMAIN);
|
|
613
|
+
// mintRecipient: CctpForwarder address (left-padded to 32 bytes)
|
|
614
|
+
const mintRecipient = Buffer.alloc(32);
|
|
615
|
+
Buffer.from(HYPERCORE_CCTP_FORWARDER.replace("0x", ""), "hex").copy(mintRecipient, 12);
|
|
616
|
+
// destinationCaller: same as CctpForwarder (required by docs)
|
|
617
|
+
const destinationCaller = Buffer.alloc(32);
|
|
618
|
+
Buffer.from(HYPERCORE_CCTP_FORWARDER.replace("0x", ""), "hex").copy(destinationCaller, 12);
|
|
619
|
+
// maxFee
|
|
620
|
+
const maxFeeBuf = Buffer.alloc(8);
|
|
621
|
+
maxFeeBuf.writeBigUInt64LE(BigInt(fees.maxFee));
|
|
622
|
+
// minFinalityThreshold: 1000 (fast transfer)
|
|
623
|
+
const minFinalityBuf = Buffer.alloc(4);
|
|
624
|
+
minFinalityBuf.writeUInt32LE(1000);
|
|
625
|
+
// hookData: encode for HyperCore perps deposit
|
|
626
|
+
const hookDataHex = encodeHyperCoreHookData(recipientAddress, HYPERCORE_DEX_PERPS);
|
|
627
|
+
const hookDataBytes = Buffer.from(hookDataHex.replace("0x", ""), "hex");
|
|
628
|
+
// Borsh-style: 4-byte length prefix + data
|
|
629
|
+
const hookLenBuf = Buffer.alloc(4);
|
|
630
|
+
hookLenBuf.writeUInt32LE(hookDataBytes.length);
|
|
631
|
+
const data = Buffer.concat([
|
|
632
|
+
discriminator, amountBuf, domainBuf, mintRecipient,
|
|
633
|
+
destinationCaller, maxFeeBuf, minFinalityBuf,
|
|
634
|
+
hookLenBuf, hookDataBytes,
|
|
635
|
+
]);
|
|
636
|
+
const instruction = {
|
|
637
|
+
programId: tokenMessengerMinter,
|
|
638
|
+
keys: [
|
|
639
|
+
{ pubkey: keypair.publicKey, isSigner: true, isWritable: true }, // 0: owner
|
|
640
|
+
{ pubkey: keypair.publicKey, isSigner: true, isWritable: true }, // 1: eventRentPayer
|
|
641
|
+
{ pubkey: senderAuthority, isSigner: false, isWritable: false }, // 2: senderAuthorityPda
|
|
642
|
+
{ pubkey: burnTokenAccount, isSigner: false, isWritable: true }, // 3: burnTokenAccount
|
|
643
|
+
{ pubkey: denylistAccount, isSigner: false, isWritable: false }, // 4: denylistAccount
|
|
644
|
+
{ pubkey: messageTransmitterAccount, isSigner: false, isWritable: true }, // 5: messageTransmitter (MT state)
|
|
645
|
+
{ pubkey: tokenMessenger, isSigner: false, isWritable: false }, // 6: tokenMessenger
|
|
646
|
+
{ pubkey: remoteTokenMessenger, isSigner: false, isWritable: false }, // 7: remoteTokenMessenger
|
|
647
|
+
{ pubkey: tokenMinter, isSigner: false, isWritable: false }, // 8: tokenMinter
|
|
648
|
+
{ pubkey: localToken, isSigner: false, isWritable: true }, // 9: localToken
|
|
649
|
+
{ pubkey: usdcMint, isSigner: false, isWritable: true }, // 10: burnTokenMint
|
|
650
|
+
{ pubkey: eventDataKeypair.publicKey, isSigner: true, isWritable: true }, // 11: messageSentEventData
|
|
651
|
+
{ pubkey: messageTransmitterProgram, isSigner: false, isWritable: false }, // 12: messageTransmitterProgram
|
|
652
|
+
{ pubkey: tokenMessengerMinter, isSigner: false, isWritable: false }, // 13: tokenMessengerMinterProgram
|
|
653
|
+
{ pubkey: tokenProgram, isSigner: false, isWritable: false }, // 14: tokenProgram
|
|
654
|
+
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, // 15: systemProgram
|
|
655
|
+
{ pubkey: eventAuthority, isSigner: false, isWritable: false }, // 16: TMM eventAuthority
|
|
656
|
+
{ pubkey: tokenMessengerMinter, isSigner: false, isWritable: false }, // 17: TMM program (self)
|
|
657
|
+
{ pubkey: mtEventAuthority, isSigner: false, isWritable: false }, // 18: MT eventAuthority
|
|
658
|
+
{ pubkey: messageTransmitterProgram, isSigner: false, isWritable: false }, // 19: MT program
|
|
659
|
+
],
|
|
660
|
+
data,
|
|
661
|
+
};
|
|
662
|
+
const { blockhash } = await connection.getLatestBlockhash();
|
|
663
|
+
const messageV0 = new TransactionMessage({
|
|
664
|
+
payerKey: keypair.publicKey,
|
|
665
|
+
recentBlockhash: blockhash,
|
|
666
|
+
instructions: [instruction],
|
|
667
|
+
}).compileToV0Message();
|
|
668
|
+
const transaction = new VersionedTransaction(messageV0);
|
|
669
|
+
transaction.sign([keypair, eventDataKeypair]);
|
|
670
|
+
const signature = await connection.sendTransaction(transaction, { skipPreflight: false });
|
|
671
|
+
await connection.confirmTransaction(signature, "confirmed");
|
|
672
|
+
return {
|
|
673
|
+
provider: "cctp (HyperCore)",
|
|
674
|
+
txHash: signature,
|
|
675
|
+
srcChain: "solana",
|
|
676
|
+
dstChain: "hyperliquid",
|
|
677
|
+
amountIn: amountUsdc,
|
|
678
|
+
amountOut: amountUsdc - fees.totalFee,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
// ── CCTP: EVM → HyperCore (CctpExtension.batchDepositForBurnWithAuth) ──
|
|
682
|
+
// Uses EIP-3009 ReceiveWithAuthorization — no separate approve TX needed.
|
|
683
|
+
// CctpExtension contract: 0xA95d9c1F655341597C94393fDdc30cf3c08E4fcE (Arbitrum mainnet)
|
|
684
|
+
const CCTP_EXTENSION = {
|
|
685
|
+
arbitrum: "0xA95d9c1F655341597C94393fDdc30cf3c08E4fcE",
|
|
686
|
+
};
|
|
687
|
+
// USDC EIP-712 domain names per chain (for ReceiveWithAuthorization)
|
|
688
|
+
const USDC_EIP712_NAME = {
|
|
689
|
+
arbitrum: "USD Coin",
|
|
690
|
+
base: "USD Coin",
|
|
691
|
+
};
|
|
692
|
+
const USDC_EIP712_VERSION = {
|
|
693
|
+
arbitrum: "2",
|
|
694
|
+
base: "2",
|
|
695
|
+
};
|
|
696
|
+
// ── CCTP: HyperCore → EVM (sendToEvmWithData via HL exchange API) ──
|
|
697
|
+
async function executeCctpHyperCoreToEvm(dstChain, amountUsdc, signerKey, recipientAddress) {
|
|
698
|
+
const { ethers, Signature: EthSig } = await import("ethers");
|
|
699
|
+
const wallet = new ethers.Wallet(signerKey);
|
|
700
|
+
const dstDomain = CCTP_DOMAINS[dstChain];
|
|
701
|
+
if (dstDomain === undefined)
|
|
702
|
+
throw new Error(`No CCTP domain for ${dstChain}`);
|
|
703
|
+
const dstChainId = CHAIN_IDS[dstChain];
|
|
704
|
+
if (!dstChainId)
|
|
705
|
+
throw new Error(`No chain ID for ${dstChain}`);
|
|
706
|
+
const signatureChainId = "0x" + dstChainId.toString(16);
|
|
707
|
+
const timestamp = Date.now();
|
|
708
|
+
// Build action payload
|
|
709
|
+
const action = {
|
|
710
|
+
type: "sendToEvmWithData",
|
|
711
|
+
hyperliquidChain: "Mainnet",
|
|
712
|
+
signatureChainId,
|
|
713
|
+
token: "USDC",
|
|
714
|
+
amount: String(amountUsdc),
|
|
715
|
+
sourceDex: "spot", // unified accounts hold funds in spot
|
|
716
|
+
destinationRecipient: recipientAddress,
|
|
717
|
+
addressEncoding: "hex",
|
|
718
|
+
destinationChainId: dstDomain,
|
|
719
|
+
gasLimit: 200000,
|
|
720
|
+
data: "0x", // empty = automatic forwarding
|
|
721
|
+
nonce: timestamp,
|
|
722
|
+
};
|
|
723
|
+
// EIP-712 signing
|
|
724
|
+
const domain = {
|
|
725
|
+
name: "HyperliquidSignTransaction",
|
|
726
|
+
version: "1",
|
|
727
|
+
chainId: dstChainId,
|
|
728
|
+
verifyingContract: "0x0000000000000000000000000000000000000000",
|
|
729
|
+
};
|
|
730
|
+
const types = {
|
|
731
|
+
"HyperliquidTransaction:SendToEvmWithData": [
|
|
732
|
+
{ name: "hyperliquidChain", type: "string" },
|
|
733
|
+
{ name: "token", type: "string" },
|
|
734
|
+
{ name: "amount", type: "string" },
|
|
735
|
+
{ name: "sourceDex", type: "string" },
|
|
736
|
+
{ name: "destinationRecipient", type: "string" },
|
|
737
|
+
{ name: "addressEncoding", type: "string" },
|
|
738
|
+
{ name: "destinationChainId", type: "uint32" },
|
|
739
|
+
{ name: "gasLimit", type: "uint64" },
|
|
740
|
+
{ name: "data", type: "bytes" },
|
|
741
|
+
{ name: "nonce", type: "uint64" },
|
|
742
|
+
],
|
|
743
|
+
};
|
|
744
|
+
const message = {
|
|
745
|
+
hyperliquidChain: "Mainnet",
|
|
746
|
+
token: "USDC",
|
|
747
|
+
amount: String(amountUsdc),
|
|
748
|
+
sourceDex: "spot",
|
|
749
|
+
destinationRecipient: recipientAddress,
|
|
750
|
+
addressEncoding: "hex",
|
|
751
|
+
destinationChainId: dstDomain,
|
|
752
|
+
gasLimit: BigInt(200000),
|
|
753
|
+
data: "0x",
|
|
754
|
+
nonce: BigInt(timestamp),
|
|
755
|
+
};
|
|
756
|
+
const sigHex = await wallet.signTypedData(domain, types, message);
|
|
757
|
+
const sig = EthSig.from(sigHex);
|
|
758
|
+
// Submit to HL exchange API
|
|
759
|
+
const resp = await fetch("https://api.hyperliquid.xyz/exchange", {
|
|
760
|
+
method: "POST",
|
|
761
|
+
headers: { "Content-Type": "application/json" },
|
|
762
|
+
body: JSON.stringify({
|
|
763
|
+
action,
|
|
764
|
+
nonce: timestamp,
|
|
765
|
+
signature: { r: sig.r, s: sig.s, v: sig.v },
|
|
766
|
+
}),
|
|
767
|
+
});
|
|
768
|
+
const result = await resp.json();
|
|
769
|
+
if (result.status !== "ok") {
|
|
770
|
+
throw new Error(`HyperCore withdrawal failed: ${JSON.stringify(result)}`);
|
|
771
|
+
}
|
|
772
|
+
return {
|
|
773
|
+
provider: "cctp (HyperCore → EVM via sendToEvmWithData)",
|
|
774
|
+
txHash: typeof result.response === "string" ? result.response
|
|
775
|
+
: result.response?.data?.hash ?? `hl-withdrawal-${timestamp}`,
|
|
776
|
+
srcChain: "hyperliquid",
|
|
777
|
+
dstChain,
|
|
778
|
+
amountIn: amountUsdc,
|
|
779
|
+
amountOut: amountUsdc, // forwarding fee deducted on-chain
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
// ── CCTP: EVM → HyperCore ──
|
|
783
|
+
// Arbitrum: CctpExtension.batchDepositForBurnWithAuth (1-TX, no approve)
|
|
784
|
+
// Other EVM chains: approve + TokenMessengerV2.depositForBurnWithHook (2-TX fallback)
|
|
785
|
+
async function executeCctpEvmToHyperCore(srcChain, amountUsdc, signerKey, recipientAddress) {
|
|
786
|
+
const { ethers } = await import("ethers");
|
|
787
|
+
const srcRpc = RPC_URLS[srcChain] ?? RPC_URLS.arbitrum;
|
|
788
|
+
const srcProvider = new ethers.JsonRpcProvider(srcRpc);
|
|
789
|
+
const srcWallet = new ethers.Wallet(signerKey, srcProvider);
|
|
790
|
+
const usdcAddr = USDC_ADDRESSES[srcChain];
|
|
791
|
+
if (!usdcAddr)
|
|
792
|
+
throw new Error(`No USDC address for ${srcChain}`);
|
|
793
|
+
const srcDomain = CCTP_DOMAINS[srcChain];
|
|
794
|
+
if (srcDomain === undefined)
|
|
795
|
+
throw new Error(`No CCTP domain for ${srcChain}`);
|
|
796
|
+
const chainId = CHAIN_IDS[srcChain];
|
|
797
|
+
if (!chainId)
|
|
798
|
+
throw new Error(`No chain ID for ${srcChain}`);
|
|
799
|
+
const fees = await getHyperCoreCctpFees(srcDomain, amountUsdc);
|
|
800
|
+
const amountRaw = BigInt(Math.round(amountUsdc * 1e6));
|
|
801
|
+
// mintRecipient & destinationCaller = CctpForwarder (bytes32)
|
|
802
|
+
const forwarderBytes32 = ethers.zeroPadValue(HYPERCORE_CCTP_FORWARDER, 32);
|
|
803
|
+
const hookData = encodeHyperCoreHookData(recipientAddress, HYPERCORE_DEX_PERPS);
|
|
804
|
+
const cctpExtensionAddr = CCTP_EXTENSION[srcChain];
|
|
805
|
+
let depositTx;
|
|
806
|
+
let providerLabel;
|
|
807
|
+
if (cctpExtensionAddr) {
|
|
808
|
+
// ── Path A: CctpExtension (Arbitrum) — single TX, no approve ──
|
|
809
|
+
const { randomBytes } = await import("crypto");
|
|
810
|
+
const authNonce = "0x" + randomBytes(32).toString("hex");
|
|
811
|
+
const validAfter = 0;
|
|
812
|
+
const validBefore = Math.floor(Date.now() / 1000) + 3600;
|
|
813
|
+
const usdcDomain = {
|
|
814
|
+
name: USDC_EIP712_NAME[srcChain] ?? "USD Coin",
|
|
815
|
+
version: USDC_EIP712_VERSION[srcChain] ?? "2",
|
|
816
|
+
chainId,
|
|
817
|
+
verifyingContract: usdcAddr,
|
|
818
|
+
};
|
|
819
|
+
const receiveAuthTypes = {
|
|
820
|
+
ReceiveWithAuthorization: [
|
|
821
|
+
{ name: "from", type: "address" },
|
|
822
|
+
{ name: "to", type: "address" },
|
|
823
|
+
{ name: "value", type: "uint256" },
|
|
824
|
+
{ name: "validAfter", type: "uint256" },
|
|
825
|
+
{ name: "validBefore", type: "uint256" },
|
|
826
|
+
{ name: "nonce", type: "bytes32" },
|
|
827
|
+
],
|
|
828
|
+
};
|
|
829
|
+
const receiveAuthValue = {
|
|
830
|
+
from: srcWallet.address,
|
|
831
|
+
to: cctpExtensionAddr,
|
|
832
|
+
value: amountRaw,
|
|
833
|
+
validAfter,
|
|
834
|
+
validBefore,
|
|
835
|
+
nonce: authNonce,
|
|
836
|
+
};
|
|
837
|
+
const sig = await srcWallet.signTypedData(usdcDomain, receiveAuthTypes, receiveAuthValue);
|
|
838
|
+
const { v, r, s } = ethers.Signature.from(sig);
|
|
839
|
+
const authParams = [amountRaw, validAfter, validBefore, authNonce, v, r, s];
|
|
840
|
+
const burnParams = [amountRaw, HYPERCORE_CCTP_DOMAIN, forwarderBytes32, forwarderBytes32, BigInt(fees.maxFee), 1000, hookData];
|
|
841
|
+
const cctpExtension = new ethers.Contract(cctpExtensionAddr, [
|
|
842
|
+
"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)",
|
|
843
|
+
], srcWallet);
|
|
844
|
+
depositTx = await cctpExtension.batchDepositForBurnWithAuth(authParams, burnParams);
|
|
845
|
+
providerLabel = "cctp (HyperCore via CctpExtension)";
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
// ── Path B: approve + depositForBurnWithHook (Base, other EVM chains) ──
|
|
849
|
+
const tokenMessengerAddr = CCTP_TOKEN_MESSENGER[srcChain];
|
|
850
|
+
if (!tokenMessengerAddr)
|
|
851
|
+
throw new Error(`CCTP not configured for ${srcChain}`);
|
|
852
|
+
const usdc = new ethers.Contract(usdcAddr, [
|
|
853
|
+
"function allowance(address,address) view returns (uint256)",
|
|
854
|
+
"function approve(address,uint256) returns (bool)",
|
|
855
|
+
], srcWallet);
|
|
856
|
+
const allowance = await usdc.allowance(srcWallet.address, tokenMessengerAddr);
|
|
857
|
+
if (allowance < amountRaw) {
|
|
858
|
+
const approveTx = await usdc.approve(tokenMessengerAddr, ethers.MaxUint256);
|
|
859
|
+
await approveTx.wait();
|
|
860
|
+
}
|
|
861
|
+
const tokenMessenger = new ethers.Contract(tokenMessengerAddr, [
|
|
862
|
+
"function depositForBurnWithHook(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold, bytes hookData) returns (uint64 nonce)",
|
|
863
|
+
], srcWallet);
|
|
864
|
+
depositTx = await tokenMessenger.depositForBurnWithHook(amountRaw, HYPERCORE_CCTP_DOMAIN, forwarderBytes32, usdcAddr, forwarderBytes32, BigInt(fees.maxFee), 1000, hookData);
|
|
865
|
+
providerLabel = "cctp (HyperCore via depositForBurnWithHook)";
|
|
866
|
+
}
|
|
867
|
+
const receipt = await depositTx.wait();
|
|
868
|
+
return {
|
|
869
|
+
provider: providerLabel,
|
|
870
|
+
txHash: receipt.hash,
|
|
871
|
+
srcChain,
|
|
872
|
+
dstChain: "hyperliquid",
|
|
873
|
+
amountIn: amountUsdc,
|
|
874
|
+
amountOut: amountUsdc - fees.totalFee,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
// ── CCTP: EVM → EVM ──
|
|
878
|
+
async function executeCctpEvmToEvm(srcChain, dstChain, amountUsdc, signerKey, recipientAddress, fast = false) {
|
|
879
|
+
const { ethers } = await import("ethers");
|
|
880
|
+
const srcRpc = RPC_URLS[srcChain] ?? RPC_URLS.arbitrum;
|
|
881
|
+
const srcProvider = new ethers.JsonRpcProvider(srcRpc);
|
|
882
|
+
const srcWallet = new ethers.Wallet(signerKey, srcProvider);
|
|
883
|
+
const usdcAddr = USDC_ADDRESSES[srcChain];
|
|
884
|
+
const tokenMessengerAddr = CCTP_TOKEN_MESSENGER[srcChain];
|
|
885
|
+
const dstDomain = CCTP_DOMAINS[dstChain];
|
|
886
|
+
const srcDomain = CCTP_DOMAINS[srcChain];
|
|
887
|
+
if (!usdcAddr || !tokenMessengerAddr || dstDomain === undefined || srcDomain === undefined) {
|
|
888
|
+
throw new Error(`CCTP not configured for ${srcChain} → ${dstChain}`);
|
|
889
|
+
}
|
|
890
|
+
// Step 0: Check USDC balance
|
|
891
|
+
const balance = await getEvmUsdcBalance(srcChain, srcWallet.address);
|
|
892
|
+
if (balance < amountUsdc) {
|
|
893
|
+
throw new Error(`Insufficient USDC on ${srcChain}: have ${balance.toFixed(2)}, need ${amountUsdc}`);
|
|
894
|
+
}
|
|
895
|
+
const amountRaw = BigInt(Math.round(amountUsdc * 1e6));
|
|
896
|
+
// Step 1: Approve USDC
|
|
897
|
+
const usdc = new ethers.Contract(usdcAddr, [
|
|
898
|
+
"function allowance(address,address) view returns (uint256)",
|
|
899
|
+
"function approve(address,uint256) returns (bool)",
|
|
900
|
+
], srcWallet);
|
|
901
|
+
const allowance = await usdc.allowance(srcWallet.address, tokenMessengerAddr);
|
|
902
|
+
if (allowance < amountRaw) {
|
|
903
|
+
const approveTx = await usdc.approve(tokenMessengerAddr, ethers.MaxUint256);
|
|
904
|
+
await approveTx.wait();
|
|
905
|
+
}
|
|
906
|
+
// Step 2: Get fee (with forwarding — Circle handles dst mint)
|
|
907
|
+
const finality = fast ? 1000 : 2000;
|
|
908
|
+
const { maxFee } = await getCctpRelayFee(srcDomain, dstDomain, finality, true, amountUsdc);
|
|
909
|
+
// Step 3: depositForBurnWithHook + Forwarding Service
|
|
910
|
+
// Circle Forwarding: include forward hook data → Circle broadcasts receiveMessage on dst.
|
|
911
|
+
// No manual relay needed. No dst chain gas needed.
|
|
912
|
+
const mintRecipient = ethers.zeroPadValue(recipientAddress, 32);
|
|
913
|
+
const destinationCaller = ethers.ZeroHash; // must be zero for forwarding
|
|
914
|
+
const tokenMessenger = new ethers.Contract(tokenMessengerAddr, [
|
|
915
|
+
"function depositForBurnWithHook(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold, bytes hookData) returns (uint64 nonce)",
|
|
916
|
+
], srcWallet);
|
|
917
|
+
const depositTx = await tokenMessenger.depositForBurnWithHook(amountRaw, dstDomain, mintRecipient, usdcAddr, destinationCaller, maxFee, finality, CCTP_FORWARD_HOOK_DATA);
|
|
918
|
+
const receipt = await depositTx.wait();
|
|
919
|
+
const txHash = receipt.hash;
|
|
920
|
+
const feeUsdc = Number(maxFee) / 1e6;
|
|
921
|
+
const providerLabel = fast ? "cctp (fast+forward)" : "cctp (forward)";
|
|
922
|
+
return { provider: providerLabel, txHash, srcChain, dstChain, amountIn: amountUsdc, amountOut: amountUsdc - feeUsdc };
|
|
923
|
+
}
|
|
924
|
+
// ── CCTP: Solana → EVM ──
|
|
925
|
+
async function executeCctpSolanaToEvm(dstChain, amountUsdc, signerKey, recipientAddress, _dstSignerKey, // EVM key for manual receiveMessage (standard finality only)
|
|
926
|
+
fast = false) {
|
|
927
|
+
const { Connection, PublicKey, Keypair, TransactionMessage, VersionedTransaction, SystemProgram } = await import("@solana/web3.js");
|
|
928
|
+
const bs58 = await import("bs58");
|
|
929
|
+
const { createHash } = await import("crypto");
|
|
930
|
+
const connection = new Connection(RPC_URLS.solana, "confirmed");
|
|
931
|
+
const dstDomain = CCTP_DOMAINS[dstChain];
|
|
932
|
+
if (dstDomain === undefined)
|
|
933
|
+
throw new Error(`Unknown CCTP domain for ${dstChain}`);
|
|
934
|
+
// Decode Solana keypair
|
|
935
|
+
let keypair;
|
|
936
|
+
try {
|
|
937
|
+
keypair = Keypair.fromSecretKey(bs58.default.decode(signerKey));
|
|
938
|
+
}
|
|
939
|
+
catch {
|
|
940
|
+
try {
|
|
941
|
+
keypair = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(signerKey)));
|
|
942
|
+
}
|
|
943
|
+
catch {
|
|
944
|
+
throw new Error("Invalid Solana private key format");
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
// Check USDC balance
|
|
948
|
+
const balance = await getSolanaUsdcBalance(keypair.publicKey.toBase58());
|
|
949
|
+
if (balance < amountUsdc) {
|
|
950
|
+
throw new Error(`Insufficient USDC on Solana: have ${balance.toFixed(2)}, need ${amountUsdc}`);
|
|
951
|
+
}
|
|
952
|
+
const tokenMessengerMinter = new PublicKey(CCTP_SOLANA_TOKEN_MESSENGER_MINTER);
|
|
953
|
+
const messageTransmitterProgram = new PublicKey(CCTP_SOLANA_MESSAGE_TRANSMITTER);
|
|
954
|
+
const usdcMint = new PublicKey(USDC_MINT_SOLANA);
|
|
955
|
+
const tokenProgram = new PublicKey(SPL_TOKEN_PROGRAM);
|
|
956
|
+
// Derive PDAs — Circle uses domain as UTF-8 string seed (e.g., "0", "3"), NOT binary
|
|
957
|
+
const [senderAuthority] = PublicKey.findProgramAddressSync([Buffer.from("sender_authority")], tokenMessengerMinter);
|
|
958
|
+
const [tokenMessenger] = PublicKey.findProgramAddressSync([Buffer.from("token_messenger")], tokenMessengerMinter);
|
|
959
|
+
const [remoteTokenMessenger] = PublicKey.findProgramAddressSync([Buffer.from("remote_token_messenger"), Buffer.from(String(dstDomain))], tokenMessengerMinter);
|
|
960
|
+
const [tokenMinter] = PublicKey.findProgramAddressSync([Buffer.from("token_minter")], tokenMessengerMinter);
|
|
961
|
+
const [localToken] = PublicKey.findProgramAddressSync([Buffer.from("local_token"), usdcMint.toBuffer()], tokenMessengerMinter);
|
|
962
|
+
// MessageTransmitter state PDA (owned by MT program)
|
|
963
|
+
const [messageTransmitterAccount] = PublicKey.findProgramAddressSync([Buffer.from("message_transmitter")], messageTransmitterProgram);
|
|
964
|
+
// Denylist account PDA: ["denylist_account", owner] from TMM — may not exist (not denylisted)
|
|
965
|
+
const [denylistAccount] = PublicKey.findProgramAddressSync([Buffer.from("denylist_account"), keypair.publicKey.toBuffer()], tokenMessengerMinter);
|
|
966
|
+
// Owner's USDC ATA (computed without @solana/spl-token)
|
|
967
|
+
const [burnTokenAccount] = PublicKey.findProgramAddressSync([keypair.publicKey.toBuffer(), tokenProgram.toBuffer(), usdcMint.toBuffer()], new PublicKey(ASSOCIATED_TOKEN_PROGRAM));
|
|
968
|
+
// Event data account — client-generated keypair, assigned to MessageTransmitter
|
|
969
|
+
const eventDataKeypair = Keypair.generate();
|
|
970
|
+
// Event authority PDAs — TMM and MT each have their own (for Anchor CPI events)
|
|
971
|
+
const [eventAuthority] = PublicKey.findProgramAddressSync([Buffer.from("__event_authority")], tokenMessengerMinter);
|
|
972
|
+
const [mtEventAuthority] = PublicKey.findProgramAddressSync([Buffer.from("__event_authority")], messageTransmitterProgram);
|
|
973
|
+
// Build instruction data: Anchor discriminator + V2 params
|
|
974
|
+
// Use deposit_for_burn_with_hook for Forwarding Service (Circle handles dst mint)
|
|
975
|
+
const discriminator = createHash("sha256").update("global:deposit_for_burn_with_hook").digest().subarray(0, 8);
|
|
976
|
+
const amountBuf = Buffer.alloc(8);
|
|
977
|
+
amountBuf.writeBigUInt64LE(BigInt(Math.round(amountUsdc * 1e6)));
|
|
978
|
+
// Domain as u32 LE for instruction data (different from PDA seed which uses string)
|
|
979
|
+
const domainBuf = Buffer.alloc(4);
|
|
980
|
+
domainBuf.writeUInt32LE(dstDomain);
|
|
981
|
+
// mintRecipient: EVM address left-padded to 32 bytes
|
|
982
|
+
const recipientBytes = Buffer.alloc(32);
|
|
983
|
+
const addrBytes = Buffer.from(recipientAddress.replace("0x", ""), "hex");
|
|
984
|
+
addrBytes.copy(recipientBytes, 32 - addrBytes.length); // left-pad
|
|
985
|
+
// destinationCaller: all zeros (required for forwarding)
|
|
986
|
+
const destinationCaller = Buffer.alloc(32);
|
|
987
|
+
const finality = fast ? 1000 : 2000;
|
|
988
|
+
const { maxFee: relayFee } = await getCctpRelayFee(CCTP_DOMAINS.solana, dstDomain, finality, true, amountUsdc);
|
|
989
|
+
const maxFeeBuf = Buffer.alloc(8);
|
|
990
|
+
maxFeeBuf.writeBigUInt64LE(relayFee);
|
|
991
|
+
const minFinalityBuf = Buffer.alloc(4);
|
|
992
|
+
minFinalityBuf.writeUInt32LE(finality);
|
|
993
|
+
// Forward hook data: "cctp-forward" magic bytes + version(0) + empty data length(0)
|
|
994
|
+
const hookData = Buffer.from(CCTP_FORWARD_HOOK_DATA.replace("0x", ""), "hex");
|
|
995
|
+
// Borsh-encode hook data as Vec<u8>: 4-byte LE length prefix + data
|
|
996
|
+
const hookLenBuf = Buffer.alloc(4);
|
|
997
|
+
hookLenBuf.writeUInt32LE(hookData.length);
|
|
998
|
+
const data = Buffer.concat([
|
|
999
|
+
discriminator, amountBuf, domainBuf, recipientBytes,
|
|
1000
|
+
destinationCaller, maxFeeBuf, minFinalityBuf,
|
|
1001
|
+
hookLenBuf, hookData,
|
|
1002
|
+
]);
|
|
1003
|
+
const instruction = {
|
|
1004
|
+
programId: tokenMessengerMinter,
|
|
1005
|
+
keys: [
|
|
1006
|
+
{ pubkey: keypair.publicKey, isSigner: true, isWritable: true }, // 0: owner
|
|
1007
|
+
{ pubkey: keypair.publicKey, isSigner: true, isWritable: true }, // 1: eventRentPayer
|
|
1008
|
+
{ pubkey: senderAuthority, isSigner: false, isWritable: false }, // 2: senderAuthorityPda
|
|
1009
|
+
{ pubkey: burnTokenAccount, isSigner: false, isWritable: true }, // 3: burnTokenAccount
|
|
1010
|
+
{ pubkey: denylistAccount, isSigner: false, isWritable: false }, // 4: denylistAccount
|
|
1011
|
+
{ pubkey: messageTransmitterAccount, isSigner: false, isWritable: true }, // 5: messageTransmitter (MT state)
|
|
1012
|
+
{ pubkey: tokenMessenger, isSigner: false, isWritable: false }, // 6: tokenMessenger
|
|
1013
|
+
{ pubkey: remoteTokenMessenger, isSigner: false, isWritable: false }, // 7: remoteTokenMessenger
|
|
1014
|
+
{ pubkey: tokenMinter, isSigner: false, isWritable: false }, // 8: tokenMinter
|
|
1015
|
+
{ pubkey: localToken, isSigner: false, isWritable: true }, // 9: localToken
|
|
1016
|
+
{ pubkey: usdcMint, isSigner: false, isWritable: true }, // 10: burnTokenMint
|
|
1017
|
+
{ pubkey: eventDataKeypair.publicKey, isSigner: true, isWritable: true }, // 11: messageSentEventData
|
|
1018
|
+
{ pubkey: messageTransmitterProgram, isSigner: false, isWritable: false }, // 12: messageTransmitterProgram
|
|
1019
|
+
{ pubkey: tokenMessengerMinter, isSigner: false, isWritable: false }, // 13: tokenMessengerMinterProgram
|
|
1020
|
+
{ pubkey: tokenProgram, isSigner: false, isWritable: false }, // 14: tokenProgram
|
|
1021
|
+
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, // 15: systemProgram
|
|
1022
|
+
{ pubkey: eventAuthority, isSigner: false, isWritable: false }, // 16: TMM eventAuthority
|
|
1023
|
+
{ pubkey: tokenMessengerMinter, isSigner: false, isWritable: false }, // 17: TMM program (self)
|
|
1024
|
+
{ pubkey: mtEventAuthority, isSigner: false, isWritable: false }, // 18: MT eventAuthority
|
|
1025
|
+
{ pubkey: messageTransmitterProgram, isSigner: false, isWritable: false }, // 19: MT program
|
|
1026
|
+
],
|
|
1027
|
+
data,
|
|
1028
|
+
};
|
|
1029
|
+
// Build and send transaction
|
|
1030
|
+
const { blockhash } = await connection.getLatestBlockhash();
|
|
1031
|
+
const messageV0 = new TransactionMessage({
|
|
1032
|
+
payerKey: keypair.publicKey,
|
|
1033
|
+
recentBlockhash: blockhash,
|
|
1034
|
+
instructions: [instruction],
|
|
1035
|
+
}).compileToV0Message();
|
|
1036
|
+
const transaction = new VersionedTransaction(messageV0);
|
|
1037
|
+
transaction.sign([keypair, eventDataKeypair]);
|
|
1038
|
+
const signature = await connection.sendTransaction(transaction, { skipPreflight: false });
|
|
1039
|
+
await connection.confirmTransaction(signature, "confirmed");
|
|
1040
|
+
const feeUsdc = Number(relayFee) / 1e6;
|
|
1041
|
+
const providerLabel = fast ? "cctp (fast+forward)" : "cctp (forward)";
|
|
1042
|
+
// Forwarding Service: Circle handles dst chain mint — no manual relay needed.
|
|
1043
|
+
return { provider: providerLabel, txHash: signature, srcChain: "solana", dstChain, amountIn: amountUsdc, amountOut: amountUsdc - feeUsdc };
|
|
1044
|
+
}
|
|
1045
|
+
// ── CCTP: EVM → Solana ──
|
|
1046
|
+
// NOTE: Circle Forwarding Service does NOT support Solana as destination.
|
|
1047
|
+
// Must use depositForBurn + manual receiveMessage (or fast finality for auto-relay).
|
|
1048
|
+
async function executeCctpEvmToSolana(srcChain, amountUsdc, signerKey, recipientAddress, // Solana base58 pubkey
|
|
1049
|
+
solanaPayerKey, // Solana private key for receiveMessage relay
|
|
1050
|
+
fast = false) {
|
|
1051
|
+
const { ethers } = await import("ethers");
|
|
1052
|
+
const srcRpc = RPC_URLS[srcChain] ?? RPC_URLS.arbitrum;
|
|
1053
|
+
const srcProvider = new ethers.JsonRpcProvider(srcRpc);
|
|
1054
|
+
const srcWallet = new ethers.Wallet(signerKey, srcProvider);
|
|
1055
|
+
const usdcAddr = USDC_ADDRESSES[srcChain];
|
|
1056
|
+
const tokenMessengerAddr = CCTP_TOKEN_MESSENGER[srcChain];
|
|
1057
|
+
const solanaDomain = CCTP_DOMAINS.solana; // 5
|
|
1058
|
+
if (!usdcAddr || !tokenMessengerAddr)
|
|
1059
|
+
throw new Error(`CCTP not configured for ${srcChain}`);
|
|
1060
|
+
// Step 0: Check USDC balance
|
|
1061
|
+
const balance = await getEvmUsdcBalance(srcChain, srcWallet.address);
|
|
1062
|
+
if (balance < amountUsdc) {
|
|
1063
|
+
throw new Error(`Insufficient USDC on ${srcChain}: have ${balance.toFixed(2)}, need ${amountUsdc}`);
|
|
1064
|
+
}
|
|
1065
|
+
const amountRaw = BigInt(Math.round(amountUsdc * 1e6));
|
|
1066
|
+
// Step 1: Approve USDC
|
|
1067
|
+
const usdc = new ethers.Contract(usdcAddr, [
|
|
1068
|
+
"function allowance(address,address) view returns (uint256)",
|
|
1069
|
+
"function approve(address,uint256) returns (bool)",
|
|
1070
|
+
], srcWallet);
|
|
1071
|
+
const allowance = await usdc.allowance(srcWallet.address, tokenMessengerAddr);
|
|
1072
|
+
if (allowance < amountRaw) {
|
|
1073
|
+
const approveTx = await usdc.approve(tokenMessengerAddr, ethers.MaxUint256);
|
|
1074
|
+
await approveTx.wait();
|
|
1075
|
+
}
|
|
1076
|
+
// Step 2: Get relay fee (no forwarding — Solana dst not supported)
|
|
1077
|
+
const finality = fast ? 1000 : 2000;
|
|
1078
|
+
const srcDomain = CCTP_DOMAINS[srcChain];
|
|
1079
|
+
const { maxFee: relayFee, feeUsdc } = await getCctpRelayFee(srcDomain, solanaDomain, finality, false, amountUsdc);
|
|
1080
|
+
// Step 3: depositForBurn — mintRecipient is the Solana ATA for the recipient
|
|
1081
|
+
const { PublicKey } = await import("@solana/web3.js");
|
|
1082
|
+
const recipientPubkey = new PublicKey(recipientAddress);
|
|
1083
|
+
const usdcMint = new PublicKey(USDC_MINT_SOLANA);
|
|
1084
|
+
const tokenProgramKey = new PublicKey(SPL_TOKEN_PROGRAM);
|
|
1085
|
+
// Compute the recipient's USDC ATA
|
|
1086
|
+
const [recipientAta] = PublicKey.findProgramAddressSync([recipientPubkey.toBuffer(), tokenProgramKey.toBuffer(), usdcMint.toBuffer()], new PublicKey(ASSOCIATED_TOKEN_PROGRAM));
|
|
1087
|
+
// Pad the Solana ATA (32 bytes) to bytes32 for EVM
|
|
1088
|
+
const mintRecipient = "0x" + Buffer.from(recipientAta.toBytes()).toString("hex");
|
|
1089
|
+
const destinationCaller = ethers.ZeroHash; // permissionless relay
|
|
1090
|
+
const tokenMessenger = new ethers.Contract(tokenMessengerAddr, [
|
|
1091
|
+
"function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) returns (uint64 nonce)",
|
|
1092
|
+
], srcWallet);
|
|
1093
|
+
const depositTx = await tokenMessenger.depositForBurn(amountRaw, solanaDomain, mintRecipient, usdcAddr, destinationCaller, relayFee, finality);
|
|
1094
|
+
const receipt = await depositTx.wait();
|
|
1095
|
+
const txHash = receipt.hash;
|
|
1096
|
+
// Fast finality (1000): Circle auto-relays — done.
|
|
1097
|
+
if (fast) {
|
|
1098
|
+
return { provider: "cctp (fast)", txHash, srcChain, dstChain: "solana", amountIn: amountUsdc, amountOut: amountUsdc - feeUsdc };
|
|
1099
|
+
}
|
|
1100
|
+
// Standard finality (2000): poll attestation + manual Solana receiveMessage
|
|
1101
|
+
if (solanaPayerKey) {
|
|
1102
|
+
const attestationUrl = `https://iris-api.circle.com/v2/messages/${srcDomain}?transactionHash=${txHash}`;
|
|
1103
|
+
let messageBytes = "";
|
|
1104
|
+
let attestationBytes = "";
|
|
1105
|
+
for (let i = 0; i < 80; i++) {
|
|
1106
|
+
await new Promise(r => setTimeout(r, 15000));
|
|
1107
|
+
try {
|
|
1108
|
+
const res = await fetch(attestationUrl);
|
|
1109
|
+
if (res.ok) {
|
|
1110
|
+
const body = await res.json();
|
|
1111
|
+
const msg = body.messages?.[0];
|
|
1112
|
+
if (msg?.status === "complete" && msg.attestation && msg.attestation !== "PENDING") {
|
|
1113
|
+
messageBytes = msg.message;
|
|
1114
|
+
attestationBytes = msg.attestation;
|
|
1115
|
+
break;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
catch { /* retry */ }
|
|
1120
|
+
}
|
|
1121
|
+
if (messageBytes && attestationBytes) {
|
|
1122
|
+
try {
|
|
1123
|
+
const receiveSig = await executeSolanaReceiveMessage(messageBytes, attestationBytes, recipientAddress, solanaPayerKey);
|
|
1124
|
+
return { provider: "cctp", txHash, receiveTxHash: receiveSig, srcChain, dstChain: "solana", amountIn: amountUsdc, amountOut: amountUsdc - feeUsdc };
|
|
1125
|
+
}
|
|
1126
|
+
catch { /* may fail if already relayed */ }
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return {
|
|
1130
|
+
provider: "cctp",
|
|
1131
|
+
txHash,
|
|
1132
|
+
srcChain,
|
|
1133
|
+
dstChain: "solana",
|
|
1134
|
+
amountIn: amountUsdc,
|
|
1135
|
+
amountOut: amountUsdc - feeUsdc,
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
// ── CCTP: Solana receiveMessage (for EVM→Solana relay) ──
|
|
1139
|
+
/**
|
|
1140
|
+
* Call receiveMessage on Solana MessageTransmitter V2 to complete an EVM→Solana bridge.
|
|
1141
|
+
* This mints USDC on Solana after the attestation is ready.
|
|
1142
|
+
*/
|
|
1143
|
+
export async function executeSolanaReceiveMessage(messageHex, // "0x..." CCTP message from Iris API
|
|
1144
|
+
attestationHex, // "0x..." attestation from Iris API
|
|
1145
|
+
recipientAddress, // Solana wallet pubkey (base58)
|
|
1146
|
+
payerKey) {
|
|
1147
|
+
const { Connection, PublicKey, Keypair, TransactionMessage, VersionedTransaction, SystemProgram, ComputeBudgetProgram } = await import("@solana/web3.js");
|
|
1148
|
+
const bs58 = await import("bs58");
|
|
1149
|
+
const { createHash } = await import("crypto");
|
|
1150
|
+
const connection = new Connection(RPC_URLS.solana, "confirmed");
|
|
1151
|
+
// Decode payer keypair
|
|
1152
|
+
let payer;
|
|
1153
|
+
try {
|
|
1154
|
+
payer = Keypair.fromSecretKey(bs58.default.decode(payerKey));
|
|
1155
|
+
}
|
|
1156
|
+
catch {
|
|
1157
|
+
try {
|
|
1158
|
+
payer = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(payerKey)));
|
|
1159
|
+
}
|
|
1160
|
+
catch {
|
|
1161
|
+
throw new Error("Invalid Solana private key format");
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
// Parse raw bytes
|
|
1165
|
+
const messageBytes = Buffer.from(messageHex.replace("0x", ""), "hex");
|
|
1166
|
+
const attestationBytes = Buffer.from(attestationHex.replace("0x", ""), "hex");
|
|
1167
|
+
// Extract fields from CCTP V2 message
|
|
1168
|
+
// Layout: version(4) + srcDomain(4) + dstDomain(4) + nonce(32) + sender(32) + recipient(32) + destCaller(32) + minFinality(4) + finalityExecuted(4) + messageBody(var)
|
|
1169
|
+
const sourceDomain = messageBytes.readUInt32BE(4);
|
|
1170
|
+
const nonce = messageBytes.subarray(12, 44); // 32-byte nonce for used_nonce PDA
|
|
1171
|
+
// Extract burn token from message body (starts at offset 148)
|
|
1172
|
+
// BurnMessage: version(4) + burnToken(32) + ...
|
|
1173
|
+
const burnToken = messageBytes.subarray(152, 184); // body[4..36]
|
|
1174
|
+
// Program IDs
|
|
1175
|
+
const tokenMessengerMinter = new PublicKey(CCTP_SOLANA_TOKEN_MESSENGER_MINTER);
|
|
1176
|
+
const messageTransmitterProgram = new PublicKey(CCTP_SOLANA_MESSAGE_TRANSMITTER);
|
|
1177
|
+
const usdcMint = new PublicKey(USDC_MINT_SOLANA);
|
|
1178
|
+
const tokenProgram = new PublicKey(SPL_TOKEN_PROGRAM);
|
|
1179
|
+
const ataProg = new PublicKey(ASSOCIATED_TOKEN_PROGRAM);
|
|
1180
|
+
// ── MessageTransmitter accounts ──
|
|
1181
|
+
const [authorityPda] = PublicKey.findProgramAddressSync([Buffer.from("message_transmitter_authority"), tokenMessengerMinter.toBuffer()], messageTransmitterProgram);
|
|
1182
|
+
const [messageTransmitter] = PublicKey.findProgramAddressSync([Buffer.from("message_transmitter")], messageTransmitterProgram);
|
|
1183
|
+
const [usedNonce] = PublicKey.findProgramAddressSync([Buffer.from("used_nonce"), nonce], messageTransmitterProgram);
|
|
1184
|
+
// ── TokenMessengerMinter handler accounts ──
|
|
1185
|
+
const [tokenMessenger] = PublicKey.findProgramAddressSync([Buffer.from("token_messenger")], tokenMessengerMinter);
|
|
1186
|
+
const [remoteTokenMessenger] = PublicKey.findProgramAddressSync([Buffer.from("remote_token_messenger"), Buffer.from(String(sourceDomain))], tokenMessengerMinter);
|
|
1187
|
+
const [tokenMinter] = PublicKey.findProgramAddressSync([Buffer.from("token_minter")], tokenMessengerMinter);
|
|
1188
|
+
const [localToken] = PublicKey.findProgramAddressSync([Buffer.from("local_token"), usdcMint.toBuffer()], tokenMessengerMinter);
|
|
1189
|
+
const [tokenPair] = PublicKey.findProgramAddressSync([Buffer.from("token_pair"), Buffer.from(String(sourceDomain)), burnToken], tokenMessengerMinter);
|
|
1190
|
+
// Fee recipient ATA (read from on-chain token_messenger account)
|
|
1191
|
+
// V2 TokenMessenger layout: disc(8)+denylister(32)+owner(32)+pending_owner(32)+message_body_version(4)+authority_bump(1)+fee_recipient(32)
|
|
1192
|
+
// fee_recipient at byte offset 109
|
|
1193
|
+
const feeRecipient = new PublicKey("9s6qCkbhtYMpWuhPHiokWUUNjrDpKK4djzpKGhyizWGk");
|
|
1194
|
+
const feeRecipientAta = new PublicKey("BDPTEfR44oztkZgiFxpbjERm6XwFZYaT6GB37ofp53tj");
|
|
1195
|
+
// Recipient's USDC ATA
|
|
1196
|
+
const recipientPk = new PublicKey(recipientAddress);
|
|
1197
|
+
const [recipientAta] = PublicKey.findProgramAddressSync([recipientPk.toBuffer(), tokenProgram.toBuffer(), usdcMint.toBuffer()], ataProg);
|
|
1198
|
+
// Custody token account
|
|
1199
|
+
const [custodyTokenAccount] = PublicKey.findProgramAddressSync([Buffer.from("custody"), usdcMint.toBuffer()], tokenMessengerMinter);
|
|
1200
|
+
// Event authority PDAs
|
|
1201
|
+
const [mtEventAuthority] = PublicKey.findProgramAddressSync([Buffer.from("__event_authority")], messageTransmitterProgram);
|
|
1202
|
+
const [tmmEventAuthority] = PublicKey.findProgramAddressSync([Buffer.from("__event_authority")], tokenMessengerMinter);
|
|
1203
|
+
// ── Build instruction data ──
|
|
1204
|
+
// Anchor discriminator for "receive_message"
|
|
1205
|
+
const discriminator = createHash("sha256").update("global:receive_message").digest().subarray(0, 8);
|
|
1206
|
+
// Borsh-serialize params: message (Vec<u8>) + attestation (Vec<u8>)
|
|
1207
|
+
const msgLenBuf = Buffer.alloc(4);
|
|
1208
|
+
msgLenBuf.writeUInt32LE(messageBytes.length);
|
|
1209
|
+
const attLenBuf = Buffer.alloc(4);
|
|
1210
|
+
attLenBuf.writeUInt32LE(attestationBytes.length);
|
|
1211
|
+
const data = Buffer.concat([
|
|
1212
|
+
discriminator,
|
|
1213
|
+
msgLenBuf, messageBytes,
|
|
1214
|
+
attLenBuf, attestationBytes,
|
|
1215
|
+
]);
|
|
1216
|
+
// ── Build accounts list ──
|
|
1217
|
+
const instruction = {
|
|
1218
|
+
programId: messageTransmitterProgram,
|
|
1219
|
+
keys: [
|
|
1220
|
+
// ReceiveMessageContext (7 accounts)
|
|
1221
|
+
{ pubkey: payer.publicKey, isSigner: true, isWritable: true }, // 0: payer
|
|
1222
|
+
{ pubkey: payer.publicKey, isSigner: true, isWritable: false }, // 1: caller
|
|
1223
|
+
{ pubkey: authorityPda, isSigner: false, isWritable: false }, // 2: authority_pda
|
|
1224
|
+
{ pubkey: messageTransmitter, isSigner: false, isWritable: false }, // 3: message_transmitter
|
|
1225
|
+
{ pubkey: usedNonce, isSigner: false, isWritable: true }, // 4: used_nonce (init)
|
|
1226
|
+
{ pubkey: tokenMessengerMinter, isSigner: false, isWritable: false }, // 5: receiver (TMM program)
|
|
1227
|
+
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, // 6: system_program
|
|
1228
|
+
// MT event_cpi (2 accounts)
|
|
1229
|
+
{ pubkey: mtEventAuthority, isSigner: false, isWritable: false }, // 7: MT event_authority
|
|
1230
|
+
{ pubkey: messageTransmitterProgram, isSigner: false, isWritable: false }, // 8: MT program
|
|
1231
|
+
// Remaining accounts for CPI to TMM HandleReceiveMessageContext (11 accounts)
|
|
1232
|
+
// NOTE: authority_pda is NOT passed here — the MT program inserts it as
|
|
1233
|
+
// a PDA signer automatically during CPI. These are positions [1..11] of
|
|
1234
|
+
// the TMM IDL's handle_receive_unfinalized_message instruction.
|
|
1235
|
+
{ pubkey: tokenMessenger, isSigner: false, isWritable: false }, // 9: token_messenger
|
|
1236
|
+
{ pubkey: remoteTokenMessenger, isSigner: false, isWritable: false }, // 10: remote_token_messenger
|
|
1237
|
+
{ pubkey: tokenMinter, isSigner: false, isWritable: false }, // 11: token_minter
|
|
1238
|
+
{ pubkey: localToken, isSigner: false, isWritable: true }, // 12: local_token
|
|
1239
|
+
{ pubkey: tokenPair, isSigner: false, isWritable: false }, // 13: token_pair
|
|
1240
|
+
{ pubkey: feeRecipientAta, isSigner: false, isWritable: true }, // 14: fee_recipient_token_account
|
|
1241
|
+
{ pubkey: recipientAta, isSigner: false, isWritable: true }, // 15: recipient_token_account
|
|
1242
|
+
{ pubkey: custodyTokenAccount, isSigner: false, isWritable: true }, // 16: custody_token_account
|
|
1243
|
+
{ pubkey: tokenProgram, isSigner: false, isWritable: false }, // 17: token_program
|
|
1244
|
+
// TMM event_cpi (2 accounts)
|
|
1245
|
+
{ pubkey: tmmEventAuthority, isSigner: false, isWritable: false }, // 18: TMM event_authority
|
|
1246
|
+
{ pubkey: tokenMessengerMinter, isSigner: false, isWritable: false }, // 19: TMM program
|
|
1247
|
+
],
|
|
1248
|
+
data,
|
|
1249
|
+
};
|
|
1250
|
+
// Build and send transaction with ALT to stay under 1232-byte limit
|
|
1251
|
+
const CCTP_ALT = new PublicKey("7xfB7Yd2UXf6hXQnYEgt26SBPEyZC3rDgb1X7Wip2NEX");
|
|
1252
|
+
const altAccount = await connection.getAddressLookupTable(CCTP_ALT);
|
|
1253
|
+
const lookupTables = altAccount.value ? [altAccount.value] : [];
|
|
1254
|
+
const computeBudgetIx = ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 });
|
|
1255
|
+
const { blockhash } = await connection.getLatestBlockhash();
|
|
1256
|
+
const messageV0 = new TransactionMessage({
|
|
1257
|
+
payerKey: payer.publicKey,
|
|
1258
|
+
recentBlockhash: blockhash,
|
|
1259
|
+
instructions: [computeBudgetIx, instruction],
|
|
1260
|
+
}).compileToV0Message(lookupTables);
|
|
1261
|
+
const transaction = new VersionedTransaction(messageV0);
|
|
1262
|
+
transaction.sign([payer]);
|
|
1263
|
+
const signature = await connection.sendTransaction(transaction, { skipPreflight: false });
|
|
1264
|
+
await connection.confirmTransaction(signature, "confirmed");
|
|
1265
|
+
return signature;
|
|
1266
|
+
}
|
|
1267
|
+
// ── Shared CCTP helpers ──
|
|
1268
|
+
/**
|
|
1269
|
+
* Poll Circle V2 Iris API by transaction hash (for Solana→EVM).
|
|
1270
|
+
* Returns the CCTP message bytes and attestation signature.
|
|
1271
|
+
*/
|
|
1272
|
+
async function pollCctpV2Attestation(sourceDomain, txHash, maxAttempts) {
|
|
1273
|
+
const url = `https://iris-api.circle.com/v2/messages/${sourceDomain}?transactionHash=${txHash}`;
|
|
1274
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
1275
|
+
await sleep(15_000);
|
|
1276
|
+
try {
|
|
1277
|
+
const res = await fetch(url);
|
|
1278
|
+
if (res.ok) {
|
|
1279
|
+
const data = await res.json();
|
|
1280
|
+
const msg = data.messages?.[0];
|
|
1281
|
+
if (msg?.status === "complete" && msg.message && msg.attestation) {
|
|
1282
|
+
return { message: msg.message, attestation: msg.attestation };
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
catch {
|
|
1287
|
+
// retry
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return { message: null, attestation: null };
|
|
1291
|
+
}
|
|
1292
|
+
// ── Relay Bridge API ──
|
|
1293
|
+
const RELAY_API = "https://api.relay.link";
|
|
1294
|
+
// Relay chain IDs (same as standard EVM chain IDs, Solana = 792703809)
|
|
1295
|
+
const RELAY_CHAIN_IDS = {
|
|
1296
|
+
solana: 792703809,
|
|
1297
|
+
arbitrum: 42161,
|
|
1298
|
+
base: 8453,
|
|
1299
|
+
};
|
|
1300
|
+
export async function getRelayQuote(srcChain, dstChain, amountUsdc, senderAddress, recipientAddress) {
|
|
1301
|
+
const srcChainId = RELAY_CHAIN_IDS[srcChain];
|
|
1302
|
+
const dstChainId = RELAY_CHAIN_IDS[dstChain];
|
|
1303
|
+
if (!srcChainId || !dstChainId)
|
|
1304
|
+
throw new Error(`Relay: unsupported chain ${srcChain} or ${dstChain}`);
|
|
1305
|
+
const srcToken = USDC_ADDRESSES[srcChain];
|
|
1306
|
+
const dstToken = USDC_ADDRESSES[dstChain];
|
|
1307
|
+
if (!srcToken || !dstToken)
|
|
1308
|
+
throw new Error(`No USDC for ${srcChain} or ${dstChain}`);
|
|
1309
|
+
const amountRaw = String(Math.round(amountUsdc * 1e6));
|
|
1310
|
+
const body = {
|
|
1311
|
+
user: senderAddress,
|
|
1312
|
+
recipient: recipientAddress,
|
|
1313
|
+
originChainId: srcChainId,
|
|
1314
|
+
destinationChainId: dstChainId,
|
|
1315
|
+
originCurrency: srcToken,
|
|
1316
|
+
destinationCurrency: dstToken,
|
|
1317
|
+
amount: amountRaw,
|
|
1318
|
+
tradeType: "EXACT_INPUT",
|
|
1319
|
+
};
|
|
1320
|
+
const res = await fetch(`${RELAY_API}/quote`, {
|
|
1321
|
+
method: "POST",
|
|
1322
|
+
headers: { "Content-Type": "application/json" },
|
|
1323
|
+
body: JSON.stringify(body),
|
|
1324
|
+
});
|
|
1325
|
+
if (!res.ok) {
|
|
1326
|
+
const errText = await res.text();
|
|
1327
|
+
throw new Error(`Relay quote failed: ${res.status} ${errText}`);
|
|
1328
|
+
}
|
|
1329
|
+
const data = await res.json();
|
|
1330
|
+
const details = data.details;
|
|
1331
|
+
const fees = data.fees;
|
|
1332
|
+
const currencyOut = details?.currencyOut;
|
|
1333
|
+
const amountOut = Number(currencyOut?.amountFormatted ?? 0);
|
|
1334
|
+
const relayerFee = fees?.relayer;
|
|
1335
|
+
const feeUsd = Number(relayerFee?.amountUsd ?? 0);
|
|
1336
|
+
const timeEstimate = Number(details?.timeEstimate ?? 30);
|
|
1337
|
+
return {
|
|
1338
|
+
provider: "relay",
|
|
1339
|
+
srcChain,
|
|
1340
|
+
dstChain,
|
|
1341
|
+
amountIn: amountUsdc,
|
|
1342
|
+
amountOut: amountOut || (amountUsdc - feeUsd),
|
|
1343
|
+
fee: feeUsd || (amountUsdc - amountOut),
|
|
1344
|
+
estimatedTime: timeEstimate,
|
|
1345
|
+
gasIncluded: true,
|
|
1346
|
+
gasNote: "Relay solver handles destination execution",
|
|
1347
|
+
raw: data,
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
export async function executeRelayBridge(bridgeQuote, signerKey) {
|
|
1351
|
+
const { srcChain, dstChain, amountIn, amountOut } = bridgeQuote;
|
|
1352
|
+
const data = bridgeQuote.raw;
|
|
1353
|
+
const steps = data.steps;
|
|
1354
|
+
if (!steps || steps.length === 0)
|
|
1355
|
+
throw new Error("Relay: no steps in quote");
|
|
1356
|
+
let txHash = "";
|
|
1357
|
+
for (const step of steps) {
|
|
1358
|
+
const kind = step.kind;
|
|
1359
|
+
const items = step.items;
|
|
1360
|
+
if (!items)
|
|
1361
|
+
continue;
|
|
1362
|
+
for (const item of items) {
|
|
1363
|
+
const itemData = item.data;
|
|
1364
|
+
if (!itemData)
|
|
1365
|
+
continue;
|
|
1366
|
+
if (kind === "transaction") {
|
|
1367
|
+
if (srcChain === "solana") {
|
|
1368
|
+
// Solana transaction
|
|
1369
|
+
const txData = String(itemData.data ?? "");
|
|
1370
|
+
if (txData) {
|
|
1371
|
+
txHash = await submitSolanaTransaction({ data: txData }, signerKey);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
else {
|
|
1375
|
+
// EVM transaction
|
|
1376
|
+
txHash = await submitEvmTransaction({ to: itemData.to, data: itemData.data, value: itemData.value ?? "0" }, signerKey, srcChain);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (!txHash)
|
|
1382
|
+
throw new Error("Relay: no transaction executed");
|
|
1383
|
+
return {
|
|
1384
|
+
provider: "relay",
|
|
1385
|
+
txHash,
|
|
1386
|
+
srcChain,
|
|
1387
|
+
dstChain,
|
|
1388
|
+
amountIn,
|
|
1389
|
+
amountOut,
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Get the best bridge quote. Strategy:
|
|
1394
|
+
* - CCTP primary ($0 fee) for all routes: Solana↔EVM, EVM↔EVM, →HyperCore
|
|
1395
|
+
* - Relay + deBridge DLN in parallel, pick cheapest
|
|
1396
|
+
*/
|
|
1397
|
+
/**
|
|
1398
|
+
* Get quotes from ALL available providers in parallel.
|
|
1399
|
+
* Returns sorted by amountOut (best first).
|
|
1400
|
+
*/
|
|
1401
|
+
export async function getAllQuotes(srcChain, dstChain, amountUsdc, senderAddress, recipientAddress) {
|
|
1402
|
+
const results = await Promise.allSettled([
|
|
1403
|
+
getCctpQuote(srcChain, dstChain, amountUsdc),
|
|
1404
|
+
getRelayQuote(srcChain, dstChain, amountUsdc, senderAddress, recipientAddress),
|
|
1405
|
+
getDebridgeQuote(srcChain, dstChain, amountUsdc, senderAddress, recipientAddress),
|
|
1406
|
+
]);
|
|
1407
|
+
const quotes = [];
|
|
1408
|
+
for (const r of results) {
|
|
1409
|
+
if (r.status === "fulfilled")
|
|
1410
|
+
quotes.push(r.value);
|
|
1411
|
+
}
|
|
1412
|
+
// Sort by best deal (highest amountOut)
|
|
1413
|
+
quotes.sort((a, b) => b.amountOut - a.amountOut);
|
|
1414
|
+
return quotes;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Get the best quote across all providers.
|
|
1418
|
+
*/
|
|
1419
|
+
export async function getBestQuote(srcChain, dstChain, amountUsdc, senderAddress, recipientAddress) {
|
|
1420
|
+
const quotes = await getAllQuotes(srcChain, dstChain, amountUsdc, senderAddress, recipientAddress);
|
|
1421
|
+
if (quotes.length === 0) {
|
|
1422
|
+
throw new Error(`No bridge available for ${srcChain} → ${dstChain}`);
|
|
1423
|
+
}
|
|
1424
|
+
return quotes[0];
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Execute a bridge using the specified or best available provider.
|
|
1428
|
+
* @param provider - Optional: force a specific provider ("cctp" | "relay" | "debridge")
|
|
1429
|
+
*/
|
|
1430
|
+
export async function executeBestBridge(srcChain, dstChain, amountUsdc, signerKey, senderAddress, recipientAddress, dstSignerKey, // Optional EVM key for manual receiveMessage (standard finality)
|
|
1431
|
+
provider, fast = false) {
|
|
1432
|
+
let quote;
|
|
1433
|
+
if (provider) {
|
|
1434
|
+
// User specified a provider — get that specific quote
|
|
1435
|
+
const quotes = await getAllQuotes(srcChain, dstChain, amountUsdc, senderAddress, recipientAddress);
|
|
1436
|
+
const match = quotes.find(q => q.provider === provider);
|
|
1437
|
+
if (!match)
|
|
1438
|
+
throw new Error(`Provider "${provider}" not available for ${srcChain} → ${dstChain}`);
|
|
1439
|
+
quote = match;
|
|
1440
|
+
}
|
|
1441
|
+
else {
|
|
1442
|
+
// Default: prefer deBridge DLN (fastest, ~2s), fallback to best available
|
|
1443
|
+
const quotes = await getAllQuotes(srcChain, dstChain, amountUsdc, senderAddress, recipientAddress);
|
|
1444
|
+
if (quotes.length === 0)
|
|
1445
|
+
throw new Error(`No bridge available for ${srcChain} → ${dstChain}`);
|
|
1446
|
+
quote = quotes.find(q => q.provider === "debridge") ?? quotes[0];
|
|
1447
|
+
}
|
|
1448
|
+
if (quote.provider === "cctp") {
|
|
1449
|
+
return executeCctpBridge(srcChain, dstChain, amountUsdc, signerKey, recipientAddress, dstSignerKey, fast);
|
|
1450
|
+
}
|
|
1451
|
+
if (quote.provider === "relay") {
|
|
1452
|
+
return executeRelayBridge(quote, signerKey);
|
|
1453
|
+
}
|
|
1454
|
+
return executeDebridgeBridge(quote, signerKey);
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Check bridge order status via deBridge.
|
|
1458
|
+
*/
|
|
1459
|
+
export async function checkDebridgeStatus(orderId) {
|
|
1460
|
+
const res = await fetch(`${DLN_API}/${orderId}/status`);
|
|
1461
|
+
if (!res.ok)
|
|
1462
|
+
throw new Error(`Status check failed: ${res.status}`);
|
|
1463
|
+
return res.json();
|
|
1464
|
+
}
|
|
1465
|
+
// ── Helpers ──
|
|
1466
|
+
/**
|
|
1467
|
+
* Append deBridge builder/affiliate params if configured via env vars:
|
|
1468
|
+
* DEBRIDGE_REFERRAL_CODE, DEBRIDGE_AFFILIATE_FEE_PERCENT, DEBRIDGE_AFFILIATE_FEE_RECIPIENT
|
|
1469
|
+
*/
|
|
1470
|
+
function appendDebridgeBuilderParams(params) {
|
|
1471
|
+
if (DEBRIDGE_REFERRAL_CODE)
|
|
1472
|
+
params.set("referralCode", DEBRIDGE_REFERRAL_CODE);
|
|
1473
|
+
if (DEBRIDGE_AFFILIATE_FEE_PERCENT && DEBRIDGE_AFFILIATE_FEE_RECIPIENT) {
|
|
1474
|
+
params.set("affiliateFeePercent", DEBRIDGE_AFFILIATE_FEE_PERCENT);
|
|
1475
|
+
params.set("affiliateFeeRecipient", DEBRIDGE_AFFILIATE_FEE_RECIPIENT);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
function hexToBytes(hex) {
|
|
1479
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
1480
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
1481
|
+
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
|
1482
|
+
}
|
|
1483
|
+
return bytes;
|
|
1484
|
+
}
|
|
1485
|
+
function sleep(ms) {
|
|
1486
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1487
|
+
}
|