diff --git a/Cargo.lock b/Cargo.lock index 02ca933718..cbcdda53b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3058,12 +3058,16 @@ dependencies = [ "chrono", "gem_client", "gem_encoding", + "gem_hash", + "hex", "num-bigint", + "num-traits", "primitives", "reqwest 0.13.2", "serde", "serde_json", "settings", + "signer", "tokio", ] @@ -3295,12 +3299,12 @@ name = "gem_near" version = "1.0.0" dependencies = [ "async-trait", - "base64", "bs58", "chain_traits", "chrono", "futures", "gem_client", + "gem_encoding", "gem_hash", "gem_jsonrpc", "hex", @@ -3391,7 +3395,11 @@ dependencies = [ "chrono", "futures", "gem_client", + "gem_encoding", + "gem_hash", + "hex", "num-bigint", + "num-traits", "number_formatter", "primitives", "reqwest 0.13.2", @@ -3399,6 +3407,7 @@ dependencies = [ "serde_json", "serde_serializers", "settings", + "signer", "tokio", "url", ] @@ -7000,6 +7009,7 @@ dependencies = [ "alloy-primitives", "bs58", "ed25519-dalek", + "gem_encoding", "gem_hash", "hex", "k256", diff --git a/crates/gem_algorand/Cargo.toml b/crates/gem_algorand/Cargo.toml index 99e8bf94bc..f72ef3f0a7 100644 --- a/crates/gem_algorand/Cargo.toml +++ b/crates/gem_algorand/Cargo.toml @@ -6,6 +6,7 @@ edition = { workspace = true } [features] default = [] rpc = ["dep:chrono", "dep:chain_traits", "dep:gem_client"] +signer = ["dep:gem_hash", "dep:hex", "dep:num-traits", "dep:signer"] reqwest = ["gem_client/reqwest"] chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] @@ -15,14 +16,20 @@ serde = { workspace = true } serde_json = { workspace = true } gem_encoding = { path = "../gem_encoding" } num-bigint = { workspace = true } +num-traits = { workspace = true, optional = true } chrono = { workspace = true, optional = true } primitives = { path = "../primitives" } chain_traits = { path = "../chain_traits", optional = true } gem_client = { path = "../gem_client", optional = true } +gem_hash = { path = "../gem_hash", optional = true } +hex = { workspace = true, optional = true } +signer = { path = "../signer", optional = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } reqwest = { workspace = true } settings = { path = "../settings", features = ["testkit"] } +primitives = { path = "../primitives", features = ["testkit"] } +hex = { workspace = true } diff --git a/crates/gem_algorand/src/address.rs b/crates/gem_algorand/src/address.rs new file mode 100644 index 0000000000..a6b3fdfc01 --- /dev/null +++ b/crates/gem_algorand/src/address.rs @@ -0,0 +1,67 @@ +use gem_encoding::{decode_base32, encode_base32}; +use gem_hash::sha2::sha512_256; +use primitives::Address; +use signer::Base32Address; +use std::fmt; + +const ADDRESS_DATA_LENGTH: usize = 32; +const ADDRESS_CHECKSUM_LENGTH: usize = 4; + +#[derive(Clone)] +pub struct AlgorandAddress { + pub(crate) base32: Base32Address, +} + +impl Address for AlgorandAddress { + fn try_parse(address: &str) -> Option { + let decoded = decode_base32(address.as_bytes()).ok()?; + if decoded.len() != ADDRESS_DATA_LENGTH + ADDRESS_CHECKSUM_LENGTH { + return None; + } + let base32 = Base32Address::from_slice(&decoded[..ADDRESS_DATA_LENGTH]).ok()?; + (decoded[ADDRESS_DATA_LENGTH..] == Self::checksum(base32.payload())).then_some(Self { base32 }) + } + + fn as_bytes(&self) -> &[u8] { + self.base32.payload() + } + + fn encode(&self) -> String { + let mut raw = Vec::with_capacity(ADDRESS_DATA_LENGTH + ADDRESS_CHECKSUM_LENGTH); + raw.extend_from_slice(self.base32.payload()); + raw.extend_from_slice(&Self::checksum(self.base32.payload())); + encode_base32(&raw) + } +} + +impl AlgorandAddress { + fn checksum(bytes: &[u8; 32]) -> [u8; ADDRESS_CHECKSUM_LENGTH] { + let digest = sha512_256(bytes); + let mut checksum = [0u8; ADDRESS_CHECKSUM_LENGTH]; + checksum.copy_from_slice(&digest[digest.len() - ADDRESS_CHECKSUM_LENGTH..]); + checksum + } +} + +impl fmt::Display for AlgorandAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.encode()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_algorand_address() { + assert!(AlgorandAddress::is_valid("QKDS2YGDHDFZFAAGA4HAF3AJIKW5ZN46P66QDR3ELCXKKJUJTPJSXVHNQU")); + assert!(!AlgorandAddress::is_valid("")); + assert!(!AlgorandAddress::is_valid("invalid")); + assert!(!AlgorandAddress::is_valid("QKDS2YGDHDFZFAAGA4HAF3AJIKW5ZN46P66QDR3ELCXKKJUJTPJSXVHNQX")); + + let addr = AlgorandAddress::from_str("QKDS2YGDHDFZFAAGA4HAF3AJIKW5ZN46P66QDR3ELCXKKJUJTPJSXVHNQU").unwrap(); + assert_eq!(addr.to_string(), "QKDS2YGDHDFZFAAGA4HAF3AJIKW5ZN46P66QDR3ELCXKKJUJTPJSXVHNQU"); + assert_eq!(addr.as_bytes().len(), 32); + } +} diff --git a/crates/gem_algorand/src/lib.rs b/crates/gem_algorand/src/lib.rs index 97c0a1199e..9abe408723 100644 --- a/crates/gem_algorand/src/lib.rs +++ b/crates/gem_algorand/src/lib.rs @@ -4,8 +4,16 @@ pub mod rpc; #[cfg(feature = "rpc")] pub mod provider; +#[cfg(feature = "signer")] +pub mod address; pub mod constants; pub mod models; +#[cfg(feature = "signer")] +pub mod signer; +#[cfg(feature = "signer")] +pub use address::AlgorandAddress; #[cfg(feature = "rpc")] pub use rpc::client::AlgorandClient; +#[cfg(feature = "signer")] +pub use signer::*; diff --git a/crates/gem_algorand/src/models/mod.rs b/crates/gem_algorand/src/models/mod.rs index 82aec10f20..b37fc59074 100644 --- a/crates/gem_algorand/src/models/mod.rs +++ b/crates/gem_algorand/src/models/mod.rs @@ -2,6 +2,8 @@ pub mod account; pub mod asset; pub mod block; pub mod indexer; +#[cfg(feature = "signer")] +pub mod signing; pub mod transaction; pub use account::*; diff --git a/crates/gem_algorand/src/models/signing/mod.rs b/crates/gem_algorand/src/models/signing/mod.rs new file mode 100644 index 0000000000..5721429979 --- /dev/null +++ b/crates/gem_algorand/src/models/signing/mod.rs @@ -0,0 +1,5 @@ +mod operation; +mod transaction; + +pub use operation::Operation; +pub use transaction::AlgorandTransaction; diff --git a/crates/gem_algorand/src/models/signing/operation.rs b/crates/gem_algorand/src/models/signing/operation.rs new file mode 100644 index 0000000000..47502623d6 --- /dev/null +++ b/crates/gem_algorand/src/models/signing/operation.rs @@ -0,0 +1,41 @@ +use crate::address::AlgorandAddress; + +const TX_TYPE_PAYMENT: &str = "pay"; +const TX_TYPE_ASSET_TRANSFER: &str = "axfer"; + +// Canonical msgpack field counts per operation type. +const PAYMENT_FIELDS: u8 = 9; +const PAYMENT_ZERO_AMOUNT_FIELDS: u8 = 8; +const ASSET_TRANSFER_FIELDS: u8 = 10; +const ASSET_OPT_IN_FIELDS: u8 = 9; + +pub enum Operation { + Payment { destination: AlgorandAddress, amount: u64 }, + AssetTransfer { destination: AlgorandAddress, amount: u64, asset_id: u64 }, + AssetOptIn { asset_id: u64 }, +} + +impl Operation { + pub fn tx_type(&self) -> &'static str { + match self { + Self::Payment { .. } => TX_TYPE_PAYMENT, + Self::AssetTransfer { .. } | Self::AssetOptIn { .. } => TX_TYPE_ASSET_TRANSFER, + } + } + + pub fn size(&self) -> u8 { + match self { + Self::Payment { amount: 0, .. } => PAYMENT_ZERO_AMOUNT_FIELDS, + Self::Payment { .. } => PAYMENT_FIELDS, + Self::AssetTransfer { .. } => ASSET_TRANSFER_FIELDS, + Self::AssetOptIn { .. } => ASSET_OPT_IN_FIELDS, + } + } + + pub fn payment_amount(&self) -> Option { + match self { + Self::Payment { amount, .. } if *amount > 0 => Some(*amount), + _ => None, + } + } +} diff --git a/crates/gem_algorand/src/models/signing/transaction.rs b/crates/gem_algorand/src/models/signing/transaction.rs new file mode 100644 index 0000000000..9122d1dc17 --- /dev/null +++ b/crates/gem_algorand/src/models/signing/transaction.rs @@ -0,0 +1,66 @@ +use super::Operation; +use crate::address::AlgorandAddress; +use gem_encoding::decode_base64; +use num_traits::ToPrimitive; +use primitives::{Address, SignerError, SignerInput}; +use signer::InvalidInput; + +const TRANSACTION_VALIDITY_ROUNDS: u64 = 1000; + +pub struct AlgorandTransaction { + pub sender: AlgorandAddress, + pub fee: u64, + pub first_round: u64, + pub last_round: u64, + pub genesis_id: String, + pub genesis_hash: Vec, + pub note: Vec, + pub operation: Operation, +} + +impl AlgorandTransaction { + pub fn transfer(input: &SignerInput) -> Result { + Self::from_input( + input, + Operation::Payment { + destination: AlgorandAddress::from_str(&input.destination_address).invalid_input("invalid Algorand address")?, + amount: input.value.parse::().invalid_input("invalid Algorand amount")?, + }, + ) + } + + pub fn token_transfer(input: &SignerInput) -> Result { + Self::from_input( + input, + Operation::AssetTransfer { + destination: AlgorandAddress::from_str(&input.destination_address).invalid_input("invalid Algorand address")?, + amount: input.value.parse::().invalid_input("invalid Algorand amount")?, + asset_id: get_asset_id(input)?, + }, + ) + } + + pub fn account_action(input: &SignerInput) -> Result { + Self::from_input(input, Operation::AssetOptIn { asset_id: get_asset_id(input)? }) + } + + fn from_input(input: &SignerInput, operation: Operation) -> Result { + let fee = input.fee.fee.to_u64().invalid_input("invalid transaction fee")?; + let first_round = input.metadata.get_sequence().map_err(SignerError::from_display)?; + + Ok(Self { + sender: AlgorandAddress::from_str(&input.sender_address).invalid_input("invalid Algorand address")?, + fee, + first_round, + last_round: first_round + TRANSACTION_VALIDITY_ROUNDS, + genesis_id: input.metadata.get_chain_id().map_err(SignerError::from_display)?, + genesis_hash: decode_base64(&input.metadata.get_block_hash().map_err(SignerError::from_display)?).invalid_input("invalid Algorand genesis hash")?, + note: input.memo.clone().unwrap_or_default().into_bytes(), + operation, + }) + } +} + +fn get_asset_id(input: &SignerInput) -> Result { + input.input_type.get_asset().id.get_token_id()?.parse::().invalid_input("invalid Algorand asset id") +} diff --git a/crates/gem_algorand/src/models/transaction.rs b/crates/gem_algorand/src/models/transaction.rs index d10c93afdd..f5362fd140 100644 --- a/crates/gem_algorand/src/models/transaction.rs +++ b/crates/gem_algorand/src/models/transaction.rs @@ -1,6 +1,7 @@ use core::str; use gem_encoding::decode_base64; +use primitives::TransactionState; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -66,4 +67,22 @@ pub struct TransactionBroadcast { pub struct TransactionStatus { #[serde(rename = "confirmed-round")] pub confirmed_round: Option, + #[serde(rename = "pool-error")] + pub pool_error: Option, +} + +impl TransactionStatus { + pub fn state(&self) -> TransactionState { + if self.confirmed_round.unwrap_or(0) > 0 { + TransactionState::Confirmed + } else if self.has_pool_error() { + TransactionState::Failed + } else { + TransactionState::Pending + } + } + + fn has_pool_error(&self) -> bool { + self.pool_error.as_ref().is_some_and(|error| !error.trim().is_empty()) + } } diff --git a/crates/gem_algorand/src/provider/transaction_state_mapper.rs b/crates/gem_algorand/src/provider/transaction_state_mapper.rs index bcf9dda161..5289adc72f 100644 --- a/crates/gem_algorand/src/provider/transaction_state_mapper.rs +++ b/crates/gem_algorand/src/provider/transaction_state_mapper.rs @@ -1,13 +1,11 @@ use crate::models::TransactionStatus; -use primitives::{TransactionChange, TransactionState, TransactionUpdate}; +use primitives::{TransactionChange, TransactionUpdate}; pub fn map_transaction_status(transaction: &TransactionStatus) -> TransactionUpdate { - let confirmed_round = transaction.confirmed_round.unwrap_or(0); - let state: TransactionState = if confirmed_round > 0 { TransactionState::Confirmed } else { TransactionState::Failed }; - + let state = transaction.state(); let mut changes = Vec::new(); - if confirmed_round > 0 { - changes.push(TransactionChange::BlockNumber(confirmed_round.to_string())); + if let Some(round) = transaction.confirmed_round.filter(|r| *r > 0) { + changes.push(TransactionChange::BlockNumber(round.to_string())); } TransactionUpdate { state, changes } @@ -21,7 +19,10 @@ mod tests { #[test] fn test_map_transaction_status_confirmed() { - let result = map_transaction_status(&TransactionStatus { confirmed_round: Some(52961610) }); + let result = map_transaction_status(&TransactionStatus { + confirmed_round: Some(52961610), + pool_error: None, + }); assert_eq!(result.state, TransactionState::Confirmed); assert_eq!(result.changes, vec![TransactionChange::BlockNumber("52961610".to_string())]); } @@ -38,6 +39,16 @@ mod tests { fn test_map_transaction_status_pending_data() { let status: TransactionStatus = serde_json::from_str(include_str!("../../testdata/transaction_transfer_pending.json")).unwrap(); let result = map_transaction_status(&status); + assert_eq!(result.state, TransactionState::Pending); + assert_eq!(result.changes.len(), 0); + } + + #[test] + fn test_map_transaction_status_failed_with_pool_error() { + let result = map_transaction_status(&TransactionStatus { + confirmed_round: None, + pool_error: Some("overspend".to_string()), + }); assert_eq!(result.state, TransactionState::Failed); assert_eq!(result.changes.len(), 0); } diff --git a/crates/gem_algorand/src/signer/chain_signer.rs b/crates/gem_algorand/src/signer/chain_signer.rs new file mode 100644 index 0000000000..6e194600f7 --- /dev/null +++ b/crates/gem_algorand/src/signer/chain_signer.rs @@ -0,0 +1,100 @@ +use crate::models::signing::AlgorandTransaction; +use crate::signer::signing::sign_transaction; +use primitives::{ChainSigner, SignerError, SignerInput}; + +#[derive(Default)] +pub struct AlgorandChainSigner; + +impl ChainSigner for AlgorandChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_transaction(&AlgorandTransaction::transfer(input)?, private_key) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_transaction(&AlgorandTransaction::token_transfer(input)?, private_key) + } + + fn sign_account_action(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_transaction(&AlgorandTransaction::account_action(input)?, private_key) + } +} + +#[cfg(test)] +mod tests { + // Tests taken from https://github.com/trustwallet/wallet-core/blob/master/tests/chains/Algorand/TWAnySignerTests.cpp + use super::*; + use primitives::{Asset, AssetId, AssetType, Chain, TransactionFee, TransactionLoadInput, TransactionLoadMetadata}; + + const PRIVATE_KEY: &str = "5a6a3cfe5ff4cc44c19381d15a0d16de2a76ee5c9b9d83b232e38cb5a2c84b04"; + const SENDER: &str = "QKDS2YGDHDFZFAAGA4HAF3AJIKW5ZN46P66QDR3ELCXKKJUJTPJSXVHNQU"; + const DESTINATION: &str = "GJIWJSX2EU5RC32LKTDDXWLA2YICBHKE35RV2ZPASXZYKWUWXFLKNFSS4U"; + + #[test] + fn test_sign_algorand_transactions() { + let key = hex::decode(PRIVATE_KEY).unwrap(); + let token = Asset::new(AssetId::token(Chain::Algorand, "13379146"), "AlgoToken".into(), "ALGO".into(), 6, AssetType::TOKEN); + let metadata = |sequence: u64| TransactionLoadMetadata::Algorand { + sequence, + block_hash: "SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=".into(), + chain_id: "testnet-v1.0".into(), + }; + + // Native transfer + let input = SignerInput::new( + TransactionLoadInput::mock_transfer(Asset::from_chain(Chain::Algorand), SENDER, DESTINATION, "1000000", 2340, None, metadata(15775683)), + TransactionFee::new_from_fee(2340.into()), + ); + let signed = AlgorandChainSigner.sign_transfer(&input, &key).unwrap(); + assert_eq!( + signed, + "82a3736967c440e87330ca542b7ee4f09ff31f8752e51c8a13fdf2b9d0c07a67a40ed3c4c981e4e23b1ea5f17cb5f34e5e66a937110f4ae5800baf09a12ea18dda25193c399d06a374786e89a3616d74ce000f4240a3666565cd0924a26676ce00f0b7c3a367656eac746573746e65742d76312e30a26768c4204863b518a4b3c84ec810f22d4f1081cb0f71f059a7ac20dec62f7f70e5093a22a26c76ce00f0bbaba3726376c420325164cafa253b116f4b54c63bd960d610209d44df635d65e095f3855a96b956a3736e64c42082872d60c338cb928006070e02ec0942addcb79e7fbd01c76458aea526899bd3a474797065a3706179" + ); + + // Token transfer + let input = SignerInput::new( + TransactionLoadInput::mock_transfer(token.clone(), SENDER, DESTINATION, "1000000", 2340, None, metadata(15775683)), + TransactionFee::new_from_fee(2340.into()), + ); + let signed = AlgorandChainSigner.sign_token_transfer(&input, &key).unwrap(); + assert_eq!( + signed, + "82a3736967c440412720eff99a17280a437bdb8eeba7404b855d6433fffd5dde7f7966c1f9ae531a1af39e18b8a58b4a6c6acb709cca92f8a18c36d8328be9520c915311027005a374786e8aa461616d74ce000f4240a461726376c420325164cafa253b116f4b54c63bd960d610209d44df635d65e095f3855a96b956a3666565cd0924a26676ce00f0b7c3a367656eac746573746e65742d76312e30a26768c4204863b518a4b3c84ec810f22d4f1081cb0f71f059a7ac20dec62f7f70e5093a22a26c76ce00f0bbaba3736e64c42082872d60c338cb928006070e02ec0942addcb79e7fbd01c76458aea526899bd3a474797065a56178666572a478616964ce00cc264a" + ); + + // Account action (asset opt-in) + let input = SignerInput::new( + TransactionLoadInput::mock_transfer(token, SENDER, "", "0", 2340, None, metadata(15775553)), + TransactionFee::new_from_fee(2340.into()), + ); + let signed = AlgorandChainSigner.sign_account_action(&input, &key).unwrap(); + assert_eq!( + signed, + "82a3736967c440f3a29d9a40271c00b542b38ab2ccb4967015ae6609368d4b8eb2f5e2b5348577cf9e0f62b0777ccb2d8d9b943b15c24c0cf1db312cb01a3c198d9d9c6c5bb00ba374786e89a461726376c42082872d60c338cb928006070e02ec0942addcb79e7fbd01c76458aea526899bd3a3666565cd0924a26676ce00f0b741a367656eac746573746e65742d76312e30a26768c4204863b518a4b3c84ec810f22d4f1081cb0f71f059a7ac20dec62f7f70e5093a22a26c76ce00f0bb29a3736e64c42082872d60c338cb928006070e02ec0942addcb79e7fbd01c76458aea526899bd3a474797065a56178666572a478616964ce00cc264a" + ); + } + + #[test] + fn test_sign_native_transfer_with_note() { + let key = hex::decode("d5b43d706ef0cb641081d45a2ec213b5d8281f439f2425d1af54e2afdaabf55b").unwrap(); + let load = TransactionLoadInput::mock_transfer( + Asset::from_chain(Chain::Algorand), + "MG7QMDX4ALRIQ7P77SHNQUTIZDAJDQAT53PTCW6FA6KNAKUHSGW4FGK32Q", + "CRLADAHJZEW2GFY2UPEHENLOGCUOU74WYSTUXQLVLJUJFHEUZOHYZNWYR4", + "1000000000000", + 263000, + Some("hello"), + TransactionLoadMetadata::Algorand { + sequence: 1937767, + block_hash: "wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=".into(), + chain_id: "mainnet-v1.0".into(), + }, + ); + let input = SignerInput::new(load, TransactionFee::new_from_fee(263000.into())); + + let signed = AlgorandChainSigner.sign_transfer(&input, &key).unwrap(); + assert_eq!( + signed, + "82a3736967c440baa00062adcdcb5875e4435cdc6885d26bfe5308ab17983c0fda790b7103051fcb111554e5badfc0ac7edf7e1223a434342a9eeed5cdb047690827325051560ba374786e8aa3616d74cf000000e8d4a51000a3666565ce00040358a26676ce001d9167a367656eac6d61696e6e65742d76312e30a26768c420c061c4d8fc1dbdded2d7604be4568e3f6d041987ac37bde4b620b5ab39248adfa26c76ce001d954fa46e6f7465c40568656c6c6fa3726376c42014560180e9c92da3171aa3c872356e30a8ea7f96c4a74bc1755a68929c94cb8fa3736e64c42061bf060efc02e2887dfffc8ed85268c8c091c013eedf315bc50794d02a8791ada474797065a3706179" + ); + } +} diff --git a/crates/gem_algorand/src/signer/mod.rs b/crates/gem_algorand/src/signer/mod.rs new file mode 100644 index 0000000000..7353a15fb2 --- /dev/null +++ b/crates/gem_algorand/src/signer/mod.rs @@ -0,0 +1,5 @@ +mod chain_signer; +mod serialization; +mod signing; + +pub use chain_signer::AlgorandChainSigner; diff --git a/crates/gem_algorand/src/signer/serialization.rs b/crates/gem_algorand/src/signer/serialization.rs new file mode 100644 index 0000000000..0df6555104 --- /dev/null +++ b/crates/gem_algorand/src/signer/serialization.rs @@ -0,0 +1,151 @@ +use crate::address::AlgorandAddress; +use crate::models::signing::{AlgorandTransaction, Operation}; +use primitives::{Address, SignerError}; + +const SIGNED_TX_FIELDS: u8 = 2; + +const FIXMAP_PREFIX: u8 = 0x80; +const FIXSTR_PREFIX: u8 = 0xa0; +const FIXSTR_MAX_LEN: usize = 0x20; +const STR8_PREFIX: u8 = 0xd9; +const UINT8_PREFIX: u8 = 0xcc; +const UINT16_PREFIX: u8 = 0xcd; +const UINT32_PREFIX: u8 = 0xce; +const UINT64_PREFIX: u8 = 0xcf; +const BIN8_PREFIX: u8 = 0xc4; +const BIN16_PREFIX: u8 = 0xc5; + +/// Encode an unsigned Algorand transaction as canonical MessagePack (keys in lexicographic order). +pub(crate) fn encode_transaction(tx: &AlgorandTransaction) -> Result, SignerError> { + let mut data = Vec::new(); + + let mut size = tx.operation.size(); + if !tx.note.is_empty() { + size += 1; + } + + data.push(FIXMAP_PREFIX | size); + + if let Some(amount) = tx.operation.payment_amount() { + encode_string("amt", &mut data); + encode_uint(amount, &mut data); + } + + match &tx.operation { + Operation::Payment { destination, .. } => encode_payment(tx, destination, &mut data), + Operation::AssetTransfer { destination, amount, asset_id } => encode_asset_transfer(tx, destination, *amount, *asset_id, &mut data), + Operation::AssetOptIn { asset_id } => encode_asset_opt_in(tx, *asset_id, &mut data), + } + + Ok(data) +} + +fn encode_payment(tx: &AlgorandTransaction, destination: &AlgorandAddress, data: &mut Vec) { + encode_common_fields(tx, data); + encode_string("rcv", data); + encode_address(destination, data); + encode_sender_and_type(tx, data); +} + +fn encode_asset_transfer(tx: &AlgorandTransaction, destination: &AlgorandAddress, amount: u64, asset_id: u64, data: &mut Vec) { + encode_string("aamt", data); + encode_uint(amount, data); + encode_string("arcv", data); + encode_address(destination, data); + encode_common_fields(tx, data); + encode_sender_and_type(tx, data); + encode_string("xaid", data); + encode_uint(asset_id, data); +} + +fn encode_asset_opt_in(tx: &AlgorandTransaction, asset_id: u64, data: &mut Vec) { + encode_string("arcv", data); + encode_address(&tx.sender, data); + encode_common_fields(tx, data); + encode_sender_and_type(tx, data); + encode_string("xaid", data); + encode_uint(asset_id, data); +} + +fn encode_sender_and_type(tx: &AlgorandTransaction, data: &mut Vec) { + encode_string("snd", data); + encode_address(&tx.sender, data); + encode_string("type", data); + encode_string(tx.operation.tx_type(), data); +} + +pub(crate) fn encode_signed_transaction(encoded_tx: &[u8], signature: &[u8]) -> Vec { + let mut data = Vec::new(); + data.push(FIXMAP_PREFIX | SIGNED_TX_FIELDS); + encode_string("sig", &mut data); + encode_bytes(signature, &mut data); + encode_string("txn", &mut data); + data.extend_from_slice(encoded_tx); + data +} + +fn encode_common_fields(tx: &AlgorandTransaction, data: &mut Vec) { + encode_string("fee", data); + encode_uint(tx.fee, data); + encode_string("fv", data); + encode_uint(tx.first_round, data); + encode_string("gen", data); + encode_string(&tx.genesis_id, data); + encode_string("gh", data); + encode_bytes(&tx.genesis_hash, data); + encode_string("lv", data); + encode_uint(tx.last_round, data); + if !tx.note.is_empty() { + encode_string("note", data); + encode_bytes(&tx.note, data); + } +} + +fn encode_address(address: &AlgorandAddress, data: &mut Vec) { + encode_bytes(address.as_bytes(), data); +} + +fn encode_string(value: &str, data: &mut Vec) { + let len = value.len(); + if len < FIXSTR_MAX_LEN { + data.push(FIXSTR_PREFIX | len as u8); + } else { + data.push(STR8_PREFIX); + data.push(len as u8); + } + data.extend_from_slice(value.as_bytes()); +} + +fn encode_uint(value: u64, data: &mut Vec) { + match value { + 0..0x80 => data.push(value as u8), + 0x80..0x100 => { + data.push(UINT8_PREFIX); + data.push(value as u8); + } + 0x100..0x1_0000 => { + data.push(UINT16_PREFIX); + data.extend_from_slice(&(value as u16).to_be_bytes()); + } + 0x1_0000..0x1_0000_0000 => { + data.push(UINT32_PREFIX); + data.extend_from_slice(&(value as u32).to_be_bytes()); + } + _ => { + data.push(UINT64_PREFIX); + data.extend_from_slice(&value.to_be_bytes()); + } + } +} + +fn encode_bytes(bytes: &[u8], data: &mut Vec) { + let len = bytes.len(); + if len < 0x100 { + data.push(BIN8_PREFIX); + data.push(len as u8); + } else { + data.push(BIN16_PREFIX); + data.extend_from_slice(&(len as u16).to_be_bytes()); + } + data.extend_from_slice(bytes); +} diff --git a/crates/gem_algorand/src/signer/signing.rs b/crates/gem_algorand/src/signer/signing.rs new file mode 100644 index 0000000000..e1baf48bc7 --- /dev/null +++ b/crates/gem_algorand/src/signer/signing.rs @@ -0,0 +1,18 @@ +use crate::models::signing::AlgorandTransaction; +use crate::signer::serialization::{encode_signed_transaction, encode_transaction}; +use primitives::SignerError; +use signer::{SignatureScheme, Signer}; + +const TX_TAG: &[u8; 2] = b"TX"; + +pub(crate) fn sign_transaction(transaction: &AlgorandTransaction, private_key: &[u8]) -> Result { + let encoded = encode_transaction(transaction)?; + + let mut preimage = Vec::with_capacity(TX_TAG.len() + encoded.len()); + preimage.extend_from_slice(TX_TAG); + preimage.extend_from_slice(&encoded); + + let signature = Signer::sign_digest(SignatureScheme::Ed25519, preimage, private_key.to_vec())?; + let signed = encode_signed_transaction(&encoded, &signature); + Ok(hex::encode(signed)) +} diff --git a/crates/gem_encoding/src/base32.rs b/crates/gem_encoding/src/base32.rs index 375c3a5d82..fce509c241 100644 --- a/crates/gem_encoding/src/base32.rs +++ b/crates/gem_encoding/src/base32.rs @@ -1,6 +1,10 @@ use crate::{EncodingError, EncodingType}; use data_encoding::BASE32_NOPAD; +pub fn encode_base32(bytes: &[u8]) -> String { + BASE32_NOPAD.encode(bytes) +} + pub fn decode_base32(value: &[u8]) -> Result, EncodingError> { BASE32_NOPAD.decode(value).map_err(|e| EncodingError::Invalid(EncodingType::Base32, e.to_string())) } diff --git a/crates/gem_encoding/src/lib.rs b/crates/gem_encoding/src/lib.rs index d410db0092..a1886e3e1b 100644 --- a/crates/gem_encoding/src/lib.rs +++ b/crates/gem_encoding/src/lib.rs @@ -10,7 +10,7 @@ mod base64; pub use error::{EncodingError, EncodingType}; #[cfg(feature = "base32")] -pub use crate::base32::decode_base32; +pub use crate::base32::{decode_base32, encode_base32}; #[cfg(feature = "base58")] pub use crate::base58::{decode_base58, encode_base58}; #[cfg(feature = "base64")] diff --git a/crates/gem_hash/src/sha2.rs b/crates/gem_hash/src/sha2.rs index 232c0dab38..22156d6f0b 100644 --- a/crates/gem_hash/src/sha2.rs +++ b/crates/gem_hash/src/sha2.rs @@ -1,4 +1,4 @@ -use sha2::{Digest, Sha256}; +use sha2::{Digest, Sha256, Sha512_256}; pub fn sha256(bytes: &[u8]) -> [u8; 32] { let mut hasher = Sha256::new(); @@ -9,3 +9,13 @@ pub fn sha256(bytes: &[u8]) -> [u8; 32] { hash.copy_from_slice(&result); hash } + +pub fn sha512_256(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha512_256::new(); + hasher.update(bytes); + let result = hasher.finalize(); + + let mut hash = [0u8; 32]; + hash.copy_from_slice(&result); + hash +} diff --git a/crates/gem_near/Cargo.toml b/crates/gem_near/Cargo.toml index c2c93b8301..fca419b8fc 100644 --- a/crates/gem_near/Cargo.toml +++ b/crates/gem_near/Cargo.toml @@ -20,7 +20,7 @@ chrono = { workspace = true, features = ["serde"], optional = true } bs58 = { workspace = true, optional = true } hex = { workspace = true, optional = true } futures = { workspace = true, optional = true } -base64 = { workspace = true, optional = true } +gem_encoding = { path = "../gem_encoding", optional = true } gem_hash = { path = "../gem_hash", optional = true } signer = { path = "../signer", optional = true } @@ -33,6 +33,6 @@ hex = { workspace = true } [features] default = [] rpc = ["dep:gem_jsonrpc", "dep:gem_client", "dep:chain_traits", "dep:async-trait", "dep:chrono", "dep:bs58", "dep:hex", "dep:futures"] -signer = ["dep:base64", "dep:bs58", "dep:gem_hash", "dep:signer"] +signer = ["dep:gem_encoding", "dep:bs58", "dep:gem_hash", "dep:signer"] reqwest = ["gem_client/reqwest", "gem_jsonrpc/reqwest"] chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] diff --git a/crates/gem_near/src/signer/chain_signer.rs b/crates/gem_near/src/signer/chain_signer.rs index a313a8a5c0..981fb0595c 100644 --- a/crates/gem_near/src/signer/chain_signer.rs +++ b/crates/gem_near/src/signer/chain_signer.rs @@ -17,6 +17,7 @@ mod tests { use super::*; use primitives::{TransactionFee, TransactionLoadInput}; + // Tests taken from https://github.com/trustwallet/wallet-core/blob/master/tests/chains/NEAR/SignerTests.cpp #[test] fn test_sign_near_transfer() { let private_key = bs58::decode("3hoMW1HvnRLSFCLZnvPzWeoGwtdHzke34B2cTHM8rhcbG3TbuLKtShTv3DvyejnXKXKBiV7YPkLeqUHN1ghnqpFv") diff --git a/crates/gem_near/src/signer/serialization.rs b/crates/gem_near/src/signer/serialization.rs index 4f048665b3..40ef3bdcce 100644 --- a/crates/gem_near/src/signer/serialization.rs +++ b/crates/gem_near/src/signer/serialization.rs @@ -1,6 +1,6 @@ use super::models::NearTransfer; +use signer::ED25519_KEY_TYPE; -pub const ED25519_KEY_TYPE: u8 = 0; const TRANSFER_ACTION: u8 = 3; pub fn encode_transfer(transfer: &NearTransfer, public_key: &[u8; 32]) -> Vec { diff --git a/crates/gem_near/src/signer/signing.rs b/crates/gem_near/src/signer/signing.rs index 667050c248..699b6c084c 100644 --- a/crates/gem_near/src/signer/signing.rs +++ b/crates/gem_near/src/signer/signing.rs @@ -1,9 +1,9 @@ use super::models::NearTransfer; -use super::serialization::{ED25519_KEY_TYPE, encode_transfer}; -use base64::{Engine, engine::general_purpose::STANDARD}; +use super::serialization::encode_transfer; +use gem_encoding::encode_base64; use gem_hash::sha2::sha256; use primitives::SignerError; -use signer::Ed25519KeyPair; +use signer::{ED25519_KEY_TYPE, Ed25519KeyPair}; pub fn sign_transfer(transfer: &NearTransfer, private_key: &[u8]) -> Result { let key_pair = Ed25519KeyPair::from_private_key(private_key)?; @@ -14,5 +14,5 @@ pub fn sign_transfer(transfer: &NearTransfer, private_key: &[u8]) -> Result Option { + if address.len() != ADDRESS_LENGTH || !address.starts_with('G') { + return None; + } + let decoded = decode_base32(address.as_bytes()).ok()?; + if decoded.len() != DECODED_LENGTH || decoded[0] != ED25519_PUBLIC_KEY_VERSION { + return None; + } + let crc = u16::from_le_bytes([decoded[33], decoded[34]]); + if Self::crc16_xmodem(&decoded[..33]) != crc { + return None; + } + Base32Address::from_slice(&decoded[1..33]).ok().map(|base32| Self { base32 }) + } + + fn as_bytes(&self) -> &[u8] { + self.base32.payload() + } + + fn encode(&self) -> String { + let mut raw = Vec::with_capacity(DECODED_LENGTH); + raw.push(ED25519_PUBLIC_KEY_VERSION); + raw.extend_from_slice(self.base32.payload()); + let crc = Self::crc16_xmodem(&raw).to_le_bytes(); + raw.extend_from_slice(&crc); + encode_base32(&raw) + } +} + +impl StellarAddress { + fn crc16_xmodem(data: &[u8]) -> u16 { + let mut crc: u16 = 0; + for &byte in data { + crc ^= (byte as u16) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { (crc << 1) ^ CRC16_XMODEM_POLY } else { crc << 1 }; + } + } + crc + } +} + +impl fmt::Display for StellarAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.encode()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stellar_address() { + assert!(StellarAddress::is_valid("GAE2SZV4VLGBAPRYRFV2VY7YYLYGYIP5I7OU7BSP6DJT7GAZ35OKFDYI")); + assert!(!StellarAddress::is_valid("")); + assert!(!StellarAddress::is_valid("invalid")); + assert!(!StellarAddress::is_valid("GAE2SZV4VLGBAPRYRFV2VY7YYLYGYIP5I7OU7BSP6DJT7GAZ35OKFDYZ")); + + let addr = StellarAddress::from_str("GAE2SZV4VLGBAPRYRFV2VY7YYLYGYIP5I7OU7BSP6DJT7GAZ35OKFDYI").unwrap(); + assert_eq!(addr.to_string(), "GAE2SZV4VLGBAPRYRFV2VY7YYLYGYIP5I7OU7BSP6DJT7GAZ35OKFDYI"); + assert_eq!(addr.as_bytes().len(), 32); + } +} diff --git a/crates/gem_stellar/src/lib.rs b/crates/gem_stellar/src/lib.rs index 422c3d42f7..a7018d4cc0 100644 --- a/crates/gem_stellar/src/lib.rs +++ b/crates/gem_stellar/src/lib.rs @@ -4,5 +4,14 @@ pub mod rpc; #[cfg(feature = "rpc")] pub mod provider; +#[cfg(feature = "signer")] +pub mod address; pub mod constants; pub mod models; +#[cfg(feature = "signer")] +pub mod signer; + +#[cfg(feature = "signer")] +pub use address::StellarAddress; +#[cfg(feature = "signer")] +pub use signer::*; diff --git a/crates/gem_stellar/src/models/mod.rs b/crates/gem_stellar/src/models/mod.rs index 3ac8b37bc0..ba3e5ef976 100644 --- a/crates/gem_stellar/src/models/mod.rs +++ b/crates/gem_stellar/src/models/mod.rs @@ -4,6 +4,8 @@ pub mod block; pub mod common; pub mod fee; pub mod node; +#[cfg(feature = "signer")] +pub mod signing; pub mod transaction; pub use account::*; diff --git a/crates/gem_stellar/src/models/signing/asset.rs b/crates/gem_stellar/src/models/signing/asset.rs new file mode 100644 index 0000000000..3d97e5e548 --- /dev/null +++ b/crates/gem_stellar/src/models/signing/asset.rs @@ -0,0 +1,45 @@ +use crate::address::StellarAddress; +use primitives::{Address, SignerError, SignerInput}; +use signer::InvalidInput; + +#[derive(Clone)] +pub enum StellarAssetCode { + Alphanum4([u8; 4]), + Alphanum12([u8; 12]), +} + +#[derive(Clone)] +pub struct StellarAssetData { + pub issuer: StellarAddress, + pub code: StellarAssetCode, +} + +impl StellarAssetData { + pub fn new(issuer: &str, code: &str) -> Result { + if !(code.is_ascii() && code.bytes().all(|byte| byte.is_ascii_alphanumeric())) { + return SignerError::invalid_input_err("Stellar asset code must be ASCII alphanumeric"); + } + + let code = match code.len() { + 1..=4 => { + let mut buf = [0u8; 4]; + buf[..code.len()].copy_from_slice(code.as_bytes()); + StellarAssetCode::Alphanum4(buf) + } + 5..=12 => { + let mut buf = [0u8; 12]; + buf[..code.len()].copy_from_slice(code.as_bytes()); + StellarAssetCode::Alphanum12(buf) + } + _ => return Err(SignerError::invalid_input("Stellar asset code must be 1-12 characters")), + }; + + let issuer = StellarAddress::from_str(issuer).invalid_input("invalid Stellar issuer address")?; + Ok(Self { issuer, code }) + } + + pub(crate) fn from_input(input: &SignerInput) -> Result { + let (issuer, code) = input.input_type.get_asset().id.split_sub_token_parts().invalid_input("invalid Stellar token ID")?; + Self::new(&issuer, &code) + } +} diff --git a/crates/gem_stellar/src/models/signing/mod.rs b/crates/gem_stellar/src/models/signing/mod.rs new file mode 100644 index 0000000000..a7e086dac5 --- /dev/null +++ b/crates/gem_stellar/src/models/signing/mod.rs @@ -0,0 +1,7 @@ +mod asset; +mod operation; +mod transaction; + +pub use asset::{StellarAssetCode, StellarAssetData}; +pub use operation::{Memo, Operation}; +pub use transaction::StellarTransaction; diff --git a/crates/gem_stellar/src/models/signing/operation.rs b/crates/gem_stellar/src/models/signing/operation.rs new file mode 100644 index 0000000000..f28194f9f2 --- /dev/null +++ b/crates/gem_stellar/src/models/signing/operation.rs @@ -0,0 +1,36 @@ +use super::StellarAssetData; +use crate::address::StellarAddress; + +#[derive(Clone)] +pub enum Memo { + None, + Text(String), + #[cfg_attr(not(test), allow(unused))] + Id(u64), +} + +#[derive(Clone)] +pub enum Operation { + CreateAccount { + destination: StellarAddress, + amount: u64, + }, + Payment { + destination: StellarAddress, + asset: Option, + amount: u64, + }, + ChangeTrust { + asset: StellarAssetData, + }, +} + +impl Operation { + pub fn operation_type(&self) -> u32 { + match self { + Self::CreateAccount { .. } => 0, + Self::Payment { .. } => 1, + Self::ChangeTrust { .. } => 6, + } + } +} diff --git a/crates/gem_stellar/src/models/signing/transaction.rs b/crates/gem_stellar/src/models/signing/transaction.rs new file mode 100644 index 0000000000..4ce10f45f9 --- /dev/null +++ b/crates/gem_stellar/src/models/signing/transaction.rs @@ -0,0 +1,80 @@ +use super::asset::StellarAssetData; +use super::operation::{Memo, Operation}; +use crate::address::StellarAddress; +use num_traits::ToPrimitive; +use primitives::{Address, SignerError, SignerInput}; +use signer::InvalidInput; + +const MEMO_TEXT_MAX_BYTES: usize = 28; + +#[derive(Clone)] +pub struct StellarTransaction { + pub account: StellarAddress, + pub fee: u32, + pub sequence: u64, + pub memo: Memo, + pub time_bounds: Option, + pub operation: Operation, +} + +impl StellarTransaction { + pub fn transfer(input: &SignerInput) -> Result { + let amount = input.value.parse::().invalid_input("invalid Stellar amount")?; + let destination = StellarAddress::from_str(&input.destination_address).invalid_input("invalid Stellar address")?; + let is_destination_exist = input.metadata.get_is_destination_address_exist().map_err(SignerError::from_display)?; + + let operation = if is_destination_exist { + Operation::Payment { destination, asset: None, amount } + } else { + Operation::CreateAccount { destination, amount } + }; + + Self::build(input, fee_u32(input)?, operation) + } + + pub fn token_transfer(input: &SignerInput) -> Result { + if !input.metadata.get_is_destination_address_exist().map_err(SignerError::from_display)? { + return SignerError::invalid_input_err("Stellar destination account not found for token transfer"); + } + + let amount = input.value.parse::().invalid_input("invalid Stellar amount")?; + let operation = Operation::Payment { + destination: StellarAddress::from_str(&input.destination_address).invalid_input("invalid Stellar address")?, + asset: Some(StellarAssetData::from_input(input)?), + amount, + }; + + Self::build(input, fee_u32(input)?, operation) + } + + pub fn account_action(input: &SignerInput) -> Result { + let operation = Operation::ChangeTrust { + asset: StellarAssetData::from_input(input)?, + }; + + Self::build(input, fee_u32(input)?, operation) + } + + fn build(input: &SignerInput, fee: u32, operation: Operation) -> Result { + Ok(Self { + account: StellarAddress::from_str(&input.sender_address).invalid_input("invalid Stellar address")?, + fee, + sequence: input.metadata.get_sequence().map_err(SignerError::from_display)?, + memo: memo(input.memo.as_deref())?, + time_bounds: None, + operation, + }) + } +} + +fn fee_u32(input: &SignerInput) -> Result { + input.fee.fee.to_u32().invalid_input("invalid transaction fee") +} + +fn memo(value: Option<&str>) -> Result { + match value { + Some(text) if text.len() > MEMO_TEXT_MAX_BYTES => SignerError::invalid_input_err("Stellar memo text must be at most 28 bytes"), + Some(text) => Ok(Memo::Text(text.to_string())), + None => Ok(Memo::None), + } +} diff --git a/crates/gem_stellar/src/signer/chain_signer.rs b/crates/gem_stellar/src/signer/chain_signer.rs new file mode 100644 index 0000000000..8daec0a085 --- /dev/null +++ b/crates/gem_stellar/src/signer/chain_signer.rs @@ -0,0 +1,149 @@ +use crate::models::signing::StellarTransaction; +use crate::signer::signing::sign_transaction; +use primitives::{ChainSigner, SignerError, SignerInput}; + +#[derive(Default)] +pub struct StellarChainSigner; + +impl ChainSigner for StellarChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_transaction(&StellarTransaction::transfer(input)?, private_key) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_transaction(&StellarTransaction::token_transfer(input)?, private_key) + } + + fn sign_account_action(&self, input: &SignerInput, private_key: &[u8]) -> Result { + sign_transaction(&StellarTransaction::account_action(input)?, private_key) + } +} + +#[cfg(test)] +mod tests { + // Tests taken from https://github.com/trustwallet/wallet-core/blob/master/tests/chains/Stellar/TWAnySignerTests.cpp + use super::*; + use crate::address::StellarAddress; + use crate::models::signing::{Memo, Operation, StellarAssetData, StellarTransaction}; + use crate::signer::signing::sign_transaction; + use gem_encoding::decode_base64; + use primitives::{Address, Asset, AssetType, Chain, TransactionFee, TransactionLoadInput, TransactionLoadMetadata}; + use signer::Ed25519KeyPair; + + const PRIVATE_KEY: &str = "59a313f46ef1c23a9e4f71cea10fc0c56a2a6bb8a4b9ea3d5348823e5a478722"; + const SENDER: &str = "GAE2SZV4VLGBAPRYRFV2VY7YYLYGYIP5I7OU7BSP6DJT7GAZ35OKFDYI"; + const DESTINATION: &str = "GDCYBNRRPIHLHG7X7TKPUPAZ7WVUXCN3VO7WCCK64RIFV5XM5V5K4A52"; + + fn metadata(sequence: u64, is_destination_address_exist: bool) -> TransactionLoadMetadata { + TransactionLoadMetadata::Stellar { + sequence, + is_destination_address_exist, + } + } + + #[test] + fn test_sign_stellar_transactions() { + let key = hex::decode(PRIVATE_KEY).unwrap(); + + // Native transfer with memo + let input = SignerInput::new( + TransactionLoadInput::mock_transfer( + Asset::from_chain(Chain::Stellar), + SENDER, + DESTINATION, + "10000000", + 1000, + Some("Hello, world!"), + metadata(2, true), + ), + TransactionFee::new_from_fee(1000.into()), + ); + let signed = StellarChainSigner.sign_transfer(&input, &key).unwrap(); + assert_eq!( + signed, + "AAAAAAmpZryqzBA+OIlrquP4wvBsIf1H3U+GT/DTP5gZ31yiAAAD6AAAAAAAAAACAAAAAAAAAAEAAAANSGVsbG8sIHdvcmxkIQAAAAAAAAEAAAAAAAAAAQAAAADFgLYxeg6zm/f81Po8Gf2rS4m7q79hCV7kUFr27O16rgAAAAAAAAAAAJiWgAAAAAAAAAABGd9cogAAAEBQQldEkYJ6rMvOHilkwFCYyroGGUvrNeWVqr/sn3iFFqgz91XxgUT0ou7bMSPRgPROfBYDfQCFfFxbcDPrrCwB" + ); + + // Transfer to non-existent destination (creates account) + let input = SignerInput::new( + TransactionLoadInput::mock_transfer(Asset::from_chain(Chain::Stellar), SENDER, DESTINATION, "10000000", 1000, None, metadata(2, false)), + TransactionFee::new_from_fee(1000.into()), + ); + let signed = StellarChainSigner.sign_transfer(&input, &key).unwrap(); + assert_eq!( + signed, + "AAAAAAmpZryqzBA+OIlrquP4wvBsIf1H3U+GT/DTP5gZ31yiAAAD6AAAAAAAAAACAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAxYC2MXoOs5v3/NT6PBn9q0uJu6u/YQle5FBa9uzteq4AAAAAAJiWgAAAAAAAAAABGd9cogAAAEA6vrVXe4OUNPKKlGtzJiNzGi1p1yAd6pxoTEcoixXZbWponp6L5XOVweg5tTM36pZVQjQIxEjOgktinR96Wf8O" + ); + + // Token transfer + let mobi = Asset::mock_with_params( + Chain::Stellar, + Some("GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH::MOBI".into()), + "MOBI".into(), + "MOBI".into(), + 7, + AssetType::TOKEN, + ); + let input = SignerInput::new( + TransactionLoadInput::mock_transfer( + mobi, + "GDFEKJIFKUZP26SESUHZONAUJZMBSODVN2XBYN4KAGNHB7LX2OIXLPUL", + "GA3ISGYIE2ZTH3UAKEKBVHBPKUSL3LT4UQ6C5CUGP2IM5F467O267KI7", + "12000000", + 1000, + None, + metadata(144098454883270661, true), + ), + TransactionFee::new_from_fee(1000.into()), + ); + let signed = StellarChainSigner + .sign_token_transfer(&input, &hex::decode("3c0635f8638605aed6e461cf3fa2d508dd895df1a1655ff92c79bfbeaf88d4b9").unwrap()) + .unwrap(); + assert_eq!( + signed, + "AAAAAMpFJQVVMv16RJUPlzQUTlgZOHVurhw3igGacP1305F1AAAD6AH/8MgAAAAFAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAANokbCCazM+6AURQanC9VJL2ufKQ8LoqGfpDOl577te8AAAABTU9CSQAAAAA8cTArnmXa4wEQJxDHOw5SwBaDVjBfAP5lRMNZkRtlZAAAAAAAtxsAAAAAAAAAAAF305F1AAAAQEuWZZvKZuF6SMuSGIyfLqx5sn5O55+Kd489uP4g9jZH4UE7zZ4ME0+74I0BU8YDsYOmmxcfp/vdwTd+n3oGCQw=" + ); + } + + #[test] + fn test_sign_change_trust_with_time_bounds() { + let transaction = StellarTransaction { + account: StellarAddress::from_str("GDFEKJIFKUZP26SESUHZONAUJZMBSODVN2XBYN4KAGNHB7LX2OIXLPUL").unwrap(), + fee: 10000, + sequence: 144098454883270659, + memo: Memo::None, + time_bounds: Some(1613336576), + operation: Operation::ChangeTrust { + asset: StellarAssetData::new("GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH", "MOBI").unwrap(), + }, + }; + + let signed = sign_transaction(&transaction, &hex::decode("3c0635f8638605aed6e461cf3fa2d508dd895df1a1655ff92c79bfbeaf88d4b9").unwrap()).unwrap(); + assert_eq!( + signed, + "AAAAAMpFJQVVMv16RJUPlzQUTlgZOHVurhw3igGacP1305F1AAAnEAH/8MgAAAADAAAAAQAAAAAAAAAAAAAAAGApkAAAAAAAAAAAAQAAAAAAAAAGAAAAAU1PQkkAAAAAPHEwK55l2uMBECcQxzsOUsAWg1YwXwD+ZUTDWZEbZWR//////////wAAAAAAAAABd9ORdQAAAEAnfyXyaNQX5Bq3AEQVBIaYd+cLib+y2sNY7DF/NYVSE51dZ6swGGElz094ObsPefmVmeRrkGsSc/fF5pmth+wJ" + ); + } + + #[test] + fn test_stellar_signing_validation_and_hint() { + let transfer_key = hex::decode("3c0635f8638605aed6e461cf3fa2d508dd895df1a1655ff92c79bfbeaf88d4b9").unwrap(); + + let signed = StellarChainSigner + .sign_transfer( + &SignerInput::new( + TransactionLoadInput::mock_transfer(Asset::from_chain(Chain::Stellar), SENDER, DESTINATION, "10000000", 1000, None, metadata(2, true)), + TransactionFee::new_from_fee(1000.into()), + ), + &transfer_key, + ) + .unwrap(); + let envelope = decode_base64(&signed).unwrap(); + let signer_key = Ed25519KeyPair::from_private_key(&transfer_key).unwrap(); + let sender = StellarAddress::from_str(SENDER).unwrap(); + let hint_offset = envelope.len() - 72; + + assert_eq!(&envelope[hint_offset..hint_offset + 4], &signer_key.public_key_bytes[28..32]); + assert_ne!(&envelope[hint_offset..hint_offset + 4], &sender.as_bytes()[28..32]); + } +} diff --git a/crates/gem_stellar/src/signer/mod.rs b/crates/gem_stellar/src/signer/mod.rs new file mode 100644 index 0000000000..a2c6ff6410 --- /dev/null +++ b/crates/gem_stellar/src/signer/mod.rs @@ -0,0 +1,5 @@ +mod chain_signer; +mod serialization; +mod signing; + +pub use chain_signer::StellarChainSigner; diff --git a/crates/gem_stellar/src/signer/serialization.rs b/crates/gem_stellar/src/signer/serialization.rs new file mode 100644 index 0000000000..0bc61cac37 --- /dev/null +++ b/crates/gem_stellar/src/signer/serialization.rs @@ -0,0 +1,111 @@ +use crate::address::StellarAddress; +use crate::models::signing::{Memo, Operation, StellarAssetCode, StellarAssetData, StellarTransaction}; +use primitives::Address; +use signer::ED25519_KEY_TYPE; + +const ASSET_TYPE_NATIVE: u32 = 0; +const ASSET_TYPE_ALPHANUM4: u32 = 1; +const ASSET_TYPE_ALPHANUM12: u32 = 2; + +/// XDR-encode a Stellar transaction (unsigned envelope body). +pub(crate) fn encode_transaction(tx: &StellarTransaction) -> Vec { + let mut data = Vec::new(); + encode_address(&mut data, &tx.account); + write_u32(&mut data, tx.fee); + write_u64(&mut data, tx.sequence); + encode_time_bounds(&mut data, tx); + encode_memo(&mut data, &tx.memo); + // 1 operation, no source account override + write_u32(&mut data, 1); + write_u32(&mut data, 0); + write_u32(&mut data, tx.operation.operation_type()); + + match &tx.operation { + Operation::CreateAccount { destination, amount } => encode_create_account(&mut data, destination, *amount), + Operation::Payment { destination, asset, amount } => encode_payment(&mut data, destination, asset.as_ref(), *amount), + Operation::ChangeTrust { asset } => encode_change_trust(&mut data, asset), + } + + // ext (union void) + write_u32(&mut data, 0); + data +} + +fn encode_create_account(data: &mut Vec, destination: &StellarAddress, amount: u64) { + encode_address(data, destination); + write_u64(data, amount); +} + +fn encode_payment(data: &mut Vec, destination: &StellarAddress, asset: Option<&StellarAssetData>, amount: u64) { + encode_address(data, destination); + encode_asset(data, asset); + write_u64(data, amount); +} + +fn encode_change_trust(data: &mut Vec, asset: &StellarAssetData) { + encode_asset(data, Some(asset)); + write_u64(data, i64::MAX as u64); +} + +fn encode_time_bounds(data: &mut Vec, tx: &StellarTransaction) { + if let Some(to) = tx.time_bounds.filter(|v| *v > 0) { + write_u32(data, 1); + write_u64(data, 0); + write_u64(data, to); + } else { + write_u32(data, 0); + } +} + +fn encode_memo(data: &mut Vec, memo: &Memo) { + match memo { + Memo::None => write_u32(data, 0), + Memo::Text(text) => { + write_u32(data, 1); + write_u32(data, text.len() as u32); + data.extend_from_slice(text.as_bytes()); + pad4(data); + } + Memo::Id(id) => { + write_u32(data, 2); + write_u64(data, *id); + } + } +} + +fn encode_asset(data: &mut Vec, asset: Option<&StellarAssetData>) { + match asset { + Some(asset) => { + match &asset.code { + StellarAssetCode::Alphanum4(code) => { + write_u32(data, ASSET_TYPE_ALPHANUM4); + data.extend_from_slice(code); + } + StellarAssetCode::Alphanum12(code) => { + write_u32(data, ASSET_TYPE_ALPHANUM12); + data.extend_from_slice(code); + } + } + encode_address(data, &asset.issuer); + } + None => write_u32(data, ASSET_TYPE_NATIVE), + } +} + +fn encode_address(data: &mut Vec, address: &StellarAddress) { + write_u32(data, ED25519_KEY_TYPE as u32); + data.extend_from_slice(address.as_bytes()); +} + +fn write_u32(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_be_bytes()); +} + +fn write_u64(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_be_bytes()); +} + +fn pad4(data: &mut Vec) { + let padding = (4 - (data.len() % 4)) % 4; + data.extend(std::iter::repeat_n(0, padding)); +} diff --git a/crates/gem_stellar/src/signer/signing.rs b/crates/gem_stellar/src/signer/signing.rs new file mode 100644 index 0000000000..76a890103f --- /dev/null +++ b/crates/gem_stellar/src/signer/signing.rs @@ -0,0 +1,31 @@ +use crate::models::signing::StellarTransaction; +use crate::signer::serialization::encode_transaction; +use gem_encoding::encode_base64; +use gem_hash::sha2::sha256; +use primitives::SignerError; +use signer::Ed25519KeyPair; + +const NETWORK_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015"; +const ENVELOPE_TYPE_TX: [u8; 4] = 2u32.to_be_bytes(); +const SIGNATURE_COUNT: [u8; 4] = 1u32.to_be_bytes(); + +pub(crate) fn sign_transaction(transaction: &StellarTransaction, private_key: &[u8]) -> Result { + let encoded = encode_transaction(transaction); + let network_id = sha256(NETWORK_PASSPHRASE.as_bytes()); + + let mut preimage = Vec::with_capacity(network_id.len() + ENVELOPE_TYPE_TX.len() + encoded.len()); + preimage.extend_from_slice(&network_id); + preimage.extend_from_slice(&ENVELOPE_TYPE_TX); + preimage.extend_from_slice(&encoded); + + let digest = sha256(&preimage); + let key_pair = Ed25519KeyPair::from_private_key(private_key)?; + let signature = key_pair.sign(&digest); + + let mut envelope = encoded; + envelope.extend_from_slice(&SIGNATURE_COUNT); + envelope.extend_from_slice(&key_pair.public_key_bytes[28..32]); + envelope.extend_from_slice(&(signature.len() as u32).to_be_bytes()); + envelope.extend_from_slice(&signature); + Ok(encode_base64(&envelope)) +} diff --git a/crates/primitives/src/address/error.rs b/crates/primitives/src/address/error.rs new file mode 100644 index 0000000000..339da082ab --- /dev/null +++ b/crates/primitives/src/address/error.rs @@ -0,0 +1,26 @@ +use std::fmt; + +#[derive(Debug)] +pub struct AddressError { + pub message: String, +} + +impl AddressError { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +impl fmt::Display for AddressError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for AddressError {} + +impl From for String { + fn from(err: AddressError) -> Self { + err.message + } +} diff --git a/crates/primitives/src/address/mod.rs b/crates/primitives/src/address/mod.rs new file mode 100644 index 0000000000..be5383391a --- /dev/null +++ b/crates/primitives/src/address/mod.rs @@ -0,0 +1,20 @@ +mod error; + +pub use error::AddressError; + +/// Common trait for blockchain addresses. +pub trait Address: Sized { + fn try_parse(address: &str) -> Option; + + fn as_bytes(&self) -> &[u8]; + + fn encode(&self) -> String; + + fn from_str(address: &str) -> Result { + Self::try_parse(address).ok_or_else(|| AddressError::new("invalid address")) + } + + fn is_valid(address: &str) -> bool { + Self::try_parse(address).is_some() + } +} diff --git a/crates/primitives/src/asset_id.rs b/crates/primitives/src/asset_id.rs index 87812a2f3e..391fd221bd 100644 --- a/crates/primitives/src/asset_id.rs +++ b/crates/primitives/src/asset_id.rs @@ -133,6 +133,12 @@ impl AssetId { Ok((parts[0].to_string(), parts[1].to_string())) } + pub fn split_sub_token_parts(&self) -> Option<(String, String)> { + let token_id = self.token_id.as_ref()?; + let (first, second) = token_id.split_once(TOKEN_ID_SEPARATOR)?; + Some((first.to_string(), second.to_string())) + } + pub fn is_native(&self) -> bool { self.token_id.is_none() } diff --git a/crates/primitives/src/block_explorer.rs b/crates/primitives/src/block_explorer.rs index ebf4f0ed59..047eb4e4a1 100644 --- a/crates/primitives/src/block_explorer.rs +++ b/crates/primitives/src/block_explorer.rs @@ -1,8 +1,9 @@ use crate::chain::Chain; use crate::chain_evm::EVMChain; use crate::explorers::{ - AlgorandAllo, BlockScout, BlockVision, Blocksec, Cardanocan, EtherScan, Explorer, FlowScan, HyperliquidExplorer, HypurrScan, MantleExplorer, Metadata, NearBlocks, OkxExplorer, - RouteScan, RuneScan, SubScan, TonScan, TronScan, Viewblock, XrpScan, ZkSync, aptos, blockchair, mempool, mintscan, solana, stellar_expert, sui, threexpl, ton, + AlgorandAllo, AlgorandPera, BlockScout, BlockVision, Blocksec, Cardanocan, EtherScan, Explorer, FlowScan, HyperliquidExplorer, HypurrScan, MantleExplorer, Metadata, + NearBlocks, OkxExplorer, RouteScan, RuneScan, SubScan, TonScan, TronScan, Viewblock, XrpScan, ZkSync, aptos, blockchair, mempool, mintscan, solana, stellar_expert, sui, + threexpl, ton, }; use std::str::FromStr; use typeshare::typeshare; @@ -92,7 +93,7 @@ pub fn get_block_explorers(chain: Chain) -> Vec> { Chain::Near => vec![NearBlocks::boxed()], Chain::Stellar => vec![stellar_expert::new(), blockchair::new_stellar()], Chain::Sonic => vec![EtherScan::boxed(EVMChain::Sonic), RouteScan::new_sonic()], - Chain::Algorand => vec![AlgorandAllo::boxed()], + Chain::Algorand => vec![AlgorandAllo::boxed(), AlgorandPera::boxed()], Chain::Polkadot => vec![SubScan::new_polkadot(), blockchair::new_polkadot()], Chain::Cardano => vec![Cardanocan::boxed()], Chain::Abstract => vec![EtherScan::boxed(EVMChain::Abstract)], diff --git a/crates/primitives/src/explorers/algorand.rs b/crates/primitives/src/explorers/algorand.rs index b4cb2ecbbb..73f59af765 100644 --- a/crates/primitives/src/explorers/algorand.rs +++ b/crates/primitives/src/explorers/algorand.rs @@ -16,3 +16,19 @@ impl AlgorandAllo { }) } } + +pub struct AlgorandPera; + +impl AlgorandPera { + pub fn boxed() -> Box { + Explorer::boxed(Metadata { + name: "Pera", + base_url: "https://explorer.perawallet.app", + tx_path: TX_PATH, + address_path: ACCOUNT_PATH, + token_path: Some("/assets"), + nft_path: None, + validator_path: Some(ACCOUNT_PATH), + }) + } +} diff --git a/crates/primitives/src/explorers/mod.rs b/crates/primitives/src/explorers/mod.rs index 22fd6ec9b0..5b4dfc0612 100644 --- a/crates/primitives/src/explorers/mod.rs +++ b/crates/primitives/src/explorers/mod.rs @@ -28,7 +28,7 @@ pub use near_intents::NearIntents; mod blocksec; pub use blocksec::Blocksec; mod algorand; -pub use algorand::AlgorandAllo; +pub use algorand::{AlgorandAllo, AlgorandPera}; pub mod blockvision; pub use blockvision::BlockVision; mod subscan; diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index 7da12593cc..7c45393468 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -116,6 +116,8 @@ pub mod subscription; pub mod transaction_utxo; pub use self::subscription::{AddressChains, DeviceSubscription, WalletSubscription, WalletSubscriptionChains, WalletSubscriptionLegacy}; pub use self::transaction_utxo::TransactionUtxoInput; +pub mod address; +pub use self::address::{Address, AddressError}; pub mod address_formatter; pub use self::address_formatter::{AddressFormatStyle, AddressFormatter}; pub mod address_name; diff --git a/crates/primitives/src/testkit/transaction_load_input_mock.rs b/crates/primitives/src/testkit/transaction_load_input_mock.rs index 3ae430049c..679cfbb0c4 100644 --- a/crates/primitives/src/testkit/transaction_load_input_mock.rs +++ b/crates/primitives/src/testkit/transaction_load_input_mock.rs @@ -100,6 +100,19 @@ impl TransactionLoadInput { } } + pub fn mock_transfer(asset: Asset, sender: &str, destination: &str, value: &str, fee: u64, memo: Option<&str>, metadata: TransactionLoadMetadata) -> Self { + TransactionLoadInput { + input_type: TransactionInputType::Transfer(asset), + sender_address: sender.into(), + destination_address: destination.into(), + value: value.into(), + gas_price: GasPriceType::regular(fee), + memo: memo.map(String::from), + is_max_value: false, + metadata, + } + } + pub fn mock_sign_data(chain: Chain, data: &str, output_type: TransferDataOutputType) -> Self { TransactionLoadInput { input_type: TransactionInputType::Generic( diff --git a/crates/signer/Cargo.toml b/crates/signer/Cargo.toml index c072e7225a..70bcd76ea8 100644 --- a/crates/signer/Cargo.toml +++ b/crates/signer/Cargo.toml @@ -13,6 +13,7 @@ alloy-primitives = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } gem_hash = { path = "../gem_hash" } +gem_encoding = { path = "../gem_encoding", features = ["base32"] } zeroize = { workspace = true } [dev-dependencies] diff --git a/crates/signer/src/address.rs b/crates/signer/src/address.rs new file mode 100644 index 0000000000..85d170c0e0 --- /dev/null +++ b/crates/signer/src/address.rs @@ -0,0 +1,15 @@ +use primitives::SignerError; + +#[derive(Clone, Copy)] +pub struct Base32Address([u8; 32]); + +impl Base32Address { + pub fn from_slice(bytes: &[u8]) -> Result { + let payload: [u8; 32] = bytes.try_into().map_err(|_| SignerError::invalid_input("invalid base32 address payload"))?; + Ok(Self(payload)) + } + + pub fn payload(&self) -> &[u8; 32] { + &self.0 + } +} diff --git a/crates/signer/src/ed25519.rs b/crates/signer/src/ed25519.rs index af625cd042..7e37917519 100644 --- a/crates/signer/src/ed25519.rs +++ b/crates/signer/src/ed25519.rs @@ -2,6 +2,9 @@ use ed25519_dalek::{Signer as DalekSigner, SigningKey}; use primitives::SignerError; +/// Byte value representing the Ed25519 scheme in on-chain serialization formats. +pub const ED25519_KEY_TYPE: u8 = 0; + #[derive(Debug)] pub struct Ed25519KeyPair { signing_key: SigningKey, diff --git a/crates/signer/src/error.rs b/crates/signer/src/error.rs new file mode 100644 index 0000000000..c5c44ffa87 --- /dev/null +++ b/crates/signer/src/error.rs @@ -0,0 +1,25 @@ +use primitives::SignerError; + +/// Extension trait to convert `Result` / `Option` into `Result<_, SignerError>` +/// with a single `invalid_input(msg)` call. +pub trait InvalidInput { + type Ok; + + fn invalid_input(self, msg: &'static str) -> Result; +} + +impl InvalidInput for Result { + type Ok = T; + + fn invalid_input(self, msg: &'static str) -> Result { + self.map_err(|_| SignerError::invalid_input(msg)) + } +} + +impl InvalidInput for Option { + type Ok = T; + + fn invalid_input(self, msg: &'static str) -> Result { + self.ok_or_else(|| SignerError::invalid_input(msg)) + } +} diff --git a/crates/signer/src/lib.rs b/crates/signer/src/lib.rs index 5839543cd8..9fdad8f0a0 100644 --- a/crates/signer/src/lib.rs +++ b/crates/signer/src/lib.rs @@ -1,6 +1,8 @@ +mod address; mod decode; mod ed25519; mod eip712; +mod error; mod secp256k1; #[cfg(test)] @@ -10,7 +12,9 @@ pub(crate) mod testkit { use zeroize::Zeroizing; -pub use crate::ed25519::Ed25519KeyPair; +pub use crate::address::Base32Address; +pub use crate::ed25519::{ED25519_KEY_TYPE, Ed25519KeyPair}; +pub use crate::error::InvalidInput; pub use crate::secp256k1::{RECOVERY_ID_INDEX, SIGNATURE_LENGTH, apply_eth_recovery_id, public_key_from_private as secp256k1_public_key}; pub use decode::{decode_private_key, encode_private_key, supports_private_key_import}; diff --git a/gemstone/Cargo.toml b/gemstone/Cargo.toml index 720b5f3195..47fa737c4a 100644 --- a/gemstone/Cargo.toml +++ b/gemstone/Cargo.toml @@ -35,8 +35,8 @@ gem_hypercore = { path = "../crates/gem_hypercore", features = ["signer"] } gem_bitcoin = { path = "../crates/gem_bitcoin", features = ["rpc", "signer"] } gem_hash = { path = "../crates/gem_hash" } gem_cardano = { path = "../crates/gem_cardano", features = ["rpc"] } -gem_algorand = { path = "../crates/gem_algorand", features = ["rpc"] } -gem_stellar = { path = "../crates/gem_stellar", features = ["rpc"] } +gem_algorand = { path = "../crates/gem_algorand", features = ["rpc", "signer"] } +gem_stellar = { path = "../crates/gem_stellar", features = ["rpc", "signer"] } gem_xrp = { path = "../crates/gem_xrp", features = ["rpc"] } gem_near = { path = "../crates/gem_near", features = ["rpc", "signer"] } gem_polkadot = { path = "../crates/gem_polkadot", features = ["rpc"] } diff --git a/gemstone/src/signer/chain.rs b/gemstone/src/signer/chain.rs index b711d96fcd..3aad61fd1c 100644 --- a/gemstone/src/signer/chain.rs +++ b/gemstone/src/signer/chain.rs @@ -1,10 +1,12 @@ use crate::{GemstoneError, models::transaction::GemSignerInput}; +use gem_algorand::AlgorandChainSigner; use gem_aptos::AptosChainSigner; use gem_cosmos::signer::CosmosChainSigner; use gem_evm::signer::EvmChainSigner; use gem_hypercore::signer::HyperCoreSigner; use gem_near::NearChainSigner; use gem_solana::signer::SolanaChainSigner; +use gem_stellar::StellarChainSigner; use gem_sui::signer::SuiChainSigner; use gem_tron::TronChainSigner; use primitives::{Chain, ChainSigner, ChainType, EVMChain, SignerError, SignerInput}; @@ -28,6 +30,8 @@ impl GemChainSigner { ChainType::Tron => Box::new(TronChainSigner), ChainType::Cosmos => Box::new(CosmosChainSigner), ChainType::Near => Box::new(NearChainSigner), + ChainType::Algorand => Box::new(AlgorandChainSigner), + ChainType::Stellar => Box::new(StellarChainSigner), _ => todo!("Signer not implemented for chain {:?}", chain), };