Skip to content
Open
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
20 changes: 18 additions & 2 deletions ows/crates/ows-core/src/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ pub enum ChainType {
Filecoin,
Sui,
Xrpl,
Stacks,
}

/// All supported chain families, used for universal wallet derivation.
pub const ALL_CHAIN_TYPES: [ChainType; 9] = [
pub const ALL_CHAIN_TYPES: [ChainType; 10] = [
ChainType::Evm,
ChainType::Solana,
ChainType::Bitcoin,
Expand All @@ -28,6 +29,7 @@ pub const ALL_CHAIN_TYPES: [ChainType; 9] = [
ChainType::Filecoin,
ChainType::Sui,
ChainType::Xrpl,
ChainType::Stacks,
];

/// A specific chain (e.g. "ethereum", "arbitrum") with its family type and CAIP-2 ID.
Expand Down Expand Up @@ -130,6 +132,16 @@ pub const KNOWN_CHAINS: &[Chain] = &[
chain_type: ChainType::Xrpl,
chain_id: "xrpl:mainnet",
},
Chain {
name: "stacks",
chain_type: ChainType::Stacks,
chain_id: "stacks:1",
},
Chain {
name: "stacks-testnet",
chain_type: ChainType::Stacks,
chain_id: "stacks:2147483648",
},
Chain {
name: "xrpl-testnet",
chain_type: ChainType::Xrpl,
Expand Down Expand Up @@ -205,6 +217,7 @@ impl ChainType {
ChainType::Filecoin => "fil",
ChainType::Sui => "sui",
ChainType::Xrpl => "xrpl",
ChainType::Stacks => "stacks",
}
}

Expand All @@ -221,6 +234,7 @@ impl ChainType {
ChainType::Filecoin => 461,
ChainType::Sui => 784,
ChainType::Xrpl => 144,
ChainType::Stacks => 5757,
}
}

Expand All @@ -237,6 +251,7 @@ impl ChainType {
"fil" => Some(ChainType::Filecoin),
"sui" => Some(ChainType::Sui),
"xrpl" => Some(ChainType::Xrpl),
"stacks" => Some(ChainType::Stacks),
_ => None,
}
}
Expand All @@ -255,6 +270,7 @@ impl fmt::Display for ChainType {
ChainType::Filecoin => "filecoin",
ChainType::Sui => "sui",
ChainType::Xrpl => "xrpl",
ChainType::Stacks => "stacks",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FromStr for ChainType missing Stacks variant

Medium Severity

The FromStr implementation for ChainType was not updated to include "stacks". All other match-based methods (namespace(), default_coin_type(), from_namespace(), Display) were updated with the new variant, but from_str still falls through to the _ wildcard, causing "stacks".parse::<ChainType>() to return an error instead of Ok(ChainType::Stacks).

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2d84e08. Configure here.

};
write!(f, "{}", s)
}
Expand Down Expand Up @@ -460,7 +476,7 @@ mod tests {

#[test]
fn test_all_chain_types() {
assert_eq!(ALL_CHAIN_TYPES.len(), 9);
assert_eq!(ALL_CHAIN_TYPES.len(), 10);
}

#[test]
Expand Down
3 changes: 3 additions & 0 deletions ows/crates/ows-lib/src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,9 @@ fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result<Str
)),
ChainType::Sui => broadcast_sui(rpc_url, signed_bytes),
ChainType::Xrpl => broadcast_xrpl(rpc_url, signed_bytes),
ChainType::Stacks => Err(OwsLibError::InvalidInput(
"Stacks broadcast not yet supported".into(),
)),
}
}

Expand Down
184 changes: 184 additions & 0 deletions ows/crates/ows-signer/src/chains/evm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,145 @@ impl EvmSigner {
.map_err(|e| SignerError::InvalidPrivateKey(e.to_string()))
}

fn parse_quantity_bytes(
value: &str,
field: &str,
max_len: usize,
) -> Result<Vec<u8>, SignerError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(SignerError::InvalidMessage(format!(
"{field} cannot be empty"
)));
}

let bytes = if let Some(hex_value) = trimmed
.strip_prefix("0x")
.or_else(|| trimmed.strip_prefix("0X"))
{
if hex_value.is_empty() {
Vec::new()
} else {
let normalized = if hex_value.len() % 2 == 0 {
hex_value.to_string()
} else {
format!("0{hex_value}")
};
let decoded = hex::decode(&normalized).map_err(|e| {
SignerError::InvalidMessage(format!("invalid {field} hex value: {e}"))
})?;
let first_nonzero = decoded
.iter()
.position(|byte| *byte != 0)
.unwrap_or(decoded.len());
decoded[first_nonzero..].to_vec()
}
} else {
// A decimal number fitting in `max_len` bytes has at most
// ceil(max_len * log10(256)) ≈ max_len * 2.41 digits.
// We use 3 * max_len + 1 as a conservative upper bound to
// reject impossibly large inputs before the O(n·m) conversion.
let max_digits = max_len * 3 + 1;
if trimmed.len() > max_digits {
return Err(SignerError::InvalidMessage(format!(
"{field} exceeds {max_len} bytes"
)));
}
Self::parse_decimal_bytes(trimmed, field)?
};

if bytes.len() > max_len {
return Err(SignerError::InvalidMessage(format!(
"{field} exceeds {max_len} bytes"
)));
}

Ok(bytes)
}

fn parse_decimal_bytes(value: &str, field: &str) -> Result<Vec<u8>, SignerError> {
if !value.bytes().all(|b| b.is_ascii_digit()) {
return Err(SignerError::InvalidMessage(format!(
"{field} must be decimal digits or 0x-prefixed hex"
)));
}

let value = value.trim_start_matches('0');
if value.is_empty() {
return Ok(Vec::new());
}

let mut bytes = Vec::<u8>::new();
for digit in value.bytes().map(|b| (b - b'0') as u32) {
let mut carry = digit;
for byte in bytes.iter_mut().rev() {
let acc = (*byte as u32) * 10 + carry;
*byte = (acc & 0xff) as u8;
carry = acc >> 8;
}
while carry > 0 {
bytes.insert(0, (carry & 0xff) as u8);
carry >>= 8;
}
}

Ok(bytes)
}

fn parse_address_bytes(address: &str) -> Result<[u8; 20], SignerError> {
let address = address
.strip_prefix("0x")
.or_else(|| address.strip_prefix("0X"))
.unwrap_or(address);
let decoded = hex::decode(address).map_err(|e| {
SignerError::InvalidMessage(format!("invalid authorization address: {e}"))
})?;
decoded.try_into().map_err(|_| {
SignerError::InvalidMessage(
"authorization address must be exactly 20 bytes".to_string(),
)
})
}

/// Build the EIP-7702 authorization preimage: `0x05 || rlp([chain_id, address, nonce])`.
pub fn authorization_payload(
&self,
chain_id: &str,
address: &str,
nonce: &str,
) -> Result<Vec<u8>, SignerError> {
let chain_id = Self::parse_quantity_bytes(chain_id, "chain_id", 32)?;
let address = Self::parse_address_bytes(address)?;
let nonce = Self::parse_quantity_bytes(nonce, "nonce", 8)?;

let items = [
crate::rlp::encode_bytes(&chain_id),
crate::rlp::encode_bytes(&address),
crate::rlp::encode_bytes(&nonce),
]
.concat();

let mut payload = Vec::with_capacity(1 + items.len());
payload.push(0x05);
payload.extend_from_slice(&crate::rlp::encode_list(&items));
Ok(payload)
}

/// Compute the EIP-7702 authorization digest:
/// `keccak256(0x05 || rlp([chain_id, address, nonce]))`.
pub fn authorization_hash(
&self,
chain_id: &str,
address: &str,
nonce: &str,
) -> Result<[u8; 32], SignerError> {
let payload = self.authorization_payload(chain_id, address, nonce)?;
let digest = Keccak256::digest(&payload);
let mut hash = [0u8; 32];
hash.copy_from_slice(&digest);
Ok(hash)
}

/// Sign EIP-712 typed structured data.
pub fn sign_typed_data(
&self,
Expand Down Expand Up @@ -234,6 +373,51 @@ mod tests {
assert_eq!(result.signature.len(), 65);
}

#[test]
fn test_oversized_decimal_nonce_rejected_early() {
let signer = EvmSigner;
// A nonce string far exceeding u64 range should be rejected, not churn CPU.
let huge_nonce = "9".repeat(10_000);
let err = signer
.authorization_payload(
"8453",
"0x1111111111111111111111111111111111111111",
&huge_nonce,
)
.unwrap_err();
assert!(
err.to_string().contains("exceeds"),
"expected size error, got: {err}"
);
}

#[test]
fn test_authorization_payload_uses_magic_byte_and_rlp_tuple() {
let signer = EvmSigner;
let payload = signer
.authorization_payload("0", "0x1111111111111111111111111111111111111111", "0")
.unwrap();

assert_eq!(
hex::encode(payload),
"05d78094111111111111111111111111111111111111111180"
);
}

#[test]
fn test_authorization_hash_is_keccak_of_authorization_payload() {
let signer = EvmSigner;
let payload = signer
.authorization_payload("8453", "0x1111111111111111111111111111111111111111", "7")
.unwrap();
let hash = signer
.authorization_hash("8453", "0x1111111111111111111111111111111111111111", "7")
.unwrap();

let expected = Keccak256::digest(&payload);
assert_eq!(hash.as_slice(), expected.as_slice());
}

#[test]
fn test_derivation_path() {
let signer = EvmSigner;
Expand Down
3 changes: 3 additions & 0 deletions ows/crates/ows-signer/src/chains/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod evm;
pub mod filecoin;
pub mod solana;
pub mod spark;
pub mod stacks;
pub mod sui;
pub mod ton;
pub mod tron;
Expand All @@ -15,6 +16,7 @@ pub use self::evm::EvmSigner;
pub use self::filecoin::FilecoinSigner;
pub use self::solana::SolanaSigner;
pub use self::spark::SparkSigner;
pub use self::stacks::StacksSigner;
pub use self::sui::SuiSigner;
pub use self::ton::TonSigner;
pub use self::tron::TronSigner;
Expand All @@ -36,5 +38,6 @@ pub fn signer_for_chain(chain: ChainType) -> Box<dyn ChainSigner> {
ChainType::Filecoin => Box::new(FilecoinSigner),
ChainType::Sui => Box::new(SuiSigner),
ChainType::Xrpl => Box::new(XrplSigner),
ChainType::Stacks => Box::new(StacksSigner::mainnet()),
}
}
Loading