use miden_protocol::Felt;
use miden_protocol::account::AccountId;
use miden_protocol::asset::AssetAmount;
use miden_protocol::utils::packed_u32_elements_to_bytes;
use super::bytes32::packed_felts_to_bytes32;
use super::deposit_intent::{
BYTES_PER_PACKED_FELT,
BYTES32_LEN,
BYTES32_PACKED_LIMBS,
DepositIntent,
DepositIntentField,
DepositIntentHeader,
DepositNonce,
ForeignChainAddress,
HookData,
};
use super::domain::CircleDomain;
use super::error::EncodingError;
#[derive(Debug, Clone, PartialEq, Eq, bon::Builder)]
pub struct MintIntent {
nonce: DepositNonce,
local_token: ForeignChainAddress,
local_depositor: ForeignChainAddress,
remote_recipient: AccountId,
max_fee: AssetAmount,
hook_data: HookData,
}
impl MintIntent {
pub const ACCOUNT_ID_FELTS: usize = 2;
pub const NONCE_FELT_OFF: usize = 0;
pub const LOCAL_TOKEN_FELT_OFF: usize = Self::NONCE_FELT_OFF + BYTES32_PACKED_LIMBS;
pub const LOCAL_DEPOSITOR_FELT_OFF: usize = Self::LOCAL_TOKEN_FELT_OFF + BYTES32_PACKED_LIMBS;
pub const REMOTE_RECIPIENT_FELT_OFF: usize =
Self::LOCAL_DEPOSITOR_FELT_OFF + BYTES32_PACKED_LIMBS;
pub const REMOTE_RECIPIENT_SUFFIX_FELT_OFF: usize = Self::REMOTE_RECIPIENT_FELT_OFF + 1;
pub const MAX_FEE_FELT_OFF: usize = Self::REMOTE_RECIPIENT_FELT_OFF + Self::ACCOUNT_ID_FELTS;
pub const HOOK_DATA_LEN_FELT_OFF: usize = Self::MAX_FEE_FELT_OFF + 1;
pub const NUM_FELTS: usize = Self::HOOK_DATA_LEN_FELT_OFF + 1;
pub fn from_deposit_intent(
intent: &DepositIntent,
target: AccountId,
remote_domain: CircleDomain,
) -> Result<Self, EncodingError> {
let header = intent.header();
if header.remote_token() != target {
return Err(EncodingError::RemoteTokenMismatch);
}
if header.remote_domain() != remote_domain {
return Err(EncodingError::RemoteDomainMismatch {
expected: remote_domain,
actual: header.remote_domain(),
});
}
Ok(Self {
nonce: header.nonce(),
local_token: header.local_token(),
local_depositor: header.local_depositor(),
remote_recipient: header.remote_recipient(),
max_fee: header.max_fee(),
hook_data: intent.hook_data().clone(),
})
}
pub fn to_deposit_intent(
&self,
amount: AssetAmount,
remote_domain: CircleDomain,
remote_token: AccountId,
) -> DepositIntent {
let header = DepositIntentHeader::builder()
.amount(amount)
.remote_domain(remote_domain)
.remote_token(remote_token)
.remote_recipient(self.remote_recipient)
.local_token(self.local_token)
.local_depositor(self.local_depositor)
.max_fee(self.max_fee)
.nonce(self.nonce)
.build();
DepositIntent::new(header, self.hook_data.clone())
}
pub fn to_elements(&self) -> Vec<Felt> {
let mut out = Vec::with_capacity(Self::NUM_FELTS);
out.extend_from_slice(&self.nonce.to_packed_felts());
out.extend_from_slice(&self.local_token.to_packed_felts());
out.extend_from_slice(&self.local_depositor.to_packed_felts());
out.push(self.remote_recipient.prefix().as_felt());
out.push(self.remote_recipient.suffix());
out.push(Felt::from(self.max_fee));
out.push(Felt::from(self.hook_data.len_u32()));
out.extend(self.hook_data.to_packed_elements());
out
}
pub fn from_elements(felts: &[Felt]) -> Result<Self, EncodingError> {
if felts.len() < Self::NUM_FELTS {
return Err(EncodingError::LengthMismatch);
}
let hook_data_len = u32_at(felts, Self::HOOK_DATA_LEN_FELT_OFF)?;
let hook_data_felts = felts.len() - Self::NUM_FELTS;
if hook_data_felts != (hook_data_len as usize).div_ceil(BYTES_PER_PACKED_FELT) {
return Err(EncodingError::LengthMismatch);
}
let mut hook_data = unpack_bytes(&felts[Self::NUM_FELTS..])?;
hook_data.truncate(hook_data_len as usize);
Ok(Self {
nonce: DepositNonce::new(bytes32_at_felts(felts, Self::NONCE_FELT_OFF)?),
local_token: ForeignChainAddress::new(bytes32_at_felts(
felts,
Self::LOCAL_TOKEN_FELT_OFF,
)?),
local_depositor: ForeignChainAddress::new(bytes32_at_felts(
felts,
Self::LOCAL_DEPOSITOR_FELT_OFF,
)?),
remote_recipient: AccountId::try_from_elements(
felts[Self::REMOTE_RECIPIENT_FELT_OFF + 1],
felts[Self::REMOTE_RECIPIENT_FELT_OFF],
)
.map_err(|_| EncodingError::NonCanonicalAccountId)?,
max_fee: AssetAmount::new(felts[Self::MAX_FEE_FELT_OFF].as_canonical_u64()).map_err(
|_| EncodingError::FieldNotAssetAmount { field: DepositIntentField::MaxFee },
)?,
hook_data: HookData::new(hook_data)?,
})
}
pub fn nonce(&self) -> DepositNonce {
self.nonce
}
pub fn local_token(&self) -> ForeignChainAddress {
self.local_token
}
pub fn local_depositor(&self) -> ForeignChainAddress {
self.local_depositor
}
pub fn remote_recipient(&self) -> AccountId {
self.remote_recipient
}
pub fn max_fee(&self) -> AssetAmount {
self.max_fee
}
pub fn hook_data(&self) -> &HookData {
&self.hook_data
}
}
fn u32_at(felts: &[Felt], index: usize) -> Result<u32, EncodingError> {
u32::try_from(felts[index].as_canonical_u64()).map_err(|_| EncodingError::LimbNotU32)
}
fn unpack_bytes(felts: &[Felt]) -> Result<Vec<u8>, EncodingError> {
for index in 0..felts.len() {
u32_at(felts, index)?;
}
Ok(packed_u32_elements_to_bytes(felts))
}
fn bytes32_at_felts(felts: &[Felt], offset: usize) -> Result<[u8; BYTES32_LEN], EncodingError> {
let limbs: [Felt; BYTES32_PACKED_LIMBS] = felts[offset..offset + BYTES32_PACKED_LIMBS]
.try_into()
.expect("the caller's length check guarantees the window");
packed_felts_to_bytes32(&limbs)
}
#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use miden_protocol::utils::serde::Serializable;
use super::*;
use crate::vectors::{MiVector, load};
fn accepts() -> impl Iterator<Item = &'static MiVector> {
load().families.mi.iter().filter(|v| v.kind == "accept")
}
fn intent(vec: &MiVector) -> DepositIntent {
DepositIntent::try_from(vec.payload().as_slice())
.unwrap_or_else(|e| panic!("vector {}: payload must decode: {e}", vec.id))
}
fn carried(vec: &MiVector) -> MintIntent {
MintIntent::from_deposit_intent(&intent(vec), vec.faucet_id(), vec.remote_domain)
.unwrap_or_else(|e| panic!("vector {}: compress failed: {e}", vec.id))
}
#[test]
fn tv_dual_6_round_trip_is_byte_exact() {
for vec in accepts() {
assert_eq!(
carried(vec)
.to_deposit_intent(vec.amount(), vec.remote_domain, vec.faucet_id())
.to_bytes(),
vec.payload(),
"vector {}: the rebuilt preimage must equal what Circle signed",
vec.id
);
}
}
#[test]
fn tv_dual_6_carried_and_rebuilt_felts_match_the_vectors() {
for vec in accepts() {
let carried = carried(vec);
assert_eq!(
carried.to_elements(),
vec.carried_values(),
"vector {}: carried felts",
vec.id
);
assert_eq!(
carried
.to_deposit_intent(vec.amount(), vec.remote_domain, vec.faucet_id())
.to_preimage_felts(),
vec.rebuilt_preimage_values(),
"vector {}: rebuilt preimage felts",
vec.id
);
}
}
#[test]
fn tv_dual_6_carried_felts_are_lossless() {
for vec in accepts() {
let carried = carried(vec);
assert_eq!(
MintIntent::from_elements(&carried.to_elements()).expect("round trip"),
carried,
"vector {}: carried felts must round-trip",
vec.id
);
}
}
#[test]
fn tv_dual_6_rejects_what_the_transport_cannot_carry() {
let rejects = load().families.mi.iter().filter(|v| v.kind == "reject");
for vec in rejects {
let id = &vec.id;
let result = DepositIntent::try_from(vec.payload().as_slice()).and_then(|intent| {
MintIntent::from_deposit_intent(&intent, vec.faucet_id(), vec.remote_domain)
});
match vec.expected_variant.as_deref().expect("reject vector") {
"FieldNotAssetAmount" => {
assert_matches!(
result,
Err(EncodingError::FieldNotAssetAmount { .. }),
"vector {id}"
)
},
"RemoteTokenMismatch" => {
assert_matches!(result, Err(EncodingError::RemoteTokenMismatch), "vector {id}")
},
"AccountIdOutOfRange" => {
assert_matches!(result, Err(EncodingError::AccountIdOutOfRange), "vector {id}")
},
"NonCanonicalAccountId" => {
assert_matches!(
result,
Err(EncodingError::NonCanonicalAccountId),
"vector {id}"
)
},
other => panic!("vector {id}: unexpected expected_variant {other:?}"),
}
}
}
#[test]
fn compress_rejects_a_foreign_remote_domain() {
let vec = accepts().next().expect("an accept vector");
assert_matches!(
MintIntent::from_deposit_intent(
&intent(vec),
vec.faucet_id(),
CircleDomain::new(vec.remote_domain.as_u32().wrapping_add(1))
),
Err(EncodingError::RemoteDomainMismatch { .. })
);
}
#[test]
fn a_source_chain_address_wider_than_an_evm_address_round_trips() {
let vec = accepts().next().expect("an accept vector");
let wide = ForeignChainAddress::new(core::array::from_fn(|i| 0xf0 ^ i as u8));
assert_ne!(wide.as_bytes()[..12], [0u8; 12], "not an EVM address");
let carried = MintIntent::builder()
.nonce(carried(vec).nonce())
.local_token(wide)
.local_depositor(wide)
.remote_recipient(vec.faucet_id())
.max_fee(vec.amount())
.hook_data(HookData::default())
.build();
let back = MintIntent::from_elements(&carried.to_elements()).expect("round trip");
assert_eq!(back.local_token(), wide);
assert_eq!(back.local_depositor(), wide);
}
#[test]
fn from_felts_rejects_a_hook_data_length_lie() {
let vec = accepts().find(|v| v.id == "mi-pos-empty-hookdata").expect("vector present");
let mut felts = vec.carried_values();
felts[MintIntent::HOOK_DATA_LEN_FELT_OFF] = Felt::from(4u32);
assert_matches!(MintIntent::from_elements(&felts), Err(EncodingError::LengthMismatch));
}
}