diff --git a/bin/tempo-bench/src/cmd/max_tps/dex.rs b/bin/tempo-bench/src/cmd/max_tps/dex.rs index 63e0a3e900..213ad6751a 100644 --- a/bin/tempo-bench/src/cmd/max_tps/dex.rs +++ b/bin/tempo-bench/src/cmd/max_tps/dex.rs @@ -152,7 +152,7 @@ where let factory = ITIP20Factory::new(TIP20_FACTORY_ADDRESS, provider.clone()); let salt = alloy::primitives::B256::random(); let receipt = factory - .createToken( + .createToken_0( "Test".to_owned(), "TEST".to_owned(), "USD".to_owned(), diff --git a/crates/contracts/src/precompiles/tip20.rs b/crates/contracts/src/precompiles/tip20.rs index bcd9a5ca5b..acbbcf1458 100644 --- a/crates/contracts/src/precompiles/tip20.rs +++ b/crates/contracts/src/precompiles/tip20.rs @@ -85,6 +85,8 @@ crate::sol! { function supplyCap() external view returns (uint256); function paused() external view returns (bool); function transferPolicyId() external view returns (uint64); + function logoURI() external view returns (string memory); + function setLogoURI(string calldata newLogoURI) external; function burnBlocked(address from, uint256 amount) external; function mintWithMemo(address to, uint256 amount, bytes32 memo) external; function burnWithMemo(uint256 amount, bytes32 memo) external; @@ -149,6 +151,7 @@ crate::sol! { event QuoteTokenUpdate(address indexed updater, address indexed newQuoteToken); event RewardDistributed(address indexed funder, uint256 amount); event RewardRecipientSet(address indexed holder, address indexed recipient); + event LogoURIUpdated(address indexed updater, string newLogoURI); // Errors error InsufficientBalance(uint256 available, uint256 required, address token); @@ -170,6 +173,8 @@ crate::sol! { error InvalidTransferPolicyId(); error PermitExpired(); error InvalidSignature(); + error LogoURITooLong(); + error InvalidLogoURI(); } } @@ -310,6 +315,17 @@ impl TIP20Error { pub const fn invalid_signature() -> Self { Self::InvalidSignature(ITIP20::InvalidSignature {}) } + + /// Error when logoURI exceeds 256 bytes (TIP-1026) + pub const fn logo_uri_too_long() -> Self { + Self::LogoURITooLong(ITIP20::LogoURITooLong {}) + } + + /// Error when logoURI is not a syntactically valid URI or its scheme is + /// not in the protocol allowlist (TIP-1026). + pub const fn invalid_logo_uri() -> Self { + Self::InvalidLogoURI(ITIP20::InvalidLogoURI {}) + } } #[cfg(test)] diff --git a/crates/contracts/src/precompiles/tip20_factory.rs b/crates/contracts/src/precompiles/tip20_factory.rs index 2ec41bf508..491f6a487d 100644 --- a/crates/contracts/src/precompiles/tip20_factory.rs +++ b/crates/contracts/src/precompiles/tip20_factory.rs @@ -1,11 +1,13 @@ pub use ITIP20Factory::{ ITIP20FactoryErrors as TIP20FactoryError, ITIP20FactoryEvents as TIP20FactoryEvent, + createToken_0Call as createTokenCall, createToken_1Call as createTokenWithLogoCall, }; use alloy_primitives::Address; crate::sol! { #[derive(Debug, PartialEq, Eq)] #[sol(abi)] + #[allow(clippy::too_many_arguments)] interface ITIP20Factory { error AddressReserved(); error AddressNotReserved(); @@ -23,6 +25,21 @@ crate::sol! { bytes32 salt ) external returns (address); + /// @notice Creates a token and sets its logoURI atomically (TIP-1026). + /// @dev Solidity overload of `createToken` with an additional `logoURI` argument. + /// Reverts with `LogoURITooLong` if `bytes(logoURI).length > 256`, or + /// with `InvalidLogoURI` if `logoURI` is non-empty and either has no + /// parseable scheme or its scheme is not in the allow-list. + function createToken( + string memory name, + string memory symbol, + string memory currency, + address quoteToken, + address admin, + bytes32 salt, + string memory logoURI + ) external returns (address); + function isTIP20(address token) public view returns (bool); function getTokenAddress(address sender, bytes32 salt) public pure returns (address); diff --git a/crates/node/tests/it/block_building.rs b/crates/node/tests/it/block_building.rs index b474ca01d1..a96a32ffcf 100644 --- a/crates/node/tests/it/block_building.rs +++ b/crates/node/tests/it/block_building.rs @@ -57,7 +57,7 @@ where // Create token let salt = B256::random(); - let create_tx = factory.createToken( + let create_tx = factory.createToken_0( "Test".to_string(), "TEST".to_string(), "USD".to_string(), diff --git a/crates/node/tests/it/tip20_factory.rs b/crates/node/tests/it/tip20_factory.rs index 9886802b38..05d35b146d 100644 --- a/crates/node/tests/it/tip20_factory.rs +++ b/crates/node/tests/it/tip20_factory.rs @@ -31,7 +31,7 @@ async fn test_create_token() -> eyre::Result<()> { let balance = provider.get_account_info(caller).await?.balance; assert_eq!(balance, U256::ZERO); let receipt = factory - .createToken( + .createToken_0( "Test".to_string(), "TEST".to_string(), "USD".to_string(), diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index 51773b115f..fb88677e0a 100644 --- a/crates/node/tests/it/utils.rs +++ b/crates/node/tests/it/utils.rs @@ -171,7 +171,7 @@ where let factory = ITIP20Factory::new(TIP20_FACTORY_ADDRESS, provider.clone()); let salt = B256::random(); let receipt = factory - .createToken( + .createToken_0( "Test".to_string(), "TEST".to_string(), "USD".to_string(), diff --git a/crates/precompiles/src/test_util.rs b/crates/precompiles/src/test_util.rs index 377a93e812..01c3b9cbba 100644 --- a/crates/precompiles/src/test_util.rs +++ b/crates/precompiles/src/test_util.rs @@ -304,7 +304,7 @@ impl TIP20Setup { let salt = self.salt.unwrap_or_else(B256::random); let token_address = factory.create_token( admin, - tip20_factory::ITIP20Factory::createTokenCall { + tip20_factory::createTokenCall { name: name.to_string(), symbol: symbol.to_string(), currency, diff --git a/crates/precompiles/src/tip20/dispatch.rs b/crates/precompiles/src/tip20/dispatch.rs index 18897e8de0..35253e74f6 100644 --- a/crates/precompiles/src/tip20/dispatch.rs +++ b/crates/precompiles/src/tip20/dispatch.rs @@ -20,6 +20,12 @@ const T2_ADDED: &[[u8; 4]] = &[ ITIP20::DOMAIN_SEPARATORCall::SELECTOR, ]; +/// Selectors added at T5: TIP-1026 Token Logo URI. +const T5_ADDED: &[[u8; 4]] = &[ + ITIP20::logoURICall::SELECTOR, + ITIP20::setLogoURICall::SELECTOR, +]; + /// Decoded call variant — either a TIP-20 token call or a role-management call. enum TIP20Call { TIP20(ITIP20Calls), @@ -57,7 +63,10 @@ impl Precompile for TIP20Token { dispatch_call( calldata, - &[SelectorSchedule::new(TempoHardfork::T2).with_added(T2_ADDED)], + &[ + SelectorSchedule::new(TempoHardfork::T2).with_added(T2_ADDED), + SelectorSchedule::new(TempoHardfork::T5).with_added(T5_ADDED), + ], TIP20Call::decode, |call| match call { // Metadata functions (no calldata decoding needed) @@ -85,6 +94,9 @@ impl Precompile for TIP20Token { TIP20Call::TIP20(ITIP20Calls::paused(_)) => { metadata::(|| self.paused()) } + TIP20Call::TIP20(ITIP20Calls::logoURI(_)) => { + metadata::(|| self.logo_uri()) + } // View functions TIP20Call::TIP20(ITIP20Calls::balanceOf(call)) => { @@ -128,6 +140,9 @@ impl Precompile for TIP20Token { TIP20Call::TIP20(ITIP20Calls::setSupplyCap(call)) => { mutate_void(call, msg_sender, |s, c| self.set_supply_cap(s, c)) } + TIP20Call::TIP20(ITIP20Calls::setLogoURI(call)) => { + mutate_void(call, msg_sender, |s, c| self.set_logo_uri(s, c)) + } TIP20Call::TIP20(ITIP20Calls::pause(call)) => { mutate_void(call, msg_sender, |s, c| self.pause(s, c)) } @@ -755,8 +770,8 @@ mod tests { use crate::test_util::{assert_full_coverage, check_selector_coverage}; use tempo_contracts::precompiles::{IRolesAuth::IRolesAuthCalls, ITIP20::ITIP20Calls}; - // Use T2 hardfork so T2-gated selectors (permit, nonces, DOMAIN_SEPARATOR) are active - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T2); + // Use T5 hardfork so all selectors are active. + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); let admin = Address::random(); StorageCtx::enter(&mut storage, || { @@ -779,6 +794,63 @@ mod tests { }) } + #[test] + fn test_logo_uri_selectors_gated_behind_t5() -> eyre::Result<()> { + // Pre-T5: logoURI/setLogoURI should return unknown selector. + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + let admin = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + // logoURI selector is gated + let logo_uri_calldata = ITIP20::logoURICall {}.abi_encode(); + let result = token.call(&logo_uri_calldata, admin)?; + assert!(result.is_revert()); + assert!(UnknownFunctionSelector::abi_decode(&result.bytes).is_ok()); + + // setLogoURI selector is gated + let set_logo_uri_calldata = ITIP20::setLogoURICall { + newLogoURI: "https://example.com/icon.svg".to_string(), + } + .abi_encode(); + let result = token.call(&set_logo_uri_calldata, admin)?; + assert!(result.is_revert()); + assert!(UnknownFunctionSelector::abi_decode(&result.bytes).is_ok()); + + Ok(()) + }) + } + + #[test] + fn test_logo_uri_pre_t5_deploy_post_t5_read_returns_empty() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + let admin = Address::random(); + let token_address = StorageCtx::enter(&mut storage, || -> eyre::Result
{ + let token = TIP20Setup::create("Test", "TST", admin).apply()?; + Ok(token.address()) + })?; + + // Activate T5; the token deployed under T4 is now read under T5. + storage.set_spec(TempoHardfork::T5); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Token::from_address(token_address)?; + + // Direct accessor: empty by default for pre-T5-deployed tokens. + assert_eq!(token.logo_uri()?, ""); + + // ABI-level: the previously-gated selector now dispatches and returns "". + let calldata = ITIP20::logoURICall {}.abi_encode(); + let result = token.call(&calldata, admin)?; + assert!(!result.is_revert(), "logoURI() must succeed post-T5"); + let decoded = ITIP20::logoURICall::abi_decode_returns(&result.bytes)?; + assert_eq!(decoded, ""); + + Ok(()) + }) + } + #[test] fn test_permit_selectors_gated_behind_t2() -> eyre::Result<()> { // Pre-T2: permit/nonces/DOMAIN_SEPARATOR should return unknown selector diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 960721a27b..866f463264 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -80,8 +80,12 @@ pub struct TIP20Token { name: String, symbol: String, currency: String, - // Unused slot, kept for storage layout compatibility - _domain_separator: B256, + // TIP-1026: Token Logo URI. + // Reuses the previously-unused `_domain_separator` slot (always 0 on + // pre-T5 tokens), which reads as the empty string under Solidity's + // short-string encoding — matching the spec's "default empty" semantics. + // Assumes the slot was never written; do not write to it from pre-T5 code. + logo_uri: String, quote_token: Address, next_quote_token: Address, transfer_policy_id: u64, @@ -145,6 +149,13 @@ impl TIP20Token { self.currency.read() } + /// Returns the logo URI for this token (TIP-1026). + /// + /// Returns an empty string if not set. + pub fn logo_uri(&self) -> Result { + self.logo_uri.read() + } + /// Returns the current total supply. pub fn total_supply(&self) -> Result { self.total_supply.read() @@ -274,6 +285,90 @@ impl TIP20Token { })) } + // ========== TIP-1026: Logo URI ========== + + /// Maximum byte length of a token logo URI (TIP-1026). + pub const MAX_LOGO_URI_BYTES: usize = 256; + + /// Allowlist of ASCII-case-insensitive URI schemes accepted for [`Self::set_logo_uri`]. + /// + /// TIP-1026 guarantees that the protocol validates the scheme prefix to make integration easier + /// and reject obviously dangerous values (e.g. `javascript:`). What the consumer does with the URI + /// afterwards (rendering, fetching, etc.) is out of scope and remains the consumer's responsibility. + pub const ALLOWED_LOGO_URI_SCHEMES: &'static [&'static str] = + &["https", "http", "ipfs", "data"]; + + /// Validates a logo URI against the TIP-1026 protocol rules: + /// - length ≤ [`Self::MAX_LOGO_URI_BYTES`] + /// - syntactically well-formed URI schemes in [`Self::ALLOWED_LOGO_URI_SCHEMES`]. + /// + /// Empty strings are accepted unconditionally. + pub(crate) fn validate_logo_uri(uri: &str) -> Result<()> { + if uri.len() > Self::MAX_LOGO_URI_BYTES { + return Err(TIP20Error::logo_uri_too_long().into()); + } + if !uri.is_empty() && !Self::is_allowed_logo_uri(uri) { + return Err(TIP20Error::invalid_logo_uri().into()); + } + Ok(()) + } + + fn is_allowed_logo_uri(uri: &str) -> bool { + let Some((scheme, _rest)) = uri.split_once(':') else { + return false; + }; + + let mut bytes = scheme.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + if !first.is_ascii_alphabetic() { + return false; + } + if !bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')) { + return false; + } + + Self::ALLOWED_LOGO_URI_SCHEMES + .iter() + .any(|allowed| scheme.eq_ignore_ascii_case(allowed)) + } + + /// Sets the logo URI for this token (TIP-1026). Empty strings are valid + /// and clear the URI. + /// + /// # Errors + /// - `Unauthorized` — caller does not hold `DEFAULT_ADMIN_ROLE` + /// - `LogoURITooLong` — `bytes(newLogoURI).length > 256` + /// - `InvalidLogoURI` — `newLogoURI` is non-empty and either has no + /// parseable scheme (RFC 3986 §3.1) or its scheme is not in + /// [`Self::ALLOWED_LOGO_URI_SCHEMES`] + pub fn set_logo_uri( + &mut self, + msg_sender: Address, + call: ITIP20::setLogoURICall, + ) -> Result<()> { + self.check_role(msg_sender, DEFAULT_ADMIN_ROLE)?; + self.write_logo_uri(msg_sender, call.newLogoURI) + } + + /// Internal helper: runs [`Self::validate_logo_uri`] (length cap + scheme allowlist), stores the + /// value, and emits `LogoURIUpdated`. + /// + /// **IMPORTANT:** this function performs NO role check. It is the caller's responsibility. + pub(crate) fn write_logo_uri(&mut self, updater: Address, new_logo_uri: String) -> Result<()> { + Self::validate_logo_uri(&new_logo_uri)?; + + self.logo_uri.write(new_logo_uri.clone())?; + + self.emit_event(TIP20Event::LogoURIUpdated(ITIP20::LogoURIUpdated { + updater, + newLogoURI: new_logo_uri, + })) + } + + // ========== End TIP-1026 ========== + /// Pauses all token transfers. /// /// # Errors @@ -1317,7 +1412,7 @@ mod recipient_tests { #[cfg(test)] pub(crate) mod tests { use alloy::primitives::{Address, FixedBytes, IntoLogData, U256, hex}; - use tempo_contracts::precompiles::ITIP20Factory; + use tempo_contracts::precompiles::createTokenCall; use super::*; use crate::{ @@ -2115,6 +2210,191 @@ pub(crate) mod tests { }) } + #[test] + fn test_validate_logo_uri() { + const MAX: usize = TIP20Token::MAX_LOGO_URI_BYTES; + + // Valid: empty, all allowlisted schemes (case-insensitive), and exactly at the 256-byte cap. + let prefix = "https://example.com/"; + let at_cap = format!("{prefix}{}", "a".repeat(MAX - prefix.len())); + assert_eq!(at_cap.len(), MAX); + for ok in [ + "", + "https://example.com/icon.svg", + "http://example.com/icon.png", + "ipfs://QmXfzKRvjZz3u5JRgC4v5mGVbm9ahrUiB4DgzHBsnWbTMM", + "data:image/svg+xml;base64,PHN2Zy8+", + "HTTPS://example.com/ICON.svg", + "IPFS://Qm123", + &at_cap, + ] { + assert!( + TIP20Token::validate_logo_uri(ok).is_ok(), + "expected Ok for {ok:?}" + ); + } + + // 257 bytes — one over the limit. Use a syntactically valid URI so + // we exercise the length check, not the URI/scheme check. + let too_long = format!("{prefix}{}", "a".repeat(MAX + 1 - prefix.len())); + assert_eq!(too_long.len(), MAX + 1); + assert!(matches!( + TIP20Token::validate_logo_uri(&too_long), + Err(TempoPrecompileError::TIP20(TIP20Error::LogoURITooLong(_))), + )); + + // Disallowed schemes and malformed URIs + for bad in [ + "javascript:alert(1)", + "file:///etc/passwd", + "ftp://x.test/icon.png", + "no-scheme-here", + "://missing-scheme.test", + "1https://digit-leading.test", + ":empty-scheme", + " https://leading-space.test", + ] { + assert!( + matches!( + TIP20Token::validate_logo_uri(bad), + Err(TempoPrecompileError::TIP20(TIP20Error::InvalidLogoURI(_))), + ), + "expected InvalidLogoURI for {bad:?}" + ); + } + } + + #[test] + fn test_set_logo_uri_non_admin_reverts() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + let non_admin = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + let result = token.set_logo_uri( + non_admin, + ITIP20::setLogoURICall { + newLogoURI: "https://example.com/icon.svg".to_string(), + }, + ); + + assert!(matches!( + result, + Err(TempoPrecompileError::RolesAuthError( + RolesAuthError::Unauthorized(_) + )) + )); + + // logoURI should remain unchanged (empty) + assert_eq!(token.logo_uri()?, ""); + + Ok(()) + }) + } + + #[test] + fn test_set_logo_uri_too_long_reverts() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + // 257 bytes — one over the limit. Use a syntactically valid URI + // so we exercise the length check, not the URI/scheme check. + let prefix = "https://example.com/"; + let too_long = format!("{prefix}{}", "a".repeat(257 - prefix.len())); + assert_eq!(too_long.len(), 257); + let result = token.set_logo_uri( + admin, + ITIP20::setLogoURICall { + newLogoURI: too_long, + }, + ); + + assert!(matches!( + result, + Err(TempoPrecompileError::TIP20(TIP20Error::LogoURITooLong(_))) + )); + + // 256 bytes — at the limit, should succeed + let at_limit = format!("{prefix}{}", "a".repeat(256 - prefix.len())); + assert_eq!(at_limit.len(), 256); + token.set_logo_uri( + admin, + ITIP20::setLogoURICall { + newLogoURI: at_limit.clone(), + }, + )?; + assert_eq!(token.logo_uri()?, at_limit); + + Ok(()) + }) + } + + #[test] + fn test_set_logo_uri_writes_and_emits() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin) + .clear_events() + .apply()?; + + // Default is empty for a freshly-created token. + assert_eq!(token.logo_uri()?, ""); + + let uri = "https://example.com/icon.svg".to_string(); + token.set_logo_uri( + admin, + ITIP20::setLogoURICall { + newLogoURI: uri.clone(), + }, + )?; + + assert_eq!(token.logo_uri()?, uri); + + token.assert_emitted_events(vec![TIP20Event::LogoURIUpdated(ITIP20::LogoURIUpdated { + updater: admin, + newLogoURI: uri, + })]); + + Ok(()) + }) + } + + #[test] + fn test_set_logo_uri_empty_clears() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin).apply()?; + + token.set_logo_uri( + admin, + ITIP20::setLogoURICall { + newLogoURI: "https://example.com/icon.svg".to_string(), + }, + )?; + assert_eq!(token.logo_uri()?, "https://example.com/icon.svg"); + + // Empty string is still valid (clears the URI per spec). + token.set_logo_uri( + admin, + ITIP20::setLogoURICall { + newLogoURI: String::new(), + }, + )?; + assert_eq!(token.logo_uri()?, ""); + + Ok(()) + }) + } + #[test] fn test_from_address() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new(1); @@ -2256,7 +2536,7 @@ pub(crate) mod tests { let created_tip20 = TIP20Factory::new().create_token( sender, - ITIP20Factory::createTokenCall { + createTokenCall { name: "Test Token".to_string(), symbol: "TEST".to_string(), currency: "USD".to_string(), diff --git a/crates/precompiles/src/tip20_factory/dispatch.rs b/crates/precompiles/src/tip20_factory/dispatch.rs index 58432a54f3..d8a97db9ec 100644 --- a/crates/precompiles/src/tip20_factory/dispatch.rs +++ b/crates/precompiles/src/tip20_factory/dispatch.rs @@ -1,11 +1,19 @@ //! ABI dispatch for the [`TIP20Factory`] precompile. use crate::{ - Precompile, charge_input_cost, dispatch_call, mutate, tip20_factory::TIP20Factory, view, + Precompile, SelectorSchedule, charge_input_cost, dispatch_call, mutate, + tip20_factory::TIP20Factory, view, +}; +use alloy::{ + primitives::Address, + sol_types::{SolCall, SolInterface}, }; -use alloy::{primitives::Address, sol_types::SolInterface}; use revm::precompile::PrecompileResult; -use tempo_contracts::precompiles::ITIP20Factory::ITIP20FactoryCalls; +use tempo_chainspec::hardfork::TempoHardfork; +use tempo_contracts::precompiles::{ITIP20Factory::ITIP20FactoryCalls, createTokenWithLogoCall}; + +/// Selectors added at T5: TIP-1026 Token Logo URI factory overload. +const T5_ADDED: &[[u8; 4]] = &[createTokenWithLogoCall::SELECTOR]; impl Precompile for TIP20Factory { fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult { @@ -15,12 +23,15 @@ impl Precompile for TIP20Factory { dispatch_call( calldata, - &[], + &[SelectorSchedule::new(TempoHardfork::T5).with_added(T5_ADDED)], ITIP20FactoryCalls::abi_decode, |call| match call { - ITIP20FactoryCalls::createToken(call) => { + ITIP20FactoryCalls::createToken_0(call) => { mutate(call, msg_sender, |s, c| self.create_token(s, c)) } + ITIP20FactoryCalls::createToken_1(call) => { + mutate(call, msg_sender, |s, c| self.create_token_with_logo(s, c)) + } ITIP20FactoryCalls::isTIP20(call) => view(call, |c| self.is_tip20(c.token)), ITIP20FactoryCalls::getTokenAddress(call) => { view(call, |c| self.get_token_address(c)) @@ -37,11 +48,19 @@ mod tests { storage::{StorageCtx, hashmap::HashMapStorageProvider}, test_util::{assert_full_coverage, check_selector_coverage}, }; - use tempo_contracts::precompiles::ITIP20Factory::ITIP20FactoryCalls; + use alloy::{ + primitives::B256, + sol_types::{SolCall, SolError}, + }; + use tempo_chainspec::hardfork::TempoHardfork; + use tempo_contracts::precompiles::{ + ITIP20Factory::ITIP20FactoryCalls, UnknownFunctionSelector, + }; #[test] fn tip20_factory_test_selector_coverage() { - let mut storage = HashMapStorageProvider::new(1); + // Use T5 hardfork so T5-gated `createTokenWithLogo` selector is active. + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); StorageCtx::enter(&mut storage, || { let mut factory = TIP20Factory::new(); @@ -56,4 +75,32 @@ mod tests { assert_full_coverage([unsupported]); }) } + + #[test] + fn test_create_token_with_logo_gated_behind_t5() -> eyre::Result<()> { + // Pre-T5: createTokenWithLogo should return unknown selector. + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + let sender = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut factory = TIP20Factory::new(); + + let calldata = createTokenWithLogoCall { + name: "Logo".to_string(), + symbol: "LOGO".to_string(), + currency: "USD".to_string(), + quoteToken: Address::ZERO, + admin: sender, + salt: B256::ZERO, + logoURI: String::new(), + } + .abi_encode(); + + let result = factory.call(&calldata, sender)?; + assert!(result.is_revert()); + assert!(UnknownFunctionSelector::abi_decode(&result.bytes).is_ok()); + + Ok(()) + }) + } } diff --git a/crates/precompiles/src/tip20_factory/mod.rs b/crates/precompiles/src/tip20_factory/mod.rs index 88b7e80f84..b723737d04 100644 --- a/crates/precompiles/src/tip20_factory/mod.rs +++ b/crates/precompiles/src/tip20_factory/mod.rs @@ -4,7 +4,9 @@ pub mod dispatch; -pub use tempo_contracts::precompiles::{ITIP20Factory, TIP20FactoryError, TIP20FactoryEvent}; +pub use tempo_contracts::precompiles::{ + ITIP20Factory, TIP20FactoryError, TIP20FactoryEvent, createTokenCall, createTokenWithLogoCall, +}; use tempo_precompiles_macros::contract; use crate::{ @@ -101,11 +103,7 @@ impl TIP20Factory { /// - `TokenAlreadyExists` — a TIP-20 is already deployed at the derived address /// - `InvalidQuoteToken` — quote token is not a deployed TIP-20 or has incompatible currency /// - `AddressReserved` — the derived address is in the reserved range - pub fn create_token( - &mut self, - sender: Address, - call: ITIP20Factory::createTokenCall, - ) -> Result
{ + pub fn create_token(&mut self, sender: Address, call: createTokenCall) -> Result
{ trace!(%sender, ?call, "Create token"); // Compute the deterministic address from sender and salt @@ -160,6 +158,45 @@ impl TIP20Factory { Ok(token_address) } + /// Creates a token and atomically sets its `logoURI` (TIP-1026). + /// + /// Behaves identically to [`Self::create_token`] plus, when `logoURI` is + /// non-empty, writes the URI to the new token's storage and emits + /// `LogoURIUpdated` from the new token's address with `updater = sender`. + /// + /// # Errors + /// - All errors from [`Self::create_token`] + /// - `LogoURITooLong` — `bytes(logoURI).length > 256` + /// - `InvalidLogoURI` — `logoURI` is non-empty and fails validation + pub fn create_token_with_logo( + &mut self, + sender: Address, + call: createTokenWithLogoCall, + ) -> Result
{ + // Validate the logo URI up-front so a bad URI does not leave a partially-created token. + if !call.logoURI.is_empty() { + crate::tip20::TIP20Token::validate_logo_uri(&call.logoURI)?; + } + + let token_address = self.create_token( + sender, + createTokenCall { + name: call.name, + symbol: call.symbol, + currency: call.currency, + quoteToken: call.quoteToken, + admin: call.admin, + salt: call.salt, + }, + )?; + + if !call.logoURI.is_empty() { + TIP20Token::from_address(token_address)?.write_logo_uri(sender, call.logoURI)?; + } + + Ok(token_address) + } + /// Deploys a TIP-20 token at a reserved address (lower 8 bytes < `RESERVED_SIZE`). Used /// during genesis or hardforks to bootstrap protocol tokens like pathUSD. /// @@ -363,7 +400,7 @@ mod tests { let salt1 = B256::random(); let salt2 = B256::random(); - let call1 = ITIP20Factory::createTokenCall { + let call1 = createTokenCall { name: "Test Token 1".to_string(), symbol: "TEST1".to_string(), currency: "USD".to_string(), @@ -371,7 +408,7 @@ mod tests { admin: sender, salt: salt1, }; - let call2 = ITIP20Factory::createTokenCall { + let call2 = createTokenCall { name: "Test Token 2".to_string(), symbol: "TEST2".to_string(), currency: "USD".to_string(), @@ -420,6 +457,186 @@ mod tests { }) } + #[test] + fn test_create_token_selector_and_event_unchanged() { + use alloy::sol_types::{SolCall, SolEvent}; + + assert_eq!( + createTokenCall::SELECTOR, + [0x68, 0x13, 0x04, 0x45], + "createToken selector must remain 0x68130445" + ); + + assert_eq!( + ITIP20Factory::TokenCreated::SIGNATURE_HASH, + alloy::primitives::b256!( + "44f7b8011db3e3647a530b4ff635726de5fafc8fa8ad10f0f31c0eb9dd52fc65" + ), + "TokenCreated topic0 must remain unchanged" + ); + } + + #[test] + fn test_create_token_with_logo() -> eyre::Result<()> { + use alloy::sol_types::SolEvent; + use tempo_contracts::precompiles::ITIP20; + + let mut storage = HashMapStorageProvider::new(1); + let sender = Address::random(); + // Use a distinct `admin` to lock in the spec-mandated + // `updater = msg.sender` semantics for the deploy-time + // `LogoURIUpdated` event (TIP-1026). + let admin = Address::random(); + assert_ne!(sender, admin); + + StorageCtx::enter(&mut storage, || { + let mut factory = TIP20Setup::factory()?; + let path_usd = TIP20Setup::path_usd(sender).apply()?; + factory.clear_emitted_events(); + + let salt = B256::random(); + let logo_uri = "https://example.com/icon.svg".to_string(); + let call = createTokenWithLogoCall { + name: "Logo Token".to_string(), + symbol: "LOGO".to_string(), + currency: "USD".to_string(), + quoteToken: path_usd.address(), + admin, + salt, + logoURI: logo_uri.clone(), + }; + + let token_addr = factory.create_token_with_logo(sender, call.clone())?; + + // Token deployed correctly + assert!(token_addr.is_tip20()); + assert!(factory.is_tip20(token_addr)?); + + // logoURI is stored on the new token + let token = TIP20Token::from_address(token_addr)?; + assert_eq!(token.logo_uri()?, logo_uri); + + // The deploy-time LogoURIUpdated event uses `updater = msg.sender` + // per TIP-1026. + let logo_topic = ITIP20::LogoURIUpdated::SIGNATURE_HASH; + let logo_event = token + .emitted_events() + .iter() + .find(|e| e.topics().first() == Some(&logo_topic)) + .expect("LogoURIUpdated event missing") + .clone(); + let decoded = ITIP20::LogoURIUpdated::decode_log_data(&logo_event).expect("decode log"); + assert_eq!(decoded.updater, sender); + assert_eq!(decoded.newLogoURI, logo_uri); + + // Factory emits TokenCreated (unchanged signature) + factory.assert_emitted_events(vec![TIP20FactoryEvent::TokenCreated( + ITIP20Factory::TokenCreated { + token: token_addr, + name: call.name, + symbol: call.symbol, + currency: call.currency, + quoteToken: call.quoteToken, + admin: call.admin, + salt: call.salt, + }, + )]); + + Ok(()) + }) + } + + #[test] + fn test_create_token_with_logo_empty_uri_skips_event() -> eyre::Result<()> { + use alloy::sol_types::SolEvent; + use tempo_contracts::precompiles::ITIP20; + + let mut storage = HashMapStorageProvider::new(1); + let sender = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut factory = TIP20Setup::factory()?; + let path_usd = TIP20Setup::path_usd(sender).apply()?; + factory.clear_emitted_events(); + + let token_addr = factory.create_token_with_logo( + sender, + createTokenWithLogoCall { + name: "Empty Logo".to_string(), + symbol: "EMPTY".to_string(), + currency: "USD".to_string(), + quoteToken: path_usd.address(), + admin: sender, + salt: B256::random(), + logoURI: String::new(), + }, + )?; + + // logoURI remains the default (empty) + let token = TIP20Token::from_address(token_addr)?; + assert_eq!(token.logo_uri()?, ""); + + // No LogoURIUpdated event was emitted on the new token + let logo_topic = ITIP20::LogoURIUpdated::SIGNATURE_HASH; + assert!( + !token + .emitted_events() + .iter() + .any(|e| e.topics().first() == Some(&logo_topic)), + "LogoURIUpdated should not be emitted when logoURI is empty" + ); + + Ok(()) + }) + } + + #[test] + fn test_create_token_with_logo_rejects_atomically() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let sender = Address::random(); + + StorageCtx::enter(&mut storage, || { + let mut factory = TIP20Setup::factory()?; + let path_usd = TIP20Setup::path_usd(sender).apply()?; + let salt = B256::random(); + + let call = |logo_uri: &str| createTokenWithLogoCall { + name: "Tok".to_string(), + symbol: "TOK".to_string(), + currency: "USD".to_string(), + quoteToken: path_usd.address(), + admin: sender, + salt, + logoURI: logo_uri.to_string(), + }; + + // (a1) Length cap: 257 bytes — one over the limit. Valid scheme + // so we exercise the length check, not the URI/scheme check. + let prefix = "https://example.com/"; + let too_long = format!("{prefix}{}", "a".repeat(257 - prefix.len())); + assert_eq!(too_long.len(), 257); + assert!(matches!( + factory.create_token_with_logo(sender, call(&too_long)), + Err(TempoPrecompileError::TIP20(TIP20Error::LogoURITooLong(_))) + )); + + // (a2) Disallowed scheme — `javascript:` is the canonical example + // from the spec's security considerations. + assert!(matches!( + factory.create_token_with_logo(sender, call("javascript:alert(1)")), + Err(TempoPrecompileError::TIP20(TIP20Error::InvalidLogoURI(_))) + )); + + // (b) Atomicity: the same salt is reusable with a valid URI, + // proving no partial token was left behind by either rejection. + let token = + factory.create_token_with_logo(sender, call("https://example.com/icon.svg"))?; + assert!(factory.is_tip20(token)?); + + Ok(()) + }) + } + #[test] fn test_create_token_invalid_quote_token() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new(1); @@ -428,7 +645,7 @@ mod tests { let mut factory = TIP20Setup::factory()?; TIP20Setup::path_usd(sender).apply()?; - let invalid_call = ITIP20Factory::createTokenCall { + let invalid_call = createTokenCall { name: "Test Token".to_string(), symbol: "TEST".to_string(), currency: "USD".to_string(), @@ -457,7 +674,7 @@ mod tests { .currency("EUR") .apply()?; - let invalid_call = ITIP20Factory::createTokenCall { + let invalid_call = createTokenCall { name: "USD Token".to_string(), symbol: "USDT".to_string(), currency: "USD".to_string(), @@ -486,7 +703,7 @@ mod tests { // Create an address with TIP20 prefix but no code let non_existent_tip20 = Address::from(alloy::hex!("20C0000000000000000000000000000000009999")); - let invalid_call = ITIP20Factory::createTokenCall { + let invalid_call = createTokenCall { name: "Test Token".to_string(), symbol: "TEST".to_string(), currency: "USD".to_string(), @@ -513,7 +730,7 @@ mod tests { TIP20Setup::path_usd(sender).apply()?; let salt = B256::random(); - let create_token_call = ITIP20Factory::createTokenCall { + let create_token_call = createTokenCall { name: "Test Token".to_string(), symbol: "TEST".to_string(), currency: "USD".to_string(), diff --git a/crates/precompiles/tests/storage_tests/solidity/precompiles.rs b/crates/precompiles/tests/storage_tests/solidity/precompiles.rs index 8792627138..fedf2c09ca 100644 --- a/crates/precompiles/tests/storage_tests/solidity/precompiles.rs +++ b/crates/precompiles/tests/storage_tests/solidity/precompiles.rs @@ -155,8 +155,8 @@ fn test_tip20_layout() { name, symbol, currency, - // Unused slot, kept for storage layout compatibility - _domain_separator, + // TIP-1026: token logo URI (reuses the previously-unused _domain_separator slot) + logo_uri, quote_token, next_quote_token, transfer_policy_id, @@ -342,8 +342,8 @@ fn export_all_storage_constants() { name, symbol, currency, - // Unused slot, kept for storage layout compatibility - _domain_separator, + // TIP-1026: token logo URI (reuses the previously-unused _domain_separator slot) + logo_uri, quote_token, next_quote_token, transfer_policy_id, 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 d10f96d9aa..602f881cc1 100644 --- a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json +++ b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json @@ -46,10 +46,10 @@ { "astId": 40, "contract": "tests/storage_tests/solidity/testdata/tip20.sol:TIP20", - "label": "domainSeparator", + "label": "logoUri", "offset": 0, "slot": "5", - "type": "t_bytes32" + "type": "t_string_storage" }, { "astId": 42, diff --git a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol index 5590c58aff..e33f2b65ac 100644 --- a/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol +++ b/crates/precompiles/tests/storage_tests/solidity/testdata/tip20.sol @@ -33,8 +33,8 @@ contract TIP20 { string public name; string public symbol; string public currency; - // Unused slot, kept for storage layout compatibility - bytes32 public domainSeparator; + /// Token logo URI (TIP-1026, reuses the previously-unused `domainSeparator` slot) + string public logoUri; address public quoteToken; address public nextQuoteToken; uint64 public transferPolicyId; diff --git a/tips/verify/lib/tempo-std b/tips/verify/lib/tempo-std index ae53fadbdf..bc33eda02d 160000 --- a/tips/verify/lib/tempo-std +++ b/tips/verify/lib/tempo-std @@ -1 +1 @@ -Subproject commit ae53fadbdf140b808ea58115882938cc3372009d +Subproject commit bc33eda02d96f86dd4592bef2c98d56ff13e7e27 diff --git a/tips/verify/test/invariants/README.md b/tips/verify/test/invariants/README.md index 3bb436c4a9..aad6d436e9 100644 --- a/tips/verify/test/invariants/README.md +++ b/tips/verify/test/invariants/README.md @@ -602,3 +602,19 @@ The SignatureVerifier precompile (`0x5165300000000000000000000000000000000000`) - **TEMPO-VA14**: Policy-on-master semantics - recipient and mint-recipient authorization is evaluated on the resolved master, not the alias. - **TEMPO-VA15**: Policy-operation rejection - TIP-403 configuration APIs reject virtual aliases as literal policy members. - **TEMPO-VA16**: Reward-recipient rejection - `setRewardRecipient` rejects virtual aliases. + +## TIP-1026 Token Logo URI + +TIP-1026 adds a `logoURI` field to TIP-20 tokens — mutable by the token admin (`setLogoURI`) or set at deploy time via the new 7-arg `createToken` overload — capped at 256 bytes and validated against a scheme allowlist (`https`, `http`, `ipfs`, `data`, ASCII-case-insensitive). + +### Global Invariants + +- **TEMPO-1026-1**: `bytes(logoURI()).length <= 256` for every TIP-20 token, after every fuzz run. +- **TEMPO-1026-2**: The legacy 6-argument `createToken` selector (`0x68130445`) and the `TokenCreated` event signature hash are unchanged by this TIP. Asserted as one-shot constants in `setUp`. + +### Per-Handler Assertions + +These verify correct behavior when the specific function is called: + +- **TEMPO-1026-1**: `setLogoURI` reverts with `LogoURITooLong` when `bytes(newLogoURI).length > 256`; reverts with `InvalidLogoURI` when the URI is non-empty and either has no parseable scheme (RFC 3986 §3.1) or its scheme is not in the allowlist; reverts with `Unauthorized` for non-admin callers; on success, persists the URI with length ≤ 256 bytes. +- **TEMPO-1026 factory**: the 7-arg `createToken` overload validates `logoURI` atomically — a rejected URI must revert and leave the predicted address undeployed. On success, the new token is deployed at the address returned by `getTokenAddress` and `logoURI()` returns the supplied value. diff --git a/tips/verify/test/invariants/TIP1026.t.sol b/tips/verify/test/invariants/TIP1026.t.sol new file mode 100644 index 0000000000..464099d6cd --- /dev/null +++ b/tips/verify/test/invariants/TIP1026.t.sol @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import { InvariantBaseTest } from "./InvariantBaseTest.t.sol"; +import { ITIP20, ITIP20Token } from "tempo-std/interfaces/ITIP20.sol"; +import { ITIP20Factory } from "tempo-std/interfaces/ITIP20Factory.sol"; +import { ITIP20RolesAuthErr } from "tempo-std/interfaces/ITIP20RolesAuth.sol"; + +/// @title TIP-1026 Token Logo URI Invariant Tests +/// @notice Handler-based invariant tests for the TIP-1026 logoURI / setLogoURI / factory +/// overload as defined in `tips/tip-1026.md`. +/// @dev Covers the two normative invariants from the spec: +/// TEMPO-1026-1: `bytes(logoURI()).length <= 256` must always hold. +/// TEMPO-1026-2: The legacy 6-argument `createToken` selector and the +/// `TokenCreated` event signature are unchanged by this TIP. +/// forge-config: default.hardfork = "tempo:T5" +/// forge-config: fuzz500.hardfork = "tempo:T5" +contract TIP1026InvariantTest is InvariantBaseTest { + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + + uint256 private constant MAX_LOGO_URI_BYTES = 256; + uint256 private constant NUM_ACTORS = 4; + uint256 private constant MAX_LOGO_TOKENS = 4; + + /// @dev Pre-image of the legacy 6-arg createToken selector. Must remain + /// `0x68130445` per TEMPO-1026-2. + bytes4 private constant LEGACY_CREATE_TOKEN_SELECTOR = + bytes4(keccak256("createToken(string,string,string,address,address,bytes32)")); + + /// @dev Pre-image of the TokenCreated event topic0. Must remain + /// `0x44f7b801...` per TEMPO-1026-2 (the event signature does NOT + /// include `logoURI`). + bytes32 private constant TOKEN_CREATED_TOPIC0 = + keccak256("TokenCreated(address,string,string,string,address,address,bytes32)"); + + /*////////////////////////////////////////////////////////////// + STATE + //////////////////////////////////////////////////////////////*/ + + /// @dev Tokens whose logoURI is exercised by the fuzz handlers; the global + /// invariant scans this list after every run. + ITIP20Token[] private _logoTokens; + + /// @dev Counter used to derive unique salts for createToken handlers. + uint256 private _saltNonce; + + /*////////////////////////////////////////////////////////////// + SETUP + //////////////////////////////////////////////////////////////*/ + + function setUp() public override { + super.setUp(); + + targetContract(address(this)); + _setupInvariantBase(); + (_actors,) = _buildActors(NUM_ACTORS); + + // TEMPO-1026-2: assert constants up-front. These are immutable after + // deployment, so a one-shot check in setUp is sufficient — any + // regression (e.g. the legacy selector being rewritten or the event + // being extended with `logoURI`) will fail the suite immediately. + assertEq( + LEGACY_CREATE_TOKEN_SELECTOR, + bytes4(0x68130445), + "TEMPO-1026-2: legacy createToken selector must remain 0x68130445" + ); + assertEq( + TOKEN_CREATED_TOPIC0, + bytes32(0x44f7b8011db3e3647a530b4ff635726de5fafc8fa8ad10f0f31c0eb9dd52fc65), + "TEMPO-1026-2: TokenCreated topic0 must remain unchanged" + ); + + // Track existing factory-deployed tokens so the global invariant has + // something to scan even before any handler runs. + _logoTokens.push(token1); + _logoTokens.push(token2); + } + + /*////////////////////////////////////////////////////////////// + FUZZ HANDLERS + //////////////////////////////////////////////////////////////*/ + + /// @notice Calls `setLogoURI` from a random actor with a fuzz-generated URI. + /// @dev Per-handler assertions: + /// - calls from non-admins revert with `Unauthorized` + /// - calls with `len > 256` revert with `LogoURITooLong` + /// - calls with a non-empty disallowed scheme revert with `InvalidLogoURI` + /// - successful calls leave `bytes(logoURI()).length <= 256` + function fuzzSetLogoURI( + uint256 tokenSeed, + uint256 actorSeed, + uint256 schemeSeed, + uint16 len + ) + external + { + if (_logoTokens.length == 0) return; + + ITIP20Token token = _logoTokens[tokenSeed % _logoTokens.length]; + address actor = _selectActor(actorSeed); + + // Generate a URI of fuzzed length (0..512) with a fuzzed scheme. + // The 0..512 window covers both sides of the 256-byte cap so the + // fuzzer exercises both LogoURITooLong and accepted lengths. + uint256 boundedLen = bound(uint256(len), 0, 512); + (string memory uri, bool wellFormedAllowed) = _buildUri(schemeSeed, boundedLen); + bool tooLong = bytes(uri).length > MAX_LOGO_URI_BYTES; + bool isEmpty = bytes(uri).length == 0; + bool acceptable = isEmpty || wellFormedAllowed; + + vm.prank(actor); + try token.setLogoURI(uri) { + // Success path — must be admin, length within cap, and either + // empty or a well-formed URI with an allowed scheme. + assertEq(actor, admin, "TEMPO-1026: non-admin setLogoURI must revert"); + assertFalse(tooLong, "TEMPO-1026-1: oversized setLogoURI must revert"); + assertTrue(acceptable, "TEMPO-1026: setLogoURI with bad URI must revert"); + assertEq(token.logoURI(), uri, "TEMPO-1026: logoURI not persisted on success"); + } catch (bytes memory reason) { + bytes4 sel = bytes4(reason); + if (actor != admin) { + assertEq( + sel, + ITIP20RolesAuthErr.Unauthorized.selector, + "TEMPO-1026: non-admin must revert with Unauthorized" + ); + } else if (tooLong) { + assertEq( + sel, + ITIP20.LogoURITooLong.selector, + "TEMPO-1026-1: oversized URI must revert with LogoURITooLong" + ); + } else { + // Admin, length OK → only legitimate failure is a malformed + // URI or a non-allowlisted scheme. + assertFalse(acceptable, "TEMPO-1026: acceptable URI must succeed for admin"); + assertEq( + sel, + ITIP20.InvalidLogoURI.selector, + "TEMPO-1026: bad URI must revert with InvalidLogoURI" + ); + } + } + + // TEMPO-1026-1: the global invariant — checked here per-handler too + // for fast feedback on the offending call. + assertLe( + bytes(token.logoURI()).length, + MAX_LOGO_URI_BYTES, + "TEMPO-1026-1: logoURI length must always be <= 256 bytes" + ); + } + + /// @notice Creates a token via the 7-arg `createToken` overload and tracks it. + /// @dev Successful creation must satisfy TEMPO-1026-1 immediately on the + /// newly-deployed token. Bad URIs must revert atomically; the would-be + /// address must remain undeployed (TEMPO-FAC1 derives that address + /// deterministically from `(sender, salt)`). + function fuzzCreateTokenWithLogo(uint256 schemeSeed, uint16 len) external { + if (_logoTokens.length >= MAX_LOGO_TOKENS) return; + + bytes32 salt = keccak256(abi.encode("TIP1026", _saltNonce++)); + uint256 boundedLen = bound(uint256(len), 0, 512); + (string memory uri, bool wellFormedAllowed) = _buildUri(schemeSeed, boundedLen); + bool tooLong = bytes(uri).length > MAX_LOGO_URI_BYTES; + bool isEmpty = bytes(uri).length == 0; + bool acceptable = isEmpty || wellFormedAllowed; + + address predicted = factory.getTokenAddress(admin, salt); + + vm.prank(admin); + try factory.createToken("LOGO", "LG", "USD", pathUSD, admin, salt, uri) returns ( + address tokenAddr + ) { + assertFalse(tooLong, "TEMPO-1026-1: oversized URI must revert createToken"); + assertTrue(acceptable, "TEMPO-1026: bad URI must revert createToken"); + assertEq(tokenAddr, predicted, "TEMPO-FAC1: deployed at predicted address"); + + ITIP20Token created = ITIP20Token(tokenAddr); + assertEq(created.logoURI(), uri, "TEMPO-1026: factory must persist logoURI"); + assertLe( + bytes(created.logoURI()).length, + MAX_LOGO_URI_BYTES, + "TEMPO-1026-1: logoURI length must always be <= 256 bytes" + ); + + _logoTokens.push(created); + } catch (bytes memory reason) { + bytes4 sel = bytes4(reason); + if (tooLong) { + assertEq( + sel, + ITIP20.LogoURITooLong.selector, + "TEMPO-1026-1: oversized URI must revert with LogoURITooLong" + ); + } else if (!acceptable) { + assertEq( + sel, + ITIP20.InvalidLogoURI.selector, + "TEMPO-1026: bad URI must revert with InvalidLogoURI" + ); + } else { + revert("TEMPO-1026: valid createToken+logoURI must not revert"); + } + // Atomicity: bad URI must NOT leave a deployed token at the + // predicted address (validation runs before deployment). + assertEq(predicted.code.length, 0, "TEMPO-1026: rejected URI left a partial token"); + } + } + + /*////////////////////////////////////////////////////////////// + GLOBAL INVARIANTS + //////////////////////////////////////////////////////////////*/ + + /// @notice TEMPO-1026-1: `bytes(logoURI()).length <= 256` for every tracked token. + function invariant_logoURILengthBounded() public view { + for (uint256 i = 0; i < _logoTokens.length; i++) { + assertLe( + bytes(_logoTokens[i].logoURI()).length, + MAX_LOGO_URI_BYTES, + "TEMPO-1026-1: logoURI length must always be <= 256 bytes" + ); + } + } + + /*////////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////////*/ + + /// @dev Builds a URI of exactly `len` bytes whose scheme is selected by + /// `schemeSeed % schemes.length`, and reports whether the result is + /// a well-formed URI with an allowlisted scheme (i.e. the protocol + /// should accept it, ignoring the length cap). + /// + /// If the requested length is shorter than the scheme prefix's `:` + /// separator, the produced slice has no parseable scheme and + /// `wellFormedAllowed` is `false` (the protocol rejects it as + /// `InvalidLogoURI`). Once the slice includes the `:`, however, the + /// protocol parses the full scheme name and accepts the URI iff that + /// scheme is allowlisted — so e.g. `"https:"`, `"https:/"`, `"http:"`, + /// `"ipfs:"`, `"data:"` are all accepted (`split_once(':')` yields + /// the allowlisted scheme name and an empty / partial path is fine + /// per RFC 3986 §3.1). `len == 0` returns `("", false)` — callers + /// handle empty as a separate accepted case. + function _buildUri( + uint256 schemeSeed, + uint256 len + ) + internal + pure + returns (string memory uri, bool wellFormedAllowed) + { + if (len == 0) return ("", false); + + // Mix of allowed, disallowed, and malformed schemes so the fuzzer + // exercises every revert/accept path. The first four are in the + // TIP-1026 allowlist; the rest are not. `colonPos[i]` is the byte + // offset of `:` inside `schemes[i]` and is used to decide whether a + // truncated slice still contains the scheme separator. + string[8] memory schemes = + ["https://", "http://", "ipfs://", "data:", "javascript:", "ftp://", "file://", "://"]; + uint256[8] memory colonPos = [uint256(5), 4, 4, 4, 10, 3, 4, 0]; + uint256 idx = schemeSeed % schemes.length; + bytes memory prefix = bytes(schemes[idx]); + + bytes memory out = new bytes(len); + uint256 copyLen = prefix.length < len ? prefix.length : len; + for (uint256 i = 0; i < copyLen; i++) { + out[i] = prefix[i]; + } + for (uint256 i = copyLen; i < len; i++) { + out[i] = "a"; + } + + // Accepted iff the slice contains the scheme's `:` AND the scheme is + // allowlisted. The protocol parses the scheme as everything before + // the first `:` (RFC 3986 §3.1), so e.g. `"https:"` is acceptable + // even though the trailing `//` was truncated. + wellFormedAllowed = (idx < 4) && (len > colonPos[idx]); + return (string(out), wellFormedAllowed); + } + +} diff --git a/xtask/src/genesis_args.rs b/xtask/src/genesis_args.rs index ab46847efb..75edba757a 100644 --- a/xtask/src/genesis_args.rs +++ b/xtask/src/genesis_args.rs @@ -43,7 +43,7 @@ use tempo_contracts::{ ARACHNID_CREATE2_FACTORY_ADDRESS, CREATEX_ADDRESS, MULTICALL3_ADDRESS, PERMIT2_ADDRESS, PERMIT2_SALT, SAFE_DEPLOYER_ADDRESS, contracts::{ARACHNID_CREATE2_FACTORY_BYTECODE, CreateX, Multicall3, SafeDeployer}, - precompiles::{ITIP20Factory, IValidatorConfigV2}, + precompiles::{IValidatorConfigV2, createTokenCall}, }; use tempo_dkg_onchain_artifacts::OnchainDkgOutcome; use tempo_evm::evm::{TempoEvm, TempoEvmFactory}; @@ -737,7 +737,7 @@ fn create_and_mint_token( SaltOrAddress::Salt(salt) => factory .create_token( admin, - ITIP20Factory::createTokenCall { + createTokenCall { name: name.into(), symbol: symbol.into(), currency: currency.into(),