Skip to main content

thruput_testkit/
account.rs

1//! Per-agent account and the chain-writing operations it performs.
2
3use std::sync::Arc;
4use std::sync::atomic::{AtomicU32, Ordering};
5
6use thruput_client::{CallRequest, DeployRequest, Wallet};
7use thruput_json_rpc_types::responses::{ReceiptExecution, TransactionReceipt};
8use thruput_json_rpc_types::transaction::TxHash;
9use thruput_signing::default_wallet::DeploymentCode;
10use thruput_signing::{DEFAULT_TX_GAS_LIMIT, PubkeyAccount};
11use thruput_xdr::contract::{Contract, ContractEntrypointID};
12use thruput_xdr::types::{ContractAddress, NetworkHash, SHA256Hash};
13
14use crate::artifact::{
15    ArtifactEntry, PendingRegistration as ArtifactPendingRegistration, RegistrationOwner,
16    contract_from_bytes, contract_hash,
17};
18use crate::error::{Result, TestkitError};
19use crate::manifest::TestAccount;
20use crate::network::{Network, SubmittedTransaction};
21use crate::nonce::NonceNamespace;
22
23/// Absolute block height at which harness-built transactions expire. Chosen far
24/// enough ahead that a short scenario never reaches it.
25const EXPIRY_HEIGHT: i64 = 10_000_000;
26
27/// Explicit funding and optional initialization in the deployment transaction.
28///
29/// Every deploy helper takes these options. A deployment is one call of the
30/// agent's `DefaultWallet.deploy(nonce, contract_hash, amount, initializer)`,
31/// so funding, deployment and initialization commit atomically. The
32/// initializer (an entrypoint, gas budget and encoded calldata) runs as the
33/// wallet against the new contract.
34#[derive(Debug, Clone, Copy)]
35pub struct DeployOptions<'a> {
36    /// Exact raw native token amount transferred to the new contract. Zero skips funding.
37    pub amount: u128,
38    /// Optional invocation on the new contract, called by its deploying wallet.
39    pub initializer: Option<thruput_signing::default_wallet::WalletInitCall<'a>>,
40}
41
42impl DeployOptions<'_> {
43    pub(crate) fn effects(self) -> Result<Vec<u8>> {
44        xdrs::cxs::encode(&(
45            self.amount,
46            self.initializer
47                .map(|init| (init.entrypoint, init.gas_limit, init.calldata)),
48        ))
49        .map_err(TestkitError::encode)
50    }
51
52    /// Deploy with an exact amount and no initializer.
53    #[must_use]
54    pub const fn funded(amount: u128) -> Self {
55        Self {
56            amount,
57            initializer: None,
58        }
59    }
60}
61
62/// Result of a contract deploy.
63#[derive(Debug)]
64pub struct Deployed {
65    /// Address of the deployed contract instance.
66    pub address: ContractAddress,
67    /// Hash of the deploy transaction.
68    pub tx_hash: TxHash,
69    /// Deploy transaction receipt.
70    pub receipt: TransactionReceipt,
71    /// Whether this transaction carried the contract binary (registered it).
72    pub registered: bool,
73}
74
75/// A submitted deployment whose receipt can be awaited after manual block production.
76#[derive(Debug)]
77#[must_use = "submitted deployments should be awaited or intentionally retained"]
78pub struct SubmittedDeploy {
79    network: Network,
80    address: ContractAddress,
81    tx_hash: TxHash,
82    registered: bool,
83    tx_hex: String,
84    cached_receipt: Option<TransactionReceipt>,
85    registration: Option<PendingRegistration>,
86}
87
88#[derive(Debug)]
89struct PendingRegistration {
90    entry: Arc<ArtifactEntry>,
91}
92
93#[derive(Debug)]
94pub(crate) struct PreparedDeploy {
95    network: Network,
96    owner: RegistrationOwner,
97    address: ContractAddress,
98    tx_hash: TxHash,
99    registered: bool,
100    tx_hex: String,
101    effects: Vec<u8>,
102}
103
104impl PreparedDeploy {
105    pub(crate) fn pending_registration(&self) -> ArtifactPendingRegistration {
106        ArtifactPendingRegistration {
107            tx_hash: self.tx_hash,
108            tx_hex: self.tx_hex.clone(),
109            owner: self.owner,
110            effects: self.effects.clone(),
111            address: self.address,
112        }
113    }
114
115    pub(crate) async fn submit(self) -> Result<SubmittedDeploy> {
116        submit_signed(&self.network, self.tx_hash, &self.tx_hex).await?;
117        Ok(SubmittedDeploy {
118            network: self.network,
119            address: self.address,
120            tx_hash: self.tx_hash,
121            registered: self.registered,
122            tx_hex: self.tx_hex,
123            cached_receipt: None,
124            registration: None,
125        })
126    }
127}
128
129impl SubmittedDeploy {
130    /// Address derived for the contract instance before submission.
131    pub fn address(&self) -> ContractAddress {
132        self.address
133    }
134
135    /// Original signed bytes retained for reconciliation.
136    pub fn signed_transaction(&self) -> &str {
137        &self.tx_hex
138    }
139
140    /// Canonical deployment transaction hash.
141    pub fn tx_hash(&self) -> TxHash {
142        self.tx_hash
143    }
144
145    /// Wait until the scheduler reports this deployment as pending.
146    pub async fn await_pending(&self) -> Result<()> {
147        if let Some(receipt) = &self.cached_receipt {
148            return Err(TestkitError::TransactionNotPending {
149                tx_hash: format!("{:?}", self.tx_hash),
150                status: Box::new(
151                    thruput_json_rpc_types::responses::TransactionStatus::Committed {
152                        block_height: receipt.block_height.clone(),
153                    },
154                ),
155            });
156        }
157        self.network.await_pending(self.tx_hash).await
158    }
159
160    /// Wait for the deployment receipt and require successful execution.
161    ///
162    /// For a registry artifact carrying its binary, successful completion marks
163    /// the retained registration attempt as committed.
164    pub async fn await_receipt(&self) -> Result<Deployed> {
165        let receipt = match &self.cached_receipt {
166            Some(receipt) => receipt.clone(),
167            None => self
168                .network
169                .await_receipt(self.tx_hash)
170                .await
171                .map_err(|error| TestkitError::UnverifiedDeployment {
172                    tx_hash: self.tx_hash,
173                    predicted_address: self.address,
174                    signed_transaction: self.tx_hex.clone(),
175                    detail: error.to_string().into(),
176                    source: Some(Box::new(error)),
177                    receipt: None,
178                })?,
179        };
180        verify_deployment_receipt(self.tx_hash, self.address, &self.tx_hex, &receipt)?;
181        if !matches!(receipt.execution, ReceiptExecution::Return { .. }) {
182            self.finish_registration(false).await;
183            return Err(TestkitError::Deploy {
184                detail: format!("{:?}", receipt.execution),
185            });
186        }
187        self.finish_registration(true).await;
188        Ok(Deployed {
189            address: self.address,
190            tx_hash: self.tx_hash,
191            receipt,
192            registered: self.registered,
193        })
194    }
195
196    pub(crate) fn with_registration(mut self, entry: Arc<ArtifactEntry>) -> Self {
197        self.registration = Some(PendingRegistration { entry });
198        self
199    }
200
201    pub(crate) fn recovered(
202        network: Network,
203        attempt: &ArtifactPendingRegistration,
204        receipt: TransactionReceipt,
205    ) -> Self {
206        Self {
207            network,
208            address: attempt.address,
209            tx_hash: attempt.tx_hash,
210            registered: true,
211            tx_hex: attempt.tx_hex.clone(),
212            cached_receipt: Some(receipt),
213            registration: None,
214        }
215    }
216
217    async fn finish_registration(&self, succeeded: bool) {
218        let Some(registration) = &self.registration else {
219            return;
220        };
221        registration
222            .entry
223            .finish_registration(self.tx_hash, succeeded)
224            .await;
225    }
226}
227
228/// An account assigned to one agent, tracking its own nonce sequence.
229///
230/// Clones share the signer and the sequence, so every clone draws distinct
231/// nonces.
232#[derive(Debug, Clone)]
233pub(crate) struct Account {
234    dev: Arc<TestAccount>,
235    namespace: NonceNamespace,
236    next_sequence: Arc<AtomicU32>,
237}
238
239impl Account {
240    pub(crate) fn new(dev: TestAccount, namespace: NonceNamespace) -> Self {
241        Self {
242            dev: Arc::new(dev),
243            namespace,
244            next_sequence: Arc::new(AtomicU32::new(0)),
245        }
246    }
247
248    pub(crate) fn index(&self) -> u32 {
249        self.dev.index
250    }
251
252    pub(crate) fn address(&self) -> ContractAddress {
253        self.dev.wallet_address
254    }
255
256    pub(crate) fn registration_owner(&self) -> RegistrationOwner {
257        RegistrationOwner {
258            address: self.dev.wallet_address,
259            namespace: self.namespace,
260        }
261    }
262
263    /// Claim the next nonce in this account's sequence.
264    ///
265    /// A nonce is consumed even if the transaction using it then fails to
266    /// build or submit; nonces need only be unique, not contiguous.
267    fn reserve_nonce(&self) -> Result<u64> {
268        let sequence = self
269            .next_sequence
270            .try_update(Ordering::Relaxed, Ordering::Relaxed, |seq| {
271                seq.checked_add(1)
272            })
273            .map_err(|_| TestkitError::NonceExhausted {
274                index: self.dev.index,
275            })?;
276        Ok(self.namespace.make_nonce(sequence))
277    }
278
279    pub(crate) async fn submit_deploy(
280        &self,
281        network: &Network,
282        artifact: &[u8],
283        options: DeployOptions<'_>,
284    ) -> Result<SubmittedDeploy> {
285        let contract = contract_from_bytes(artifact.to_vec());
286        let hash = contract_hash(&contract)?;
287        let prepared = self.build_deploy(
288            network,
289            hash,
290            Some(contract),
291            self.reserve_nonce()?,
292            options,
293        )?;
294        prepared.submit().await
295    }
296
297    pub(crate) async fn submit_deploy_entry(
298        &self,
299        network: &Network,
300        entry: &ArtifactEntry,
301        include_binary: bool,
302        options: DeployOptions<'_>,
303    ) -> Result<SubmittedDeploy> {
304        let contract = include_binary.then(|| entry.contract());
305        let prepared = self.build_deploy(
306            network,
307            entry.hash(),
308            contract,
309            self.reserve_nonce()?,
310            options,
311        )?;
312        prepared.submit().await
313    }
314
315    pub(crate) fn prepare_deploy_entry(
316        &self,
317        network: &Network,
318        entry: &ArtifactEntry,
319        include_binary: bool,
320        options: DeployOptions<'_>,
321    ) -> Result<PreparedDeploy> {
322        let contract = include_binary.then(|| entry.contract());
323        let prepared = self.build_deploy(
324            network,
325            entry.hash(),
326            contract,
327            self.reserve_nonce()?,
328            options,
329        )?;
330        Ok(prepared)
331    }
332
333    /// Build, sign, and submit a deploy transaction. `contract` present registers
334    /// the binary in this transaction; absent deploys by hash only.
335    fn build_deploy(
336        &self,
337        network: &Network,
338        hash: SHA256Hash,
339        contract: Option<Contract>,
340        tx_nonce: u64,
341        options: DeployOptions<'_>,
342    ) -> Result<PreparedDeploy> {
343        let registered = contract.is_some();
344        let account = native_account(&self.dev);
345        let gas_limit = DEFAULT_TX_GAS_LIMIT
346            .checked_add(options.initializer.map_or(0, |init| init.gas_limit))
347            .ok_or_else(|| TestkitError::signing("initializer gas overflows deployment budget"))?;
348        let signed = thruput_client::prepare_deploy(
349            DeployRequest {
350                account: &account,
351                code: contract.map_or(DeploymentCode::Registered(hash), DeploymentCode::Attached),
352                amount: options.amount,
353                initializer: options.initializer,
354                gas_limit,
355                block_expiry_height: EXPIRY_HEIGHT,
356                nonce: tx_nonce,
357            },
358            network_hash(&self.dev, "deploy")?,
359        )
360        .and_then(|plan| plan.sign(&self.dev.signer))
361        .map_err(TestkitError::signing)?;
362        let (address, tx_hash) = (signed.address(), signed.tx_hash());
363        let tx_hex = signed.tx_hex().to_owned();
364        Ok(PreparedDeploy {
365            network: network.clone(),
366            owner: self.registration_owner(),
367            address,
368            tx_hash,
369            registered,
370            tx_hex,
371            effects: options.effects()?,
372        })
373    }
374
375    pub(crate) async fn submit_call(
376        &self,
377        network: &Network,
378        target: ContractAddress,
379        entrypoint: ContractEntrypointID,
380        calldata: &[u8],
381    ) -> Result<SubmittedTransaction> {
382        let (tx_hash, tx_hex) = prepare_call(
383            &self.dev,
384            target,
385            entrypoint,
386            calldata,
387            self.reserve_nonce()?,
388        )?;
389        submit_signed(network, tx_hash, &tx_hex).await?;
390        Ok(SubmittedTransaction::new(network.clone(), tx_hash, tx_hex))
391    }
392}
393
394/// The account's native nonce-zero `DefaultWallet` identity.
395fn native_account(dev: &TestAccount) -> PubkeyAccount {
396    PubkeyAccount::from_public_key_bytes(dev.signer.verifying_key_bytes(), 0)
397}
398
399fn network_hash(dev: &TestAccount, operation: &'static str) -> Result<NetworkHash> {
400    dev.network_hash
401        .clone()
402        .ok_or(TestkitError::Offline { operation })
403}
404
405pub(crate) fn prepare_call(
406    dev: &TestAccount,
407    target: ContractAddress,
408    entrypoint: ContractEntrypointID,
409    calldata: &[u8],
410    nonce: u64,
411) -> Result<(TxHash, String)> {
412    let account = native_account(dev);
413    let signed = thruput_client::prepare_call(
414        CallRequest {
415            wallet: Wallet::Native(&account),
416            target_address: target,
417            target_entrypoint: entrypoint,
418            target_calldata: calldata,
419            gas_limit: DEFAULT_TX_GAS_LIMIT * 2,
420            target_gas_limit: DEFAULT_TX_GAS_LIMIT,
421            block_expiry_height: EXPIRY_HEIGHT,
422            nonce,
423        },
424        network_hash(dev, "call")?,
425    )
426    .and_then(|plan| plan.sign(&dev.signer))
427    .map_err(TestkitError::signing)?;
428    Ok((signed.tx_hash(), signed.tx_hex().to_owned()))
429}
430
431pub(crate) async fn submit_signed(network: &Network, tx_hash: TxHash, tx_hex: &str) -> Result<()> {
432    let client = network.require_client("sendTransaction")?;
433    thruput_client::submit_signed_transaction(client, tx_hash, tx_hex)
434        .await
435        .map(|_| ())
436        .map_err(|error| TestkitError::Submission {
437            tx_hash,
438            signed_transaction: tx_hex.to_owned(),
439            detail: error.to_string(),
440            source: Box::new(error),
441        })
442}
443
444pub(crate) fn verify_deployment_receipt(
445    tx_hash: TxHash,
446    address: ContractAddress,
447    tx_hex: &str,
448    receipt: &TransactionReceipt,
449) -> Result<()> {
450    if receipt.transaction_hash != tx_hash {
451        return Err(TestkitError::ReceiptHashMismatch {
452            expected: tx_hash,
453            receipt: Box::new(receipt.clone()),
454        });
455    }
456    if let ReceiptExecution::Return { return_data } = &receipt.execution
457        && return_data.as_ref() != address.as_ref()
458    {
459        return Err(TestkitError::UnverifiedDeployment {
460            tx_hash,
461            predicted_address: address,
462            signed_transaction: tx_hex.to_owned(),
463            detail: "successful deployment did not return the predicted address".into(),
464            source: None,
465            receipt: Some(Box::new(receipt.clone())),
466        });
467    }
468    Ok(())
469}
470
471#[cfg(test)]
472mod tests {
473    use thruput_json_rpc_types::BlockHeight;
474    use thruput_json_rpc_types::base58::Base58;
475    use thruput_json_rpc_types::responses::{ReceiptExecution, TransactionReceipt};
476    use thruput_json_rpc_types::{responses::TransactionStatus, transaction::TxHash};
477    use thruput_xdr::types::{ContractAddress, SHA256Hash};
478
479    use crate::artifact::{PendingRegistration, RegistrationOwner};
480    use crate::error::TestkitError;
481    use crate::manifest::derive_native_accounts;
482    use crate::network::Network;
483    use crate::nonce::NonceNamespace;
484
485    use super::{Account, SubmittedDeploy};
486
487    #[tokio::test]
488    async fn recovered_committed_deploy_does_not_satisfy_pending_barrier() {
489        let tx_hash = TxHash(Base58(SHA256Hash::from([0x11; 32])));
490        let address = ContractAddress::from([0x22; 32]);
491        let attempt = PendingRegistration {
492            tx_hash,
493            tx_hex: "0x01".to_owned(),
494            effects: Vec::new(),
495            owner: RegistrationOwner {
496                address,
497                namespace: NonceNamespace::for_run("seed", 0),
498            },
499            address,
500        };
501        let submitted = SubmittedDeploy::recovered(
502            Network::offline(),
503            &attempt,
504            TransactionReceipt {
505                transaction_hash: tx_hash,
506                block_height: BlockHeight::from(7),
507                execution: ReceiptExecution::Return {
508                    return_data: thruput_json_rpc_types::hex::HexBytes(vec![]),
509                },
510                events: Vec::new(),
511                gas_used: 1_500,
512            },
513        );
514
515        let error = submitted
516            .await_pending()
517            .await
518            .expect_err("bug: a committed deployment is no longer pending");
519        assert!(matches!(
520            error,
521            TestkitError::TransactionNotPending {
522                status,
523                ..
524            } if matches!(status.as_ref(), TransactionStatus::Committed { block_height: height } if height == &BlockHeight::from(7))
525        ));
526    }
527
528    #[test]
529    fn deployment_validation_retains_conflicting_commitment_and_return_bytes() {
530        let expected = TxHash(Base58(SHA256Hash::from([1; 32])));
531        let observed = TransactionReceipt {
532            transaction_hash: expected,
533            block_height: 7.into(),
534            execution: ReceiptExecution::Return {
535                return_data: thruput_json_rpc_types::hex::HexBytes(vec![9; 32]),
536            },
537            events: vec![],
538            gas_used: 42,
539        };
540        let error = super::verify_deployment_receipt(
541            expected,
542            ContractAddress::from([2; 32]),
543            "0x01",
544            &observed,
545        )
546        .unwrap_err();
547        let TestkitError::UnverifiedDeployment {
548            receipt: Some(receipt),
549            ..
550        } = error
551        else {
552            panic!("missing observed receipt")
553        };
554        assert_eq!(*receipt.block_height, 7);
555        assert!(
556            matches!(receipt.execution, ReceiptExecution::Return { return_data } if return_data.0 == vec![9; 32])
557        );
558        let mut mismatched = observed;
559        mismatched.transaction_hash = TxHash(Base58(SHA256Hash::from([3; 32])));
560        let error = super::verify_deployment_receipt(
561            expected,
562            ContractAddress::from([2; 32]),
563            "0x01",
564            &mismatched,
565        )
566        .unwrap_err();
567        let TestkitError::ReceiptHashMismatch { receipt, .. } = error else {
568            panic!("missing conflicting receipt")
569        };
570        assert_eq!(receipt.transaction_hash, mismatched.transaction_hash);
571        assert_eq!(*receipt.block_height, 7);
572    }
573
574    #[test]
575    fn clones_share_one_nonce_sequence() {
576        let namespace = NonceNamespace::for_run("seed", 0);
577        let dev = derive_native_accounts(1, "seed", None).remove(0);
578        let account = Account::new(dev, namespace);
579        let clone = account.clone();
580
581        assert_eq!(account.reserve_nonce().unwrap(), namespace.make_nonce(0));
582        assert_eq!(clone.reserve_nonce().unwrap(), namespace.make_nonce(1));
583        assert_eq!(account.reserve_nonce().unwrap(), namespace.make_nonce(2));
584    }
585
586    #[test]
587    fn exhausted_sequence_is_an_error() {
588        let dev = derive_native_accounts(1, "seed", None).remove(0);
589        let account = Account::new(dev, NonceNamespace::for_run("seed", 0));
590        account
591            .next_sequence
592            .store(u32::MAX, std::sync::atomic::Ordering::Relaxed);
593
594        assert!(matches!(
595            account.reserve_nonce(),
596            Err(TestkitError::NonceExhausted { index: 0 })
597        ));
598    }
599}