Skip to main content

thruput_client/
deploy.rs

1//! Native contract deployment through an account's `DefaultWallet`.
2//!
3//! Deployment follows the same prepare, sign, submit, and wait sequence as calls. The new
4//! contract's address is derived before signing, so callers can record it before submission
5//! and verify it against the receipt afterwards.
6
7use jsonrpsee::core::client::ClientT;
8use sha2::{Digest, Sha256};
9use thruput_json_rpc_client::native::{
10    NativeClient,
11    receipt::{ReceiptPollConfig, ReceiptPollError},
12};
13use thruput_json_rpc_types::{
14    responses::{ReceiptExecution, TransactionReceipt},
15    transaction::TxHash,
16};
17use thruput_signing::default_wallet::{
18    BuildDefaultWalletDeployTransaction, DeploymentCode, WalletInitCall,
19    build_default_wallet_deploy_transaction,
20};
21use thruput_signing::{PubkeyAccount, Signer, SigningError};
22use thruput_xdr::{
23    contract::Contract,
24    transaction::Transaction,
25    types::{ContractAddress, NetworkHash, SHA256Hash},
26};
27
28use crate::{SubmitCallError, SubmittedCall, sign_transaction};
29
30/// A native deployment from `account`'s `DefaultWallet`, with optional funding and
31/// initialization in the same transaction.
32#[derive(Debug, Clone)]
33pub struct DeployRequest<'a> {
34    /// Deploying native account.
35    pub account: &'a PubkeyAccount,
36    /// Binary attached to this transaction, or the hash of an already registered binary.
37    pub code: DeploymentCode,
38    /// Exact raw native token amount transferred to the new contract. Zero skips funding.
39    pub amount: u128,
40    /// Invocation of the new contract by the deploying wallet.
41    pub initializer: Option<WalletInitCall<'a>>,
42    /// Root gas budget, which must cover any initializer's gas limit.
43    pub gas_limit: u64,
44    /// Transaction expiration height.
45    pub block_expiry_height: i64,
46    /// Transaction uniquifier. The deployment salt is [`deployment_nonce`] of this value.
47    pub nonce: u64,
48}
49
50/// Hash identifying a contract binary on the network.
51pub fn contract_hash(contract: &Contract) -> Result<SHA256Hash, SigningError> {
52    thruput_signing::wallet_contract_hash(contract)
53}
54
55/// Deployment salt derived from a transaction nonce: SHA-256 of its little-endian bytes.
56pub fn deployment_nonce(nonce: u64) -> SHA256Hash {
57    SHA256Hash::from(<[u8; 32]>::from(Sha256::digest(nonce.to_le_bytes())))
58}
59
60/// Prepare a native deployment without submitting it, deriving the new contract's address.
61pub fn prepare_deploy(
62    request: DeployRequest<'_>,
63    network_hash: NetworkHash,
64) -> Result<UnsignedDeploy, SigningError> {
65    let code_hash = match &request.code {
66        DeploymentCode::Attached(contract) => contract_hash(contract)?,
67        DeploymentCode::Registered(hash) => *hash,
68    };
69    let salt = deployment_nonce(request.nonce);
70    let address =
71        thruput_signing::derive_deploy_address(&request.account.address(), &salt, &code_hash)?;
72    let transaction =
73        build_default_wallet_deploy_transaction(BuildDefaultWalletDeployTransaction {
74            account: request.account,
75            deployment_nonce: salt,
76            code: request.code,
77            amount: request.amount,
78            initializer: request.initializer,
79            gas_limit: request.gas_limit,
80            block_expiry_height: request.block_expiry_height,
81            nonce: request.nonce,
82        })?;
83    Ok(UnsignedDeploy {
84        transaction,
85        network_hash,
86        address,
87        code_hash,
88    })
89}
90
91/// An inspectable unsigned deployment, bound to the intended network.
92#[derive(Debug, Clone)]
93pub struct UnsignedDeploy {
94    transaction: Transaction,
95    network_hash: NetworkHash,
96    address: ContractAddress,
97    code_hash: SHA256Hash,
98}
99
100impl UnsignedDeploy {
101    /// The exact transaction body that will be signed.
102    pub fn transaction(&self) -> &Transaction {
103        &self.transaction
104    }
105
106    /// Address the contract will have if this transaction succeeds.
107    pub fn address(&self) -> ContractAddress {
108        self.address
109    }
110
111    /// Hash of the deployed binary.
112    pub fn code_hash(&self) -> SHA256Hash {
113        self.code_hash
114    }
115
116    /// Sign this deployment without submitting it. Reject a signer whose public key
117    /// differs from the deploying account's auth claim.
118    pub fn sign(self, signer: &impl Signer) -> Result<SignedDeploy, SigningError> {
119        let (tx_hash, tx_hex) = sign_transaction(self.transaction, &self.network_hash, signer)?;
120        Ok(SignedDeploy {
121            tx_hash,
122            tx_hex,
123            address: self.address,
124        })
125    }
126}
127
128/// A signed deployment with its canonical hash and predicted address available before
129/// any network request.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct SignedDeploy {
132    tx_hash: TxHash,
133    tx_hex: String,
134    address: ContractAddress,
135}
136
137impl SignedDeploy {
138    /// Canonical signed transaction encoded for `sendTransaction`.
139    pub fn tx_hex(&self) -> &str {
140        &self.tx_hex
141    }
142
143    /// Canonical transaction hash, also available if submission has an ambiguous outcome.
144    pub fn tx_hash(&self) -> TxHash {
145        self.tx_hash
146    }
147
148    /// Address the contract will have if this transaction succeeds.
149    pub fn address(&self) -> ContractAddress {
150        self.address
151    }
152
153    /// Submit exactly these signed bytes once. Errors retain the original hash for reconciliation.
154    pub async fn submit<C: ClientT + Sync>(
155        &self,
156        client: &NativeClient<C>,
157    ) -> Result<SubmittedDeploy, SubmitCallError> {
158        crate::submit_signed_transaction(client, self.tx_hash, &self.tx_hex).await?;
159        Ok(SubmittedDeploy {
160            tx_hash: self.tx_hash,
161            address: self.address,
162        })
163    }
164}
165
166/// An admitted deployment, whose hash and predicted address can be saved and checked
167/// again without resubmission.
168#[derive(Debug, Clone, Copy)]
169pub struct SubmittedDeploy {
170    pub tx_hash: TxHash,
171    pub address: ContractAddress,
172}
173
174impl SubmittedDeploy {
175    /// Wait for the authoritative receipt, including unsuccessful execution receipts.
176    /// Check it with [`verify`](Self::verify). This method never resubmits.
177    pub async fn wait<C: ClientT + Sync, F: std::future::Future<Output = ()>>(
178        &self,
179        client: &NativeClient<C>,
180        network_hash: thruput_json_rpc_types::NetworkHash,
181        config: ReceiptPollConfig,
182        cancelled: F,
183    ) -> Result<TransactionReceipt, ReceiptPollError> {
184        SubmittedCall {
185            tx_hash: self.tx_hash,
186        }
187        .wait(client, network_hash, config, cancelled)
188        .await
189    }
190
191    /// Require `receipt` to belong to this deployment and, when execution succeeded, to
192    /// return exactly the predicted address. Unsuccessful execution is not an error here;
193    /// callers inspect `receipt.execution`.
194    pub fn verify(&self, receipt: &TransactionReceipt) -> Result<(), DeploymentVerificationError> {
195        if receipt.transaction_hash != self.tx_hash {
196            return Err(DeploymentVerificationError::ReceiptHashMismatch {
197                tx_hash: self.tx_hash,
198                receipt: receipt.transaction_hash,
199            });
200        }
201        if let ReceiptExecution::Return { return_data } = &receipt.execution
202            && return_data.as_ref() != self.address.as_ref()
203        {
204            return Err(DeploymentVerificationError::AddressMismatch {
205                tx_hash: self.tx_hash,
206                predicted: self.address,
207            });
208        }
209        Ok(())
210    }
211}
212
213/// A receipt that does not confirm the predicted deployment.
214#[derive(Debug, thiserror::Error)]
215#[non_exhaustive]
216pub enum DeploymentVerificationError {
217    /// The receipt belongs to a different transaction.
218    #[error("deployment {tx_hash:?} received a receipt for {receipt:?}")]
219    ReceiptHashMismatch { tx_hash: TxHash, receipt: TxHash },
220    /// Successful execution did not return the predicted address.
221    #[error("deployment {tx_hash:?} did not return the predicted address {predicted:?}")]
222    AddressMismatch {
223        tx_hash: TxHash,
224        predicted: ContractAddress,
225    },
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use thruput_json_rpc_types::{BlockHeight, hex::HexBytes};
232    use thruput_signing::LocalSigner;
233    use thruput_xdr::contract::ContractBlob;
234    use thruput_xdr::contract::ContractEntrypointID;
235    use xdrs::BytesM;
236
237    fn network() -> NetworkHash {
238        NetworkHash::from(SHA256Hash::from([8; 32]))
239    }
240
241    fn contract() -> Contract {
242        Contract::kRv32 {
243            rv32_contract: ContractBlob {
244                data: BytesM(vec![1, 2, 3]),
245            },
246        }
247    }
248
249    fn request(account: &PubkeyAccount, code: DeploymentCode) -> DeployRequest<'_> {
250        DeployRequest {
251            account,
252            code,
253            amount: 5,
254            initializer: Some(WalletInitCall {
255                entrypoint: ContractEntrypointID::from([4; 8]),
256                gas_limit: 50,
257                calldata: &[6],
258            }),
259            gas_limit: 100,
260            block_expiry_height: 1000,
261            nonce: 7,
262        }
263    }
264
265    fn receipt(tx_hash: TxHash, return_data: Option<Vec<u8>>) -> TransactionReceipt {
266        TransactionReceipt {
267            transaction_hash: tx_hash,
268            block_height: BlockHeight::from(1),
269            execution: ReceiptExecution::Return {
270                return_data: HexBytes(return_data.unwrap_or_default()),
271            },
272            events: vec![],
273            gas_used: 1,
274        }
275    }
276
277    #[test]
278    fn prepared_deploy_matches_canonical_builder_and_address() {
279        let key = LocalSigner::from_secret_key([9; 32]);
280        let account = PubkeyAccount::new(key.public_key(), 0);
281        let plan = prepare_deploy(
282            request(&account, DeploymentCode::Attached(contract())),
283            network(),
284        )
285        .unwrap();
286
287        let hash = contract_hash(&contract()).unwrap();
288        let salt = SHA256Hash::from(<[u8; 32]>::from(Sha256::digest(7u64.to_le_bytes())));
289        assert_eq!(deployment_nonce(7), salt);
290        assert_eq!(plan.code_hash(), hash);
291        assert_eq!(
292            plan.address(),
293            thruput_signing::derive_deploy_address(&account.address(), &salt, &hash).unwrap()
294        );
295        let expected =
296            build_default_wallet_deploy_transaction(BuildDefaultWalletDeployTransaction {
297                account: &account,
298                deployment_nonce: salt,
299                code: DeploymentCode::Attached(contract()),
300                amount: 5,
301                initializer: request(&account, DeploymentCode::Registered(hash)).initializer,
302                gas_limit: 100,
303                block_expiry_height: 1000,
304                nonce: 7,
305            })
306            .unwrap();
307        assert_eq!(plan.transaction(), &expected);
308
309        // Registering and deploying by hash share the address.
310        let registered = prepare_deploy(
311            request(&account, DeploymentCode::Registered(hash)),
312            network(),
313        )
314        .unwrap();
315        assert_eq!(registered.address(), plan.address());
316        assert!(registered.transaction().contracts_to_deploy.0.is_empty());
317
318        let signed = plan.sign(&key).unwrap();
319        assert_eq!(
320            signed.tx_hash(),
321            TxHash(thruput_signing::transaction_hash(&expected).unwrap().into())
322        );
323        assert_eq!(signed.address(), registered.address());
324    }
325
326    #[test]
327    fn deploy_signer_mismatch_is_rejected() {
328        let owner = LocalSigner::from_secret_key([9; 32]);
329        let account = PubkeyAccount::new(owner.public_key(), 0);
330        let plan = prepare_deploy(
331            request(&account, DeploymentCode::Attached(contract())),
332            network(),
333        )
334        .unwrap();
335        assert!(matches!(
336            plan.sign(&LocalSigner::from_secret_key([10; 32])),
337            Err(SigningError::SignerMismatch)
338        ));
339    }
340
341    #[test]
342    fn verification_requires_matching_hash_and_exact_address() {
343        let tx_hash = TxHash(SHA256Hash::from([1; 32]).into());
344        let address = ContractAddress::from([2; 32]);
345        let submitted = SubmittedDeploy { tx_hash, address };
346
347        submitted
348            .verify(&receipt(tx_hash, Some(vec![2; 32])))
349            .unwrap();
350        let other = TxHash(SHA256Hash::from([3; 32]).into());
351        assert!(matches!(
352            submitted.verify(&receipt(other, Some(vec![2; 32]))),
353            Err(DeploymentVerificationError::ReceiptHashMismatch { .. })
354        ));
355        for returned in [None, Some(vec![3; 32]), Some(vec![2; 31])] {
356            assert!(matches!(
357                submitted.verify(&receipt(tx_hash, returned)),
358                Err(DeploymentVerificationError::AddressMismatch { .. })
359            ));
360        }
361        let reverted = TransactionReceipt {
362            execution: ReceiptExecution::Abort {
363                abort_data: HexBytes(vec![]),
364            },
365            ..receipt(tx_hash, None)
366        };
367        submitted.verify(&reverted).unwrap();
368    }
369}