diff --git a/.github/workflows/build-contracts-and-push-to-r2.yaml b/.github/workflows/build-contracts-and-push-to-r2.yaml index 969ac5d65..c20433394 100644 --- a/.github/workflows/build-contracts-and-push-to-r2.yaml +++ b/.github/workflows/build-contracts-and-push-to-r2.yaml @@ -139,10 +139,10 @@ jobs: fi mkdir -p "../${crate_version}" - cp "$wasm_file" "../${crate_version}/${crate_name}.wasm" + cp "$wasm_file" "../${crate_version}/${crate_name//-/_}.wasm" cp "$checksum_file" "../${crate_version}/" - gpg --armor --detach-sign ../${crate_version}/${crate_name}.wasm + gpg --armor --detach-sign ../${crate_version}/${crate_name//-/_}.wasm gpg --armor --detach-sign ../${crate_version}/checksums.txt echo "release-artifacts-dir=./${crate_version}" >> $GITHUB_OUTPUT diff --git a/Cargo.lock b/Cargo.lock index c804d204a..fa0c159f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,6 +872,7 @@ dependencies = [ "serde_json", "serde_with", "sha3", + "starknet-checked-felt", "stellar-xdr", "strum 0.25.0", "sui-types 1.0.0", @@ -4651,6 +4652,28 @@ dependencies = [ "sha3-asm", ] +[[package]] +name = "lambdaworks-crypto" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc2a4da0d9e52ccfe6306801a112e81a8fc0c76aa3e4449fefeda7fef72bb34" +dependencies = [ + "lambdaworks-math", + "serde", + "sha2 0.10.8", + "sha3", +] + +[[package]] +name = "lambdaworks-math" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1bd2632acbd9957afc5aeec07ad39f078ae38656654043bf16e046fa2730e23" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -7181,6 +7204,7 @@ dependencies = [ "error-stack", "flagset", "gateway-api", + "goldie", "hex", "integration-tests", "itertools 0.11.0", @@ -8175,6 +8199,33 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "starknet-checked-felt" +version = "1.0.0" +dependencies = [ + "alloy-primitives", + "axelar-wasm-std", + "error-stack", + "hex", + "serde", + "starknet-types-core", + "thiserror 1.0.69", +] + +[[package]] +name = "starknet-types-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa1b9e01ccb217ab6d475c5cda05dbb22c30029f7bb52b192a010a00d77a3d74" +dependencies = [ + "lambdaworks-crypto", + "lambdaworks-math", + "num-bigint 0.4.6", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -9927,6 +9978,7 @@ dependencies = [ "service-registry", "service-registry-api", "sha3", + "starknet-checked-felt", "thiserror 1.0.69", ] diff --git a/Cargo.toml b/Cargo.toml index dc4ffe53f..ff1acbf2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [workspace] members = [ - "ampd", - "contracts/*", - "external-gateways/*", - "integration-tests", - "packages/*" + "ampd", + "contracts/*", + "external-gateways/*", + "integration-tests", + "packages/*", ] resolver = "2" @@ -13,8 +13,12 @@ rust-version = "1.78.0" # be sure there is an optimizer release supporting this edition = "2021" [workspace.dependencies] -alloy-primitives = { version = "0.7.6", default-features = false, features = ["std"] } -alloy-sol-types = { version = "0.7.6", default-features = false, features = ["std"] } +alloy-primitives = { version = "0.7.6", default-features = false, features = [ + "std", +] } +alloy-sol-types = { version = "0.7.6", default-features = false, features = [ + "std", +] } anyhow = "1.0.89" assert_ok = "1.0" axelar-wasm-std = { version = "^1.0.0", path = "packages/axelar-wasm-std" } @@ -33,7 +37,9 @@ cw-utils = "2.0.0" cw2 = "2.0.0" ed25519-dalek = { version = "2.1.1", default-features = false } error-stack = { version = "0.4.0", features = ["eyre"] } -ethers-contract = { version = "2.0.14", default-features = false, features = ["abigen"] } +ethers-contract = { version = "2.0.14", default-features = false, features = [ + "abigen", +] } ethers-core = "2.0.14" events = { version = "^1.0.0", path = "packages/events" } events-derive = { version = "^1.0.0", path = "packages/events-derive" } @@ -72,6 +78,10 @@ stellar-xdr = { version = "21.2.0" } strum = { version = "0.25", default-features = false, features = ["derive"] } sui-gateway = { version = "^1.0.0", path = "packages/sui-gateway" } sui-types = { version = "^1.0.0", path = "packages/sui-types" } +starknet-checked-felt = { version = "^1.0.0", path = "packages/starknet-checked-felt" } +starknet-types-core = { version = "0.1.7" } +starknet-core = "0.12.0" +starknet-providers = "0.12.0" syn = "2.0.92" thiserror = "1.0.61" tofn = { version = "1.1" } diff --git a/contracts/router/Cargo.toml b/contracts/router/Cargo.toml index b0bb38c92..b43837458 100644 --- a/contracts/router/Cargo.toml +++ b/contracts/router/Cargo.toml @@ -53,6 +53,7 @@ thiserror = { workspace = true } assert_ok = { workspace = true } axelar-core-std = { workspace = true, features = ["test"] } cw-multi-test = { workspace = true } +goldie = { workspace = true } hex = { version = "0.4.3", default-features = false } integration-tests = { workspace = true } rand = { workspace = true } diff --git a/contracts/router/src/contract.rs b/contracts/router/src/contract.rs index d5605fa6d..934773666 100644 --- a/contracts/router/src/contract.rs +++ b/contracts/router/src/contract.rs @@ -1823,4 +1823,19 @@ mod test { ) .is_ok()); } + + #[test] + fn chain_info_fails_on_unregistered_chain() { + let deps = setup(); + let unregistered_chain: ChainName = "unregistered".parse().unwrap(); + + // Ensure that the error message doesn't change unexpectedly since the relayer depends on it + let err = query( + deps.as_ref(), + mock_env(), + QueryMsg::ChainInfo(unregistered_chain), + ) + .unwrap_err(); + goldie::assert!(err.to_string()); + } } diff --git a/contracts/router/src/testdata/chain_info_fails_on_unregistered_chain.golden b/contracts/router/src/testdata/chain_info_fails_on_unregistered_chain.golden new file mode 100644 index 000000000..1274fbdfa --- /dev/null +++ b/contracts/router/src/testdata/chain_info_fails_on_unregistered_chain.golden @@ -0,0 +1 @@ +chain is not found \ No newline at end of file diff --git a/contracts/voting-verifier/Cargo.toml b/contracts/voting-verifier/Cargo.toml index 2c2c64d1b..73f445300 100644 --- a/contracts/voting-verifier/Cargo.toml +++ b/contracts/voting-verifier/Cargo.toml @@ -5,10 +5,7 @@ rust-version = { workspace = true } edition = { workspace = true } description = "Voting verifier contract" -exclude = [ - "contract.wasm", - "hash.txt" -] +exclude = ["contract.wasm", "hash.txt"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] @@ -58,6 +55,7 @@ integration-tests = { workspace = true } multisig = { workspace = true, features = ["test", "library"] } rand = { workspace = true } sha3 = { workspace = true } +starknet-checked-felt = { workspace = true } [lints] workspace = true diff --git a/contracts/voting-verifier/src/contract.rs b/contracts/voting-verifier/src/contract.rs index 519be2a8a..524c5695d 100644 --- a/contracts/voting-verifier/src/contract.rs +++ b/contracts/voting-verifier/src/contract.rs @@ -123,8 +123,8 @@ mod test { use assert_ok::assert_ok; use axelar_wasm_std::address::AddressFormat; use axelar_wasm_std::msg_id::{ - Base58SolanaTxSignatureAndEventIndex, Base58TxDigestAndEventIndex, HexTxHash, - HexTxHashAndEventIndex, MessageIdFormat, + Base58SolanaTxSignatureAndEventIndex, Base58TxDigestAndEventIndex, + FieldElementAndEventIndex, HexTxHash, HexTxHashAndEventIndex, MessageIdFormat, }; use axelar_wasm_std::voting::Vote; use axelar_wasm_std::{ @@ -143,6 +143,7 @@ mod test { AuthorizationState, BondingState, Verifier, WeightedVerifier, VERIFIER_WEIGHT, }; use sha3::{Digest, Keccak256, Keccak512}; + use starknet_checked_felt::CheckedFelt; use super::*; use crate::error::ContractError; @@ -241,6 +242,17 @@ mod test { fn message_id(id: &str, index: u64, msg_id_format: &MessageIdFormat) -> nonempty::String { match msg_id_format { + MessageIdFormat::FieldElementAndEventIndex => { + let mut id_bytes: [u8; 32] = Keccak256::digest(id.as_bytes()).into(); + id_bytes[0] = 0; // felt is ~31 bytes + FieldElementAndEventIndex { + tx_hash: CheckedFelt::try_from(&id_bytes).unwrap(), + event_index: index, + } + .to_string() + .parse() + .unwrap() + } MessageIdFormat::HexTxHashAndEventIndex => HexTxHashAndEventIndex { tx_hash: Keccak256::digest(id.as_bytes()).into(), event_index: index, @@ -355,10 +367,53 @@ mod test { address_format: AddressFormat::Eip55, should_fail: true, }, + TestCase { + source_gateway_address: + // 63 chars + "0x06cdc5221388566e09e1a9be3dcfd4b1bbb4abf98296bb4674401a79373cce5" + .to_string() + .to_lowercase(), + address_format: AddressFormat::Starknet, + should_fail: true, + }, + TestCase { + source_gateway_address: + // 62 chars + "0x6cdc5221388566e09e1a9be3dcfd4b1bbb4abf98296bb4674401a79373cce5" + .to_string() + .to_lowercase(), + address_format: AddressFormat::Starknet, + should_fail: true, + }, + TestCase { + source_gateway_address: + // 64 chars, but out of prime field range + "0xff6cdc5221388566e09e1a9be3dcfd4b1bbb4abf98296bb4674401a79373cce5" + .to_string() + .to_lowercase(), + address_format: AddressFormat::Starknet, + should_fail: true, + }, + TestCase { + source_gateway_address: + "0x006cdc5221388566e09e1a9be3dcfd4b1bbb4abf98296bb4674401a79373cce5" + .to_string() + .to_lowercase(), + address_format: AddressFormat::Starknet, + should_fail: false, + }, TestCase { source_gateway_address: "0x4F4495243837681061C4743b74B3eEdf548D56A5" .to_string() .to_lowercase(), + address_format: AddressFormat::Starknet, + should_fail: true, + }, + TestCase { + source_gateway_address: + "0xdb1473ed56ddede13225b99d779ebf9d9011874e26acbb8bfec8b6a43d0fbcaa" + .to_string() + .to_uppercase(), address_format: AddressFormat::Sui, should_fail: true, }, @@ -499,6 +554,29 @@ mod test { ); } + #[test] + fn should_fail_if_messages_have_hex_msg_id_but_contract_expects_field_element() { + let msg_id_format = MessageIdFormat::FieldElementAndEventIndex; + let verifiers = verifiers(2); + let mut deps = setup(verifiers.clone(), &msg_id_format); + + let messages = messages(1, &MessageIdFormat::HexTxHashAndEventIndex); + let msg = ExecuteMsg::VerifyMessages(messages.clone()); + let api = deps.api; + + let err = execute( + deps.as_mut(), + mock_env(), + message_info(&api.addr_make(SENDER), &[]), + msg, + ) + .unwrap_err(); + assert_contract_err_strings_equal( + err, + ContractError::InvalidMessageID(messages[0].cc_id.message_id.to_string()), + ); + } + #[test] fn should_fail_if_messages_have_hex_msg_id_but_contract_expects_base58() { let msg_id_format = MessageIdFormat::Base58TxDigestAndEventIndex; @@ -880,6 +958,7 @@ mod test { [ (v, s, MessageIdFormat::HexTxHashAndEventIndex), (v, s, MessageIdFormat::Base58TxDigestAndEventIndex), + (v, s, MessageIdFormat::FieldElementAndEventIndex), ] }) .collect::>(); diff --git a/contracts/voting-verifier/src/events.rs b/contracts/voting-verifier/src/events.rs index 52634c9c6..5fc57f374 100644 --- a/contracts/voting-verifier/src/events.rs +++ b/contracts/voting-verifier/src/events.rs @@ -2,8 +2,8 @@ use std::str::FromStr; use std::vec::Vec; use axelar_wasm_std::msg_id::{ - Base58SolanaTxSignatureAndEventIndex, Base58TxDigestAndEventIndex, Bech32mFormat, HexTxHash, - HexTxHashAndEventIndex, MessageIdFormat, + Base58SolanaTxSignatureAndEventIndex, Base58TxDigestAndEventIndex, Bech32mFormat, + FieldElementAndEventIndex, HexTxHash, HexTxHashAndEventIndex, MessageIdFormat, }; use axelar_wasm_std::voting::{PollId, Vote}; use axelar_wasm_std::{nonempty, VerificationStatus}; @@ -160,6 +160,16 @@ fn parse_message_id( .map_err(|_| ContractError::InvalidMessageID(message_id.to_string()))?, )) } + MessageIdFormat::FieldElementAndEventIndex => { + let id = FieldElementAndEventIndex::from_str(message_id) + .map_err(|_| ContractError::InvalidMessageID(message_id.to_string()))?; + + Ok(( + id.tx_hash_as_hex(), + u32::try_from(id.event_index) + .map_err(|_| ContractError::InvalidMessageID(message_id.to_string()))?, + )) + } MessageIdFormat::HexTxHashAndEventIndex => { let id = HexTxHashAndEventIndex::from_str(message_id) .map_err(|_| ContractError::InvalidMessageID(message_id.to_string()))?; diff --git a/packages/axelar-wasm-std/Cargo.toml b/packages/axelar-wasm-std/Cargo.toml index 7bcb3ecba..f8178d449 100644 --- a/packages/axelar-wasm-std/Cargo.toml +++ b/packages/axelar-wasm-std/Cargo.toml @@ -5,10 +5,7 @@ rust-version = { workspace = true } edition = { workspace = true } description = "Axelar cosmwasm standard library crate" -exclude = [ - "contract.wasm", - "hash.txt" -] +exclude = ["contract.wasm", "hash.txt"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] @@ -39,7 +36,10 @@ into-inner-derive = { workspace = true } itertools = { workspace = true } lazy_static = "1.4.0" num-traits = { workspace = true } -regex = { version = "1.10.0", default-features = false, features = ["perf", "std"] } +regex = { version = "1.10.0", default-features = false, features = [ + "perf", + "std", +] } report = { workspace = true } schemars = "0.8.10" semver = { workspace = true } @@ -47,6 +47,7 @@ serde = { version = "1.0.145", default-features = false, features = ["derive"] } serde_json = "1.0.89" serde_with = { version = "3.11.0", features = ["macros"] } sha3 = { workspace = true } +starknet-checked-felt = { workspace = true } stellar-xdr = { workspace = true } strum = { workspace = true } sui-types = { workspace = true } diff --git a/packages/axelar-wasm-std/src/address.rs b/packages/axelar-wasm-std/src/address.rs index fefac6373..4baa1a9cb 100644 --- a/packages/axelar-wasm-std/src/address.rs +++ b/packages/axelar-wasm-std/src/address.rs @@ -4,6 +4,7 @@ use alloy_primitives::Address; use cosmwasm_schema::cw_serde; use cosmwasm_std::{Addr, Api}; use error_stack::{bail, Result, ResultExt}; +use starknet_checked_felt::CheckedFelt; use stellar_xdr::curr::ScAddress; use sui_types::SuiAddress; @@ -19,6 +20,7 @@ pub enum AddressFormat { Eip55, Sui, Stellar, + Starknet, } pub fn validate_address(address: &str, format: &AddressFormat) -> Result<(), Error> { @@ -38,6 +40,10 @@ pub fn validate_address(address: &str, format: &AddressFormat) -> Result<(), Err ScAddress::from_str(address) .change_context(Error::InvalidAddress(address.to_string()))?; } + AddressFormat::Starknet => { + CheckedFelt::from_str(address) + .change_context(Error::InvalidAddress(address.to_string()))?; + } } Ok(()) @@ -187,4 +193,96 @@ mod tests { address::Error::InvalidAddress(..) ); } + + #[test] + fn validate_starknet_address() { + // 0 prefixed field element + // 64 chars + let addr = "0x0282b4492e08d8b6bbec8dfe7412e42e897eef9c080c5b97be1537433e583bdc"; + assert_ok!(address::validate_address( + addr, + &address::AddressFormat::Starknet + )); + + // 0x prefix removed from string, but padded with 0 + // 64 chars + let zero_x_removed = "0282b4492e08d8b6bbec8dfe7412e42e897eef9c080c5b97be1537433e583bdc"; + assert_err_contains!( + address::validate_address(zero_x_removed, &address::AddressFormat::Starknet), + address::Error, + address::Error::InvalidAddress(..) + ); + + // 0x0 prefix removed from string. + // Commonly a `0` is prefixed to the field element, in order to make it a valid hex. + // Originally the felt is 63 chars, which is an invalid hex by itself + // 63 chars + let zero_x_zero_removed = "282b4492e08d8b6bbec8dfe7412e42e897eef9c080c5b97be1537433e583bdc"; + assert_err_contains!( + address::validate_address(zero_x_zero_removed, &address::AddressFormat::Starknet), + address::Error, + address::Error::InvalidAddress(..) + ); + + // 0 prefix removed from string, but 0x is left in. + // This is an invalid 63char hex by itself, but a valid field element. + // 63 chars. + let zero_removed = "0x282b4492e08d8b6bbec8dfe7412e42e897eef9c080c5b97be1537433e583bdc"; + assert_err_contains!( + address::validate_address(zero_removed, &address::AddressFormat::Starknet), + address::Error, + address::Error::InvalidAddress(..) + ); + + // invalid hex (starts with `q`) + let invalid_hex = "0xq282b4492e08d8b6bbec8dfe7412e42e897eef9c080c5b97be1537433e583bdc"; + assert_err_contains!( + address::validate_address(invalid_hex, &address::AddressFormat::Starknet), + address::Error, + address::Error::InvalidAddress(..) + ); + + // more than 64 chars is invalid + let more_than_64 = "0x282b4492e08d8b6bbec8dfe7412e42e897eef9c080c5b97be1537433e583bdc123"; + assert_err_contains!( + address::validate_address(more_than_64, &address::AddressFormat::Starknet), + address::Error, + address::Error::InvalidAddress(..) + ); + + // less than 63 chars is invalid + let less_than_63 = "0x123"; + assert_err_contains!( + address::validate_address(less_than_63, &address::AddressFormat::Starknet), + address::Error, + address::Error::InvalidAddress(..) + ); + + let overflown_felt_with_one = + "0x080000006b9f1bed878fcc665f2ca1a6afd545a6b864d8400000000000000001"; + assert_err_contains!( + address::validate_address(overflown_felt_with_one, &address::AddressFormat::Starknet), + address::Error, + address::Error::InvalidAddress(..) + ); + + // overflowed field element (added a 64th char, other than 0) + let overflown_felt = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + assert_err_contains!( + address::validate_address(overflown_felt, &address::AddressFormat::Starknet), + address::Error, + address::Error::InvalidAddress(..) + ); + + // uppercase string field element + let upper_case_invalid = addr.to_uppercase(); + assert_err_contains!( + address::validate_address( + upper_case_invalid.as_str(), + &address::AddressFormat::Starknet + ), + address::Error, + address::Error::InvalidAddress(..) + ); + } } diff --git a/packages/axelar-wasm-std/src/msg_id/mod.rs b/packages/axelar-wasm-std/src/msg_id/mod.rs index c4cc1b972..1107b98c2 100644 --- a/packages/axelar-wasm-std/src/msg_id/mod.rs +++ b/packages/axelar-wasm-std/src/msg_id/mod.rs @@ -7,6 +7,7 @@ use error_stack::Report; pub use self::base_58_event_index::Base58TxDigestAndEventIndex; pub use self::base_58_solana_event_index::Base58SolanaTxSignatureAndEventIndex; pub use self::bech32m::Bech32mFormat; +pub use self::starknet_field_element_event_index::FieldElementAndEventIndex; pub use self::tx_hash::HexTxHash; pub use self::tx_hash_event_index::HexTxHashAndEventIndex; use crate::nonempty; @@ -14,6 +15,7 @@ use crate::nonempty; mod base_58_event_index; mod base_58_solana_event_index; mod bech32m; +mod starknet_field_element_event_index; mod tx_hash; mod tx_hash_event_index; @@ -32,6 +34,8 @@ pub enum Error { InvalidBech32mFormat(String), #[error("Invalid bech32m: '{0}'")] InvalidBech32m(String), + #[error("invalid field element '{0}'")] + InvalidFieldElement(String), } /// Any message id format must implement this trait. @@ -48,6 +52,7 @@ pub trait MessageId: FromStr + Display {} /// enum to pass to the router when registering a new chain #[cw_serde] pub enum MessageIdFormat { + FieldElementAndEventIndex, HexTxHashAndEventIndex, Base58TxDigestAndEventIndex, Base58SolanaTxSignatureAndEventIndex, @@ -61,6 +66,9 @@ pub enum MessageIdFormat { // function the router calls to verify msg ids pub fn verify_msg_id(message_id: &str, format: &MessageIdFormat) -> Result<(), Report> { match format { + MessageIdFormat::FieldElementAndEventIndex => { + FieldElementAndEventIndex::from_str(message_id).map(|_| ()) + } MessageIdFormat::HexTxHashAndEventIndex => { HexTxHashAndEventIndex::from_str(message_id).map(|_| ()) } diff --git a/packages/axelar-wasm-std/src/msg_id/starknet_field_element_event_index.rs b/packages/axelar-wasm-std/src/msg_id/starknet_field_element_event_index.rs new file mode 100644 index 000000000..8c97acd89 --- /dev/null +++ b/packages/axelar-wasm-std/src/msg_id/starknet_field_element_event_index.rs @@ -0,0 +1,277 @@ +use core::fmt; +use std::fmt::Display; +use std::str::FromStr; + +use cosmwasm_std::HexBinary; +use error_stack::Report; +use lazy_static::lazy_static; +use regex::Regex; +use serde_with::DeserializeFromStr; +use starknet_checked_felt::CheckedFelt; + +use super::Error; +use crate::nonempty; + +#[derive(Debug, DeserializeFromStr, Clone)] +pub struct FieldElementAndEventIndex { + pub tx_hash: CheckedFelt, + pub event_index: u64, +} + +impl FieldElementAndEventIndex { + pub fn tx_hash_as_hex(&self) -> nonempty::String { + format!("0x{}", self.tx_hash_as_hex_no_prefix()) + .try_into() + .expect("failed to convert tx hash to non-empty string") + } + + pub fn tx_hash_as_hex_no_prefix(&self) -> nonempty::String { + HexBinary::from(self.tx_hash.to_bytes_be()) + .to_hex() + .to_string() + .try_into() + .expect("failed to convert tx hash to non-empty string") + } + + pub fn new>(tx_id: T, event_index: impl Into) -> Result { + Ok(Self { + tx_hash: tx_id.into(), + event_index: event_index.into(), + }) + } +} + +// A valid field element is max 252 bits, meaning max 63 hex characters after 0x. +// We require the hex to be 64 characters, meaning that it should be padded with zeroes in order +// for us to consider it valid. +const PATTERN: &str = "^(0x0[0-9a-f]{63})-(0|[1-9][0-9]*)$"; +lazy_static! { + static ref REGEX: Regex = Regex::new(PATTERN).expect("invalid regex"); +} + +impl FromStr for FieldElementAndEventIndex { + type Err = Report; + + fn from_str(message_id: &str) -> Result + where + Self: Sized, + { + // the PATTERN has exactly two capture groups, so the groups can be extracted safely + let (_, [tx_id, event_index]) = REGEX + .captures(message_id) + .ok_or(Error::InvalidMessageID { + id: message_id.to_string(), + expected_format: PATTERN.to_string(), + })? + .extract(); + let felt = CheckedFelt::from_str(tx_id) + .map_err(|e| Error::InvalidFieldElement(format!("{}: {}", e, tx_id)))?; + + Ok(FieldElementAndEventIndex { + tx_hash: felt, + event_index: event_index + .parse() + .map_err(|_| Error::EventIndexOverflow(message_id.to_string()))?, + }) + } +} + +// pad the FieldElement with zeroes +impl Display for FieldElementAndEventIndex { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "0x{:064x}-{}", self.tx_hash, self.event_index) + } +} + +impl From for nonempty::String { + fn from(msg_id: FieldElementAndEventIndex) -> Self { + msg_id + .to_string() + .try_into() + .expect("failed to convert msg id to non-empty string") + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::U256; + use rand::Rng; + + use super::*; + + fn random_hash() -> String { + // Generate a random 256-bit value + let mut rng = rand::thread_rng(); + let mut bytes = [0u8; 32]; + rng.fill(&mut bytes); + let number = U256::from_be_bytes::<32>(bytes); + let max: U256 = U256::from_be_bytes::<32>([ + 8, 0, 0, 0, 0, 0, 0, 17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]); + + let result = number.checked_rem(max).expect("modulo operation failed"); + + format!("0x{:064x}", result) + } + + fn random_event_index() -> u64 { + rand::random() + } + + #[test] + fn should_parse_msg_id() { + let res = FieldElementAndEventIndex::from_str( + "0x0670d1dd42a19cb229bb4378b58b9c3e76aa43edaaea46845cd8c456c1224d89-0", + ); + assert!(res.is_ok()); + + for _ in 0..1000 { + let tx_hash = random_hash(); + let event_index = random_event_index(); + let msg_id = format!("{}-{}", tx_hash, event_index); + + let res = FieldElementAndEventIndex::from_str(&msg_id); + let parsed = res.unwrap(); + assert_eq!(parsed.event_index, event_index); + assert_eq!(parsed.tx_hash_as_hex(), tx_hash.try_into().unwrap(),); + assert_eq!(parsed.to_string(), msg_id); + } + } + + #[test] + fn should_not_parse_msg_id_overflowing_felt() { + let res = FieldElementAndEventIndex::from_str( + "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff-0", + ); + assert!(res.is_err()); + + // Felt::MAX + 1 + let res = FieldElementAndEventIndex::from_str( + "0x080000006b9f1bed878fcc665f2ca1a6afd545a6b864d8400000000000000001-0", + ); + assert!(res.is_err()); + } + + #[test] + fn should_not_parse_msg_id_with_63_or_62_chars() { + let res = FieldElementAndEventIndex::from_str( + "0x670d1dd42a19cb229bb4378b58b9c3e76aa43edaaea46845cd8c456c1224d89-0", + ); + assert!(res.is_err()); + + let res = FieldElementAndEventIndex::from_str( + "0x17f60b1e54f3b012bffc2b328070fde2b5dae12220c985f098fb8e36338472-0", + ); + assert!(res.is_err()); + } + + #[test] + fn should_not_parse_msg_id_with_wrong_length_tx_hash() { + let tx_hash = random_hash(); + // too long + let res = FieldElementAndEventIndex::from_str(&format!("{}ff-{}", tx_hash, 1)); + assert!(res.is_err()); + + // too short + let res = FieldElementAndEventIndex::from_str(&format!( + "{}-{}", + &tx_hash[..tx_hash.len() - 2], + 1 + )); + assert!(res.is_err()); + } + + #[test] + fn should_not_parse_msg_id_with_uppercase_tx_hash() { + let tx_hash = &random_hash()[2..]; + let res = + FieldElementAndEventIndex::from_str(&format!("0x{}-{}", tx_hash.to_uppercase(), 1)); + assert!(res.is_err()); + } + + #[test] + fn should_not_parse_msg_id_with_non_hex_tx_hash() { + let msg_id = "82GKYvWv5EKm7jnYksHoh3u5M2RxHN2boPreM8Df4ej9-1"; + let res = FieldElementAndEventIndex::from_str(msg_id); + assert!(res.is_err()); + } + + #[test] + fn should_not_parse_msg_id_without_0x() { + let msg_id = "7cedbb3799cd99636045c84c5c55aef8a138f107ac8ba53a08cad1070ba4385b-1"; + let res = FieldElementAndEventIndex::from_str(msg_id); + assert!(res.is_err()); + } + + #[test] + fn should_not_parse_msg_id_with_missing_event_index() { + let msg_id = random_hash(); + let res = FieldElementAndEventIndex::from_str(&msg_id); + assert!(res.is_err()); + } + + #[test] + fn should_not_parse_msg_id_with_wrong_separator() { + let tx_hash = random_hash(); + let event_index = random_event_index(); + + let res = FieldElementAndEventIndex::from_str(&format!("{}:{}", tx_hash, event_index)); + assert!(res.is_err()); + + let res = FieldElementAndEventIndex::from_str(&format!("{}_{}", tx_hash, event_index)); + assert!(res.is_err()); + + let res = FieldElementAndEventIndex::from_str(&format!("{}+{}", tx_hash, event_index)); + assert!(res.is_err()); + + let res = FieldElementAndEventIndex::from_str(&format!("{}{}", tx_hash, event_index)); + assert!(res.is_err()); + + for _ in 0..10 { + let random_sep: char = rand::random(); + if random_sep == '-' { + continue; + } + let res = FieldElementAndEventIndex::from_str(&format!( + "{}{}{}", + tx_hash, random_sep, event_index + )); + assert!(res.is_err()); + } + } + + #[test] + fn should_not_parse_msg_id_with_event_index_with_leading_zeroes() { + let tx_hash = random_hash(); + let res = FieldElementAndEventIndex::from_str(&format!("{}-01", tx_hash)); + assert!(res.is_err()); + } + + #[test] + fn should_not_parse_msg_id_with_non_integer_event_index() { + let tx_hash = random_hash(); + let res = FieldElementAndEventIndex::from_str(&format!("{}-1.0", tx_hash)); + assert!(res.is_err()); + + let res = FieldElementAndEventIndex::from_str(&format!("{}-0x00", tx_hash)); + assert!(res.is_err()); + + let res = FieldElementAndEventIndex::from_str(&format!("{}-foobar", tx_hash)); + assert!(res.is_err()); + + let res = FieldElementAndEventIndex::from_str(&format!("{}-true", tx_hash)); + assert!(res.is_err()); + + let res = FieldElementAndEventIndex::from_str(&format!("{}-", tx_hash)); + assert!(res.is_err()); + } + + #[test] + fn should_not_parse_msg_id_with_overflowing_event_index() { + let event_index: u64 = u64::MAX; + let tx_hash = random_hash(); + let res = FieldElementAndEventIndex::from_str(&format!("{}-{}1", tx_hash, event_index)); + assert!(res.is_err()); + } +} diff --git a/packages/starknet-checked-felt/Cargo.toml b/packages/starknet-checked-felt/Cargo.toml new file mode 100644 index 000000000..fbc975934 --- /dev/null +++ b/packages/starknet-checked-felt/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "starknet-checked-felt" +version = "1.0.0" +rust-version = { workspace = true } +edition = { workspace = true } + +[dependencies] +alloy-primitives = { workspace = true } +error-stack = { workspace = true } +hex = { workspace = true } +serde = { workspace = true } +starknet-types-core = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +axelar-wasm-std = { workspace = true } + +[lints] +workspace = true diff --git a/packages/starknet-checked-felt/src/lib.rs b/packages/starknet-checked-felt/src/lib.rs new file mode 100644 index 000000000..4afa2672e --- /dev/null +++ b/packages/starknet-checked-felt/src/lib.rs @@ -0,0 +1,266 @@ +use std::ops::Deref; +use std::str::FromStr; + +use alloy_primitives::U256; +use error_stack::{ensure, Report}; +use hex::FromHexError; +use serde::{Deserialize, Serialize}; +use starknet_types_core::felt::Felt; +use thiserror::Error; + +#[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Hash, Deserialize, Serialize)] +/// A type that wraps the `starknet_types_core::felt::Felt` type +/// and makes sure that it doesn't overflow the +/// `starknet_types_core::felt::Felt::MAX` value +/// +/// Contract addresses in Starknet are Felts, which are decimals in a +/// prime field, which fit in 252 bytes and can't exceed that prime field. +/// We'll only accept hex representation of the Felts, because they're the most +/// commonly used representation for addresses. +/// +/// We'll only accept 64 char hex strings. +/// 62 and 63 hex string chars is also a valid address but we expect those to be padded +/// with zeroes. +pub struct CheckedFelt(Felt); + +#[derive(Error, Debug, PartialEq)] +pub enum CheckedFeltError { + #[error("0x prefix is missing")] + AddressPrefix, + #[error("hex string is not 64 chars")] + AddressLength, + #[error("Felt value overflowing the Felt::MAX, value")] + Overflowing, + #[error("failed to decode hex string: {0}")] + HexDecode(#[from] FromHexError), +} + +// Decimal - 3618502788666131213697322783095070105623107215331596699973092056135872020480 +// Hex - 800000000000011000000000000000000000000000000000000000000000000 +const FELT_MAX_U256: U256 = U256::from_be_bytes::<32>([ + 8, 0, 0, 0, 0, 0, 0, 17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, +]); + +impl TryFrom<&[u8]> for CheckedFelt { + type Error = Report; + + fn try_from(value: &[u8]) -> Result { + ensure!(value.len() <= 32, CheckedFeltError::Overflowing); + ensure!( + U256::from_be_slice(value) <= FELT_MAX_U256, + CheckedFeltError::Overflowing + ); + + Ok(CheckedFelt(Felt::from_bytes_be_slice(value))) + } +} + +impl TryFrom<&[u8; 32]> for CheckedFelt { + type Error = Report; + + fn try_from(value: &[u8; 32]) -> Result { + Self::try_from(&value[..]) + } +} + +impl TryFrom for CheckedFelt { + type Error = Report; + + fn try_from(value: U256) -> Result { + ensure!(value <= FELT_MAX_U256, CheckedFeltError::Overflowing); + + Ok(CheckedFelt(Felt::from_bytes_be(&value.to_be_bytes::<32>()))) + } +} + +impl TryFrom<&str> for CheckedFelt { + type Error = Report; + + fn try_from(value: &str) -> Result { + CheckedFelt::from_str(value) + } +} + +/// Creates a `CheckedFelt` from a hex string value +impl FromStr for CheckedFelt { + type Err = Report; + + fn from_str(s: &str) -> Result { + ensure!(s.starts_with("0x"), CheckedFeltError::AddressPrefix); + + let trimmed_addr = s.trim_start_matches("0x"); + ensure!(trimmed_addr.len() == 64, CheckedFeltError::AddressLength); + + let felt_hex_bytes = hex::decode(trimmed_addr).map_err(CheckedFeltError::HexDecode)?; + + Self::try_from(U256::from_be_slice(felt_hex_bytes.as_slice())) + } +} + +impl From for Felt { + fn from(value: CheckedFelt) -> Self { + value.0 + } +} + +impl std::fmt::Display for CheckedFelt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Deref for CheckedFelt { + type Target = Felt; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for CheckedFelt { + fn as_ref(&self) -> &Felt { + self.0.as_ref() + } +} + +impl std::fmt::LowerHex for CheckedFelt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let val = self.0; + + std::fmt::LowerHex::fmt(&val, f) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use alloy_primitives::U256; + use axelar_wasm_std::assert_err_contains; + use hex::FromHexError; + use starknet_types_core::felt::Felt; + + use super::{CheckedFelt, CheckedFeltError}; + + // same as valid, but with 9, instead 0 for first char + const INVALID_HEX: &str = "0xzz9ec69cd2e0c987857fbda7966ff59077e2e92c18959bdb9b0012438c452047"; + const NO_PREFIX_FELT: &str = "049ec69cd2e0c987857fbda7966ff59077e2e92c18959bdb9b0012438c452047"; + const WRONG_LENGTH_FELT: &str = + "0x00049ec69cd2e0c987857fbda7966ff59077e2e92c18959bdb9b0012438c452047"; + const OVERFLOWING_FELT: &str = + "0x949ec69cd2e0c987857fbda7966ff59077e2e92c18959bdb9b0012438c452047"; + const VALID_FELT: &str = "0x049ec69cd2e0c987857fbda7966ff59077e2e92c18959bdb9b0012438c452047"; + + #[test] + fn should_create_from_u256() { + let trimmed_addr = VALID_FELT.trim_start_matches("0x"); + + let felt = hex::decode(trimmed_addr).unwrap(); + let felt_u256: U256 = U256::from_be_slice(felt.as_slice()); + let actual = CheckedFelt::try_from(felt_u256).unwrap(); + let expected = Felt::from_hex(trimmed_addr).unwrap(); + + assert_eq!(expected, actual.0); + } + + #[test] + fn should_create_from_str() { + let actual = CheckedFelt::from_str(VALID_FELT).unwrap(); + let expected = Felt::from_hex(VALID_FELT).unwrap(); + assert_eq!(expected, actual.0); + + let actual = CheckedFelt::try_from(VALID_FELT).unwrap(); + let expected = Felt::from_hex(VALID_FELT).unwrap(); + assert_eq!(expected, actual.0); + } + + #[test] + fn should_create_from_bytes32() { + let trimmed_addr = VALID_FELT.trim_start_matches("0x"); + + let felt = hex::decode(trimmed_addr).unwrap(); + let actual = CheckedFelt::try_from(felt.as_slice()).unwrap(); + let expected = Felt::from_hex(trimmed_addr).unwrap(); + + assert_eq!(expected, actual.0); + } + + #[test] + fn should_create_from_slice() { + let trimmed_addr = VALID_FELT.trim_start_matches("0x"); + + let felt = hex::decode(trimmed_addr).unwrap(); + let actual = CheckedFelt::try_from(felt.as_slice()).unwrap(); + let expected = Felt::from_hex(trimmed_addr).unwrap(); + + assert_eq!(expected, actual.0); + } + + #[test] + fn should_not_create_from_invalid_hex() { + assert_err_contains!( + CheckedFelt::from_str(INVALID_HEX), + CheckedFeltError, + CheckedFeltError::HexDecode(FromHexError::InvalidHexCharacter { c: 'z', index: 0 }) + ); + } + + #[test] + fn should_not_create_from_felt_with_no_prefix() { + assert_err_contains!( + CheckedFelt::from_str(NO_PREFIX_FELT), + CheckedFeltError, + CheckedFeltError::AddressPrefix + ); + } + + #[test] + fn should_not_create_from_felt_with_wrong_hex_string_length() { + assert_err_contains!( + CheckedFelt::from_str(WRONG_LENGTH_FELT), + CheckedFeltError, + CheckedFeltError::AddressLength + ); + } + + #[test] + fn should_not_create_from_overflowing_str() { + assert_err_contains!( + CheckedFelt::from_str(OVERFLOWING_FELT), + CheckedFeltError, + CheckedFeltError::Overflowing + ); + + assert_err_contains!( + CheckedFelt::try_from(OVERFLOWING_FELT), + CheckedFeltError, + CheckedFeltError::Overflowing + ); + } + + #[test] + fn should_not_create_from_overflowing_u256() { + let trimmed_addr = OVERFLOWING_FELT.trim_start_matches("0x"); + let felt = hex::decode(trimmed_addr).unwrap(); + let overflowing_felt_u256: U256 = U256::from_be_slice(felt.as_slice()); + + assert_err_contains!( + CheckedFelt::try_from(overflowing_felt_u256), + CheckedFeltError, + CheckedFeltError::Overflowing + ); + } + + #[test] + fn should_not_create_from_more_than_32_bytes() { + let trimmed_addr = VALID_FELT.trim_start_matches("0x"); + let mut felt = hex::decode(trimmed_addr).unwrap(); + felt.push(1); // add a 33rd byte + + assert_err_contains!( + CheckedFelt::try_from(felt.as_slice()), + CheckedFeltError, + CheckedFeltError::Overflowing + ); + } +}