aztec_ethereum/
l1_client.rs

1//! L1 (Ethereum) client for interacting with Aztec protocol contracts.
2//!
3//! Uses raw JSON-RPC via `reqwest` to avoid heavy alloy dependencies.
4//! Provides wrappers for the Inbox contract's `sendL2Message` function.
5
6use aztec_core::types::{AztecAddress, Fr};
7use sha3::{Digest, Keccak256};
8
9// ---------------------------------------------------------------------------
10// L1 Contract Addresses
11// ---------------------------------------------------------------------------
12
13/// Parsed L1 contract addresses from node info.
14#[derive(Clone, Debug)]
15pub struct L1ContractAddresses {
16    pub inbox: String,
17    pub outbox: String,
18    pub rollup: String,
19    pub fee_juice_portal: String,
20    /// L1 ERC-20 Fee Juice token address.
21    pub fee_juice: Option<String>,
22    /// Fee asset handler address (mints Fee Juice on L1 in sandbox).
23    pub fee_asset_handler: Option<String>,
24}
25
26impl L1ContractAddresses {
27    /// Parse from the `l1ContractAddresses` JSON in `NodeInfo`.
28    pub fn from_json(json: &serde_json::Value) -> Option<Self> {
29        Some(Self {
30            inbox: json.get("inboxAddress")?.as_str()?.to_owned(),
31            outbox: json.get("outboxAddress")?.as_str()?.to_owned(),
32            rollup: json.get("rollupAddress")?.as_str()?.to_owned(),
33            fee_juice_portal: json.get("feeJuicePortalAddress")?.as_str()?.to_owned(),
34            fee_juice: json
35                .get("feeJuiceAddress")
36                .and_then(|v| v.as_str())
37                .map(|s| s.to_owned()),
38            fee_asset_handler: json
39                .get("feeAssetHandlerAddress")
40                .and_then(|v| v.as_str())
41                .map(|s| s.to_owned()),
42        })
43    }
44}
45
46// ---------------------------------------------------------------------------
47// Result types
48// ---------------------------------------------------------------------------
49
50/// Result of sending an L1→L2 message via the Inbox contract.
51#[derive(Clone, Debug)]
52pub struct L1ToL2MessageSentResult {
53    /// The message hash from the `MessageSent` event.
54    pub msg_hash: Fr,
55    /// The global leaf index in the L1→L2 message tree.
56    pub global_leaf_index: Fr,
57    /// The L1 transaction hash.
58    pub tx_hash: String,
59}
60
61// ---------------------------------------------------------------------------
62// Ethereum JSON-RPC helpers
63// ---------------------------------------------------------------------------
64
65/// A minimal Ethereum JSON-RPC client.
66pub struct EthClient {
67    url: String,
68    client: reqwest::Client,
69}
70
71impl EthClient {
72    pub fn new(url: &str) -> Self {
73        Self {
74            url: url.to_owned(),
75            client: reqwest::Client::new(),
76        }
77    }
78
79    /// Get the default L1 RPC URL from env or fallback.
80    pub fn default_url() -> String {
81        std::env::var("ETHEREUM_HOST").unwrap_or_else(|_| "http://localhost:8545".to_owned())
82    }
83
84    pub async fn rpc_call(
85        &self,
86        method: &str,
87        params: serde_json::Value,
88    ) -> Result<serde_json::Value, aztec_core::Error> {
89        let body = serde_json::json!({
90            "jsonrpc": "2.0",
91            "method": method,
92            "params": params,
93            "id": 1,
94        });
95        let resp = self
96            .client
97            .post(&self.url)
98            .json(&body)
99            .send()
100            .await
101            .map_err(|e| aztec_core::Error::InvalidData(format!("L1 RPC error: {e}")))?;
102
103        let json: serde_json::Value = resp
104            .json()
105            .await
106            .map_err(|e| aztec_core::Error::InvalidData(format!("L1 RPC parse error: {e}")))?;
107
108        if let Some(err) = json.get("error") {
109            return Err(aztec_core::Error::InvalidData(format!(
110                "L1 RPC error: {}",
111                err
112            )));
113        }
114
115        Ok(json["result"].clone())
116    }
117
118    /// Get the first account from the L1 node (for sandbox use).
119    pub async fn get_account(&self) -> Result<String, aztec_core::Error> {
120        let result = self.rpc_call("eth_accounts", serde_json::json!([])).await?;
121        result
122            .as_array()
123            .and_then(|a| a.first())
124            .and_then(|v| v.as_str())
125            .map(|s| s.to_owned())
126            .ok_or_else(|| aztec_core::Error::InvalidData("no L1 accounts available".into()))
127    }
128
129    /// Send a raw transaction via `eth_sendTransaction` (sandbox/Anvil only).
130    pub async fn send_transaction(
131        &self,
132        to: &str,
133        data: &str,
134        from: &str,
135    ) -> Result<String, aztec_core::Error> {
136        let result = self
137            .rpc_call(
138                "eth_sendTransaction",
139                serde_json::json!([{
140                    "from": from,
141                    "to": to,
142                    "data": data,
143                    "gas": "0xf4240", // 1_000_000
144                }]),
145            )
146            .await?;
147
148        result
149            .as_str()
150            .map(|s| s.to_owned())
151            .ok_or_else(|| aztec_core::Error::InvalidData("no tx hash in response".into()))
152    }
153
154    /// Wait for a transaction receipt.
155    pub async fn wait_for_receipt(
156        &self,
157        tx_hash: &str,
158    ) -> Result<serde_json::Value, aztec_core::Error> {
159        for _ in 0..60 {
160            let result = self
161                .rpc_call("eth_getTransactionReceipt", serde_json::json!([tx_hash]))
162                .await?;
163            if !result.is_null() {
164                return Ok(result);
165            }
166            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
167        }
168        Err(aztec_core::Error::Timeout(
169            "L1 tx receipt not available after 30s".into(),
170        ))
171    }
172}
173
174// ---------------------------------------------------------------------------
175// Inbox interaction
176// ---------------------------------------------------------------------------
177
178/// Compute the `sendL2Message` ABI-encoded calldata.
179///
180/// Function signature: `sendL2Message((bytes32,uint256),bytes32,bytes32)`
181/// Selector: first 4 bytes of keccak256 of the signature.
182fn encode_send_l2_message(
183    recipient: &AztecAddress,
184    rollup_version: u64,
185    content: &Fr,
186    secret_hash: &Fr,
187) -> String {
188    // Compute function selector
189    let sig = b"sendL2Message((bytes32,uint256),bytes32,bytes32)";
190    let mut hasher = Keccak256::new();
191    hasher.update(sig);
192    let selector = &hasher.finalize()[..4];
193
194    // ABI encode parameters:
195    // - recipient.actor (bytes32): 32 bytes
196    // - recipient.version (uint256): 32 bytes
197    // - content (bytes32): 32 bytes
198    // - secretHash (bytes32): 32 bytes
199    let actor_bytes = recipient.0.to_be_bytes();
200    let mut version_bytes = [0u8; 32];
201    version_bytes[24..32].copy_from_slice(&rollup_version.to_be_bytes());
202    let content_bytes = content.to_be_bytes();
203    let secret_hash_bytes = secret_hash.to_be_bytes();
204
205    let mut calldata = Vec::with_capacity(4 + 128);
206    calldata.extend_from_slice(selector);
207    calldata.extend_from_slice(&actor_bytes);
208    calldata.extend_from_slice(&version_bytes);
209    calldata.extend_from_slice(&content_bytes);
210    calldata.extend_from_slice(&secret_hash_bytes);
211
212    format!("0x{}", hex::encode(&calldata))
213}
214
215/// Send an L1→L2 message via the Inbox contract.
216///
217/// Returns the message hash and global leaf index from the `MessageSent` event.
218pub async fn send_l1_to_l2_message(
219    eth_client: &EthClient,
220    inbox_address: &str,
221    recipient: &AztecAddress,
222    rollup_version: u64,
223    content: &Fr,
224    secret_hash: &Fr,
225) -> Result<L1ToL2MessageSentResult, aztec_core::Error> {
226    let from = eth_client.get_account().await?;
227    let calldata = encode_send_l2_message(recipient, rollup_version, content, secret_hash);
228
229    let tx_hash = eth_client
230        .send_transaction(inbox_address, &calldata, &from)
231        .await?;
232
233    let receipt = eth_client.wait_for_receipt(&tx_hash).await?;
234
235    // Check status
236    let status = receipt
237        .get("status")
238        .and_then(|v| v.as_str())
239        .unwrap_or("0x0");
240    if status != "0x1" {
241        return Err(aztec_core::Error::InvalidData(format!(
242            "L1 tx {tx_hash} failed with status {status}"
243        )));
244    }
245
246    // Parse MessageSent event from logs
247    // Actual Solidity event:
248    //   event MessageSent(uint256 indexed checkpointNumber, uint256 index, bytes32 indexed hash, bytes16 rollingHash)
249    // Topics: [signature, checkpointNumber, hash]
250    // Data: [index (uint256), rollingHash (bytes16 padded to 32)]
251    let event_sig = {
252        let mut hasher = Keccak256::new();
253        hasher.update(b"MessageSent(uint256,uint256,bytes32,bytes16)");
254        format!("0x{}", hex::encode(hasher.finalize()))
255    };
256
257    let logs = receipt
258        .get("logs")
259        .and_then(|v| v.as_array())
260        .ok_or_else(|| aztec_core::Error::InvalidData("no logs in L1 receipt".into()))?;
261
262    for log in logs {
263        let topics = log
264            .get("topics")
265            .and_then(|v| v.as_array())
266            .unwrap_or(&Vec::new())
267            .clone();
268        if topics.len() >= 3 {
269            let topic0 = topics[0].as_str().unwrap_or("");
270            if topic0 == event_sig {
271                // topics[1] = checkpointNumber (indexed), topics[2] = hash (indexed)
272                // data = ABI-encoded (uint256 index, bytes16 rollingHash)
273                let hash_hex = topics[2].as_str().unwrap_or("0x0");
274                let hash_bytes = hex::decode(hash_hex.strip_prefix("0x").unwrap_or(hash_hex))
275                    .unwrap_or_default();
276
277                let mut hsh = [0u8; 32];
278                let start = 32usize.saturating_sub(hash_bytes.len());
279                hsh[start..].copy_from_slice(&hash_bytes);
280
281                // Parse index from data (first 32 bytes of log data)
282                let data_hex = log.get("data").and_then(|v| v.as_str()).unwrap_or("0x");
283                let data_bytes = hex::decode(data_hex.strip_prefix("0x").unwrap_or(data_hex))
284                    .unwrap_or_default();
285                let mut idx = [0u8; 32];
286                if data_bytes.len() >= 32 {
287                    idx.copy_from_slice(&data_bytes[..32]);
288                }
289
290                return Ok(L1ToL2MessageSentResult {
291                    msg_hash: Fr::from(hsh),
292                    global_leaf_index: Fr::from(idx),
293                    tx_hash,
294                });
295            }
296        }
297    }
298
299    Err(aztec_core::Error::InvalidData(
300        "no MessageSent event found in L1 receipt".into(),
301    ))
302}
303
304// ---------------------------------------------------------------------------
305// Fee Juice bridge helpers
306// ---------------------------------------------------------------------------
307
308fn keccak_selector(sig: &[u8]) -> [u8; 4] {
309    let mut hasher = Keccak256::new();
310    hasher.update(sig);
311    let hash = hasher.finalize();
312    let mut sel = [0u8; 4];
313    sel.copy_from_slice(&hash[..4]);
314    sel
315}
316
317/// ABI-encode `mint(address)` on the FeeAssetHandler.
318fn encode_mint(to: &str) -> String {
319    let sel = keccak_selector(b"mint(address)");
320    let mut calldata = Vec::with_capacity(4 + 32);
321    calldata.extend_from_slice(&sel);
322    // address is left-padded to 32 bytes
323    let addr_bytes = hex::decode(to.strip_prefix("0x").unwrap_or(to)).unwrap_or_default();
324    let mut padded = [0u8; 32];
325    let start = 32usize.saturating_sub(addr_bytes.len());
326    padded[start..].copy_from_slice(&addr_bytes);
327    calldata.extend_from_slice(&padded);
328    format!("0x{}", hex::encode(&calldata))
329}
330
331/// ABI-encode `approve(address,uint256)` on the ERC-20 token.
332fn encode_approve(spender: &str, amount: u128) -> String {
333    let sel = keccak_selector(b"approve(address,uint256)");
334    let mut calldata = Vec::with_capacity(4 + 64);
335    calldata.extend_from_slice(&sel);
336    let addr_bytes = hex::decode(spender.strip_prefix("0x").unwrap_or(spender)).unwrap_or_default();
337    let mut padded = [0u8; 32];
338    let start = 32usize.saturating_sub(addr_bytes.len());
339    padded[start..].copy_from_slice(&addr_bytes);
340    calldata.extend_from_slice(&padded);
341    let mut amt = [0u8; 32];
342    amt[16..32].copy_from_slice(&amount.to_be_bytes());
343    calldata.extend_from_slice(&amt);
344    format!("0x{}", hex::encode(&calldata))
345}
346
347/// ABI-encode `depositToAztecPublic(bytes32,uint256,bytes32)` on the Fee Juice Portal.
348fn encode_deposit_to_aztec_public(to: &AztecAddress, amount: u128, secret_hash: &Fr) -> String {
349    let sel = keccak_selector(b"depositToAztecPublic(bytes32,uint256,bytes32)");
350    let mut calldata = Vec::with_capacity(4 + 96);
351    calldata.extend_from_slice(&sel);
352    calldata.extend_from_slice(&to.0.to_be_bytes());
353    let mut amt = [0u8; 32];
354    amt[16..32].copy_from_slice(&amount.to_be_bytes());
355    calldata.extend_from_slice(&amt);
356    calldata.extend_from_slice(&secret_hash.to_be_bytes());
357    format!("0x{}", hex::encode(&calldata))
358}
359
360/// ABI-encode `mintAmount()` view call on the FeeAssetHandler.
361///
362/// `mintAmount` is a public state variable; Solidity auto-generates a
363/// `mintAmount()` getter — there is no `getMintAmount()` function.
364fn encode_mint_amount() -> String {
365    let sel = keccak_selector(b"mintAmount()");
366    format!("0x{}", hex::encode(sel))
367}
368
369/// Result of preparing Fee Juice on L1 for an L2 claim.
370#[derive(Clone, Debug)]
371pub struct FeeJuiceBridgeResult {
372    /// Amount bridged.
373    pub claim_amount: u128,
374    /// Secret to claim with.
375    pub claim_secret: Fr,
376    /// Index in the L1→L2 message tree.
377    pub message_leaf_index: u64,
378    /// Message hash (the `key` from the portal event) used to check sync status.
379    pub message_hash: Fr,
380}
381
382/// Mint Fee Juice on L1 and bridge it to L2 via the Fee Juice Portal.
383///
384/// Mirrors upstream `GasBridgingTestHarness.prepareTokensOnL1()`:
385/// 1. Queries `getMintAmount()` from the FeeAssetHandler
386/// 2. Mints ERC-20 tokens on L1
387/// 3. Approves the portal to spend them
388/// 4. Calls `depositToAztecPublic` on the portal
389/// 5. Parses the `DepositToAztecPublic` event for the message leaf index
390///
391/// Returns the claim data needed for `FeeJuicePaymentMethodWithClaim`.
392pub async fn prepare_fee_juice_on_l1(
393    eth_client: &EthClient,
394    l1_addresses: &L1ContractAddresses,
395    recipient: &AztecAddress,
396) -> Result<FeeJuiceBridgeResult, aztec_core::Error> {
397    let fee_juice_address = l1_addresses
398        .fee_juice
399        .as_deref()
400        .ok_or_else(|| aztec_core::Error::InvalidData("feeJuiceAddress not in node info".into()))?;
401    let handler_address = l1_addresses.fee_asset_handler.as_deref().ok_or_else(|| {
402        aztec_core::Error::InvalidData("feeAssetHandlerAddress not in node info".into())
403    })?;
404    let portal_address = &l1_addresses.fee_juice_portal;
405    let from = eth_client.get_account().await?;
406
407    // 1. Query mint amount from FeeAssetHandler (public state variable getter)
408    let mint_amount = {
409        let data = encode_mint_amount();
410        let result = eth_client
411            .rpc_call(
412                "eth_call",
413                serde_json::json!([{ "to": handler_address, "data": data }, "latest"]),
414            )
415            .await
416            .map_err(|e| {
417                aztec_core::Error::InvalidData(format!("mintAmount() call failed: {e}"))
418            })?;
419        let hex_str = result.as_str().unwrap_or("0x0");
420        let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)).unwrap_or_default();
421        if bytes.len() >= 32 {
422            let mut amt_bytes = [0u8; 16];
423            amt_bytes.copy_from_slice(&bytes[16..32]);
424            u128::from_be_bytes(amt_bytes)
425        } else {
426            return Err(aztec_core::Error::InvalidData(format!(
427                "mintAmount() returned invalid data ({} bytes)",
428                bytes.len()
429            )));
430        }
431    };
432
433    // 2. Mint tokens on L1
434    let mint_data = encode_mint(&from);
435    let tx_hash = eth_client
436        .send_transaction(handler_address, &mint_data, &from)
437        .await
438        .map_err(|e| aztec_core::Error::InvalidData(format!("mint tx send failed: {e}")))?;
439    let receipt = eth_client.wait_for_receipt(&tx_hash).await?;
440    let status = receipt
441        .get("status")
442        .and_then(|v| v.as_str())
443        .unwrap_or("0x0");
444    if status != "0x1" {
445        return Err(aztec_core::Error::InvalidData(format!(
446            "mint tx reverted: {status}"
447        )));
448    }
449
450    // 3. Approve portal to spend tokens
451    let approve_data = encode_approve(portal_address, mint_amount);
452    let tx_hash = eth_client
453        .send_transaction(fee_juice_address, &approve_data, &from)
454        .await
455        .map_err(|e| aztec_core::Error::InvalidData(format!("approve tx send failed: {e}")))?;
456    let receipt = eth_client.wait_for_receipt(&tx_hash).await?;
457    let status = receipt
458        .get("status")
459        .and_then(|v| v.as_str())
460        .unwrap_or("0x0");
461    if status != "0x1" {
462        return Err(aztec_core::Error::InvalidData(format!(
463            "approve tx reverted: {status}"
464        )));
465    }
466
467    // 4. Generate claim secret
468    let (claim_secret, claim_secret_hash) = super::messaging::generate_claim_secret();
469
470    // 5. Deposit to Aztec public
471    let deposit_data = encode_deposit_to_aztec_public(recipient, mint_amount, &claim_secret_hash);
472    let tx_hash = eth_client
473        .send_transaction(portal_address, &deposit_data, &from)
474        .await
475        .map_err(|e| {
476            aztec_core::Error::InvalidData(format!("depositToAztecPublic tx send failed: {e}"))
477        })?;
478    let receipt = eth_client.wait_for_receipt(&tx_hash).await?;
479    let status = receipt
480        .get("status")
481        .and_then(|v| v.as_str())
482        .unwrap_or("0x0");
483    if status != "0x1" {
484        return Err(aztec_core::Error::InvalidData(format!(
485            "depositToAztecPublic tx reverted: {status}"
486        )));
487    }
488
489    // 6. Parse the Inbox's MessageSent event from the deposit receipt.
490    //
491    // The portal calls `inbox.sendL2Message(...)` internally, so the
492    // deposit receipt contains both the portal's DepositToAztecPublic
493    // event AND the Inbox's MessageSent event.  We parse MessageSent
494    // because its hash (topics[2]) is the canonical L1→L2 message hash
495    // that `get_l1_to_l2_message_checkpoint` expects.
496    //
497    //   event MessageSent(uint256 indexed checkpointNumber, uint256 index,
498    //                     bytes32 indexed hash, bytes16 rollingHash)
499    //   Topics: [sig, checkpointNumber, hash]
500    //   Data:   [index (uint256), rollingHash (bytes16 padded to 32)]
501    let message_sent_sig = {
502        let mut hasher = Keccak256::new();
503        hasher.update(b"MessageSent(uint256,uint256,bytes32,bytes16)");
504        format!("0x{}", hex::encode(hasher.finalize()))
505    };
506
507    let logs = receipt
508        .get("logs")
509        .and_then(|v| v.as_array())
510        .ok_or_else(|| {
511            aztec_core::Error::InvalidData("no logs in depositToAztecPublic receipt".into())
512        })?;
513
514    for log in logs {
515        let topics = log
516            .get("topics")
517            .and_then(|v| v.as_array())
518            .unwrap_or(&Vec::new())
519            .clone();
520        if topics.len() >= 3 && topics[0].as_str().unwrap_or("") == message_sent_sig {
521            // topics[2] = hash (indexed)
522            let hash_hex = topics[2].as_str().unwrap_or("0x0");
523            let hash_bytes =
524                hex::decode(hash_hex.strip_prefix("0x").unwrap_or(hash_hex)).unwrap_or_default();
525            let mut msg_hash = [0u8; 32];
526            let start = 32usize.saturating_sub(hash_bytes.len());
527            msg_hash[start..].copy_from_slice(&hash_bytes);
528
529            // data[0..32] = index (uint256)
530            let data_hex = log.get("data").and_then(|v| v.as_str()).unwrap_or("0x");
531            let data_bytes =
532                hex::decode(data_hex.strip_prefix("0x").unwrap_or(data_hex)).unwrap_or_default();
533            let message_leaf_index = if data_bytes.len() >= 32 {
534                let mut idx = [0u8; 8];
535                idx.copy_from_slice(&data_bytes[24..32]);
536                u64::from_be_bytes(idx)
537            } else {
538                0
539            };
540
541            return Ok(FeeJuiceBridgeResult {
542                claim_amount: mint_amount,
543                claim_secret,
544                message_leaf_index,
545                message_hash: Fr::from(msg_hash),
546            });
547        }
548    }
549
550    Err(aztec_core::Error::InvalidData(
551        "no MessageSent event found in deposit receipt".into(),
552    ))
553}