1pub 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#[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 pub fn tx_hash(&self) -> TxHash {
105 self.transaction.tx_hash()
106 }
107
108 pub async fn await_pending(&self) -> Result<()> {
110 self.transaction.await_pending().await
111 }
112
113 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#[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 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 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 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 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#[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
236fn 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#[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
315pub 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#[derive(Debug, Clone)]
361pub struct Contract {
362 address: ContractAddress,
363 interface: String,
364 version: String,
365}
366
367impl Contract {
368 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 #[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 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 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 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 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 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
498async 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
523fn 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 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 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 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 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 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}