diff --git a/Cargo.lock b/Cargo.lock index 2ab4cc5d0..81c2d9806 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2304,10 +2304,12 @@ dependencies = [ "getrandom", "jstz_crypto", "jstz_proto", + "nom", "serde", "tezos-smart-rollup", "tezos-smart-rollup-host", "tezos-smart-rollup-mock", + "tezos_data_encoding 0.6.0", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 4cdae3287..4579d90b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ in-container = "^1" indicatif = "0.17.0" log = "0.4.20" nix = { version = "^0.27.1", features = ["process", "signal"] } +nom = "7.1.3" num-traits = "0.2.16" parking_lot = "0.12.1" prettytable = "0.10.0" diff --git a/crates/jstz_core/Cargo.toml b/crates/jstz_core/Cargo.toml index 908286d91..274bb8dd2 100644 --- a/crates/jstz_core/Cargo.toml +++ b/crates/jstz_core/Cargo.toml @@ -22,6 +22,8 @@ jstz_crypto = { path = "../jstz_crypto" } serde.workspace = true tezos-smart-rollup-host.workspace = true tezos-smart-rollup.workspace = true +tezos_data_encoding.workspace = true +nom.workspace = true [dev-dependencies] anyhow.workspace = true diff --git a/crates/jstz_core/src/error.rs b/crates/jstz_core/src/error.rs index 10345136d..51e57f304 100644 --- a/crates/jstz_core/src/error.rs +++ b/crates/jstz_core/src/error.rs @@ -25,6 +25,9 @@ pub enum Error { SerializationError { description: String, }, + OutboxError { + source: crate::kv::outbox::OutboxError, + }, } impl From for JsError { @@ -46,6 +49,9 @@ impl From for JsError { Error::SerializationError { description } => JsNativeError::eval() .with_message(format!("serialization error: {description}")) .into(), + Error::OutboxError { source } => JsNativeError::eval() + .with_message(format!("OutboxError: {}", source)) + .into(), } } } diff --git a/crates/jstz_core/src/kv/mod.rs b/crates/jstz_core/src/kv/mod.rs index 9029a3b09..34d264c6e 100644 --- a/crates/jstz_core/src/kv/mod.rs +++ b/crates/jstz_core/src/kv/mod.rs @@ -9,6 +9,7 @@ use tezos_smart_rollup_host::{path::Path, runtime::Runtime}; use crate::error::Result; +pub mod outbox; pub mod transaction; pub mod value; diff --git a/crates/jstz_core/src/kv/outbox.rs b/crates/jstz_core/src/kv/outbox.rs new file mode 100644 index 000000000..5a3240e71 --- /dev/null +++ b/crates/jstz_core/src/kv/outbox.rs @@ -0,0 +1,461 @@ +use crate::error::Result; +use derive_more::{Display, Error, From}; +use serde::{Deserialize, Serialize}; +use tezos_smart_rollup::{ + core_unsafe::MAX_OUTPUT_SIZE, + michelson::{ticket::FA2_1Ticket, MichelsonContract, MichelsonPair}, + outbox::{ + AtomicBatch, OutboxMessageFull, OutboxMessageTransaction, + OutboxQueue as KernelSdkOutboxQueue, OUTBOX_QUEUE, + }, + prelude::debug_msg, +}; + +use tezos_data_encoding::{enc::BinWriter, encoding::HasEncoding, nom::NomReader}; +use tezos_smart_rollup_host::{path::RefPath, runtime::Runtime}; + +use super::Storage; + +/// Exposing tezos_smart_rollup::outbox::OUTBOX_QUEUE_ROOT +const ROLLUP_OUTBOX_QUEUE_ROOT: RefPath = RefPath::assert_from(b"/__sdk/outbox"); + +const OUTBOX_QUEUE_ROOT: RefPath = RefPath::assert_from(b"/outbox"); + +type RollupOutboxQueue = KernelSdkOutboxQueue<'static, RefPath<'static>>; + +type NativeWithdrawalParameters = MichelsonPair; + +// TODO: Might need to use OutboxMessageTransactionBatch for L1 encoding +type Withdrawal = OutboxMessageTransaction; + +#[derive(Debug, HasEncoding, PartialEq)] +pub enum OutboxMessage { + Withdrawal(Withdrawal), +} + +impl AtomicBatch for OutboxMessage {} + +impl BinWriter for OutboxMessage { + fn bin_write(&self, output: &mut Vec) -> tezos_data_encoding::enc::BinResult { + match self { + // TODO: Might need to use OutboxMessageTransactionBatch serialization for + // L1 encoding + OutboxMessage::Withdrawal(withdrawal) => withdrawal.bin_write(output), + } + } +} + +impl<'a> NomReader<'a> for OutboxMessage { + fn nom_read(input: &'a [u8]) -> tezos_data_encoding::nom::NomResult<'a, Self> { + nom::branch::alt((nom::combinator::map(Withdrawal::nom_read, |withdrawal| { + OutboxMessage::Withdrawal(withdrawal) + }),))(input) + } +} + +impl From for OutboxMessageFull { + fn from(message: OutboxMessage) -> Self { + match message { + OutboxMessage::Withdrawal(_) => { + OutboxMessageFull::AtomicTransactionBatch(message) + } + } + } +} + +/// Represents a pending outbox queue stored as part of the +/// trasaction's snapshot. +#[derive(Debug, Default)] +pub struct OutboxQueueSnapshot(Vec); + +impl OutboxQueueSnapshot { + pub fn extend(&mut self, queue: OutboxQueueSnapshot) { + self.0.extend(queue.0) + } + + pub fn queue_message(&mut self, message: OutboxMessage) { + self.0.push(message) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutboxQueueMeta { + /// Current queue length between the rollup and snapshots' + /// outbox queues + queue_len: u32, + /// Maximum capacity of the rollup outbox queue + max: u32, +} + +impl OutboxQueueMeta { + // FIX: Unfortunately, the RollupOutboxQueue does not expose any methods to + // read its metadata so use this workaround for now. + pub fn read_meta(rt: &impl Runtime) -> Result> { + Storage::get::(rt, &OUTBOX_QUEUE_ROOT) + } + + fn write_meta(&self, rt: &mut impl Runtime) -> Result<()> { + Storage::insert(rt, &OUTBOX_QUEUE_ROOT, self) + } +} + +/// An outbox queue that composes outbox queue snapshots +/// and `ROLLUP_OUTBOX_QUEUE` while maintaining the invariants +/// of the kernel outbox. +#[derive(Debug)] +pub struct OutboxQueue { + /// Metadata for the outbox queue + meta: OutboxQueueMeta, + + /// Rollup outbox queue + rollup_outbox_queue: RollupOutboxQueue, +} + +impl OutboxQueue { + fn write_outbox_message( + rt: &mut impl Runtime, + message: &OutboxMessageFull, + ) -> Result<()> { + let mut buffer = Vec::with_capacity(MAX_OUTPUT_SIZE); + message + .bin_write(&mut buffer) + .map_err(|_| OutboxError::OutboxMessageSerializationError)?; + rt.write_output(&buffer)?; + Ok(()) + } + + pub fn new(max: u32) -> Self { + Self { + meta: OutboxQueueMeta { queue_len: 0, max }, + rollup_outbox_queue: RollupOutboxQueue::new(&ROLLUP_OUTBOX_QUEUE_ROOT, max) + .unwrap(), + } + } + + pub fn queue_len(&self) -> u32 { + self.meta.queue_len + } + + pub fn incr_queue_len(&mut self) { + self.meta.queue_len += 1; + } + + pub fn max(&self) -> u32 { + self.meta.max + } + + /// Flushes the outbox queue in the order of the rollup outbox queue + /// then the outbox queue snapshot. The outbox has a maximum capacity + /// of 100 messages per level. Messages that cannot be flushed in the + /// current level are enqueued into the rollup outbox queue for the + /// next flush. + pub fn flush( + &mut self, + host: &mut impl Runtime, + snapshot: OutboxQueueSnapshot, + ) -> usize { + // 1. Flush the existing outbox queue + let mut flushed_count = self.rollup_outbox_queue.flush_queue(host); + + // 2. Flush the outbox queue snapshot if there is space in the outbox + let mut tezos_outbox_messages = + snapshot.0.into_iter().map(|message| message.into()); + + for message in tezos_outbox_messages.by_ref() { + match Self::write_outbox_message(host, &message) { + Ok(()) => { + flushed_count += 1; + continue; + } + Err(crate::Error::HostError { + source: + tezos_smart_rollup::host::RuntimeError::HostErr( + tezos_smart_rollup_host::Error::FullOutbox, + ), + }) => { + self.rollup_outbox_queue + .queue_message(host, message) + .unwrap(); + break; + } + Err(e) => { + // This arm is unexpected and probably indicates a bug + // or cpu/memory degradation. + debug_msg!(host, "Error while writing message to outbox: {:?}", e); + self.rollup_outbox_queue + .queue_message(host, message) + .unwrap(); + break; + } + } + } + + // 3. Enqueue the remaining messages into the outbox queue + for message in tezos_outbox_messages { + self.rollup_outbox_queue + .queue_message(host, message) + .expect("Should always be able to queue message"); + } + + // 4. Finally, update the counter outbox messages + self.meta.queue_len -= flushed_count as u32; + self.meta + .write_meta(host) + .expect("Should always be able to write OutboxQueueMeta"); + flushed_count + } +} + +impl Default for OutboxQueue { + fn default() -> Self { + Self { + meta: OutboxQueueMeta { + queue_len: 0, + max: u16::MAX as u32, + }, + rollup_outbox_queue: OUTBOX_QUEUE, + } + } +} + +#[derive(Display, Debug, Error, From)] +pub enum OutboxError { + /// Outbox reached its maximum capacity + OutboxFull, + /// Error while serializing an outbox message. + /// This is unexpected and probably indicates a bug + OutboxMessageSerializationError, +} + +#[cfg(test)] +mod test { + use jstz_crypto::public_key_hash::PublicKeyHash; + use tezos_data_encoding::nom::NomReader; + use tezos_smart_rollup::{ + michelson::{ + ticket::FA2_1Ticket, MichelsonContract, MichelsonNat, MichelsonOption, + MichelsonPair, + }, + outbox::{OutboxMessageFull, OutboxMessageTransaction, OUTBOX_QUEUE}, + types::{Contract, Entrypoint}, + }; + + use tezos_smart_rollup_mock::MockHost; + + use crate::kv::outbox::{OutboxQueueMeta, ROLLUP_OUTBOX_QUEUE_ROOT}; + + use super::{OutboxMessage, OutboxQueue, OutboxQueueSnapshot, RollupOutboxQueue}; + + fn make_withdrawal(account: &PublicKeyHash) -> OutboxMessage { + let creator = + Contract::from_b58check("KT1NgXQ6Mwu3XKFDcKdYFS6dkkY3iNKdBKEc").unwrap(); + let parameters = MichelsonPair( + MichelsonContract(Contract::try_from(account.to_base58()).unwrap()), + FA2_1Ticket::new( + creator.clone(), + MichelsonPair(MichelsonNat::from(0), MichelsonOption(None)), + 10, + ) + .unwrap(), + ); + let outbox_tx = OutboxMessageTransaction { + parameters, + destination: creator, + entrypoint: Entrypoint::try_from("burn".to_string()).unwrap(), + }; + OutboxMessage::Withdrawal(outbox_tx) + } + + #[test] + fn flush_empty_outbox_queue_does_nothing() { + let mut host = MockHost::default(); + let outbox_queue_snapshot = OutboxQueueSnapshot(vec![]); + let mut outbox_queue = OutboxQueue::default(); + + let num_flushed = outbox_queue.flush(&mut host, outbox_queue_snapshot); + + assert_eq!(0, num_flushed); + assert_eq!(0, outbox_queue.queue_len()); + + let level = host.run_level(|_| {}); + let outbox = host.outbox_at(level); + + assert_eq!(0, outbox.len()); + } + + #[test] + fn flush_empty_snapshot_flushes_rollup_queue() { + let mut host = MockHost::default(); + let mut outbox_queue = OutboxQueue::default(); + outbox_queue.meta.queue_len = 2; + let accounts = [ + PublicKeyHash::digest(b"account1").unwrap(), + PublicKeyHash::digest(b"account2").unwrap(), + ]; + let withdrawals: Vec = accounts + .clone() + .into_iter() + .map(|acc| make_withdrawal(&acc)) + .collect(); + + for withdrawal in withdrawals { + OUTBOX_QUEUE.queue_message(&mut host, withdrawal).unwrap(); + } + + let outbox_queue_snapshot = OutboxQueueSnapshot(vec![]); + let num_flushed = outbox_queue.flush(&mut host, outbox_queue_snapshot); + + assert_eq!(2, num_flushed); + assert_eq!(0, outbox_queue.queue_len()); + + let level = host.run_level(|_| {}); + let outbox = host.outbox_at(level); + + for (i, message) in outbox.iter().enumerate() { + let (_, message) = + OutboxMessageFull::::nom_read(message).unwrap(); + assert_eq!(message, make_withdrawal(&accounts[i]).into()); + } + } + + #[test] + fn flush_rollup_queue_first_then_snapshot_queue() { + let mut host = MockHost::default(); + let mut outbox_queue = OutboxQueue::default(); + outbox_queue.meta.queue_len = 4; + + let accounts = [ + PublicKeyHash::digest(b"account1").unwrap(), + PublicKeyHash::digest(b"account2").unwrap(), + PublicKeyHash::digest(b"account3").unwrap(), + PublicKeyHash::digest(b"account4").unwrap(), + ]; + for i in 0..2 { + outbox_queue + .rollup_outbox_queue + .queue_message(&mut host, make_withdrawal(&accounts[i])) + .unwrap(); + } + + let outbox_queue_snapshot = OutboxQueueSnapshot( + accounts[2..] + .iter() + .map(|acc| make_withdrawal(acc)) + .collect(), + ); + + let num_flushed = outbox_queue.flush(&mut host, outbox_queue_snapshot); + + assert_eq!(0, outbox_queue.queue_len()); + assert_eq!(4, num_flushed); + + let level = host.run_level(|_| {}); + let outbox = host.outbox_at(level); + + assert_eq!(4, outbox.len()); + + for (i, message) in outbox.iter().enumerate() { + let (_, message) = + OutboxMessageFull::::nom_read(message).unwrap(); + assert_eq!(message, make_withdrawal(&accounts[i]).into()); + } + } + + #[test] + fn flush_enqueues_remaining_messages_to_rollup_queue() { + let mut host = MockHost::default(); + let mut messages: Vec = vec![]; + for i in 0..120 { + let account = + PublicKeyHash::digest(format!("account{}", i).as_bytes()).unwrap(); + messages.push(make_withdrawal(&account)) + } + let mut messages = messages.into_iter(); + + let max = u16::MAX as u32; + let mut outbox_queue = OutboxQueue { + meta: OutboxQueueMeta { + queue_len: 120, + max, + }, + rollup_outbox_queue: RollupOutboxQueue::new(&ROLLUP_OUTBOX_QUEUE_ROOT, max) + .unwrap(), + }; + + for message in messages.by_ref().take(60) { + OUTBOX_QUEUE.queue_message(&mut host, message).unwrap(); + } + + let outbox_queue_snapshot = OutboxQueueSnapshot(messages.take(60).collect()); + + outbox_queue.flush(&mut host, outbox_queue_snapshot); + + assert_eq!(20, outbox_queue.queue_len()); + + let level = host.run_level(|_| {}); + let outbox = host.outbox_at(level); + + assert_eq!(100, outbox.len()); + } + + #[test] + fn write_outbox_message() { + let mut host = MockHost::default(); + let withdrawals = [0; 10] + .map(|_| make_withdrawal(&PublicKeyHash::digest(b"account1").unwrap())) + .into_iter(); + + for withdrawal in withdrawals { + OutboxQueue::write_outbox_message(&mut host, &withdrawal.into()).unwrap(); + } + + let level = host.run_level(|_| {}); + let outbox = host.outbox_at(level); + + assert_eq!(10, outbox.len()); + } + + #[test] + fn write_outbox_message_errors_on_full_outbox() { + let mut host = MockHost::default(); + let mut withdrawals = [0; 101] + .map(|_| make_withdrawal(&PublicKeyHash::digest(b"account1").unwrap())) + .into_iter(); + + for withdrawal in withdrawals.by_ref().take(100) { + OutboxQueue::write_outbox_message(&mut host, &withdrawal.into()).unwrap(); + } + + let error = OutboxQueue::write_outbox_message( + &mut host, + &withdrawals.next().unwrap().into(), + ) + .expect_err("Expected outbox full error"); + + assert!(matches!( + error, + crate::Error::HostError { + source: tezos_smart_rollup::host::RuntimeError::HostErr( + tezos_smart_rollup_host::Error::FullOutbox, + ), + } + )); + } + + #[test] + fn extend_snapshot() { + let acc1 = PublicKeyHash::digest(b"account1").unwrap(); + let acc2 = PublicKeyHash::digest(b"account2").unwrap(); + let mut outbox_queue_snapshot1 = + OutboxQueueSnapshot(vec![make_withdrawal(&acc1)]); + let outbox_queue_snapshot2 = OutboxQueueSnapshot(vec![make_withdrawal(&acc2)]); + + outbox_queue_snapshot1.extend(outbox_queue_snapshot2); + + assert_eq!(2, outbox_queue_snapshot1.0.len()); + assert_eq!( + vec![make_withdrawal(&acc1), make_withdrawal(&acc2)], + outbox_queue_snapshot1.0 + ); + } +} diff --git a/crates/jstz_core/src/kv/transaction.rs b/crates/jstz_core/src/kv/transaction.rs index 9745120b8..b3750ddaa 100644 --- a/crates/jstz_core/src/kv/transaction.rs +++ b/crates/jstz_core/src/kv/transaction.rs @@ -8,8 +8,14 @@ use derive_more::{Deref, DerefMut}; use serde::de::DeserializeOwned; use tezos_smart_rollup_host::{path::OwnedPath, runtime::Runtime}; -use super::value::{BoxedValue, Value}; -use super::Storage; +use super::{ + outbox::{OutboxError, OutboxQueueSnapshot}, + Storage, +}; +use super::{ + outbox::{OutboxMessage, OutboxQueue}, + value::{BoxedValue, Value}, +}; use crate::error::{KvError, Result}; /// A transaction is a 'lazy' snapshot of the persistent key-value store from @@ -55,6 +61,7 @@ pub struct Transaction { // A stack of transactional snapshots stack: Vec, lookup_map: LookupMap, + outbox_queue: OutboxQueue, } #[derive(Debug, Clone, Deref, DerefMut)] @@ -101,6 +108,7 @@ struct Snapshot { insert_edits: BTreeMap, // A set of 'remove' edits to be applied remove_edits: BTreeSet, + outbox_snapshot_queue: OutboxQueueSnapshot, } impl Snapshot { @@ -133,6 +141,10 @@ impl Snapshot { pub fn contains_key(&self, key: &Key) -> bool { self.insert_edits.contains_key(key) && !self.remove_edits.contains(key) } + + pub fn outbox_queue_mut(&mut self) -> &mut OutboxQueueSnapshot { + &mut self.outbox_snapshot_queue + } } impl LookupMap { @@ -338,6 +350,17 @@ impl Transaction { } } + pub fn push_outbox_message(&mut self, message: OutboxMessage) -> Result<()> { + if self.outbox_queue.queue_len() + 1 > self.outbox_queue.max() { + Err(OutboxError::OutboxFull)?; + } + + let current_outbox_queue = self.current_snapshot()?.outbox_queue_mut(); + current_outbox_queue.queue_message(message); + self.outbox_queue.incr_queue_len(); + Ok(()) + } + /// Begin a transaction. pub fn begin(&mut self) { self.stack.push(Snapshot::default()) @@ -361,6 +384,10 @@ impl Transaction { self.lookup_map.update(key.clone(), prev_idx); prev_ctxt.insert(key, value); } + + prev_ctxt + .outbox_snapshot_queue + .extend(curr_ctxt.outbox_snapshot_queue); } else { for key in &curr_ctxt.remove_edits { Storage::remove(rt, key)? @@ -370,6 +397,8 @@ impl Transaction { Storage::insert(rt, &key, value.0.as_ref())? } + self.outbox_queue.flush(rt, curr_ctxt.outbox_snapshot_queue); + // Update lookup map self.lookup_map.clear() } @@ -573,3 +602,169 @@ impl JsTransaction { Self { inner: rt } } } + +#[cfg(test)] +mod test { + use jstz_crypto::public_key_hash::PublicKeyHash; + use tezos_data_encoding::nom::NomReader; + use tezos_smart_rollup::{ + michelson::{ + ticket::FA2_1Ticket, MichelsonContract, MichelsonNat, MichelsonOption, + MichelsonPair, + }, + outbox::{OutboxMessageFull, OutboxMessageTransaction}, + types::{Contract, Entrypoint}, + }; + use tezos_smart_rollup_mock::MockHost; + + use crate::kv::outbox::{OutboxMessage, OutboxQueue}; + + use super::Transaction; + + fn make_withdrawal(account: &PublicKeyHash) -> OutboxMessage { + let creator = + Contract::from_b58check("KT1NgXQ6Mwu3XKFDcKdYFS6dkkY3iNKdBKEc").unwrap(); + let parameters = MichelsonPair( + MichelsonContract(Contract::try_from(account.to_base58()).unwrap()), + FA2_1Ticket::new( + creator.clone(), + MichelsonPair(MichelsonNat::from(0), MichelsonOption(None)), + 10, + ) + .unwrap(), + ); + let outbox_tx = OutboxMessageTransaction { + parameters, + destination: creator, + entrypoint: Entrypoint::try_from("burn".to_string()).unwrap(), + }; + OutboxMessage::Withdrawal(outbox_tx) + } + + #[test] + fn push_outbox_message_succeeds_until_outbox_queue_is_full() { + let outbox_queue = OutboxQueue::new(120); + let mut tx = Transaction { + outbox_queue, + ..Transaction::default() + }; + + for i in 0..120 { + if i % 10 == 0 { + tx.begin(); + } + let acc = PublicKeyHash::digest(format!("account{}", i).as_bytes()).unwrap(); + let message = make_withdrawal(&acc); + tx.push_outbox_message(message).unwrap(); + } + + assert_eq!(120, tx.outbox_queue.queue_len()); + + let error = tx + .push_outbox_message(make_withdrawal( + &PublicKeyHash::digest(format!("failure account").as_bytes()).unwrap(), + )) + .expect_err("Outbox should be full"); + + assert!(matches!( + error, + crate::error::Error::OutboxError { + source: crate::kv::outbox::OutboxError::OutboxFull + } + )); + } + + #[test] + fn non_final_commit_appends_outbox_messages_to_previous_snapshot() { + let mut host = MockHost::default(); + let outbox_queue = OutboxQueue::new(120); + let mut tx = Transaction { + outbox_queue, + ..Transaction::default() + }; + + for i in 0..120 { + if i % 60 == 0 { + tx.begin(); + } + let acc = PublicKeyHash::digest(format!("account{}", i).as_bytes()).unwrap(); + let message = make_withdrawal(&acc); + tx.push_outbox_message(message).unwrap(); + } + + tx.commit(&mut host).unwrap(); + + assert_eq!(120, tx.outbox_queue.queue_len()); + + let level = host.run_level(|_| {}); + let outbox = host.outbox_at(level); + + assert_eq!(0, outbox.len()); + } + + #[test] + fn final_commit_flush_outbox_messages_in_enqueue_order() { + let mut host = MockHost::default(); + let outbox_queue = OutboxQueue::new(120); + let mut tx = Transaction { + outbox_queue, + ..Transaction::default() + }; + + for i in 0..120 { + if i % 60 == 0 { + tx.begin(); + } + let acc = PublicKeyHash::digest(format!("account{}", i).as_bytes()).unwrap(); + let message = make_withdrawal(&acc); + tx.push_outbox_message(message).unwrap(); + } + + tx.commit(&mut host).unwrap(); + tx.commit(&mut host).unwrap(); + + assert_eq!(20, tx.outbox_queue.queue_len()); + + let level = host.run_level(|_| {}); + let outbox = host.outbox_at(level); + + assert_eq!(100, outbox.len()); + + for i in 0..100 { + let (_, message) = + OutboxMessageFull::::nom_read(outbox[i].as_slice()) + .unwrap(); + + assert_eq!( + message, + make_withdrawal( + &PublicKeyHash::digest(format!("account{}", i).as_bytes()).unwrap() + ) + .into() + ); + } + + tx.begin(); + tx.commit(&mut host).unwrap(); + + let level = host.run_level(|_| {}); + let outbox = host.outbox_at(level); + + assert_eq!(20, outbox.len()); + + for i in 0..20 { + let (_, message) = + OutboxMessageFull::::nom_read(outbox[i].as_slice()) + .unwrap(); + + assert_eq!( + message, + make_withdrawal( + &PublicKeyHash::digest(format!("account{}", 100 + i).as_bytes()) + .unwrap() + ) + .into() + ); + } + } +}