Skip to main content

xrpl_stdlib_test_utils/
mock_escrow.rs

1//! Escrow-specific scenario builder on top of [`crate::mock_common`].
2//!
3//! Translates domain facts (account, amount, ...) into `MockHostBindings` expectations, so
4//! tests read in terms of the escrow scenario instead of raw host-function wiring.
5
6use crate::mock_common::{MockGuard, MockHostBindings, apply_default_expectations, setup_mock};
7use xrpl_common_stdlib::host::Error;
8use xrpl_common_stdlib::host::error_codes::BUFFER_TOO_SMALL;
9use xrpl_common_stdlib::sfield;
10use xrpl_common_stdlib::types::account_id::AccountID;
11use xrpl_common_stdlib::types::amount::{AMOUNT_SIZE, Amount};
12
13/// Pre-wires common Smart Escrow test setups onto a [`MockHostBindings`].
14///
15/// ```ignore
16/// let _guard = EscrowScenario::builder()
17///     .with_account(some_account)
18///     .with_amount(Amount::XRP { num_drops: 1000 })
19///     .install();
20/// ```
21pub struct EscrowScenario;
22
23impl EscrowScenario {
24    pub fn builder() -> EscrowScenarioBuilder {
25        EscrowScenarioBuilder::default()
26    }
27}
28
29#[derive(Default)]
30pub struct EscrowScenarioBuilder {
31    account: Option<AccountID>,
32    amount: Option<Amount>,
33    // Stored pre-converted to a host status code (0 == success) rather than `Result<(), Error>`
34    // so the builder doesn't need `Result`/`Error` to be `Copy` to stash it in a field.
35    set_data_status: Option<i32>,
36}
37
38impl EscrowScenarioBuilder {
39    pub fn with_account(mut self, account: AccountID) -> Self {
40        self.account = Some(account);
41        self
42    }
43
44    pub fn with_amount(mut self, amount: Amount) -> Self {
45        self.amount = Some(amount);
46        self
47    }
48
49    pub fn with_set_data_returns(mut self, result: Result<(), Error>) -> Self {
50        self.set_data_status = Some(match result {
51            Ok(()) => 0,
52            Err(error) => error.code(),
53        });
54        self
55    }
56
57    /// Builds a mock with this scenario's expectations, falling back to
58    /// [`apply_default_expectations`] for anything the scenario doesn't configure.
59    pub fn build(self) -> MockHostBindings {
60        let mut mock = MockHostBindings::new();
61        self.apply(&mut mock);
62        apply_default_expectations(&mut mock);
63        mock
64    }
65
66    /// Layers this scenario's expectations onto an existing mock. mockall matches
67    /// expectations in the order they were registered, so anything already set on `mock`
68    /// takes precedence over what the scenario adds here.
69    pub fn build_onto(self, mut mock: MockHostBindings) -> MockHostBindings {
70        self.apply(&mut mock);
71        mock
72    }
73
74    /// Builds the scenario and installs it as the thread-local mock. The returned guard
75    /// clears the mock on drop.
76    pub fn install(self) -> MockGuard {
77        setup_mock(self.build())
78    }
79
80    fn apply(&self, mock: &mut MockHostBindings) {
81        if self.account.is_some() || self.amount.is_some() {
82            let account = self.account;
83            let amount = self.amount.clone();
84            let account_code = i32::from(sfield::Account);
85            let amount_code = i32::from(sfield::Amount);
86
87            mock.expect_tx_field()
88                .returning(move |field, out_buff_ptr, out_buff_len| {
89                    if field == account_code
90                        && let Some(account) = account
91                    {
92                        return write_bytes(&account.0, out_buff_ptr, out_buff_len);
93                    }
94                    if field == amount_code
95                        && let Some(amount) = &amount
96                    {
97                        // `to_stamount_bytes` always reports 48 (the full trace buffer). A real
98                        // host instead returns only the bytes it wrote for the amount's variant
99                        // (8 XRP / 33 MPT / 48 IOU), which is what the getter's decoder validates
100                        // against — so model that here rather than claiming a full 48-byte write.
101                        let (bytes, _) = amount.to_stamount_bytes();
102                        let len = match amount {
103                            Amount::XRP { .. } => 8,
104                            Amount::MPT { .. } => 33,
105                            Amount::IOU { .. } => AMOUNT_SIZE,
106                        };
107                        return write_bytes(&bytes[..len], out_buff_ptr, out_buff_len);
108                    }
109                    out_buff_len as i32
110                });
111        }
112
113        if let Some(status) = self.set_data_status {
114            mock.expect_set_data().returning(
115                move |_data_ptr, data_len| {
116                    if status == 0 { data_len as i32 } else { status }
117                },
118            );
119        }
120    }
121}
122
123/// Writes `bytes` into the raw output buffer, mirroring how the real host functions report
124/// back the number of bytes written (or `BUFFER_TOO_SMALL` if the caller's buffer is too small).
125fn write_bytes(bytes: &[u8], out_buff_ptr: *mut u8, out_buff_len: usize) -> i32 {
126    if out_buff_len < bytes.len() {
127        return BUFFER_TOO_SMALL;
128    }
129    unsafe {
130        std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_buff_ptr, bytes.len());
131    }
132    bytes.len() as i32
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use xrpl_common_stdlib::current_tx::get_field;
139
140    fn test_account() -> AccountID {
141        AccountID::from([0xAB; 20])
142    }
143
144    #[test]
145    fn write_bytes_returns_buffer_too_small_when_the_caller_buffer_is_undersized() {
146        let mut undersized = [0u8; 4];
147        let result = write_bytes(&[1, 2, 3, 4, 5], undersized.as_mut_ptr(), undersized.len());
148        assert_eq!(result, BUFFER_TOO_SMALL);
149    }
150
151    #[test]
152    fn with_account_is_readable_back_through_the_real_getter() {
153        let _guard = EscrowScenario::builder()
154            .with_account(test_account())
155            .install();
156
157        let account: AccountID = get_field(sfield::Account).unwrap();
158        assert_eq!(account, test_account());
159    }
160
161    #[test]
162    fn with_amount_is_readable_back_through_the_real_getter() {
163        let configured = Amount::XRP { num_drops: 1_000 };
164        let _guard = EscrowScenario::builder()
165            .with_amount(configured.clone())
166            .install();
167
168        let amount: Amount = get_field(sfield::Amount).unwrap();
169        assert_eq!(amount, configured);
170    }
171
172    #[test]
173    fn unconfigured_fields_fall_back_to_defaults() {
174        let _guard = EscrowScenario::builder()
175            .with_account(test_account())
176            .install();
177
178        // OfferSequence wasn't configured by the scenario; the default fallback (declared
179        // after the scenario's expectation) still handles it instead of panicking.
180        let result: xrpl_common_stdlib::host::Result<u32> = get_field(sfield::OfferSequence);
181        assert!(result.is_ok());
182    }
183
184    #[test]
185    fn with_set_data_returns_ok_reports_the_payload_length_through_the_real_host_call() {
186        let _guard = EscrowScenario::builder()
187            .with_set_data_returns(Ok(()))
188            .install();
189
190        let payload = b"payload";
191        let code = unsafe { xrpl_common_stdlib::host::set_data(payload.as_ptr(), payload.len()) };
192        assert_eq!(code, payload.len() as i32);
193    }
194
195    #[test]
196    fn with_set_data_returns_err_reports_the_error_code_through_the_real_host_call() {
197        let _guard = EscrowScenario::builder()
198            .with_set_data_returns(Err(Error::from_code(
199                xrpl_common_stdlib::host::error_codes::SOME_ERROR,
200            )))
201            .install();
202
203        let payload = b"payload";
204        let code = unsafe { xrpl_common_stdlib::host::set_data(payload.as_ptr(), payload.len()) };
205        assert_eq!(
206            code,
207            Error::from_code(xrpl_common_stdlib::host::error_codes::SOME_ERROR).code()
208        );
209        assert!(code < 0);
210    }
211
212    #[test]
213    fn build_onto_lets_the_caller_override_the_scenario() {
214        let overridden_account = AccountID::from([0u8; 20]);
215        let mut mock = MockHostBindings::new();
216        let expected_code: i32 = i32::from(sfield::Account);
217        mock.expect_tx_field()
218            .withf(move |field, _, _| *field == expected_code)
219            .returning(move |_, out_buff_ptr, out_buff_len| {
220                write_bytes(&overridden_account.0, out_buff_ptr, out_buff_len)
221            });
222
223        // The caller's own expectation above was registered first, so it takes precedence
224        // over the scenario's account expectation added by `build_onto`.
225        let mock = EscrowScenario::builder()
226            .with_account(test_account())
227            .build_onto(mock);
228
229        let _guard = setup_mock(mock);
230        let account: AccountID = get_field(sfield::Account).unwrap();
231        assert_eq!(account, overridden_account);
232        assert_ne!(account, test_account());
233    }
234}