Skip to main content

thruput_testkit/
contract.rs

1//! Contract method helpers.
2//!
3//! [`entrypoint_id`] computes a method selector from an interface identity.
4//! [`Contract`] binds an address plus that identity and offers typed
5//! `call`/`view`, encoding arguments and decoding returns with `xdrs` so tests
6//! name methods and pass Rust values instead of hand-building calldata.
7//! [`ContractCall`] is a prepared, typed call: the building block for typed
8//! clients whose methods mirror a contract interface.
9//!
10//! [`Caller`] implements [`RemoteInvoker`], so an interface's generated
11//! `<Iface>Remote` proxy (from `#[contract]` with `thruput-sdk/remote`) can
12//! serve as that typed client. Its methods declared `pure` or `readOnly` return
13//! a [`ContractView`]; all others, including those that declare no effect,
14//! return a [`ContractCall`].
15//!
16//! Awaiting either gives the decoded return. A [`ContractCall`] can instead be
17//! awaited with its receipt, submitted without waiting, or simulated. With the
18//! `Counter` interface from the [crate docs](crate#typed-calls), plus a
19//! `peek` method that declares no effect:
20//!
21//! ```rust,no_run
22//! # #![allow(unexpected_cfgs)]
23//! # mod counter {
24//! #     use thruput_sdk::contract;
25//! #     #[contract(version = "0.1.0")]
26//! #     pub trait Counter {
27//! #         fn increment(&mut self, by: u64) -> u64;
28//! #         #[contract(effect = "readOnly")]
29//! #         fn get(&self) -> u64;
30//! #         fn peek(&self) -> u64;
31//! #     }
32//! # }
33//! # use counter::CounterRemote;
34//! use thruput_testkit::{AgentContext, Caller, Result, TestkitError};
35//!
36//! async fn exercise(ctx: &AgentContext, counter: &CounterRemote<Caller>) -> Result<()> {
37//!     // Submit, require success, and decode the return.
38//!     let count = counter.increment(1).await?;
39//!
40//!     // The same, keeping the receipt for its events or gas.
41//!     let (next, receipt) = counter.increment(1).with_receipt().await?;
42//!     assert_eq!(next, count + 1);
43//!     println!("{} events, {} gas", receipt.events.len(), receipt.gas_used);
44//!
45//!     // Only the receipt, ignoring the return value.
46//!     let receipt = counter.increment(1).receipt().await?;
47//!     println!("{} gas", receipt.gas_used);
48//!
49//!     // Submit now and await later: several calls can land in one block,
50//!     // which a manual block driver produces on request.
51//!     let first = counter.increment(1).submit().await?;
52//!     let second = counter.increment(1).submit().await?;
53//!     first.await_pending().await?;
54//!     second.await_pending().await?;
55//!     ctx.network().produce_block().await?;
56//!     first.await_receipt().await?;
57//!     second.await_receipt().await?;
58//!
59//!     // A call that reverts is an error, which a test can expect.
60//!     match counter.increment(u64::MAX).await {
61//!         Err(TestkitError::Reverted { .. }) => {}
62//!         other => panic!("an overflowing increment should revert, got {other:?}"),
63//!     }
64//!
65//!     // A method that declares no effect returns a `ContractCall`, even when
66//!     // it only reads: `simulate` it, since awaiting it would submit.
67//!     assert_eq!(counter.peek().simulate().await?, counter.get().await?);
68//!     Ok(())
69//! }
70//! # fn main() {}
71//! ```
72
73pub use thruput_xdr::contract::ContractEntrypointID;
74
75use std::fmt;
76use std::future::{Future, IntoFuture};
77use std::marker::PhantomData;
78use std::pin::Pin;
79
80use thruput_contract_util::remote::{RemoteInvoker, RemoteMethod};
81use thruput_contract_util::semver::try_major_version_from_string;
82use thruput_json_rpc_types::responses::InvocationOutput;
83use thruput_json_rpc_types::responses::{ReceiptExecution, TransactionReceipt};
84use thruput_json_rpc_types::transaction::TxHash;
85use thruput_signing::default_wallet::decode_default_wallet_call_return;
86use thruput_xdr::types::ContractAddress;
87
88use xdrs::{ReadXDR, WriteXDR};
89
90use crate::caller::Caller;
91use crate::error::{Result, TestkitError};
92use crate::network::SubmittedTransaction;
93
94/// A typed contract call submitted without waiting for block production.
95#[derive(Debug, Clone)]
96#[must_use = "submitted contract calls should be awaited or intentionally retained"]
97pub struct SubmittedContractCall {
98    transaction: SubmittedTransaction,
99    method: String,
100}
101
102impl SubmittedContractCall {
103    /// Canonical transaction hash returned by submission.
104    pub fn tx_hash(&self) -> TxHash {
105        self.transaction.tx_hash()
106    }
107
108    /// Wait until the scheduler reports this call as pending.
109    pub async fn await_pending(&self) -> Result<()> {
110        self.transaction.await_pending().await
111    }
112
113    /// Wait for the receipt and require successful execution.
114    pub async fn await_receipt(&self) -> Result<TransactionReceipt> {
115        let receipt = self.transaction.await_receipt().await?;
116        match receipt.execution {
117            ReceiptExecution::Return { .. } => Ok(receipt),
118            ref status => Err(TestkitError::Reverted {
119                method: self.method.clone(),
120                status: format!("{status:?}"),
121            }),
122        }
123    }
124}
125
126/// A typed state-changing contract call, prepared but not yet submitted.
127///
128/// Built by [`Contract::prepare_call`], and the natural return type of a typed
129/// client's call methods. Awaiting it submits the call, requires successful
130/// execution, and decodes the return as `R`. To see the receipt as well, use
131/// [`with_receipt`](Self::with_receipt); to submit without waiting, use
132/// [`submit`](Self::submit).
133#[must_use = "a contract call does nothing until it is awaited or submitted"]
134pub struct ContractCall<R> {
135    caller: Caller,
136    target: ContractAddress,
137    method: String,
138    prepared: Result<(ContractEntrypointID, Vec<u8>)>,
139    returns: PhantomData<fn() -> R>,
140}
141
142impl<R> fmt::Debug for ContractCall<R> {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.debug_struct("ContractCall")
145            .field("caller", &self.caller.address())
146            .field("target", &self.target)
147            .field("method", &self.method)
148            .finish_non_exhaustive()
149    }
150}
151
152impl<R> ContractCall<R> {
153    /// Submit the call without waiting for its receipt.
154    pub async fn submit(self) -> Result<SubmittedContractCall> {
155        let (selector, calldata) = self.prepared?;
156        let transaction = self
157            .caller
158            .submit_call(self.target, selector, &calldata)
159            .await?;
160        Ok(SubmittedContractCall {
161            transaction,
162            method: self.method,
163        })
164    }
165
166    /// Submit the call and await its receipt, requiring successful execution
167    /// but ignoring the return value.
168    pub async fn receipt(self) -> Result<TransactionReceipt> {
169        self.submit().await?.await_receipt().await
170    }
171}
172
173impl<R> ContractCall<R>
174where
175    R: for<'r> ReadXDR<'r>,
176{
177    /// Submit the call, require successful execution, and return the decoded
178    /// return value together with the receipt (for its events or gas).
179    pub async fn with_receipt(self) -> Result<(R, TransactionReceipt)> {
180        let receipt = self.receipt().await?;
181        let returned = decode_call_return(&receipt)?;
182        Ok((returned, receipt))
183    }
184
185    /// Simulate the call instead of submitting it, with the caller as
186    /// `msg.sender`, and decode its return. For reading through a method
187    /// whose interface does not declare it read-only; any state changes are
188    /// discarded.
189    pub async fn simulate(self) -> Result<R> {
190        let (selector, calldata) = self.prepared?;
191        let sender = self.caller.address();
192        simulate_return(&self.caller, self.target, selector, &calldata, Some(sender)).await
193    }
194}
195
196/// A typed read-only contract invocation, prepared but not yet simulated.
197///
198/// Returned by a generated `<Iface>Remote` proxy for methods declared `pure`
199/// or `readOnly`. Awaiting it simulates the method with the caller as
200/// `msg.sender` and decodes the return as `R`. Nothing is submitted.
201#[must_use = "a contract view does nothing until it is awaited"]
202pub struct ContractView<R> {
203    caller: Caller,
204    target: ContractAddress,
205    method: String,
206    prepared: Result<(ContractEntrypointID, Vec<u8>)>,
207    returns: PhantomData<fn() -> R>,
208}
209
210impl<R> fmt::Debug for ContractView<R> {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        f.debug_struct("ContractView")
213            .field("caller", &self.caller.address())
214            .field("target", &self.target)
215            .field("method", &self.method)
216            .finish_non_exhaustive()
217    }
218}
219
220impl<R> IntoFuture for ContractView<R>
221where
222    R: for<'r> ReadXDR<'r> + Send + 'static,
223{
224    type Output = Result<R>;
225    type IntoFuture = Pin<Box<dyn Future<Output = Result<R>> + Send>>;
226
227    fn into_future(self) -> Self::IntoFuture {
228        Box::pin(async move {
229            let (selector, calldata) = self.prepared?;
230            let sender = self.caller.address();
231            simulate_return(&self.caller, self.target, selector, &calldata, Some(sender)).await
232        })
233    }
234}
235
236/// Encode `args` now, so the prepared invocation borrows nothing.
237fn encode_for<A>(method: &RemoteMethod, args: &A) -> Result<(ContractEntrypointID, Vec<u8>)>
238where
239    A: WriteXDR + ?Sized,
240{
241    let calldata = xdrs::cxs::encode(args).map_err(TestkitError::encode)?;
242    Ok((method.entrypoint, calldata))
243}
244
245impl RemoteInvoker for Caller {
246    type Call<R> = ContractCall<R>;
247    type View<R> = ContractView<R>;
248
249    fn call<R, A>(
250        &self,
251        target: &ContractAddress,
252        method: RemoteMethod,
253        args: &A,
254    ) -> ContractCall<R>
255    where
256        R: xdrs::ReadXDROwned,
257        A: WriteXDR + ?Sized,
258    {
259        ContractCall {
260            caller: self.clone(),
261            target: *target,
262            method: method.name.to_owned(),
263            prepared: encode_for(&method, args),
264            returns: PhantomData,
265        }
266    }
267
268    fn view<R, A>(
269        &self,
270        target: &ContractAddress,
271        method: RemoteMethod,
272        args: &A,
273    ) -> ContractView<R>
274    where
275        R: xdrs::ReadXDROwned,
276        A: WriteXDR + ?Sized,
277    {
278        ContractView {
279            caller: self.clone(),
280            target: *target,
281            method: method.name.to_owned(),
282            prepared: encode_for(&method, args),
283            returns: PhantomData,
284        }
285    }
286}
287
288impl<R> IntoFuture for ContractCall<R>
289where
290    R: for<'r> ReadXDR<'r> + Send + 'static,
291{
292    type Output = Result<R>;
293    type IntoFuture = Pin<Box<dyn Future<Output = Result<R>> + Send>>;
294
295    fn into_future(self) -> Self::IntoFuture {
296        Box::pin(async move { Ok(self.with_receipt().await?.0) })
297    }
298}
299
300/// Compute the entrypoint selector for a contract method.
301///
302/// It is derived from the contract's interface `name`, `version`, and `method`
303/// name exactly as the `thruput` CLI derives selectors, so tests can name
304/// methods instead of pasting raw hex (which drifts whenever a fixture is
305/// rebuilt).
306///
307/// ```
308/// let bump = thruput_testkit::entrypoint_id("CallerCounter", "0.1.0", "bump");
309/// ```
310#[must_use]
311pub fn entrypoint_id(interface: &str, version: &str, method: &str) -> ContractEntrypointID {
312    thruput_client::codec::method_entrypoint_id(interface, version, method)
313}
314
315/// Fallible form of [`entrypoint_id`] for interface identities from untrusted input.
316pub fn try_entrypoint_id(
317    interface: &str,
318    version: &str,
319    method: &str,
320) -> Result<ContractEntrypointID> {
321    if try_major_version_from_string(version).is_none() {
322        return Err(TestkitError::InvalidContractVersion {
323            version: version.to_owned(),
324        });
325    }
326    Ok(entrypoint_id(interface, version, method))
327}
328
329/// A deployed contract instance bound to its interface identity.
330///
331/// `call` and `view` encode the argument value (typically a tuple of the
332/// method's arguments) with the contract (CXS) codec and decode the return the
333/// same way, so a test works in Rust values rather than raw calldata. The
334/// argument and return types must implement `xdrs` `WriteXDR` / `ReadXDR` (the
335/// method's own argument/return types from a contract's interface crate do).
336///
337/// Each method takes anything that can reach a [`Caller`]: an agent's `&ctx`,
338/// or a `Caller` cloned out of it with
339/// [`AgentContext::caller`](crate::AgentContext::caller).
340///
341/// Prefer an interface's generated `<Iface>Remote` proxy (see the
342/// [module docs](self)), which checks method names and argument types at
343/// compile time. A `Contract` suits a contract with no interface crate to
344/// generate one from:
345///
346/// ```rust,no_run
347/// use thruput_testkit::xdr::types::ContractAddress;
348/// use thruput_testkit::{AgentContext, Contract, Result};
349///
350/// async fn bump(ctx: &AgentContext, address: ContractAddress) -> Result<u64> {
351///     let counter = Contract::new(address, "Counter", "0.1.0");
352///     // One argument is passed as itself; several, as a tuple.
353///     counter.call(ctx, "increment", &1u64).await?;
354///     let count: u64 = counter.call_returning(ctx, "increment", &1u64).await?;
355///     let read: u64 = counter.view(ctx, "get", &(), Some(ctx.address())).await?;
356///     assert_eq!(read, count);
357///     Ok(count)
358/// }
359/// ```
360#[derive(Debug, Clone)]
361pub struct Contract {
362    address: ContractAddress,
363    interface: String,
364    version: String,
365}
366
367impl Contract {
368    /// Bind a handle to a deployed `address` and its interface identity.
369    pub fn new(
370        address: ContractAddress,
371        interface: impl Into<String>,
372        version: impl Into<String>,
373    ) -> Self {
374        Self {
375            address,
376            interface: interface.into(),
377            version: version.into(),
378        }
379    }
380
381    /// The deployed contract address.
382    #[must_use]
383    pub fn address(&self) -> ContractAddress {
384        self.address
385    }
386
387    fn selector(&self, method: &str) -> Result<ContractEntrypointID> {
388        try_entrypoint_id(&self.interface, &self.version, method)
389    }
390
391    /// Prepare a state-changing call to `method` from `ctx`'s account, whose
392    /// return decodes as `R`. Nothing is submitted until the [`ContractCall`]
393    /// is awaited or submitted.
394    ///
395    /// The arguments are encoded now, so the call borrows nothing: it can be
396    /// held, moved into another task, or returned from a typed client method.
397    pub fn prepare_call<R, A>(
398        &self,
399        ctx: &(impl AsRef<Caller> + ?Sized),
400        method: &str,
401        args: &A,
402    ) -> ContractCall<R>
403    where
404        A: WriteXDR + ?Sized,
405    {
406        let prepared = self.selector(method).and_then(|selector| {
407            let calldata = xdrs::cxs::encode(args).map_err(TestkitError::encode)?;
408            Ok((selector, calldata))
409        });
410        ContractCall {
411            caller: ctx.as_ref().clone(),
412            target: self.address,
413            method: method.to_owned(),
414            prepared,
415            returns: PhantomData,
416        }
417    }
418
419    /// Submit a state-changing call to `method` through the agent's wallet,
420    /// awaiting the receipt and requiring successful execution.
421    ///
422    /// Returns [`TestkitError::Reverted`] if the transaction executed but its
423    /// status was not `Ok`, so callers can `?` a call they expect to succeed
424    /// instead of asserting on the receipt. Add call-site context with
425    /// `map_err` where the method name alone does not identify the call.
426    pub async fn call<A>(
427        &self,
428        ctx: &(impl AsRef<Caller> + ?Sized),
429        method: &str,
430        args: &A,
431    ) -> Result<TransactionReceipt>
432    where
433        A: WriteXDR + ?Sized,
434    {
435        self.prepare_call::<(), _>(ctx, method, args)
436            .receipt()
437            .await
438    }
439
440    /// Submit a state-changing call without waiting for its receipt.
441    pub async fn submit_call<A>(
442        &self,
443        ctx: &(impl AsRef<Caller> + ?Sized),
444        method: &str,
445        args: &A,
446    ) -> Result<SubmittedContractCall>
447    where
448        A: WriteXDR + ?Sized,
449    {
450        self.prepare_call::<(), _>(ctx, method, args).submit().await
451    }
452
453    /// Call `method`, requiring success, and decode the return value to `R`.
454    pub async fn call_returning<A, R>(
455        &self,
456        ctx: &(impl AsRef<Caller> + ?Sized),
457        method: &str,
458        args: &A,
459    ) -> Result<R>
460    where
461        A: WriteXDR + ?Sized,
462        R: for<'r> ReadXDR<'r>,
463    {
464        let (returned, _) = self
465            .prepare_call::<R, _>(ctx, method, args)
466            .with_receipt()
467            .await?;
468        Ok(returned)
469    }
470
471    /// Simulate a read-only `view` of `method`, decoding the return to `R`.
472    ///
473    /// `caller` sets the simulated `msg.sender`; pass an agent's own address for
474    /// state keyed by the caller.
475    pub async fn view<A, R>(
476        &self,
477        ctx: &(impl AsRef<Caller> + ?Sized),
478        method: &str,
479        args: &A,
480        caller: Option<ContractAddress>,
481    ) -> Result<R>
482    where
483        A: WriteXDR + ?Sized,
484        R: for<'r> ReadXDR<'r>,
485    {
486        let calldata = xdrs::cxs::encode(args).map_err(TestkitError::encode)?;
487        simulate_return(
488            ctx.as_ref(),
489            self.address,
490            self.selector(method)?,
491            &calldata,
492            caller,
493        )
494        .await
495    }
496}
497
498/// Simulate `selector` on `target` and decode its return. A simulation
499/// invokes the target directly, so the return is not wallet-wrapped.
500async fn simulate_return<R>(
501    caller: &Caller,
502    target: ContractAddress,
503    selector: ContractEntrypointID,
504    calldata: &[u8],
505    sender: Option<ContractAddress>,
506) -> Result<R>
507where
508    R: for<'r> ReadXDR<'r>,
509{
510    let response = caller
511        .network()
512        .view_call(target, selector, calldata, sender)
513        .await?;
514    match response.output {
515        InvocationOutput::Return { return_data } => decode_return(&return_data.0),
516        other => Err(TestkitError::NoReturn {
517            kind: "view",
518            detail: format!("{other:?}"),
519        }),
520    }
521}
522
523/// Decode a successful call's return from its receipt. The account's wallet
524/// wraps the method's return, one buffer per call it made; absent return data
525/// is rejected rather than read as an empty return.
526fn decode_call_return<R>(receipt: &TransactionReceipt) -> Result<R>
527where
528    R: for<'r> ReadXDR<'r>,
529{
530    let ReceiptExecution::Return { return_data } = &receipt.execution else {
531        return Err(TestkitError::NoReturn {
532            kind: "call",
533            detail: format!("{:?}", receipt.execution),
534        });
535    };
536    let data = return_data.as_ref();
537    let bytes = decode_default_wallet_call_return(data).map_err(TestkitError::decode)?;
538    decode_return(&bytes)
539}
540
541fn decode_return<R>(bytes: &[u8]) -> Result<R>
542where
543    R: for<'r> ReadXDR<'r>,
544{
545    xdrs::cxs::decode::<R>(bytes).map_err(TestkitError::decode)
546}
547
548#[cfg(test)]
549mod tests {
550    use thruput_xdr::types::ContractAddress;
551
552    use crate::account::Account;
553    use crate::caller::Caller;
554    use crate::error::TestkitError;
555    use crate::manifest::derive_native_accounts;
556    use crate::network::Network;
557    use crate::nonce::NonceNamespace;
558
559    use thruput_json_rpc_types::BlockHeight;
560    use thruput_json_rpc_types::responses::{ReceiptExecution, TransactionReceipt};
561    use thruput_json_rpc_types::transaction::TxHash;
562    use thruput_json_rpc_types::{base58::Base58, hex::HexBytes};
563    use thruput_xdr::types::SHA256Hash;
564
565    use super::{
566        Contract, ContractEntrypointID, decode_call_return, decode_return, entrypoint_id,
567        try_entrypoint_id,
568    };
569
570    fn receipt(return_data: Option<Vec<u8>>) -> TransactionReceipt {
571        TransactionReceipt {
572            transaction_hash: TxHash(Base58(SHA256Hash::from([1; 32]))),
573            block_height: BlockHeight::from(1),
574            execution: ReceiptExecution::Return {
575                return_data: HexBytes(return_data.unwrap_or_default()),
576            },
577            events: Vec::new(),
578            gas_used: 1,
579        }
580    }
581
582    #[test]
583    fn call_return_requires_exactly_one_wallet_result() {
584        let wrap = |results: Vec<Vec<u8>>| Some(xdrs::cxs::encode(&results).unwrap());
585
586        // One empty child result is a valid `()` return.
587        decode_call_return::<()>(&receipt(wrap(vec![Vec::new()]))).unwrap();
588        let value = xdrs::cxs::encode(&7u64).unwrap();
589        assert_eq!(
590            decode_call_return::<u64>(&receipt(wrap(vec![value.clone()]))).unwrap(),
591            7
592        );
593
594        // Missing data, or other than one child result, is not.
595        for return_data in [None, wrap(Vec::new()), wrap(vec![value.clone(), value])] {
596            assert!(matches!(
597                decode_call_return::<()>(&receipt(return_data)),
598                Err(TestkitError::Decode { .. })
599            ));
600        }
601    }
602
603    #[test]
604    fn matches_known_erc20_selector() {
605        // Same known-answer vector the CLI pins for `method_entrypoint_id`.
606        assert_eq!(
607            entrypoint_id("ERC20", "0.1.0", "transfer"),
608            ContractEntrypointID::from([0x63, 0x28, 0x5b, 0x35, 0x23, 0xf9, 0xdc, 0x3f])
609        );
610    }
611
612    #[test]
613    fn invalid_version_returns_an_error_instead_of_panicking() {
614        assert!(matches!(
615            try_entrypoint_id("ERC20", "latest", "transfer"),
616            Err(TestkitError::InvalidContractVersion { version }) if version == "latest"
617        ));
618    }
619
620    #[test]
621    fn cxs_round_trip_matches_helper_bounds() {
622        // Confirms the encode/decode plumbing and trait bounds the `Contract`
623        // helper relies on (a tuple of method arguments).
624        let encoded = xdrs::cxs::encode(&(7u64, 9u32)).expect("encode");
625        let (a, b): (u64, u32) = decode_return(&encoded).expect("decode");
626        assert_eq!((a, b), (7, 9));
627    }
628
629    #[tokio::test]
630    async fn prepared_call_reports_a_bad_identity_when_awaited() {
631        let dev = derive_native_accounts(1, "seed", None).remove(0);
632        let caller = Caller::new(
633            Account::new(dev, NonceNamespace::for_run("seed", 0)),
634            Network::offline(),
635        );
636        let contract = Contract::new(ContractAddress::from([7; 32]), "ERC20", "latest");
637
638        // Preparing never fails; the error surfaces once the call is used.
639        let call = contract.prepare_call::<u64, _>(&caller, "get", &());
640        assert!(matches!(
641            call.await,
642            Err(TestkitError::InvalidContractVersion { version }) if version == "latest"
643        ));
644    }
645}