Skip to content

feat(universal-token): add eip-2612 permit/nonces/domain separator#377

Draft
hedwig0x wants to merge 2 commits intofluentlabs-xyz:develfrom
hedwig0x:feat/universal-token-eip2612-permit
Draft

feat(universal-token): add eip-2612 permit/nonces/domain separator#377
hedwig0x wants to merge 2 commits intofluentlabs-xyz:develfrom
hedwig0x:feat/universal-token-eip2612-permit

Conversation

@hedwig0x
Copy link
Copy Markdown
Contributor

@hedwig0x hedwig0x commented Mar 31, 2026

Summary

  • add Universal Token permit support (EIP-2612 style): permit(owner,spender,value,deadline,v,r,s)
  • add nonces(owner) and DOMAIN_SEPARATOR() handlers + selector dispatch
  • add SDK universal-token constants/selectors/errors/typehashes and nonce storage slot
  • add SDK command/storage-key support for permit/nonces/domain-separator
  • add E2E coverage for:
    • valid permit path (allowance set + nonce increment)
    • invalid signature rejection

Implementation notes

  • Permit digest uses EIP-712 domain (name, version 1, chainId, verifyingContract).
  • Signature recovery is done in-contract via revm_precompile::secp256k1::ecrecover.
    • This avoids the nested precompile syscall panic path observed when invoking ecrecover via call/static_call from the universal-token contract path.
  • Expired deadline and invalid signature return existing universal-token errors:
    • ERR_UST_EXPIRED_DEADLINE
    • ERR_UST_INVALID_SIGNATURE

Validation

  • cargo test --manifest-path contracts/universal-token/Cargo.toml --lib -- --nocapture
  • cargo test -p fluentbase-e2e universal_token_permit -- --nocapture

Summary by CodeRabbit

  • New Features

    • Added ERC-2612 permit support for gasless token approvals via signatures
    • Introduced nonce-based permit flow with EIP-712 domain separator handling
    • Added signature verification and error handling for invalid or expired permits
  • Tests

    • Added end-to-end tests covering successful permit use and invalid-signature rejection

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 31, 2026

📝 Walkthrough

Walkthrough

Adds ERC‑2612 permit support: domain separator and permit digest computation, nonce storage and queries, signature-based allowance setting (via ecrecover), SDK commands/constants for permit/nonces, storage key handling, dependency bump, and end-to-end tests for valid and invalid signatures.

Changes

Cohort / File(s) Summary
Contract + manifest
contracts/universal-token/Cargo.toml, contracts/universal-token/lib.rs
Added revm-precompile dependency; implemented ERC‑2612 nonce storage, domain separator, permit digest computation, signature verification via ecrecover, nonce increment, and dispatch cases for permit/nonces/domain_separator.
SDK commands
crates/sdk/src/universal_token/command.rs
Added NoncesCommand and PermitCommand with encoding/decoding and signatures SIG_ERC20_NONCES / SIG_ERC20_PERMIT.
Constants
crates/sdk/src/universal_token/consts.rs
Introduced new error codes (ERR_UST_EXPIRED_DEADLINE, ERR_UST_INVALID_SIGNATURE), new signature IDs (SIG_ERC20_PERMIT, SIG_ERC20_NONCES, SIG_ERC20_DOMAIN_SEPARATOR), EIP‑712/EIP‑2612 typehash constants, and NONCES_STORAGE_SLOT.
Storage helpers
crates/sdk/src/universal_token/storage.rs
Extended storage key computation to include permit/nonces/domain separator branches and decode respective commands from input.
End-to-end tests
e2e/src/universal_token.rs
Added helpers to compute EIP‑712 permit digest, sign it, and two E2E tests: a successful permit flow (allowance set, nonce increment) and a signature-mismatch revert (ERR_UST_INVALID_SIGNATURE).

Sequence Diagram(s)

sequenceDiagram
    participant User as User/Signer
    participant Client as Caller
    participant Contract as UniversalToken
    participant Crypto as ecrecover
    participant Storage as ContractStorage

    User->>Client: Produce permit parameters & sign digest (v,r,s)
    Client->>Contract: call permit(owner, spender, value, deadline, v, r, s)
    Contract->>Contract: compute EIP‑712 domain separator
    Contract->>Contract: compute permit struct hash
    Contract->>Contract: build final digest ("\x19\x01" || separator || permitHash)
    Contract->>Crypto: ecrecover(digest, v, r, s)
    Crypto-->>Contract: recovered signer
    Contract->>Contract: verify recovered == owner
    alt valid signature
        Contract->>Storage: set allowance[owner][spender] = value
        Contract->>Storage: increment nonce[owner]
        Contract-->>Client: success
    else invalid signature or expired
        Contract-->>Client: revert (ERR_UST_INVALID_SIGNATURE / ERR_UST_EXPIRED_DEADLINE)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested reviewers

  • d1r1
  • chillhacker

Poem

🐰
I nibble bytes where nonces grow,
Sign with whiskers, keccak aglow,
A permit hop, allowance set,
One little nonce — not forged, not yet,
Hooray, my ledger lets us go!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: adding EIP-2612 permit/nonces/domain separator functionality to the universal token contract.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
e2e/src/universal_token.rs (1)

53-61: Minor code duplication with contracts/universal-token/lib.rs.

The abi_word_addr and abi_word_u256 helpers duplicate the implementations in contracts/universal-token/lib.rs (lines 257-265). While acceptable for test isolation, consider extracting to a shared utility in the SDK if these patterns are needed elsewhere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/src/universal_token.rs` around lines 53 - 61, The helpers abi_word_addr
and abi_word_u256 in e2e/src/universal_token.rs duplicate logic found in
contracts/universal-token/lib.rs; extract these functions into a shared utility
module in the SDK (e.g., sdk::abi_utils or similar), move the implementations of
abi_word_addr(Address) and abi_word_u256(U256) into that module, then replace
the local definitions with imports/uses of the shared functions in both places
so the tests and contract code reuse a single implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@contracts/universal-token/lib.rs`:
- Around line 281-303: Add a SECP256K1N_HALF constant using the provided
U256::from_limbs([...]) little-endian limbs and enforce signature
non-malleability in ecrecover_address by rejecting any signature where s >
SECP256K1N_HALF; implement the check near the start of ecrecover_address (before
proceeding to rec_id logic) and return None when the test fails, using U256
comparison semantics to compare the incoming s to SECP256K1N_HALF so malleable
signatures are rejected.

---

Nitpick comments:
In `@e2e/src/universal_token.rs`:
- Around line 53-61: The helpers abi_word_addr and abi_word_u256 in
e2e/src/universal_token.rs duplicate logic found in
contracts/universal-token/lib.rs; extract these functions into a shared utility
module in the SDK (e.g., sdk::abi_utils or similar), move the implementations of
abi_word_addr(Address) and abi_word_u256(U256) into that module, then replace
the local definitions with imports/uses of the shared functions in both places
so the tests and contract code reuse a single implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0d3682b0-afa9-4fae-8b03-f966dda9e5f8

📥 Commits

Reviewing files that changed from the base of the PR and between dbfaeb1 and e2e8470.

⛔ Files ignored due to path filters (1)
  • contracts/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • contracts/universal-token/Cargo.toml
  • contracts/universal-token/lib.rs
  • crates/sdk/src/universal_token/command.rs
  • crates/sdk/src/universal_token/consts.rs
  • crates/sdk/src/universal_token/storage.rs
  • e2e/src/universal_token.rs

Comment on lines +281 to +303
fn ecrecover_address(digest: B256, v: u8, r: U256, s: U256) -> Option<Address> {
let rec_id = match v {
27 | 28 => v - 27,
0 | 1 => v,
_ => return None,
};

let mut sig_bytes = [0u8; 64];
sig_bytes[0..32].copy_from_slice(&abi_word_u256(r));
sig_bytes[32..64].copy_from_slice(&abi_word_u256(s));
let sig = <&B512>::try_from(&sig_bytes[..]).ok()?;

let recovered = ecrecover(sig, rec_id, &digest).ok()?;
let mut recovered_addr = [0u8; 20];
recovered_addr.copy_from_slice(&recovered[12..32]);
let recovered_addr = Address::from_slice(&recovered_addr);

if recovered_addr == Address::ZERO {
return None;
}

Some(recovered_addr)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What is the secp256k1 curve order N value and how should EIP-2 signature malleability be checked?

💡 Result:

The secp256k1 curve order N is 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141, or in decimal: 115792089237316195423570985008687907852837564279074904382605163141518161494337. EIP-2 addresses ECDSA signature malleability in Ethereum's Homestead hard fork by making signatures with s-value greater than secp256k1n/2 invalid. To check for EIP-2 signature malleability compliance (i.e., enforce low-s), verify that the s value satisfies s <= N/2, where N/2 is 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0. If s > N/2, the signature is malleable (equivalent to N - s would be the low-s version) and should be rejected. This is implemented in Ethereum's ecrecover precompile post-Homestead and recommended in Solidity contracts using manual checks or libraries like OpenZeppelin's ECDSA.

Citations:


🏁 Script executed:

# First, check if the file exists and examine the ecrecover_address function
find . -name "lib.rs" -path "*/universal-token/*" | head -5

Repository: fluentlabs-xyz/fluentbase

Length of output: 103


🏁 Script executed:

# Verify the U256 limb conversion for SECP256K1N_HALF
python3 << 'PYTHON_EOF'
# Correct N/2 in big-endian hex
N_HALF_HEX = "0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0"

# Convert to int
n_half = int(N_HALF_HEX, 16)

# Extract limbs (little-endian, each limb is 64-bit)
limb0 = n_half & ((1 << 64) - 1)
limb1 = (n_half >> 64) & ((1 << 64) - 1)
limb2 = (n_half >> 128) & ((1 << 64) - 1)
limb3 = (n_half >> 192) & ((1 << 64) - 1)

print("Correct N/2 limbs (little-endian):")
print(f"Limb 0: 0x{limb0:016X}")
print(f"Limb 1: 0x{limb1:016X}")
print(f"Limb 2: 0x{limb2:016X}")
print(f"Limb 3: 0x{limb3:016X}")

print("\nProposed constant in review:")
proposed = [0xFFFFFFFFFFFFFFFF, 0xFFFFFFFFFFFFFFFE, 0xBAAEDCE6AF48A03B, 0x7FFFFFFFFFFFFFFF]
for i, limb in enumerate(proposed):
    print(f"Limb {i}: 0x{limb:016X}")

print("\nDo they match?")
print(f"Match: {[limb0, limb1, limb2, limb3] == proposed}")
PYTHON_EOF

Repository: fluentlabs-xyz/fluentbase

Length of output: 378


🏁 Script executed:

# Read the ecrecover_address function from the file
sed -n '281,303p' ./contracts/universal-token/lib.rs

Repository: fluentlabs-xyz/fluentbase

Length of output: 778


🏁 Script executed:

# Check if there are any other permit/signature-related implementations in the codebase
rg -A 10 -B 5 "permit\|erc20_permit" ./contracts/universal-token/lib.rs | head -100

Repository: fluentlabs-xyz/fluentbase

Length of output: 51


🏁 Script executed:

# Check if malleability check is done elsewhere in the codebase
rg "SECP256K1\|s >\|s_value\|malleab" ./contracts/

Repository: fluentlabs-xyz/fluentbase

Length of output: 51


🏁 Script executed:

# Read more context around the ecrecover_address function
sed -n '250,330p' ./contracts/universal-token/lib.rs

Repository: fluentlabs-xyz/fluentbase

Length of output: 2490


🏁 Script executed:

# Check if there's a permit handler that calls ecrecover_address
sed -n '324,400p' ./contracts/universal-token/lib.rs | head -80

Repository: fluentlabs-xyz/fluentbase

Length of output: 2397


Consider adding signature malleability check and correct the constant value.

The ecrecover_address function doesn't verify that the s value is in the lower half of the curve order (EIP-2). While this doesn't enable replay attacks (the nonce is consumed), it's a best practice to reject malleable signatures to match standard EIP-2612 implementations like OpenZeppelin's.

The proposed constant must be corrected before implementation. The correct SECP256K1N_HALF U256 limbs in little-endian format are:

U256::from_limbs([
    0xDFE92F46681B20A0,
    0x5D576E7357A4501D,
    0xFFFFFFFFFFFFFFFF,
    0x7FFFFFFFFFFFFFFF,
])

Add the check before the rec_id match to reject any signature with s > SECP256K1N_HALF.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/universal-token/lib.rs` around lines 281 - 303, Add a
SECP256K1N_HALF constant using the provided U256::from_limbs([...])
little-endian limbs and enforce signature non-malleability in ecrecover_address
by rejecting any signature where s > SECP256K1N_HALF; implement the check near
the start of ecrecover_address (before proceeding to rec_id logic) and return
None when the test fails, using U256 comparison semantics to compare the
incoming s to SECP256K1N_HALF so malleable signatures are rejected.

@dmitry123 dmitry123 changed the title universal-token: add EIP-2612 permit/nonces/domain separator feat(universal-token): add eip-2612 permit/nonces/domain separator Apr 3, 2026
@dmitry123 dmitry123 marked this pull request as draft April 3, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant