use alloc::collections::BTreeMap;
use alloc::string::ToString;
use miden_protocol::account::AccountId;
use miden_protocol::asset::AssetAmount;
use miden_protocol::block::{BlockHeader, BlockNumber};
use miden_protocol::note::{Note, NoteId, NoteInclusionProof, NoteTag};
use miden_protocol::{Felt, Word};
use miden_standards::note::{PswapNote, PswapNoteAttachment};
use super::errors::PswapLineageError;
use crate::utils::{ByteReader, ByteWriter, Deserializable, DeserializationError, Serializable};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PswapLineageState {
Active = 0,
FullyFilled = 1,
Reclaimed = 2,
}
impl PswapLineageState {
pub fn as_u8(self) -> u8 {
self as u8
}
pub fn try_from_u8(value: u8) -> Result<Self, PswapLineageError> {
match value {
0 => Ok(Self::Active),
1 => Ok(Self::FullyFilled),
2 => Ok(Self::Reclaimed),
other => Err(PswapLineageError::UnknownState(other)),
}
}
}
#[derive(Debug, Clone)]
pub struct PswapLineageRecord {
pub original_note_id: NoteId,
order_id: Felt,
creator_account_id: AccountId,
pub current_tip_note_id: NoteId,
pub current_depth: u32,
pub remaining_offered: AssetAmount,
pub remaining_requested: AssetAmount,
pub state: PswapLineageState,
}
impl PswapLineageRecord {
pub fn new_depth_zero(original_note_id: NoteId, pswap: &PswapNote) -> Self {
Self {
original_note_id,
order_id: pswap.order_id(),
creator_account_id: pswap.storage().creator_account_id(),
current_tip_note_id: original_note_id,
current_depth: 0,
remaining_offered: pswap.offered_asset().amount(),
remaining_requested: pswap.storage().requested_asset().amount(),
state: PswapLineageState::Active,
}
}
pub fn order_id(&self) -> Felt {
self.order_id
}
pub fn creator_account_id(&self) -> AccountId {
self.creator_account_id
}
}
#[derive(Debug, Clone)]
pub(crate) struct PswapLineageRoundUpdate {
pub order_id: Felt,
pub round_depth: u32,
pub remaining_offered: AssetAmount,
pub remaining_requested: AssetAmount,
pub state: PswapLineageState,
pub tip_note_id: Option<NoteId>,
pub at_block_note_root: Option<Word>,
pub payback: Option<(Note, NoteInclusionProof)>,
pub remainder: Option<(Note, NoteInclusionProof)>,
}
#[derive(Debug, Clone)]
pub(crate) struct ObservedPswapNote {
pub note_id: NoteId,
pub attachment: PswapNoteAttachment,
pub sender: AccountId,
pub tag: NoteTag,
pub block_num: BlockNumber,
pub inclusion_proof: NoteInclusionProof,
}
impl PswapLineageRecord {
pub(crate) fn build_round_update(
&self,
round_depth: u32,
notes: &[&ObservedPswapNote],
block_headers: &BTreeMap<BlockNumber, BlockHeader>,
original_pswap: Option<&PswapNote>,
tip_consumed: bool,
) -> Result<Option<PswapLineageRoundUpdate>, PswapLineageError> {
if notes.is_empty() {
return Ok(Some(self.build_reclaim_round(round_depth)));
}
let pswap = original_pswap
.ok_or(PswapLineageError::OriginalNoteUnavailable(self.original_note_id))?;
let payback_tag = pswap.storage().payback_note_tag();
let Some((observed_payback, payback_note)) = notes
.iter()
.copied()
.filter(|note| note.tag == payback_tag)
.find_map(|note| validate_payback(pswap, note).map(|recon| (note, recon)))
else {
return Ok(tip_consumed.then(|| self.build_reclaim_round(round_depth)));
};
let fill_amount = observed_payback.attachment.amount();
let remainder =
notes.iter().copied().filter(|note| note.tag != payback_tag).find_map(|note| {
self.validate_remainder(pswap, note, fill_amount).map(|recon| (note, recon))
});
Ok(Some(match remainder {
Some((observed_remainder, remainder_note)) => self.build_partial_fill_round(
round_depth,
observed_payback,
payback_note,
observed_remainder,
remainder_note,
fill_amount,
block_headers,
),
None => self.build_full_fill_round(
round_depth,
observed_payback,
payback_note,
block_headers,
),
}))
}
fn validate_remainder(
&self,
pswap: &PswapNote,
observed: &ObservedPswapNote,
fill_amount: AssetAmount,
) -> Option<Note> {
let payout_amount = observed.attachment.amount();
let (remaining_offered, remaining_requested) =
self.remaining_after_fill(fill_amount, payout_amount);
let remainder_note = pswap
.remainder_note(
observed.sender,
&observed.attachment,
remaining_offered,
remaining_requested,
)
.ok()?;
(remainder_note.id() == observed.note_id).then_some(remainder_note)
}
fn remaining_after_fill(
&self,
fill_amount: AssetAmount,
payout_amount: AssetAmount,
) -> (AssetAmount, AssetAmount) {
(
saturating_sub(self.remaining_offered, payout_amount),
saturating_sub(self.remaining_requested, fill_amount),
)
}
fn build_reclaim_round(&self, round_depth: u32) -> PswapLineageRoundUpdate {
PswapLineageRoundUpdate {
order_id: self.order_id(),
round_depth,
remaining_offered: AssetAmount::ZERO,
remaining_requested: AssetAmount::ZERO,
state: PswapLineageState::Reclaimed,
tip_note_id: None,
at_block_note_root: None,
payback: None,
remainder: None,
}
}
fn build_full_fill_round(
&self,
round_depth: u32,
observed_payback: &ObservedPswapNote,
payback_note: Note,
block_headers: &BTreeMap<BlockNumber, BlockHeader>,
) -> PswapLineageRoundUpdate {
PswapLineageRoundUpdate {
order_id: self.order_id(),
round_depth,
remaining_offered: AssetAmount::ZERO,
remaining_requested: AssetAmount::ZERO,
state: PswapLineageState::FullyFilled,
tip_note_id: None,
at_block_note_root: block_headers
.get(&observed_payback.block_num)
.map(BlockHeader::note_root),
payback: Some((payback_note, observed_payback.inclusion_proof.clone())),
remainder: None,
}
}
#[allow(clippy::too_many_arguments)]
fn build_partial_fill_round(
&self,
round_depth: u32,
observed_payback: &ObservedPswapNote,
payback_note: Note,
observed_remainder: &ObservedPswapNote,
remainder_note: Note,
fill_amount: AssetAmount,
block_headers: &BTreeMap<BlockNumber, BlockHeader>,
) -> PswapLineageRoundUpdate {
let payout_amount = observed_remainder.attachment.amount();
let (remaining_offered, remaining_requested) =
self.remaining_after_fill(fill_amount, payout_amount);
PswapLineageRoundUpdate {
order_id: self.order_id(),
round_depth,
remaining_offered,
remaining_requested,
state: PswapLineageState::Active,
tip_note_id: Some(observed_remainder.note_id),
at_block_note_root: block_headers
.get(&observed_payback.block_num)
.map(BlockHeader::note_root),
payback: Some((payback_note, observed_payback.inclusion_proof.clone())),
remainder: Some((remainder_note, observed_remainder.inclusion_proof.clone())),
}
}
pub(crate) fn advance(mut self, update: &PswapLineageRoundUpdate) -> PswapLineageRecord {
self.current_depth = update.round_depth;
self.remaining_offered = update.remaining_offered;
self.remaining_requested = update.remaining_requested;
self.state = update.state;
if let Some(note_id) = update.tip_note_id {
self.current_tip_note_id = note_id;
}
self
}
}
fn validate_payback(pswap: &PswapNote, observed: &ObservedPswapNote) -> Option<Note> {
let payback_note = pswap.payback_note(observed.sender, &observed.attachment).ok()?;
(payback_note.id() == observed.note_id).then_some(payback_note)
}
fn saturating_sub(total: AssetAmount, used: AssetAmount) -> AssetAmount {
AssetAmount::new(total.as_u64().saturating_sub(used.as_u64()))
.expect("a value <= an existing AssetAmount is itself a valid AssetAmount")
}
#[derive(Debug, Clone)]
pub(crate) enum PswapLineageFilter {
All,
Active,
ByCreator(AccountId),
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_record_from_fields(
original_note_id: NoteId,
order_id: Felt,
creator_account_id: AccountId,
current_tip_note_id: NoteId,
current_depth: u32,
remaining_offered: AssetAmount,
remaining_requested: AssetAmount,
state_byte: u8,
) -> Result<PswapLineageRecord, PswapLineageError> {
Ok(PswapLineageRecord {
original_note_id,
order_id,
creator_account_id,
current_tip_note_id,
current_depth,
remaining_offered,
remaining_requested,
state: PswapLineageState::try_from_u8(state_byte)?,
})
}
impl Serializable for PswapLineageRecord {
fn write_into<W: ByteWriter>(&self, target: &mut W) {
self.original_note_id.write_into(target);
self.order_id.write_into(target);
self.creator_account_id.write_into(target);
self.current_tip_note_id.write_into(target);
self.current_depth.write_into(target);
self.remaining_offered.write_into(target);
self.remaining_requested.write_into(target);
self.state.as_u8().write_into(target);
}
}
impl Deserializable for PswapLineageRecord {
fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
let original_note_id = NoteId::read_from(source)?;
let order_id = Felt::read_from(source)?;
let creator_account_id = AccountId::read_from(source)?;
let current_tip_note_id = NoteId::read_from(source)?;
let current_depth = u32::read_from(source)?;
let remaining_offered = AssetAmount::read_from(source)?;
let remaining_requested = AssetAmount::read_from(source)?;
let state_byte = u8::read_from(source)?;
build_record_from_fields(
original_note_id,
order_id,
creator_account_id,
current_tip_note_id,
current_depth,
remaining_offered,
remaining_requested,
state_byte,
)
.map_err(|err| DeserializationError::InvalidValue(err.to_string()))
}
}
#[cfg(test)]
pub(crate) mod test_helpers {
use miden_protocol::Word;
use miden_protocol::account::AccountId;
use miden_protocol::asset::FungibleAsset;
use miden_protocol::note::NoteType;
use miden_protocol::testing::account_id::{
ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1,
ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
};
use miden_standards::note::{PswapNote, PswapNoteStorage};
pub fn fixed_account_ids() -> (AccountId, AccountId, AccountId, AccountId) {
(
AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(),
AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2).unwrap(),
AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(),
AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1).unwrap(),
)
}
pub fn build_test_pswap(
sender: AccountId,
creator: AccountId,
offered_faucet: AccountId,
offered_amount: u64,
requested_faucet: AccountId,
requested_amount: u64,
) -> PswapNote {
let offered = FungibleAsset::new(offered_faucet, offered_amount).unwrap();
let requested = FungibleAsset::new(requested_faucet, requested_amount).unwrap();
let storage = PswapNoteStorage::builder()
.requested_asset(requested)
.creator_account_id(creator)
.build();
PswapNote::builder()
.sender(sender)
.storage(storage)
.serial_number(Word::from([
miden_protocol::Felt::new(1).unwrap(),
miden_protocol::Felt::new(2).unwrap(),
miden_protocol::Felt::new(3).unwrap(),
miden_protocol::Felt::new(4).unwrap(),
]))
.note_type(NoteType::Public)
.offered_asset(offered)
.build()
.unwrap()
}
}
#[cfg(test)]
mod tests {
use alloc::vec::Vec;
use miden_protocol::asset::AssetAmount;
use miden_protocol::crypto::merkle::SparseMerklePath;
use miden_standards::note::PswapNote;
use super::test_helpers::{build_test_pswap, fixed_account_ids};
use super::*;
fn record_from_test_pswap(
pswap: &PswapNote,
current_tip_note_id: NoteId,
current_depth: u32,
remaining_offered: u64,
remaining_requested: u64,
state_byte: u8,
) -> Result<PswapLineageRecord, PswapLineageError> {
let original_note_id = miden_protocol::note::Note::from(pswap.clone()).id();
build_record_from_fields(
original_note_id,
pswap.order_id(),
pswap.storage().creator_account_id(),
current_tip_note_id,
current_depth,
AssetAmount::new(remaining_offered).unwrap(),
AssetAmount::new(remaining_requested).unwrap(),
state_byte,
)
}
#[test]
fn state_byte_encoding_is_stable() {
assert_eq!(PswapLineageState::Active.as_u8(), 0);
assert_eq!(PswapLineageState::FullyFilled.as_u8(), 1);
assert_eq!(PswapLineageState::Reclaimed.as_u8(), 2);
}
#[test]
fn state_try_from_u8_round_trips_known_variants() {
for state in [
PswapLineageState::Active,
PswapLineageState::FullyFilled,
PswapLineageState::Reclaimed,
] {
assert_eq!(PswapLineageState::try_from_u8(state.as_u8()).unwrap(), state);
}
}
#[test]
fn state_try_from_u8_rejects_unknown() {
match PswapLineageState::try_from_u8(99) {
Err(PswapLineageError::UnknownState(99)) => {},
other => panic!("expected UnknownState(99), got {other:?}"),
}
}
#[test]
fn build_record_from_fields_accepts_valid_depth_zero_record() {
let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
let initial_note_id = miden_protocol::note::Note::from(pswap.clone()).id();
let record = record_from_test_pswap(
&pswap,
initial_note_id,
0,
100,
50,
PswapLineageState::Active.as_u8(),
)
.unwrap();
assert_eq!(record.current_depth, 0);
assert_eq!(record.remaining_offered, AssetAmount::new(100).unwrap());
assert_eq!(record.remaining_requested, AssetAmount::new(50).unwrap());
assert_eq!(record.state, PswapLineageState::Active);
}
#[test]
fn build_record_from_fields_accepts_valid_advanced_record() {
let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
let note = miden_protocol::note::Note::from(pswap.clone());
let record =
record_from_test_pswap(&pswap, note.id(), 3, 70, 35, PswapLineageState::Active.as_u8())
.unwrap();
assert_eq!(record.current_depth, 3);
assert_eq!(record.remaining_offered, AssetAmount::new(70).unwrap());
}
#[test]
fn build_record_from_fields_rejects_unknown_state() {
let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
let note = miden_protocol::note::Note::from(pswap.clone());
match record_from_test_pswap(&pswap, note.id(), 0, 100, 50, 42) {
Err(PswapLineageError::UnknownState(42)) => {},
other => panic!("expected UnknownState(42), got {other:?}"),
}
}
#[test]
fn accessors_mirror_depth_zero_note() {
let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
let expected_order_id = pswap.order_id();
let note = miden_protocol::note::Note::from(pswap.clone());
let record = record_from_test_pswap(
&pswap,
note.id(),
0,
100,
50,
PswapLineageState::Active.as_u8(),
)
.unwrap();
assert_eq!(record.original_note_id, note.id());
assert_eq!(record.order_id(), expected_order_id);
assert_eq!(record.creator_account_id(), creator);
}
#[test]
fn value_codec_round_trips() {
let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
let note = miden_protocol::note::Note::from(pswap.clone());
let record =
record_from_test_pswap(&pswap, note.id(), 3, 70, 35, PswapLineageState::Active.as_u8())
.unwrap();
let bytes = record.to_bytes();
let decoded = PswapLineageRecord::read_from_bytes(&bytes).unwrap();
assert_eq!(decoded.original_note_id, record.original_note_id);
assert_eq!(decoded.creator_account_id(), record.creator_account_id());
assert_eq!(decoded.order_id(), record.order_id());
assert_eq!(decoded.current_tip_note_id, record.current_tip_note_id);
assert_eq!(decoded.current_depth, record.current_depth);
assert_eq!(decoded.remaining_offered, record.remaining_offered);
assert_eq!(decoded.remaining_requested, record.remaining_requested);
assert_eq!(decoded.remaining_offered, AssetAmount::new(70).unwrap());
assert_eq!(decoded.remaining_requested, AssetAmount::new(35).unwrap());
assert_eq!(decoded.state, record.state);
}
fn dummy_inclusion_proof(block: u32) -> NoteInclusionProof {
let path =
SparseMerklePath::from_parts(0, Vec::new()).expect("empty SparseMerklePath is valid");
NoteInclusionProof::new(BlockNumber::from(block), 0, path)
.expect("zero index is well below the per-block notes ceiling")
}
fn no_block_headers() -> BTreeMap<BlockNumber, BlockHeader> {
BTreeMap::new()
}
fn initial_record(pswap: &PswapNote, offered: u64, requested: u64) -> PswapLineageRecord {
let original_note_id = Note::from(pswap.clone()).id();
let mut record = PswapLineageRecord::new_depth_zero(original_note_id, pswap);
record.remaining_offered =
AssetAmount::new(offered).expect("test value fits in AssetAmount");
record.remaining_requested =
AssetAmount::new(requested).expect("test value fits in AssetAmount");
record
}
fn chain_update_from(
note: &Note,
attachment: PswapNoteAttachment,
sender: AccountId,
block: u32,
) -> ObservedPswapNote {
ObservedPswapNote {
note_id: note.id(),
attachment,
sender,
tag: note.metadata().tag(),
block_num: BlockNumber::from(block),
inclusion_proof: dummy_inclusion_proof(block),
}
}
fn forged_note(
forged_id: NoteId,
attachment: PswapNoteAttachment,
tag: NoteTag,
sender: AccountId,
block: u32,
) -> ObservedPswapNote {
ObservedPswapNote {
note_id: forged_id,
attachment,
sender,
tag,
block_num: BlockNumber::from(block),
inclusion_proof: dummy_inclusion_proof(block),
}
}
fn expect_round(
result: Result<Option<PswapLineageRoundUpdate>, PswapLineageError>,
) -> PswapLineageRoundUpdate {
result
.expect("build_round_update should not error")
.expect("expected a round update")
}
#[test]
fn build_round_update_partial_fill_advances_active() {
let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
let consumer = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
)
.unwrap();
let creator = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
)
.unwrap();
let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
let record = initial_record(&pswap, 100, 50);
let fill_amount = 20;
let payout_amount = 40;
let new_off = 100 - payout_amount;
let new_req = 50 - fill_amount;
let payback_att =
PswapNoteAttachment::new(AssetAmount::new(fill_amount).unwrap(), pswap.order_id(), 1);
let remainder_att =
PswapNoteAttachment::new(AssetAmount::new(payout_amount).unwrap(), pswap.order_id(), 1);
let payback = pswap.payback_note(consumer, &payback_att).unwrap();
let remainder = pswap
.remainder_note(
consumer,
&remainder_att,
AssetAmount::new(new_off).unwrap(),
AssetAmount::new(new_req).unwrap(),
)
.unwrap();
let cand_payback = chain_update_from(&payback, payback_att, consumer, 7);
let cand_remainder = chain_update_from(&remainder, remainder_att, consumer, 7);
let update = expect_round(record.build_round_update(
1,
&[&cand_payback, &cand_remainder],
&no_block_headers(),
Some(&pswap),
true,
));
assert_eq!(update.round_depth, 1);
assert_eq!(update.remaining_offered, AssetAmount::new(new_off).unwrap());
assert_eq!(update.remaining_requested, AssetAmount::new(new_req).unwrap());
assert_eq!(update.state, PswapLineageState::Active);
assert_eq!(update.tip_note_id, Some(remainder.id()));
assert!(update.payback.is_some());
assert!(update.remainder.is_some());
}
#[test]
fn build_round_update_partial_fill_classifies_regardless_of_note_order() {
let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
let consumer = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
)
.unwrap();
let creator = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
)
.unwrap();
let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
let record = initial_record(&pswap, 100, 50);
let fill_amount = 20;
let payout_amount = 40;
let new_off = 100 - payout_amount;
let new_req = 50 - fill_amount;
let payback_att =
PswapNoteAttachment::new(AssetAmount::new(fill_amount).unwrap(), pswap.order_id(), 1);
let remainder_att =
PswapNoteAttachment::new(AssetAmount::new(payout_amount).unwrap(), pswap.order_id(), 1);
let payback = pswap.payback_note(consumer, &payback_att).unwrap();
let remainder = pswap
.remainder_note(
consumer,
&remainder_att,
AssetAmount::new(new_off).unwrap(),
AssetAmount::new(new_req).unwrap(),
)
.unwrap();
let cand_payback = chain_update_from(&payback, payback_att, consumer, 7);
let cand_remainder = chain_update_from(&remainder, remainder_att, consumer, 7);
let update = expect_round(record.build_round_update(
1,
&[&cand_remainder, &cand_payback],
&no_block_headers(),
Some(&pswap),
true,
));
assert_eq!(update.tip_note_id, Some(remainder.id()));
assert_eq!(update.state, PswapLineageState::Active);
}
#[test]
fn build_round_update_filters_unreconstructable_candidate() {
let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
let consumer = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
)
.unwrap();
let creator = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
)
.unwrap();
let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
let record = initial_record(&pswap, 100, 50);
let good_att = PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
let payback = pswap.payback_note(consumer, &good_att).unwrap();
let bad_att = PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 0);
let cand = forged_note(payback.id(), bad_att, payback.metadata().tag(), consumer, 5);
let result =
record.build_round_update(1, &[&cand], &no_block_headers(), Some(&pswap), false);
assert!(
matches!(result, Ok(None)),
"unreconstructable candidate must be filtered, not fatal"
);
}
#[test]
fn build_round_update_full_fill_marks_fully_filled() {
let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
let consumer = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
)
.unwrap();
let creator = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
)
.unwrap();
let pswap = build_test_pswap(consumer, creator, offered_faucet, 30, requested_faucet, 50);
let record = initial_record(&pswap, 30, 50);
let fill_amount = 50; let payback_att =
PswapNoteAttachment::new(AssetAmount::new(fill_amount).unwrap(), pswap.order_id(), 1);
let payback = pswap.payback_note(consumer, &payback_att).unwrap();
let cand = chain_update_from(&payback, payback_att, consumer, 9);
let update = expect_round(record.build_round_update(
1,
&[&cand],
&no_block_headers(),
Some(&pswap),
true,
));
assert_eq!(update.state, PswapLineageState::FullyFilled);
assert_eq!(update.remaining_offered, AssetAmount::ZERO);
assert_eq!(update.remaining_requested, AssetAmount::ZERO);
assert_eq!(update.tip_note_id, None);
assert!(update.remainder.is_none());
}
#[test]
fn build_round_update_zero_outputs_marks_reclaimed_with_remaining_zero() {
let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
let consumer = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
)
.unwrap();
let creator = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
)
.unwrap();
let pswap = build_test_pswap(consumer, creator, offered_faucet, 80, requested_faucet, 40);
let record = initial_record(&pswap, 80, 40);
let update =
expect_round(record.build_round_update(1, &[], &no_block_headers(), None, true));
assert_eq!(update.state, PswapLineageState::Reclaimed);
assert_eq!(update.remaining_offered, AssetAmount::ZERO);
assert_eq!(update.remaining_requested, AssetAmount::ZERO);
assert!(update.payback.is_none());
}
#[test]
fn advance_chains_correctly_for_multi_fill() {
let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
let consumer = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
)
.unwrap();
let creator = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
)
.unwrap();
let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
let record0 = initial_record(&pswap, 100, 50);
let fill1 = 20;
let payout1 = 40;
let new_off1 = 100 - payout1;
let new_req1 = 50 - fill1;
let payback_att1 =
PswapNoteAttachment::new(AssetAmount::new(fill1).unwrap(), pswap.order_id(), 1);
let remainder_att1 =
PswapNoteAttachment::new(AssetAmount::new(payout1).unwrap(), pswap.order_id(), 1);
let payback1 = pswap.payback_note(consumer, &payback_att1).unwrap();
let remainder1 = pswap
.remainder_note(
consumer,
&remainder_att1,
AssetAmount::new(new_off1).unwrap(),
AssetAmount::new(new_req1).unwrap(),
)
.unwrap();
let payback_cand = chain_update_from(&payback1, payback_att1, consumer, 11);
let remainder_cand = chain_update_from(&remainder1, remainder_att1, consumer, 11);
let update1 = expect_round(record0.build_round_update(
1,
&[&payback_cand, &remainder_cand],
&no_block_headers(),
Some(&pswap),
true,
));
let record1 = record0.advance(&update1);
assert_eq!(record1.current_depth, 1);
assert_eq!(record1.remaining_offered, AssetAmount::new(new_off1).unwrap());
assert_eq!(record1.remaining_requested, AssetAmount::new(new_req1).unwrap());
assert_eq!(record1.current_tip_note_id, remainder1.id());
assert_eq!(record1.state, PswapLineageState::Active);
let fill2 = new_req1; let payback_att2 =
PswapNoteAttachment::new(AssetAmount::new(fill2).unwrap(), pswap.order_id(), 2);
let payback2 = pswap.payback_note(consumer, &payback_att2).unwrap();
let cand_p2 = chain_update_from(&payback2, payback_att2, consumer, 11);
let update2 = expect_round(record1.build_round_update(
2,
&[&cand_p2],
&no_block_headers(),
Some(&pswap),
true,
));
assert_eq!(update2.round_depth, 2);
assert_eq!(update2.state, PswapLineageState::FullyFilled);
assert_eq!(update2.remaining_offered, AssetAmount::ZERO);
assert_eq!(update2.remaining_requested, AssetAmount::ZERO);
let record2 = record1.advance(&update2);
assert_eq!(record2.state, PswapLineageState::FullyFilled);
let emitted = [update1, update2];
assert_eq!(emitted.len(), 2);
}
#[test]
fn build_round_update_filters_forged_payback() {
let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
let consumer = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
)
.unwrap();
let creator = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
)
.unwrap();
let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
let record = initial_record(&pswap, 100, 50);
let att = PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
let genuine_payback = pswap.payback_note(consumer, &att).unwrap();
let forged = forged_note(
Note::from(pswap.clone()).id(),
att,
genuine_payback.metadata().tag(),
consumer,
7,
);
assert!(matches!(
record.build_round_update(1, &[&forged], &no_block_headers(), Some(&pswap), false,),
Ok(None)
));
let reclaim = expect_round(record.build_round_update(
1,
&[&forged],
&no_block_headers(),
Some(&pswap),
true,
));
assert_eq!(reclaim.state, PswapLineageState::Reclaimed);
}
#[test]
fn build_round_update_forged_remainder_yields_full_fill() {
let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
let consumer = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
)
.unwrap();
let creator = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
)
.unwrap();
let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
let record = initial_record(&pswap, 100, 50);
let payback_att =
PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
let payback = pswap.payback_note(consumer, &payback_att).unwrap();
let cand_payback = chain_update_from(&payback, payback_att, consumer, 7);
let remainder_att =
PswapNoteAttachment::new(AssetAmount::new(40).unwrap(), pswap.order_id(), 1);
let genuine_remainder = pswap
.remainder_note(
consumer,
&remainder_att,
AssetAmount::new(60).unwrap(),
AssetAmount::new(30).unwrap(),
)
.unwrap();
let forged_remainder = forged_note(
Note::from(pswap.clone()).id(),
remainder_att,
genuine_remainder.metadata().tag(),
consumer,
7,
);
let update = expect_round(record.build_round_update(
1,
&[&cand_payback, &forged_remainder],
&no_block_headers(),
Some(&pswap),
true,
));
assert_eq!(
update.state,
PswapLineageState::FullyFilled,
"forged remainder filtered → full fill"
);
assert!(update.remainder.is_none());
}
#[test]
fn build_round_update_bucket_padding_stays_partial() {
let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
let consumer = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
)
.unwrap();
let creator = AccountId::try_from(
miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
)
.unwrap();
let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
let record = initial_record(&pswap, 100, 50);
let payback_att =
PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
let remainder_att =
PswapNoteAttachment::new(AssetAmount::new(40).unwrap(), pswap.order_id(), 1);
let payback = pswap.payback_note(consumer, &payback_att).unwrap();
let remainder = pswap
.remainder_note(
consumer,
&remainder_att,
AssetAmount::new(60).unwrap(),
AssetAmount::new(30).unwrap(),
)
.unwrap();
let cand_payback = chain_update_from(&payback, payback_att, consumer, 7);
let cand_remainder = chain_update_from(&remainder, remainder_att, consumer, 7);
let forged = forged_note(
Note::from(pswap.clone()).id(),
payback_att,
payback.metadata().tag(),
consumer,
7,
);
let update = expect_round(record.build_round_update(
1,
&[&forged, &cand_payback, &cand_remainder],
&no_block_headers(),
Some(&pswap),
true,
));
assert_eq!(
update.state,
PswapLineageState::Active,
"forgery dropped → still a partial fill"
);
assert_eq!(update.tip_note_id, Some(remainder.id()));
}
}