use std::str::FromStr;
use cainome::cairo_serde::ByteArray;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Number;
use starknet_crypto::poseidon_hash_many;
use starknet_rust::core::{
types::Felt,
utils::{cairo_short_string_to_felt, get_selector_from_name},
};
use crate::starknet::errors::SignerError;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SimpleField {
pub name: String,
pub r#type: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ParentField {
pub name: String,
pub r#type: String,
pub contains: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Field {
ParentType(ParentField),
SimpleType(SimpleField),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PrimitiveType {
Object(IndexMap<String, PrimitiveType>),
Array(Vec<PrimitiveType>),
Bool(bool),
String(String),
Number(Number),
}
fn get_preset_types() -> IndexMap<String, Vec<Field>> {
let mut types = IndexMap::new();
types.insert(
"TokenAmount".to_string(),
vec![
Field::SimpleType(SimpleField {
name: "token_address".to_string(),
r#type: "ContractAddress".to_string(),
}),
Field::SimpleType(SimpleField {
name: "amount".to_string(),
r#type: "u256".to_string(),
}),
],
);
types.insert(
"NftId".to_string(),
vec![
Field::SimpleType(SimpleField {
name: "collection_address".to_string(),
r#type: "ContractAddress".to_string(),
}),
Field::SimpleType(SimpleField {
name: "token_id".to_string(),
r#type: "u256".to_string(),
}),
],
);
types.insert(
"u256".to_string(),
vec![
Field::SimpleType(SimpleField {
name: "low".to_string(),
r#type: "u128".to_string(),
}),
Field::SimpleType(SimpleField {
name: "high".to_string(),
r#type: "u128".to_string(),
}),
],
);
types
}
fn get_fields(name: &str, types: &IndexMap<String, Vec<Field>>) -> Result<Vec<Field>, SignerError> {
if let Some(fields) = types.get(name) {
return Ok(fields.clone());
}
Err(SignerError::InvalidMessage(format!(
"Type {name} not found",
)))
}
fn get_dependencies(
name: &str,
types: &IndexMap<String, Vec<Field>>,
dependencies: &mut Vec<String>,
) -> Result<(), SignerError> {
if dependencies.contains(&name.to_string()) {
return Ok(());
}
dependencies.push(name.to_string());
for field in get_fields(name, types)? {
let mut field_type = match field {
Field::SimpleType(simple_field) => simple_field.r#type.clone(),
Field::ParentType(parent_field) => parent_field.contains.clone(),
};
field_type = field_type.trim_end_matches('*').to_string();
if types.contains_key(&field_type) && !dependencies.contains(&field_type) {
get_dependencies(&field_type, types, dependencies)?;
}
}
Ok(())
}
pub fn encode_type(
name: &str,
types: &IndexMap<String, Vec<Field>>,
) -> Result<String, SignerError> {
let mut type_hash = String::new();
let mut dependencies: Vec<String> = Vec::new();
get_dependencies(name, types, &mut dependencies)?;
let (_, rest) = dependencies
.split_first_mut()
.ok_or_else(|| SignerError::InvalidMessage("No dependencies found".to_string()))?;
rest.sort_by_key(|dep| dep.to_lowercase());
for dep in dependencies {
type_hash += &format!("\"{dep}\"");
type_hash += "(";
let fields = get_fields(&dep, types)?;
for (idx, field) in fields.iter().enumerate() {
match field {
Field::SimpleType(simple_field) => {
if simple_field.r#type.starts_with('(') && simple_field.r#type.ends_with(')') {
let inner_types = &simple_field.r#type[1..simple_field.r#type.len() - 1]
.split(',')
.map(|t| {
if t.is_empty() {
t.to_string()
} else {
format!("\"{t}\"")
}
})
.collect::<Vec<String>>()
.join(",");
type_hash += &format!("\"{}\":({})", simple_field.name, inner_types);
} else {
type_hash +=
&format!("\"{}\":\"{}\"", simple_field.name, simple_field.r#type);
}
}
Field::ParentType(parent_field) => {
type_hash +=
&format!("\"{}\":\"{}\"", parent_field.name, parent_field.contains);
}
}
if idx < fields.len() - 1 {
type_hash += ",";
}
}
type_hash += ")";
}
Ok(type_hash)
}
#[derive(Debug, Default)]
pub struct Ctx {
pub base_type: String,
pub parent_type: String,
pub is_preset: bool,
}
pub(crate) struct FieldInfo {
_name: String,
r#type: String,
base_type: String,
index: usize,
}
pub(crate) fn get_value_type(
name: &str,
types: &IndexMap<String, Vec<Field>>,
) -> Result<FieldInfo, SignerError> {
for (idx, (key, value)) in types.iter().enumerate() {
if key == name {
return Ok(FieldInfo {
_name: name.to_string(),
r#type: key.clone(),
base_type: String::new(),
index: idx,
});
}
for (idx, field) in value.iter().enumerate() {
match field {
Field::SimpleType(simple_field) => {
if simple_field.name == name {
return Ok(FieldInfo {
_name: name.to_string(),
r#type: simple_field.r#type.clone(),
base_type: String::new(),
index: idx,
});
}
}
Field::ParentType(parent_field) => {
if parent_field.name == name {
return Ok(FieldInfo {
_name: name.to_string(),
r#type: parent_field.contains.clone(),
base_type: parent_field.r#type.clone(),
index: idx,
});
}
}
}
}
}
Err(SignerError::InvalidMessage(format!(
"Field {name} not found in types",
)))
}
fn get_hex(value: &str) -> Result<Felt, SignerError> {
Felt::from_str(value).map_or_else(
|_| {
cairo_short_string_to_felt(value).map_err(|e| {
SignerError::InvalidMessage(format!("Invalid shortstring for felt: {e}"))
})
},
Ok,
)
}
impl PrimitiveType {
pub fn encode(
&self,
r#type: &str,
types: &IndexMap<String, Vec<Field>>,
ctx: &mut Ctx,
) -> Result<Felt, SignerError> {
match self {
Self::Object(obj) => {
ctx.is_preset = types.contains_key(r#type);
let mut hashes = Vec::new();
if ctx.base_type == "enum" {
let (variant_name, value) = obj.first().ok_or_else(|| {
SignerError::InvalidMessage("Enum value must be populated".to_string())
})?;
let variant_type = get_value_type(variant_name, types)?;
let arr: &Vec<Self> = match value {
Self::Array(arr) => arr,
_ => {
return Err(SignerError::InvalidMessage(
"Enum value must be an array".to_string(),
));
}
};
hashes.push(Felt::from(variant_type.index as u32));
for (idx, param) in arr.iter().enumerate() {
let field_type = &variant_type
.r#type
.trim_start_matches('(')
.trim_end_matches(')')
.split(',')
.nth(idx)
.ok_or_else(|| {
SignerError::InvalidMessage("Invalid enum variant type".to_string())
})?;
let field_hash = param.encode(field_type, types, ctx)?;
hashes.push(field_hash);
}
return Ok(poseidon_hash_many(hashes.as_slice()));
}
let type_hash = encode_type(r#type, types)?;
hashes.push(get_selector_from_name(&type_hash).map_err(|e| {
SignerError::InvalidMessage(format!("Invalid type {type} for selector: {e}",))
})?);
for (field_name, value) in obj {
ctx.is_preset = types.contains_key(r#type);
let field_type = get_value_type(field_name, types)?;
ctx.base_type = field_type.base_type;
ctx.parent_type = r#type.to_string();
let field_hash = value.encode(field_type.r#type.as_str(), types, ctx)?;
hashes.push(field_hash);
}
Ok(poseidon_hash_many(hashes.as_slice()))
}
Self::Array(array) => Ok(poseidon_hash_many(
array
.iter()
.map(|x| x.encode(r#type.trim_end_matches('*'), types, ctx))
.collect::<Result<Vec<_>, _>>()?
.as_slice(),
)),
Self::Bool(boolean) => {
let v = if *boolean {
Felt::from(1_u32)
} else {
Felt::from(0_u32)
};
Ok(v)
}
Self::String(string) => match r#type {
"felt" | "shortstring" | "ContractAddress" | "ClassHash" | "timestamp" | "u128"
| "i128" => get_hex(string),
"string" => {
let byte_array = ByteArray::from_string(string).map_err(|e| {
SignerError::InvalidMessage(format!("Invalid string for bytearray: {e}"))
})?;
let mut hashes = vec![Felt::from(byte_array.data.len())];
for hash in byte_array.data {
hashes.push(hash.felt());
}
hashes.push(byte_array.pending_word);
hashes.push(Felt::from(byte_array.pending_word_len));
Ok(poseidon_hash_many(hashes.as_slice()))
}
"selector" => get_selector_from_name(string)
.map_err(|e| SignerError::InvalidMessage(format!("Invalid selector: {e}"))),
_ => Err(SignerError::InvalidMessage(format!(
"Invalid type {type} for string",
))),
},
Self::Number(number) => {
let felt = Felt::from_str(&number.to_string())
.map_err(|_| SignerError::InvalidMessage(format!("Invalid number {number}")))?;
Ok(felt)
}
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Domain {
pub name: String,
pub version: String,
#[serde(rename = "chainId")]
pub chain_id: String,
pub revision: Option<String>,
}
impl Domain {
pub fn new(name: &str, version: &str, chain_id: &str, revision: Option<&str>) -> Self {
Self {
name: name.to_string(),
version: version.to_string(),
chain_id: chain_id.to_string(),
revision: revision.map(ToString::to_string),
}
}
pub fn encode(&self, types: &IndexMap<String, Vec<Field>>) -> Result<Felt, SignerError> {
let mut object = IndexMap::new();
object.insert("name".to_string(), PrimitiveType::String(self.name.clone()));
object.insert(
"version".to_string(),
PrimitiveType::String(self.version.clone()),
);
object.insert(
"chainId".to_string(),
PrimitiveType::String(self.chain_id.clone()),
);
if let Some(revision) = &self.revision {
object.insert(
"revision".to_string(),
PrimitiveType::String(revision.clone()),
);
}
PrimitiveType::Object(object).encode("StarknetDomain", types, &mut Default::default())
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TypedData {
pub types: IndexMap<String, Vec<Field>>,
#[serde(rename = "primaryType")]
pub primary_type: String,
pub domain: Domain,
pub message: IndexMap<String, PrimitiveType>,
}
#[derive(Debug, Clone)]
pub struct TypedDataHash {
pub hash: Felt,
#[allow(dead_code)]
pub domain_separator_hash: Felt,
#[allow(dead_code)]
pub message_hash: Felt,
}
impl TypedData {
pub fn new(
types: IndexMap<String, Vec<Field>>,
primary_type: &str,
domain: Domain,
message: IndexMap<String, PrimitiveType>,
) -> Self {
Self {
types,
primary_type: primary_type.to_string(),
domain,
message,
}
}
pub fn encode(&self, account: Felt) -> Result<TypedDataHash, SignerError> {
let preset_types = get_preset_types();
let mut all_types = preset_types;
all_types.extend(self.types.clone());
if self.domain.revision.clone().unwrap_or("1".to_string()) != "1" {
return Err(SignerError::InvalidMessage(
"Legacy revision 0 is not supported".to_string(),
));
}
let prefix_message = cairo_short_string_to_felt("StarkNet Message").unwrap();
let domain_hash = self.domain.encode(&self.types)?;
let message_hash = PrimitiveType::Object(self.message.clone()).encode(
&self.primary_type,
&all_types,
&mut Default::default(),
)?;
Ok(TypedDataHash {
hash: poseidon_hash_many(
vec![prefix_message, domain_hash, account, message_hash].as_slice(),
),
domain_separator_hash: domain_hash,
message_hash,
})
}
}
#[cfg(test)]
mod tests {
use rstest::*;
use starknet_rust::core::types::Felt;
use starknet_rust::core::utils::starknet_keccak;
use super::*;
#[rstest]
#[case(EXAMPLE_BASE_TYPES)]
#[case(EXAMPLE_ENUM)]
#[case(EXAMPLE_PRESET_TYPES)]
#[case(MAIL_STRUCT_ARRAY)]
fn test_read_json(#[case] json_data: &str) {
let reader = std::io::BufReader::new(json_data.as_bytes());
let typed_data: TypedData = serde_json::from_reader(reader).unwrap();
assert!(!typed_data.types.is_empty());
assert!(!typed_data.primary_type.is_empty());
}
#[rstest]
#[case(
EXAMPLE_BASE_TYPES,
"\"Example\"(\"n0\":\"felt\",\"n1\":\"bool\",\"n2\":\"string\",\"n3\":\"selector\",\"n4\":\"u128\",\"n5\":\"i128\",\"n6\":\"ContractAddress\",\"n7\":\"ClassHash\",\"n8\":\"timestamp\",\"n9\":\"shortstring\")"
)]
#[case(
MAIL_STRUCT_ARRAY,
"\"Mail\"(\"from\":\"Person\",\"to\":\"Person\",\"posts_len\":\"felt\",\"posts\":\"Post*\")\"Person\"(\"name\":\"felt\",\"wallet\":\"felt\")\"Post\"(\"title\":\"felt\",\"content\":\"felt\")"
)]
#[case(
EXAMPLE_ENUM,
"\"Example\"(\"someEnum\":\"MyEnum\")\"MyEnum\"(\"Variant 1\":(),\"Variant 2\":(\"u128\",\"u128*\"),\"Variant 3\":(\"u128\"))"
)]
#[case(
EXAMPLE_PRESET_TYPES,
"\"Example\"(\"n0\":\"TokenAmount\",\"n1\":\"NftId\")"
)]
fn test_type_encode(#[case] json_data: &str, #[case] expected_encoded: &str) {
let reader = std::io::BufReader::new(json_data.as_bytes());
let typed_data: TypedData = serde_json::from_reader(reader).unwrap();
let encoded = encode_type(&typed_data.primary_type, &typed_data.types).unwrap();
assert_eq!(encoded, expected_encoded);
}
#[test]
fn test_selector_encode() {
let selector = PrimitiveType::String("transfer".to_string());
let selector_hash = PrimitiveType::String(starknet_keccak(b"transfer").to_string());
let preset_types = get_preset_types();
let encoded_selector = selector
.encode("selector", &preset_types, &mut Default::default())
.unwrap();
let raw_encoded_selector = selector_hash
.encode("felt", &preset_types, &mut Default::default())
.unwrap();
assert_eq!(encoded_selector, raw_encoded_selector);
assert_eq!(encoded_selector, starknet_keccak(b"transfer"));
}
#[test]
fn test_domain_hash() {
let reader = std::io::BufReader::new(EXAMPLE_BASE_TYPES.as_bytes());
let typed_data: TypedData = serde_json::from_reader(reader).unwrap();
let domain_hash = typed_data.domain.encode(&typed_data.types).unwrap();
assert_eq!(
domain_hash,
Felt::from_hex("0x555f72e550b308e50c1a4f8611483a174026c982a9893a05c185eeb85399657")
.unwrap()
);
}
#[rstest]
#[case(
EXAMPLE_BASE_TYPES,
"0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826",
"0x2d80b87b8bc32068247c779b2ef0f15f65c9c449325e44a9df480fb01eb43ec"
)]
#[case(
EXAMPLE_ENUM,
"0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826",
"0x3df10475ad5a8f49db4345a04a5b09164d2e24b09f6e1e236bc1ccd87627cc"
)]
#[case(
EXAMPLE_PRESET_TYPES,
"0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826",
"0x185b339d5c566a883561a88fb36da301051e2c0225deb325c91bb7aa2f3473a"
)]
#[case(
MAIL_STRUCT_ARRAY,
"0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826",
"0x1df06fd32d689b5431a784a33b02314cc7f395f3bda3ecedf97deafaa66ea31"
)]
fn test_message_hash(
#[case] example_data: &str,
#[case] address_hex: &str,
#[case] expected_hash_hex: &str,
) {
let address = Felt::from_str(address_hex).unwrap();
let reader = std::io::BufReader::new(example_data.as_bytes());
let typed_data: TypedData = serde_json::from_reader(reader).unwrap();
let message_hash = typed_data.encode(address).unwrap().hash;
assert_eq!(message_hash, Felt::from_str(expected_hash_hex).unwrap());
}
const EXAMPLE_BASE_TYPES: &str = r#"
{
"types": {
"StarknetDomain": [
{ "name": "name", "type": "shortstring" },
{ "name": "version", "type": "shortstring" },
{ "name": "chainId", "type": "shortstring" },
{ "name": "revision", "type": "shortstring" }
],
"Example": [
{ "name": "n0", "type": "felt" },
{ "name": "n1", "type": "bool" },
{ "name": "n2", "type": "string" },
{ "name": "n3", "type": "selector" },
{ "name": "n4", "type": "u128" },
{ "name": "n5", "type": "i128" },
{ "name": "n6", "type": "ContractAddress" },
{ "name": "n7", "type": "ClassHash" },
{ "name": "n8", "type": "timestamp" },
{ "name": "n9", "type": "shortstring" }
]
},
"primaryType": "Example",
"domain": {
"name": "StarkNet Mail",
"version": "1",
"chainId": "1",
"revision": "1"
},
"message": {
"n0": "0x3e8",
"n1": true,
"n2": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"n3": "transfer",
"n4": "0x3e8",
"n5": "-170141183460469231731687303715884105727",
"n6": "0x3e8",
"n7": "0x3e8",
"n8": 1000,
"n9": "transfer"
}
}"#;
const EXAMPLE_ENUM: &str = r#"
{
"types": {
"StarknetDomain": [
{ "name": "name", "type": "shortstring" },
{ "name": "version", "type": "shortstring" },
{ "name": "chainId", "type": "shortstring" },
{ "name": "revision", "type": "shortstring" }
],
"Example": [{ "name": "someEnum", "type": "enum", "contains": "MyEnum" }],
"MyEnum": [
{ "name": "Variant 1", "type": "()" },
{ "name": "Variant 2", "type": "(u128,u128*)" },
{ "name": "Variant 3", "type": "(u128)" }
]
},
"primaryType": "Example",
"domain": {
"name": "StarkNet Mail",
"version": "1",
"chainId": "1",
"revision": "1"
},
"message": {
"someEnum": {
"Variant 2": [2, [0, 1]]
}
}
}"#;
const EXAMPLE_PRESET_TYPES: &str = r#"
{
"types": {
"StarknetDomain": [
{ "name": "name", "type": "shortstring" },
{ "name": "version", "type": "shortstring" },
{ "name": "chainId", "type": "shortstring" },
{ "name": "revision", "type": "shortstring" }
],
"Example": [
{ "name": "n0", "type": "TokenAmount" },
{ "name": "n1", "type": "NftId" }
]
},
"primaryType": "Example",
"domain": {
"name": "StarkNet Mail",
"version": "1",
"chainId": "1",
"revision": "1"
},
"message": {
"n0": {
"token_address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
"amount": {
"low": "0x3e8",
"high": "0x0"
}
},
"n1": {
"collection_address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
"token_id": {
"low": "0x3e8",
"high": "0x0"
}
}
}
}"#;
const MAIL_STRUCT_ARRAY: &str = r#"
{
"types": {
"StarknetDomain": [
{ "name": "name", "type": "shortstring" },
{ "name": "version", "type": "shortstring" },
{ "name": "chainId", "type": "shortstring" },
{ "name": "revision", "type": "shortstring" }
],
"Person": [
{ "name": "name", "type": "felt" },
{ "name": "wallet", "type": "felt" }
],
"Post": [
{ "name": "title", "type": "felt" },
{ "name": "content", "type": "felt" }
],
"Mail": [
{ "name": "from", "type": "Person" },
{ "name": "to", "type": "Person" },
{ "name": "posts_len", "type": "felt" },
{ "name": "posts", "type": "Post*" }
]
},
"primaryType": "Mail",
"domain": {
"name": "StarkNet Mail",
"version": "1",
"chainId": "1",
"revision": "1"
},
"message": {
"from": {
"name": "Cow",
"wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"
},
"to": {
"name": "Bob",
"wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"
},
"posts_len": 2,
"posts": [
{ "title": "Greeting", "content": "Hello, Bob!" },
{ "title": "Farewell", "content": "Goodbye, Bob!" }
]
}
}"#;
}