aurora_engine_precompiles/
xcc.rs

1//! Cross contract call precompile.
2//!
3//! Allow Aurora users interacting with NEAR smart contracts using cross contract call primitives.
4
5use aurora_engine_sdk::io::IO;
6use aurora_engine_types::{
7    Cow, H160, H256, U256, Vec,
8    account_id::AccountId,
9    borsh::{self, BorshDeserialize},
10    format,
11    parameters::{CrossContractCallArgs, PromiseCreateArgs},
12    str,
13    types::{Address, EthGas, NearGas, balance::ZERO_YOCTO},
14    vec,
15};
16use aurora_evm::ExitError;
17use aurora_evm::backend::Log;
18use aurora_evm::executor::stack::{PrecompileFailure, PrecompileHandle};
19
20use crate::{HandleBasedPrecompile, PrecompileOutput, utils};
21
22pub mod costs {
23    use crate::prelude::types::{EthGas, NearGas};
24
25    /// Base EVM gas cost for calling this precompile.
26    /// Value obtained from the following methodology:
27    /// 1. Estimate the cost of calling this precompile in terms of NEAR gas.
28    ///    This is done by calling the precompile with inputs of different lengths
29    ///    and performing a linear regression to obtain a function
30    ///    `NEAR_gas = CROSS_CONTRACT_CALL_BASE + (input_length) * (CROSS_CONTRACT_CALL_BYTE)`.
31    /// 2. Convert the NEAR gas cost into an EVM gas cost using the conversion ratio below
32    ///    (`CROSS_CONTRACT_CALL_NEAR_GAS`).
33    ///
34    /// This process is done in the `test_xcc_eth_gas_cost` test in
35    /// `engine-tests/src/tests/xcc.rs`.
36    pub const CROSS_CONTRACT_CALL_BASE: EthGas = EthGas::new(343_650);
37    /// Additional EVM gas cost per bytes of input given.
38    /// See `CROSS_CONTRACT_CALL_BASE` for estimation methodology.
39    pub const CROSS_CONTRACT_CALL_BYTE: EthGas = EthGas::new(4);
40    /// EVM gas cost per NEAR gas attached to the created promise.
41    /// This value is derived from the gas report `https://hackmd.io/@birchmd/Sy4piXQ29`
42    /// The units on this quantity are `NEAR Gas / EVM Gas`.
43    /// The report gives a value `0.175 T(NEAR_gas) / k(EVM_gas)`. To convert the units to
44    /// `NEAR Gas / EVM Gas`, we simply multiply `0.175 * 10^12 / 10^3 = 175 * 10^6`.
45    pub const CROSS_CONTRACT_CALL_NEAR_GAS: u64 = 175_000_000;
46
47    pub const ROUTER_EXEC_BASE: NearGas = NearGas::new(7_000_000_000_000);
48    pub const ROUTER_EXEC_PER_CALLBACK: NearGas = NearGas::new(12_000_000_000_000);
49    pub const ROUTER_SCHEDULE: NearGas = NearGas::new(5_000_000_000_000);
50}
51
52mod consts {
53    pub(super) const ERR_INVALID_INPUT: &str = "ERR_INVALID_XCC_INPUT";
54    pub(super) const ERR_SERIALIZE: &str = "ERR_XCC_CALL_SERIALIZE";
55    pub(super) const ERR_STATIC: &str = "ERR_INVALID_IN_STATIC";
56    pub(super) const ERR_DELEGATE: &str = "ERR_INVALID_IN_DELEGATE";
57    pub(super) const ERR_XCC_ACCOUNT_ID: &str = "ERR_FAILED_TO_CREATE_XCC_ACCOUNT_ID";
58    pub(super) const ROUTER_EXEC_NAME: &str = "execute";
59    pub(super) const ROUTER_SCHEDULE_NAME: &str = "schedule";
60    /// Solidity selector for the ERC-20 transferFrom function
61    /// `https://www.4byte.directory/signatures/?bytes4_signature=0x23b872dd`
62    pub(super) const TRANSFER_FROM_SELECTOR: [u8; 4] = [0x23, 0xb8, 0x72, 0xdd];
63}
64
65pub struct CrossContractCall<I> {
66    io: I,
67    engine_account_id: AccountId,
68}
69
70impl<I> CrossContractCall<I> {
71    pub const fn new(engine_account_id: AccountId, io: I) -> Self {
72        Self {
73            io,
74            engine_account_id,
75        }
76    }
77}
78
79pub mod cross_contract_call {
80    use aurora_engine_types::{
81        H256,
82        types::{Address, make_address},
83    };
84
85    /// NEAR Cross Contract Call precompile address
86    ///
87    /// Address: `0x516cded1d16af10cad47d6d49128e2eb7d27b372`
88    /// This address is computed as: `&keccak("nearCrossContractCall")[12..]`
89    pub const ADDRESS: Address = make_address(0x516cded1, 0xd16af10cad47d6d49128e2eb7d27b372);
90
91    /// Sentinel value used to indicate the following topic field is how much NEAR the
92    /// cross-contract call will require.
93    pub const AMOUNT_TOPIC: H256 = crate::make_h256(
94        0x0072657175697265645f6e656172,
95        0x0072657175697265645f6e656172,
96    );
97}
98
99impl<I: IO> HandleBasedPrecompile for CrossContractCall<I> {
100    #[allow(clippy::too_many_lines)]
101    fn run_with_handle(
102        &self,
103        handle: &mut impl PrecompileHandle,
104    ) -> Result<PrecompileOutput, PrecompileFailure> {
105        let input = handle.input();
106        let target_gas = handle.gas_limit().map(EthGas::new);
107        let context = handle.context();
108        utils::validate_no_value_attached_to_precompile(context.apparent_value)?;
109        let is_static = handle.is_static();
110
111        // This only includes the cost we can easily derive without parsing the input.
112        // This allows failing fast without wasting computation on parsing.
113        let input_len = u64::try_from(input.len()).map_err(utils::err_usize_conv)?;
114        let mut cost =
115            costs::CROSS_CONTRACT_CALL_BASE + costs::CROSS_CONTRACT_CALL_BYTE * input_len;
116        let check_cost = |cost: EthGas| -> Result<(), PrecompileFailure> {
117            if let Some(target_gas) = target_gas {
118                if cost > target_gas {
119                    return Err(PrecompileFailure::Error {
120                        exit_status: ExitError::OutOfGas,
121                    });
122                }
123            }
124            Ok(())
125        };
126        check_cost(cost)?;
127
128        // It's not allowed to call cross contract call precompile in static or delegate mode
129        if is_static {
130            return Err(revert_with_message(consts::ERR_STATIC));
131        } else if context.address != cross_contract_call::ADDRESS.raw() {
132            return Err(revert_with_message(consts::ERR_DELEGATE));
133        }
134
135        let sender = context.caller;
136        let target_account_id = create_target_account_id(sender, self.engine_account_id.as_ref())?;
137        let args = CrossContractCallArgs::try_from_slice(input)
138            .map_err(|_| ExitError::Other(Cow::from(consts::ERR_INVALID_INPUT)))?;
139        let (promise, attached_near) = match args {
140            CrossContractCallArgs::Eager(call) => {
141                let call_gas = call.total_gas();
142                let attached_near = call.total_near();
143                let callback_count = call
144                    .promise_count()
145                    .checked_sub(1)
146                    .ok_or_else(|| ExitError::Other(Cow::from(consts::ERR_INVALID_INPUT)))?;
147                let router_exec_cost = costs::ROUTER_EXEC_BASE
148                    + NearGas::new(callback_count * costs::ROUTER_EXEC_PER_CALLBACK.as_u64());
149                let promise = PromiseCreateArgs {
150                    target_account_id,
151                    method: consts::ROUTER_EXEC_NAME.into(),
152                    args: borsh::to_vec(&call)
153                        .map_err(|_| ExitError::Other(Cow::from(consts::ERR_SERIALIZE)))?,
154                    attached_balance: ZERO_YOCTO,
155                    attached_gas: router_exec_cost.saturating_add(call_gas),
156                };
157                (promise, attached_near)
158            }
159            CrossContractCallArgs::Delayed(call) => {
160                let attached_near = call.total_near();
161                let promise = PromiseCreateArgs {
162                    target_account_id,
163                    method: consts::ROUTER_SCHEDULE_NAME.into(),
164                    args: borsh::to_vec(&call)
165                        .map_err(|_| ExitError::Other(Cow::from(consts::ERR_SERIALIZE)))?,
166                    attached_balance: ZERO_YOCTO,
167                    // We don't need to add any gas to the amount need for the schedule call
168                    // since the promise is not executed right away.
169                    attached_gas: costs::ROUTER_SCHEDULE,
170                };
171                (promise, attached_near)
172            }
173        };
174        cost += EthGas::new(promise.attached_gas.as_u64() / costs::CROSS_CONTRACT_CALL_NEAR_GAS);
175        check_cost(cost)?;
176
177        let required_near =
178            match state::get_code_version_of_address(&self.io, &Address::new(sender)) {
179                // If there is no deployed version of the router contract then we need to charge for storage staking
180                None => attached_near + state::STORAGE_AMOUNT,
181                Some(_) => attached_near,
182            };
183        // if some NEAR payment is needed, transfer it from the caller to the engine's implicit address
184        if required_near != ZERO_YOCTO {
185            let engine_implicit_address = aurora_engine_sdk::types::near_account_to_evm_address(
186                self.engine_account_id.as_bytes(),
187            );
188            let tx_data = transfer_from_args(
189                sender.0.into(),
190                engine_implicit_address.raw().0.into(),
191                required_near.as_u128().into(),
192            );
193            let wnear_address = state::get_wnear_address(&self.io);
194            let context = aurora_evm::Context {
195                address: wnear_address.raw(),
196                caller: cross_contract_call::ADDRESS.raw(),
197                apparent_value: U256::zero(),
198            };
199            let (exit_reason, return_value) =
200                handle.call(wnear_address.raw(), None, tx_data, None, false, &context);
201            match exit_reason {
202                // Transfer successful, nothing to do
203                aurora_evm::ExitReason::Succeed(_) => (),
204                aurora_evm::ExitReason::Revert(r) => {
205                    return Err(PrecompileFailure::Revert {
206                        exit_status: r,
207                        output: return_value,
208                    });
209                }
210                aurora_evm::ExitReason::Error(e) => {
211                    return Err(PrecompileFailure::Error { exit_status: e });
212                }
213                aurora_evm::ExitReason::Fatal(f) => {
214                    return Err(PrecompileFailure::Fatal { exit_status: f });
215                }
216            }
217        }
218
219        let topics = vec![
220            cross_contract_call::AMOUNT_TOPIC,
221            H256(aurora_engine_types::types::u256_to_arr(&U256::from(
222                required_near.as_u128(),
223            ))),
224        ];
225
226        let promise_log = Log {
227            address: cross_contract_call::ADDRESS.raw(),
228            topics,
229            data: borsh::to_vec(&promise)
230                .map_err(|_| ExitError::Other(Cow::from(consts::ERR_SERIALIZE)))?,
231        };
232
233        Ok(PrecompileOutput {
234            logs: vec![promise_log],
235            cost,
236            ..Default::default()
237        })
238    }
239}
240
241pub mod state {
242    //! Functions for reading state related to the cross-contract call feature
243
244    use aurora_engine_sdk::error::ReadU32Error;
245    use aurora_engine_sdk::io::{IO, StorageIntermediate};
246    use aurora_engine_types::parameters::xcc::CodeVersion;
247    use aurora_engine_types::storage::{self, KeyPrefix};
248    use aurora_engine_types::types::{Address, Yocto};
249
250    pub const ERR_CORRUPTED_STORAGE: &str = "ERR_CORRUPTED_XCC_STORAGE";
251    pub const ERR_MISSING_WNEAR_ADDRESS: &str = "ERR_MISSING_WNEAR_ADDRESS";
252    pub const VERSION_KEY: &[u8] = b"version";
253    pub const WNEAR_KEY: &[u8] = b"wnear";
254    /// Amount of NEAR needed to cover storage for a router contract.
255    pub const STORAGE_AMOUNT: Yocto = Yocto::new(2_000_000_000_000_000_000_000_000);
256
257    /// Get the address of the `wNEAR` ERC-20 contract
258    ///
259    /// # Panics
260    ///
261    /// Panic is ok here because there is no sense to continue with corrupted storage.
262    pub fn get_wnear_address<I: IO>(io: &I) -> Address {
263        let key = storage::bytes_to_key(KeyPrefix::CrossContractCall, WNEAR_KEY);
264        io.read_storage(&key).map_or_else(
265            || panic!("{ERR_MISSING_WNEAR_ADDRESS}"),
266            |bytes| Address::try_from_slice(&bytes.to_vec()).expect(ERR_CORRUPTED_STORAGE),
267        )
268    }
269
270    /// Get the latest router contract version.
271    pub fn get_latest_code_version<I: IO>(io: &I) -> CodeVersion {
272        let key = storage::bytes_to_key(KeyPrefix::CrossContractCall, VERSION_KEY);
273        read_version(io, &key).unwrap_or_default()
274    }
275
276    /// Get the version of the currently deploy router for the given address (if it exists).
277    pub fn get_code_version_of_address<I: IO>(io: &I, address: &Address) -> Option<CodeVersion> {
278        let key = storage::bytes_to_key(KeyPrefix::CrossContractCall, address.as_bytes());
279        read_version(io, &key)
280    }
281
282    /// Private utility method for reading code version from storage.
283    fn read_version<I: IO>(io: &I, key: &[u8]) -> Option<CodeVersion> {
284        match io.read_u32(key) {
285            Ok(value) => Some(CodeVersion(value)),
286            Err(ReadU32Error::MissingValue) => None,
287            Err(ReadU32Error::InvalidU32) => panic!("{}", ERR_CORRUPTED_STORAGE),
288        }
289    }
290}
291
292fn transfer_from_args(from: ethabi::Address, to: ethabi::Address, amount: ethabi::Uint) -> Vec<u8> {
293    let args = ethabi::encode(&[
294        ethabi::Token::Address(from),
295        ethabi::Token::Address(to),
296        ethabi::Token::Uint(amount),
297    ]);
298    [&consts::TRANSFER_FROM_SELECTOR, args.as_slice()].concat()
299}
300
301fn create_target_account_id(
302    sender: H160,
303    engine_account_id: &str,
304) -> Result<AccountId, PrecompileFailure> {
305    let mut buffer = [0; 40];
306    hex::encode_to_slice(sender.as_bytes(), &mut buffer)
307        .map_err(|_| revert_with_message(consts::ERR_XCC_ACCOUNT_ID))?;
308    let sender_in_hex =
309        str::from_utf8(&buffer).map_err(|_| revert_with_message(consts::ERR_XCC_ACCOUNT_ID))?;
310
311    AccountId::try_from(format!("{sender_in_hex}.{engine_account_id}"))
312        .map_err(|_| revert_with_message(consts::ERR_XCC_ACCOUNT_ID))
313}
314
315fn revert_with_message(message: &str) -> PrecompileFailure {
316    PrecompileFailure::Revert {
317        exit_status: aurora_evm::ExitRevert::Reverted,
318        output: message.as_bytes().to_vec(),
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use crate::prelude::sdk::types::near_account_to_evm_address;
325    use crate::xcc::cross_contract_call;
326    use aurora_engine_types::vec;
327    use rand::Rng;
328
329    #[test]
330    fn test_precompile_id() {
331        assert_eq!(
332            cross_contract_call::ADDRESS,
333            near_account_to_evm_address(b"nearCrossContractCall")
334        );
335    }
336
337    #[test]
338    fn test_transfer_from_encoding() {
339        let mut rng = rand::rng();
340
341        let from = rng.random::<[u8; 20]>().into();
342        let to = rng.random::<[u8; 20]>().into();
343        let amount = rng.random::<[u8; 32]>().into();
344
345        #[allow(deprecated)]
346        let transfer_from_function = ethabi::Function {
347            name: "transferFrom".into(),
348            inputs: vec![
349                ethabi::Param {
350                    name: "from".into(),
351                    kind: ethabi::ParamType::Address,
352                    internal_type: None,
353                },
354                ethabi::Param {
355                    name: "to".into(),
356                    kind: ethabi::ParamType::Address,
357                    internal_type: None,
358                },
359                ethabi::Param {
360                    name: "amount".into(),
361                    kind: ethabi::ParamType::Uint(256),
362                    internal_type: None,
363                },
364            ],
365            outputs: vec![ethabi::Param {
366                name: String::new(),
367                kind: ethabi::ParamType::Bool,
368                internal_type: None,
369            }],
370            constant: None,
371            state_mutability: ethabi::StateMutability::NonPayable,
372        };
373
374        let expected_tx_data = transfer_from_function
375            .encode_input(&[
376                ethabi::Token::Address(from),
377                ethabi::Token::Address(to),
378                ethabi::Token::Uint(amount),
379            ])
380            .unwrap();
381
382        assert_eq!(
383            super::transfer_from_args(from, to, amount),
384            expected_tx_data
385        );
386    }
387}