Skip to main content

thruput_sdk/mock/
contract_test.rs

1//! Test sugar over the fluent mock-execution API.
2//!
3//! The canonical test pattern is:
4//! ```rust,ignore
5//! let env = TestEnvironment::no_cost();
6//! let result = env.transact(|tx| tx.call(&addr).run(|| { /* contract code */ }));
7//! result.assert_success();
8//! ```
9//! `contract_test!` and `client_test` are thin sugar over that pattern.
10//! `test_rv32` is the harness for running real rv32 artifacts through the
11//! full SignedTransaction path (the only place gas_consumed is observable).
12
13use crate::call::RawCallError;
14use crate::exn::FaultInfo;
15use crate::exn::RawException;
16use crate::types::transaction::InvocationFrameOutput::*;
17use crate::*;
18use native_exec::rv32_stack_usage::Rv32StackUsage;
19use native_exec::transaction_handler::TransactionHandler;
20use std::panic::UnwindSafe;
21use std::vec::Vec;
22use thruput_native_exec as native_exec;
23use thruput_xdr::auth_claim::{AuthClaim, AuthClaim_source_address_claim};
24use thruput_xdr::contract::ContractEntrypointID;
25use thruput_xdr::transaction::{
26    Invocation, InvocationFrameStatusCode, SignedTransaction, Transaction,
27    Uniquifier,
28};
29
30/// Decode a `TransactResult`'s root-frame outcome into the same
31/// `Result<T, RawCallError>` shape that contract clients produce.
32/// Use this when a test asserts on typed exceptions/selectors rather
33/// than raw buffers.
34///
35/// The success path requires a committed transaction (panics on commit
36/// failure); Abort/Fault decode frame-only.
37pub fn into_root_call_result<T>(
38    result: &TransactResult,
39) -> Result<T, RawCallError>
40where
41    T: ReadXDROwned + ReceiveAuthHandles,
42{
43    match result.frame().output() {
44        Return { return_buffer } => {
45            // A successful frame whose commit failed must not decode to Ok —
46            // mirror the legacy wrappers, which panicked on commit failure.
47            match result.exec_result() {
48                &ExecResult::ExecResultOK => {}
49                r => panic!("Transaction failed: {:?}", r),
50            }
51            // No returned auth handles: the root frame has no caller to hand
52            // them back to.
53            crate::call::contract_call_decode::<T>(
54                return_buffer.as_slice(),
55                [],
56            )
57            .map_err(crate::call::widen_uninhabited_exn)
58        }
59        // Kept raw, not typed-decoded: callers assert on the selector and
60        // payload of whatever came back.
61        Abort { abort_buffer } => {
62            match RawException::from_abort_buf(abort_buffer.as_slice()) {
63                Ok(exn) => Err(RawCallError::Exception(exn)),
64                Err(e) => Err(RawCallError::UnparseableAbort(
65                    FaultString::from_display(e),
66                )),
67            }
68        }
69        // Whatever the harness recorded for this frame's own panic. There
70        // is no record when the frame faulted without an ordinary panic to
71        // catch — a syscall that runs out of gas unwinds an `InvokeError`
72        // instead, which carries no message — or when the fault came from a
73        // called contract (see `TransactResult::fault_panic`). Both read
74        // back as `"<fault>"`.
75        Fault => Err(RawCallError::Fault(
76            result
77                .fault_panic()
78                .map(FaultInfo::from)
79                .unwrap_or_default(),
80        )),
81        other => {
82            panic!("unsupported InvocationFrameOutput variant: {:?}", other)
83        }
84    }
85}
86
87/// Run some code in the test environment, as a single auto-committed
88/// transaction at a deterministic per-test address.
89///
90#[macro_export]
91macro_rules! contract_test {
92    ($name:ident $test_body:tt) => {
93        #[test]
94        fn $name() {
95            let env = $crate::TestEnvironment::no_cost();
96            env.transact(|tx| {
97                let addr = <$crate::ContractAddress as $crate::Named>::named(
98                    stringify!($name),
99                );
100                tx.call(&addr).run(|| $test_body)
101            })
102            .assert_success();
103        }
104    };
105}
106
107pub trait TestRegisterFn: Fn(&TestEnvironmentHandle) -> MockContract {}
108
109impl<T> TestRegisterFn for T where T: Fn(&TestEnvironmentHandle) -> MockContract
110{}
111
112pub fn client_test<R, F, C>(register: R, run_test: F)
113where
114    R: TestRegisterFn,
115    F: FnOnce(&C) + UnwindSafe,
116    C: crate::call::ContractClient,
117{
118    static CLIENT_TEST_NONCE: SHA256Hash =
119        SHA256Hash::new(hash_to_array!("CLIENT_TEST_NONCE"));
120
121    let env = TestEnvironment::no_cost();
122    let test_contract = register(&env);
123    let contract_hash = *test_contract.as_hash();
124
125    env.transact(|tx| {
126        let caller = <ContractAddress as Named>::named("client_test");
127        tx.call(&caller).run(move || {
128            // Deploy the contract under test and hand the caller a client.
129            let addr = deploy_contract(&CLIENT_TEST_NONCE, &contract_hash);
130            let client = C::new(&addr);
131            run_test(&client);
132        })
133    })
134    .assert_success();
135}
136
137mod contract_test_guest {
138    use crate::SHA256Hash;
139    use alloc::boxed::Box;
140    use core::error::Error;
141
142    pub trait ContractTest {
143        fn run(
144            &self,
145            contract_hash: &SHA256Hash,
146        ) -> Result<(), Box<dyn Error>>;
147    }
148}
149
150pub use contract_test_guest::*;
151
152#[derive(Copy, Clone, Debug)]
153pub struct Stats {
154    pub gas_consumed: u64,
155    /// The stack record of the most recent rv32 call the test body made;
156    /// `None` if it made none. See [`Rv32StackUsage`].
157    pub rv32_stack_usage: Option<Rv32StackUsage>,
158}
159
160/// Build a one-shot TransactionHandler that runs `test` as a mock contract
161/// method through the full SignedTransaction path. Internal harness for
162/// `test_rv32` — everything else uses `env.transact`.
163fn guest_test_handler<F>(
164    env: &TestEnvironmentHandle,
165    test: F,
166) -> TransactionHandler
167where
168    F: Fn() + UnwindSafe + Clone + Send + Sync + 'static,
169{
170    let mut test_contract = env.add_contract();
171    let entrypoint = ContractEntrypointID::from([0; 8]);
172    test_contract.register_method(entrypoint, test);
173    let _ = env.setup_create_contract(&test_contract.as_contract());
174
175    let invoke_addr: ContractAddress = [0xFF; 32].into();
176
177    env.setup_create_deployment(&invoke_addr, &test_contract.as_contract());
178
179    let default_auth_claim: AuthClaim = AuthClaim::kSourceAddressAuthData {
180        source_address_claim: AuthClaim_source_address_claim {
181            allowed_keys: 0,
182            required_weight: 0,
183        },
184    };
185
186    let stx = SignedTransaction {
187        signatures: Vec::new().try_into().unwrap(),
188        tx: Transaction {
189            uniquifier: Uniquifier {
190                block_expiry_height: 0.into(),
191                nonce: 0,
192            },
193            auth_claim: default_auth_claim,
194            invocation: Invocation {
195                invoked_address: invoke_addr,
196                entrypoint,
197                gas_limit: u64::MAX,
198                calldata: [].try_into().unwrap(),
199            },
200            contracts_to_deploy: Vec::new().try_into().unwrap(),
201        },
202        pubkey_claim_key: None,
203    };
204
205    TransactionHandler::new(env.clone(), &stx)
206}
207
208pub fn test_rv32<P, T>(path: P, test: T) -> Stats
209where
210    P: AsRef<std::path::Path>,
211    T: ContractTest + Clone + 'static + core::panic::UnwindSafe + Send + Sync,
212{
213    let contract = load_rv32_contract(path);
214
215    // Set up the contract
216    let env = TestEnvironment::new(None);
217    TestEnvironment::enable_syscall_logging();
218    let contract_hash = env.setup_create_contract(&contract);
219
220    let mut handler = guest_test_handler(&env, move || -> () {
221        test.run(&contract_hash)
222            .unwrap_or_else(|e| panic!("run method failed: {}", e));
223    });
224
225    let res = handler.root_invoke();
226
227    if res.output.status_code() != InvocationFrameStatusCode::Return {
228        panic!("Root invocation panicked with code {:?}", res.output);
229    }
230
231    let gas_consumed = res.gas_consumed;
232
233    // std::println!("gas consumed: {}", gas_consumed);
234
235    match handler.try_commit_transaction() {
236        native_exec::TryCommitResult::Ok => {}
237        // Note: these results are also deterministic:
238        //     TxDeterministicError
239        //     TxRootFrameReverted
240        r => panic!("Transaction failed: {:?}", r),
241    }
242
243    Stats {
244        gas_consumed,
245        rv32_stack_usage: res.rv32_stack_usage(),
246    }
247}
248
249pub fn load_rv32_contract<P>(path: P) -> Contract
250where
251    P: AsRef<std::path::Path>,
252{
253    use crate::contract::ContractBlob;
254
255    let raw_rv32 = std::fs::read(path.as_ref()).unwrap_or_else(|e| {
256        panic!(
257            "{}: couldn't load rv32 contract: {}",
258            path.as_ref().to_str().unwrap_or("?"),
259            e
260        )
261    });
262
263    let contract: ContractBlob = ContractBlob {
264        data: BytesM(raw_rv32),
265    };
266
267    Contract::kRv32 {
268        rv32_contract: contract,
269    }
270}
271
272pub fn deploy_address(
273    deployer_address: &ContractAddress,
274    nonce: &SHA256Hash,
275    contract_hash: &SHA256Hash,
276) -> ContractAddress {
277    use sha2::{Digest, Sha256};
278    use thruput_xdr::contract::ContractDeploymentHashPreimage;
279
280    let preimage = ContractDeploymentHashPreimage {
281        deployer_address: *deployer_address,
282        nonce: *nonce,
283        contract_hash: *contract_hash,
284    };
285    let preimage: Vec<u8> = xdrs::txs::encode(&preimage).unwrap();
286
287    let mut hasher = Sha256::new();
288    hasher.update(preimage.as_slice());
289
290    let mut addr = [1u8; 32];
291    hasher.finalize_into((&mut addr).into());
292
293    addr.into()
294}
295
296#[cfg(test)]
297mod tests {
298    use super::guest_test_handler;
299    use super::load_rv32_contract;
300    use crate::TestEnvironment;
301    use std::format;
302    use std::fs;
303    use std::string::String;
304
305    #[test]
306    fn panic_with_live_transaction_handler_survives_drop() {
307        // Parity with native-exec's
308        // `panic_with_open_transaction_is_not_masked_by_drop`, but for the real
309        // `TransactionHandler` (full SignedTransaction) path: a test that
310        // panics while a handler is live (invoked but not yet committed) must
311        // let the original panic propagate, not have it masked by a second
312        // panic from `Drop`'s FFI teardown.
313        //
314        // This drives the clean-abort path — the FFI abort/close succeed, so
315        // the only way `Drop` could double-panic here is if it panicked
316        // unconditionally (as it did before the teardown errors were routed
317        // through `drop_failure`). We can't inject an FFI teardown *failure*
318        // from a unit test, so this guards against reintroducing an
319        // unconditional Drop panic rather than reproducing the conditional
320        // double-failure that the fix ultimately hardens against.
321        let caught =
322            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
323                let env = TestEnvironment::new(None);
324                let mut handler = guest_test_handler(&env, || {});
325                // Transaction invoked but never committed or aborted: Drop must
326                // abort it during unwind.
327                let _ = handler.root_invoke();
328                panic!("original failure message");
329            }));
330
331        let payload = caught.expect_err("the panic must propagate, not abort");
332        let msg = payload
333            .downcast_ref::<&'static str>()
334            .copied()
335            .or_else(|| payload.downcast_ref::<String>().map(String::as_str));
336        assert_eq!(
337            msg,
338            Some("original failure message"),
339            "the original panic message must survive Drop cleanup"
340        );
341    }
342
343    #[test]
344    fn load_rv32_contract_wraps_artifact_bytes() {
345        let bytes = b"fake-rv32-artifact";
346        let path = std::env::temp_dir().join(format!(
347            "thruput-sdk-load-rv32-contract-{}.rv32a",
348            std::process::id()
349        ));
350        fs::write(&path, bytes).unwrap();
351
352        let contract = load_rv32_contract(&path);
353
354        fs::remove_file(&path).unwrap();
355        match contract {
356            thruput_xdr::contract::Contract::kRv32 { rv32_contract } => {
357                assert_eq!(rv32_contract.data.0, bytes);
358            }
359            other => panic!("expected rv32 contract, got {other:?}"),
360        }
361    }
362}