From 09710e5fb9deced42894b1514c8ec286d39a6459 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:40:50 -0500 Subject: [PATCH 1/5] fix: unchecked seed++ in _selectActorKeyExcluding to prevent overflow Amp-Thread-ID: https://ampcode.com/threads/T-019c76d4-c191-703b-a1da-a66c517ebed4 Co-authored-by: Amp --- tips/ref-impls/test/invariants/TIP20.t.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tips/ref-impls/test/invariants/TIP20.t.sol b/tips/ref-impls/test/invariants/TIP20.t.sol index 1fc670ff89..aedcb57d54 100644 --- a/tips/ref-impls/test/invariants/TIP20.t.sol +++ b/tips/ref-impls/test/invariants/TIP20.t.sol @@ -1418,9 +1418,7 @@ contract TIP20InvariantTest is InvariantBaseTest { do { key = _selectActorKey(seed); actor = vm.addr(key); - unchecked { - seed++; - } + unchecked { seed++; } } while (actor == exclude); return key; } From 630e542f940fc0886f2ba8dcd477d944c16b36aa Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:20:28 -0500 Subject: [PATCH 2/5] fix(tip-1004): reject zero-address ecrecover in permit, add edge-case tests Address Zellic audit finding ZELLIC-53: 1. Explicitly reject recovered == address(0) in permit() before comparing against owner, matching Solidity reference behavior. 2. Add missing test coverage: - Zero-address recovery reverts with InvalidSignature - Domain separator changes when chainId changes Amp-Thread-ID: https://ampcode.com/threads/T-019c76d4-c191-703b-a1da-a66c517ebed4 Co-authored-by: Amp --- crates/precompiles/src/tip20/mod.rs | 54 ++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index b5da8793ae..abc7660553 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -558,7 +558,7 @@ impl TIP20Token { let sig = Signature::from_scalars_and_parity(call.r, call.s, parity); let recovered = alloy::consensus::crypto::secp256k1::recover_signer(&sig, digest) .map_err(|_| TIP20Error::invalid_signature())?; - if recovered != call.owner { + if recovered.is_zero() || recovered != call.owner { return Err(TIP20Error::invalid_signature().into()); } @@ -2600,5 +2600,57 @@ pub(crate) mod tests { 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(()) + } } } From 898fd7016d93938ce36789b93d5115d2b6911b7a Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:22:19 -0500 Subject: [PATCH 3/5] chore: fmt Amp-Thread-ID: https://ampcode.com/threads/T-019c76d4-c191-703b-a1da-a66c517ebed4 Co-authored-by: Amp --- crates/precompiles/src/tip20/mod.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index abc7660553..513b52ea66 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -2641,14 +2641,21 @@ pub(crate) mod tests { 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() + TIP20Setup::create("Test", "TST", admin) + .apply()? + .domain_separator() })?; let ds_b = StorageCtx::enter(&mut storage_b, || { - TIP20Setup::create("Test", "TST", admin).apply()?.domain_separator() + TIP20Setup::create("Test", "TST", admin) + .apply()? + .domain_separator() })?; - assert_ne!(ds_a, ds_b, "domain separator must change when chainId changes"); + assert_ne!( + ds_a, ds_b, + "domain separator must change when chainId changes" + ); Ok(()) } From 48400ac2cd861d10f594758f1405282ca3999a3d Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:51:31 -0500 Subject: [PATCH 4/5] chore: fmt --- tips/ref-impls/test/invariants/TIP20.t.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tips/ref-impls/test/invariants/TIP20.t.sol b/tips/ref-impls/test/invariants/TIP20.t.sol index aedcb57d54..1fc670ff89 100644 --- a/tips/ref-impls/test/invariants/TIP20.t.sol +++ b/tips/ref-impls/test/invariants/TIP20.t.sol @@ -1418,7 +1418,9 @@ contract TIP20InvariantTest is InvariantBaseTest { do { key = _selectActorKey(seed); actor = vm.addr(key); - unchecked { seed++; } + unchecked { + seed++; + } } while (actor == exclude); return key; } From ed2a75aab5ae7117204f117c850d3725a01e7f54 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:57:29 -0500 Subject: [PATCH 5/5] docs(tip-1004): document v=27/28 requirement, no v=0/1 normalization Addresses ZELLIC-53 item #2: explicitly documents that permit only accepts v=27/28 and does not normalize v=0/1. Added to both the TIP-1004 spec and as a code comment in the Rust implementation. Amp-Thread-ID: https://ampcode.com/threads/T-019c76d4-c191-703b-a1da-a66c517ebed4 Co-authored-by: Amp --- crates/precompiles/src/tip20/mod.rs | 1 + tips/tip-1004.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 513b52ea66..38d3a81e5f 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -551,6 +551,7 @@ impl TIP20Token { ); // 4. Validate ECDSA signature + // Only v=27/28 is accepted; v=0/1 is intentionally NOT normalized (see TIP-1004 spec). if call.v != 27 && call.v != 28 { return Err(TIP20Error::invalid_signature().into()); } 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`