1use 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
23const EXPIRY_HEIGHT: i64 = 10_000_000;
26
27#[derive(Debug, Clone, Copy)]
35pub struct DeployOptions<'a> {
36 pub amount: u128,
38 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 #[must_use]
54 pub const fn funded(amount: u128) -> Self {
55 Self {
56 amount,
57 initializer: None,
58 }
59 }
60}
61
62#[derive(Debug)]
64pub struct Deployed {
65 pub address: ContractAddress,
67 pub tx_hash: TxHash,
69 pub receipt: TransactionReceipt,
71 pub registered: bool,
73}
74
75#[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 pub fn address(&self) -> ContractAddress {
132 self.address
133 }
134
135 pub fn signed_transaction(&self) -> &str {
137 &self.tx_hex
138 }
139
140 pub fn tx_hash(&self) -> TxHash {
142 self.tx_hash
143 }
144
145 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 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#[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 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 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
394fn 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}