Skip to content

Commit d91ff57

Browse files
Michael YuanMichael Yuan
authored andcommitted
feat: USDC deposit via Across bridge (Ethereum/Base → Arbitrum → HL)
- fintool deposit USDC --amount 100 --from ethereum (quote mode) - fintool deposit USDC --amount 100 --from base (quote mode) - --execute flag (placeholder) for automated tx signing - Across API integration: quote, approval txns, bridge calldata - Shows full route: source chain → Arbitrum (Across) → HL Bridge2 - Includes fee breakdown and estimated fill time - bridge.rs: Across API client, USDC parsing, chain constants
1 parent 424072b commit d91ff57

5 files changed

Lines changed: 436 additions & 29 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ dirs = "6"
2222
shellexpand = "3"
2323
eth-keystore = "0.5"
2424
hyperliquid_rust_sdk = { version = "0.6", default-features = false }
25-
ethers = { version = "2", default-features = false, features = ["rustls", "eip712"] }
25+
ethers = { version = "2", default-features = false, features = ["rustls", "eip712", "abigen"] }
2626
urlencoding = "2"
2727
regex = "1"
2828
openssl = { version = "0.10", features = ["vendored"] }

src/bridge.rs

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
//! Cross-chain USDC bridge via Across Protocol + HL Bridge2 deposit
2+
//!
3+
//! Flow:
4+
//! 1. Query Across API for bridge quote + calldata
5+
//! 2. Approve USDC spend if needed (ERC-20 approve tx on source chain)
6+
//! 3. Execute bridge tx on source chain (Across SpokePool)
7+
//! 4. USDC arrives on Arbitrum (~2-10 seconds via Across relayers)
8+
//! 5. Send USDC from user's Arbitrum address to HL Bridge2 contract
9+
//!
10+
//! Alternatively, step 4+5 can be done as: Across deposits to user on Arb,
11+
//! then user sends to HL bridge. Both automated with the same private key.
12+
13+
use anyhow::{bail, Context, Result};
14+
use reqwest::Client;
15+
use serde::{Deserialize, Serialize};
16+
use serde_json::Value;
17+
18+
// ── Chain constants ──────────────────────────────────────────────────
19+
20+
pub const ETHEREUM_CHAIN_ID: u64 = 1;
21+
pub const BASE_CHAIN_ID: u64 = 8453;
22+
pub const ARBITRUM_CHAIN_ID: u64 = 42161;
23+
24+
// USDC contract addresses
25+
pub const USDC_ETHEREUM: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
26+
pub const USDC_BASE: &str = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
27+
pub const USDC_ARBITRUM: &str = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";
28+
29+
// HL Bridge2 on Arbitrum
30+
pub const HL_BRIDGE2_MAINNET: &str = "0x2df1c51e09aecf9cacb7bc98cb1742757f163df7";
31+
pub const HL_BRIDGE2_TESTNET: &str = "0x08cfc1B6b2dCF36A1480b99353A354AA8AC56f89";
32+
33+
// Across API
34+
const ACROSS_API: &str = "https://app.across.to/api";
35+
36+
// Default public RPC endpoints
37+
pub const RPC_ETHEREUM: &str = "https://eth.llamarpc.com";
38+
pub const RPC_BASE: &str = "https://mainnet.base.org";
39+
pub const RPC_ARBITRUM: &str = "https://arb1.arbitrum.io/rpc";
40+
41+
// ── Types ────────────────────────────────────────────────────────────
42+
43+
#[derive(Debug, Clone, Copy)]
44+
pub enum SourceChain {
45+
Ethereum,
46+
Base,
47+
}
48+
49+
impl SourceChain {
50+
pub fn chain_id(&self) -> u64 {
51+
match self {
52+
Self::Ethereum => ETHEREUM_CHAIN_ID,
53+
Self::Base => BASE_CHAIN_ID,
54+
}
55+
}
56+
57+
pub fn usdc_address(&self) -> &'static str {
58+
match self {
59+
Self::Ethereum => USDC_ETHEREUM,
60+
Self::Base => USDC_BASE,
61+
}
62+
}
63+
64+
pub fn rpc_url(&self) -> &'static str {
65+
match self {
66+
Self::Ethereum => RPC_ETHEREUM,
67+
Self::Base => RPC_BASE,
68+
}
69+
}
70+
71+
pub fn name(&self) -> &'static str {
72+
match self {
73+
Self::Ethereum => "ethereum",
74+
Self::Base => "base",
75+
}
76+
}
77+
}
78+
79+
impl std::str::FromStr for SourceChain {
80+
type Err = anyhow::Error;
81+
fn from_str(s: &str) -> Result<Self> {
82+
match s.to_lowercase().as_str() {
83+
"ethereum" | "eth" | "mainnet" => Ok(Self::Ethereum),
84+
"base" => Ok(Self::Base),
85+
_ => bail!("Unsupported source chain '{}'. Use: ethereum, base", s),
86+
}
87+
}
88+
}
89+
90+
// ── Across API ───────────────────────────────────────────────────────
91+
92+
#[derive(Debug, Deserialize)]
93+
pub struct AcrossSwapResponse {
94+
#[serde(rename = "crossSwapType")]
95+
pub cross_swap_type: Option<String>,
96+
#[serde(rename = "approvalTxns")]
97+
pub approval_txns: Option<Vec<AcrossTx>>,
98+
#[serde(rename = "swapTx")]
99+
pub swap_tx: AcrossTx,
100+
#[serde(rename = "inputAmount")]
101+
pub input_amount: String,
102+
#[serde(rename = "expectedOutputAmount")]
103+
pub expected_output_amount: Option<String>,
104+
#[serde(rename = "expectedFillTime")]
105+
pub expected_fill_time: Option<u64>,
106+
pub fees: Option<Value>,
107+
pub checks: Option<Value>,
108+
}
109+
110+
#[derive(Debug, Deserialize, Serialize, Clone)]
111+
pub struct AcrossTx {
112+
#[serde(rename = "chainId")]
113+
pub chain_id: Option<u64>,
114+
pub to: String,
115+
pub data: String,
116+
pub value: Option<String>,
117+
#[serde(rename = "maxFeePerGas")]
118+
pub max_fee_per_gas: Option<String>,
119+
#[serde(rename = "maxPriorityFeePerGas")]
120+
pub max_priority_fee_per_gas: Option<String>,
121+
}
122+
123+
fn client() -> Result<Client> {
124+
Client::builder()
125+
.user_agent("fintool/0.1")
126+
.build()
127+
.context("Failed to build HTTP client")
128+
}
129+
130+
/// Get bridge quote and executable calldata from Across API
131+
/// Bridges USDC from source chain → USDC on Arbitrum
132+
pub async fn get_across_quote(
133+
source: SourceChain,
134+
amount_usdc: &str,
135+
depositor: &str,
136+
) -> Result<AcrossSwapResponse> {
137+
// Convert USDC amount to smallest unit (6 decimals)
138+
let amount_raw = parse_usdc_amount(amount_usdc)?;
139+
140+
let url = format!("{}/swap/approval", ACROSS_API);
141+
let resp = client()?
142+
.get(&url)
143+
.query(&[
144+
("tradeType", "exactInput"),
145+
("amount", &amount_raw),
146+
("inputToken", source.usdc_address()),
147+
("originChainId", &source.chain_id().to_string()),
148+
("outputToken", USDC_ARBITRUM),
149+
("destinationChainId", &ARBITRUM_CHAIN_ID.to_string()),
150+
("depositor", depositor),
151+
])
152+
.send()
153+
.await
154+
.context("Failed to call Across API")?;
155+
156+
let status = resp.status();
157+
let body = resp.text().await?;
158+
159+
if !status.is_success() {
160+
bail!("Across API error ({}): {}", status, body);
161+
}
162+
163+
serde_json::from_str(&body).context("Failed to parse Across response")
164+
}
165+
166+
/// Parse a human-readable USDC amount (e.g. "100" or "100.50") to raw units (6 decimals)
167+
fn parse_usdc_amount(amount: &str) -> Result<String> {
168+
let parts: Vec<&str> = amount.split('.').collect();
169+
match parts.len() {
170+
1 => {
171+
let whole: u64 = parts[0].parse().context("Invalid USDC amount")?;
172+
Ok((whole * 1_000_000).to_string())
173+
}
174+
2 => {
175+
let whole: u64 = parts[0].parse().context("Invalid USDC amount")?;
176+
let mut frac = parts[1].to_string();
177+
// Pad or truncate to 6 decimal places
178+
while frac.len() < 6 {
179+
frac.push('0');
180+
}
181+
frac.truncate(6);
182+
let frac_val: u64 = frac.parse().context("Invalid USDC decimal")?;
183+
Ok((whole * 1_000_000 + frac_val).to_string())
184+
}
185+
_ => bail!("Invalid USDC amount: {}", amount),
186+
}
187+
}
188+
189+
/// Format raw USDC amount (6 decimals) to human-readable
190+
pub fn format_usdc(raw: &str) -> String {
191+
let val: u64 = raw.parse().unwrap_or(0);
192+
let whole = val / 1_000_000;
193+
let frac = val % 1_000_000;
194+
if frac == 0 {
195+
format!("{} USDC", whole)
196+
} else {
197+
let decimal = format!("{:06}", frac).trim_end_matches('0').to_string();
198+
format!("{}.{} USDC", whole, decimal)
199+
}
200+
}
201+
202+
// ── ERC-20 ABI helpers ───────────────────────────────────────────────
203+
204+
/// Encode ERC-20 transfer(address,uint256) calldata
205+
pub fn encode_erc20_transfer(to: &str, amount_raw: &str) -> Result<Vec<u8>> {
206+
use ethers::abi::{encode, Token};
207+
let to_addr: ethers::types::Address = to.parse().context("Invalid address")?;
208+
let amount: ethers::types::U256 =
209+
ethers::types::U256::from_dec_str(amount_raw).context("Invalid amount")?;
210+
211+
// transfer(address,uint256) selector = 0xa9059cbb
212+
let selector = hex::decode("a9059cbb")?;
213+
let encoded = encode(&[Token::Address(to_addr), Token::Uint(amount)]);
214+
215+
let mut calldata = selector;
216+
calldata.extend_from_slice(&encoded);
217+
Ok(calldata)
218+
}
219+
220+
#[cfg(test)]
221+
mod tests {
222+
use super::*;
223+
224+
#[test]
225+
fn test_parse_usdc_amount() {
226+
assert_eq!(parse_usdc_amount("100").unwrap(), "100000000");
227+
assert_eq!(parse_usdc_amount("100.50").unwrap(), "100500000");
228+
assert_eq!(parse_usdc_amount("0.01").unwrap(), "10000");
229+
assert_eq!(parse_usdc_amount("1000").unwrap(), "1000000000");
230+
}
231+
232+
#[test]
233+
fn test_format_usdc() {
234+
assert_eq!(format_usdc("100000000"), "100 USDC");
235+
assert_eq!(format_usdc("100500000"), "100.5 USDC");
236+
assert_eq!(format_usdc("10000"), "0.01 USDC");
237+
}
238+
}

src/cli.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,19 @@ pub enum Commands {
6161
#[command(subcommand)]
6262
Report(ReportCmd),
6363

64-
/// Get your deposit address for Hyperliquid via Unit bridge (ETH/BTC/SOL) or Arbitrum (USDC)
64+
/// Deposit to Hyperliquid: address for ETH/BTC/SOL (Unit), or bridge USDC (Across)
6565
Deposit {
6666
/// Asset: ETH, BTC, SOL, or USDC
6767
asset: String,
68+
/// Amount (required for USDC, ignored for ETH/BTC/SOL)
69+
#[arg(long)]
70+
amount: Option<String>,
71+
/// Source chain for USDC: ethereum or base (required for USDC)
72+
#[arg(long)]
73+
from: Option<String>,
74+
/// Execute the transactions (without this, shows quote only)
75+
#[arg(long)]
76+
execute: bool,
6877
},
6978

7079
/// Withdraw assets from Hyperliquid via Unit bridge (ETH/BTC/SOL) or Arbitrum (USDC)

0 commit comments

Comments
 (0)