Skip to main content

df_hyperliquid_core/transfer/
arbitrum.rs

1//! USDC into Hyperliquid through the Arbitrum bridge: the calldata the wallet sends and the
2//! JSON-RPC reads before it. A USDC `transfer` to the bridge is credited to the same address on
3//! Hyperliquid in about a minute; under [`MINIMUM_DEPOSIT`] it is lost.
4
5use crate::client::Network;
6use crate::utils::{Address, Dec, Json, Round, hex};
7use serde_json::Value;
8
9const fn address(text: &str) -> Address {
10    match Address::parse_const(text) {
11        Some(address) => address,
12        None => Address::ZERO,
13    }
14}
15
16/// The Hyperliquid bridge on Arbitrum One.
17pub const MAINNET_BRIDGE: Address = address("0x2df1c51e09aecf9cacb7bc98cb1742757f163df7");
18/// Testnet's bridge, on Arbitrum Sepolia.
19pub const TESTNET_BRIDGE: Address = address("0x08cfc1B6b2dCF36A1480b99353A354AA8AC56f89");
20
21/// Native USDC on Arbitrum One.
22pub const MAINNET_USDC: Address = address("0xaf88d065e77c8cC2239327C5EDb3A432268e5831");
23/// The USDC testnet's bridge takes, on Arbitrum Sepolia.
24pub const TESTNET_USDC: Address = address("0x1baAbB04529D43a73232B713C0FE471f7c7334d5");
25
26/// The bridge a deposit on `network` goes to.
27#[must_use]
28pub const fn bridge(network: Network) -> Address {
29    match network {
30        Network::Mainnet => MAINNET_BRIDGE,
31        Network::Testnet => TESTNET_BRIDGE,
32    }
33}
34
35/// The USDC contract a deposit on `network` is a transfer of.
36#[must_use]
37pub const fn usdc(network: Network) -> Address {
38    match network {
39        Network::Mainnet => MAINNET_USDC,
40        Network::Testnet => TESTNET_USDC,
41    }
42}
43
44/// The chain id of Arbitrum One or Arbitrum Sepolia, where the bridge is.
45#[must_use]
46pub const fn chain_id(network: Network) -> u64 {
47    network.arbitrum_chain_id()
48}
49
50/// A public JSON-RPC for that chain.
51#[must_use]
52pub const fn rpc_url(network: Network) -> &'static str {
53    match network {
54        Network::Mainnet => "https://arb1.arbitrum.io/rpc",
55        Network::Testnet => "https://sepolia-rollup.arbitrum.io/rpc",
56    }
57}
58
59pub const USDC_DECIMALS: u32 = 6;
60pub const ETH_DECIMALS: u32 = 18;
61
62/// Below this the bridge credits nothing and the USDC is gone.
63pub const MINIMUM_DEPOSIT: Dec = Dec::from_int(5);
64
65/// `transfer(address,uint256)`.
66const TRANSFER: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb];
67/// `balanceOf(address)`.
68const BALANCE_OF: [u8; 4] = [0x70, 0xa0, 0x82, 0x31];
69
70#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
71pub enum BadDeposit {
72    #[error("{0} USDC has more than six decimal places")]
73    TooFine(Dec),
74    #[error("{0} USDC is not a positive amount")]
75    NotPositive(Dec),
76    #[error("{0} USDC is below the bridge's minimum of {MINIMUM_DEPOSIT}; less is lost")]
77    BelowMinimum(Dec),
78    #[error("{0} USDC is too large")]
79    TooLarge(Dec),
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
83pub enum RpcError {
84    /// The node's `error.message`.
85    #[error("the node refused: {0}")]
86    Refused(String),
87    #[error("the node's answer could not be read: {0}")]
88    Unreadable(String),
89}
90
91/// `0x` calldata for USDC `transfer(to, amount)`: the selector, then each
92/// argument as a 32-byte word. The amount is scaled to six decimals exactly;
93/// a seventh place is refused, never rounded.
94pub fn usdc_transfer_calldata(to: Address, amount: Dec) -> Result<String, BadDeposit> {
95    if !amount.is_positive() {
96        return Err(BadDeposit::NotPositive(amount));
97    }
98    if amount.places() > USDC_DECIMALS {
99        return Err(BadDeposit::TooFine(amount));
100    }
101    let units = amount
102        .units_at(USDC_DECIMALS, Round::Down)
103        .and_then(|units| u128::try_from(units).ok())
104        .ok_or(BadDeposit::TooLarge(amount))?;
105    let mut data = Vec::with_capacity(68);
106    data.extend_from_slice(&TRANSFER);
107    data.extend_from_slice(&address_word(to));
108    data.extend_from_slice(&uint_word(units));
109    Ok(hex::encode_prefixed(&data))
110}
111
112/// [`usdc_transfer_calldata`] to the network's bridge, refusing what it
113/// would lose. The transaction goes to [`usdc`] on [`chain_id`] with no value.
114pub fn deposit_calldata(network: Network, amount: Dec) -> Result<String, BadDeposit> {
115    if amount.is_positive() && amount < MINIMUM_DEPOSIT {
116        return Err(BadDeposit::BelowMinimum(amount));
117    }
118    usdc_transfer_calldata(bridge(network), amount)
119}
120
121/// `0x` calldata for `balanceOf(owner)`.
122#[must_use]
123pub fn balance_of_calldata(owner: Address) -> String {
124    let mut data = Vec::with_capacity(36);
125    data.extend_from_slice(&BALANCE_OF);
126    data.extend_from_slice(&address_word(owner));
127    hex::encode_prefixed(&data)
128}
129
130/// `eth_call` of `data` on `to` at the latest block.
131#[must_use]
132pub fn eth_call_request(to: Address, data: &str) -> String {
133    let call = Json::Object(vec![
134        ("to".to_owned(), Json::String(to.to_hex())),
135        ("data".to_owned(), Json::String(data.to_owned())),
136    ]);
137    rpc("eth_call", vec![call, Json::String("latest".to_owned())])
138}
139
140/// The owner's USDC on the network's chain, read with `balanceOf`; its answer
141/// reads with [`read_quantity`] at [`USDC_DECIMALS`].
142#[must_use]
143pub fn usdc_balance_request(network: Network, owner: Address) -> String {
144    eth_call_request(usdc(network), &balance_of_calldata(owner))
145}
146
147/// The owner's ETH, for gas; its answer reads at [`ETH_DECIMALS`].
148#[must_use]
149pub fn eth_balance_request(owner: Address) -> String {
150    rpc("eth_getBalance", vec![Json::String(owner.to_hex()), Json::String("latest".to_owned())])
151}
152
153fn rpc(method: &str, params: Vec<Json>) -> String {
154    Json::Object(vec![
155        ("jsonrpc".to_owned(), Json::String("2.0".to_owned())),
156        ("id".to_owned(), Json::Number("1".to_owned())),
157        ("method".to_owned(), Json::String(method.to_owned())),
158        ("params".to_owned(), Json::Array(params)),
159    ])
160    .compact()
161}
162
163/// A JSON-RPC answer's `result`, a hex quantity or 32-byte word, as an amount
164/// with `decimals` places.
165pub fn read_quantity(answer: &str, decimals: u32) -> Result<Dec, RpcError> {
166    let said: Value = serde_json::from_str(answer).map_err(|error| RpcError::Unreadable(error.to_string()))?;
167    if let Some(error) = said.get("error") {
168        let message = error.get("message").and_then(Value::as_str).map_or_else(|| error.to_string(), str::to_owned);
169        return Err(RpcError::Refused(message));
170    }
171    let result = said.get("result").and_then(Value::as_str).ok_or_else(|| RpcError::Unreadable(said.to_string()))?;
172    quantity(result, decimals).ok_or_else(|| RpcError::Unreadable(result.to_owned()))
173}
174
175/// `0x`-prefixed hex as an amount with `decimals` places: `0x4c4b40` at six is
176/// `5`. `None` for anything else, or past 128 bits.
177#[must_use]
178pub fn quantity(text: &str, decimals: u32) -> Option<Dec> {
179    let digits = text.strip_prefix("0x")?.trim_start_matches('0');
180    let units = match digits.is_empty() {
181        true => 0,
182        false if digits.len() <= 32 => u128::from_str_radix(digits, 16).ok()?,
183        false => return None,
184    };
185    Dec::new(i128::try_from(units).ok()?, decimals)
186}
187
188fn address_word(address: Address) -> [u8; 32] {
189    let mut word = [0u8; 32];
190    word[12..].copy_from_slice(address.bytes());
191    word
192}
193
194fn uint_word(value: u128) -> [u8; 32] {
195    let mut word = [0u8; 32];
196    word[16..].copy_from_slice(&value.to_be_bytes());
197    word
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    fn dec(text: &str) -> Dec {
205        Dec::parse(text).unwrap()
206    }
207
208    #[test]
209    fn each_network_deposits_through_its_own_bridge_and_usdc() {
210        assert_eq!(bridge(Network::Mainnet).to_hex(), "0x2df1c51e09aecf9cacb7bc98cb1742757f163df7");
211        assert_eq!(bridge(Network::Testnet).to_hex(), "0x08cfc1b6b2dcf36a1480b99353a354aa8ac56f89");
212        assert_eq!(usdc(Network::Mainnet).to_hex(), "0xaf88d065e77c8cc2239327c5edb3a432268e5831");
213        assert_eq!(usdc(Network::Testnet).to_hex(), "0x1baabb04529d43a73232b713c0fe471f7c7334d5");
214        assert_eq!((chain_id(Network::Mainnet), chain_id(Network::Testnet)), (42_161, 421_614));
215        assert_eq!(rpc_url(Network::Mainnet), "https://arb1.arbitrum.io/rpc");
216        assert_eq!(rpc_url(Network::Testnet), "https://sepolia-rollup.arbitrum.io/rpc");
217        assert_eq!(MINIMUM_DEPOSIT, dec("5"));
218    }
219
220    #[test]
221    fn a_deposit_is_a_usdc_transfer_to_the_bridge_in_millionths() {
222        assert_eq!(
223            deposit_calldata(Network::Mainnet, dec("12.5")).unwrap(),
224            concat!(
225                "0xa9059cbb",
226                "0000000000000000000000002df1c51e09aecf9cacb7bc98cb1742757f163df7",
227                "0000000000000000000000000000000000000000000000000000000000bebc20",
228            )
229        );
230        assert_eq!(
231            deposit_calldata(Network::Testnet, dec("12.5")).unwrap(),
232            concat!(
233                "0xa9059cbb",
234                "00000000000000000000000008cfc1b6b2dcf36a1480b99353a354aa8ac56f89",
235                "0000000000000000000000000000000000000000000000000000000000bebc20",
236            )
237        );
238        assert_eq!(
239            usdc_transfer_calldata(Address::ZERO, dec("0.000001")).unwrap(),
240            concat!(
241                "0xa9059cbb",
242                "0000000000000000000000000000000000000000000000000000000000000000",
243                "0000000000000000000000000000000000000000000000000000000000000001",
244            )
245        );
246        assert_eq!(deposit_calldata(Network::Mainnet, dec("5")).unwrap().len(), 2 + 8 + 64 + 64);
247    }
248
249    #[test]
250    fn what_the_bridge_would_lose_or_round_is_refused() {
251        for network in Network::ALL {
252            assert_eq!(deposit_calldata(network, dec("4.999999")), Err(BadDeposit::BelowMinimum(dec("4.999999"))));
253            assert_eq!(deposit_calldata(network, dec("5.0000001")), Err(BadDeposit::TooFine(dec("5.0000001"))));
254            assert_eq!(deposit_calldata(network, Dec::ZERO), Err(BadDeposit::NotPositive(Dec::ZERO)));
255        }
256        assert_eq!(
257            usdc_transfer_calldata(bridge(Network::Mainnet), dec("-5")),
258            Err(BadDeposit::NotPositive(dec("-5")))
259        );
260    }
261
262    #[test]
263    fn the_reads_are_json_rpc_with_balance_of_calldata() {
264        let owner: Address = "0x14791697260E4c9A71f18484C9f997B308e59325".parse().unwrap();
265        assert_eq!(
266            balance_of_calldata(owner),
267            "0x70a0823100000000000000000000000014791697260e4c9a71f18484c9f997b308e59325"
268        );
269        assert_eq!(
270            usdc_balance_request(Network::Mainnet, owner),
271            concat!(
272                r#"{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0xaf88d065e77c8cc2239327c5edb3a432268e5831","#,
273                r#""data":"0x70a0823100000000000000000000000014791697260e4c9a71f18484c9f997b308e59325"},"latest"]}"#
274            )
275        );
276        assert!(
277            usdc_balance_request(Network::Testnet, owner)
278                .contains(r#""to":"0x1baabb04529d43a73232b713c0fe471f7c7334d5""#)
279        );
280        assert_eq!(
281            eth_balance_request(owner),
282            r#"{"jsonrpc":"2.0","id":1,"method":"eth_getBalance","params":["0x14791697260e4c9a71f18484c9f997b308e59325","latest"]}"#
283        );
284    }
285
286    #[test]
287    fn answers_read_as_exact_amounts() {
288        let word =
289            r#"{"jsonrpc":"2.0","id":1,"result":"0x00000000000000000000000000000000000000000000000000000000004c4b40"}"#;
290        assert_eq!(read_quantity(word, USDC_DECIMALS), Ok(dec("5")));
291        let wei = r#"{"jsonrpc":"2.0","id":1,"result":"0x2386f26fc10000"}"#;
292        assert_eq!(read_quantity(wei, ETH_DECIMALS), Ok(dec("0.01")));
293        assert_eq!(read_quantity(r#"{"jsonrpc":"2.0","id":1,"result":"0x0"}"#, 18), Ok(Dec::ZERO));
294        assert_eq!(read_quantity(r#"{"jsonrpc":"2.0","id":1,"result":"0x"}"#, 6), Ok(Dec::ZERO));
295        assert_eq!(
296            read_quantity(r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"execution reverted"}}"#, 6),
297            Err(RpcError::Refused("execution reverted".to_owned()))
298        );
299        assert!(read_quantity(r#"{"jsonrpc":"2.0","id":1,"result":"12"}"#, 6).is_err());
300        assert!(read_quantity("<html>", 6).is_err());
301        assert_eq!(quantity(&format!("0x1{}", "0".repeat(32)), 6), None, "past 128 bits");
302    }
303}