Skip to main content

polyester/chain/
fees.rs

1//! Zipper fee quoting for Funding → external withdraws.
2
3use alloy_primitives::{Address, U256};
4use alloy_sol_types::{SolCall, sol};
5use serde_json::json;
6
7use crate::chain::environment::{POLYESTER_TESTNET_ENVIRONMENT, PolyesterChainEnvironment};
8use crate::chain::rpc::JsonRpcClient;
9use crate::errors::{Error, Result};
10
11sol! {
12    function feeFactory() external view returns (address);
13    function decimals() external view returns (uint8);
14    function getFee(uint16 chainId, address zToken) external view returns (uint256);
15}
16
17/// Result of quoting a Zipper network fee for withdraws.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ZipperFeeQuote {
20    pub fee: U256,
21    pub z_token_decimals: u8,
22    pub fee_factory: String,
23    pub zipper_endpoint: String,
24}
25
26/// Quote Zipper network fee via `feeFactory.getFee(uint16,address)`.
27///
28/// Use the returned `fee` (or a small buffer above it) as `max_fee` for
29/// [`crate::chain::encode_funding_withdraw_to_chain`].
30pub async fn quote_zipper_fee(
31    chain_id: u16,
32    z_token: &str,
33    zipper_endpoint: &str,
34    environment: Option<&PolyesterChainEnvironment>,
35    rpc: Option<&JsonRpcClient>,
36) -> Result<ZipperFeeQuote> {
37    if chain_id == 0 {
38        return Err(Error::validation("chain_id must be a uint16 > 0"));
39    }
40    let token = normalize_address(z_token, "z_token")?;
41    let endpoint = normalize_address(zipper_endpoint, "zipper_endpoint")?;
42
43    let env = environment.unwrap_or(&POLYESTER_TESTNET_ENVIRONMENT);
44    let owned_client;
45    let client = match rpc {
46        Some(c) => c,
47        None => {
48            owned_client = JsonRpcClient::new(env.rpc_url, std::time::Duration::from_secs(60));
49            &owned_client
50        }
51    };
52
53    let ff_raw = eth_call(client, &endpoint, feeFactoryCall {}.abi_encode()).await?;
54    let fee_factory = address_from_eth_call_result(&ff_raw)?;
55
56    let decimals_raw = eth_call(client, &token, decimalsCall {}.abi_encode()).await?;
57    let decimals = u256_from_eth_call_result(&decimals_raw)?;
58    let z_token_decimals = u8::try_from(decimals)
59        .map_err(|_| Error::validation(format!("decimals out of range: {decimals}")))?;
60
61    let token_addr: Address = token
62        .parse()
63        .map_err(|_| Error::validation("z_token is not a valid hex address"))?;
64    let fee_raw = eth_call(
65        client,
66        &fee_factory,
67        getFeeCall {
68            chainId: chain_id,
69            zToken: token_addr,
70        }
71        .abi_encode(),
72    )
73    .await?;
74    let fee = u256_from_eth_call_result(&fee_raw)?;
75
76    Ok(ZipperFeeQuote {
77        fee,
78        z_token_decimals,
79        fee_factory,
80        zipper_endpoint: endpoint,
81    })
82}
83
84async fn eth_call(client: &JsonRpcClient, to: &str, data: Vec<u8>) -> Result<String> {
85    let result = client
86        .request(
87            "eth_call",
88            json!([
89                {
90                    "to": to,
91                    "data": format!("0x{}", hex::encode(data)),
92                },
93                "latest"
94            ]),
95        )
96        .await?;
97    result
98        .as_str()
99        .map(str::to_owned)
100        .ok_or_else(|| Error::transport("eth_call result is not a hex string"))
101}
102
103fn address_from_eth_call_result(raw: &str) -> Result<String> {
104    let hex_body = raw.trim().trim_start_matches("0x").to_ascii_lowercase();
105    if hex_body.len() < 40 {
106        return Err(Error::transport(format!(
107            "eth_call address result too short: {raw}"
108        )));
109    }
110    Ok(format!("0x{}", &hex_body[hex_body.len() - 40..]))
111}
112
113fn u256_from_eth_call_result(raw: &str) -> Result<U256> {
114    let hex_body = raw.trim().trim_start_matches("0x");
115    U256::from_str_radix(hex_body, 16)
116        .map_err(|_| Error::transport(format!("eth_call u256 decode failed: {raw}")))
117}
118
119fn normalize_address(value: &str, field: &str) -> Result<String> {
120    let addr = value.trim();
121    if !addr.starts_with("0x") || addr.len() != 42 {
122        return Err(Error::validation(format!(
123            "{field} must be a 20-byte 0x-prefixed address"
124        )));
125    }
126    if hex::decode(&addr[2..]).is_err() {
127        return Err(Error::validation(format!(
128            "{field} is not a valid hex address"
129        )));
130    }
131    Ok(addr.to_ascii_lowercase())
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn fee_factory_selector() {
140        assert_eq!(
141            feeFactoryCall::SELECTOR.as_slice(),
142            &alloy_primitives::keccak256(b"feeFactory()")[..4]
143        );
144    }
145
146    #[test]
147    fn get_fee_encode_round_trip() {
148        let token: Address = "0x5555555555555555555555555555555555555555"
149            .parse()
150            .unwrap();
151        let data = getFeeCall {
152            chainId: 56,
153            zToken: token,
154        }
155        .abi_encode();
156        assert_eq!(&data[..4], getFeeCall::SELECTOR.as_slice());
157        let decoded = getFeeCall::abi_decode(&data).unwrap();
158        assert_eq!(decoded.chainId, 56);
159        assert_eq!(decoded.zToken, token);
160    }
161
162    #[test]
163    fn address_from_padded_eth_call() {
164        let raw = "0x000000000000000000000000ae6b981be9b73421eb1ba5372d1a4a937d63fffb";
165        assert_eq!(
166            address_from_eth_call_result(raw).unwrap(),
167            "0xae6b981be9b73421eb1ba5372d1a4a937d63fffb"
168        );
169    }
170}