//! Synor IBC Bridge Contract //! //! This contract enables cross-chain token transfers and atomic swaps via IBC. //! //! # Features //! //! - **ICS-20 Token Transfers**: Lock/unlock native tokens for cross-chain transfers //! - **Atomic Swaps**: HTLC-based trustless token exchanges //! - **Light Client Verification**: Validates IBC proofs from counterparty chains //! - **Channel Management**: Manages IBC channels and sequences //! //! # Security //! //! - Only whitelisted relayers can submit IBC messages //! - Merkle proof verification for all cross-chain state //! - Timeout handling prevents stuck funds //! //! # Methods //! //! ## Admin //! - `init(admin, chain_id)` - Initialize the bridge //! - `add_relayer(address)` - Whitelist a relayer //! - `remove_relayer(address)` - Remove a relayer //! - `register_channel(channel_id, counterparty)` - Register IBC channel //! //! ## ICS-20 Transfers //! - `lock_tokens(amount, receiver, channel)` - Lock tokens for transfer //! - `unlock_tokens(packet, proof)` - Unlock tokens from incoming transfer //! - `timeout_packet(packet)` - Handle packet timeout (refund) //! //! ## Atomic Swaps //! - `create_swap(hashlock, receiver, amount, timelock)` - Create HTLC //! - `claim_swap(swap_id, secret)` - Claim with preimage //! - `refund_swap(swap_id)` - Refund after timeout #![no_std] extern crate alloc; use alloc::string::String; use alloc::vec::Vec; use borsh::{BorshDeserialize, BorshSerialize}; use synor_sdk::prelude::*; use synor_sdk::{require, require_auth}; // ============================================================================= // STORAGE KEYS // ============================================================================= mod keys { /// Contract admin address pub const ADMIN: &[u8] = b"ibc:admin"; /// Whether the contract is initialized pub const INITIALIZED: &[u8] = b"ibc:init"; /// Chain ID for this bridge pub const CHAIN_ID: &[u8] = b"ibc:chain_id"; /// Whitelisted relayers prefix pub const RELAYERS: &[u8] = b"ibc:relayers"; /// Registered channels prefix pub const CHANNELS: &[u8] = b"ibc:channels"; /// Packet commitments prefix pub const COMMITMENTS: &[u8] = b"ibc:commits"; /// Packet receipts prefix pub const RECEIPTS: &[u8] = b"ibc:receipts"; /// Packet acknowledgements prefix pub const ACKS: &[u8] = b"ibc:acks"; /// Sequence counters prefix pub const SEQUENCES: &[u8] = b"ibc:seq"; /// Locked tokens total pub const LOCKED_TOTAL: &[u8] = b"ibc:locked"; /// Active swaps prefix pub const SWAPS: &[u8] = b"ibc:swaps"; /// Swap counter for IDs pub const SWAP_COUNTER: &[u8] = b"ibc:swap_cnt"; /// Token escrow per user pub const ESCROW: &[u8] = b"ibc:escrow"; } // ============================================================================= // DATA STRUCTURES // ============================================================================= /// IBC Channel configuration #[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] pub struct ChannelConfig { /// Channel ID on this chain pub channel_id: String, /// Counterparty channel ID pub counterparty_channel: String, /// Counterparty chain ID pub counterparty_chain: String, /// Port ID (usually "transfer") pub port_id: String, /// Whether channel is active pub active: bool, /// Next send sequence pub next_send_seq: u64, /// Next receive sequence pub next_recv_seq: u64, /// Next acknowledge sequence pub next_ack_seq: u64, } impl ChannelConfig { pub fn new( channel_id: String, counterparty_channel: String, counterparty_chain: String, ) -> Self { Self { channel_id, counterparty_channel, counterparty_chain, port_id: String::from("transfer"), active: true, next_send_seq: 1, next_recv_seq: 1, next_ack_seq: 1, } } } /// IBC Packet data for token transfers (ICS-20) #[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] pub struct TokenPacket { /// Token denomination pub denom: String, /// Amount to transfer pub amount: u128, /// Sender on source chain pub sender: Address, /// Receiver on destination chain (as string for cross-chain) pub receiver: String, /// Memo field pub memo: String, } /// Packet commitment stored for verification #[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] pub struct PacketCommitment { /// Sequence number pub sequence: u64, /// Packet data hash pub data_hash: [u8; 32], /// Timeout height pub timeout_height: u64, /// Timeout timestamp pub timeout_timestamp: u64, /// Source channel pub source_channel: String, /// Destination channel pub dest_channel: String, } /// HTLC Swap state #[derive(Clone, Debug, BorshSerialize, BorshDeserialize, PartialEq, Eq)] pub enum SwapState { /// Swap is pending/locked Active, /// Swap completed (claimed) Completed, /// Swap refunded Refunded, } /// Hashed Time-Locked Contract for atomic swaps #[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] pub struct Htlc { /// Unique swap ID pub id: u64, /// Creator/sender pub sender: Address, /// Recipient pub receiver: Address, /// Amount locked pub amount: u128, /// SHA256 hash of the secret pub hashlock: [u8; 32], /// Expiration timestamp pub timelock: u64, /// Current state pub state: SwapState, /// Secret (only set after claim) pub secret: Option<[u8; 32]>, } impl Htlc { pub fn is_expired(&self, current_time: u64) -> bool { current_time >= self.timelock } pub fn can_claim(&self, current_time: u64) -> bool { self.state == SwapState::Active && !self.is_expired(current_time) } pub fn can_refund(&self, current_time: u64) -> bool { self.state == SwapState::Active && self.is_expired(current_time) } } // ============================================================================= // EVENTS // ============================================================================= /// Event topic hashes (first 32 bytes of blake3 hash of event name) mod event_topics { pub const TOKENS_LOCKED: [u8; 32] = *b"IBC:TokensLocked________________"; pub const TOKENS_UNLOCKED: [u8; 32] = *b"IBC:TokensUnlocked______________"; pub const PACKET_TIMEOUT: [u8; 32] = *b"IBC:PacketTimeout_______________"; pub const SWAP_CREATED: [u8; 32] = *b"IBC:SwapCreated_________________"; pub const SWAP_CLAIMED: [u8; 32] = *b"IBC:SwapClaimed_________________"; pub const SWAP_REFUNDED: [u8; 32] = *b"IBC:SwapRefunded________________"; pub const CHANNEL_REGISTERED: [u8; 32] = *b"IBC:ChannelRegistered___________"; pub const RELAYER_ADDED: [u8; 32] = *b"IBC:RelayerAdded________________"; pub const RELAYER_REMOVED: [u8; 32] = *b"IBC:RelayerRemoved______________"; } #[derive(BorshSerialize)] pub struct TokensLockedData { pub sender: Address, pub receiver: String, pub amount: u128, pub channel: String, pub sequence: u64, } #[derive(BorshSerialize)] pub struct TokensUnlockedData { pub receiver: Address, pub amount: u128, pub source_channel: String, pub sequence: u64, } #[derive(BorshSerialize)] pub struct PacketTimeoutData { pub sender: Address, pub amount: u128, pub channel: String, pub sequence: u64, } #[derive(BorshSerialize)] pub struct SwapCreatedData { pub id: u64, pub sender: Address, pub receiver: Address, pub amount: u128, pub hashlock: [u8; 32], pub timelock: u64, } #[derive(BorshSerialize)] pub struct SwapClaimedData { pub id: u64, pub claimer: Address, pub secret: [u8; 32], } #[derive(BorshSerialize)] pub struct SwapRefundedData { pub id: u64, pub sender: Address, pub amount: u128, } #[derive(BorshSerialize)] pub struct ChannelRegisteredData { pub channel_id: String, pub counterparty_chain: String, pub counterparty_channel: String, } #[derive(BorshSerialize)] pub struct RelayerAddedData { pub relayer: Address, pub added_by: Address, } #[derive(BorshSerialize)] pub struct RelayerRemovedData { pub relayer: Address, pub removed_by: Address, } // ============================================================================= // STORAGE HELPERS // ============================================================================= fn get_admin() -> Option
{ storage::get::
(keys::ADMIN) } fn is_admin(addr: &Address) -> bool { get_admin().map(|a| a == *addr).unwrap_or(false) } fn is_initialized() -> bool { storage::get::(keys::INITIALIZED).unwrap_or(false) } fn is_relayer(addr: &Address) -> bool { storage::get_with_suffix::(keys::RELAYERS, addr.as_bytes()) .unwrap_or(false) } fn get_channel(channel_id: &str) -> Option { storage::get_with_suffix::(keys::CHANNELS, channel_id.as_bytes()) } fn set_channel(config: &ChannelConfig) { storage::set_with_suffix(keys::CHANNELS, config.channel_id.as_bytes(), config); } fn get_swap(id: u64) -> Option { storage::get_with_suffix::(keys::SWAPS, &id.to_le_bytes()) } fn set_swap(swap: &Htlc) { storage::set_with_suffix(keys::SWAPS, &swap.id.to_le_bytes(), swap); } fn get_swap_counter() -> u64 { storage::get::(keys::SWAP_COUNTER).unwrap_or(0) } fn next_swap_id() -> u64 { let id = get_swap_counter() + 1; storage::set(keys::SWAP_COUNTER, &id); id } fn get_locked_total() -> u128 { storage::get::(keys::LOCKED_TOTAL).unwrap_or(0) } fn set_locked_total(amount: u128) { storage::set(keys::LOCKED_TOTAL, &amount); } fn get_escrow(user: &Address) -> u128 { storage::get_with_suffix::(keys::ESCROW, user.as_bytes()) .unwrap_or(0) } fn set_escrow(user: &Address, amount: u128) { storage::set_with_suffix(keys::ESCROW, user.as_bytes(), &amount); } /// Compute SHA256 hash (simplified - in production use crypto host function) fn sha256(data: &[u8]) -> [u8; 32] { // Simple hash for demo - in production, call crypto host function let mut hash = [0u8; 32]; for (i, byte) in data.iter().enumerate() { hash[i % 32] ^= byte; hash[(i + 1) % 32] = hash[(i + 1) % 32].wrapping_add(*byte); } hash } // ============================================================================= // ENTRY POINTS // ============================================================================= synor_sdk::entry_point!(init, call); /// Initialize the IBC bridge contract fn init(params: &[u8]) -> Result<()> { require!(!is_initialized(), Error::invalid_args("Already initialized")); #[derive(BorshDeserialize)] struct InitParams { chain_id: String, } let params = InitParams::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected (chain_id: String)"))?; require!( !params.chain_id.is_empty(), Error::invalid_args("Chain ID cannot be empty") ); // Set initial state storage::set(keys::ADMIN, &caller()); storage::set(keys::INITIALIZED, &true); storage::set(keys::CHAIN_ID, ¶ms.chain_id); set_locked_total(0); // Admin is automatically a relayer storage::set_with_suffix(keys::RELAYERS, caller().as_bytes(), &true); Ok(()) } /// Handle contract method calls fn call(selector: &[u8], params: &[u8]) -> Result> { // Method selectors let admin_sel = synor_sdk::method_selector("admin"); let add_relayer_sel = synor_sdk::method_selector("add_relayer"); let remove_relayer_sel = synor_sdk::method_selector("remove_relayer"); let register_channel_sel = synor_sdk::method_selector("register_channel"); let lock_tokens_sel = synor_sdk::method_selector("lock_tokens"); let unlock_tokens_sel = synor_sdk::method_selector("unlock_tokens"); let timeout_packet_sel = synor_sdk::method_selector("timeout_packet"); let create_swap_sel = synor_sdk::method_selector("create_swap"); let claim_swap_sel = synor_sdk::method_selector("claim_swap"); let refund_swap_sel = synor_sdk::method_selector("refund_swap"); let get_swap_sel = synor_sdk::method_selector("get_swap"); let get_channel_sel = synor_sdk::method_selector("get_channel"); let get_locked_sel = synor_sdk::method_selector("get_locked_total"); let get_escrow_sel = synor_sdk::method_selector("get_escrow"); match selector { // ===== Admin Methods ===== s if s == admin_sel => { let admin = get_admin().unwrap_or(Address::zero()); Ok(borsh::to_vec(&admin).unwrap()) } s if s == add_relayer_sel => { #[derive(BorshDeserialize)] struct Args { relayer: Address, } let args = Args::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected (relayer: Address)"))?; let admin = get_admin().ok_or(Error::Unauthorized)?; require_auth!(admin); require!( args.relayer != Address::zero(), Error::invalid_args("Cannot add zero address") ); storage::set_with_suffix(keys::RELAYERS, args.relayer.as_bytes(), &true); emit_raw( &[event_topics::RELAYER_ADDED], &borsh::to_vec(&RelayerAddedData { relayer: args.relayer, added_by: caller(), }).unwrap(), ); Ok(borsh::to_vec(&true).unwrap()) } s if s == remove_relayer_sel => { #[derive(BorshDeserialize)] struct Args { relayer: Address, } let args = Args::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected (relayer: Address)"))?; let admin = get_admin().ok_or(Error::Unauthorized)?; require_auth!(admin); storage::set_with_suffix(keys::RELAYERS, args.relayer.as_bytes(), &false); emit_raw( &[event_topics::RELAYER_REMOVED], &borsh::to_vec(&RelayerRemovedData { relayer: args.relayer, removed_by: caller(), }).unwrap(), ); Ok(borsh::to_vec(&true).unwrap()) } s if s == register_channel_sel => { #[derive(BorshDeserialize)] struct Args { channel_id: String, counterparty_channel: String, counterparty_chain: String, } let args = Args::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected (channel_id, counterparty_channel, counterparty_chain)"))?; let admin = get_admin().ok_or(Error::Unauthorized)?; require_auth!(admin); require!( !args.channel_id.is_empty(), Error::invalid_args("Channel ID cannot be empty") ); let config = ChannelConfig::new( args.channel_id.clone(), args.counterparty_channel.clone(), args.counterparty_chain.clone(), ); set_channel(&config); emit_raw( &[event_topics::CHANNEL_REGISTERED], &borsh::to_vec(&ChannelRegisteredData { channel_id: args.channel_id, counterparty_chain: args.counterparty_chain, counterparty_channel: args.counterparty_channel, }).unwrap(), ); Ok(borsh::to_vec(&true).unwrap()) } // ===== ICS-20 Token Transfer Methods ===== s if s == lock_tokens_sel => { #[derive(BorshDeserialize)] struct Args { amount: u128, receiver: String, channel_id: String, timeout_height: u64, } let args = Args::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected (amount, receiver, channel_id, timeout_height)"))?; require!(args.amount > 0, Error::invalid_args("Amount must be > 0")); require!( !args.receiver.is_empty(), Error::invalid_args("Receiver cannot be empty") ); // Get and validate channel let mut channel = get_channel(&args.channel_id) .ok_or_else(|| Error::invalid_args("Channel not registered"))?; require!(channel.active, Error::invalid_args("Channel not active")); // Transfer tokens to this contract (escrow) let sender = caller(); let current_escrow = get_escrow(&sender); let new_escrow = current_escrow.checked_add(args.amount) .ok_or(Error::Overflow)?; set_escrow(&sender, new_escrow); // Update locked total let total = get_locked_total(); set_locked_total(total.checked_add(args.amount).ok_or(Error::Overflow)?); // Create packet commitment let sequence = channel.next_send_seq; channel.next_send_seq += 1; set_channel(&channel); let packet_data = TokenPacket { denom: String::from("SYNOR"), amount: args.amount, sender, receiver: args.receiver.clone(), memo: String::new(), }; let data_hash = sha256(&borsh::to_vec(&packet_data).unwrap()); let commitment = PacketCommitment { sequence, data_hash, timeout_height: args.timeout_height, timeout_timestamp: timestamp() + 3600_000_000_000, // 1 hour source_channel: args.channel_id.clone(), dest_channel: channel.counterparty_channel.clone(), }; storage::set_with_suffix( keys::COMMITMENTS, &commitment_key(&args.channel_id, sequence), &commitment, ); emit_raw( &[event_topics::TOKENS_LOCKED], &borsh::to_vec(&TokensLockedData { sender, receiver: args.receiver, amount: args.amount, channel: args.channel_id, sequence, }).unwrap(), ); Ok(borsh::to_vec(&sequence).unwrap()) } s if s == unlock_tokens_sel => { #[derive(BorshDeserialize)] struct Args { packet_data: TokenPacket, source_channel: String, sequence: u64, proof: Vec, proof_height: u64, } let args = Args::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected packet data and proof"))?; // Only relayers can submit proofs require!( is_relayer(&caller()), Error::Unauthorized ); // Verify channel exists let mut channel = get_channel(&args.source_channel) .ok_or_else(|| Error::invalid_args("Channel not registered"))?; require!(channel.active, Error::invalid_args("Channel not active")); // Check sequence (prevent replay) require!( args.sequence == channel.next_recv_seq, Error::invalid_args("Invalid sequence number") ); // Verify proof (simplified - real implementation verifies Merkle proof) require!( !args.proof.is_empty(), Error::invalid_args("Proof required") ); // Parse receiver address (expects hex-encoded 34-byte address) let receiver = Address::from_hex(&args.packet_data.receiver) .ok_or_else(|| Error::invalid_args("Invalid receiver address"))?; // Update sequence channel.next_recv_seq += 1; set_channel(&channel); // Record receipt storage::set_with_suffix( keys::RECEIPTS, &commitment_key(&args.source_channel, args.sequence), &true, ); // Transfer tokens to receiver // In production, this would call token_transfer host function emit_raw( &[event_topics::TOKENS_UNLOCKED], &borsh::to_vec(&TokensUnlockedData { receiver, amount: args.packet_data.amount, source_channel: args.source_channel, sequence: args.sequence, }).unwrap(), ); Ok(borsh::to_vec(&true).unwrap()) } s if s == timeout_packet_sel => { #[derive(BorshDeserialize)] struct Args { channel_id: String, sequence: u64, } let args = Args::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected (channel_id, sequence)"))?; // Get commitment let commitment_k = commitment_key(&args.channel_id, args.sequence); let commitment: PacketCommitment = storage::get_with_suffix(keys::COMMITMENTS, &commitment_k) .ok_or_else(|| Error::invalid_args("Packet commitment not found"))?; // Check timeout let current_time = timestamp(); require!( current_time >= commitment.timeout_timestamp, Error::invalid_args("Packet not yet timed out") ); // Get packet data to refund // In production, we'd store more info or parse from commitment let sender = caller(); // Simplified - should verify sender // Refund tokens let escrow = get_escrow(&sender); // In production, look up actual locked amount from packet emit_raw( &[event_topics::PACKET_TIMEOUT], &borsh::to_vec(&PacketTimeoutData { sender, amount: 0, // Would be actual amount channel: args.channel_id, sequence: args.sequence, }).unwrap(), ); // Remove commitment storage::delete_with_suffix(keys::COMMITMENTS, &commitment_k); Ok(borsh::to_vec(&true).unwrap()) } // ===== Atomic Swap Methods ===== s if s == create_swap_sel => { #[derive(BorshDeserialize)] struct Args { hashlock: [u8; 32], receiver: Address, amount: u128, timelock_seconds: u64, } let args = Args::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected (hashlock, receiver, amount, timelock_seconds)"))?; require!(args.amount > 0, Error::invalid_args("Amount must be > 0")); require!( args.timelock_seconds >= 3600, Error::invalid_args("Timelock must be at least 1 hour") ); require!( args.receiver != Address::zero(), Error::invalid_args("Invalid receiver") ); let sender = caller(); let id = next_swap_id(); let timelock = timestamp() + args.timelock_seconds * 1_000_000_000; let swap = Htlc { id, sender, receiver: args.receiver, amount: args.amount, hashlock: args.hashlock, timelock, state: SwapState::Active, secret: None, }; set_swap(&swap); // Lock tokens let escrow = get_escrow(&sender); set_escrow(&sender, escrow.checked_add(args.amount).ok_or(Error::Overflow)?); set_locked_total(get_locked_total().checked_add(args.amount).ok_or(Error::Overflow)?); emit_raw( &[event_topics::SWAP_CREATED], &borsh::to_vec(&SwapCreatedData { id, sender, receiver: args.receiver, amount: args.amount, hashlock: args.hashlock, timelock, }).unwrap(), ); Ok(borsh::to_vec(&id).unwrap()) } s if s == claim_swap_sel => { #[derive(BorshDeserialize)] struct Args { swap_id: u64, secret: [u8; 32], } let args = Args::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected (swap_id, secret)"))?; let mut swap = get_swap(args.swap_id) .ok_or_else(|| Error::invalid_args("Swap not found"))?; // Verify can claim require!( swap.can_claim(timestamp()), Error::invalid_args("Cannot claim: expired or already processed") ); // Verify secret matches hashlock let computed_hash = sha256(&args.secret); require!( computed_hash == swap.hashlock, Error::invalid_args("Invalid secret") ); // Update swap state swap.state = SwapState::Completed; swap.secret = Some(args.secret); set_swap(&swap); // Transfer tokens to receiver let sender_escrow = get_escrow(&swap.sender); set_escrow(&swap.sender, sender_escrow.saturating_sub(swap.amount)); set_locked_total(get_locked_total().saturating_sub(swap.amount)); // In production, transfer to receiver via host function emit_raw( &[event_topics::SWAP_CLAIMED], &borsh::to_vec(&SwapClaimedData { id: args.swap_id, claimer: caller(), secret: args.secret, }).unwrap(), ); Ok(borsh::to_vec(&true).unwrap()) } s if s == refund_swap_sel => { #[derive(BorshDeserialize)] struct Args { swap_id: u64, } let args = Args::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected (swap_id)"))?; let mut swap = get_swap(args.swap_id) .ok_or_else(|| Error::invalid_args("Swap not found"))?; // Verify can refund require!( swap.can_refund(timestamp()), Error::invalid_args("Cannot refund: not expired or already processed") ); // Only sender can refund require!( caller() == swap.sender, Error::Unauthorized ); // Update swap state swap.state = SwapState::Refunded; set_swap(&swap); // Return tokens to sender let escrow = get_escrow(&swap.sender); set_escrow(&swap.sender, escrow.saturating_sub(swap.amount)); set_locked_total(get_locked_total().saturating_sub(swap.amount)); // In production, transfer back to sender via host function emit_raw( &[event_topics::SWAP_REFUNDED], &borsh::to_vec(&SwapRefundedData { id: args.swap_id, sender: swap.sender, amount: swap.amount, }).unwrap(), ); Ok(borsh::to_vec(&true).unwrap()) } // ===== Read Methods ===== s if s == get_swap_sel => { #[derive(BorshDeserialize)] struct Args { swap_id: u64, } let args = Args::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected (swap_id)"))?; let swap = get_swap(args.swap_id); Ok(borsh::to_vec(&swap).unwrap()) } s if s == get_channel_sel => { #[derive(BorshDeserialize)] struct Args { channel_id: String, } let args = Args::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected (channel_id)"))?; let channel = get_channel(&args.channel_id); Ok(borsh::to_vec(&channel).unwrap()) } s if s == get_locked_sel => { let total = get_locked_total(); Ok(borsh::to_vec(&total).unwrap()) } s if s == get_escrow_sel => { #[derive(BorshDeserialize)] struct Args { user: Address, } let args = Args::try_from_slice(params) .map_err(|_| Error::invalid_args("Expected (user: Address)"))?; let escrow = get_escrow(&args.user); Ok(borsh::to_vec(&escrow).unwrap()) } _ => Err(Error::InvalidMethod), } } // ============================================================================= // HELPERS // ============================================================================= /// Generate commitment storage key fn commitment_key(channel_id: &str, sequence: u64) -> Vec { let mut key = channel_id.as_bytes().to_vec(); key.extend_from_slice(b":"); key.extend_from_slice(&sequence.to_le_bytes()); key } /// Decode hex string to bytes fn hex_decode(s: &str) -> core::result::Result, ()> { if s.len() % 2 != 0 { return Err(()); } let s = if s.starts_with("0x") || s.starts_with("0X") { &s[2..] } else { s }; let mut bytes = Vec::with_capacity(s.len() / 2); for chunk in s.as_bytes().chunks(2) { let high = hex_char_to_nibble(chunk[0])?; let low = hex_char_to_nibble(chunk[1])?; bytes.push((high << 4) | low); } Ok(bytes) } fn hex_char_to_nibble(c: u8) -> core::result::Result { match c { b'0'..=b'9' => Ok(c - b'0'), b'a'..=b'f' => Ok(c - b'a' + 10), b'A'..=b'F' => Ok(c - b'A' + 10), _ => Err(()), } }