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`