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..61de49bcc2 100644 --- a/crates/precompiles/src/tip20/dispatch.rs +++ b/crates/precompiles/src/tip20/dispatch.rs @@ -4,9 +4,12 @@ use crate::{ input_cost, metadata, mutate, mutate_void, storage::ContractStorage, tip20::{ITIP20, TIP20Token}, - view, + unknown_selector, view, +}; +use alloy::{ + primitives::Address, + sol_types::{SolCall, SolInterface}, }; -use alloy::{primitives::Address, sol_types::SolInterface}; use revm::precompile::{PrecompileError, PrecompileResult}; use tempo_contracts::precompiles::{IRolesAuth::IRolesAuthCalls, ITIP20::ITIP20Calls, TIP20Error}; @@ -164,6 +167,28 @@ 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)) @@ -198,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<()> { @@ -720,7 +747,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 +770,47 @@ mod tests { Ok(()) }) } + + #[test] + fn test_permit_selectors_gated_behind_t2() -> eyre::Result<()> { + // 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..cbf67c6229 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,7 @@ pub struct TIP20Token { total_supply: U256, balances: Mapping, allowances: Mapping>, - // Unused slot, kept for storage layout compatibility - _nonces: Mapping, + permit_nonces: Mapping, paused: bool, supply_cap: U256, // Unused slot, kept for storage layout compatibility @@ -86,6 +86,19 @@ 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 +490,97 @@ 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<()> { + // 1. Check deadline + if self.storage.timestamp() > call.deadline { + return Err(TIP20Error::permit_expired().into()); + } + + // 2. Construct EIP-712 struct hash + let nonce = self.permit_nonces[call.owner].read()?; + let struct_hash = keccak256( + ( + *PERMIT_TYPEHASH, + call.owner, + call.spender, + call.value, + nonce, + call.deadline, + ) + .abi_encode(), + ); + + // 3. Construct EIP-712 digest + let domain_separator = self.domain_separator()?; + let digest = keccak256( + [ + &[0x19, 0x01], + domain_separator.as_slice(), + struct_hash.as_slice(), + ] + .concat(), + ); + + // 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()); + } + let parity = call.v == 28; + 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.is_zero() || recovered != call.owner { + 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)?; + + // 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 +2158,543 @@ 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(), + ) + } + + 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 PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); + let owner = signer.address(); + let value = U256::from(1000); + + 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, U256::MAX); + token.permit(call)?; + + // 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 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 result = token.permit(call); + + 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 PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); + let wrong_owner = Address::random(); // Not the signer's address + 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 PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); + let value = U256::from(1000); + + 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, U256::MAX); + + // First use should succeed + token.permit(call.clone())?; + + // Second use of same signature should fail (nonce incremented) + let result = token.permit(call); + + assert!(matches!( + result, + Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_))) + )); + + Ok(()) + }) + } + + #[test] + fn test_permit_nonce_tracking() -> eyre::Result<()> { + let PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); + let owner = signer.address(); + + 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 call = + make_permit_call(signer, spender, token.address, value, nonce, U256::MAX); + token.permit(call)?; + + assert_eq!( + token.nonces(ITIP20::noncesCall { owner })?, + U256::from(i + 1) + ); + } + + Ok(()) + }) + } + + #[test] + fn test_permit_works_when_paused() -> eyre::Result<()> { + let PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); + let owner = signer.address(); + let value = U256::from(1000); + + 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 call = + make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX); + + // Permit should work even when paused + token.permit(call)?; + + assert_eq!( + token.allowance(ITIP20::allowanceCall { owner, spender })?, + value + ); + + Ok(()) + }) + } + + #[test] + fn test_permit_domain_separator() -> eyre::Result<()> { + let PermitFixture { + mut storage, admin, .. + } = PermitFixture::new(); + + 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 PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); + let owner = signer.address(); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + let call = make_permit_call( + signer, + spender, + token.address, + U256::MAX, + U256::ZERO, + U256::MAX, + ); + token.permit(call)?; + + assert_eq!( + token.allowance(ITIP20::allowanceCall { owner, spender })?, + U256::MAX + ); + + Ok(()) + }) + } + + #[test] + fn test_permit_allowance_override() -> eyre::Result<()> { + let PermitFixture { + mut storage, + admin, + ref signer, + spender, + } = PermitFixture::new(); + let owner = signer.address(); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + // First permit: set allowance to 1000 + let call = make_permit_call( + signer, + spender, + token.address, + U256::from(1000), + U256::ZERO, + U256::MAX, + ); + token.permit(call)?; + assert_eq!( + token.allowance(ITIP20::allowanceCall { owner, spender })?, + U256::from(1000) + ); + + // Second permit: override to 0 + let call = make_permit_call( + signer, + spender, + token.address, + U256::ZERO, + U256::from(1), + U256::MAX, + ); + token.permit(call)?; + assert_eq!( + token.allowance(ITIP20::allowanceCall { owner, spender })?, + U256::ZERO + ); + + 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/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..5590c58aff 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; - // Unused slot, kept for storage layout compatibility - mapping(address => uint256) public nonces; + 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); + } diff --git a/tips/ref-impls/test/TIP20.t.sol b/tips/ref-impls/test/TIP20.t.sol index 03bf1a64ac..2683acc5c6 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,178 @@ 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 { + 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; + + // 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 { + 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; + + // 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 { + 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; + + 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 { + vm.skip(isTempo); // TODO: skip for Tempo for now, reenable after tempo-foundry deps bumped + 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 { + 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; + + 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 { + 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(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..8b88583652 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-TIP36**: 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..1fc670ff89 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 @@ -31,8 +31,14 @@ 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; + 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 { @@ -49,7 +55,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 +1233,86 @@ 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 + { + 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); + uint256 actorNonce = token.nonces(actor); + + // build permit digest + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + token.DOMAIN_SEPARATOR(), + keccak256( + abi.encode(PERMIT_TYPEHASH, actor, recipient, amount, actorNonce, 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 + 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 + + try token.permit(actor, recipient, amount, deadline, v, r, s) { + // If permit passes, check invariants + + // **TEMPO-TIP36**: Permit should set correct allowance + assertEq( + token.allowance(actor, recipient), + amount, + "TEMPO-TIP36: 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" + ); + + // **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" + ); + + // **TEMPO-TIP35**: The recovered signer from a valid permit signature must exactly match the `owner` parameter. + assertEq( + ecrecover(digest, v, r, s), + actor, + "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) { } + } + /// @notice Handler that verifies paused tokens reject transfers with ContractPaused /// @dev Tests TEMPO-TIP17: pause enforcement - transfers revert with ContractPaused function tryTransferWhilePaused( @@ -1314,4 +1400,29 @@ 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]; + } + + function _selectActorKeyExcluding( + uint256 seed, + address exclude + ) + internal + view + returns (uint256) + { + uint256 key; + address actor; + do { + key = _selectActorKey(seed); + actor = vm.addr(key); + unchecked { + seed++; + } + } while (actor == exclude); + return key; + } + } 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 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`