diff --git a/crates/fe/tests/fixtures/fe_test/call_with_default.fe b/crates/fe/tests/fixtures/fe_test/call_with_default.fe new file mode 100644 index 0000000000..7b4bf8f837 --- /dev/null +++ b/crates/fe/tests/fixtures/fe_test/call_with_default.fe @@ -0,0 +1,265 @@ +/// Tests for `call_with_default`: like `call`/`Address::call`, but a call +/// that succeeds with EMPTY returndata yields a caller-supplied default +/// instead of a decode revert. Reverts still bubble and non-empty returndata +/// is still decoded strictly. + +use std::evm::{RawMem, RawOps, last_returndata, mem} + +/// Strict caller-side view of an ERC20-style `transfer`. +msg TokenMsg { + #[selector = sol("transfer(address,uint256)")] + Transfer { receiver: Address, amount: u256 } -> bool, +} + +/// Same selector, but declared without a return type so the handler returns +/// void (empty returndata), like USDT-style tokens. +msg NoRetTokenMsg { + #[selector = sol("transfer(address,uint256)")] + Transfer { receiver: Address, amount: u256 }, +} + +pub contract CompliantToken { + recv TokenMsg { + Transfer { receiver, amount } -> bool { + true + } + } +} + +/// Reverts with a distinctive 32-byte payload; the marker sits in the top +/// bytes so `#[test(should_revert, selector = 0xdeadbeef)]` can match it. +pub contract RevertingToken { + recv TokenMsg { + Transfer { receiver, amount } -> bool { + let marker: u256 = 0xdeadbeefcafebabe << 192 + revert(marker) + } + } +} + +pub contract NoReturnToken { + recv NoRetTokenMsg { + Transfer { receiver, amount } {} + } +} + +pub contract FalseToken { + recv TokenMsg { + Transfer { receiver, amount } -> bool { + false + } + } +} + +/// Returns fewer than 32 bytes of returndata. +pub contract MalformedToken { + recv TokenMsg { + Transfer { receiver, amount } -> bool uses (raw: mut RawOps) { + let ptr = mem::alloc(32) + raw.mstore(addr: ptr, value: 0xaabbccdd << 224) + raw.return_data(offset: ptr, len: 4) + } + } +} + +/// Non-bool returns: proves the API is generic, not bool-special-cased. +msg ValueQueryMsg { + #[selector = 0x10] + GetValue -> u256, + #[selector = 0x11] + GetNothing -> u256, +} + +msg ValueImplMsg { + #[selector = 0x10] + GetValue -> u256, +} + +msg NoRetValueImplMsg { + #[selector = 0x11] + GetNothing, +} + +pub contract ValueToken { + recv ValueImplMsg { + GetValue -> u256 { + 42 + } + } + + recv NoRetValueImplMsg { + GetNothing {} + } +} + +/// Wrapper contract so a test can observe the revert data that +/// `call_with_default` bubbles out of a reverting callee. +msg CallerMsg { + #[selector = 0x01] + DoTransfer { token: Address } -> bool, +} + +pub contract Caller uses (call: mut Call) { + recv CallerMsg { + DoTransfer { token } -> bool uses (mut call) { + call.call_with_default( + addr: token, + gas: 100000, + value: 0, + message: TokenMsg::Transfer { receiver: Address { inner: 9 }, amount: 1 }, + default: true, + ) + } + } +} + +fn receiver() -> Address { + Address { inner: 2 } +} + +#[test] +fn test_present_returndata_is_decoded_not_defaulted() uses (evm: mut Evm) { + let token = evm.create2(value: 0, args: (), salt: 0) + + // default is false; the decoded `true` must win + let ok: bool = evm.call_with_default( + addr: token, + gas: 100000, + value: 0, + message: TokenMsg::Transfer { receiver: receiver(), amount: 1 }, + default: false, + ) + assert!(ok) +} + +#[test] +fn test_empty_returndata_yields_default() uses (evm: mut Evm) { + let token = evm.create2(value: 0, args: (), salt: 0) + + let ok: bool = evm.call_with_default( + addr: token, + gas: 100000, + value: 0, + message: TokenMsg::Transfer { receiver: receiver(), amount: 1 }, + default: true, + ) + assert!(ok) +} + +#[test] +fn test_default_never_masks_present_false() uses (evm: mut Evm) { + let token = evm.create2(value: 0, args: (), salt: 0) + + let ok: bool = evm.call_with_default( + addr: token, + gas: 100000, + value: 0, + message: TokenMsg::Transfer { receiver: receiver(), amount: 1 }, + default: true, + ) + assert!(!ok) +} + +#[test(should_revert, selector = 0xdeadbeef)] +fn test_reverting_token_still_bubbles() uses (evm: mut Evm) { + let token = evm.create2(value: 0, args: (), salt: 0) + + let _: bool = evm.call_with_default( + addr: token, + gas: 100000, + value: 0, + message: TokenMsg::Transfer { receiver: receiver(), amount: 1 }, + default: true, + ) +} + +#[test] +fn test_bubbled_revert_data_survives_verbatim() uses (evm: mut Evm) { + let token = evm.create2(value: 0, args: (), salt: 0) + let caller = evm.create2(value: 0, args: (), salt: 1) + + // Hand-encode CallerMsg::DoTransfer { token } so we can observe the + // wrapped call's revert data instead of reverting the test itself. + let ptr = mem::alloc(36) + evm.mstore(addr: ptr, value: 1 << 224) + evm.mstore(addr: ptr + 4, value: token.inner) + assert!(!evm.raw_call(addr: caller, gas: 300000, value: 0, args_offset: ptr, args_len: 36)) + + // The token's revert data must arrive untouched: exactly one word + // holding the marker. + let ret = last_returndata() + assert!(ret.len == 32) + assert!(evm.mload(ret.base) == 0xdeadbeefcafebabe << 192) +} + +#[test(should_revert)] +fn test_malformed_returndata_still_reverts_in_decoder() uses (evm: mut Evm) { + let token = evm.create2(value: 0, args: (), salt: 0) + + let _: bool = evm.call_with_default( + addr: token, + gas: 100000, + value: 0, + message: TokenMsg::Transfer { receiver: receiver(), amount: 1 }, + default: true, + ) +} + +#[test] +fn test_no_code_address_yields_default() uses (evm: mut Evm) { + // Documented trap: a call to an address with no code succeeds with empty + // returndata, so the default surfaces as a silent "success". + let eoa = Address { inner: 0xe0a } + + let ok: bool = evm.call_with_default( + addr: eoa, + gas: 100000, + value: 0, + message: TokenMsg::Transfer { receiver: receiver(), amount: 1 }, + default: true, + ) + assert!(ok) +} + +#[test] +fn test_non_bool_return_type() uses (evm: mut Evm) { + let token = evm.create2(value: 0, args: (), salt: 0) + + // Non-empty path: the decoded value wins over a non-trivial default. + let value: u256 = evm.call_with_default( + addr: token, + gas: 100000, + value: 0, + message: ValueQueryMsg::GetValue {}, + default: 777, + ) + assert!(value == 42) + + // Empty path: the default surfaces. + let value: u256 = evm.call_with_default( + addr: token, + gas: 100000, + value: 0, + message: ValueQueryMsg::GetNothing {}, + default: 777, + ) + assert!(value == 777) +} + +#[test] +fn test_address_call_with_default_convenience() uses (evm: mut Evm, call: mut Call) { + let compliant = evm.create2(value: 0, args: (), salt: 0) + let silent = evm.create2(value: 0, args: (), salt: 1) + + let ok: bool = compliant.call_with_default( + TokenMsg::Transfer { receiver: receiver(), amount: 1 }, + default: false, + ) + assert!(ok) + + let ok: bool = silent.call_with_default( + TokenMsg::Transfer { receiver: receiver(), amount: 1 }, + default: true, + ) + assert!(ok) +} diff --git a/crates/fe/tests/fixtures/fe_test/safe_erc20.fe b/crates/fe/tests/fixtures/fe_test/safe_erc20.fe new file mode 100644 index 0000000000..ca2273b1c5 --- /dev/null +++ b/crates/fe/tests/fixtures/fe_test/safe_erc20.fe @@ -0,0 +1,225 @@ +/// Tests for `std::evm::erc20`: SafeERC20-style `safe_transfer` / +/// `safe_transfer_from` / `safe_approve`, built on `call_with_default`. +/// Tolerates tokens that return no data, reverts on a `false` return, and +/// closes the no-code-at-address hole with a codesize check. + +use std::evm::{RawOps, erc20, mem} + +msg TokenMsg { + #[selector = sol("transfer(address,uint256)")] + Transfer { receiver: Address, amount: u256 } -> bool, + #[selector = sol("transferFrom(address,address,uint256)")] + TransferFrom { owner: Address, receiver: Address, amount: u256 } -> bool, + #[selector = sol("approve(address,uint256)")] + Approve { spender: Address, amount: u256 } -> bool, +} + +/// Same selectors, declared without return types: handlers execute and +/// return no data, like USDT-style tokens. +msg NoRetTokenMsg { + #[selector = sol("transfer(address,uint256)")] + Transfer { receiver: Address, amount: u256 }, + #[selector = sol("transferFrom(address,address,uint256)")] + TransferFrom { owner: Address, receiver: Address, amount: u256 }, + #[selector = sol("approve(address,uint256)")] + Approve { spender: Address, amount: u256 }, +} + +msg CallCountMsg { + #[selector = 0x01] + GetCallCount -> u256, +} + +pub contract CompliantToken { + recv TokenMsg { + Transfer { receiver, amount } -> bool { + true + } + TransferFrom { owner, receiver, amount } -> bool { + true + } + Approve { spender, amount } -> bool { + true + } + } +} + +/// Reverts with a distinctive 32-byte payload; the marker sits in the top +/// bytes so `#[test(should_revert, selector = 0xdeadbeef)]` can match it. +pub contract RevertingToken { + recv TokenMsg { + Transfer { receiver, amount } -> bool { + let marker: u256 = 0xdeadbeefcafebabe << 192 + revert(marker) + } + TransferFrom { owner, receiver, amount } -> bool { + let marker: u256 = 0xdeadbeefcafebabe << 192 + revert(marker) + } + Approve { spender, amount } -> bool { + let marker: u256 = 0xdeadbeefcafebabe << 192 + revert(marker) + } + } +} + +struct CallCount { + count: u256, +} + +/// Executes (counts calls) but returns no data. +pub contract NoReturnToken { + mut store: CallCount + + init() uses (mut store) { + store.count = 0 + } + + recv NoRetTokenMsg { + Transfer { receiver, amount } uses (mut store) { + store.count += 1 + } + TransferFrom { owner, receiver, amount } uses (mut store) { + store.count += 1 + } + Approve { spender, amount } uses (mut store) { + store.count += 1 + } + } + + recv CallCountMsg { + GetCallCount -> u256 uses (store) { + store.count + } + } +} + +pub contract FalseToken { + recv TokenMsg { + Transfer { receiver, amount } -> bool { + false + } + TransferFrom { owner, receiver, amount } -> bool { + false + } + Approve { spender, amount } -> bool { + false + } + } +} + +msg MalformedTokenMsg { + #[selector = sol("transfer(address,uint256)")] + Transfer { receiver: Address, amount: u256 } -> bool, +} + +/// Returns fewer than 32 bytes of returndata. +pub contract MalformedToken { + recv MalformedTokenMsg { + Transfer { receiver, amount } -> bool uses (raw: mut RawOps) { + let ptr = mem::alloc(32) + raw.mstore(addr: ptr, value: 0xaabbccdd << 224) + raw.return_data(offset: ptr, len: 4) + } + } +} + +fn alice() -> Address { + Address { inner: 2 } +} + +fn bob() -> Address { + Address { inner: 3 } +} + +fn call_count(token: Address) -> u256 uses (evm: mut Evm) { + evm.call(addr: token, gas: 100000, value: 0, message: CallCountMsg::GetCallCount {}) +} + +// --------------------------------------------------------------------------- +// safe_transfer: full mock matrix +// --------------------------------------------------------------------------- + +#[test] +fn test_safe_transfer_compliant_token() uses (evm: mut Evm, ctx: Ctx, call: mut Call) { + let token = evm.create2(value: 0, args: (), salt: 0) + erc20::safe_transfer(token, receiver: alice(), amount: 1) +} + +#[test(should_revert, selector = 0xdeadbeef)] +fn test_safe_transfer_bubbles_token_revert() uses (evm: mut Evm, ctx: Ctx, call: mut Call) { + let token = evm.create2(value: 0, args: (), salt: 0) + erc20::safe_transfer(token, receiver: alice(), amount: 1) +} + +#[test] +fn test_safe_transfer_no_return_token() uses (evm: mut Evm, ctx: Ctx, call: mut Call) { + let token = evm.create2(value: 0, args: (), salt: 0) + erc20::safe_transfer(token, receiver: alice(), amount: 1) + assert!(call_count(token) == 1) +} + +#[test(should_revert)] +fn test_safe_transfer_reverts_on_false_return() uses (evm: mut Evm, ctx: Ctx, call: mut Call) { + let token = evm.create2(value: 0, args: (), salt: 0) + erc20::safe_transfer(token, receiver: alice(), amount: 1) +} + +#[test(should_revert)] +fn test_safe_transfer_reverts_on_malformed_returndata() uses (evm: mut Evm, ctx: Ctx, call: mut Call) { + let token = evm.create2(value: 0, args: (), salt: 0) + erc20::safe_transfer(token, receiver: alice(), amount: 1) +} + +#[test(should_revert)] +fn test_safe_transfer_reverts_on_no_code_address() uses (evm: mut Evm, ctx: Ctx, call: mut Call) { + // Unlike bare `call_with_default`, the codesize check catches this. + let eoa = Address { inner: 0xe0a } + erc20::safe_transfer(token: eoa, receiver: alice(), amount: 1) +} + +// --------------------------------------------------------------------------- +// safe_transfer_from +// --------------------------------------------------------------------------- + +#[test] +fn test_safe_transfer_from_compliant_token() uses (evm: mut Evm, ctx: Ctx, call: mut Call) { + let token = evm.create2(value: 0, args: (), salt: 0) + erc20::safe_transfer_from(token, owner: alice(), receiver: bob(), amount: 1) +} + +#[test] +fn test_safe_transfer_from_no_return_token() uses (evm: mut Evm, ctx: Ctx, call: mut Call) { + let token = evm.create2(value: 0, args: (), salt: 0) + erc20::safe_transfer_from(token, owner: alice(), receiver: bob(), amount: 1) + assert!(call_count(token) == 1) +} + +#[test(should_revert)] +fn test_safe_transfer_from_reverts_on_false_return() uses (evm: mut Evm, ctx: Ctx, call: mut Call) { + let token = evm.create2(value: 0, args: (), salt: 0) + erc20::safe_transfer_from(token, owner: alice(), receiver: bob(), amount: 1) +} + +// --------------------------------------------------------------------------- +// safe_approve +// --------------------------------------------------------------------------- + +#[test] +fn test_safe_approve_compliant_token() uses (evm: mut Evm, ctx: Ctx, call: mut Call) { + let token = evm.create2(value: 0, args: (), salt: 0) + erc20::safe_approve(token, spender: alice(), amount: 1) +} + +#[test] +fn test_safe_approve_no_return_token() uses (evm: mut Evm, ctx: Ctx, call: mut Call) { + let token = evm.create2(value: 0, args: (), salt: 0) + erc20::safe_approve(token, spender: alice(), amount: 1) + assert!(call_count(token) == 1) +} + +#[test(should_revert)] +fn test_safe_approve_reverts_on_false_return() uses (evm: mut Evm, ctx: Ctx, call: mut Call) { + let token = evm.create2(value: 0, args: (), salt: 0) + erc20::safe_approve(token, spender: alice(), amount: 1) +} diff --git a/ingots/std/src/evm.fe b/ingots/std/src/evm.fe index 558b3f8b6e..11d50b8520 100644 --- a/ingots/std/src/evm.fe +++ b/ingots/std/src/evm.fe @@ -2,6 +2,7 @@ pub use calldata::{self, *} pub use crypto::{self, *} pub use memory_input::{self, *} pub use effects::{self, *} +pub use erc20::{self} pub use event::{self, *} pub use panic::{self, *} pub use ops::{ diff --git a/ingots/std/src/evm/effects.fe b/ingots/std/src/evm/effects.fe index b60982a473..f78520d4d5 100644 --- a/ingots/std/src/evm/effects.fe +++ b/ingots/std/src/evm/effects.fe @@ -498,6 +498,24 @@ pub trait Call: EvmCapabilitySeal { fn call(mut self, addr: own Address, gas: u256, value: u256, message: own M) -> M::Return where M: MsgVariant + Encode, M::Return: Decode + AbiSize + /// Like `call`, but if the call succeeds with EMPTY returndata, returns + /// `default` instead of reverting in the decoder. Reverts are still bubbled, + /// and non-empty returndata is still decoded strictly. + /// + /// Caveat (same as Vyper's `default_return_value`): a call to an address + /// with no code succeeds with empty returndata at the EVM level, so it + /// yields `default`, a silent no-op "success". For ERC20 interactions use + /// `std::evm::erc20`, which closes this hole with a code-existence check. + fn call_with_default( + mut self, + addr: own Address, + gas: u256, + value: u256, + message: own M, + default: own M::Return, + ) -> M::Return + where M: MsgVariant + Encode, M::Return: Decode + AbiSize + fn static(mut self, addr: own Address, gas: u256, message: own M) -> M::Return where M: MsgVariant + Encode, M::Return: Decode + AbiSize @@ -523,6 +541,18 @@ impl Address { { call.static(addr: self, gas: ops::gas(), message) } + + /// Call a contract at this address with a typed message, returning + /// `default` if the call succeeds with empty returndata. + /// + /// Forwards all available gas and sends no value. Reverts are bubbled and + /// non-empty returndata is decoded strictly; see `Call::call_with_default` + /// for the no-code-at-address caveat. + pub fn call_with_default(self, _ message: own M, default: own M::Return) -> M::Return + uses (call: mut Call) where M: MsgVariant + Encode, M::Return: Decode + AbiSize + { + call.call_with_default(addr: self, gas: ops::gas(), value: 0, message, default) + } } /// Combined EVM capability: provides all EVM effects. @@ -826,6 +856,29 @@ impl Call for Evm { decode_returndata(last_returndata()) } + fn call_with_default( + mut self, + addr: own Address, + gas: u256, + value: u256, + message: own M, + default: own M::Return, + ) -> M::Return + where M: MsgVariant + Encode, M::Return: Decode + AbiSize + { + let out: (u256, u256) = encode_calldata(>::SELECTOR, message) + + if !self.raw_call(addr, gas, value, args_offset: out.0, args_len: out.1) { + bubble_last_revert() + } + + if ops::returndatasize() == 0 { + return default + } + + decode_returndata(last_returndata()) + } + fn static(mut self, addr: own Address, gas: u256, message: own M) -> M::Return where M: MsgVariant + Encode, M::Return: Decode + AbiSize { diff --git a/ingots/std/src/evm/erc20.fe b/ingots/std/src/evm/erc20.fe new file mode 100644 index 0000000000..a5958e2646 --- /dev/null +++ b/ingots/std/src/evm/erc20.fe @@ -0,0 +1,94 @@ +//! Safe ERC20 helpers (OpenZeppelin `SafeERC20` equivalent). +//! +//! Widely used tokens (USDT, BNB, OMG, ...) execute `transfer`/`approve` but +//! return no data, so strictly decoding a `bool` result reverts on success. +//! These helpers tolerate empty returndata via `call_with_default`, and in +//! addition: +//! +//! - revert if the call succeeded with empty returndata but there is no code +//! at the token address (a call to a codeless account succeeds with empty +//! returndata at the EVM level, which would otherwise be a silent no-op) +//! - revert if the token returned `false` +//! +//! Reverts from the token itself are bubbled through untouched. +//! +//! `safe_approve` has no "must set the allowance to zero first" restriction; +//! callers dealing with tokens that require it (e.g. USDT) should approve 0 +//! first themselves. + +use super::effects::{Address, Call, Ctx, revert} +use ingot::abi::sol::sol +use ingot::evm::ops + +msg Erc20Msg { + #[selector = sol("transfer(address,uint256)")] + Transfer { receiver: Address, amount: u256 } -> bool, + #[selector = sol("transferFrom(address,address,uint256)")] + TransferFrom { owner: Address, receiver: Address, amount: u256 } -> bool, + #[selector = sol("approve(address,uint256)")] + Approve { spender: Address, amount: u256 } -> bool, +} + +/// Call `transfer(receiver, amount)` on `token`, reverting unless the +/// transfer succeeded under SafeERC20 rules. +pub fn safe_transfer(token: Address, receiver: Address, amount: u256) +uses (ctx: Ctx, call: mut Call) +{ + let ok = call.call_with_default( + addr: token, + gas: ops::gas(), + value: 0, + message: Erc20Msg::Transfer { receiver, amount }, + default: true, + ) + if !ok || empty_return_without_code(token) { + revert("erc20: transfer failed") + } +} + +/// Call `transferFrom(owner, receiver, amount)` on `token`, reverting unless +/// the transfer succeeded under SafeERC20 rules. +pub fn safe_transfer_from(token: Address, owner: Address, receiver: Address, amount: u256) +uses (ctx: Ctx, call: mut Call) +{ + let ok = call.call_with_default( + addr: token, + gas: ops::gas(), + value: 0, + message: Erc20Msg::TransferFrom { owner, receiver, amount }, + default: true, + ) + if !ok || empty_return_without_code(token) { + revert("erc20: transferFrom failed") + } +} + +/// Call `approve(spender, amount)` on `token`, reverting unless the approval +/// succeeded under SafeERC20 rules. +pub fn safe_approve(token: Address, spender: Address, amount: u256) +uses (ctx: Ctx, call: mut Call) +{ + let ok = call.call_with_default( + addr: token, + gas: ops::gas(), + value: 0, + message: Erc20Msg::Approve { spender, amount }, + default: true, + ) + if !ok || empty_return_without_code(token) { + revert("erc20: approve failed") + } +} + +/// True if the token call just performed succeeded with empty returndata and +/// `token` has no code, i.e. the "success" was a no-op call to a codeless +/// account. Checked only on the empty-returndata path (mirroring +/// OpenZeppelin) so compliant tokens pay no extra codesize gas. +/// +/// Must run before any further call, while returndata still belongs to the +/// token call. +fn empty_return_without_code(_ token: Address) -> bool +uses (ctx: Ctx) +{ + ops::returndatasize() == 0 && ctx.extcodesize(token) == 0 +}