pub use thruput_xdr::contract::ContractEntrypointID;
use std::fmt;
use std::future::{Future, IntoFuture};
use std::marker::PhantomData;
use std::pin::Pin;
use thruput_contract_util::remote::{RemoteInvoker, RemoteMethod};
use thruput_contract_util::semver::try_major_version_from_string;
use thruput_json_rpc_types::responses::InvocationOutput;
use thruput_json_rpc_types::responses::{ReceiptExecution, TransactionReceipt};
use thruput_json_rpc_types::transaction::TxHash;
use thruput_signing::default_wallet::decode_default_wallet_call_return;
use thruput_xdr::types::ContractAddress;
use xdrs::{ReadXDR, WriteXDR};
use crate::caller::Caller;
use crate::error::{Result, TestkitError};
use crate::network::SubmittedTransaction;
#[derive(Debug, Clone)]
#[must_use = "submitted contract calls should be awaited or intentionally retained"]
pub struct SubmittedContractCall {
transaction: SubmittedTransaction,
method: String,
}
impl SubmittedContractCall {
pub fn tx_hash(&self) -> TxHash {
self.transaction.tx_hash()
}
pub async fn await_pending(&self) -> Result<()> {
self.transaction.await_pending().await
}
pub async fn await_receipt(&self) -> Result<TransactionReceipt> {
let receipt = self.transaction.await_receipt().await?;
match receipt.execution {
ReceiptExecution::Return { .. } => Ok(receipt),
ref status => Err(TestkitError::Reverted {
method: self.method.clone(),
status: format!("{status:?}"),
}),
}
}
}
#[must_use = "a contract call does nothing until it is awaited or submitted"]
pub struct ContractCall<R> {
caller: Caller,
target: ContractAddress,
method: String,
prepared: Result<(ContractEntrypointID, Vec<u8>)>,
returns: PhantomData<fn() -> R>,
}
impl<R> fmt::Debug for ContractCall<R> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ContractCall")
.field("caller", &self.caller.address())
.field("target", &self.target)
.field("method", &self.method)
.finish_non_exhaustive()
}
}
impl<R> ContractCall<R> {
pub async fn submit(self) -> Result<SubmittedContractCall> {
let (selector, calldata) = self.prepared?;
let transaction = self
.caller
.submit_call(self.target, selector, &calldata)
.await?;
Ok(SubmittedContractCall {
transaction,
method: self.method,
})
}
pub async fn receipt(self) -> Result<TransactionReceipt> {
self.submit().await?.await_receipt().await
}
}
impl<R> ContractCall<R>
where
R: for<'r> ReadXDR<'r>,
{
pub async fn with_receipt(self) -> Result<(R, TransactionReceipt)> {
let receipt = self.receipt().await?;
let returned = decode_call_return(&receipt)?;
Ok((returned, receipt))
}
pub async fn simulate(self) -> Result<R> {
let (selector, calldata) = self.prepared?;
let sender = self.caller.address();
simulate_return(&self.caller, self.target, selector, &calldata, Some(sender)).await
}
}
#[must_use = "a contract view does nothing until it is awaited"]
pub struct ContractView<R> {
caller: Caller,
target: ContractAddress,
method: String,
prepared: Result<(ContractEntrypointID, Vec<u8>)>,
returns: PhantomData<fn() -> R>,
}
impl<R> fmt::Debug for ContractView<R> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ContractView")
.field("caller", &self.caller.address())
.field("target", &self.target)
.field("method", &self.method)
.finish_non_exhaustive()
}
}
impl<R> IntoFuture for ContractView<R>
where
R: for<'r> ReadXDR<'r> + Send + 'static,
{
type Output = Result<R>;
type IntoFuture = Pin<Box<dyn Future<Output = Result<R>> + Send>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
let (selector, calldata) = self.prepared?;
let sender = self.caller.address();
simulate_return(&self.caller, self.target, selector, &calldata, Some(sender)).await
})
}
}
fn encode_for<A>(method: &RemoteMethod, args: &A) -> Result<(ContractEntrypointID, Vec<u8>)>
where
A: WriteXDR + ?Sized,
{
let calldata = xdrs::cxs::encode(args).map_err(TestkitError::encode)?;
Ok((method.entrypoint, calldata))
}
impl RemoteInvoker for Caller {
type Call<R> = ContractCall<R>;
type View<R> = ContractView<R>;
fn call<R, A>(
&self,
target: &ContractAddress,
method: RemoteMethod,
args: &A,
) -> ContractCall<R>
where
R: xdrs::ReadXDROwned,
A: WriteXDR + ?Sized,
{
ContractCall {
caller: self.clone(),
target: *target,
method: method.name.to_owned(),
prepared: encode_for(&method, args),
returns: PhantomData,
}
}
fn view<R, A>(
&self,
target: &ContractAddress,
method: RemoteMethod,
args: &A,
) -> ContractView<R>
where
R: xdrs::ReadXDROwned,
A: WriteXDR + ?Sized,
{
ContractView {
caller: self.clone(),
target: *target,
method: method.name.to_owned(),
prepared: encode_for(&method, args),
returns: PhantomData,
}
}
}
impl<R> IntoFuture for ContractCall<R>
where
R: for<'r> ReadXDR<'r> + Send + 'static,
{
type Output = Result<R>;
type IntoFuture = Pin<Box<dyn Future<Output = Result<R>> + Send>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move { Ok(self.with_receipt().await?.0) })
}
}
#[must_use]
pub fn entrypoint_id(interface: &str, version: &str, method: &str) -> ContractEntrypointID {
thruput_client::codec::method_entrypoint_id(interface, version, method)
}
pub fn try_entrypoint_id(
interface: &str,
version: &str,
method: &str,
) -> Result<ContractEntrypointID> {
if try_major_version_from_string(version).is_none() {
return Err(TestkitError::InvalidContractVersion {
version: version.to_owned(),
});
}
Ok(entrypoint_id(interface, version, method))
}
#[derive(Debug, Clone)]
pub struct Contract {
address: ContractAddress,
interface: String,
version: String,
}
impl Contract {
pub fn new(
address: ContractAddress,
interface: impl Into<String>,
version: impl Into<String>,
) -> Self {
Self {
address,
interface: interface.into(),
version: version.into(),
}
}
#[must_use]
pub fn address(&self) -> ContractAddress {
self.address
}
fn selector(&self, method: &str) -> Result<ContractEntrypointID> {
try_entrypoint_id(&self.interface, &self.version, method)
}
pub fn prepare_call<R, A>(
&self,
ctx: &(impl AsRef<Caller> + ?Sized),
method: &str,
args: &A,
) -> ContractCall<R>
where
A: WriteXDR + ?Sized,
{
let prepared = self.selector(method).and_then(|selector| {
let calldata = xdrs::cxs::encode(args).map_err(TestkitError::encode)?;
Ok((selector, calldata))
});
ContractCall {
caller: ctx.as_ref().clone(),
target: self.address,
method: method.to_owned(),
prepared,
returns: PhantomData,
}
}
pub async fn call<A>(
&self,
ctx: &(impl AsRef<Caller> + ?Sized),
method: &str,
args: &A,
) -> Result<TransactionReceipt>
where
A: WriteXDR + ?Sized,
{
self.prepare_call::<(), _>(ctx, method, args)
.receipt()
.await
}
pub async fn submit_call<A>(
&self,
ctx: &(impl AsRef<Caller> + ?Sized),
method: &str,
args: &A,
) -> Result<SubmittedContractCall>
where
A: WriteXDR + ?Sized,
{
self.prepare_call::<(), _>(ctx, method, args).submit().await
}
pub async fn call_returning<A, R>(
&self,
ctx: &(impl AsRef<Caller> + ?Sized),
method: &str,
args: &A,
) -> Result<R>
where
A: WriteXDR + ?Sized,
R: for<'r> ReadXDR<'r>,
{
let (returned, _) = self
.prepare_call::<R, _>(ctx, method, args)
.with_receipt()
.await?;
Ok(returned)
}
pub async fn view<A, R>(
&self,
ctx: &(impl AsRef<Caller> + ?Sized),
method: &str,
args: &A,
caller: Option<ContractAddress>,
) -> Result<R>
where
A: WriteXDR + ?Sized,
R: for<'r> ReadXDR<'r>,
{
let calldata = xdrs::cxs::encode(args).map_err(TestkitError::encode)?;
simulate_return(
ctx.as_ref(),
self.address,
self.selector(method)?,
&calldata,
caller,
)
.await
}
}
async fn simulate_return<R>(
caller: &Caller,
target: ContractAddress,
selector: ContractEntrypointID,
calldata: &[u8],
sender: Option<ContractAddress>,
) -> Result<R>
where
R: for<'r> ReadXDR<'r>,
{
let response = caller
.network()
.view_call(target, selector, calldata, sender)
.await?;
match response.output {
InvocationOutput::Return { return_data } => decode_return(&return_data.0),
other => Err(TestkitError::NoReturn {
kind: "view",
detail: format!("{other:?}"),
}),
}
}
fn decode_call_return<R>(receipt: &TransactionReceipt) -> Result<R>
where
R: for<'r> ReadXDR<'r>,
{
let ReceiptExecution::Return { return_data } = &receipt.execution else {
return Err(TestkitError::NoReturn {
kind: "call",
detail: format!("{:?}", receipt.execution),
});
};
let data = return_data.as_ref();
let bytes = decode_default_wallet_call_return(data).map_err(TestkitError::decode)?;
decode_return(&bytes)
}
fn decode_return<R>(bytes: &[u8]) -> Result<R>
where
R: for<'r> ReadXDR<'r>,
{
xdrs::cxs::decode::<R>(bytes).map_err(TestkitError::decode)
}
#[cfg(test)]
mod tests {
use thruput_xdr::types::ContractAddress;
use crate::account::Account;
use crate::caller::Caller;
use crate::error::TestkitError;
use crate::manifest::derive_native_accounts;
use crate::network::Network;
use crate::nonce::NonceNamespace;
use thruput_json_rpc_types::BlockHeight;
use thruput_json_rpc_types::responses::{ReceiptExecution, TransactionReceipt};
use thruput_json_rpc_types::transaction::TxHash;
use thruput_json_rpc_types::{base58::Base58, hex::HexBytes};
use thruput_xdr::types::SHA256Hash;
use super::{
Contract, ContractEntrypointID, decode_call_return, decode_return, entrypoint_id,
try_entrypoint_id,
};
fn receipt(return_data: Option<Vec<u8>>) -> TransactionReceipt {
TransactionReceipt {
transaction_hash: TxHash(Base58(SHA256Hash::from([1; 32]))),
block_height: BlockHeight::from(1),
execution: ReceiptExecution::Return {
return_data: HexBytes(return_data.unwrap_or_default()),
},
events: Vec::new(),
gas_used: 1,
}
}
#[test]
fn call_return_requires_exactly_one_wallet_result() {
let wrap = |results: Vec<Vec<u8>>| Some(xdrs::cxs::encode(&results).unwrap());
decode_call_return::<()>(&receipt(wrap(vec![Vec::new()]))).unwrap();
let value = xdrs::cxs::encode(&7u64).unwrap();
assert_eq!(
decode_call_return::<u64>(&receipt(wrap(vec![value.clone()]))).unwrap(),
7
);
for return_data in [None, wrap(Vec::new()), wrap(vec![value.clone(), value])] {
assert!(matches!(
decode_call_return::<()>(&receipt(return_data)),
Err(TestkitError::Decode { .. })
));
}
}
#[test]
fn matches_known_erc20_selector() {
assert_eq!(
entrypoint_id("ERC20", "0.1.0", "transfer"),
ContractEntrypointID::from([0x63, 0x28, 0x5b, 0x35, 0x23, 0xf9, 0xdc, 0x3f])
);
}
#[test]
fn invalid_version_returns_an_error_instead_of_panicking() {
assert!(matches!(
try_entrypoint_id("ERC20", "latest", "transfer"),
Err(TestkitError::InvalidContractVersion { version }) if version == "latest"
));
}
#[test]
fn cxs_round_trip_matches_helper_bounds() {
let encoded = xdrs::cxs::encode(&(7u64, 9u32)).expect("encode");
let (a, b): (u64, u32) = decode_return(&encoded).expect("decode");
assert_eq!((a, b), (7, 9));
}
#[tokio::test]
async fn prepared_call_reports_a_bad_identity_when_awaited() {
let dev = derive_native_accounts(1, "seed", None).remove(0);
let caller = Caller::new(
Account::new(dev, NonceNamespace::for_run("seed", 0)),
Network::offline(),
);
let contract = Contract::new(ContractAddress::from([7; 32]), "ERC20", "latest");
let call = contract.prepare_call::<u64, _>(&caller, "get", &());
assert!(matches!(
call.await,
Err(TestkitError::InvalidContractVersion { version }) if version == "latest"
));
}
}