From 9f72cba36bd4c09d31395f477e3a1dd6ca3755a0 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:17:21 -0500 Subject: [PATCH 01/18] feat(tip-1004): add EIP-2612 permit to TIP-20 precompile Implements permit(), nonces(), and DOMAIN_SEPARATOR() for TIP-20 tokens, gated behind the T2 hardfork. Only EOA signatures are supported (no EIP-1271). Includes Solidity reference implementation and comprehensive tests. Amp-Thread-ID: https://ampcode.com/threads/T-019c6da0-59df-709e-ba03-6613b5571b2d Co-authored-by: Amp --- crates/contracts/src/precompiles/tip20.rs | 17 + crates/precompiles/src/tip20/dispatch.rs | 82 ++- crates/precompiles/src/tip20/mod.rs | 614 +++++++++++++++++- .../storage_tests/solidity/precompiles.rs | 8 +- .../solidity/testdata/tip20.layout.json | 2 +- .../storage_tests/solidity/testdata/tip20.sol | 4 +- tips/ref-impls/src/TIP20.sol | 50 ++ tips/ref-impls/src/interfaces/ITIP20.sol | 26 + 8 files changed, 790 insertions(+), 13 deletions(-) diff --git a/crates/contracts/src/precompiles/tip20.rs b/crates/contracts/src/precompiles/tip20.rs index 88c577d21a..b24aef4fc9 100644 --- a/crates/contracts/src/precompiles/tip20.rs +++ b/crates/contracts/src/precompiles/tip20.rs @@ -85,6 +85,11 @@ crate::sol! { /// @return The burn blocked role identifier function BURN_BLOCKED_ROLE() external view returns (bytes32); + // EIP-2612 Permit Functions + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external; + function nonces(address owner) external view returns (uint256); + function DOMAIN_SEPARATOR() external view returns (bytes32); + struct UserRewardInfo { address rewardRecipient; uint256 rewardPerToken; @@ -135,6 +140,8 @@ crate::sol! { error InvalidToken(); error Uninitialized(); error InvalidTransferPolicyId(); + error PermitExpired(); + error InvalidSignature(); } } @@ -244,4 +251,14 @@ impl TIP20Error { pub const fn uninitialized() -> Self { Self::Uninitialized(ITIP20::Uninitialized {}) } + + /// Error when permit signature has expired (block.timestamp > deadline) + pub const fn permit_expired() -> Self { + Self::PermitExpired(ITIP20::PermitExpired {}) + } + + /// Error when permit signature is invalid + pub const fn invalid_signature() -> Self { + Self::InvalidSignature(ITIP20::InvalidSignature {}) + } } diff --git a/crates/precompiles/src/tip20/dispatch.rs b/crates/precompiles/src/tip20/dispatch.rs index 269f0d6cb2..c46c2e7fe5 100644 --- a/crates/precompiles/src/tip20/dispatch.rs +++ b/crates/precompiles/src/tip20/dispatch.rs @@ -4,9 +4,9 @@ use crate::{ input_cost, metadata, mutate, mutate_void, storage::ContractStorage, tip20::{ITIP20, TIP20Token}, - view, + unknown_selector, view, }; -use alloy::{primitives::Address, sol_types::SolInterface}; +use alloy::{primitives::Address, sol_types::{SolCall, SolInterface}}; use revm::precompile::{PrecompileError, PrecompileResult}; use tempo_contracts::precompiles::{IRolesAuth::IRolesAuthCalls, ITIP20::ITIP20Calls, TIP20Error}; @@ -164,6 +164,34 @@ impl Precompile for TIP20Token { view(call, |c| self.get_pending_rewards(c.account)) } + TIP20Call::TIP20(ITIP20Calls::permit(call)) => { + if !self.storage.spec().is_t2() { + return unknown_selector( + ITIP20::permitCall::SELECTOR, + self.storage.gas_used(), + ); + } + mutate_void(call, msg_sender, |_s, c| self.permit(c)) + } + TIP20Call::TIP20(ITIP20Calls::nonces(call)) => { + if !self.storage.spec().is_t2() { + return unknown_selector( + ITIP20::noncesCall::SELECTOR, + self.storage.gas_used(), + ); + } + view(call, |c| self.nonces(c)) + } + TIP20Call::TIP20(ITIP20Calls::DOMAIN_SEPARATOR(call)) => { + if !self.storage.spec().is_t2() { + return unknown_selector( + ITIP20::DOMAIN_SEPARATORCall::SELECTOR, + self.storage.gas_used(), + ); + } + view(call, |_| self.domain_separator()) + } + // RolesAuth functions TIP20Call::RolesAuth(IRolesAuthCalls::hasRole(call)) => { view(call, |c| self.has_role(c)) @@ -720,7 +748,9 @@ mod tests { use crate::test_util::{assert_full_coverage, check_selector_coverage}; use tempo_contracts::precompiles::{IRolesAuth::IRolesAuthCalls, ITIP20::ITIP20Calls}; - let (mut storage, admin) = setup_storage(); + // Use T2 hardfork so T2-gated selectors (permit, nonces, DOMAIN_SEPARATOR) are active + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T2); + let admin = Address::random(); StorageCtx::enter(&mut storage, || { let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; @@ -741,4 +771,50 @@ mod tests { Ok(()) }) } + + #[test] + fn test_permit_selectors_gated_behind_t2() -> eyre::Result<()> { + use alloy::sol_types::SolError; + use tempo_contracts::precompiles::UnknownFunctionSelector; + + // Pre-T2: permit/nonces/DOMAIN_SEPARATOR should return unknown selector + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T1); + let admin = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + // Test permit selector is gated + let permit_calldata = ITIP20::permitCall { + owner: Address::random(), + spender: Address::random(), + value: U256::ZERO, + deadline: U256::MAX, + v: 27, + r: alloy::primitives::B256::ZERO, + s: alloy::primitives::B256::ZERO, + } + .abi_encode(); + let result = token.call(&permit_calldata, admin)?; + assert!(result.reverted); + assert!(UnknownFunctionSelector::abi_decode(&result.bytes).is_ok()); + + // Test nonces selector is gated + let nonces_calldata = ITIP20::noncesCall { + owner: Address::random(), + } + .abi_encode(); + let result = token.call(&nonces_calldata, admin)?; + assert!(result.reverted); + assert!(UnknownFunctionSelector::abi_decode(&result.bytes).is_ok()); + + // Test DOMAIN_SEPARATOR selector is gated + let ds_calldata = ITIP20::DOMAIN_SEPARATORCall {}.abi_encode(); + let result = token.call(&ds_calldata, admin)?; + assert!(result.reverted); + assert!(UnknownFunctionSelector::abi_decode(&result.bytes).is_ok()); + + Ok(()) + }) + } } diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index b083fb5381..12bbced017 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -18,7 +18,8 @@ use crate::{ }; use alloy::{ hex, - primitives::{Address, B256, U256, keccak256, uint}, + primitives::{Address, B256, Signature, U256, keccak256, uint}, + sol_types::SolValue, }; use std::sync::LazyLock; use tempo_precompiles_macros::contract; @@ -73,8 +74,8 @@ pub struct TIP20Token { total_supply: U256, balances: Mapping, allowances: Mapping>, - // Unused slot, kept for storage layout compatibility - _nonces: Mapping, + // EIP-2612 permit nonces + permit_nonces: Mapping, paused: bool, supply_cap: U256, // Unused slot, kept for storage layout compatibility @@ -86,6 +87,21 @@ pub struct TIP20Token { user_reward_info: Mapping, } +/// EIP-712 Permit typehash: keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)") +pub static PERMIT_TYPEHASH: LazyLock = LazyLock::new(|| { + keccak256(b"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)") +}); + +/// EIP-712 domain separator typehash +pub static EIP712_DOMAIN_TYPEHASH: LazyLock = LazyLock::new(|| { + keccak256( + b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)", + ) +}); + +/// EIP-712 version hash: keccak256("1") +pub static VERSION_HASH: LazyLock = LazyLock::new(|| keccak256(b"1")); + pub static PAUSE_ROLE: LazyLock = LazyLock::new(|| keccak256(b"PAUSE_ROLE")); pub static UNPAUSE_ROLE: LazyLock = LazyLock::new(|| keccak256(b"UNPAUSE_ROLE")); pub static ISSUER_ROLE: LazyLock = LazyLock::new(|| keccak256(b"ISSUER_ROLE")); @@ -477,6 +493,92 @@ impl TIP20Token { Ok(true) } + // EIP-2612 Permit + + /// Returns the current nonce for an address (EIP-2612) + pub fn nonces(&self, call: ITIP20::noncesCall) -> Result { + self.permit_nonces[call.owner].read() + } + + /// Returns the EIP-712 domain separator, computed dynamically + pub fn domain_separator(&self) -> Result { + let name = self.name()?; + let name_hash = keccak256(name.as_bytes()); + let chain_id = U256::from(self.storage.chain_id()); + + let encoded = ( + *EIP712_DOMAIN_TYPEHASH, + name_hash, + *VERSION_HASH, + chain_id, + self.address, + ) + .abi_encode(); + + Ok(keccak256(encoded)) + } + + /// Executes a permit: sets allowance via a signed EIP-2612 message. + /// + /// Does NOT take msg_sender — the owner is validated from the signature. + /// Follows same pause behavior as approve() (allowed when paused). + pub fn permit(&mut self, call: ITIP20::permitCall) -> Result<()> { + let timestamp = self.storage.timestamp(); + let deadline = U256::from(call.deadline); + + // 1. Check deadline + if timestamp > deadline { + return Err(TIP20Error::permit_expired().into()); + } + + // 2. Read current nonce and increment BEFORE validation (reentrancy protection) + let nonce = self.permit_nonces[call.owner].read()?; + self.permit_nonces[call.owner].write( + nonce + .checked_add(U256::from(1)) + .ok_or(TempoPrecompileError::under_overflow())?, + )?; + + // 3. Construct EIP-712 struct hash + let struct_hash = keccak256( + ( + *PERMIT_TYPEHASH, + call.owner, + call.spender, + call.value, + nonce, + call.deadline, + ) + .abi_encode(), + ); + + // 4. Construct EIP-712 digest + let domain_separator = self.domain_separator()?; + let digest = keccak256( + [&[0x19, 0x01], domain_separator.as_slice(), struct_hash.as_slice()].concat(), + ); + + // 5. Validate signature (EOA only, no EIP-1271) + let parity = call.v.wrapping_sub(27) != 0; + let sig = Signature::from_scalars_and_parity(call.r, call.s, parity); + let recovered = sig + .recover_address_from_prehash(&digest) + .map_err(|_| TIP20Error::invalid_signature())?; + if recovered != call.owner { + return Err(TIP20Error::invalid_signature().into()); + } + + // 6. Set allowance + self.set_allowance(call.owner, call.spender, call.value)?; + + // 7. Emit Approval event + self.emit_event(TIP20Event::Approval(ITIP20::Approval { + owner: call.owner, + spender: call.spender, + amount: call.value, + })) + } + pub fn transfer(&mut self, msg_sender: Address, call: ITIP20::transferCall) -> Result { trace!(%msg_sender, ?call, "transferring TIP20"); self.check_not_paused()?; @@ -2054,4 +2156,510 @@ pub(crate) mod tests { Ok(()) }) } + + // ═══════════════════════════════════════════════════════════ + // EIP-2612 Permit Tests (TIP-1004) + // ═══════════════════════════════════════════════════════════ + + mod permit_tests { + use super::*; + use alloy::sol_types::SolValue; + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use tempo_chainspec::hardfork::TempoHardfork; + + const CHAIN_ID: u64 = 42; + + /// Create a T2 storage provider for permit tests + fn setup_t2_storage() -> HashMapStorageProvider { + HashMapStorageProvider::new_with_spec(CHAIN_ID, TempoHardfork::T2) + } + + /// Helper to create a valid permit signature + fn sign_permit( + signer: &PrivateKeySigner, + token_name: &str, + token_address: Address, + spender: Address, + value: U256, + nonce: U256, + deadline: U256, + ) -> (u8, B256, B256) { + let domain_separator = compute_domain_separator(token_name, token_address); + let struct_hash = keccak256( + ( + *PERMIT_TYPEHASH, + signer.address(), + spender, + value, + nonce, + deadline, + ) + .abi_encode(), + ); + let digest = keccak256( + [&[0x19, 0x01], domain_separator.as_slice(), struct_hash.as_slice()].concat(), + ); + + let sig = signer.sign_hash_sync(&digest).unwrap(); + let v = sig.v() as u8 + 27; + let r: B256 = sig.r().into(); + let s: B256 = sig.s().into(); + (v, r, s) + } + + fn compute_domain_separator(token_name: &str, token_address: Address) -> B256 { + keccak256( + ( + *EIP712_DOMAIN_TYPEHASH, + keccak256(token_name.as_bytes()), + *VERSION_HASH, + U256::from(CHAIN_ID), + token_address, + ) + .abi_encode(), + ) + } + + #[test] + fn test_permit_happy_path() -> eyre::Result<()> { + let mut storage = setup_t2_storage(); + let admin = Address::random(); + let signer = PrivateKeySigner::random(); + let owner = signer.address(); + let spender = Address::random(); + let value = U256::from(1000); + let deadline = U256::MAX; + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + let (v, r, s) = sign_permit( + &signer, + "Test", + token.address, + spender, + value, + U256::ZERO, + deadline, + ); + + token.permit(ITIP20::permitCall { + owner, + spender, + value, + deadline, + v, + r, + s, + })?; + + // Verify allowance was set + let allowance = token.allowance(ITIP20::allowanceCall { owner, spender })?; + assert_eq!(allowance, value); + + // Verify nonce was incremented + let nonce = token.nonces(ITIP20::noncesCall { owner })?; + assert_eq!(nonce, U256::from(1)); + + Ok(()) + }) + } + + #[test] + fn test_permit_expired() -> eyre::Result<()> { + let mut storage = setup_t2_storage(); + let admin = Address::random(); + let signer = PrivateKeySigner::random(); + let owner = signer.address(); + let spender = Address::random(); + let value = U256::from(1000); + // Deadline in the past + let deadline = U256::ZERO; + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + let (v, r, s) = sign_permit( + &signer, + "Test", + token.address, + spender, + value, + U256::ZERO, + deadline, + ); + + let result = token.permit(ITIP20::permitCall { + owner, + spender, + value, + deadline, + v, + r, + s, + }); + + assert!(matches!( + result, + Err(TempoPrecompileError::TIP20(TIP20Error::PermitExpired(_))) + )); + + Ok(()) + }) + } + + #[test] + fn test_permit_invalid_signature() -> eyre::Result<()> { + let mut storage = setup_t2_storage(); + let admin = Address::random(); + let owner = Address::random(); + let spender = Address::random(); + let value = U256::from(1000); + let deadline = U256::MAX; + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + // Use garbage signature bytes + let result = token.permit(ITIP20::permitCall { + owner, + spender, + value, + deadline, + v: 27, + r: B256::ZERO, + s: B256::ZERO, + }); + + assert!(matches!( + result, + Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_))) + )); + + Ok(()) + }) + } + + #[test] + fn test_permit_wrong_signer() -> eyre::Result<()> { + let mut storage = setup_t2_storage(); + let admin = Address::random(); + let signer = PrivateKeySigner::random(); + let wrong_owner = Address::random(); // Not the signer's address + let spender = Address::random(); + let value = U256::from(1000); + let deadline = U256::MAX; + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + // Sign with signer but claim wrong_owner + let (v, r, s) = sign_permit( + &signer, + "Test", + token.address, + spender, + value, + U256::ZERO, + deadline, + ); + + let result = token.permit(ITIP20::permitCall { + owner: wrong_owner, // Different from signer + spender, + value, + deadline, + v, + r, + s, + }); + + assert!(matches!( + result, + Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_))) + )); + + Ok(()) + }) + } + + #[test] + fn test_permit_replay_protection() -> eyre::Result<()> { + let mut storage = setup_t2_storage(); + let admin = Address::random(); + let signer = PrivateKeySigner::random(); + let owner = signer.address(); + let spender = Address::random(); + let value = U256::from(1000); + let deadline = U256::MAX; + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + let (v, r, s) = sign_permit( + &signer, + "Test", + token.address, + spender, + value, + U256::ZERO, // nonce 0 + deadline, + ); + + // First use should succeed + token.permit(ITIP20::permitCall { + owner, + spender, + value, + deadline, + v, + r, + s, + })?; + + // Second use of same signature should fail (nonce incremented) + let result = token.permit(ITIP20::permitCall { + owner, + spender, + value, + deadline, + v, + r, + s, + }); + + assert!(matches!( + result, + Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_))) + )); + + Ok(()) + }) + } + + #[test] + fn test_permit_nonce_tracking() -> eyre::Result<()> { + let mut storage = setup_t2_storage(); + let admin = Address::random(); + let signer = PrivateKeySigner::random(); + let owner = signer.address(); + let spender = Address::random(); + let deadline = U256::MAX; + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + // Initial nonce should be 0 + assert_eq!( + token.nonces(ITIP20::noncesCall { owner })?, + U256::ZERO + ); + + // Do 3 permits, each with correct nonce + for i in 0u64..3 { + let nonce = U256::from(i); + let value = U256::from(100 * (i + 1)); + + let (v, r, s) = sign_permit( + &signer, + "Test", + token.address, + spender, + value, + nonce, + deadline, + ); + + token.permit(ITIP20::permitCall { + owner, + spender, + value, + deadline, + v, + r, + s, + })?; + + assert_eq!( + token.nonces(ITIP20::noncesCall { owner })?, + U256::from(i + 1) + ); + } + + Ok(()) + }) + } + + #[test] + fn test_permit_works_when_paused() -> eyre::Result<()> { + let mut storage = setup_t2_storage(); + let admin = Address::random(); + let signer = PrivateKeySigner::random(); + let owner = signer.address(); + let spender = Address::random(); + let value = U256::from(1000); + let deadline = U256::MAX; + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin) + .with_role(admin, *PAUSE_ROLE) + .apply()?; + + // Pause the token + token.pause(admin, ITIP20::pauseCall {})?; + assert!(token.paused()?); + + let (v, r, s) = sign_permit( + &signer, + "Test", + token.address, + spender, + value, + U256::ZERO, + deadline, + ); + + // Permit should work even when paused + token.permit(ITIP20::permitCall { + owner, + spender, + value, + deadline, + v, + r, + s, + })?; + + assert_eq!( + token.allowance(ITIP20::allowanceCall { owner, spender })?, + value + ); + + Ok(()) + }) + } + + #[test] + fn test_permit_domain_separator() -> eyre::Result<()> { + let mut storage = setup_t2_storage(); + let admin = Address::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::create("Test", "TST", admin).apply()?; + + let ds = token.domain_separator()?; + let expected = compute_domain_separator("Test", token.address); + assert_eq!(ds, expected); + + Ok(()) + }) + } + + #[test] + fn test_permit_max_allowance() -> eyre::Result<()> { + let mut storage = setup_t2_storage(); + let admin = Address::random(); + let signer = PrivateKeySigner::random(); + let owner = signer.address(); + let spender = Address::random(); + let value = U256::MAX; + let deadline = U256::MAX; + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + let (v, r, s) = sign_permit( + &signer, + "Test", + token.address, + spender, + value, + U256::ZERO, + deadline, + ); + + token.permit(ITIP20::permitCall { + owner, + spender, + value, + deadline, + v, + r, + s, + })?; + + assert_eq!( + token.allowance(ITIP20::allowanceCall { owner, spender })?, + U256::MAX + ); + + Ok(()) + }) + } + + #[test] + fn test_permit_allowance_override() -> eyre::Result<()> { + let mut storage = setup_t2_storage(); + let admin = Address::random(); + let signer = PrivateKeySigner::random(); + let owner = signer.address(); + let spender = Address::random(); + let deadline = U256::MAX; + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + // First permit: set allowance to 1000 + let (v, r, s) = sign_permit( + &signer, + "Test", + token.address, + spender, + U256::from(1000), + U256::ZERO, + deadline, + ); + token.permit(ITIP20::permitCall { + owner, + spender, + value: U256::from(1000), + deadline, + v, + r, + s, + })?; + assert_eq!( + token.allowance(ITIP20::allowanceCall { owner, spender })?, + U256::from(1000) + ); + + // Second permit: override to 0 + let (v, r, s) = sign_permit( + &signer, + "Test", + token.address, + spender, + U256::ZERO, + U256::from(1), // nonce 1 + deadline, + ); + token.permit(ITIP20::permitCall { + owner, + spender, + value: U256::ZERO, + deadline, + v, + r, + s, + })?; + assert_eq!( + token.allowance(ITIP20::allowanceCall { owner, spender })?, + U256::ZERO + ); + + Ok(()) + }) + } + } } diff --git a/crates/precompiles/tests/storage_tests/solidity/precompiles.rs b/crates/precompiles/tests/storage_tests/solidity/precompiles.rs index da528465c1..8792627138 100644 --- a/crates/precompiles/tests/storage_tests/solidity/precompiles.rs +++ b/crates/precompiles/tests/storage_tests/solidity/precompiles.rs @@ -164,8 +164,8 @@ fn test_tip20_layout() { total_supply, balances, allowances, - // Unused slot, kept for storage layout compatibility - _nonces, + // EIP-2612 permit nonces (TIP-1004) + permit_nonces, paused, supply_cap, // Unused slot, kept for storage layout compatibility @@ -351,8 +351,8 @@ fn export_all_storage_constants() { total_supply, balances, allowances, - // Unused slot, kept for storage layout compatibility - _nonces, + // EIP-2612 permit nonces (TIP-1004) + permit_nonces, paused, supply_cap, // Unused slot, kept for storage layout compatibility diff --git a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json index 2cd3ba3cda..d10f96d9aa 100644 --- a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json +++ b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json @@ -102,7 +102,7 @@ { "astId": 62, "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "nonces", + "label": "permitNonces", "offset": 0, "slot": "11", "type": "t_mapping(t_address,t_uint256)" diff --git a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol index ea510b4e4b..49af5eb630 100644 --- a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol +++ b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol @@ -44,8 +44,8 @@ contract TIP20 { uint256 public totalSupply; mapping(address => uint256) public balances; mapping(address => mapping(address => uint256)) public allowances; - // Unused slot, kept for storage layout compatibility - mapping(address => uint256) public nonces; + // EIP-2612 permit nonces (TIP-1004) + mapping(address => uint256) public permitNonces; bool public paused; uint256 public supplyCap; // Unused slot, kept for storage layout compatibility diff --git a/tips/ref-impls/src/TIP20.sol b/tips/ref-impls/src/TIP20.sol index aa924c5043..1aae3dea60 100644 --- a/tips/ref-impls/src/TIP20.sol +++ b/tips/ref-impls/src/TIP20.sol @@ -36,6 +36,10 @@ contract TIP20 is ITIP20, TIP20RolesAuth { ITIP20 public override quoteToken; ITIP20 public override nextQuoteToken; + bytes32 public constant PERMIT_TYPEHASH = keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ); + bytes32 public constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); bytes32 public constant UNPAUSE_ROLE = keccak256("UNPAUSE_ROLE"); bytes32 public constant ISSUER_ROLE = keccak256("ISSUER_ROLE"); @@ -69,6 +73,7 @@ contract TIP20 is ITIP20, TIP20RolesAuth { uint128 internal _totalSupply; mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; + mapping(address => uint256) public nonces; /*////////////////////////////////////////////////////////////// TIP20 STORAGE @@ -330,6 +335,51 @@ contract TIP20 is ITIP20, TIP20RolesAuth { emit Transfer(address(0), to, amount); } + /*////////////////////////////////////////////////////////////// + EIP-2612 PERMIT + //////////////////////////////////////////////////////////////*/ + + function DOMAIN_SEPARATOR() public view returns (bytes32) { + return keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), + keccak256(bytes(name)), + keccak256(bytes("1")), + block.chainid, + address(this) + ) + ); + } + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) + external + { + if (block.timestamp > deadline) revert PermitExpired(); + + bytes32 structHash = keccak256( + abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline) + ); + + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash)); + + address recovered = ecrecover(digest, v, r, s); + if (recovered == address(0) || recovered != owner) { + revert InvalidSignature(); + } + + emit Approval(owner, spender, allowance[owner][spender] = value); + } + /*////////////////////////////////////////////////////////////// TIP20 EXTENSION FUNCTIONS //////////////////////////////////////////////////////////////*/ diff --git a/tips/ref-impls/src/interfaces/ITIP20.sol b/tips/ref-impls/src/interfaces/ITIP20.sol index 5ee1e02af8..e3fb9714ca 100644 --- a/tips/ref-impls/src/interfaces/ITIP20.sol +++ b/tips/ref-impls/src/interfaces/ITIP20.sol @@ -248,4 +248,30 @@ interface ITIP20 { /// @return The total pending claimable reward amount. function getPendingRewards(address account) external view returns (uint256); + // EIP-2612 Permit (TIP-1004) + + /// @notice The permit signature has expired (block.timestamp > deadline) + error PermitExpired(); + + /// @notice The permit signature is invalid (wrong signer, malformed, or zero address recovered) + error InvalidSignature(); + + /// @notice Approves `spender` to spend `value` tokens on behalf of `owner` via a signed permit + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) + external; + + /// @notice Returns the current nonce for an address + function nonces(address owner) external view returns (uint256); + + /// @notice Returns the EIP-712 domain separator for this token + function DOMAIN_SEPARATOR() external view returns (bytes32); + } From 3806804542a44ad6b6a5bad6a31c6a810aaad4db Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:49:42 -0500 Subject: [PATCH 02/18] chore: lint --- crates/precompiles/src/tip20/dispatch.rs | 15 +++++------ crates/precompiles/src/tip20/mod.rs | 32 +++++++++++++----------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/crates/precompiles/src/tip20/dispatch.rs b/crates/precompiles/src/tip20/dispatch.rs index c46c2e7fe5..958061042a 100644 --- a/crates/precompiles/src/tip20/dispatch.rs +++ b/crates/precompiles/src/tip20/dispatch.rs @@ -6,7 +6,10 @@ use crate::{ tip20::{ITIP20, TIP20Token}, unknown_selector, view, }; -use alloy::{primitives::Address, sol_types::{SolCall, SolInterface}}; +use alloy::{ + primitives::Address, + sol_types::{SolCall, SolInterface}, +}; use revm::precompile::{PrecompileError, PrecompileResult}; use tempo_contracts::precompiles::{IRolesAuth::IRolesAuthCalls, ITIP20::ITIP20Calls, TIP20Error}; @@ -166,19 +169,13 @@ impl Precompile for TIP20Token { TIP20Call::TIP20(ITIP20Calls::permit(call)) => { if !self.storage.spec().is_t2() { - return unknown_selector( - ITIP20::permitCall::SELECTOR, - self.storage.gas_used(), - ); + return unknown_selector(ITIP20::permitCall::SELECTOR, self.storage.gas_used()); } mutate_void(call, msg_sender, |_s, c| self.permit(c)) } TIP20Call::TIP20(ITIP20Calls::nonces(call)) => { if !self.storage.spec().is_t2() { - return unknown_selector( - ITIP20::noncesCall::SELECTOR, - self.storage.gas_used(), - ); + return unknown_selector(ITIP20::noncesCall::SELECTOR, self.storage.gas_used()); } view(call, |c| self.nonces(c)) } diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 12bbced017..cbb406d217 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -74,8 +74,7 @@ pub struct TIP20Token { total_supply: U256, balances: Mapping, allowances: Mapping>, - // EIP-2612 permit nonces - permit_nonces: Mapping, + nonces: Mapping, paused: bool, supply_cap: U256, // Unused slot, kept for storage layout compatibility @@ -94,9 +93,7 @@ pub static PERMIT_TYPEHASH: LazyLock = LazyLock::new(|| { /// EIP-712 domain separator typehash pub static EIP712_DOMAIN_TYPEHASH: LazyLock = LazyLock::new(|| { - keccak256( - b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)", - ) + keccak256(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") }); /// EIP-712 version hash: keccak256("1") @@ -497,7 +494,7 @@ impl TIP20Token { /// Returns the current nonce for an address (EIP-2612) pub fn nonces(&self, call: ITIP20::noncesCall) -> Result { - self.permit_nonces[call.owner].read() + self.nonces[call.owner].read() } /// Returns the EIP-712 domain separator, computed dynamically @@ -532,8 +529,8 @@ impl TIP20Token { } // 2. Read current nonce and increment BEFORE validation (reentrancy protection) - let nonce = self.permit_nonces[call.owner].read()?; - self.permit_nonces[call.owner].write( + let nonce = self.nonces[call.owner].read()?; + self.nonces[call.owner].write( nonce .checked_add(U256::from(1)) .ok_or(TempoPrecompileError::under_overflow())?, @@ -555,7 +552,12 @@ impl TIP20Token { // 4. Construct EIP-712 digest let domain_separator = self.domain_separator()?; let digest = keccak256( - [&[0x19, 0x01], domain_separator.as_slice(), struct_hash.as_slice()].concat(), + [ + &[0x19, 0x01], + domain_separator.as_slice(), + struct_hash.as_slice(), + ] + .concat(), ); // 5. Validate signature (EOA only, no EIP-1271) @@ -2198,7 +2200,12 @@ pub(crate) mod tests { .abi_encode(), ); let digest = keccak256( - [&[0x19, 0x01], domain_separator.as_slice(), struct_hash.as_slice()].concat(), + [ + &[0x19, 0x01], + domain_separator.as_slice(), + struct_hash.as_slice(), + ] + .concat(), ); let sig = signer.sign_hash_sync(&digest).unwrap(); @@ -2451,10 +2458,7 @@ pub(crate) mod tests { let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; // Initial nonce should be 0 - assert_eq!( - token.nonces(ITIP20::noncesCall { owner })?, - U256::ZERO - ); + assert_eq!(token.nonces(ITIP20::noncesCall { owner })?, U256::ZERO); // Do 3 permits, each with correct nonce for i in 0u64..3 { From 3b99986d27c648dc417b92d1f111f83475948639 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:06:44 -0500 Subject: [PATCH 03/18] chore: rename permitNonces -> nonce --- crates/precompiles/src/tip20/mod.rs | 2 +- .../precompiles/tests/storage_tests/solidity/precompiles.rs | 4 ++-- .../tests/storage_tests/solidity/testdata/tip20.layout.json | 2 +- .../tests/storage_tests/solidity/testdata/tip20.sol | 3 +-- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index cbb406d217..684457a3be 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -560,7 +560,7 @@ impl TIP20Token { .concat(), ); - // 5. Validate signature (EOA only, no EIP-1271) + // 5. Validate ECDSA signature let parity = call.v.wrapping_sub(27) != 0; let sig = Signature::from_scalars_and_parity(call.r, call.s, parity); let recovered = sig diff --git a/crates/precompiles/tests/storage_tests/solidity/precompiles.rs b/crates/precompiles/tests/storage_tests/solidity/precompiles.rs index 8792627138..ab38bc10c5 100644 --- a/crates/precompiles/tests/storage_tests/solidity/precompiles.rs +++ b/crates/precompiles/tests/storage_tests/solidity/precompiles.rs @@ -165,7 +165,7 @@ fn test_tip20_layout() { balances, allowances, // EIP-2612 permit nonces (TIP-1004) - permit_nonces, + nonces, paused, supply_cap, // Unused slot, kept for storage layout compatibility @@ -352,7 +352,7 @@ fn export_all_storage_constants() { balances, allowances, // EIP-2612 permit nonces (TIP-1004) - permit_nonces, + nonces, paused, supply_cap, // Unused slot, kept for storage layout compatibility diff --git a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json index d10f96d9aa..2cd3ba3cda 100644 --- a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json +++ b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json @@ -102,7 +102,7 @@ { "astId": 62, "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "permitNonces", + "label": "nonces", "offset": 0, "slot": "11", "type": "t_mapping(t_address,t_uint256)" diff --git a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol index 49af5eb630..88e0a60e8b 100644 --- a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol +++ b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol @@ -44,8 +44,7 @@ contract TIP20 { uint256 public totalSupply; mapping(address => uint256) public balances; mapping(address => mapping(address => uint256)) public allowances; - // EIP-2612 permit nonces (TIP-1004) - mapping(address => uint256) public permitNonces; + mapping(address => uint256) public nonces; bool public paused; uint256 public supplyCap; // Unused slot, kept for storage layout compatibility From d65a2628aebd461ab30c0b67ebb05043d4cb2a85 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:12:08 -0500 Subject: [PATCH 04/18] chore: rename to permit_nonces for clarity --- crates/precompiles/src/tip20/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 684457a3be..4eabaf50fc 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -74,7 +74,7 @@ pub struct TIP20Token { total_supply: U256, balances: Mapping, allowances: Mapping>, - nonces: Mapping, + permit_nonces: Mapping, paused: bool, supply_cap: U256, // Unused slot, kept for storage layout compatibility @@ -494,7 +494,7 @@ impl TIP20Token { /// Returns the current nonce for an address (EIP-2612) pub fn nonces(&self, call: ITIP20::noncesCall) -> Result { - self.nonces[call.owner].read() + self.permit_nonces[call.owner].read() } /// Returns the EIP-712 domain separator, computed dynamically @@ -529,8 +529,8 @@ impl TIP20Token { } // 2. Read current nonce and increment BEFORE validation (reentrancy protection) - let nonce = self.nonces[call.owner].read()?; - self.nonces[call.owner].write( + let nonce = self.permit_nonces[call.owner].read()?; + self.permit_nonces[call.owner].write( nonce .checked_add(U256::from(1)) .ok_or(TempoPrecompileError::under_overflow())?, From e5eaac97b847650282b06cc3200679e1f2e515bf Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:09:04 -0500 Subject: [PATCH 05/18] feat: add unit, invariant tests --- tips/ref-impls/test/TIP20.t.sol | 172 ++++++++++++++++++ .../test/invariants/AccountKeychain.t.sol | 2 +- .../test/invariants/InvariantBaseTest.t.sol | 19 +- tips/ref-impls/test/invariants/Nonce.t.sol | 2 +- tips/ref-impls/test/invariants/README.md | 9 + tips/ref-impls/test/invariants/TIP1015.t.sol | 2 +- tips/ref-impls/test/invariants/TIP20.t.sol | 70 ++++++- .../test/invariants/TIP20Factory.t.sol | 2 +- .../test/invariants/TIP403Registry.t.sol | 2 +- .../test/invariants/ValidatorConfig.t.sol | 2 +- .../test/invariants/ValidatorConfigV2.t.sol | 2 +- 11 files changed, 269 insertions(+), 15 deletions(-) diff --git a/tips/ref-impls/test/TIP20.t.sol b/tips/ref-impls/test/TIP20.t.sol index 03bf1a64ac..44eb45e91e 100644 --- a/tips/ref-impls/test/TIP20.t.sol +++ b/tips/ref-impls/test/TIP20.t.sol @@ -18,6 +18,10 @@ contract TIP20Test is BaseTest { bytes32 constant TEST_MEMO = bytes32(uint256(0x1234567890abcdef)); bytes32 constant ANOTHER_MEMO = bytes32("Hello World"); + // Signer key pair for permit tests + uint256 internal constant SIGNER_KEY = 0xA11CE; + uint256 internal constant WRONG_KEY = 0xB0B; + event TransferWithMemo( address indexed from, address indexed to, uint256 amount, bytes32 indexed memo ); @@ -2534,4 +2538,172 @@ contract TIP20Test is BaseTest { vm.stopPrank(); } + /*////////////////////////////////////////////////////////////// + EIP-2612 PERMIT TESTS + //////////////////////////////////////////////////////////////*/ + + /// @dev Helper to build the EIP-712 digest for a permit call + function _permitDigest( + address owner_, + address spender_, + uint256 value_, + uint256 nonce_, + uint256 deadline_ + ) + internal + view + returns (bytes32) + { + bytes32 structHash = keccak256( + abi.encode(token.PERMIT_TYPEHASH(), owner_, spender_, value_, nonce_, deadline_) + ); + return keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + } + + function test_Permit() public { + address signer = vm.addr(SIGNER_KEY); + uint256 value = 500e18; + uint256 deadline = block.timestamp + 1 hours; + + // Mint tokens so signer has a balance (not strictly required for approve, but realistic) + vm.prank(admin); + token.mint(signer, 1000e18); + + // Nonce starts at 0 + assertEq(token.nonces(signer), 0); + + bytes32 digest = _permitDigest(signer, bob, value, 0, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_KEY, digest); + + vm.expectEmit(true, true, true, true); + emit Approval(signer, bob, value); + + token.permit(signer, bob, value, deadline, v, r, s); + + // Allowance reflects new value + assertEq(token.allowance(signer, bob), value); + // Nonce incremented + assertEq(token.nonces(signer), 1); + } + + function test_Permit_OverridesExistingAllowance() public { + address signer = vm.addr(SIGNER_KEY); + uint256 deadline = block.timestamp + 1 hours; + + // Set initial allowance via approve + vm.prank(signer); + token.approve(bob, 100e18); + assertEq(token.allowance(signer, bob), 100e18); + + // Permit overrides to 50e18 + bytes32 digest = _permitDigest(signer, bob, 50e18, 0, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_KEY, digest); + + token.permit(signer, bob, 50e18, deadline, v, r, s); + + assertEq(token.allowance(signer, bob), 50e18); + } + + function test_Permit_Replay() public { + address signer = vm.addr(SIGNER_KEY); + uint256 value = 500e18; + uint256 deadline = block.timestamp + 1 hours; + + bytes32 digest = _permitDigest(signer, bob, value, 0, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_KEY, digest); + + // First call succeeds + token.permit(signer, bob, value, deadline, v, r, s); + assertEq(token.nonces(signer), 1); + + // Replay fails — nonce already consumed + try token.permit(signer, bob, value, deadline, v, r, s) { + revert CallShouldHaveReverted(); + } catch (bytes memory err) { + assertEq(err, abi.encodeWithSelector(ITIP20.InvalidSignature.selector)); + } + + // Nonce unchanged after failed replay + assertEq(token.nonces(signer), 1); + } + + function test_Permit_Fail() public { + address signer = vm.addr(SIGNER_KEY); + uint256 value = 500e18; + + // 1. Expired deadline + { + uint256 expiredDeadline = block.timestamp - 1; + bytes32 digest = _permitDigest(signer, bob, value, 0, expiredDeadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_KEY, digest); + + try token.permit(signer, bob, value, expiredDeadline, v, r, s) { + revert CallShouldHaveReverted(); + } catch (bytes memory err) { + assertEq(err, abi.encodeWithSelector(ITIP20.PermitExpired.selector)); + } + assertEq(token.nonces(signer), 0); + } + + // 2. Invalid signature (garbage bytes) + { + uint256 deadline = block.timestamp + 1 hours; + + try token.permit(signer, bob, value, deadline, 27, bytes32("bad_r"), bytes32("bad_s")) { + revert CallShouldHaveReverted(); + } catch (bytes memory err) { + assertEq(err, abi.encodeWithSelector(ITIP20.InvalidSignature.selector)); + } + assertEq(token.nonces(signer), 0); + } + + // 3. Wrong signer (bob signs a permit claiming owner = signer) + { + uint256 deadline = block.timestamp + 1 hours; + bytes32 digest = _permitDigest(signer, bob, value, 0, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(WRONG_KEY, digest); + + try token.permit(signer, bob, value, deadline, v, r, s) { + revert CallShouldHaveReverted(); + } catch (bytes memory err) { + assertEq(err, abi.encodeWithSelector(ITIP20.InvalidSignature.selector)); + } + assertEq(token.nonces(signer), 0); + } + } + + function test_Nonces() public { + address signer = vm.addr(SIGNER_KEY); + uint256 deadline = block.timestamp + 1 hours; + + assertEq(token.nonces(signer), 0); + + // First permit: nonce 0 → 1 + bytes32 digest0 = _permitDigest(signer, bob, 100e18, 0, deadline); + (uint8 v0, bytes32 r0, bytes32 s0) = vm.sign(SIGNER_KEY, digest0); + token.permit(signer, bob, 100e18, deadline, v0, r0, s0); + assertEq(token.nonces(signer), 1); + + // Second permit: nonce 1 → 2 + bytes32 digest1 = _permitDigest(signer, charlie, 200e18, 1, deadline); + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(SIGNER_KEY, digest1); + token.permit(signer, charlie, 200e18, deadline, v1, r1, s1); + assertEq(token.nonces(signer), 2); + } + + function test_DomainSeparator() public view { + bytes32 expected = keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), + keccak256(bytes(token.name())), + keccak256(bytes("1")), + block.chainid, + address(token) + ) + ); + assertEq(token.DOMAIN_SEPARATOR(), expected); + } + } diff --git a/tips/ref-impls/test/invariants/AccountKeychain.t.sol b/tips/ref-impls/test/invariants/AccountKeychain.t.sol index fb4859490e..9969b804f0 100644 --- a/tips/ref-impls/test/invariants/AccountKeychain.t.sol +++ b/tips/ref-impls/test/invariants/AccountKeychain.t.sol @@ -64,7 +64,7 @@ contract AccountKeychainInvariantTest is InvariantBaseTest { targetContract(address(this)); _setupInvariantBase(); - _actors = _buildActors(10); + (_actors,) = _buildActors(10); _potentialKeyIds = _buildAddressPool(20, KEY_ID_POOL_OFFSET); // Seed each actor with an initial key to ensure handlers have keys to work with diff --git a/tips/ref-impls/test/invariants/InvariantBaseTest.t.sol b/tips/ref-impls/test/invariants/InvariantBaseTest.t.sol index 7370059d8b..21dc6c43c6 100644 --- a/tips/ref-impls/test/invariants/InvariantBaseTest.t.sol +++ b/tips/ref-impls/test/invariants/InvariantBaseTest.t.sol @@ -126,21 +126,26 @@ abstract contract InvariantBaseTest is BaseTest { /// @dev Each actor gets funded with all tokens /// @param noOfActors_ Number of actors to create /// @return actorsAddress Array of created actor addresses - function _buildActors(uint256 noOfActors_) internal virtual returns (address[] memory) { + function _buildActors(uint256 noOfActors_) + internal + virtual + returns (address[] memory, uint256[] memory) + { address[] memory actorsAddress = new address[](noOfActors_); + uint256[] memory actorKeys = new uint256[](noOfActors_); for (uint256 i = 0; i < noOfActors_; i++) { - address actor = makeAddr(string(abi.encodePacked("Actor", vm.toString(i)))); - actorsAddress[i] = actor; + (actorsAddress[i], actorKeys[i]) = + makeAddrAndKey(string(abi.encodePacked("Actor", vm.toString(i)))); // Register actor as balance holder for invariant checks - _registerBalanceHolder(actor); + _registerBalanceHolder(actorsAddress[i]); // Initial actor balance for all tokens - _ensureFundsAll(actor, 1_000_000_000_000); + _ensureFundsAll(actorsAddress[i], 1_000_000_000_000); } - return actorsAddress; + return (actorsAddress, actorKeys); } /// @notice Creates test actors with approvals for a specific contract @@ -154,7 +159,7 @@ abstract contract InvariantBaseTest is BaseTest { internal returns (address[] memory) { - address[] memory actorsAddress = _buildActors(noOfActors_); + (address[] memory actorsAddress,) = _buildActors(noOfActors_); for (uint256 i = 0; i < noOfActors_; i++) { vm.startPrank(actorsAddress[i]); diff --git a/tips/ref-impls/test/invariants/Nonce.t.sol b/tips/ref-impls/test/invariants/Nonce.t.sol index 5ea773579d..fc3b15fde7 100644 --- a/tips/ref-impls/test/invariants/Nonce.t.sol +++ b/tips/ref-impls/test/invariants/Nonce.t.sol @@ -84,7 +84,7 @@ contract NonceInvariantTest is InvariantBaseTest { targetSelector(FuzzSelector({ addr: address(this), selectors: selectors })); _setupInvariantBase(); - _actors = _buildActors(10); + (_actors,) = _buildActors(10); } /// @dev Gets a valid nonce key (1 to MAX_NORMAL_NONCE_KEY) diff --git a/tips/ref-impls/test/invariants/README.md b/tips/ref-impls/test/invariants/README.md index 96c6d84803..de57336f21 100644 --- a/tips/ref-impls/test/invariants/README.md +++ b/tips/ref-impls/test/invariants/README.md @@ -495,6 +495,7 @@ TIP20 is the Tempo token standard that extends ERC-20 with transfer policies, me ### Approval Invariants - **TEMPO-TIP5**: Allowance setting - `approve` sets exact allowance amount, returns `true`. +- **TEMPO-TIP29**: A valid permit sets allowance to the `value` in the permit struct. ### Mint/Burn Invariants @@ -536,3 +537,11 @@ TIP20 is the Tempo token standard that extends ERC-20 with transfer policies, me - **TEMPO-TIP27**: Pause-role enforcement - only accounts with `PAUSE_ROLE` can call `pause` (non-role holders revert with `Unauthorized`). - **TEMPO-TIP28**: Unpause-role enforcement - only accounts with `UNPAUSE_ROLE` can call `unpause` (non-role holders revert with `Unauthorized`). - **TEMPO-TIP29**: Burn-blocked-role enforcement - only accounts with `BURN_BLOCKED_ROLE` can call `burnBlocked` (non-role holders revert with `Unauthorized`). + +### Permit Invariants + +- **TEMPO-TIP31**: `nonces(owner)` must only ever increase, never decrease. +- **TEMPO-TIP32**: `nonces(owner)` must increment by exactly 1 on each successful `permit()` call for that owner. +- **TEMPO-TIP33**: A permit signature can only be used once (enforced by nonce increment). +- **TEMPO-TIP34**: A permit with a deadline in the past must always revert. +- **TEMPO-TIP35**: The recovered signer from a valid permit signature must exactly match the `owner` parameter. diff --git a/tips/ref-impls/test/invariants/TIP1015.t.sol b/tips/ref-impls/test/invariants/TIP1015.t.sol index e078061610..45fa727fa5 100644 --- a/tips/ref-impls/test/invariants/TIP1015.t.sol +++ b/tips/ref-impls/test/invariants/TIP1015.t.sol @@ -65,7 +65,7 @@ contract TIP1015InvariantTest is InvariantBaseTest { targetContract(address(this)); _setupInvariantBase(); - _actors = _buildActors(NUM_ACTORS); + (_actors,) = _buildActors(NUM_ACTORS); vm.startPrank(admin); diff --git a/tips/ref-impls/test/invariants/TIP20.t.sol b/tips/ref-impls/test/invariants/TIP20.t.sol index 33523d68b0..490c55412f 100644 --- a/tips/ref-impls/test/invariants/TIP20.t.sol +++ b/tips/ref-impls/test/invariants/TIP20.t.sol @@ -31,6 +31,9 @@ contract TIP20InvariantTest is InvariantBaseTest { mapping(address => mapping(address => bool)) private _tokenHolderSeen; mapping(address => address[]) private _tokenHolders; + /// @dev Private keys associated with actor addresses + uint256[] private _keys; + /// @dev Constants uint256 internal constant ACC_PRECISION = 1e18; @@ -49,7 +52,7 @@ contract TIP20InvariantTest is InvariantBaseTest { targetContract(address(this)); _setupInvariantBase(); - _actors = _buildActors(20); + (_actors, _keys) = _buildActors(20); // Snapshot initial supply after _buildActors mints tokens to actors for (uint256 i = 0; i < _tokens.length; i++) { @@ -1227,6 +1230,66 @@ contract TIP20InvariantTest is InvariantBaseTest { vm.stopPrank(); } + function permit( + uint256 actorSeed, + uint256 recipientSeed, + uint256 tokenSeed, + uint128 amount, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s, + uint256 resultSeed + ) + external + { + address actor = _selectActor(actorSeed); + address recipient = _selectActorExcluding(recipientSeed, actor); + TIP20 token = _selectBaseToken(tokenSeed); + uint256 actorNonce = token.nonces(actor); + + // build permit digest + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + token.DOMAIN_SEPARATOR(), + keccak256(abi.encodePacked(actor, recipient, amount, deadline)) + ) + ); + + // alternate between: correct sig, random sig, corrupted digest, and fully random sig + address signer; + if (resultSeed % 4 == 0) { + signer = actor; + (v, r, s) = vm.sign(_selectActorKey(actorSeed), digest); + } else if (resultSeed % 4 == 1) { + // Sign with a random key + resultSeed = resultSeed >> 1; + signer = _selectActorExcluding(resultSeed, actor); + (v, r, s) = vm.sign(_selectActorKey(actorSeed), digest); + } else if (resultSeed % 4 == 2) { + digest = keccak256(abi.encodePacked(digest, resultSeed)); // corrupt the digest unpredictably + } // else use the random bytes entirely + + try token.permit(actor, recipient, amount, deadline, v, r, s) { + // If permit passes, check invariants + + // **TEMPO-TIP29**: Permit should set correct allowance + assertEq( + token.allowance(actor, recipient), amount, "Permit did not set correct allowance" + ); + + // **TEMPO-TIP32**: Nonce should be incremented + assertEq(token.nonces(actor), actorNonce + 1, "Permit did not increment nonce"); + + // **TEMPO-TIP34**: A permit with a deadline in the past must always revert. + assertGe(deadline, block.timestamp, "Permit should revert if deadline is past"); + + // **TEMPO-TIP35**: The recovered signer from a valid permit signature must exactly match the `owner` parameter. + assertEq(ecrecover(digest, v, r, s), signer, "Recovered signer does not match expected"); + } catch (bytes memory) { } + } + /// @notice Handler that verifies paused tokens reject transfers with ContractPaused /// @dev Tests TEMPO-TIP17: pause enforcement - transfers revert with ContractPaused function tryTransferWhilePaused( @@ -1314,4 +1377,9 @@ contract TIP20InvariantTest is InvariantBaseTest { } } + // Helper function to select key associated with seed + function _selectActorKey(uint256 seed) internal view returns (uint256) { + return _keys[seed % _keys.length]; + } + } diff --git a/tips/ref-impls/test/invariants/TIP20Factory.t.sol b/tips/ref-impls/test/invariants/TIP20Factory.t.sol index 0972bc078b..07b9c08017 100644 --- a/tips/ref-impls/test/invariants/TIP20Factory.t.sol +++ b/tips/ref-impls/test/invariants/TIP20Factory.t.sol @@ -38,7 +38,7 @@ contract TIP20FactoryInvariantTest is InvariantBaseTest { targetContract(address(this)); _setupInvariantBase(); - _actors = _buildActors(10); + (_actors,) = _buildActors(10); // One-time constant checks (immutable after deployment) // TEMPO-FAC8: isTIP20 consistency for system contracts diff --git a/tips/ref-impls/test/invariants/TIP403Registry.t.sol b/tips/ref-impls/test/invariants/TIP403Registry.t.sol index 4d3cd5905a..046720b052 100644 --- a/tips/ref-impls/test/invariants/TIP403Registry.t.sol +++ b/tips/ref-impls/test/invariants/TIP403Registry.t.sol @@ -136,7 +136,7 @@ contract TIP403RegistryInvariantTest is InvariantBaseTest { _setupInvariantBase(); _basePoliciesCreated = registry.policyIdCounter() - counterBefore; - _actors = _buildActors(10); + (_actors,) = _buildActors(10); // One-time constant checks (immutable after deployment) // TEMPO-REG13: Special policies 0 and 1 always exist diff --git a/tips/ref-impls/test/invariants/ValidatorConfig.t.sol b/tips/ref-impls/test/invariants/ValidatorConfig.t.sol index ae407f70ee..18fa1fc75e 100644 --- a/tips/ref-impls/test/invariants/ValidatorConfig.t.sol +++ b/tips/ref-impls/test/invariants/ValidatorConfig.t.sol @@ -42,7 +42,7 @@ contract ValidatorConfigInvariantTest is InvariantBaseTest { targetContract(address(this)); _setupInvariantBase(); - _actors = _buildActors(10); + (_actors,) = _buildActors(10); _potentialValidators = _buildAddressPool(20, VALIDATOR_POOL_OFFSET); _ghostOwner = admin; } diff --git a/tips/ref-impls/test/invariants/ValidatorConfigV2.t.sol b/tips/ref-impls/test/invariants/ValidatorConfigV2.t.sol index 4dcf686d5b..6b61df01d1 100644 --- a/tips/ref-impls/test/invariants/ValidatorConfigV2.t.sol +++ b/tips/ref-impls/test/invariants/ValidatorConfigV2.t.sol @@ -71,7 +71,7 @@ contract ValidatorConfigV2InvariantTest is InvariantBaseTest { targetContract(address(this)); _setupInvariantBase(); - _actors = _buildActors(5); + (_actors,) = _buildActors(5); _potentialValidators = _buildAddressPool(500, VALIDATOR_POOL_OFFSET); // Add V1 validators — migration and initialization driven by the fuzzer From f0b30dc435851b5d9d1e0bc40be94edfa761f828 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:26:17 -0500 Subject: [PATCH 06/18] fix: update storage tests to use permit_nonces field name The TIP20 struct field was renamed from `_nonces` to `permit_nonces`, but the storage layout tests still referenced `nonces`. Co-Authored-By: Claude Opus 4.6 --- .../storage_tests/solidity/precompiles.rs | 4 +- .../solidity/testdata/tip20.layout.json | 273 ------------------ .../storage_tests/solidity/testdata/tip20.sol | 2 +- 3 files changed, 3 insertions(+), 276 deletions(-) delete mode 100644 crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json diff --git a/crates/precompiles/tests/storage_tests/solidity/precompiles.rs b/crates/precompiles/tests/storage_tests/solidity/precompiles.rs index ab38bc10c5..8792627138 100644 --- a/crates/precompiles/tests/storage_tests/solidity/precompiles.rs +++ b/crates/precompiles/tests/storage_tests/solidity/precompiles.rs @@ -165,7 +165,7 @@ fn test_tip20_layout() { balances, allowances, // EIP-2612 permit nonces (TIP-1004) - nonces, + permit_nonces, paused, supply_cap, // Unused slot, kept for storage layout compatibility @@ -352,7 +352,7 @@ fn export_all_storage_constants() { balances, allowances, // EIP-2612 permit nonces (TIP-1004) - nonces, + permit_nonces, paused, supply_cap, // Unused slot, kept for storage layout compatibility diff --git a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json deleted file mode 100644 index 2cd3ba3cda..0000000000 --- a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json +++ /dev/null @@ -1,273 +0,0 @@ -{ - "contracts": { - "tests/storage_tests/solidity/testdata/tip20.sol:TIP20": { - "storage-layout": { - "storage": [ - { - "astId": 27, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "roles", - "offset": 0, - "slot": "0", - "type": "t_mapping(t_address,t_mapping(t_bytes32,t_bool))" - }, - { - "astId": 32, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "roleAdmins", - "offset": 0, - "slot": "1", - "type": "t_mapping(t_bytes32,t_bytes32)" - }, - { - "astId": 34, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "name", - "offset": 0, - "slot": "2", - "type": "t_string_storage" - }, - { - "astId": 36, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "symbol", - "offset": 0, - "slot": "3", - "type": "t_string_storage" - }, - { - "astId": 38, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "currency", - "offset": 0, - "slot": "4", - "type": "t_string_storage" - }, - { - "astId": 40, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "domainSeparator", - "offset": 0, - "slot": "5", - "type": "t_bytes32" - }, - { - "astId": 42, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "quoteToken", - "offset": 0, - "slot": "6", - "type": "t_address" - }, - { - "astId": 44, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "nextQuoteToken", - "offset": 0, - "slot": "7", - "type": "t_address" - }, - { - "astId": 46, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "transferPolicyId", - "offset": 20, - "slot": "7", - "type": "t_uint64" - }, - { - "astId": 48, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "totalSupply", - "offset": 0, - "slot": "8", - "type": "t_uint256" - }, - { - "astId": 52, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "balances", - "offset": 0, - "slot": "9", - "type": "t_mapping(t_address,t_uint256)" - }, - { - "astId": 58, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "allowances", - "offset": 0, - "slot": "10", - "type": "t_mapping(t_address,t_mapping(t_address,t_uint256))" - }, - { - "astId": 62, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "nonces", - "offset": 0, - "slot": "11", - "type": "t_mapping(t_address,t_uint256)" - }, - { - "astId": 64, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "paused", - "offset": 0, - "slot": "12", - "type": "t_bool" - }, - { - "astId": 66, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "supplyCap", - "offset": 0, - "slot": "13", - "type": "t_uint256" - }, - { - "astId": 70, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "salts", - "offset": 0, - "slot": "14", - "type": "t_mapping(t_bytes32,t_bool)" - }, - { - "astId": 72, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "globalRewardPerToken", - "offset": 0, - "slot": "15", - "type": "t_uint256" - }, - { - "astId": 74, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "optedInSupply", - "offset": 0, - "slot": "16", - "type": "t_uint128" - }, - { - "astId": 80, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "userRewardInfo", - "offset": 0, - "slot": "17", - "type": "t_mapping(t_address,t_struct(UserRewardInfo)20_storage)" - } - ], - "types": { - "t_address": { - "encoding": "inplace", - "label": "address", - "numberOfBytes": "20" - }, - "t_bool": { - "encoding": "inplace", - "label": "bool", - "numberOfBytes": "1" - }, - "t_bytes32": { - "encoding": "inplace", - "label": "bytes32", - "numberOfBytes": "32" - }, - "t_mapping(t_address,t_mapping(t_address,t_uint256))": { - "encoding": "mapping", - "key": "t_address", - "label": "mapping(address => mapping(address => uint256))", - "numberOfBytes": "32", - "value": "t_mapping(t_address,t_uint256)" - }, - "t_mapping(t_address,t_mapping(t_bytes32,t_bool))": { - "encoding": "mapping", - "key": "t_address", - "label": "mapping(address => mapping(bytes32 => bool))", - "numberOfBytes": "32", - "value": "t_mapping(t_bytes32,t_bool)" - }, - "t_mapping(t_address,t_struct(UserRewardInfo)20_storage)": { - "encoding": "mapping", - "key": "t_address", - "label": "mapping(address => struct TIP20.UserRewardInfo)", - "numberOfBytes": "32", - "value": "t_struct(UserRewardInfo)20_storage" - }, - "t_mapping(t_address,t_uint256)": { - "encoding": "mapping", - "key": "t_address", - "label": "mapping(address => uint256)", - "numberOfBytes": "32", - "value": "t_uint256" - }, - "t_mapping(t_bytes32,t_bool)": { - "encoding": "mapping", - "key": "t_bytes32", - "label": "mapping(bytes32 => bool)", - "numberOfBytes": "32", - "value": "t_bool" - }, - "t_mapping(t_bytes32,t_bytes32)": { - "encoding": "mapping", - "key": "t_bytes32", - "label": "mapping(bytes32 => bytes32)", - "numberOfBytes": "32", - "value": "t_bytes32" - }, - "t_string_storage": { - "encoding": "bytes", - "label": "string", - "numberOfBytes": "32" - }, - "t_struct(UserRewardInfo)20_storage": { - "encoding": "inplace", - "label": "struct TIP20.UserRewardInfo", - "members": [ - { - "astId": 15, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "rewardRecipient", - "offset": 0, - "slot": "0", - "type": "t_address" - }, - { - "astId": 17, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "rewardPerToken", - "offset": 0, - "slot": "1", - "type": "t_uint256" - }, - { - "astId": 19, - "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "rewardBalance", - "offset": 0, - "slot": "2", - "type": "t_uint256" - } - ], - "numberOfBytes": "96" - }, - "t_uint128": { - "encoding": "inplace", - "label": "uint128", - "numberOfBytes": "16" - }, - "t_uint256": { - "encoding": "inplace", - "label": "uint256", - "numberOfBytes": "32" - }, - "t_uint64": { - "encoding": "inplace", - "label": "uint64", - "numberOfBytes": "8" - } - } - } - } - }, - "version": "0.8.30+commit.73712a01.Darwin.appleclang" -} \ No newline at end of file diff --git a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol index 88e0a60e8b..5590c58aff 100644 --- a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol +++ b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol @@ -44,7 +44,7 @@ contract TIP20 { uint256 public totalSupply; mapping(address => uint256) public balances; mapping(address => mapping(address => uint256)) public allowances; - mapping(address => uint256) public nonces; + mapping(address => uint256) public permitNonces; bool public paused; uint256 public supplyCap; // Unused slot, kept for storage layout compatibility From 1cf008ae0d11c591f1903983433edb7dc9e19dd5 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:43:05 -0500 Subject: [PATCH 07/18] chore: disable permit tests for tempo for now --- tips/ref-impls/test/TIP20.t.sol | 8 ++++++- tips/ref-impls/test/invariants/TIP20.t.sol | 26 ++++++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/tips/ref-impls/test/TIP20.t.sol b/tips/ref-impls/test/TIP20.t.sol index 44eb45e91e..2683acc5c6 100644 --- a/tips/ref-impls/test/TIP20.t.sol +++ b/tips/ref-impls/test/TIP20.t.sol @@ -2561,6 +2561,7 @@ contract TIP20Test is BaseTest { } function test_Permit() public { + vm.skip(isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped address signer = vm.addr(SIGNER_KEY); uint256 value = 500e18; uint256 deadline = block.timestamp + 1 hours; @@ -2587,6 +2588,7 @@ contract TIP20Test is BaseTest { } function test_Permit_OverridesExistingAllowance() public { + vm.skip(isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped address signer = vm.addr(SIGNER_KEY); uint256 deadline = block.timestamp + 1 hours; @@ -2605,6 +2607,7 @@ contract TIP20Test is BaseTest { } function test_Permit_Replay() public { + vm.skip(isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped address signer = vm.addr(SIGNER_KEY); uint256 value = 500e18; uint256 deadline = block.timestamp + 1 hours; @@ -2628,6 +2631,7 @@ contract TIP20Test is BaseTest { } function test_Permit_Fail() public { + vm.skip(isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped address signer = vm.addr(SIGNER_KEY); uint256 value = 500e18; @@ -2673,6 +2677,7 @@ contract TIP20Test is BaseTest { } function test_Nonces() public { + vm.skip(isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped address signer = vm.addr(SIGNER_KEY); uint256 deadline = block.timestamp + 1 hours; @@ -2691,7 +2696,8 @@ contract TIP20Test is BaseTest { assertEq(token.nonces(signer), 2); } - function test_DomainSeparator() public view { + function test_DomainSeparator() public { + vm.skip(isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped bytes32 expected = keccak256( abi.encode( keccak256( diff --git a/tips/ref-impls/test/invariants/TIP20.t.sol b/tips/ref-impls/test/invariants/TIP20.t.sol index 490c55412f..84529cfa4b 100644 --- a/tips/ref-impls/test/invariants/TIP20.t.sol +++ b/tips/ref-impls/test/invariants/TIP20.t.sol @@ -1243,6 +1243,7 @@ contract TIP20InvariantTest is InvariantBaseTest { ) external { + vm.skip(isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped address actor = _selectActor(actorSeed); address recipient = _selectActorExcluding(recipientSeed, actor); TIP20 token = _selectBaseToken(tokenSeed); @@ -1276,17 +1277,34 @@ contract TIP20InvariantTest is InvariantBaseTest { // **TEMPO-TIP29**: Permit should set correct allowance assertEq( - token.allowance(actor, recipient), amount, "Permit did not set correct allowance" + token.allowance(actor, recipient), + amount, + "TEMPO-TIP29: Permit did not set correct allowance" ); // **TEMPO-TIP32**: Nonce should be incremented - assertEq(token.nonces(actor), actorNonce + 1, "Permit did not increment nonce"); + assertEq( + token.nonces(actor), actorNonce + 1, "TEMPO-TIP32: Permit did not increment nonce" + ); // **TEMPO-TIP34**: A permit with a deadline in the past must always revert. - assertGe(deadline, block.timestamp, "Permit should revert if deadline is past"); + assertGe( + deadline, block.timestamp, "TEMPO-TIP34: Permit should revert if deadline is past" + ); // **TEMPO-TIP35**: The recovered signer from a valid permit signature must exactly match the `owner` parameter. - assertEq(ecrecover(digest, v, r, s), signer, "Recovered signer does not match expected"); + assertEq( + ecrecover(digest, v, r, s), + signer, + "TEMPO-TIP35: Recovered signer does not match expected" + ); + + // Occasionally try 2nd permit. Use prime modulo to test all cases of seed % 4 between [0, 3] + if (resultSeed % 7 == 0) { + try token.permit(actor, recipient, amount, deadline, v, r, s) { + revert("TEMPO-TIP33: Permit should not be reusable"); + } catch (bytes memory) { } + } } catch (bytes memory) { } } From 07a004dc97a8f4e6b9e382653b7820308ac33cff Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:03:26 -0500 Subject: [PATCH 08/18] fix: use vm.assume in invariant handler instead of vm.skip vm.skip() inside an invariant handler causes [FAIL: skipped] in Foundry. Use vm.assume(!isTempo) to discard the fuzz run instead. Also formats all PR-changed Solidity files. Amp-Thread-ID: https://ampcode.com/threads/T-019c6f1f-917c-75d8-a908-1621cd459d6f Co-authored-by: Amp --- tips/ref-impls/src/TIP20.sol | 78 ++----- tips/ref-impls/src/interfaces/ITIP20.sol | 25 +-- tips/ref-impls/test/TIP20.t.sol | 224 +++++------------- tips/ref-impls/test/invariants/TIP20.t.sol | 249 +++++---------------- 4 files changed, 130 insertions(+), 446 deletions(-) diff --git a/tips/ref-impls/src/TIP20.sol b/tips/ref-impls/src/TIP20.sol index 1aae3dea60..cd58dd178d 100644 --- a/tips/ref-impls/src/TIP20.sol +++ b/tips/ref-impls/src/TIP20.sol @@ -1,16 +1,14 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 pragma solidity >=0.8.13 <0.9.0; -import { TIP20Factory } from "./TIP20Factory.sol"; -import { TIP403Registry } from "./TIP403Registry.sol"; -import { TempoUtilities } from "./TempoUtilities.sol"; -import { TIP20RolesAuth } from "./abstracts/TIP20RolesAuth.sol"; -import { ITIP20 } from "./interfaces/ITIP20.sol"; +import {TIP20Factory} from "./TIP20Factory.sol"; +import {TIP403Registry} from "./TIP403Registry.sol"; +import {TempoUtilities} from "./TempoUtilities.sol"; +import {TIP20RolesAuth} from "./abstracts/TIP20RolesAuth.sol"; +import {ITIP20} from "./interfaces/ITIP20.sol"; contract TIP20 is ITIP20, TIP20RolesAuth { - - TIP403Registry internal constant TIP403_REGISTRY = - TIP403Registry(0x403c000000000000000000000000000000000000); + TIP403Registry internal constant TIP403_REGISTRY = TIP403Registry(0x403c000000000000000000000000000000000000); address internal constant TIP_FEE_MANAGER_ADDRESS = 0xfeEC000000000000000000000000000000000000; address internal constant STABLECOIN_DEX_ADDRESS = 0xDEc0000000000000000000000000000000000000; @@ -36,9 +34,8 @@ contract TIP20 is ITIP20, TIP20RolesAuth { ITIP20 public override quoteToken; ITIP20 public override nextQuoteToken; - bytes32 public constant PERMIT_TYPEHASH = keccak256( - "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" - ); + bytes32 public constant PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); bytes32 public constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); bytes32 public constant UNPAUSE_ROLE = keccak256("UNPAUSE_ROLE"); @@ -234,10 +231,7 @@ contract TIP20 is ITIP20, TIP20RolesAuth { _; } - function transfer( - address to, - uint256 amount - ) + function transfer(address to, uint256 amount) public virtual notPaused @@ -254,11 +248,7 @@ contract TIP20 is ITIP20, TIP20RolesAuth { return true; } - function transferFrom( - address from, - address to, - uint256 amount - ) + function transferFrom(address from, address to, uint256 amount) public virtual notPaused @@ -342,9 +332,7 @@ contract TIP20 is ITIP20, TIP20RolesAuth { function DOMAIN_SEPARATOR() public view returns (bytes32) { return keccak256( abi.encode( - keccak256( - "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" - ), + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256(bytes(name)), keccak256(bytes("1")), block.chainid, @@ -353,22 +341,12 @@ contract TIP20 is ITIP20, TIP20RolesAuth { ); } - function permit( - address owner, - address spender, - uint256 value, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external { if (block.timestamp > deadline) revert PermitExpired(); - bytes32 structHash = keccak256( - abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline) - ); + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash)); @@ -384,11 +362,7 @@ contract TIP20 is ITIP20, TIP20RolesAuth { TIP20 EXTENSION FUNCTIONS //////////////////////////////////////////////////////////////*/ - function transferWithMemo( - address to, - uint256 amount, - bytes32 memo - ) + function transferWithMemo(address to, uint256 amount, bytes32 memo) public virtual notPaused @@ -399,12 +373,7 @@ contract TIP20 is ITIP20, TIP20RolesAuth { emit TransferWithMemo(msg.sender, to, amount, memo); } - function transferFromWithMemo( - address from, - address to, - uint256 amount, - bytes32 memo - ) + function transferFromWithMemo(address from, address to, uint256 amount, bytes32 memo) public virtual notPaused @@ -429,11 +398,7 @@ contract TIP20 is ITIP20, TIP20RolesAuth { /// @dev In the Tempo node implementation, this function is not exposed via the TIP20 interface /// and is not externally callable. It is only invoked internally by specific precompiles /// (like the fee manager precompile), avoiding the need to approve precompiles to spend tokens. - function systemTransferFrom( - address from, - address to, - uint256 amount - ) + function systemTransferFrom(address from, address to, uint256 amount) external virtual notPaused @@ -496,14 +461,10 @@ contract TIP20 is ITIP20, TIP20RolesAuth { //////////////////////////////////////////////////////////////*/ // Updates the rewards for `user` and their `rewardRecipient` - function _updateRewardsAndGetRecipient(address user) - internal - returns (address rewardRecipient) - { + function _updateRewardsAndGetRecipient(address user) internal returns (address rewardRecipient) { rewardRecipient = userRewardInfo[user].rewardRecipient; uint256 cachedGlobalRewardPerToken = globalRewardPerToken; - uint256 rewardPerTokenDelta = - cachedGlobalRewardPerToken - userRewardInfo[user].rewardPerToken; + uint256 rewardPerTokenDelta = cachedGlobalRewardPerToken - userRewardInfo[user].rewardPerToken; if (rewardPerTokenDelta != 0) { // No rewards to update if not opted-in @@ -612,5 +573,4 @@ contract TIP20 is ITIP20, TIP20RolesAuth { } } } - - } +} diff --git a/tips/ref-impls/src/interfaces/ITIP20.sol b/tips/ref-impls/src/interfaces/ITIP20.sol index e3fb9714ca..86adc07d87 100644 --- a/tips/ref-impls/src/interfaces/ITIP20.sol +++ b/tips/ref-impls/src/interfaces/ITIP20.sol @@ -4,7 +4,6 @@ pragma solidity >=0.8.13 <0.9.0; /// @title The interface for TIP-20 compliant tokens /// @notice A token standard that extends ERC-20 with additional features including transfer policies, memo support, and pause functionality interface ITIP20 { - /// @notice Error when attempting an operation while the contract is paused. error ContractPaused(); @@ -85,9 +84,7 @@ interface ITIP20 { /// @param to The address tokens were transferred to. /// @param amount The amount of tokens transferred. /// @param memo The memo attached to the transfer. - event TransferWithMemo( - address indexed from, address indexed to, uint256 amount, bytes32 indexed memo - ); + event TransferWithMemo(address indexed from, address indexed to, uint256 amount, bytes32 indexed memo); /// @notice Returns the role identifier for burning tokens from blocked accounts. /// @return The burn blocked role identifier. @@ -215,14 +212,7 @@ interface ITIP20 { /// @param amount The amount of tokens to transfer. /// @param memo The memo to attach to the transfer. /// @return success True if the transfer was successful. - function transferFromWithMemo( - address from, - address to, - uint256 amount, - bytes32 memo - ) - external - returns (bool); + function transferFromWithMemo(address from, address to, uint256 amount, bytes32 memo) external returns (bool); /// @notice Returns the current transfer policy identifier. /// @return The active transfer policy ID. @@ -257,15 +247,7 @@ interface ITIP20 { error InvalidSignature(); /// @notice Approves `spender` to spend `value` tokens on behalf of `owner` via a signed permit - function permit( - address owner, - address spender, - uint256 value, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external; /// @notice Returns the current nonce for an address @@ -273,5 +255,4 @@ interface ITIP20 { /// @notice Returns the EIP-712 domain separator for this token function DOMAIN_SEPARATOR() external view returns (bytes32); - } diff --git a/tips/ref-impls/test/TIP20.t.sol b/tips/ref-impls/test/TIP20.t.sol index 2683acc5c6..83c8ac8589 100644 --- a/tips/ref-impls/test/TIP20.t.sol +++ b/tips/ref-impls/test/TIP20.t.sol @@ -1,16 +1,15 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 pragma solidity >=0.8.13 <0.9.0; -import { TIP20 } from "../src/TIP20.sol"; -import { TIP20Factory } from "../src/TIP20Factory.sol"; -import { TIP403Registry } from "../src/TIP403Registry.sol"; -import { ITIP20 } from "../src/interfaces/ITIP20.sol"; -import { ITIP20RolesAuth } from "../src/interfaces/ITIP20RolesAuth.sol"; -import { ITIP403Registry } from "../src/interfaces/ITIP403Registry.sol"; -import { BaseTest } from "./BaseTest.t.sol"; +import {TIP20} from "../src/TIP20.sol"; +import {TIP20Factory} from "../src/TIP20Factory.sol"; +import {TIP403Registry} from "../src/TIP403Registry.sol"; +import {ITIP20} from "../src/interfaces/ITIP20.sol"; +import {ITIP20RolesAuth} from "../src/interfaces/ITIP20RolesAuth.sol"; +import {ITIP403Registry} from "../src/interfaces/ITIP403Registry.sol"; +import {BaseTest} from "./BaseTest.t.sol"; contract TIP20Test is BaseTest { - TIP20 token; TIP20 linkedToken; TIP20 anotherToken; @@ -22,9 +21,7 @@ contract TIP20Test is BaseTest { uint256 internal constant SIGNER_KEY = 0xA11CE; uint256 internal constant WRONG_KEY = 0xB0B; - event TransferWithMemo( - address indexed from, address indexed to, uint256 amount, bytes32 indexed memo - ); + event TransferWithMemo(address indexed from, address indexed to, uint256 amount, bytes32 indexed memo); event Transfer(address indexed from, address indexed to, uint256 amount); event Approval(address indexed owner, address indexed spender, uint256 amount); event Mint(address indexed to, uint256 amount); @@ -37,19 +34,11 @@ contract TIP20Test is BaseTest { function setUp() public override { super.setUp(); - linkedToken = TIP20( - factory.createToken( - "Linked Token", "LINK", "USD", TIP20(_PATH_USD), admin, bytes32("linked") - ) - ); - anotherToken = TIP20( - factory.createToken( - "Another Token", "OTHER", "USD", TIP20(_PATH_USD), admin, bytes32("another") - ) - ); - token = TIP20( - factory.createToken("Test Token", "TST", "USD", linkedToken, admin, bytes32("token")) - ); + linkedToken = + TIP20(factory.createToken("Linked Token", "LINK", "USD", TIP20(_PATH_USD), admin, bytes32("linked"))); + anotherToken = + TIP20(factory.createToken("Another Token", "OTHER", "USD", TIP20(_PATH_USD), admin, bytes32("another"))); + token = TIP20(factory.createToken("Test Token", "TST", "USD", linkedToken, admin, bytes32("token"))); // Setup roles and mint tokens vm.startPrank(admin); @@ -337,9 +326,7 @@ contract TIP20Test is BaseTest { uint256 allowanceAmount, uint256 transferAmount, bytes32 memo - ) - public - { + ) public { // Avoid invalid addresses vm.assume(spender != address(0) && to != address(0)); vm.assume((uint160(to) >> 64) != 0x20C000000000000000000000); @@ -457,10 +444,7 @@ contract TIP20Test is BaseTest { assertEq( err, abi.encodeWithSelector( - ITIP20.InsufficientBalance.selector, - token.balanceOf(admin), - 100e18, - address(token) + ITIP20.InsufficientBalance.selector, token.balanceOf(admin), 100e18, address(token) ) ); } @@ -487,9 +471,7 @@ contract TIP20Test is BaseTest { // Create a policy that blocks alice address[] memory accounts = new address[](1); accounts[0] = alice; - uint64 blockingPolicy = registry.createPolicyWithAccounts( - admin, ITIP403Registry.PolicyType.BLACKLIST, accounts - ); + uint64 blockingPolicy = registry.createPolicyWithAccounts(admin, ITIP403Registry.PolicyType.BLACKLIST, accounts); vm.prank(admin); token.changeTransferPolicyId(blockingPolicy); @@ -746,17 +728,11 @@ contract TIP20Test is BaseTest { } function testSetNextQuoteTokenUsdRequiresUsdQuote() public { - TIP20 usdToken = TIP20( - factory.createToken( - "USD Token", "USD", "USD", TIP20(_PATH_USD), admin, bytes32("usdtoken") - ) - ); + TIP20 usdToken = + TIP20(factory.createToken("USD Token", "USD", "USD", TIP20(_PATH_USD), admin, bytes32("usdtoken"))); - TIP20 nonUsdToken = TIP20( - factory.createToken( - "Euro Token", "EUR", "EUR", TIP20(_PATH_USD), admin, bytes32("eurotok") - ) - ); + TIP20 nonUsdToken = + TIP20(factory.createToken("Euro Token", "EUR", "EUR", TIP20(_PATH_USD), admin, bytes32("eurotok"))); vm.prank(admin); try usdToken.setNextQuoteToken(nonUsdToken) { @@ -825,12 +801,7 @@ contract TIP20Test is BaseTest { try token.burn(100e18) { revert CallShouldHaveReverted(); } catch (bytes memory err) { - assertEq( - err, - abi.encodeWithSelector( - ITIP20.InsufficientBalance.selector, 0, 100e18, address(token) - ) - ); + assertEq(err, abi.encodeWithSelector(ITIP20.InsufficientBalance.selector, 0, 100e18, address(token))); } } @@ -858,9 +829,7 @@ contract TIP20Test is BaseTest { // Create a policy that blocks alice address[] memory accounts = new address[](1); accounts[0] = alice; - uint64 blockingPolicy = registry.createPolicyWithAccounts( - admin, ITIP403Registry.PolicyType.BLACKLIST, accounts - ); + uint64 blockingPolicy = registry.createPolicyWithAccounts(admin, ITIP403Registry.PolicyType.BLACKLIST, accounts); // Change to a policy where alice is blocked vm.startPrank(admin); @@ -884,9 +853,7 @@ contract TIP20Test is BaseTest { // Create a policy that blocks alice address[] memory accounts = new address[](1); accounts[0] = alice; - uint64 blockingPolicy = registry.createPolicyWithAccounts( - admin, ITIP403Registry.PolicyType.BLACKLIST, accounts - ); + uint64 blockingPolicy = registry.createPolicyWithAccounts(admin, ITIP403Registry.PolicyType.BLACKLIST, accounts); vm.prank(admin); token.changeTransferPolicyId(blockingPolicy); @@ -925,12 +892,7 @@ contract TIP20Test is BaseTest { try token.transfer(bob, 2000e18) { revert CallShouldHaveReverted(); } catch (bytes memory err) { - assertEq( - err, - abi.encodeWithSelector( - ITIP20.InsufficientBalance.selector, 1000e18, 2000e18, address(token) - ) - ); + assertEq(err, abi.encodeWithSelector(ITIP20.InsufficientBalance.selector, 1000e18, 2000e18, address(token))); } } @@ -1058,9 +1020,7 @@ contract TIP20Test is BaseTest { } function testCompleteQuoteTokenUpdateCannotCreateIndirectLoop() public { - TIP20 newToken = TIP20( - factory.createToken("New Token", "NEW", "USD", token, admin, bytes32("newtoken")) - ); + TIP20 newToken = TIP20(factory.createToken("New Token", "NEW", "USD", token, admin, bytes32("newtoken"))); // Try to set token's quote token to newToken (which would create a loop) vm.startPrank(admin); @@ -1081,8 +1041,7 @@ contract TIP20Test is BaseTest { function testCompleteQuoteTokenUpdateCannotCreateLongerLoop() public { // Create a longer chain: pathUSD -> linkedToken -> token -> token2 -> token3 - TIP20 token3 = - TIP20(factory.createToken("Token 3", "TK2", "USD", token, admin, bytes32("token3"))); + TIP20 token3 = TIP20(factory.createToken("Token 3", "TK2", "USD", token, admin, bytes32("token3"))); // Try to set linkedToken's quote token to token3 (would create loop) vm.startPrank(admin); @@ -1555,13 +1514,7 @@ contract TIP20Test is BaseTest { } } - function testFuzzRewardDistribution( - uint256 aliceBalance, - uint256 bobBalance, - uint256 rewardAmount - ) - public - { + function testFuzzRewardDistribution(uint256 aliceBalance, uint256 bobBalance, uint256 rewardAmount) public { // Bound inputs aliceBalance = bound(aliceBalance, 1e18, 1000e18); bobBalance = bound(bobBalance, 1e18, 1000e18); @@ -1694,12 +1647,7 @@ contract TIP20Test is BaseTest { assertEq(token.totalSupply(), totalSupplyBefore); } - function testFuzz_transferFrom( - address spender, - address to, - uint256 allowanceAmount, - uint256 transferAmount - ) + function testFuzz_transferFrom(address spender, address to, uint256 allowanceAmount, uint256 transferAmount) public { vm.assume(spender != address(0) && to != address(0)); @@ -1741,14 +1689,7 @@ contract TIP20Test is BaseTest { assertEq(token.balanceOf(alice), 1000e18); } - function testFuzz_multipleApprovals( - address spender, - uint256 amount1, - uint256 amount2, - uint256 amount3 - ) - public - { + function testFuzz_multipleApprovals(address spender, uint256 amount1, uint256 amount2, uint256 amount3) public { vm.assume(spender != address(0)); amount1 = bound(amount1, 0, type(uint128).max); amount2 = bound(amount2, 0, type(uint128).max); @@ -1804,14 +1745,7 @@ contract TIP20Test is BaseTest { vm.stopPrank(); } - function testFuzz_mintBurnSequence( - uint256 mint1, - uint256 mint2, - uint256 burn1, - uint256 mint3 - ) - public - { + function testFuzz_mintBurnSequence(uint256 mint1, uint256 mint2, uint256 burn1, uint256 mint3) public { mint1 = bound(mint1, 1e18, type(uint128).max / 5); mint2 = bound(mint2, 1e18, type(uint128).max / 5); burn1 = bound(burn1, 0, mint1 + mint2); @@ -1877,13 +1811,7 @@ contract TIP20Test is BaseTest { } } - function testFuzz_rewardDistributionAlt( - uint256 aliceBalance, - uint256 bobBalance, - uint256 rewardAmount - ) - public - { + function testFuzz_rewardDistributionAlt(uint256 aliceBalance, uint256 bobBalance, uint256 rewardAmount) public { aliceBalance = bound(aliceBalance, 1e18, 1000e18); bobBalance = bound(bobBalance, 1e18, 1000e18); rewardAmount = bound(rewardAmount, 1e18, 500e18); @@ -1941,12 +1869,7 @@ contract TIP20Test is BaseTest { assertApproxEqAbs(token.balanceOf(bob), bobBalance + bobExpected, 1000); } - function testFuzz_optedInSupplyConsistency( - uint256 aliceAmount, - uint256 bobAmount, - bool aliceOpts, - bool bobOpts - ) + function testFuzz_optedInSupplyConsistency(uint256 aliceAmount, uint256 bobAmount, bool aliceOpts, bool bobOpts) public { aliceAmount = bound(aliceAmount, 1e18, type(uint128).max / 4); @@ -2042,9 +1965,7 @@ contract TIP20Test is BaseTest { /// @notice INVARIANT: OptedInSupply never exceeds totalSupply function test_INVARIANT_optedInSupplyBounds() public view { - assertLe( - token.optedInSupply(), token.totalSupply(), "CRITICAL: OptedInSupply > totalSupply" - ); + assertLe(token.optedInSupply(), token.totalSupply(), "CRITICAL: OptedInSupply > totalSupply"); } /// @notice INVARIANT: Total supply never exceeds supply cap @@ -2274,9 +2195,8 @@ contract TIP20Test is BaseTest { function test_ClaimRewards_RevertsIf_UserUnauthorized() public { address[] memory accounts = new address[](1); accounts[0] = alice; - uint64 blacklistPolicy = registry.createPolicyWithAccounts( - admin, ITIP403Registry.PolicyType.BLACKLIST, accounts - ); + uint64 blacklistPolicy = + registry.createPolicyWithAccounts(admin, ITIP403Registry.PolicyType.BLACKLIST, accounts); vm.prank(admin); token.changeTransferPolicyId(blacklistPolicy); @@ -2293,18 +2213,14 @@ contract TIP20Test is BaseTest { vm.startPrank(admin); uint64 senderWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); - uint64 recipientWhitelist = - registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); + uint64 recipientWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); uint64 mintWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); registry.modifyPolicyWhitelist(mintWhitelist, charlie, true); - uint64 compound = - registry.createCompoundPolicy(senderWhitelist, recipientWhitelist, mintWhitelist); + uint64 compound = registry.createCompoundPolicy(senderWhitelist, recipientWhitelist, mintWhitelist); - TIP20 compoundToken = TIP20( - factory.createToken("COMPOUND", "CMP", "USD", pathUSD, admin, bytes32("compound")) - ); + TIP20 compoundToken = TIP20(factory.createToken("COMPOUND", "CMP", "USD", pathUSD, admin, bytes32("compound"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.changeTransferPolicyId(compound); @@ -2318,18 +2234,15 @@ contract TIP20Test is BaseTest { vm.startPrank(admin); uint64 senderWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); - uint64 recipientWhitelist = - registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); + uint64 recipientWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); uint64 mintWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); // charlie is NOT in mintWhitelist - uint64 compound = - registry.createCompoundPolicy(senderWhitelist, recipientWhitelist, mintWhitelist); + uint64 compound = registry.createCompoundPolicy(senderWhitelist, recipientWhitelist, mintWhitelist); - TIP20 compoundToken = TIP20( - factory.createToken("COMPOUND2", "CMP2", "USD", pathUSD, admin, bytes32("compound2")) - ); + TIP20 compoundToken = + TIP20(factory.createToken("COMPOUND2", "CMP2", "USD", pathUSD, admin, bytes32("compound2"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.changeTransferPolicyId(compound); @@ -2347,17 +2260,15 @@ contract TIP20Test is BaseTest { vm.startPrank(admin); uint64 senderWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); - uint64 recipientWhitelist = - registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); + uint64 recipientWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); registry.modifyPolicyWhitelist(senderWhitelist, alice, true); registry.modifyPolicyWhitelist(recipientWhitelist, bob, true); uint64 compound = registry.createCompoundPolicy(senderWhitelist, recipientWhitelist, 1); - TIP20 compoundToken = TIP20( - factory.createToken("COMPOUND3", "CMP3", "USD", pathUSD, admin, bytes32("compound3")) - ); + TIP20 compoundToken = + TIP20(factory.createToken("COMPOUND3", "CMP3", "USD", pathUSD, admin, bytes32("compound3"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.changeTransferPolicyId(1); compoundToken.mint(alice, 1000); @@ -2380,9 +2291,8 @@ contract TIP20Test is BaseTest { uint64 compound = registry.createCompoundPolicy(senderWhitelist, 1, 1); - TIP20 compoundToken = TIP20( - factory.createToken("COMPOUND4", "CMP4", "USD", pathUSD, admin, bytes32("compound4")) - ); + TIP20 compoundToken = + TIP20(factory.createToken("COMPOUND4", "CMP4", "USD", pathUSD, admin, bytes32("compound4"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.changeTransferPolicyId(1); compoundToken.mint(alice, 1000); @@ -2402,15 +2312,13 @@ contract TIP20Test is BaseTest { function test_Transfer_Fails_RecipientUnauthorized_CompoundPolicy() public { vm.startPrank(admin); - uint64 recipientWhitelist = - registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); + uint64 recipientWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); // bob is NOT in recipientWhitelist uint64 compound = registry.createCompoundPolicy(1, recipientWhitelist, 1); - TIP20 compoundToken = TIP20( - factory.createToken("COMPOUND5", "CMP5", "USD", pathUSD, admin, bytes32("compound5")) - ); + TIP20 compoundToken = + TIP20(factory.createToken("COMPOUND5", "CMP5", "USD", pathUSD, admin, bytes32("compound5"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.changeTransferPolicyId(1); compoundToken.mint(alice, 1000); @@ -2436,8 +2344,7 @@ contract TIP20Test is BaseTest { // charlie blocked from sending, but anyone can receive uint64 asymmetricCompound = registry.createCompoundPolicy(senderBlacklist, 1, 1); - TIP20 compoundToken = - TIP20(factory.createToken("ASYM", "ASY", "USD", pathUSD, admin, bytes32("asym"))); + TIP20 compoundToken = TIP20(factory.createToken("ASYM", "ASY", "USD", pathUSD, admin, bytes32("asym"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.changeTransferPolicyId(1); compoundToken.mint(alice, 1000); @@ -2469,8 +2376,7 @@ contract TIP20Test is BaseTest { uint64 asymmetricCompound = registry.createCompoundPolicy(senderBlacklist, 1, 1); - TIP20 compoundToken = - TIP20(factory.createToken("BURN1", "BRN1", "USD", pathUSD, admin, bytes32("burn1"))); + TIP20 compoundToken = TIP20(factory.createToken("BURN1", "BRN1", "USD", pathUSD, admin, bytes32("burn1"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.grantRole(_BURN_BLOCKED_ROLE, admin); compoundToken.changeTransferPolicyId(1); @@ -2491,8 +2397,7 @@ contract TIP20Test is BaseTest { uint64 asymmetricCompound = registry.createCompoundPolicy(senderBlacklist, 1, 1); - TIP20 compoundToken = - TIP20(factory.createToken("BURN2", "BRN2", "USD", pathUSD, admin, bytes32("burn2"))); + TIP20 compoundToken = TIP20(factory.createToken("BURN2", "BRN2", "USD", pathUSD, admin, bytes32("burn2"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.grantRole(_BURN_BLOCKED_ROLE, admin); compoundToken.changeTransferPolicyId(1); @@ -2513,14 +2418,12 @@ contract TIP20Test is BaseTest { vm.startPrank(admin); // Create compound where only recipient is blocked, sender is allowed - uint64 recipientBlacklist = - registry.createPolicy(admin, ITIP403Registry.PolicyType.BLACKLIST); + uint64 recipientBlacklist = registry.createPolicy(admin, ITIP403Registry.PolicyType.BLACKLIST); registry.modifyPolicyBlacklist(recipientBlacklist, charlie, true); uint64 recipientBlockedCompound = registry.createCompoundPolicy(1, recipientBlacklist, 1); - TIP20 compoundToken = - TIP20(factory.createToken("BURN3", "BRN3", "USD", pathUSD, admin, bytes32("burn3"))); + TIP20 compoundToken = TIP20(factory.createToken("BURN3", "BRN3", "USD", pathUSD, admin, bytes32("burn3"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.grantRole(_BURN_BLOCKED_ROLE, admin); compoundToken.changeTransferPolicyId(1); @@ -2543,20 +2446,12 @@ contract TIP20Test is BaseTest { //////////////////////////////////////////////////////////////*/ /// @dev Helper to build the EIP-712 digest for a permit call - function _permitDigest( - address owner_, - address spender_, - uint256 value_, - uint256 nonce_, - uint256 deadline_ - ) + function _permitDigest(address owner_, address spender_, uint256 value_, uint256 nonce_, uint256 deadline_) internal view returns (bytes32) { - bytes32 structHash = keccak256( - abi.encode(token.PERMIT_TYPEHASH(), owner_, spender_, value_, nonce_, deadline_) - ); + bytes32 structHash = keccak256(abi.encode(token.PERMIT_TYPEHASH(), owner_, spender_, value_, nonce_, deadline_)); return keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); } @@ -2700,9 +2595,7 @@ contract TIP20Test is BaseTest { vm.skip(isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped bytes32 expected = keccak256( abi.encode( - keccak256( - "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" - ), + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256(bytes(token.name())), keccak256(bytes("1")), block.chainid, @@ -2711,5 +2604,4 @@ contract TIP20Test is BaseTest { ); assertEq(token.DOMAIN_SEPARATOR(), expected); } - } diff --git a/tips/ref-impls/test/invariants/TIP20.t.sol b/tips/ref-impls/test/invariants/TIP20.t.sol index 84529cfa4b..c64bf102c0 100644 --- a/tips/ref-impls/test/invariants/TIP20.t.sol +++ b/tips/ref-impls/test/invariants/TIP20.t.sol @@ -1,15 +1,14 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; -import { TIP20 } from "../../src/TIP20.sol"; -import { ITIP20 } from "../../src/interfaces/ITIP20.sol"; -import { InvariantBaseTest } from "./InvariantBaseTest.t.sol"; +import {TIP20} from "../../src/TIP20.sol"; +import {ITIP20} from "../../src/interfaces/ITIP20.sol"; +import {InvariantBaseTest} from "./InvariantBaseTest.t.sol"; /// @title TIP20 Invariant Tests /// @notice Fuzz-based invariant tests for the TIP20 token implementation /// @dev Tests invariants TEMPO-TIP1 through TEMPO-TIP29 contract TIP20InvariantTest is InvariantBaseTest { - /// @dev Ghost variables for reward distribution tracking uint256 private _totalRewardsDistributed; uint256 private _totalRewardsClaimed; @@ -106,14 +105,7 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for token transfers /// @dev Tests TEMPO-TIP1 (balance conservation), TEMPO-TIP2 (total supply unchanged) - function transfer( - uint256 actorSeed, - uint256 tokenSeed, - uint256 recipientSeed, - uint256 amount - ) - external - { + function transfer(uint256 actorSeed, uint256 tokenSeed, uint256 recipientSeed, uint256 amount) external { TIP20 token = _selectBaseToken(tokenSeed); address actor = _selectAuthorizedActor(actorSeed, address(token)); address recipient = _selectActorExcluding(recipientSeed, actor); @@ -137,9 +129,7 @@ contract TIP20InvariantTest is InvariantBaseTest { // TEMPO-TIP1: Balance conservation assertEq( - token.balanceOf(actor), - actorBalance - amount, - "TEMPO-TIP1: Sender balance not decreased correctly" + token.balanceOf(actor), actorBalance - amount, "TEMPO-TIP1: Sender balance not decreased correctly" ); assertEq( token.balanceOf(recipient), @@ -148,11 +138,7 @@ contract TIP20InvariantTest is InvariantBaseTest { ); // TEMPO-TIP2: Total supply unchanged - assertEq( - token.totalSupply(), - totalSupplyBefore, - "TEMPO-TIP2: Total supply changed during transfer" - ); + assertEq(token.totalSupply(), totalSupplyBefore, "TEMPO-TIP2: Total supply changed during transfer"); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -161,13 +147,7 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for zero-amount transfer edge case /// @dev Tests that zero-amount transfers are handled correctly - function transferZeroAmount( - uint256 actorSeed, - uint256 tokenSeed, - uint256 recipientSeed - ) - external - { + function transferZeroAmount(uint256 actorSeed, uint256 tokenSeed, uint256 recipientSeed) external { TIP20 token = _selectBaseToken(tokenSeed); address actor = _selectAuthorizedActor(actorSeed, address(token)); address recipient = _selectActorExcluding(recipientSeed, actor); @@ -186,19 +166,9 @@ contract TIP20InvariantTest is InvariantBaseTest { assertTrue(success, "Zero transfer should return true"); // Balances should remain unchanged - assertEq( - token.balanceOf(actor), - actorBalanceBefore, - "Sender balance changed on zero transfer" - ); - assertEq( - token.balanceOf(recipient), - recipientBalanceBefore, - "Recipient balance changed on zero transfer" - ); - assertEq( - token.totalSupply(), totalSupplyBefore, "Total supply changed on zero transfer" - ); + assertEq(token.balanceOf(actor), actorBalanceBefore, "Sender balance changed on zero transfer"); + assertEq(token.balanceOf(recipient), recipientBalanceBefore, "Recipient balance changed on zero transfer"); + assertEq(token.totalSupply(), totalSupplyBefore, "Total supply changed on zero transfer"); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -213,9 +183,7 @@ contract TIP20InvariantTest is InvariantBaseTest { uint256 ownerSeed, uint256 recipientSeed, uint256 amount - ) - external - { + ) external { TIP20 token = _selectBaseToken(tokenSeed); address owner = _selectAuthorizedActor(ownerSeed, address(token)); address spender = _selectActorExcluding(actorSeed, owner); @@ -250,17 +218,11 @@ contract TIP20InvariantTest is InvariantBaseTest { ); } else { assertEq( - token.allowance(owner, spender), - allowance - amount, - "TEMPO-TIP3: Allowance not decreased correctly" + token.allowance(owner, spender), allowance - amount, "TEMPO-TIP3: Allowance not decreased correctly" ); } - assertEq( - token.balanceOf(owner), - ownerBalance - amount, - "TEMPO-TIP3: Owner balance not decreased" - ); + assertEq(token.balanceOf(owner), ownerBalance - amount, "TEMPO-TIP3: Owner balance not decreased"); assertEq( token.balanceOf(recipient), recipientBalanceBefore + amount, @@ -274,14 +236,7 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for approvals /// @dev Tests TEMPO-TIP5 (allowance setting) - function approve( - uint256 actorSeed, - uint256 tokenSeed, - uint256 spenderSeed, - uint256 amount - ) - external - { + function approve(uint256 actorSeed, uint256 tokenSeed, uint256 spenderSeed, uint256 amount) external { address actor = _selectActor(actorSeed); address spender = _selectActor(spenderSeed); TIP20 token = _selectBaseToken(tokenSeed); @@ -293,9 +248,7 @@ contract TIP20InvariantTest is InvariantBaseTest { vm.stopPrank(); assertTrue(success, "TEMPO-TIP5: Approve should return true"); - assertEq( - token.allowance(actor, spender), amount, "TEMPO-TIP5: Allowance not set correctly" - ); + assertEq(token.allowance(actor, spender), amount, "TEMPO-TIP5: Allowance not set correctly"); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -326,11 +279,7 @@ contract TIP20InvariantTest is InvariantBaseTest { _tokenMintSum[address(token)] += amount; // TEMPO-TIP6: Total supply should increase - assertEq( - token.totalSupply(), - currentSupply + amount, - "TEMPO-TIP6: Total supply not increased correctly" - ); + assertEq(token.totalSupply(), currentSupply + amount, "TEMPO-TIP6: Total supply not increased correctly"); // TEMPO-TIP7: Total supply should not exceed cap assertLe(token.totalSupply(), supplyCap, "TEMPO-TIP7: Total supply exceeds supply cap"); @@ -366,16 +315,10 @@ contract TIP20InvariantTest is InvariantBaseTest { // TEMPO-TIP8: Total supply should decrease assertEq( - token.totalSupply(), - totalSupplyBefore - amount, - "TEMPO-TIP8: Total supply not decreased correctly" + token.totalSupply(), totalSupplyBefore - amount, "TEMPO-TIP8: Total supply not decreased correctly" ); - assertEq( - token.balanceOf(admin), - adminBalance - amount, - "TEMPO-TIP8: Admin balance not decreased" - ); + assertEq(token.balanceOf(admin), adminBalance - amount, "TEMPO-TIP8: Admin balance not decreased"); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -384,13 +327,7 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for transfer with memo /// @dev Tests TEMPO-TIP9 (memo transfers work like regular transfers) - function transferWithMemo( - uint256 actorSeed, - uint256 tokenSeed, - uint256 recipientSeed, - uint256 amount, - bytes32 memo - ) + function transferWithMemo(uint256 actorSeed, uint256 tokenSeed, uint256 recipientSeed, uint256 amount, bytes32 memo) external { TIP20 token = _selectBaseToken(tokenSeed); @@ -414,11 +351,7 @@ contract TIP20InvariantTest is InvariantBaseTest { vm.stopPrank(); // TEMPO-TIP9: Balance changes same as regular transfer - assertEq( - token.balanceOf(actor), - actorBalance - amount, - "TEMPO-TIP9: Sender balance not decreased" - ); + assertEq(token.balanceOf(actor), actorBalance - amount, "TEMPO-TIP9: Sender balance not decreased"); assertEq( token.balanceOf(recipient), recipientBalanceBefore + amount, @@ -440,9 +373,7 @@ contract TIP20InvariantTest is InvariantBaseTest { uint256 recipientSeed, uint256 amount, bytes32 memo - ) - external - { + ) external { TIP20 token = _selectBaseToken(tokenSeed); address owner = _selectAuthorizedActor(ownerSeed, address(token)); address spender = _selectActorExcluding(actorSeed, owner); @@ -470,11 +401,7 @@ contract TIP20InvariantTest is InvariantBaseTest { assertTrue(success, "TEMPO-TIP9: TransferFromWithMemo should return true"); // Balance changes same as regular transferFrom - assertEq( - token.balanceOf(owner), - ownerBalance - amount, - "TEMPO-TIP9: Owner balance not decreased" - ); + assertEq(token.balanceOf(owner), ownerBalance - amount, "TEMPO-TIP9: Owner balance not decreased"); assertEq( token.balanceOf(recipient), recipientBalanceBefore + amount, @@ -491,9 +418,7 @@ contract TIP20InvariantTest is InvariantBaseTest { ); } else { assertEq( - token.allowance(owner, spender), - allowance - amount, - "TEMPO-TIP3: Allowance not decreased correctly" + token.allowance(owner, spender), allowance - amount, "TEMPO-TIP3: Allowance not decreased correctly" ); } } catch (bytes memory reason) { @@ -504,13 +429,7 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for setting reward recipient (opt-in, opt-out, or delegate) /// @dev Tests TEMPO-TIP10 (opted-in supply), TEMPO-TIP11 (supply updates), TEMPO-TIP25 (delegation) - function setRewardRecipient( - uint256 actorSeed, - uint256 tokenSeed, - uint256 recipientSeed - ) - external - { + function setRewardRecipient(uint256 actorSeed, uint256 tokenSeed, uint256 recipientSeed) external { TIP20 token = _selectBaseToken(tokenSeed); address actor = _selectAuthorizedActor(actorSeed, address(token)); @@ -553,15 +472,11 @@ contract TIP20InvariantTest is InvariantBaseTest { uint128 optedInSupplyAfter = token.optedInSupply(); if (currentRecipient == address(0) && newRecipient != address(0)) { assertEq( - optedInSupplyAfter, - optedInSupplyBefore + uint128(actorBalance), - "Opted-in supply not increased" + optedInSupplyAfter, optedInSupplyBefore + uint128(actorBalance), "Opted-in supply not increased" ); } else if (currentRecipient != address(0) && newRecipient == address(0)) { assertEq( - optedInSupplyAfter, - optedInSupplyBefore - uint128(actorBalance), - "Opted-in supply not decreased" + optedInSupplyAfter, optedInSupplyBefore - uint128(actorBalance), "Opted-in supply not decreased" ); } } catch (bytes memory reason) { @@ -745,9 +660,7 @@ contract TIP20InvariantTest is InvariantBaseTest { ); // TEMPO-TIP15: Claimed amount should not exceed available - assertLe( - claimed, contractBalanceBefore, "TEMPO-TIP15: Claimed more than contract balance" - ); + assertLe(claimed, contractBalanceBefore, "TEMPO-TIP15: Claimed more than contract balance"); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -835,18 +748,10 @@ contract TIP20InvariantTest is InvariantBaseTest { _tokenBurnSum[address(token)] += amount; // TEMPO-TIP23: Balance should decrease - assertEq( - token.balanceOf(target), - targetBalance - amount, - "TEMPO-TIP23: Target balance not decreased" - ); + assertEq(token.balanceOf(target), targetBalance - amount, "TEMPO-TIP23: Target balance not decreased"); // TEMPO-TIP23: Total supply should decrease - assertEq( - token.totalSupply(), - totalSupplyBefore - amount, - "TEMPO-TIP23: Total supply not decreased" - ); + assertEq(token.totalSupply(), totalSupplyBefore - amount, "TEMPO-TIP23: Total supply not decreased"); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -955,12 +860,7 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for unauthorized burnBlocked attempts /// @dev Tests TEMPO-TIP29 (only BURN_BLOCKED_ROLE can call burnBlocked) - function burnBlockedUnauthorized( - uint256 actorSeed, - uint256 tokenSeed, - uint256 targetSeed, - uint256 amount - ) + function burnBlockedUnauthorized(uint256 actorSeed, uint256 tokenSeed, uint256 targetSeed, uint256 amount) external { address attacker = _selectActor(actorSeed); @@ -1053,25 +953,20 @@ contract TIP20InvariantTest is InvariantBaseTest { vm.startPrank(admin); try token.setNextQuoteToken(ITIP20(address(newQuoteToken))) { // Next quote token should be set - assertEq( - address(token.nextQuoteToken()), address(newQuoteToken), "Next quote token not set" - ); + assertEq(address(token.nextQuoteToken()), address(newQuoteToken), "Next quote token not set"); // Try to complete the update try token.completeQuoteTokenUpdate() { vm.stopPrank(); // Quote token should be updated - assertEq( - address(token.quoteToken()), address(newQuoteToken), "Quote token not updated" - ); + assertEq(address(token.quoteToken()), address(newQuoteToken), "Quote token not updated"); } catch (bytes memory reason) { vm.stopPrank(); // Cycle detection may reject bytes4 selector = bytes4(reason); assertTrue( - selector == ITIP20.InvalidQuoteToken.selector, - "Unexpected error on completeQuoteTokenUpdate" + selector == ITIP20.InvalidQuoteToken.selector, "Unexpected error on completeQuoteTokenUpdate" ); } } catch (bytes memory reason) { @@ -1117,9 +1012,7 @@ contract TIP20InvariantTest is InvariantBaseTest { assertEq(token.supplyCap(), newCap, "TEMPO-TIP22: Supply cap not updated"); // TEMPO-TIP22: Cap must be >= current supply - assertGe( - token.supplyCap(), token.totalSupply(), "TEMPO-TIP22: Supply cap below total supply" - ); + assertGe(token.supplyCap(), token.totalSupply(), "TEMPO-TIP22: Supply cap below total supply"); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -1128,13 +1021,7 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for unauthorized supply cap change attempts /// @dev Tests that non-admin cannot change supply cap - function setSupplyCapUnauthorized( - uint256 actorSeed, - uint256 tokenSeed, - uint256 newCap - ) - external - { + function setSupplyCapUnauthorized(uint256 actorSeed, uint256 tokenSeed, uint256 newCap) external { address attacker = _selectActor(actorSeed); TIP20 token = _selectBaseToken(tokenSeed); @@ -1167,9 +1054,7 @@ contract TIP20InvariantTest is InvariantBaseTest { } catch (bytes memory reason) { vm.stopPrank(); assertEq( - bytes4(reason), - ITIP20.InvalidSupplyCap.selector, - "TEMPO-TIP22: Should revert with InvalidSupplyCap" + bytes4(reason), ITIP20.InvalidSupplyCap.selector, "TEMPO-TIP22: Should revert with InvalidSupplyCap" ); } } @@ -1202,9 +1087,7 @@ contract TIP20InvariantTest is InvariantBaseTest { vm.stopPrank(); // TEMPO-TIP16: Authorization status should be updated bool afterAuthorized = _isAuthorized(address(token), actor); - assertEq( - afterAuthorized, !blacklist, "TEMPO-TIP16: Blacklist status not updated correctly" - ); + assertEq(afterAuthorized, !blacklist, "TEMPO-TIP16: Blacklist status not updated correctly"); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -1240,10 +1123,8 @@ contract TIP20InvariantTest is InvariantBaseTest { bytes32 r, bytes32 s, uint256 resultSeed - ) - external - { - vm.skip(isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped + ) external { + vm.assume(!isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped address actor = _selectActor(actorSeed); address recipient = _selectActorExcluding(recipientSeed, actor); TIP20 token = _selectBaseToken(tokenSeed); @@ -1252,9 +1133,7 @@ contract TIP20InvariantTest is InvariantBaseTest { // build permit digest bytes32 digest = keccak256( abi.encodePacked( - "\x19\x01", - token.DOMAIN_SEPARATOR(), - keccak256(abi.encodePacked(actor, recipient, amount, deadline)) + "\x19\x01", token.DOMAIN_SEPARATOR(), keccak256(abi.encodePacked(actor, recipient, amount, deadline)) ) ); @@ -1276,47 +1155,29 @@ contract TIP20InvariantTest is InvariantBaseTest { // If permit passes, check invariants // **TEMPO-TIP29**: Permit should set correct allowance - assertEq( - token.allowance(actor, recipient), - amount, - "TEMPO-TIP29: Permit did not set correct allowance" - ); + assertEq(token.allowance(actor, recipient), amount, "TEMPO-TIP29: Permit did not set correct allowance"); // **TEMPO-TIP32**: Nonce should be incremented - assertEq( - token.nonces(actor), actorNonce + 1, "TEMPO-TIP32: Permit did not increment nonce" - ); + assertEq(token.nonces(actor), actorNonce + 1, "TEMPO-TIP32: Permit did not increment nonce"); // **TEMPO-TIP34**: A permit with a deadline in the past must always revert. - assertGe( - deadline, block.timestamp, "TEMPO-TIP34: Permit should revert if deadline is past" - ); + assertGe(deadline, block.timestamp, "TEMPO-TIP34: Permit should revert if deadline is past"); // **TEMPO-TIP35**: The recovered signer from a valid permit signature must exactly match the `owner` parameter. - assertEq( - ecrecover(digest, v, r, s), - signer, - "TEMPO-TIP35: Recovered signer does not match expected" - ); + assertEq(ecrecover(digest, v, r, s), signer, "TEMPO-TIP35: Recovered signer does not match expected"); // Occasionally try 2nd permit. Use prime modulo to test all cases of seed % 4 between [0, 3] if (resultSeed % 7 == 0) { try token.permit(actor, recipient, amount, deadline, v, r, s) { revert("TEMPO-TIP33: Permit should not be reusable"); - } catch (bytes memory) { } + } catch (bytes memory) {} } - } catch (bytes memory) { } + } catch (bytes memory) {} } /// @notice Handler that verifies paused tokens reject transfers with ContractPaused /// @dev Tests TEMPO-TIP17: pause enforcement - transfers revert with ContractPaused - function tryTransferWhilePaused( - uint256 actorSeed, - uint256 tokenSeed, - uint256 recipientSeed - ) - external - { + function tryTransferWhilePaused(uint256 actorSeed, uint256 tokenSeed, uint256 recipientSeed) external { address actor = _selectActor(actorSeed); address recipient = _selectActorExcluding(recipientSeed, actor); TIP20 token = _selectBaseToken(tokenSeed); @@ -1351,11 +1212,7 @@ contract TIP20InvariantTest is InvariantBaseTest { uint256 totalSupply = token.totalSupply(); // TEMPO-TIP19: Opted-in supply <= total supply - assertLe( - token.optedInSupply(), - totalSupply, - "TEMPO-TIP19: Opted-in supply exceeds total supply" - ); + assertLe(token.optedInSupply(), totalSupply, "TEMPO-TIP19: Opted-in supply exceeds total supply"); // TEMPO-TIP22: Supply cap is enforced assertLe(totalSupply, token.supplyCap(), "TEMPO-TIP22: Total supply exceeds supply cap"); @@ -1381,15 +1238,10 @@ contract TIP20InvariantTest is InvariantBaseTest { uint256 contractBalance = token.balanceOf(tokenAddr); uint256 expectedUnclaimed = distributed - claimed; uint256 holderCount = holders.length; - uint256 maxDust = - _tokenDistributionCount[tokenAddr] * (holderCount > 0 ? holderCount : 1); + uint256 maxDust = _tokenDistributionCount[tokenAddr] * (holderCount > 0 ? holderCount : 1); if (expectedUnclaimed > maxDust) { - assertGe( - contractBalance, - expectedUnclaimed - maxDust, - "Reward dust exceeds theoretical bound" - ); + assertGe(contractBalance, expectedUnclaimed - maxDust, "Reward dust exceeds theoretical bound"); } } } @@ -1399,5 +1251,4 @@ contract TIP20InvariantTest is InvariantBaseTest { function _selectActorKey(uint256 seed) internal view returns (uint256) { return _keys[seed % _keys.length]; } - } From 8df0c39a885fca4eca794cd0556972e915249af2 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:05:20 -0500 Subject: [PATCH 09/18] chore: lint sol --- tips/ref-impls/src/TIP20.sol | 78 +++++-- tips/ref-impls/src/interfaces/ITIP20.sol | 25 ++- tips/ref-impls/test/TIP20.t.sol | 224 ++++++++++++++----- tips/ref-impls/test/invariants/TIP20.t.sol | 247 +++++++++++++++++---- 4 files changed, 445 insertions(+), 129 deletions(-) diff --git a/tips/ref-impls/src/TIP20.sol b/tips/ref-impls/src/TIP20.sol index cd58dd178d..1aae3dea60 100644 --- a/tips/ref-impls/src/TIP20.sol +++ b/tips/ref-impls/src/TIP20.sol @@ -1,14 +1,16 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 pragma solidity >=0.8.13 <0.9.0; -import {TIP20Factory} from "./TIP20Factory.sol"; -import {TIP403Registry} from "./TIP403Registry.sol"; -import {TempoUtilities} from "./TempoUtilities.sol"; -import {TIP20RolesAuth} from "./abstracts/TIP20RolesAuth.sol"; -import {ITIP20} from "./interfaces/ITIP20.sol"; +import { TIP20Factory } from "./TIP20Factory.sol"; +import { TIP403Registry } from "./TIP403Registry.sol"; +import { TempoUtilities } from "./TempoUtilities.sol"; +import { TIP20RolesAuth } from "./abstracts/TIP20RolesAuth.sol"; +import { ITIP20 } from "./interfaces/ITIP20.sol"; contract TIP20 is ITIP20, TIP20RolesAuth { - TIP403Registry internal constant TIP403_REGISTRY = TIP403Registry(0x403c000000000000000000000000000000000000); + + TIP403Registry internal constant TIP403_REGISTRY = + TIP403Registry(0x403c000000000000000000000000000000000000); address internal constant TIP_FEE_MANAGER_ADDRESS = 0xfeEC000000000000000000000000000000000000; address internal constant STABLECOIN_DEX_ADDRESS = 0xDEc0000000000000000000000000000000000000; @@ -34,8 +36,9 @@ contract TIP20 is ITIP20, TIP20RolesAuth { ITIP20 public override quoteToken; ITIP20 public override nextQuoteToken; - bytes32 public constant PERMIT_TYPEHASH = - keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 public constant PERMIT_TYPEHASH = keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ); bytes32 public constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); bytes32 public constant UNPAUSE_ROLE = keccak256("UNPAUSE_ROLE"); @@ -231,7 +234,10 @@ contract TIP20 is ITIP20, TIP20RolesAuth { _; } - function transfer(address to, uint256 amount) + function transfer( + address to, + uint256 amount + ) public virtual notPaused @@ -248,7 +254,11 @@ contract TIP20 is ITIP20, TIP20RolesAuth { return true; } - function transferFrom(address from, address to, uint256 amount) + function transferFrom( + address from, + address to, + uint256 amount + ) public virtual notPaused @@ -332,7 +342,9 @@ contract TIP20 is ITIP20, TIP20RolesAuth { function DOMAIN_SEPARATOR() public view returns (bytes32) { return keccak256( abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), keccak256(bytes(name)), keccak256(bytes("1")), block.chainid, @@ -341,12 +353,22 @@ contract TIP20 is ITIP20, TIP20RolesAuth { ); } - function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { if (block.timestamp > deadline) revert PermitExpired(); - bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)); + bytes32 structHash = keccak256( + abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline) + ); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash)); @@ -362,7 +384,11 @@ contract TIP20 is ITIP20, TIP20RolesAuth { TIP20 EXTENSION FUNCTIONS //////////////////////////////////////////////////////////////*/ - function transferWithMemo(address to, uint256 amount, bytes32 memo) + function transferWithMemo( + address to, + uint256 amount, + bytes32 memo + ) public virtual notPaused @@ -373,7 +399,12 @@ contract TIP20 is ITIP20, TIP20RolesAuth { emit TransferWithMemo(msg.sender, to, amount, memo); } - function transferFromWithMemo(address from, address to, uint256 amount, bytes32 memo) + function transferFromWithMemo( + address from, + address to, + uint256 amount, + bytes32 memo + ) public virtual notPaused @@ -398,7 +429,11 @@ contract TIP20 is ITIP20, TIP20RolesAuth { /// @dev In the Tempo node implementation, this function is not exposed via the TIP20 interface /// and is not externally callable. It is only invoked internally by specific precompiles /// (like the fee manager precompile), avoiding the need to approve precompiles to spend tokens. - function systemTransferFrom(address from, address to, uint256 amount) + function systemTransferFrom( + address from, + address to, + uint256 amount + ) external virtual notPaused @@ -461,10 +496,14 @@ contract TIP20 is ITIP20, TIP20RolesAuth { //////////////////////////////////////////////////////////////*/ // Updates the rewards for `user` and their `rewardRecipient` - function _updateRewardsAndGetRecipient(address user) internal returns (address rewardRecipient) { + function _updateRewardsAndGetRecipient(address user) + internal + returns (address rewardRecipient) + { rewardRecipient = userRewardInfo[user].rewardRecipient; uint256 cachedGlobalRewardPerToken = globalRewardPerToken; - uint256 rewardPerTokenDelta = cachedGlobalRewardPerToken - userRewardInfo[user].rewardPerToken; + uint256 rewardPerTokenDelta = + cachedGlobalRewardPerToken - userRewardInfo[user].rewardPerToken; if (rewardPerTokenDelta != 0) { // No rewards to update if not opted-in @@ -573,4 +612,5 @@ contract TIP20 is ITIP20, TIP20RolesAuth { } } } -} + + } diff --git a/tips/ref-impls/src/interfaces/ITIP20.sol b/tips/ref-impls/src/interfaces/ITIP20.sol index 86adc07d87..e3fb9714ca 100644 --- a/tips/ref-impls/src/interfaces/ITIP20.sol +++ b/tips/ref-impls/src/interfaces/ITIP20.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.13 <0.9.0; /// @title The interface for TIP-20 compliant tokens /// @notice A token standard that extends ERC-20 with additional features including transfer policies, memo support, and pause functionality interface ITIP20 { + /// @notice Error when attempting an operation while the contract is paused. error ContractPaused(); @@ -84,7 +85,9 @@ interface ITIP20 { /// @param to The address tokens were transferred to. /// @param amount The amount of tokens transferred. /// @param memo The memo attached to the transfer. - event TransferWithMemo(address indexed from, address indexed to, uint256 amount, bytes32 indexed memo); + event TransferWithMemo( + address indexed from, address indexed to, uint256 amount, bytes32 indexed memo + ); /// @notice Returns the role identifier for burning tokens from blocked accounts. /// @return The burn blocked role identifier. @@ -212,7 +215,14 @@ interface ITIP20 { /// @param amount The amount of tokens to transfer. /// @param memo The memo to attach to the transfer. /// @return success True if the transfer was successful. - function transferFromWithMemo(address from, address to, uint256 amount, bytes32 memo) external returns (bool); + function transferFromWithMemo( + address from, + address to, + uint256 amount, + bytes32 memo + ) + external + returns (bool); /// @notice Returns the current transfer policy identifier. /// @return The active transfer policy ID. @@ -247,7 +257,15 @@ interface ITIP20 { error InvalidSignature(); /// @notice Approves `spender` to spend `value` tokens on behalf of `owner` via a signed permit - function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; /// @notice Returns the current nonce for an address @@ -255,4 +273,5 @@ interface ITIP20 { /// @notice Returns the EIP-712 domain separator for this token function DOMAIN_SEPARATOR() external view returns (bytes32); + } diff --git a/tips/ref-impls/test/TIP20.t.sol b/tips/ref-impls/test/TIP20.t.sol index 83c8ac8589..2683acc5c6 100644 --- a/tips/ref-impls/test/TIP20.t.sol +++ b/tips/ref-impls/test/TIP20.t.sol @@ -1,15 +1,16 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 pragma solidity >=0.8.13 <0.9.0; -import {TIP20} from "../src/TIP20.sol"; -import {TIP20Factory} from "../src/TIP20Factory.sol"; -import {TIP403Registry} from "../src/TIP403Registry.sol"; -import {ITIP20} from "../src/interfaces/ITIP20.sol"; -import {ITIP20RolesAuth} from "../src/interfaces/ITIP20RolesAuth.sol"; -import {ITIP403Registry} from "../src/interfaces/ITIP403Registry.sol"; -import {BaseTest} from "./BaseTest.t.sol"; +import { TIP20 } from "../src/TIP20.sol"; +import { TIP20Factory } from "../src/TIP20Factory.sol"; +import { TIP403Registry } from "../src/TIP403Registry.sol"; +import { ITIP20 } from "../src/interfaces/ITIP20.sol"; +import { ITIP20RolesAuth } from "../src/interfaces/ITIP20RolesAuth.sol"; +import { ITIP403Registry } from "../src/interfaces/ITIP403Registry.sol"; +import { BaseTest } from "./BaseTest.t.sol"; contract TIP20Test is BaseTest { + TIP20 token; TIP20 linkedToken; TIP20 anotherToken; @@ -21,7 +22,9 @@ contract TIP20Test is BaseTest { uint256 internal constant SIGNER_KEY = 0xA11CE; uint256 internal constant WRONG_KEY = 0xB0B; - event TransferWithMemo(address indexed from, address indexed to, uint256 amount, bytes32 indexed memo); + event TransferWithMemo( + address indexed from, address indexed to, uint256 amount, bytes32 indexed memo + ); event Transfer(address indexed from, address indexed to, uint256 amount); event Approval(address indexed owner, address indexed spender, uint256 amount); event Mint(address indexed to, uint256 amount); @@ -34,11 +37,19 @@ contract TIP20Test is BaseTest { function setUp() public override { super.setUp(); - linkedToken = - TIP20(factory.createToken("Linked Token", "LINK", "USD", TIP20(_PATH_USD), admin, bytes32("linked"))); - anotherToken = - TIP20(factory.createToken("Another Token", "OTHER", "USD", TIP20(_PATH_USD), admin, bytes32("another"))); - token = TIP20(factory.createToken("Test Token", "TST", "USD", linkedToken, admin, bytes32("token"))); + linkedToken = TIP20( + factory.createToken( + "Linked Token", "LINK", "USD", TIP20(_PATH_USD), admin, bytes32("linked") + ) + ); + anotherToken = TIP20( + factory.createToken( + "Another Token", "OTHER", "USD", TIP20(_PATH_USD), admin, bytes32("another") + ) + ); + token = TIP20( + factory.createToken("Test Token", "TST", "USD", linkedToken, admin, bytes32("token")) + ); // Setup roles and mint tokens vm.startPrank(admin); @@ -326,7 +337,9 @@ contract TIP20Test is BaseTest { uint256 allowanceAmount, uint256 transferAmount, bytes32 memo - ) public { + ) + public + { // Avoid invalid addresses vm.assume(spender != address(0) && to != address(0)); vm.assume((uint160(to) >> 64) != 0x20C000000000000000000000); @@ -444,7 +457,10 @@ contract TIP20Test is BaseTest { assertEq( err, abi.encodeWithSelector( - ITIP20.InsufficientBalance.selector, token.balanceOf(admin), 100e18, address(token) + ITIP20.InsufficientBalance.selector, + token.balanceOf(admin), + 100e18, + address(token) ) ); } @@ -471,7 +487,9 @@ contract TIP20Test is BaseTest { // Create a policy that blocks alice address[] memory accounts = new address[](1); accounts[0] = alice; - uint64 blockingPolicy = registry.createPolicyWithAccounts(admin, ITIP403Registry.PolicyType.BLACKLIST, accounts); + uint64 blockingPolicy = registry.createPolicyWithAccounts( + admin, ITIP403Registry.PolicyType.BLACKLIST, accounts + ); vm.prank(admin); token.changeTransferPolicyId(blockingPolicy); @@ -728,11 +746,17 @@ contract TIP20Test is BaseTest { } function testSetNextQuoteTokenUsdRequiresUsdQuote() public { - TIP20 usdToken = - TIP20(factory.createToken("USD Token", "USD", "USD", TIP20(_PATH_USD), admin, bytes32("usdtoken"))); + TIP20 usdToken = TIP20( + factory.createToken( + "USD Token", "USD", "USD", TIP20(_PATH_USD), admin, bytes32("usdtoken") + ) + ); - TIP20 nonUsdToken = - TIP20(factory.createToken("Euro Token", "EUR", "EUR", TIP20(_PATH_USD), admin, bytes32("eurotok"))); + TIP20 nonUsdToken = TIP20( + factory.createToken( + "Euro Token", "EUR", "EUR", TIP20(_PATH_USD), admin, bytes32("eurotok") + ) + ); vm.prank(admin); try usdToken.setNextQuoteToken(nonUsdToken) { @@ -801,7 +825,12 @@ contract TIP20Test is BaseTest { try token.burn(100e18) { revert CallShouldHaveReverted(); } catch (bytes memory err) { - assertEq(err, abi.encodeWithSelector(ITIP20.InsufficientBalance.selector, 0, 100e18, address(token))); + assertEq( + err, + abi.encodeWithSelector( + ITIP20.InsufficientBalance.selector, 0, 100e18, address(token) + ) + ); } } @@ -829,7 +858,9 @@ contract TIP20Test is BaseTest { // Create a policy that blocks alice address[] memory accounts = new address[](1); accounts[0] = alice; - uint64 blockingPolicy = registry.createPolicyWithAccounts(admin, ITIP403Registry.PolicyType.BLACKLIST, accounts); + uint64 blockingPolicy = registry.createPolicyWithAccounts( + admin, ITIP403Registry.PolicyType.BLACKLIST, accounts + ); // Change to a policy where alice is blocked vm.startPrank(admin); @@ -853,7 +884,9 @@ contract TIP20Test is BaseTest { // Create a policy that blocks alice address[] memory accounts = new address[](1); accounts[0] = alice; - uint64 blockingPolicy = registry.createPolicyWithAccounts(admin, ITIP403Registry.PolicyType.BLACKLIST, accounts); + uint64 blockingPolicy = registry.createPolicyWithAccounts( + admin, ITIP403Registry.PolicyType.BLACKLIST, accounts + ); vm.prank(admin); token.changeTransferPolicyId(blockingPolicy); @@ -892,7 +925,12 @@ contract TIP20Test is BaseTest { try token.transfer(bob, 2000e18) { revert CallShouldHaveReverted(); } catch (bytes memory err) { - assertEq(err, abi.encodeWithSelector(ITIP20.InsufficientBalance.selector, 1000e18, 2000e18, address(token))); + assertEq( + err, + abi.encodeWithSelector( + ITIP20.InsufficientBalance.selector, 1000e18, 2000e18, address(token) + ) + ); } } @@ -1020,7 +1058,9 @@ contract TIP20Test is BaseTest { } function testCompleteQuoteTokenUpdateCannotCreateIndirectLoop() public { - TIP20 newToken = TIP20(factory.createToken("New Token", "NEW", "USD", token, admin, bytes32("newtoken"))); + TIP20 newToken = TIP20( + factory.createToken("New Token", "NEW", "USD", token, admin, bytes32("newtoken")) + ); // Try to set token's quote token to newToken (which would create a loop) vm.startPrank(admin); @@ -1041,7 +1081,8 @@ contract TIP20Test is BaseTest { function testCompleteQuoteTokenUpdateCannotCreateLongerLoop() public { // Create a longer chain: pathUSD -> linkedToken -> token -> token2 -> token3 - TIP20 token3 = TIP20(factory.createToken("Token 3", "TK2", "USD", token, admin, bytes32("token3"))); + TIP20 token3 = + TIP20(factory.createToken("Token 3", "TK2", "USD", token, admin, bytes32("token3"))); // Try to set linkedToken's quote token to token3 (would create loop) vm.startPrank(admin); @@ -1514,7 +1555,13 @@ contract TIP20Test is BaseTest { } } - function testFuzzRewardDistribution(uint256 aliceBalance, uint256 bobBalance, uint256 rewardAmount) public { + function testFuzzRewardDistribution( + uint256 aliceBalance, + uint256 bobBalance, + uint256 rewardAmount + ) + public + { // Bound inputs aliceBalance = bound(aliceBalance, 1e18, 1000e18); bobBalance = bound(bobBalance, 1e18, 1000e18); @@ -1647,7 +1694,12 @@ contract TIP20Test is BaseTest { assertEq(token.totalSupply(), totalSupplyBefore); } - function testFuzz_transferFrom(address spender, address to, uint256 allowanceAmount, uint256 transferAmount) + function testFuzz_transferFrom( + address spender, + address to, + uint256 allowanceAmount, + uint256 transferAmount + ) public { vm.assume(spender != address(0) && to != address(0)); @@ -1689,7 +1741,14 @@ contract TIP20Test is BaseTest { assertEq(token.balanceOf(alice), 1000e18); } - function testFuzz_multipleApprovals(address spender, uint256 amount1, uint256 amount2, uint256 amount3) public { + function testFuzz_multipleApprovals( + address spender, + uint256 amount1, + uint256 amount2, + uint256 amount3 + ) + public + { vm.assume(spender != address(0)); amount1 = bound(amount1, 0, type(uint128).max); amount2 = bound(amount2, 0, type(uint128).max); @@ -1745,7 +1804,14 @@ contract TIP20Test is BaseTest { vm.stopPrank(); } - function testFuzz_mintBurnSequence(uint256 mint1, uint256 mint2, uint256 burn1, uint256 mint3) public { + function testFuzz_mintBurnSequence( + uint256 mint1, + uint256 mint2, + uint256 burn1, + uint256 mint3 + ) + public + { mint1 = bound(mint1, 1e18, type(uint128).max / 5); mint2 = bound(mint2, 1e18, type(uint128).max / 5); burn1 = bound(burn1, 0, mint1 + mint2); @@ -1811,7 +1877,13 @@ contract TIP20Test is BaseTest { } } - function testFuzz_rewardDistributionAlt(uint256 aliceBalance, uint256 bobBalance, uint256 rewardAmount) public { + function testFuzz_rewardDistributionAlt( + uint256 aliceBalance, + uint256 bobBalance, + uint256 rewardAmount + ) + public + { aliceBalance = bound(aliceBalance, 1e18, 1000e18); bobBalance = bound(bobBalance, 1e18, 1000e18); rewardAmount = bound(rewardAmount, 1e18, 500e18); @@ -1869,7 +1941,12 @@ contract TIP20Test is BaseTest { assertApproxEqAbs(token.balanceOf(bob), bobBalance + bobExpected, 1000); } - function testFuzz_optedInSupplyConsistency(uint256 aliceAmount, uint256 bobAmount, bool aliceOpts, bool bobOpts) + function testFuzz_optedInSupplyConsistency( + uint256 aliceAmount, + uint256 bobAmount, + bool aliceOpts, + bool bobOpts + ) public { aliceAmount = bound(aliceAmount, 1e18, type(uint128).max / 4); @@ -1965,7 +2042,9 @@ contract TIP20Test is BaseTest { /// @notice INVARIANT: OptedInSupply never exceeds totalSupply function test_INVARIANT_optedInSupplyBounds() public view { - assertLe(token.optedInSupply(), token.totalSupply(), "CRITICAL: OptedInSupply > totalSupply"); + assertLe( + token.optedInSupply(), token.totalSupply(), "CRITICAL: OptedInSupply > totalSupply" + ); } /// @notice INVARIANT: Total supply never exceeds supply cap @@ -2195,8 +2274,9 @@ contract TIP20Test is BaseTest { function test_ClaimRewards_RevertsIf_UserUnauthorized() public { address[] memory accounts = new address[](1); accounts[0] = alice; - uint64 blacklistPolicy = - registry.createPolicyWithAccounts(admin, ITIP403Registry.PolicyType.BLACKLIST, accounts); + uint64 blacklistPolicy = registry.createPolicyWithAccounts( + admin, ITIP403Registry.PolicyType.BLACKLIST, accounts + ); vm.prank(admin); token.changeTransferPolicyId(blacklistPolicy); @@ -2213,14 +2293,18 @@ contract TIP20Test is BaseTest { vm.startPrank(admin); uint64 senderWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); - uint64 recipientWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); + uint64 recipientWhitelist = + registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); uint64 mintWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); registry.modifyPolicyWhitelist(mintWhitelist, charlie, true); - uint64 compound = registry.createCompoundPolicy(senderWhitelist, recipientWhitelist, mintWhitelist); + uint64 compound = + registry.createCompoundPolicy(senderWhitelist, recipientWhitelist, mintWhitelist); - TIP20 compoundToken = TIP20(factory.createToken("COMPOUND", "CMP", "USD", pathUSD, admin, bytes32("compound"))); + TIP20 compoundToken = TIP20( + factory.createToken("COMPOUND", "CMP", "USD", pathUSD, admin, bytes32("compound")) + ); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.changeTransferPolicyId(compound); @@ -2234,15 +2318,18 @@ contract TIP20Test is BaseTest { vm.startPrank(admin); uint64 senderWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); - uint64 recipientWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); + uint64 recipientWhitelist = + registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); uint64 mintWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); // charlie is NOT in mintWhitelist - uint64 compound = registry.createCompoundPolicy(senderWhitelist, recipientWhitelist, mintWhitelist); + uint64 compound = + registry.createCompoundPolicy(senderWhitelist, recipientWhitelist, mintWhitelist); - TIP20 compoundToken = - TIP20(factory.createToken("COMPOUND2", "CMP2", "USD", pathUSD, admin, bytes32("compound2"))); + TIP20 compoundToken = TIP20( + factory.createToken("COMPOUND2", "CMP2", "USD", pathUSD, admin, bytes32("compound2")) + ); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.changeTransferPolicyId(compound); @@ -2260,15 +2347,17 @@ contract TIP20Test is BaseTest { vm.startPrank(admin); uint64 senderWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); - uint64 recipientWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); + uint64 recipientWhitelist = + registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); registry.modifyPolicyWhitelist(senderWhitelist, alice, true); registry.modifyPolicyWhitelist(recipientWhitelist, bob, true); uint64 compound = registry.createCompoundPolicy(senderWhitelist, recipientWhitelist, 1); - TIP20 compoundToken = - TIP20(factory.createToken("COMPOUND3", "CMP3", "USD", pathUSD, admin, bytes32("compound3"))); + TIP20 compoundToken = TIP20( + factory.createToken("COMPOUND3", "CMP3", "USD", pathUSD, admin, bytes32("compound3")) + ); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.changeTransferPolicyId(1); compoundToken.mint(alice, 1000); @@ -2291,8 +2380,9 @@ contract TIP20Test is BaseTest { uint64 compound = registry.createCompoundPolicy(senderWhitelist, 1, 1); - TIP20 compoundToken = - TIP20(factory.createToken("COMPOUND4", "CMP4", "USD", pathUSD, admin, bytes32("compound4"))); + TIP20 compoundToken = TIP20( + factory.createToken("COMPOUND4", "CMP4", "USD", pathUSD, admin, bytes32("compound4")) + ); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.changeTransferPolicyId(1); compoundToken.mint(alice, 1000); @@ -2312,13 +2402,15 @@ contract TIP20Test is BaseTest { function test_Transfer_Fails_RecipientUnauthorized_CompoundPolicy() public { vm.startPrank(admin); - uint64 recipientWhitelist = registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); + uint64 recipientWhitelist = + registry.createPolicy(admin, ITIP403Registry.PolicyType.WHITELIST); // bob is NOT in recipientWhitelist uint64 compound = registry.createCompoundPolicy(1, recipientWhitelist, 1); - TIP20 compoundToken = - TIP20(factory.createToken("COMPOUND5", "CMP5", "USD", pathUSD, admin, bytes32("compound5"))); + TIP20 compoundToken = TIP20( + factory.createToken("COMPOUND5", "CMP5", "USD", pathUSD, admin, bytes32("compound5")) + ); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.changeTransferPolicyId(1); compoundToken.mint(alice, 1000); @@ -2344,7 +2436,8 @@ contract TIP20Test is BaseTest { // charlie blocked from sending, but anyone can receive uint64 asymmetricCompound = registry.createCompoundPolicy(senderBlacklist, 1, 1); - TIP20 compoundToken = TIP20(factory.createToken("ASYM", "ASY", "USD", pathUSD, admin, bytes32("asym"))); + TIP20 compoundToken = + TIP20(factory.createToken("ASYM", "ASY", "USD", pathUSD, admin, bytes32("asym"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.changeTransferPolicyId(1); compoundToken.mint(alice, 1000); @@ -2376,7 +2469,8 @@ contract TIP20Test is BaseTest { uint64 asymmetricCompound = registry.createCompoundPolicy(senderBlacklist, 1, 1); - TIP20 compoundToken = TIP20(factory.createToken("BURN1", "BRN1", "USD", pathUSD, admin, bytes32("burn1"))); + TIP20 compoundToken = + TIP20(factory.createToken("BURN1", "BRN1", "USD", pathUSD, admin, bytes32("burn1"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.grantRole(_BURN_BLOCKED_ROLE, admin); compoundToken.changeTransferPolicyId(1); @@ -2397,7 +2491,8 @@ contract TIP20Test is BaseTest { uint64 asymmetricCompound = registry.createCompoundPolicy(senderBlacklist, 1, 1); - TIP20 compoundToken = TIP20(factory.createToken("BURN2", "BRN2", "USD", pathUSD, admin, bytes32("burn2"))); + TIP20 compoundToken = + TIP20(factory.createToken("BURN2", "BRN2", "USD", pathUSD, admin, bytes32("burn2"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.grantRole(_BURN_BLOCKED_ROLE, admin); compoundToken.changeTransferPolicyId(1); @@ -2418,12 +2513,14 @@ contract TIP20Test is BaseTest { vm.startPrank(admin); // Create compound where only recipient is blocked, sender is allowed - uint64 recipientBlacklist = registry.createPolicy(admin, ITIP403Registry.PolicyType.BLACKLIST); + uint64 recipientBlacklist = + registry.createPolicy(admin, ITIP403Registry.PolicyType.BLACKLIST); registry.modifyPolicyBlacklist(recipientBlacklist, charlie, true); uint64 recipientBlockedCompound = registry.createCompoundPolicy(1, recipientBlacklist, 1); - TIP20 compoundToken = TIP20(factory.createToken("BURN3", "BRN3", "USD", pathUSD, admin, bytes32("burn3"))); + TIP20 compoundToken = + TIP20(factory.createToken("BURN3", "BRN3", "USD", pathUSD, admin, bytes32("burn3"))); compoundToken.grantRole(_ISSUER_ROLE, admin); compoundToken.grantRole(_BURN_BLOCKED_ROLE, admin); compoundToken.changeTransferPolicyId(1); @@ -2446,12 +2543,20 @@ contract TIP20Test is BaseTest { //////////////////////////////////////////////////////////////*/ /// @dev Helper to build the EIP-712 digest for a permit call - function _permitDigest(address owner_, address spender_, uint256 value_, uint256 nonce_, uint256 deadline_) + function _permitDigest( + address owner_, + address spender_, + uint256 value_, + uint256 nonce_, + uint256 deadline_ + ) internal view returns (bytes32) { - bytes32 structHash = keccak256(abi.encode(token.PERMIT_TYPEHASH(), owner_, spender_, value_, nonce_, deadline_)); + bytes32 structHash = keccak256( + abi.encode(token.PERMIT_TYPEHASH(), owner_, spender_, value_, nonce_, deadline_) + ); return keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); } @@ -2595,7 +2700,9 @@ contract TIP20Test is BaseTest { vm.skip(isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped bytes32 expected = keccak256( abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), keccak256(bytes(token.name())), keccak256(bytes("1")), block.chainid, @@ -2604,4 +2711,5 @@ contract TIP20Test is BaseTest { ); assertEq(token.DOMAIN_SEPARATOR(), expected); } + } diff --git a/tips/ref-impls/test/invariants/TIP20.t.sol b/tips/ref-impls/test/invariants/TIP20.t.sol index c64bf102c0..31e4e32bcb 100644 --- a/tips/ref-impls/test/invariants/TIP20.t.sol +++ b/tips/ref-impls/test/invariants/TIP20.t.sol @@ -1,14 +1,15 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; -import {TIP20} from "../../src/TIP20.sol"; -import {ITIP20} from "../../src/interfaces/ITIP20.sol"; -import {InvariantBaseTest} from "./InvariantBaseTest.t.sol"; +import { TIP20 } from "../../src/TIP20.sol"; +import { ITIP20 } from "../../src/interfaces/ITIP20.sol"; +import { InvariantBaseTest } from "./InvariantBaseTest.t.sol"; /// @title TIP20 Invariant Tests /// @notice Fuzz-based invariant tests for the TIP20 token implementation /// @dev Tests invariants TEMPO-TIP1 through TEMPO-TIP29 contract TIP20InvariantTest is InvariantBaseTest { + /// @dev Ghost variables for reward distribution tracking uint256 private _totalRewardsDistributed; uint256 private _totalRewardsClaimed; @@ -105,7 +106,14 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for token transfers /// @dev Tests TEMPO-TIP1 (balance conservation), TEMPO-TIP2 (total supply unchanged) - function transfer(uint256 actorSeed, uint256 tokenSeed, uint256 recipientSeed, uint256 amount) external { + function transfer( + uint256 actorSeed, + uint256 tokenSeed, + uint256 recipientSeed, + uint256 amount + ) + external + { TIP20 token = _selectBaseToken(tokenSeed); address actor = _selectAuthorizedActor(actorSeed, address(token)); address recipient = _selectActorExcluding(recipientSeed, actor); @@ -129,7 +137,9 @@ contract TIP20InvariantTest is InvariantBaseTest { // TEMPO-TIP1: Balance conservation assertEq( - token.balanceOf(actor), actorBalance - amount, "TEMPO-TIP1: Sender balance not decreased correctly" + token.balanceOf(actor), + actorBalance - amount, + "TEMPO-TIP1: Sender balance not decreased correctly" ); assertEq( token.balanceOf(recipient), @@ -138,7 +148,11 @@ contract TIP20InvariantTest is InvariantBaseTest { ); // TEMPO-TIP2: Total supply unchanged - assertEq(token.totalSupply(), totalSupplyBefore, "TEMPO-TIP2: Total supply changed during transfer"); + assertEq( + token.totalSupply(), + totalSupplyBefore, + "TEMPO-TIP2: Total supply changed during transfer" + ); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -147,7 +161,13 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for zero-amount transfer edge case /// @dev Tests that zero-amount transfers are handled correctly - function transferZeroAmount(uint256 actorSeed, uint256 tokenSeed, uint256 recipientSeed) external { + function transferZeroAmount( + uint256 actorSeed, + uint256 tokenSeed, + uint256 recipientSeed + ) + external + { TIP20 token = _selectBaseToken(tokenSeed); address actor = _selectAuthorizedActor(actorSeed, address(token)); address recipient = _selectActorExcluding(recipientSeed, actor); @@ -166,9 +186,19 @@ contract TIP20InvariantTest is InvariantBaseTest { assertTrue(success, "Zero transfer should return true"); // Balances should remain unchanged - assertEq(token.balanceOf(actor), actorBalanceBefore, "Sender balance changed on zero transfer"); - assertEq(token.balanceOf(recipient), recipientBalanceBefore, "Recipient balance changed on zero transfer"); - assertEq(token.totalSupply(), totalSupplyBefore, "Total supply changed on zero transfer"); + assertEq( + token.balanceOf(actor), + actorBalanceBefore, + "Sender balance changed on zero transfer" + ); + assertEq( + token.balanceOf(recipient), + recipientBalanceBefore, + "Recipient balance changed on zero transfer" + ); + assertEq( + token.totalSupply(), totalSupplyBefore, "Total supply changed on zero transfer" + ); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -183,7 +213,9 @@ contract TIP20InvariantTest is InvariantBaseTest { uint256 ownerSeed, uint256 recipientSeed, uint256 amount - ) external { + ) + external + { TIP20 token = _selectBaseToken(tokenSeed); address owner = _selectAuthorizedActor(ownerSeed, address(token)); address spender = _selectActorExcluding(actorSeed, owner); @@ -218,11 +250,17 @@ contract TIP20InvariantTest is InvariantBaseTest { ); } else { assertEq( - token.allowance(owner, spender), allowance - amount, "TEMPO-TIP3: Allowance not decreased correctly" + token.allowance(owner, spender), + allowance - amount, + "TEMPO-TIP3: Allowance not decreased correctly" ); } - assertEq(token.balanceOf(owner), ownerBalance - amount, "TEMPO-TIP3: Owner balance not decreased"); + assertEq( + token.balanceOf(owner), + ownerBalance - amount, + "TEMPO-TIP3: Owner balance not decreased" + ); assertEq( token.balanceOf(recipient), recipientBalanceBefore + amount, @@ -236,7 +274,14 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for approvals /// @dev Tests TEMPO-TIP5 (allowance setting) - function approve(uint256 actorSeed, uint256 tokenSeed, uint256 spenderSeed, uint256 amount) external { + function approve( + uint256 actorSeed, + uint256 tokenSeed, + uint256 spenderSeed, + uint256 amount + ) + external + { address actor = _selectActor(actorSeed); address spender = _selectActor(spenderSeed); TIP20 token = _selectBaseToken(tokenSeed); @@ -248,7 +293,9 @@ contract TIP20InvariantTest is InvariantBaseTest { vm.stopPrank(); assertTrue(success, "TEMPO-TIP5: Approve should return true"); - assertEq(token.allowance(actor, spender), amount, "TEMPO-TIP5: Allowance not set correctly"); + assertEq( + token.allowance(actor, spender), amount, "TEMPO-TIP5: Allowance not set correctly" + ); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -279,7 +326,11 @@ contract TIP20InvariantTest is InvariantBaseTest { _tokenMintSum[address(token)] += amount; // TEMPO-TIP6: Total supply should increase - assertEq(token.totalSupply(), currentSupply + amount, "TEMPO-TIP6: Total supply not increased correctly"); + assertEq( + token.totalSupply(), + currentSupply + amount, + "TEMPO-TIP6: Total supply not increased correctly" + ); // TEMPO-TIP7: Total supply should not exceed cap assertLe(token.totalSupply(), supplyCap, "TEMPO-TIP7: Total supply exceeds supply cap"); @@ -315,10 +366,16 @@ contract TIP20InvariantTest is InvariantBaseTest { // TEMPO-TIP8: Total supply should decrease assertEq( - token.totalSupply(), totalSupplyBefore - amount, "TEMPO-TIP8: Total supply not decreased correctly" + token.totalSupply(), + totalSupplyBefore - amount, + "TEMPO-TIP8: Total supply not decreased correctly" ); - assertEq(token.balanceOf(admin), adminBalance - amount, "TEMPO-TIP8: Admin balance not decreased"); + assertEq( + token.balanceOf(admin), + adminBalance - amount, + "TEMPO-TIP8: Admin balance not decreased" + ); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -327,7 +384,13 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for transfer with memo /// @dev Tests TEMPO-TIP9 (memo transfers work like regular transfers) - function transferWithMemo(uint256 actorSeed, uint256 tokenSeed, uint256 recipientSeed, uint256 amount, bytes32 memo) + function transferWithMemo( + uint256 actorSeed, + uint256 tokenSeed, + uint256 recipientSeed, + uint256 amount, + bytes32 memo + ) external { TIP20 token = _selectBaseToken(tokenSeed); @@ -351,7 +414,11 @@ contract TIP20InvariantTest is InvariantBaseTest { vm.stopPrank(); // TEMPO-TIP9: Balance changes same as regular transfer - assertEq(token.balanceOf(actor), actorBalance - amount, "TEMPO-TIP9: Sender balance not decreased"); + assertEq( + token.balanceOf(actor), + actorBalance - amount, + "TEMPO-TIP9: Sender balance not decreased" + ); assertEq( token.balanceOf(recipient), recipientBalanceBefore + amount, @@ -373,7 +440,9 @@ contract TIP20InvariantTest is InvariantBaseTest { uint256 recipientSeed, uint256 amount, bytes32 memo - ) external { + ) + external + { TIP20 token = _selectBaseToken(tokenSeed); address owner = _selectAuthorizedActor(ownerSeed, address(token)); address spender = _selectActorExcluding(actorSeed, owner); @@ -401,7 +470,11 @@ contract TIP20InvariantTest is InvariantBaseTest { assertTrue(success, "TEMPO-TIP9: TransferFromWithMemo should return true"); // Balance changes same as regular transferFrom - assertEq(token.balanceOf(owner), ownerBalance - amount, "TEMPO-TIP9: Owner balance not decreased"); + assertEq( + token.balanceOf(owner), + ownerBalance - amount, + "TEMPO-TIP9: Owner balance not decreased" + ); assertEq( token.balanceOf(recipient), recipientBalanceBefore + amount, @@ -418,7 +491,9 @@ contract TIP20InvariantTest is InvariantBaseTest { ); } else { assertEq( - token.allowance(owner, spender), allowance - amount, "TEMPO-TIP3: Allowance not decreased correctly" + token.allowance(owner, spender), + allowance - amount, + "TEMPO-TIP3: Allowance not decreased correctly" ); } } catch (bytes memory reason) { @@ -429,7 +504,13 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for setting reward recipient (opt-in, opt-out, or delegate) /// @dev Tests TEMPO-TIP10 (opted-in supply), TEMPO-TIP11 (supply updates), TEMPO-TIP25 (delegation) - function setRewardRecipient(uint256 actorSeed, uint256 tokenSeed, uint256 recipientSeed) external { + function setRewardRecipient( + uint256 actorSeed, + uint256 tokenSeed, + uint256 recipientSeed + ) + external + { TIP20 token = _selectBaseToken(tokenSeed); address actor = _selectAuthorizedActor(actorSeed, address(token)); @@ -472,11 +553,15 @@ contract TIP20InvariantTest is InvariantBaseTest { uint128 optedInSupplyAfter = token.optedInSupply(); if (currentRecipient == address(0) && newRecipient != address(0)) { assertEq( - optedInSupplyAfter, optedInSupplyBefore + uint128(actorBalance), "Opted-in supply not increased" + optedInSupplyAfter, + optedInSupplyBefore + uint128(actorBalance), + "Opted-in supply not increased" ); } else if (currentRecipient != address(0) && newRecipient == address(0)) { assertEq( - optedInSupplyAfter, optedInSupplyBefore - uint128(actorBalance), "Opted-in supply not decreased" + optedInSupplyAfter, + optedInSupplyBefore - uint128(actorBalance), + "Opted-in supply not decreased" ); } } catch (bytes memory reason) { @@ -660,7 +745,9 @@ contract TIP20InvariantTest is InvariantBaseTest { ); // TEMPO-TIP15: Claimed amount should not exceed available - assertLe(claimed, contractBalanceBefore, "TEMPO-TIP15: Claimed more than contract balance"); + assertLe( + claimed, contractBalanceBefore, "TEMPO-TIP15: Claimed more than contract balance" + ); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -748,10 +835,18 @@ contract TIP20InvariantTest is InvariantBaseTest { _tokenBurnSum[address(token)] += amount; // TEMPO-TIP23: Balance should decrease - assertEq(token.balanceOf(target), targetBalance - amount, "TEMPO-TIP23: Target balance not decreased"); + assertEq( + token.balanceOf(target), + targetBalance - amount, + "TEMPO-TIP23: Target balance not decreased" + ); // TEMPO-TIP23: Total supply should decrease - assertEq(token.totalSupply(), totalSupplyBefore - amount, "TEMPO-TIP23: Total supply not decreased"); + assertEq( + token.totalSupply(), + totalSupplyBefore - amount, + "TEMPO-TIP23: Total supply not decreased" + ); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -860,7 +955,12 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for unauthorized burnBlocked attempts /// @dev Tests TEMPO-TIP29 (only BURN_BLOCKED_ROLE can call burnBlocked) - function burnBlockedUnauthorized(uint256 actorSeed, uint256 tokenSeed, uint256 targetSeed, uint256 amount) + function burnBlockedUnauthorized( + uint256 actorSeed, + uint256 tokenSeed, + uint256 targetSeed, + uint256 amount + ) external { address attacker = _selectActor(actorSeed); @@ -953,20 +1053,25 @@ contract TIP20InvariantTest is InvariantBaseTest { vm.startPrank(admin); try token.setNextQuoteToken(ITIP20(address(newQuoteToken))) { // Next quote token should be set - assertEq(address(token.nextQuoteToken()), address(newQuoteToken), "Next quote token not set"); + assertEq( + address(token.nextQuoteToken()), address(newQuoteToken), "Next quote token not set" + ); // Try to complete the update try token.completeQuoteTokenUpdate() { vm.stopPrank(); // Quote token should be updated - assertEq(address(token.quoteToken()), address(newQuoteToken), "Quote token not updated"); + assertEq( + address(token.quoteToken()), address(newQuoteToken), "Quote token not updated" + ); } catch (bytes memory reason) { vm.stopPrank(); // Cycle detection may reject bytes4 selector = bytes4(reason); assertTrue( - selector == ITIP20.InvalidQuoteToken.selector, "Unexpected error on completeQuoteTokenUpdate" + selector == ITIP20.InvalidQuoteToken.selector, + "Unexpected error on completeQuoteTokenUpdate" ); } } catch (bytes memory reason) { @@ -1012,7 +1117,9 @@ contract TIP20InvariantTest is InvariantBaseTest { assertEq(token.supplyCap(), newCap, "TEMPO-TIP22: Supply cap not updated"); // TEMPO-TIP22: Cap must be >= current supply - assertGe(token.supplyCap(), token.totalSupply(), "TEMPO-TIP22: Supply cap below total supply"); + assertGe( + token.supplyCap(), token.totalSupply(), "TEMPO-TIP22: Supply cap below total supply" + ); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -1021,7 +1128,13 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @notice Handler for unauthorized supply cap change attempts /// @dev Tests that non-admin cannot change supply cap - function setSupplyCapUnauthorized(uint256 actorSeed, uint256 tokenSeed, uint256 newCap) external { + function setSupplyCapUnauthorized( + uint256 actorSeed, + uint256 tokenSeed, + uint256 newCap + ) + external + { address attacker = _selectActor(actorSeed); TIP20 token = _selectBaseToken(tokenSeed); @@ -1054,7 +1167,9 @@ contract TIP20InvariantTest is InvariantBaseTest { } catch (bytes memory reason) { vm.stopPrank(); assertEq( - bytes4(reason), ITIP20.InvalidSupplyCap.selector, "TEMPO-TIP22: Should revert with InvalidSupplyCap" + bytes4(reason), + ITIP20.InvalidSupplyCap.selector, + "TEMPO-TIP22: Should revert with InvalidSupplyCap" ); } } @@ -1087,7 +1202,9 @@ contract TIP20InvariantTest is InvariantBaseTest { vm.stopPrank(); // TEMPO-TIP16: Authorization status should be updated bool afterAuthorized = _isAuthorized(address(token), actor); - assertEq(afterAuthorized, !blacklist, "TEMPO-TIP16: Blacklist status not updated correctly"); + assertEq( + afterAuthorized, !blacklist, "TEMPO-TIP16: Blacklist status not updated correctly" + ); } catch (bytes memory reason) { vm.stopPrank(); assertTrue(_isKnownTIP20Error(bytes4(reason)), "Unknown error encountered"); @@ -1123,7 +1240,9 @@ contract TIP20InvariantTest is InvariantBaseTest { bytes32 r, bytes32 s, uint256 resultSeed - ) external { + ) + external + { vm.assume(!isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped address actor = _selectActor(actorSeed); address recipient = _selectActorExcluding(recipientSeed, actor); @@ -1133,7 +1252,9 @@ contract TIP20InvariantTest is InvariantBaseTest { // build permit digest bytes32 digest = keccak256( abi.encodePacked( - "\x19\x01", token.DOMAIN_SEPARATOR(), keccak256(abi.encodePacked(actor, recipient, amount, deadline)) + "\x19\x01", + token.DOMAIN_SEPARATOR(), + keccak256(abi.encodePacked(actor, recipient, amount, deadline)) ) ); @@ -1155,29 +1276,47 @@ contract TIP20InvariantTest is InvariantBaseTest { // If permit passes, check invariants // **TEMPO-TIP29**: Permit should set correct allowance - assertEq(token.allowance(actor, recipient), amount, "TEMPO-TIP29: Permit did not set correct allowance"); + assertEq( + token.allowance(actor, recipient), + amount, + "TEMPO-TIP29: Permit did not set correct allowance" + ); // **TEMPO-TIP32**: Nonce should be incremented - assertEq(token.nonces(actor), actorNonce + 1, "TEMPO-TIP32: Permit did not increment nonce"); + assertEq( + token.nonces(actor), actorNonce + 1, "TEMPO-TIP32: Permit did not increment nonce" + ); // **TEMPO-TIP34**: A permit with a deadline in the past must always revert. - assertGe(deadline, block.timestamp, "TEMPO-TIP34: Permit should revert if deadline is past"); + assertGe( + deadline, block.timestamp, "TEMPO-TIP34: Permit should revert if deadline is past" + ); // **TEMPO-TIP35**: The recovered signer from a valid permit signature must exactly match the `owner` parameter. - assertEq(ecrecover(digest, v, r, s), signer, "TEMPO-TIP35: Recovered signer does not match expected"); + assertEq( + ecrecover(digest, v, r, s), + signer, + "TEMPO-TIP35: Recovered signer does not match expected" + ); // Occasionally try 2nd permit. Use prime modulo to test all cases of seed % 4 between [0, 3] if (resultSeed % 7 == 0) { try token.permit(actor, recipient, amount, deadline, v, r, s) { revert("TEMPO-TIP33: Permit should not be reusable"); - } catch (bytes memory) {} + } catch (bytes memory) { } } - } catch (bytes memory) {} + } catch (bytes memory) { } } /// @notice Handler that verifies paused tokens reject transfers with ContractPaused /// @dev Tests TEMPO-TIP17: pause enforcement - transfers revert with ContractPaused - function tryTransferWhilePaused(uint256 actorSeed, uint256 tokenSeed, uint256 recipientSeed) external { + function tryTransferWhilePaused( + uint256 actorSeed, + uint256 tokenSeed, + uint256 recipientSeed + ) + external + { address actor = _selectActor(actorSeed); address recipient = _selectActorExcluding(recipientSeed, actor); TIP20 token = _selectBaseToken(tokenSeed); @@ -1212,7 +1351,11 @@ contract TIP20InvariantTest is InvariantBaseTest { uint256 totalSupply = token.totalSupply(); // TEMPO-TIP19: Opted-in supply <= total supply - assertLe(token.optedInSupply(), totalSupply, "TEMPO-TIP19: Opted-in supply exceeds total supply"); + assertLe( + token.optedInSupply(), + totalSupply, + "TEMPO-TIP19: Opted-in supply exceeds total supply" + ); // TEMPO-TIP22: Supply cap is enforced assertLe(totalSupply, token.supplyCap(), "TEMPO-TIP22: Total supply exceeds supply cap"); @@ -1238,10 +1381,15 @@ contract TIP20InvariantTest is InvariantBaseTest { uint256 contractBalance = token.balanceOf(tokenAddr); uint256 expectedUnclaimed = distributed - claimed; uint256 holderCount = holders.length; - uint256 maxDust = _tokenDistributionCount[tokenAddr] * (holderCount > 0 ? holderCount : 1); + uint256 maxDust = + _tokenDistributionCount[tokenAddr] * (holderCount > 0 ? holderCount : 1); if (expectedUnclaimed > maxDust) { - assertGe(contractBalance, expectedUnclaimed - maxDust, "Reward dust exceeds theoretical bound"); + assertGe( + contractBalance, + expectedUnclaimed - maxDust, + "Reward dust exceeds theoretical bound" + ); } } } @@ -1251,4 +1399,5 @@ contract TIP20InvariantTest is InvariantBaseTest { function _selectActorKey(uint256 seed) internal view returns (uint256) { return _keys[seed % _keys.length]; } + } From a7ac9b6daf4ce8c383998173edcfcbd98f94ed94 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:27:06 -0500 Subject: [PATCH 10/18] fix: regenerate tip20.layout.json for storage layout test The file was accidentally deleted, causing test_tip20_layout to fail when solc is not available (CI and most dev environments). Amp-Thread-ID: https://ampcode.com/threads/T-019c6f1f-917c-75d8-a908-1621cd459d6f Co-authored-by: Amp --- .../solidity/testdata/tip20.layout.json | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json diff --git a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json new file mode 100644 index 0000000000..85ab838337 --- /dev/null +++ b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json @@ -0,0 +1,273 @@ +{ + "contracts": { + "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20": { + "storage-layout": { + "storage": [ + { + "astId": 27, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "roles", + "offset": 0, + "slot": "0", + "type": "t_mapping(t_address,t_mapping(t_bytes32,t_bool))" + }, + { + "astId": 32, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "roleAdmins", + "offset": 0, + "slot": "1", + "type": "t_mapping(t_bytes32,t_bytes32)" + }, + { + "astId": 34, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "name", + "offset": 0, + "slot": "2", + "type": "t_string_storage" + }, + { + "astId": 36, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "symbol", + "offset": 0, + "slot": "3", + "type": "t_string_storage" + }, + { + "astId": 38, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "currency", + "offset": 0, + "slot": "4", + "type": "t_string_storage" + }, + { + "astId": 40, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "domainSeparator", + "offset": 0, + "slot": "5", + "type": "t_bytes32" + }, + { + "astId": 42, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "quoteToken", + "offset": 0, + "slot": "6", + "type": "t_address" + }, + { + "astId": 44, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "nextQuoteToken", + "offset": 0, + "slot": "7", + "type": "t_address" + }, + { + "astId": 46, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "transferPolicyId", + "offset": 20, + "slot": "7", + "type": "t_uint64" + }, + { + "astId": 48, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "totalSupply", + "offset": 0, + "slot": "8", + "type": "t_uint256" + }, + { + "astId": 52, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "balances", + "offset": 0, + "slot": "9", + "type": "t_mapping(t_address,t_uint256)" + }, + { + "astId": 58, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "allowances", + "offset": 0, + "slot": "10", + "type": "t_mapping(t_address,t_mapping(t_address,t_uint256))" + }, + { + "astId": 62, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "permitNonces", + "offset": 0, + "slot": "11", + "type": "t_mapping(t_address,t_uint256)" + }, + { + "astId": 64, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "paused", + "offset": 0, + "slot": "12", + "type": "t_bool" + }, + { + "astId": 66, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "supplyCap", + "offset": 0, + "slot": "13", + "type": "t_uint256" + }, + { + "astId": 70, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "salts", + "offset": 0, + "slot": "14", + "type": "t_mapping(t_bytes32,t_bool)" + }, + { + "astId": 72, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "globalRewardPerToken", + "offset": 0, + "slot": "15", + "type": "t_uint256" + }, + { + "astId": 74, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "optedInSupply", + "offset": 0, + "slot": "16", + "type": "t_uint128" + }, + { + "astId": 80, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "userRewardInfo", + "offset": 0, + "slot": "17", + "type": "t_mapping(t_address,t_struct(UserRewardInfo)20_storage)" + } + ], + "types": { + "t_address": { + "encoding": "inplace", + "label": "address", + "numberOfBytes": "20" + }, + "t_bool": { + "encoding": "inplace", + "label": "bool", + "numberOfBytes": "1" + }, + "t_bytes32": { + "encoding": "inplace", + "label": "bytes32", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_mapping(t_address,t_uint256))": { + "encoding": "mapping", + "key": "t_address", + "label": "mapping(address => mapping(address => uint256))", + "numberOfBytes": "32", + "value": "t_mapping(t_address,t_uint256)" + }, + "t_mapping(t_address,t_mapping(t_bytes32,t_bool))": { + "encoding": "mapping", + "key": "t_address", + "label": "mapping(address => mapping(bytes32 => bool))", + "numberOfBytes": "32", + "value": "t_mapping(t_bytes32,t_bool)" + }, + "t_mapping(t_address,t_struct(UserRewardInfo)20_storage)": { + "encoding": "mapping", + "key": "t_address", + "label": "mapping(address => struct TIP20.UserRewardInfo)", + "numberOfBytes": "32", + "value": "t_struct(UserRewardInfo)20_storage" + }, + "t_mapping(t_address,t_uint256)": { + "encoding": "mapping", + "key": "t_address", + "label": "mapping(address => uint256)", + "numberOfBytes": "32", + "value": "t_uint256" + }, + "t_mapping(t_bytes32,t_bool)": { + "encoding": "mapping", + "key": "t_bytes32", + "label": "mapping(bytes32 => bool)", + "numberOfBytes": "32", + "value": "t_bool" + }, + "t_mapping(t_bytes32,t_bytes32)": { + "encoding": "mapping", + "key": "t_bytes32", + "label": "mapping(bytes32 => bytes32)", + "numberOfBytes": "32", + "value": "t_bytes32" + }, + "t_string_storage": { + "encoding": "bytes", + "label": "string", + "numberOfBytes": "32" + }, + "t_struct(UserRewardInfo)20_storage": { + "encoding": "inplace", + "label": "struct TIP20.UserRewardInfo", + "members": [ + { + "astId": 15, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "rewardRecipient", + "offset": 0, + "slot": "0", + "type": "t_address" + }, + { + "astId": 17, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "rewardPerToken", + "offset": 0, + "slot": "1", + "type": "t_uint256" + }, + { + "astId": 19, + "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "rewardBalance", + "offset": 0, + "slot": "2", + "type": "t_uint256" + } + ], + "numberOfBytes": "96" + }, + "t_uint128": { + "encoding": "inplace", + "label": "uint128", + "numberOfBytes": "16" + }, + "t_uint256": { + "encoding": "inplace", + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint64": { + "encoding": "inplace", + "label": "uint64", + "numberOfBytes": "8" + } + } + } + } + }, + "version": "0.8.33+commit.64118f21.Darwin.appleclang" +} From 4ecdf72b736874c4e3148e64c66de8d351635597 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:30:45 -0500 Subject: [PATCH 11/18] fix: restore tip20.layout.json from main, rename nonces -> permitNonces Restores the original file from main and only changes the 'nonces' label to 'permitNonces' to match the Rust field name (permit_nonces -> permitNonces via camelCase conversion). Amp-Thread-ID: https://ampcode.com/threads/T-019c6f1f-917c-75d8-a908-1621cd459d6f Co-authored-by: Amp --- .../solidity/testdata/tip20.layout.json | 542 +++++++++--------- 1 file changed, 271 insertions(+), 271 deletions(-) diff --git a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json index 85ab838337..d10f96d9aa 100644 --- a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json +++ b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json @@ -1,273 +1,273 @@ { - "contracts": { - "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20": { - "storage-layout": { - "storage": [ - { - "astId": 27, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "roles", - "offset": 0, - "slot": "0", - "type": "t_mapping(t_address,t_mapping(t_bytes32,t_bool))" - }, - { - "astId": 32, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "roleAdmins", - "offset": 0, - "slot": "1", - "type": "t_mapping(t_bytes32,t_bytes32)" - }, - { - "astId": 34, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "name", - "offset": 0, - "slot": "2", - "type": "t_string_storage" - }, - { - "astId": 36, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "symbol", - "offset": 0, - "slot": "3", - "type": "t_string_storage" - }, - { - "astId": 38, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "currency", - "offset": 0, - "slot": "4", - "type": "t_string_storage" - }, - { - "astId": 40, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "domainSeparator", - "offset": 0, - "slot": "5", - "type": "t_bytes32" - }, - { - "astId": 42, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "quoteToken", - "offset": 0, - "slot": "6", - "type": "t_address" - }, - { - "astId": 44, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "nextQuoteToken", - "offset": 0, - "slot": "7", - "type": "t_address" - }, - { - "astId": 46, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "transferPolicyId", - "offset": 20, - "slot": "7", - "type": "t_uint64" - }, - { - "astId": 48, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "totalSupply", - "offset": 0, - "slot": "8", - "type": "t_uint256" - }, - { - "astId": 52, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "balances", - "offset": 0, - "slot": "9", - "type": "t_mapping(t_address,t_uint256)" - }, - { - "astId": 58, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "allowances", - "offset": 0, - "slot": "10", - "type": "t_mapping(t_address,t_mapping(t_address,t_uint256))" - }, - { - "astId": 62, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "permitNonces", - "offset": 0, - "slot": "11", - "type": "t_mapping(t_address,t_uint256)" - }, - { - "astId": 64, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "paused", - "offset": 0, - "slot": "12", - "type": "t_bool" - }, - { - "astId": 66, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "supplyCap", - "offset": 0, - "slot": "13", - "type": "t_uint256" - }, - { - "astId": 70, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "salts", - "offset": 0, - "slot": "14", - "type": "t_mapping(t_bytes32,t_bool)" - }, - { - "astId": 72, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "globalRewardPerToken", - "offset": 0, - "slot": "15", - "type": "t_uint256" - }, - { - "astId": 74, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "optedInSupply", - "offset": 0, - "slot": "16", - "type": "t_uint128" - }, - { - "astId": 80, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "userRewardInfo", - "offset": 0, - "slot": "17", - "type": "t_mapping(t_address,t_struct(UserRewardInfo)20_storage)" - } - ], - "types": { - "t_address": { - "encoding": "inplace", - "label": "address", - "numberOfBytes": "20" - }, - "t_bool": { - "encoding": "inplace", - "label": "bool", - "numberOfBytes": "1" - }, - "t_bytes32": { - "encoding": "inplace", - "label": "bytes32", - "numberOfBytes": "32" - }, - "t_mapping(t_address,t_mapping(t_address,t_uint256))": { - "encoding": "mapping", - "key": "t_address", - "label": "mapping(address => mapping(address => uint256))", - "numberOfBytes": "32", - "value": "t_mapping(t_address,t_uint256)" - }, - "t_mapping(t_address,t_mapping(t_bytes32,t_bool))": { - "encoding": "mapping", - "key": "t_address", - "label": "mapping(address => mapping(bytes32 => bool))", - "numberOfBytes": "32", - "value": "t_mapping(t_bytes32,t_bool)" - }, - "t_mapping(t_address,t_struct(UserRewardInfo)20_storage)": { - "encoding": "mapping", - "key": "t_address", - "label": "mapping(address => struct TIP20.UserRewardInfo)", - "numberOfBytes": "32", - "value": "t_struct(UserRewardInfo)20_storage" - }, - "t_mapping(t_address,t_uint256)": { - "encoding": "mapping", - "key": "t_address", - "label": "mapping(address => uint256)", - "numberOfBytes": "32", - "value": "t_uint256" - }, - "t_mapping(t_bytes32,t_bool)": { - "encoding": "mapping", - "key": "t_bytes32", - "label": "mapping(bytes32 => bool)", - "numberOfBytes": "32", - "value": "t_bool" - }, - "t_mapping(t_bytes32,t_bytes32)": { - "encoding": "mapping", - "key": "t_bytes32", - "label": "mapping(bytes32 => bytes32)", - "numberOfBytes": "32", - "value": "t_bytes32" - }, - "t_string_storage": { - "encoding": "bytes", - "label": "string", - "numberOfBytes": "32" - }, - "t_struct(UserRewardInfo)20_storage": { - "encoding": "inplace", - "label": "struct TIP20.UserRewardInfo", - "members": [ - { - "astId": 15, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "rewardRecipient", - "offset": 0, - "slot": "0", - "type": "t_address" - }, - { - "astId": 17, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "rewardPerToken", - "offset": 0, - "slot": "1", - "type": "t_uint256" - }, - { - "astId": 19, - "contract": "crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "rewardBalance", - "offset": 0, - "slot": "2", - "type": "t_uint256" - } - ], - "numberOfBytes": "96" - }, - "t_uint128": { - "encoding": "inplace", - "label": "uint128", - "numberOfBytes": "16" - }, - "t_uint256": { - "encoding": "inplace", - "label": "uint256", - "numberOfBytes": "32" - }, - "t_uint64": { - "encoding": "inplace", - "label": "uint64", - "numberOfBytes": "8" - } - } - } + "contracts": { + "tests/storage_tests/solidity/testdata/tip20.sol:TIP20": { + "storage-layout": { + "storage": [ + { + "astId": 27, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "roles", + "offset": 0, + "slot": "0", + "type": "t_mapping(t_address,t_mapping(t_bytes32,t_bool))" + }, + { + "astId": 32, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "roleAdmins", + "offset": 0, + "slot": "1", + "type": "t_mapping(t_bytes32,t_bytes32)" + }, + { + "astId": 34, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "name", + "offset": 0, + "slot": "2", + "type": "t_string_storage" + }, + { + "astId": 36, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "symbol", + "offset": 0, + "slot": "3", + "type": "t_string_storage" + }, + { + "astId": 38, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "currency", + "offset": 0, + "slot": "4", + "type": "t_string_storage" + }, + { + "astId": 40, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "domainSeparator", + "offset": 0, + "slot": "5", + "type": "t_bytes32" + }, + { + "astId": 42, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "quoteToken", + "offset": 0, + "slot": "6", + "type": "t_address" + }, + { + "astId": 44, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "nextQuoteToken", + "offset": 0, + "slot": "7", + "type": "t_address" + }, + { + "astId": 46, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "transferPolicyId", + "offset": 20, + "slot": "7", + "type": "t_uint64" + }, + { + "astId": 48, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "totalSupply", + "offset": 0, + "slot": "8", + "type": "t_uint256" + }, + { + "astId": 52, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "balances", + "offset": 0, + "slot": "9", + "type": "t_mapping(t_address,t_uint256)" + }, + { + "astId": 58, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "allowances", + "offset": 0, + "slot": "10", + "type": "t_mapping(t_address,t_mapping(t_address,t_uint256))" + }, + { + "astId": 62, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "permitNonces", + "offset": 0, + "slot": "11", + "type": "t_mapping(t_address,t_uint256)" + }, + { + "astId": 64, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "paused", + "offset": 0, + "slot": "12", + "type": "t_bool" + }, + { + "astId": 66, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "supplyCap", + "offset": 0, + "slot": "13", + "type": "t_uint256" + }, + { + "astId": 70, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "salts", + "offset": 0, + "slot": "14", + "type": "t_mapping(t_bytes32,t_bool)" + }, + { + "astId": 72, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "globalRewardPerToken", + "offset": 0, + "slot": "15", + "type": "t_uint256" + }, + { + "astId": 74, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "optedInSupply", + "offset": 0, + "slot": "16", + "type": "t_uint128" + }, + { + "astId": 80, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "userRewardInfo", + "offset": 0, + "slot": "17", + "type": "t_mapping(t_address,t_struct(UserRewardInfo)20_storage)" + } + ], + "types": { + "t_address": { + "encoding": "inplace", + "label": "address", + "numberOfBytes": "20" + }, + "t_bool": { + "encoding": "inplace", + "label": "bool", + "numberOfBytes": "1" + }, + "t_bytes32": { + "encoding": "inplace", + "label": "bytes32", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_mapping(t_address,t_uint256))": { + "encoding": "mapping", + "key": "t_address", + "label": "mapping(address => mapping(address => uint256))", + "numberOfBytes": "32", + "value": "t_mapping(t_address,t_uint256)" + }, + "t_mapping(t_address,t_mapping(t_bytes32,t_bool))": { + "encoding": "mapping", + "key": "t_address", + "label": "mapping(address => mapping(bytes32 => bool))", + "numberOfBytes": "32", + "value": "t_mapping(t_bytes32,t_bool)" + }, + "t_mapping(t_address,t_struct(UserRewardInfo)20_storage)": { + "encoding": "mapping", + "key": "t_address", + "label": "mapping(address => struct TIP20.UserRewardInfo)", + "numberOfBytes": "32", + "value": "t_struct(UserRewardInfo)20_storage" + }, + "t_mapping(t_address,t_uint256)": { + "encoding": "mapping", + "key": "t_address", + "label": "mapping(address => uint256)", + "numberOfBytes": "32", + "value": "t_uint256" + }, + "t_mapping(t_bytes32,t_bool)": { + "encoding": "mapping", + "key": "t_bytes32", + "label": "mapping(bytes32 => bool)", + "numberOfBytes": "32", + "value": "t_bool" + }, + "t_mapping(t_bytes32,t_bytes32)": { + "encoding": "mapping", + "key": "t_bytes32", + "label": "mapping(bytes32 => bytes32)", + "numberOfBytes": "32", + "value": "t_bytes32" + }, + "t_string_storage": { + "encoding": "bytes", + "label": "string", + "numberOfBytes": "32" + }, + "t_struct(UserRewardInfo)20_storage": { + "encoding": "inplace", + "label": "struct TIP20.UserRewardInfo", + "members": [ + { + "astId": 15, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "rewardRecipient", + "offset": 0, + "slot": "0", + "type": "t_address" + }, + { + "astId": 17, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "rewardPerToken", + "offset": 0, + "slot": "1", + "type": "t_uint256" + }, + { + "astId": 19, + "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", + "label": "rewardBalance", + "offset": 0, + "slot": "2", + "type": "t_uint256" + } + ], + "numberOfBytes": "96" + }, + "t_uint128": { + "encoding": "inplace", + "label": "uint128", + "numberOfBytes": "16" + }, + "t_uint256": { + "encoding": "inplace", + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint64": { + "encoding": "inplace", + "label": "uint64", + "numberOfBytes": "8" + } } - }, - "version": "0.8.33+commit.64118f21.Darwin.appleclang" -} + } + } + }, + "version": "0.8.30+commit.73712a01.Darwin.appleclang" +} \ No newline at end of file From bd6fb4e830d5ac3d6a304cbb5fb2a2c40dbd041a Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:03:23 -0500 Subject: [PATCH 12/18] fix: review fixes --- crates/precompiles/src/tip20/mod.rs | 358 +++++++++------------ tips/ref-impls/test/invariants/README.md | 2 +- tips/ref-impls/test/invariants/TIP20.t.sol | 17 +- 3 files changed, 160 insertions(+), 217 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 4eabaf50fc..8c44d46879 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -520,23 +520,13 @@ impl TIP20Token { /// Does NOT take msg_sender — the owner is validated from the signature. /// Follows same pause behavior as approve() (allowed when paused). pub fn permit(&mut self, call: ITIP20::permitCall) -> Result<()> { - let timestamp = self.storage.timestamp(); - let deadline = U256::from(call.deadline); - // 1. Check deadline - if timestamp > deadline { + if self.storage.timestamp() > call.deadline { return Err(TIP20Error::permit_expired().into()); } - // 2. Read current nonce and increment BEFORE validation (reentrancy protection) + // 2. Construct EIP-712 struct hash let nonce = self.permit_nonces[call.owner].read()?; - self.permit_nonces[call.owner].write( - nonce - .checked_add(U256::from(1)) - .ok_or(TempoPrecompileError::under_overflow())?, - )?; - - // 3. Construct EIP-712 struct hash let struct_hash = keccak256( ( *PERMIT_TYPEHASH, @@ -549,7 +539,7 @@ impl TIP20Token { .abi_encode(), ); - // 4. Construct EIP-712 digest + // 3. Construct EIP-712 digest let domain_separator = self.domain_separator()?; let digest = keccak256( [ @@ -560,8 +550,11 @@ impl TIP20Token { .concat(), ); - // 5. Validate ECDSA signature - let parity = call.v.wrapping_sub(27) != 0; + // 4. Validate ECDSA signature + if call.v != 27 && call.v != 28 { + return Err(TIP20Error::invalid_signature().into()); + } + let parity = call.v == 28; let sig = Signature::from_scalars_and_parity(call.r, call.s, parity); let recovered = sig .recover_address_from_prehash(&digest) @@ -570,6 +563,13 @@ impl TIP20Token { return Err(TIP20Error::invalid_signature().into()); } + // 5. Increment nonce + self.permit_nonces[call.owner].write( + nonce + .checked_add(U256::from(1)) + .ok_or(TempoPrecompileError::under_overflow())?, + )?; + // 6. Set allowance self.set_allowance(call.owner, call.spender, call.value)?; @@ -2228,38 +2228,68 @@ pub(crate) mod tests { ) } + struct PermitFixture { + storage: HashMapStorageProvider, + admin: Address, + signer: PrivateKeySigner, + spender: Address, + } + + impl PermitFixture { + fn new() -> Self { + Self { + storage: setup_t2_storage(), + admin: Address::random(), + signer: PrivateKeySigner::random(), + spender: Address::random(), + } + } + } + + fn make_permit_call( + signer: &PrivateKeySigner, + spender: Address, + token_address: Address, + value: U256, + nonce: U256, + deadline: U256, + ) -> ITIP20::permitCall { + let (v, r, s) = sign_permit( + signer, + "Test", + token_address, + spender, + value, + nonce, + deadline, + ); + ITIP20::permitCall { + owner: signer.address(), + spender, + value, + deadline, + v, + r, + s, + } + } + #[test] fn test_permit_happy_path() -> eyre::Result<()> { - let mut storage = setup_t2_storage(); - let admin = Address::random(); - let signer = PrivateKeySigner::random(); + let PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); let owner = signer.address(); - let spender = Address::random(); let value = U256::from(1000); - let deadline = U256::MAX; StorageCtx::enter(&mut storage, || { let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; - - let (v, r, s) = sign_permit( - &signer, - "Test", - token.address, - spender, - value, - U256::ZERO, - deadline, - ); - - token.permit(ITIP20::permitCall { - owner, - spender, - value, - deadline, - v, - r, - s, - })?; + let call = + make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX); + token.permit(call)?; // Verify allowance was set let allowance = token.allowance(ITIP20::allowanceCall { owner, spender })?; @@ -2275,37 +2305,22 @@ pub(crate) mod tests { #[test] fn test_permit_expired() -> eyre::Result<()> { - let mut storage = setup_t2_storage(); - let admin = Address::random(); - let signer = PrivateKeySigner::random(); - let owner = signer.address(); - let spender = Address::random(); + let PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); let value = U256::from(1000); // Deadline in the past let deadline = U256::ZERO; StorageCtx::enter(&mut storage, || { let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + let call = + make_permit_call(signer, spender, token.address, value, U256::ZERO, deadline); - let (v, r, s) = sign_permit( - &signer, - "Test", - token.address, - spender, - value, - U256::ZERO, - deadline, - ); - - let result = token.permit(ITIP20::permitCall { - owner, - spender, - value, - deadline, - v, - r, - s, - }); + let result = token.permit(call); assert!(matches!( result, @@ -2350,11 +2365,13 @@ pub(crate) mod tests { #[test] fn test_permit_wrong_signer() -> eyre::Result<()> { - let mut storage = setup_t2_storage(); - let admin = Address::random(); - let signer = PrivateKeySigner::random(); + let PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); let wrong_owner = Address::random(); // Not the signer's address - let spender = Address::random(); let value = U256::from(1000); let deadline = U256::MAX; @@ -2363,7 +2380,7 @@ pub(crate) mod tests { // Sign with signer but claim wrong_owner let (v, r, s) = sign_permit( - &signer, + signer, "Test", token.address, spender, @@ -2393,48 +2410,26 @@ pub(crate) mod tests { #[test] fn test_permit_replay_protection() -> eyre::Result<()> { - let mut storage = setup_t2_storage(); - let admin = Address::random(); - let signer = PrivateKeySigner::random(); - let owner = signer.address(); - let spender = Address::random(); + let PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); let value = U256::from(1000); - let deadline = U256::MAX; StorageCtx::enter(&mut storage, || { let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; - - let (v, r, s) = sign_permit( - &signer, - "Test", - token.address, - spender, - value, - U256::ZERO, // nonce 0 - deadline, - ); + let call1 = + make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX); + let call2 = + make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX); // First use should succeed - token.permit(ITIP20::permitCall { - owner, - spender, - value, - deadline, - v, - r, - s, - })?; + token.permit(call1)?; // Second use of same signature should fail (nonce incremented) - let result = token.permit(ITIP20::permitCall { - owner, - spender, - value, - deadline, - v, - r, - s, - }); + let result = token.permit(call2); assert!(matches!( result, @@ -2447,12 +2442,13 @@ pub(crate) mod tests { #[test] fn test_permit_nonce_tracking() -> eyre::Result<()> { - let mut storage = setup_t2_storage(); - let admin = Address::random(); - let signer = PrivateKeySigner::random(); + let PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); let owner = signer.address(); - let spender = Address::random(); - let deadline = U256::MAX; StorageCtx::enter(&mut storage, || { let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; @@ -2464,26 +2460,9 @@ pub(crate) mod tests { for i in 0u64..3 { let nonce = U256::from(i); let value = U256::from(100 * (i + 1)); - - let (v, r, s) = sign_permit( - &signer, - "Test", - token.address, - spender, - value, - nonce, - deadline, - ); - - token.permit(ITIP20::permitCall { - owner, - spender, - value, - deadline, - v, - r, - s, - })?; + let call = + make_permit_call(signer, spender, token.address, value, nonce, U256::MAX); + token.permit(call)?; assert_eq!( token.nonces(ITIP20::noncesCall { owner })?, @@ -2497,13 +2476,14 @@ pub(crate) mod tests { #[test] fn test_permit_works_when_paused() -> eyre::Result<()> { - let mut storage = setup_t2_storage(); - let admin = Address::random(); - let signer = PrivateKeySigner::random(); + let PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); let owner = signer.address(); - let spender = Address::random(); let value = U256::from(1000); - let deadline = U256::MAX; StorageCtx::enter(&mut storage, || { let mut token = TIP20Setup::create("Test", "TST", admin) @@ -2514,26 +2494,11 @@ pub(crate) mod tests { token.pause(admin, ITIP20::pauseCall {})?; assert!(token.paused()?); - let (v, r, s) = sign_permit( - &signer, - "Test", - token.address, - spender, - value, - U256::ZERO, - deadline, - ); + let call = + make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX); // Permit should work even when paused - token.permit(ITIP20::permitCall { - owner, - spender, - value, - deadline, - v, - r, - s, - })?; + token.permit(call)?; assert_eq!( token.allowance(ITIP20::allowanceCall { owner, spender })?, @@ -2546,8 +2511,9 @@ pub(crate) mod tests { #[test] fn test_permit_domain_separator() -> eyre::Result<()> { - let mut storage = setup_t2_storage(); - let admin = Address::random(); + let PermitFixture { + mut storage, admin, .. + } = PermitFixture::new(); StorageCtx::enter(&mut storage, || { let token = TIP20Setup::create("Test", "TST", admin).apply()?; @@ -2562,36 +2528,25 @@ pub(crate) mod tests { #[test] fn test_permit_max_allowance() -> eyre::Result<()> { - let mut storage = setup_t2_storage(); - let admin = Address::random(); - let signer = PrivateKeySigner::random(); + let PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); let owner = signer.address(); - let spender = Address::random(); - let value = U256::MAX; - let deadline = U256::MAX; StorageCtx::enter(&mut storage, || { let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; - - let (v, r, s) = sign_permit( - &signer, - "Test", - token.address, + let call = make_permit_call( + signer, spender, - value, + token.address, + U256::MAX, U256::ZERO, - deadline, + U256::MAX, ); - - token.permit(ITIP20::permitCall { - owner, - spender, - value, - deadline, - v, - r, - s, - })?; + token.permit(call)?; assert_eq!( token.allowance(ITIP20::allowanceCall { owner, spender })?, @@ -2604,59 +2559,42 @@ pub(crate) mod tests { #[test] fn test_permit_allowance_override() -> eyre::Result<()> { - let mut storage = setup_t2_storage(); - let admin = Address::random(); - let signer = PrivateKeySigner::random(); + let PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); let owner = signer.address(); - let spender = Address::random(); - let deadline = U256::MAX; StorageCtx::enter(&mut storage, || { let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; // First permit: set allowance to 1000 - let (v, r, s) = sign_permit( - &signer, - "Test", - token.address, + let call = make_permit_call( + signer, spender, + token.address, U256::from(1000), U256::ZERO, - deadline, + U256::MAX, ); - token.permit(ITIP20::permitCall { - owner, - spender, - value: U256::from(1000), - deadline, - v, - r, - s, - })?; + token.permit(call)?; assert_eq!( token.allowance(ITIP20::allowanceCall { owner, spender })?, U256::from(1000) ); // Second permit: override to 0 - let (v, r, s) = sign_permit( - &signer, - "Test", - token.address, + let call = make_permit_call( + signer, spender, + token.address, U256::ZERO, - U256::from(1), // nonce 1 - deadline, + U256::from(1), + U256::MAX, ); - token.permit(ITIP20::permitCall { - owner, - spender, - value: U256::ZERO, - deadline, - v, - r, - s, - })?; + token.permit(call)?; assert_eq!( token.allowance(ITIP20::allowanceCall { owner, spender })?, U256::ZERO diff --git a/tips/ref-impls/test/invariants/README.md b/tips/ref-impls/test/invariants/README.md index de57336f21..8b88583652 100644 --- a/tips/ref-impls/test/invariants/README.md +++ b/tips/ref-impls/test/invariants/README.md @@ -495,7 +495,7 @@ TIP20 is the Tempo token standard that extends ERC-20 with transfer policies, me ### Approval Invariants - **TEMPO-TIP5**: Allowance setting - `approve` sets exact allowance amount, returns `true`. -- **TEMPO-TIP29**: A valid permit sets allowance to the `value` in the permit struct. +- **TEMPO-TIP36**: A valid permit sets allowance to the `value` in the permit struct. ### Mint/Burn Invariants diff --git a/tips/ref-impls/test/invariants/TIP20.t.sol b/tips/ref-impls/test/invariants/TIP20.t.sol index 31e4e32bcb..35fae374e2 100644 --- a/tips/ref-impls/test/invariants/TIP20.t.sol +++ b/tips/ref-impls/test/invariants/TIP20.t.sol @@ -7,7 +7,7 @@ import { InvariantBaseTest } from "./InvariantBaseTest.t.sol"; /// @title TIP20 Invariant Tests /// @notice Fuzz-based invariant tests for the TIP20 token implementation -/// @dev Tests invariants TEMPO-TIP1 through TEMPO-TIP29 +/// @dev Tests invariants TEMPO-TIP1 through TEMPO-TIP36 contract TIP20InvariantTest is InvariantBaseTest { /// @dev Ghost variables for reward distribution tracking @@ -36,6 +36,9 @@ contract TIP20InvariantTest is InvariantBaseTest { /// @dev Constants uint256 internal constant ACC_PRECISION = 1e18; + bytes32 internal constant PERMIT_TYPEHASH = keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ); /// @dev Register an address as a potential token holder function _registerHolder(address token, address holder) internal { @@ -1254,7 +1257,9 @@ contract TIP20InvariantTest is InvariantBaseTest { abi.encodePacked( "\x19\x01", token.DOMAIN_SEPARATOR(), - keccak256(abi.encodePacked(actor, recipient, amount, deadline)) + keccak256( + abi.encode(PERMIT_TYPEHASH, actor, recipient, amount, actorNonce, deadline) + ) ) ); @@ -1262,12 +1267,12 @@ contract TIP20InvariantTest is InvariantBaseTest { address signer; if (resultSeed % 4 == 0) { signer = actor; - (v, r, s) = vm.sign(_selectActorKey(actorSeed), digest); + (v, r, s) = vm.sign(signer, digest); } else if (resultSeed % 4 == 1) { // Sign with a random key resultSeed = resultSeed >> 1; signer = _selectActorExcluding(resultSeed, actor); - (v, r, s) = vm.sign(_selectActorKey(actorSeed), digest); + (v, r, s) = vm.sign(signer, digest); } else if (resultSeed % 4 == 2) { digest = keccak256(abi.encodePacked(digest, resultSeed)); // corrupt the digest unpredictably } // else use the random bytes entirely @@ -1275,11 +1280,11 @@ contract TIP20InvariantTest is InvariantBaseTest { try token.permit(actor, recipient, amount, deadline, v, r, s) { // If permit passes, check invariants - // **TEMPO-TIP29**: Permit should set correct allowance + // **TEMPO-TIP36**: Permit should set correct allowance assertEq( token.allowance(actor, recipient), amount, - "TEMPO-TIP29: Permit did not set correct allowance" + "TEMPO-TIP36: Permit did not set correct allowance" ); // **TEMPO-TIP32**: Nonce should be incremented From fdb2fc7f2204aa3188c714131d5837bddc954125 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:15:46 -0500 Subject: [PATCH 13/18] fix: test fail, further cleanup --- crates/precompiles/src/tip20/mod.rs | 8 +++---- tips/ref-impls/test/invariants/TIP20.t.sol | 26 ++++++++++++++++++---- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 8c44d46879..408dfacc0f 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -2420,16 +2420,14 @@ pub(crate) mod tests { StorageCtx::enter(&mut storage, || { let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; - let call1 = - make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX); - let call2 = + let call = make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX); // First use should succeed - token.permit(call1)?; + token.permit(call.clone())?; // Second use of same signature should fail (nonce incremented) - let result = token.permit(call2); + let result = token.permit(call); assert!(matches!( result, diff --git a/tips/ref-impls/test/invariants/TIP20.t.sol b/tips/ref-impls/test/invariants/TIP20.t.sol index 35fae374e2..598137348c 100644 --- a/tips/ref-impls/test/invariants/TIP20.t.sol +++ b/tips/ref-impls/test/invariants/TIP20.t.sol @@ -1267,12 +1267,12 @@ contract TIP20InvariantTest is InvariantBaseTest { address signer; if (resultSeed % 4 == 0) { signer = actor; - (v, r, s) = vm.sign(signer, digest); + (v, r, s) = vm.sign(_selectActorKey(actorSeed), digest); } else if (resultSeed % 4 == 1) { // Sign with a random key - resultSeed = resultSeed >> 1; - signer = _selectActorExcluding(resultSeed, actor); - (v, r, s) = vm.sign(signer, digest); + uint256 wrongKey = _selectActorKeyExcluding(recipientSeed, actor); + signer = vm.addr(wrongKey); + (v, r, s) = vm.sign(wrongKey, digest); } else if (resultSeed % 4 == 2) { digest = keccak256(abi.encodePacked(digest, resultSeed)); // corrupt the digest unpredictably } // else use the random bytes entirely @@ -1405,4 +1405,22 @@ contract TIP20InvariantTest is InvariantBaseTest { return _keys[seed % _keys.length]; } + function _selectActorKeyExcluding( + uint256 seed, + address exclude + ) + internal + view + returns (uint256) + { + uint256 key; + address actor; + do { + key = _selectActorKey(seed); + actor = vm.addr(key); + seed++; + } while (actor == exclude); + return key; + } + } From 92db26198fef78bf087c0bc0395a595c3f976943 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:17:21 -0500 Subject: [PATCH 14/18] chore: review fixes --- crates/precompiles/src/tip20/dispatch.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/precompiles/src/tip20/dispatch.rs b/crates/precompiles/src/tip20/dispatch.rs index 958061042a..61de49bcc2 100644 --- a/crates/precompiles/src/tip20/dispatch.rs +++ b/crates/precompiles/src/tip20/dispatch.rs @@ -223,10 +223,12 @@ mod tests { }; use alloy::{ primitives::{Bytes, U256, address}, - sol_types::{SolCall, SolInterface, SolValue}, + sol_types::{SolCall, SolError, SolInterface, SolValue}, }; use tempo_chainspec::hardfork::TempoHardfork; - use tempo_contracts::precompiles::{IRolesAuth, RolesAuthError, TIP20Error}; + use tempo_contracts::precompiles::{ + IRolesAuth, RolesAuthError, TIP20Error, UnknownFunctionSelector, + }; #[test] fn test_function_selector_dispatch() -> eyre::Result<()> { @@ -771,9 +773,6 @@ mod tests { #[test] fn test_permit_selectors_gated_behind_t2() -> eyre::Result<()> { - use alloy::sol_types::SolError; - use tempo_contracts::precompiles::UnknownFunctionSelector; - // Pre-T2: permit/nonces/DOMAIN_SEPARATOR should return unknown selector let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T1); let admin = Address::random(); From 413d49171268351d1f167e62862570929e7b88dd Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:45:13 -0500 Subject: [PATCH 15/18] fix: ecdsa sig replayability --- crates/precompiles/src/tip20/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 408dfacc0f..b5da8793ae 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -556,8 +556,7 @@ impl TIP20Token { } let parity = call.v == 28; let sig = Signature::from_scalars_and_parity(call.r, call.s, parity); - let recovered = sig - .recover_address_from_prehash(&digest) + let recovered = alloy::consensus::crypto::secp256k1::recover_signer(&sig, digest) .map_err(|_| TIP20Error::invalid_signature())?; if recovered != call.owner { return Err(TIP20Error::invalid_signature().into()); From 1b9add187bd08e98b7f1803794fccef5d544f138 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:39:26 -0500 Subject: [PATCH 16/18] Update tips/ref-impls/test/invariants/TIP20.t.sol --- tips/ref-impls/test/invariants/TIP20.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/ref-impls/test/invariants/TIP20.t.sol b/tips/ref-impls/test/invariants/TIP20.t.sol index 598137348c..cb028754ef 100644 --- a/tips/ref-impls/test/invariants/TIP20.t.sol +++ b/tips/ref-impls/test/invariants/TIP20.t.sol @@ -1300,7 +1300,7 @@ contract TIP20InvariantTest is InvariantBaseTest { // **TEMPO-TIP35**: The recovered signer from a valid permit signature must exactly match the `owner` parameter. assertEq( ecrecover(digest, v, r, s), - signer, + actor, "TEMPO-TIP35: Recovered signer does not match expected" ); From e13c21eef6218686c7007c221b2ebf320e5900d3 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:40:50 -0500 Subject: [PATCH 17/18] fix: unchecked seed++ in _selectActorKeyExcluding to prevent overflow Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019c76d4-c191-703b-a1da-a66c517ebed4 --- tips/ref-impls/test/invariants/TIP20.t.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tips/ref-impls/test/invariants/TIP20.t.sol b/tips/ref-impls/test/invariants/TIP20.t.sol index cb028754ef..1fc670ff89 100644 --- a/tips/ref-impls/test/invariants/TIP20.t.sol +++ b/tips/ref-impls/test/invariants/TIP20.t.sol @@ -1418,7 +1418,9 @@ contract TIP20InvariantTest is InvariantBaseTest { do { key = _selectActorKey(seed); actor = vm.addr(key); - seed++; + unchecked { + seed++; + } } while (actor == exclude); return key; } From d612cb72eeaf7b1d7d69a9d2d55d9bbb31c312a6 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:36:12 -0500 Subject: [PATCH 18/18] fix(tip-1004): reject zero-address ecrecover in permit, add edge-case tests (#2786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Addresses [ZELLIC-53](https://linear.app/tempoxyz/issue/ZELLIC-53): audit findings on TIP-1004 permit implementation. ### Changes **Bug fix — zero-address recovery rejection** `permit()` now explicitly rejects `recovered == address(0)` before comparing against `owner`, matching the Solidity reference `ecrecover` behavior. Previously, a crafted signature that somehow recovered to `address(0)` could have been accepted if `owner` was also `address(0)`. **New tests** - `test_permit_zero_address_recovery_reverts` — verifies `InvalidSignature` is returned when ecrecover yields the zero address - `test_permit_domain_separator_changes_with_chain_id` — verifies the EIP-712 domain separator differs across chain IDs (fork safety) --------- Co-authored-by: Amp --- crates/precompiles/src/tip20/mod.rs | 98 ++++++++++++++++++++++++++++- tips/tip-1004.md | 3 +- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index b5da8793ae..cbf67c6229 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -551,6 +551,7 @@ impl TIP20Token { ); // 4. Validate ECDSA signature + // Only v=27/28 is accepted; v=0/1 is intentionally NOT normalized (see TIP-1004 spec). if call.v != 27 && call.v != 28 { return Err(TIP20Error::invalid_signature().into()); } @@ -558,7 +559,7 @@ impl TIP20Token { let sig = Signature::from_scalars_and_parity(call.r, call.s, parity); let recovered = alloy::consensus::crypto::secp256k1::recover_signer(&sig, digest) .map_err(|_| TIP20Error::invalid_signature())?; - if recovered != call.owner { + if recovered.is_zero() || recovered != call.owner { return Err(TIP20Error::invalid_signature().into()); } @@ -2600,5 +2601,100 @@ pub(crate) mod tests { Ok(()) }) } + + #[test] + fn test_permit_invalid_v_values() -> eyre::Result<()> { + let PermitFixture { + mut storage, + admin, + spender, + .. + } = PermitFixture::new(); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + for v in [0u8, 1] { + let result = token.permit(ITIP20::permitCall { + owner: admin, + spender, + value: U256::from(1000), + deadline: U256::MAX, + v, + r: B256::ZERO, + s: B256::ZERO, + }); + + assert!( + matches!( + result, + Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_))) + ), + "v={v} should revert with InvalidSignature" + ); + } + + Ok(()) + }) + } + + #[test] + fn test_permit_zero_address_recovery_reverts() -> eyre::Result<()> { + let PermitFixture { + mut storage, + admin, + spender, + .. + } = PermitFixture::new(); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + let result = token.permit(ITIP20::permitCall { + owner: Address::ZERO, + spender, + value: U256::from(1000), + deadline: U256::MAX, + v: 27, + r: B256::ZERO, + s: B256::ZERO, + }); + + assert!(matches!( + result, + Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_))) + )); + + Ok(()) + }) + } + + #[test] + fn test_permit_domain_separator_changes_with_chain_id() -> eyre::Result<()> { + let PermitFixture { admin, .. } = PermitFixture::new(); + + let mut storage_a = setup_t2_storage(); + let mut storage_b = + HashMapStorageProvider::new_with_spec(CHAIN_ID + 1, TempoHardfork::T2); + + let ds_a = StorageCtx::enter(&mut storage_a, || { + TIP20Setup::create("Test", "TST", admin) + .apply()? + .domain_separator() + })?; + + let ds_b = StorageCtx::enter(&mut storage_b, || { + TIP20Setup::create("Test", "TST", admin) + .apply()? + .domain_separator() + })?; + + assert_ne!( + ds_a, ds_b, + "domain separator must change when chainId changes" + ); + + Ok(()) + } } } diff --git a/tips/tip-1004.md b/tips/tip-1004.md index 67759c82fd..75c30b4d9b 100644 --- a/tips/tip-1004.md +++ b/tips/tip-1004.md @@ -173,7 +173,8 @@ The implementation must: 2. Retrieve the current nonce for `owner` and use it to construct the `structHash` and `digest` 3. Increment `nonces[owner]` 4. Validate the signature: - - First, use `ecrecover` to recover a signer address from the digest + - The `v` parameter must be `27` or `28`. Values of `0` or `1` are **not** normalized and will revert with `InvalidSignature`. Callers using signing libraries that produce `v ∈ {0, 1}` must add `27` before calling `permit`. + - Use `ecrecover` to recover a signer address from the digest - If `ecrecover` returns a non-zero address that equals `owner`, the signature is valid (EOA case) - Otherwise, revert with `InvalidSignature` 5. Set `allowance[owner][spender] = value`