Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions contracts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions contracts/universal-token/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ edition = "2021"

[dependencies]
fluentbase-sdk = { workspace = true }
revm-precompile = { workspace = true }

[dev-dependencies]
fluentbase-testing = { workspace = true }
Expand Down
155 changes: 154 additions & 1 deletion contracts/universal-token/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@ extern crate core;
#[cfg(test)]
mod tests;

use alloc::vec::Vec;
use fluentbase_sdk::{
bytes::BytesMut,
codec::SolidityABI,
crypto::crypto_keccak256,
derive::Event,
evm::write_evm_exit_message,
storage::{StorageMap, StorageU256},
system_entrypoint,
universal_token::*,
Address, ContextReader, EvmExitCode, ExitCode, StorageUtils, SystemAPI, U256,
Address, B256, B512, ContextReader, EvmExitCode, ExitCode, StorageUtils, SystemAPI, U256,
};
use revm_precompile::secp256k1::ecrecover;

mod events {
use super::*;
Expand Down Expand Up @@ -57,6 +60,20 @@ mod events {
type BalanceStorageMap = StorageMap<Address, StorageU256>;
/// Allowance mapping: `owner -> (spender -> allowance)`.
type AllowanceStorageMap = StorageMap<Address, StorageMap<Address, StorageU256>>;
/// Permit nonce mapping: `owner -> nonce`.
type NonceStorageMap = StorageMap<Address, StorageU256>;

#[inline(always)]
fn nonce_get<SDK: SystemAPI>(sdk: &mut SDK, owner: Address) -> Result<U256, ExitCode> {
Ok(NonceStorageMap::new(NONCES_STORAGE_SLOT).entry(owner).get(sdk))
}

#[inline(always)]
fn nonce_set<SDK: SystemAPI>(sdk: &mut SDK, owner: Address, nonce: U256) -> Result<(), ExitCode> {
NonceStorageMap::new(NONCES_STORAGE_SLOT)
.entry(owner)
.set_checked(sdk, nonce)
}

/// Returns the ERC-20 `symbol()` as a short string stored at `SYMBOL_STORAGE_SLOT`.
fn erc20_symbol_handler<SDK: SystemAPI>(
Expand Down Expand Up @@ -237,6 +254,139 @@ fn erc20_allowance_handler<SDK: SystemAPI>(
Ok(0)
}

fn abi_word_addr(a: Address) -> [u8; 32] {
let mut w = [0u8; 32];
w[12..].copy_from_slice(a.as_ref());
w
}

fn abi_word_u256(x: U256) -> [u8; 32] {
x.to_be_bytes::<{ U256::BYTES }>()
}

fn erc20_domain_separator_value<SDK: SystemAPI>(sdk: &mut SDK) -> Result<B256, ExitCode> {
let token_name = sdk.storage_short_string(&NAME_STORAGE_SLOT)?;
let name_hash = crypto_keccak256(token_name.as_bytes());

let mut encoded = Vec::with_capacity(32 * 5);
encoded.extend_from_slice(&EIP712_DOMAIN_TYPEHASH);
encoded.extend_from_slice(name_hash.as_slice());
encoded.extend_from_slice(&EIP2612_VERSION_HASH);
encoded.extend_from_slice(&abi_word_u256(U256::from(sdk.context().block_chain_id())));
encoded.extend_from_slice(&abi_word_addr(sdk.context().contract_address()));

Ok(crypto_keccak256(&encoded))
}

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)
}
Comment on lines +281 to +303
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.


fn erc20_domain_separator_handler<SDK: SystemAPI>(
sdk: &mut SDK,
_input: &[u8],
) -> Result<EvmExitCode, ExitCode> {
let domain_separator = erc20_domain_separator_value(sdk)?;
sdk.write(*domain_separator);
Ok(0)
}

fn erc20_nonces_handler<SDK: SystemAPI>(
sdk: &mut SDK,
input: &[u8],
) -> Result<EvmExitCode, ExitCode> {
let NoncesCommand { owner } = NoncesCommand::try_decode(input)?;
let nonce = nonce_get(sdk, owner)?;
sdk.write(abi_word_u256(nonce));
Ok(0)
}

fn erc20_permit_handler<SDK: SystemAPI>(
sdk: &mut SDK,
input: &[u8],
) -> Result<EvmExitCode, ExitCode> {
if sdk.context().contract_is_static() {
return Err(ExitCode::StateChangeDuringStaticCall);
}

let PermitCommand {
owner,
spender,
value,
deadline,
v,
r,
s,
} = PermitCommand::try_decode(input)?;

let now = U256::from(sdk.context().block_timestamp());
if deadline < now {
return Ok(ERR_UST_EXPIRED_DEADLINE);
}

let nonce = nonce_get(sdk, owner)?;

let mut permit_encoded = Vec::with_capacity(32 * 6);
permit_encoded.extend_from_slice(&EIP2612_PERMIT_TYPEHASH);
permit_encoded.extend_from_slice(&abi_word_addr(owner));
permit_encoded.extend_from_slice(&abi_word_addr(spender));
permit_encoded.extend_from_slice(&abi_word_u256(value));
permit_encoded.extend_from_slice(&abi_word_u256(nonce));
permit_encoded.extend_from_slice(&abi_word_u256(deadline));
let permit_hash = crypto_keccak256(&permit_encoded);

let domain_separator = erc20_domain_separator_value(sdk)?;
let mut digest_payload = Vec::with_capacity(66);
digest_payload.extend_from_slice(b"\x19\x01");
digest_payload.extend_from_slice(domain_separator.as_slice());
digest_payload.extend_from_slice(permit_hash.as_slice());
let digest = crypto_keccak256(&digest_payload);
let Some(recovered) = ecrecover_address(digest, v, r, s) else {
return Ok(ERR_UST_INVALID_SIGNATURE);
};

if recovered != owner {
return Ok(ERR_UST_INVALID_SIGNATURE);
}

AllowanceStorageMap::new(ALLOWANCE_STORAGE_SLOT)
.entry(owner)
.entry(spender)
.set_checked(sdk, value)?;

let next_nonce = nonce.checked_add(U256::ONE).ok_or(ExitCode::IntegerOverflow)?;
nonce_set(sdk, owner, next_nonce)?;

events::Approval {
owner,
spender,
amount: value,
}
.emit(sdk)?;

Ok(0)
}

/// Returns ERC-20 `totalSupply()` as a 32-byte big-endian U256 word.
fn erc20_total_supply_handler<SDK: SystemAPI>(
sdk: &mut SDK,
Expand Down Expand Up @@ -573,6 +723,9 @@ pub fn main_entry<SDK: SystemAPI>(sdk: &mut SDK) -> Result<(), ExitCode> {
SIG_ERC20_BURN => erc20_burn_handler(sdk, input),
SIG_ERC20_PAUSE => erc20_pause_handler(sdk, input),
SIG_ERC20_UNPAUSE => erc20_unpause_handler(sdk, input),
SIG_ERC20_PERMIT => erc20_permit_handler(sdk, input),
SIG_ERC20_NONCES => erc20_nonces_handler(sdk, input),
SIG_ERC20_DOMAIN_SEPARATOR => erc20_domain_separator_handler(sdk, input),
_ => erc20_unknown_method(sdk, input),
}?;
if evm_exit_code != 0 {
Expand Down
24 changes: 23 additions & 1 deletion crates/sdk/src/universal_token/command.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::universal_token::consts::{
SIG_ERC20_ALLOWANCE, SIG_ERC20_APPROVE, SIG_ERC20_BALANCE_OF, SIG_ERC20_BURN, SIG_ERC20_MINT,
SIG_ERC20_TRANSFER, SIG_ERC20_TRANSFER_FROM,
SIG_ERC20_NONCES, SIG_ERC20_PERMIT, SIG_ERC20_TRANSFER, SIG_ERC20_TRANSFER_FROM,
};
use alloc::vec::Vec;
use fluentbase_codec::{Codec, Encoder, SolidityABI};
Expand Down Expand Up @@ -71,6 +71,14 @@ impl UniversalTokenCommand for BalanceOfCommand {
const SIGNATURE: u32 = SIG_ERC20_BALANCE_OF;
}

#[derive(Default, Debug, Codec)]
pub struct NoncesCommand {
pub owner: Address,
}
impl UniversalTokenCommand for NoncesCommand {
const SIGNATURE: u32 = SIG_ERC20_NONCES;
}

#[derive(Default, Debug, Codec)]
pub struct MintCommand {
pub to: Address,
Expand All @@ -88,3 +96,17 @@ pub struct BurnCommand {
impl UniversalTokenCommand for BurnCommand {
const SIGNATURE: u32 = SIG_ERC20_BURN;
}

#[derive(Default, Debug, Codec)]
pub struct PermitCommand {
pub owner: Address,
pub spender: Address,
pub value: U256,
pub deadline: U256,
pub v: u8,
pub r: U256,
pub s: U256,
}
impl UniversalTokenCommand for PermitCommand {
const SIGNATURE: u32 = SIG_ERC20_PERMIT;
}
24 changes: 23 additions & 1 deletion crates/sdk/src/universal_token/consts.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use fluentbase_sdk_derive::{derive_evm_error, derive_keccak256_id, erc7201_slot};
use fluentbase_sdk_derive::{
derive_evm_error, derive_keccak256, derive_keccak256_id, erc7201_slot,
};
use fluentbase_types::{EvmExitCode, U256};

// Custom UST (Universal Token Standard) error codes
Expand All @@ -7,6 +9,8 @@ pub const ERR_UST_NOT_PAUSABLE: EvmExitCode = derive_evm_error!("USTNotPausable(
pub const ERR_UST_PAUSER_MISMATCH: EvmExitCode = derive_evm_error!("USTPauserMismatch(address)");
pub const ERR_UST_NOT_MINTABLE: EvmExitCode = derive_evm_error!("USTNotMintable()");
pub const ERR_UST_MINTER_MISMATCH: EvmExitCode = derive_evm_error!("USTMinterMismatch(address)");
pub const ERR_UST_EXPIRED_DEADLINE: EvmExitCode = derive_evm_error!("USTExpiredDeadline()");
pub const ERR_UST_INVALID_SIGNATURE: EvmExitCode = derive_evm_error!("USTInvalidSignature()");

// These errors are compliant with: @openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol
pub const ERR_ERC20_INSUFFICIENT_BALANCE: EvmExitCode =
Expand Down Expand Up @@ -41,10 +45,22 @@ pub const SIG_ERC20_MINT: u32 = derive_keccak256_id!("mint(address,uint256)");
pub const SIG_ERC20_BURN: u32 = derive_keccak256_id!("burn(address,uint256)");
pub const SIG_ERC20_PAUSE: u32 = derive_keccak256_id!("pause()");
pub const SIG_ERC20_UNPAUSE: u32 = derive_keccak256_id!("unpause()");
pub const SIG_ERC20_PERMIT: u32 =
derive_keccak256_id!("permit(address,address,uint256,uint256,uint8,bytes32,bytes32)");
pub const SIG_ERC20_NONCES: u32 = derive_keccak256_id!("nonces(address)");
pub const SIG_ERC20_DOMAIN_SEPARATOR: u32 = derive_keccak256_id!("DOMAIN_SEPARATOR()");

// Not in use, reserved for future use
pub const SIG_TOKEN2022: u32 = derive_keccak256_id!("token2022()");

pub const EIP712_DOMAIN_TYPEHASH: [u8; 32] = derive_keccak256!(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
pub const EIP2612_PERMIT_TYPEHASH: [u8; 32] = derive_keccak256!(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
pub const EIP2612_VERSION_HASH: [u8; 32] = derive_keccak256!("1");

// Storage slots (all ERC7201 complaint)
pub const TOTAL_SUPPLY_STORAGE_SLOT: U256 = erc7201_slot!("universal-token.total-supply");
pub const MINTER_STORAGE_SLOT: U256 = erc7201_slot!("universal-token.minter");
Expand All @@ -54,6 +70,7 @@ pub const SYMBOL_STORAGE_SLOT: U256 = erc7201_slot!("universal-token.symbol");
pub const NAME_STORAGE_SLOT: U256 = erc7201_slot!("universal-token.name");
pub const DECIMALS_STORAGE_SLOT: U256 = erc7201_slot!("universal-token.decimals");
pub const ALLOWANCE_STORAGE_SLOT: U256 = erc7201_slot!("universal-token.allowance");
pub const NONCES_STORAGE_SLOT: U256 = erc7201_slot!("universal-token.nonces");
pub const BALANCE_STORAGE_SLOT: U256 = erc7201_slot!("universal-token.balance");

#[allow(unused)]
Expand All @@ -77,6 +94,8 @@ const _: () = assert_unique_u32([
ERR_UST_PAUSER_MISMATCH,
ERR_UST_NOT_MINTABLE,
ERR_UST_MINTER_MISMATCH,
ERR_UST_EXPIRED_DEADLINE,
ERR_UST_INVALID_SIGNATURE,
ERR_ERC20_INSUFFICIENT_BALANCE,
ERR_ERC20_INVALID_SENDER,
ERR_ERC20_INVALID_RECEIVER,
Expand All @@ -102,5 +121,8 @@ const _: () = assert_unique_u32([
SIG_ERC20_BURN,
SIG_ERC20_PAUSE,
SIG_ERC20_UNPAUSE,
SIG_ERC20_PERMIT,
SIG_ERC20_NONCES,
SIG_ERC20_DOMAIN_SEPARATOR,
SIG_TOKEN2022,
]);
24 changes: 20 additions & 4 deletions crates/sdk/src/universal_token/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ use crate::{
universal_token::{
command::{
AllowanceCommand, ApproveCommand, BalanceOfCommand, BurnCommand, MintCommand,
TransferCommand, TransferFromCommand, UniversalTokenCommand,
NoncesCommand, PermitCommand, TransferCommand, TransferFromCommand,
UniversalTokenCommand,
},
consts::{
ALLOWANCE_STORAGE_SLOT, BALANCE_STORAGE_SLOT, CONTRACT_FROZEN_STORAGE_SLOT,
DECIMALS_STORAGE_SLOT, MINTER_STORAGE_SLOT, NAME_STORAGE_SLOT, PAUSER_STORAGE_SLOT,
SIG_ERC20_ALLOWANCE, SIG_ERC20_APPROVE, SIG_ERC20_BALANCE, SIG_ERC20_BALANCE_OF,
SIG_ERC20_BURN, SIG_ERC20_DECIMALS, SIG_ERC20_MINT, SIG_ERC20_NAME, SIG_ERC20_PAUSE,
DECIMALS_STORAGE_SLOT, MINTER_STORAGE_SLOT, NAME_STORAGE_SLOT, NONCES_STORAGE_SLOT,
PAUSER_STORAGE_SLOT, SIG_ERC20_ALLOWANCE, SIG_ERC20_APPROVE, SIG_ERC20_BALANCE,
SIG_ERC20_BALANCE_OF, SIG_ERC20_BURN, SIG_ERC20_DECIMALS, SIG_ERC20_DOMAIN_SEPARATOR,
SIG_ERC20_MINT, SIG_ERC20_NAME, SIG_ERC20_NONCES, SIG_ERC20_PAUSE, SIG_ERC20_PERMIT,
SIG_ERC20_SYMBOL, SIG_ERC20_TOTAL_SUPPLY, SIG_ERC20_TRANSFER, SIG_ERC20_TRANSFER_FROM,
SIG_ERC20_UNPAUSE, SYMBOL_STORAGE_SLOT, TOTAL_SUPPLY_STORAGE_SLOT,
},
Expand Down Expand Up @@ -202,6 +204,20 @@ pub fn erc20_compute_main_storage_keys(input: &[u8], caller: &Address) -> Option
let allowance_slot = caller.compute_slot(ALLOWANCE_STORAGE_SLOT);
result.push(spender.compute_slot(allowance_slot));
}
SIG_ERC20_PERMIT => {
let PermitCommand { owner, spender, .. } = PermitCommand::try_decode(input).ok()?;
let allowance_slot = owner.compute_slot(ALLOWANCE_STORAGE_SLOT);
result.push(spender.compute_slot(allowance_slot));
result.push(owner.compute_slot(NONCES_STORAGE_SLOT));
result.push(NAME_STORAGE_SLOT);
}
SIG_ERC20_NONCES => {
let NoncesCommand { owner } = NoncesCommand::try_decode(input).ok()?;
result.push(owner.compute_slot(NONCES_STORAGE_SLOT));
}
SIG_ERC20_DOMAIN_SEPARATOR => {
result.push(NAME_STORAGE_SLOT);
}
SIG_ERC20_MINT => {
result.push(CONTRACT_FROZEN_STORAGE_SLOT);
let MintCommand { to, .. } = MintCommand::try_decode(input).ok()?;
Expand Down
Loading
Loading