Skip to main content

thruput_client/
lib.rs

1//! Contract invocation without CLI configuration, terminal output, or runtime ownership.
2//!
3//! Callers resolve addresses, wallet entrypoints, and calldata, verify network identity,
4//! then prepare, inspect, sign, submit, and wait as separate operations. Calls go through
5//! [`prepare_call`]; native deployments through [`prepare_deploy`].
6
7pub mod codec;
8mod deploy;
9mod native;
10pub use native::{
11    NativeRpcError, SubmitCallError, SubmittedCall, check_endpoint, first_submission_not_admitted,
12    resolve_block_expiry_height, submit_signed_transaction, validate_network,
13};
14pub use thruput_json_rpc_client::native::NativeClient;
15pub use thruput_json_rpc_client::native::receipt::{ReceiptPollConfig, ReceiptPollError};
16
17pub use deploy::{
18    DeployRequest, DeploymentVerificationError, SignedDeploy, SubmittedDeploy, UnsignedDeploy,
19    contract_hash, deployment_nonce, prepare_deploy,
20};
21pub use jsonrpsee;
22pub use thruput_contract_spec as spec;
23pub use thruput_json_rpc_client as rpc;
24pub use thruput_json_rpc_types as types;
25pub use thruput_signing as signing;
26/// Shared Z/G/S text encodings; independent of either CLI.
27pub use thruput_signing::strkey;
28pub use thruput_xdr as xdr;
29
30use thruput_json_rpc_types::transaction::TxHash;
31use thruput_signing::{SignTransactionParams, Signer, SigningError};
32use thruput_xdr::{transaction::Transaction, types::NetworkHash};
33
34/// An inspectable unsigned wallet invocation, bound to the intended network.
35#[derive(Debug, Clone)]
36pub struct UnsignedCall {
37    transaction: Transaction,
38    network_hash: NetworkHash,
39    native_return: bool,
40}
41
42/// Explicit wallet identity and invocation format. Legacy addresses are never
43/// reinterpreted as native accounts based on their owner key.
44#[derive(Debug, Clone, Copy)]
45pub enum Wallet<'a> {
46    /// Root pubkey account using the native `DefaultWallet` ABI.
47    Native(&'a signing::PubkeyAccount),
48    /// A deployed legacy wallet with its configured call entrypoint.
49    Legacy {
50        /// Deployed wallet address.
51        address: xdr::types::ContractAddress,
52        /// Legacy `do_action` entrypoint from the network's wallet interface.
53        entrypoint: xdr::contract::ContractEntrypointID,
54    },
55}
56
57/// A single child call through an explicitly selected wallet adapter.
58#[derive(Debug, Clone, Copy)]
59pub struct CallRequest<'a> {
60    /// Native or legacy caller identity.
61    pub wallet: Wallet<'a>,
62    /// Child contract address.
63    pub target_address: xdr::types::ContractAddress,
64    /// Child contract method selector.
65    pub target_entrypoint: xdr::contract::ContractEntrypointID,
66    /// Encoded child arguments.
67    pub target_calldata: &'a [u8],
68    /// Wallet root gas budget.
69    pub gas_limit: u64,
70    /// Child gas budget.
71    pub target_gas_limit: u64,
72    /// Transaction expiration height.
73    pub block_expiry_height: i64,
74    /// Transaction uniquifier, independent of native account derivation.
75    pub nonce: u64,
76}
77
78/// Prepare a single native or legacy child call without submitting it.
79pub fn prepare_call(
80    request: CallRequest<'_>,
81    network_hash: NetworkHash,
82) -> Result<UnsignedCall, SigningError> {
83    let transaction = match request.wallet {
84        Wallet::Native(account) => signing::default_wallet::build_default_wallet_call_transaction(
85            signing::default_wallet::BuildDefaultWalletCallTransaction {
86                account,
87                target_address: request.target_address,
88                target_entrypoint: request.target_entrypoint,
89                target_calldata: request.target_calldata,
90                target_gas_limit: request.target_gas_limit,
91                gas_limit: request.gas_limit,
92                block_expiry_height: request.block_expiry_height,
93                nonce: request.nonce,
94            },
95        )?,
96        Wallet::Legacy {
97            address,
98            entrypoint,
99        } => signing::build_dev_wallet_do_action_transaction(
100            signing::BuildDevWalletDoActionTransaction {
101                wallet_address: address,
102                wallet_entrypoint: entrypoint,
103                target_address: request.target_address,
104                target_entrypoint: request.target_entrypoint,
105                target_calldata: request.target_calldata,
106                target_gas_limit: request.target_gas_limit,
107                gas_limit: request.gas_limit,
108                block_expiry_height: request.block_expiry_height,
109                nonce: request.nonce,
110            },
111        )?,
112    };
113    Ok(UnsignedCall {
114        transaction,
115        network_hash,
116        native_return: matches!(request.wallet, Wallet::Native(_)),
117    })
118}
119
120impl UnsignedCall {
121    /// The exact transaction body that will be signed.
122    pub fn transaction(&self) -> &Transaction {
123        &self.transaction
124    }
125
126    /// Sign this prepared invocation without submitting it or consulting configuration.
127    /// Reject a signer whose public key differs from the native account's auth claim.
128    pub fn sign(self, signer: &impl Signer) -> Result<SignedCall, SigningError> {
129        let (tx_hash, tx_hex) = sign_transaction(self.transaction, &self.network_hash, signer)?;
130        Ok(SignedCall {
131            tx_hash,
132            tx_hex,
133            native_return: self.native_return,
134        })
135    }
136}
137
138/// Sign a prepared transaction, rejecting a signer whose public key differs from
139/// a native account's auth claim. Returns the canonical hash and signed hex.
140fn sign_transaction(
141    transaction: Transaction,
142    network_hash: &NetworkHash,
143    signer: &impl Signer,
144) -> Result<(TxHash, String), SigningError> {
145    let tx_hash = TxHash(thruput_signing::transaction_hash(&transaction)?.into());
146    let tx_hex = thruput_signing::sign_and_encode_transaction_hex(
147        transaction,
148        signer,
149        SignTransactionParams::default(),
150        network_hash,
151    )?;
152    Ok((tx_hash, tx_hex))
153}
154
155/// A signed invocation with its canonical hash available before any network request.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct SignedCall {
158    tx_hash: TxHash,
159    tx_hex: String,
160    native_return: bool,
161}
162
163impl SignedCall {
164    /// Whether the root invocation uses the native wallet ABI.
165    pub fn is_native(&self) -> bool {
166        self.native_return
167    }
168
169    /// Extract the one child's return bytes after checking the receipt's success
170    /// status. The original wallet receipt remains available to the caller.
171    pub fn decode_return(&self, data: &[u8]) -> Result<Vec<u8>, SigningError> {
172        if self.native_return {
173            signing::default_wallet::decode_default_wallet_call_return(data)
174        } else {
175            Ok(data.to_vec())
176        }
177    }
178    /// Canonical signed transaction encoded for `sendTransaction`.
179    pub fn tx_hex(&self) -> &str {
180        &self.tx_hex
181    }
182    /// Canonical transaction hash, also available if submission has an ambiguous outcome.
183    pub fn tx_hash(&self) -> TxHash {
184        self.tx_hash
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use thruput_signing::LocalSigner;
192    use thruput_xdr::{
193        contract::ContractEntrypointID,
194        types::{ContractAddress, SHA256Hash},
195    };
196
197    fn prepared() -> UnsignedCall {
198        prepared_with_wallet(Wallet::Legacy {
199            address: ContractAddress::from([1; 32]),
200            entrypoint: ContractEntrypointID::from([2; 8]),
201        })
202    }
203
204    fn prepared_with_wallet(wallet: Wallet<'_>) -> UnsignedCall {
205        prepare_call(
206            CallRequest {
207                wallet,
208                target_address: ContractAddress::from([3; 32]),
209                target_entrypoint: ContractEntrypointID::from([4; 8]),
210                target_calldata: &[5, 6],
211                gas_limit: 100,
212                target_gas_limit: 90,
213                block_expiry_height: 1000,
214                nonce: 7,
215            },
216            NetworkHash::from(SHA256Hash::from([8; 32])),
217        )
218        .unwrap()
219    }
220
221    #[test]
222    fn native_signer_mismatch_is_rejected_before_signing() {
223        struct WrongSigner;
224        impl Signer for WrongSigner {
225            fn public_key(&self) -> xdr::types::PublicKey {
226                LocalSigner::from_secret_key([10; 32]).public_key()
227            }
228
229            fn sign(&self, _: &[u8]) -> Result<xdr::types::Signature, SigningError> {
230                panic!("mismatching signer must not be invoked")
231            }
232        }
233
234        let owner = LocalSigner::from_secret_key([9; 32]);
235        for nonce in [0, 17] {
236            let account = signing::PubkeyAccount::new(owner.public_key(), nonce);
237            let plan = prepared_with_wallet(Wallet::Native(&account));
238            assert!(matches!(
239                plan.sign(&WrongSigner),
240                Err(SigningError::SignerMismatch)
241            ));
242        }
243    }
244
245    #[test]
246    fn matching_native_signer_preserves_canonical_bytes() {
247        let owner = LocalSigner::from_secret_key([9; 32]);
248        let account = signing::PubkeyAccount::new(owner.public_key(), 17);
249        let plan = prepared_with_wallet(Wallet::Native(&account));
250        let expected = signing::sign_and_encode_transaction_hex(
251            plan.transaction().clone(),
252            &owner,
253            SignTransactionParams::default(),
254            &NetworkHash::from(SHA256Hash::from([8; 32])),
255        )
256        .unwrap();
257        assert_eq!(plan.sign(&owner).unwrap().tx_hex(), expected);
258    }
259
260    #[test]
261    fn signed_bytes_match_canonical_signing_and_remain_network_bound() {
262        let key = LocalSigner::from_secret_key([9; 32]);
263        let plan = prepared();
264        let expected = thruput_signing::sign_and_encode_transaction_hex(
265            plan.transaction().clone(),
266            &key,
267            SignTransactionParams::default(),
268            &NetworkHash::from(SHA256Hash::from([8; 32])),
269        )
270        .unwrap();
271        let signed = plan.clone().sign(&key).unwrap();
272        assert_eq!(signed.tx_hex(), expected);
273        assert_eq!(
274            signed.tx_hash(),
275            TxHash(
276                thruput_signing::transaction_hash(plan.transaction())
277                    .unwrap()
278                    .into()
279            )
280        );
281        let mut elsewhere = plan;
282        elsewhere.network_hash = NetworkHash::from(SHA256Hash::from([10; 32]));
283        let elsewhere = elsewhere.sign(&key).unwrap();
284        assert_eq!(elsewhere.tx_hash(), signed.tx_hash());
285        assert_ne!(elsewhere.tx_hex(), signed.tx_hex());
286    }
287
288    #[test]
289    fn wallet_dispatch_preserves_legacy_bytes_and_unwraps_native_returns() {
290        let key = LocalSigner::from_secret_key([9; 32]);
291        let account = signing::PubkeyAccount::from_public_key_bytes(key.verifying_key_bytes(), 17);
292        let request = CallRequest {
293            wallet: Wallet::Legacy {
294                address: ContractAddress::from([1; 32]),
295                entrypoint: ContractEntrypointID::from([2; 8]),
296            },
297            target_address: ContractAddress::from([3; 32]),
298            target_entrypoint: ContractEntrypointID::from([4; 8]),
299            target_calldata: &[5, 6],
300            gas_limit: 100,
301            target_gas_limit: 90,
302            block_expiry_height: 1000,
303            nonce: 7,
304        };
305        let network = NetworkHash::from(SHA256Hash::from([8; 32]));
306        let legacy = prepare_call(request, network.clone()).unwrap();
307        assert_eq!(legacy.transaction(), prepared().transaction());
308        let legacy = legacy.sign(&key).unwrap();
309        assert!(!legacy.is_native());
310        assert_eq!(legacy.decode_return(&[42]).unwrap(), [42]);
311        let native = prepare_call(
312            CallRequest {
313                wallet: Wallet::Native(&account),
314                ..request
315            },
316            network,
317        )
318        .unwrap();
319        assert_eq!(
320            native.transaction().invocation.invoked_address,
321            account.address()
322        );
323        let signed = native.sign(&key).unwrap();
324        assert!(signed.is_native());
325        let bytes = xdrs::cxs::encode(&vec![vec![42u8]]).unwrap();
326        assert_eq!(signed.decode_return(&bytes).unwrap(), [42]);
327        for returns in [vec![], vec![vec![1u8], vec![2u8]]] {
328            assert!(
329                signed
330                    .decode_return(&xdrs::cxs::encode(&returns).unwrap())
331                    .is_err()
332            );
333        }
334        assert!(signed.decode_return(&[255]).is_err());
335    }
336}