perp-cli 0.12.15 → 0.14.0

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