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_wasm_stdlib::core::types::account_id::AccountID;
8use xrpl_wasm_stdlib::core::types::amount::Amount;
9use xrpl_wasm_stdlib::host::Error;
10use xrpl_wasm_stdlib::host::error_codes::BUFFER_TOO_SMALL;
11use xrpl_wasm_stdlib::sfield;
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    update_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_update_data_returns(mut self, result: Result<(), Error>) -> Self {
50        self.update_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_get_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                        let (bytes, len) = amount.to_stamount_bytes();
98                        return write_bytes(&bytes[..len], out_buff_ptr, out_buff_len);
99                    }
100                    out_buff_len as i32
101                });
102        }
103
104        if let Some(status) = self.update_data_status {
105            mock.expect_update_data().returning(
106                move |_data_ptr, data_len| {
107                    if status == 0 { data_len as i32 } else { status }
108                },
109            );
110        }
111    }
112}
113
114/// Writes `bytes` into the raw output buffer, mirroring how the real host functions report
115/// back the number of bytes written (or `BUFFER_TOO_SMALL` if the caller's buffer is too small).
116fn write_bytes(bytes: &[u8], out_buff_ptr: *mut u8, out_buff_len: usize) -> i32 {
117    if out_buff_len < bytes.len() {
118        return BUFFER_TOO_SMALL;
119    }
120    unsafe {
121        std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_buff_ptr, bytes.len());
122    }
123    bytes.len() as i32
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use xrpl_wasm_stdlib::core::current_tx::get_field;
130
131    fn test_account() -> AccountID {
132        AccountID::from([0xAB; 20])
133    }
134
135    #[test]
136    fn write_bytes_returns_buffer_too_small_when_the_caller_buffer_is_undersized() {
137        let mut undersized = [0u8; 4];
138        let result = write_bytes(&[1, 2, 3, 4, 5], undersized.as_mut_ptr(), undersized.len());
139        assert_eq!(result, BUFFER_TOO_SMALL);
140    }
141
142    #[test]
143    fn with_account_is_readable_back_through_the_real_getter() {
144        let _guard = EscrowScenario::builder()
145            .with_account(test_account())
146            .install();
147
148        let account: AccountID = get_field(sfield::Account).unwrap();
149        assert_eq!(account, test_account());
150    }
151
152    #[test]
153    fn with_amount_is_readable_back_through_the_real_getter() {
154        let configured = Amount::XRP { num_drops: 1_000 };
155        let _guard = EscrowScenario::builder()
156            .with_amount(configured.clone())
157            .install();
158
159        let amount: Amount = get_field(sfield::Amount).unwrap();
160        assert_eq!(amount, configured);
161    }
162
163    #[test]
164    fn unconfigured_fields_fall_back_to_defaults() {
165        let _guard = EscrowScenario::builder()
166            .with_account(test_account())
167            .install();
168
169        // OfferSequence wasn't configured by the scenario; the default fallback (declared
170        // after the scenario's expectation) still handles it instead of panicking.
171        let result: xrpl_wasm_stdlib::host::Result<u32> = get_field(sfield::OfferSequence);
172        assert!(result.is_ok());
173    }
174
175    #[test]
176    fn with_update_data_returns_ok_reports_the_payload_length_through_the_real_host_call() {
177        let _guard = EscrowScenario::builder()
178            .with_update_data_returns(Ok(()))
179            .install();
180
181        let payload = b"payload";
182        let code = unsafe { xrpl_wasm_stdlib::host::update_data(payload.as_ptr(), payload.len()) };
183        assert_eq!(code, payload.len() as i32);
184    }
185
186    #[test]
187    fn with_update_data_returns_err_reports_the_error_code_through_the_real_host_call() {
188        let _guard = EscrowScenario::builder()
189            .with_update_data_returns(Err(Error::InternalError))
190            .install();
191
192        let payload = b"payload";
193        let code = unsafe { xrpl_wasm_stdlib::host::update_data(payload.as_ptr(), payload.len()) };
194        assert_eq!(code, Error::InternalError.code());
195        assert!(code < 0);
196    }
197
198    #[test]
199    fn build_onto_lets_the_caller_override_the_scenario() {
200        let overridden_account = AccountID::from([0u8; 20]);
201        let mut mock = MockHostBindings::new();
202        let expected_code: i32 = i32::from(sfield::Account);
203        mock.expect_get_tx_field()
204            .withf(move |field, _, _| *field == expected_code)
205            .returning(move |_, out_buff_ptr, out_buff_len| {
206                write_bytes(&overridden_account.0, out_buff_ptr, out_buff_len)
207            });
208
209        // The caller's own expectation above was registered first, so it takes precedence
210        // over the scenario's account expectation added by `build_onto`.
211        let mock = EscrowScenario::builder()
212            .with_account(test_account())
213            .build_onto(mock);
214
215        let _guard = setup_mock(mock);
216        let account: AccountID = get_field(sfield::Account).unwrap();
217        assert_eq!(account, overridden_account);
218        assert_ne!(account, test_account());
219    }
220}