diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index b5da8793ae..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()); } @@ -558,7 +559,7 @@ impl TIP20Token { let sig = Signature::from_scalars_and_parity(call.r, call.s, parity); let recovered = alloy::consensus::crypto::secp256k1::recover_signer(&sig, digest) .map_err(|_| TIP20Error::invalid_signature())?; - if recovered != call.owner { + if recovered.is_zero() || recovered != call.owner { return Err(TIP20Error::invalid_signature().into()); } @@ -2600,5 +2601,64 @@ 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(()) + } } } 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`