Skip to main content

thruput_testkit/
artifact.rs

1//! Contract artifacts: a scenario-level registry of contract binaries.
2//!
3//! A contract binary must be registered on the network before it can be
4//! deployed by hash. Registration means sending the binary in a transaction's
5//! `contracts_to_deploy` field; the binary is visible to a `deploy` syscall in
6//! that same transaction, and to other transactions only once the registering
7//! transaction commits. Sending the binary again is wasteful, so the harness
8//! registers each artifact at most once per run.
9//!
10//! The registry precomputes each artifact's contract hash (its network
11//! identity) at scenario build, and tracks whether it has been registered this
12//! run. The registration state is shared across agents; the actual register /
13//! deploy transactions are performed by an agent's [`crate::agent::AgentContext`].
14
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19
20use thruput_json_rpc_types::transaction::TxHash;
21use thruput_xdr::contract::{Contract, ContractBlob};
22use thruput_xdr::types::{ContractAddress, SHA256Hash};
23use tokio::sync::{Mutex, MutexGuard};
24use xdr_common::BytesM;
25
26use crate::error::{Result, TestkitError};
27use crate::nonce::NonceNamespace;
28
29/// Which artifact format a contract binary is in.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ArtifactKind {
32    /// A lowered RV32 artifact (`.rv32a`).
33    Rv32,
34}
35
36/// Builder-side description of an artifact, resolved at scenario build.
37#[derive(Debug)]
38pub(crate) enum ArtifactSpec {
39    /// Load bytes from a file; infer the kind from the extension when absent.
40    Path {
41        name: String,
42        path: PathBuf,
43        kind: Option<ArtifactKind>,
44    },
45    /// Use in-memory RV32 bytes.
46    Bytes { name: String, bytes: Vec<u8> },
47}
48
49impl ArtifactSpec {
50    fn name(&self) -> &str {
51        match self {
52            Self::Path { name, .. } | Self::Bytes { name, .. } => name,
53        }
54    }
55}
56
57/// A loaded artifact: its bytes, precomputed contract hash, and shared
58/// on-network registration state.
59#[derive(Debug)]
60pub(crate) struct ArtifactEntry {
61    bytes: Vec<u8>,
62    hash: SHA256Hash,
63    registered: AtomicBool,
64    pending_registration: Mutex<Option<PendingRegistration>>,
65}
66
67/// A binary-carrying transaction retained for observation and recovery.
68#[derive(Debug, Clone)]
69pub(crate) struct PendingRegistration {
70    pub(crate) tx_hash: TxHash,
71    pub(crate) tx_hex: String,
72    pub(crate) owner: RegistrationOwner,
73    pub(crate) effects: Vec<u8>,
74    pub(crate) address: ContractAddress,
75}
76
77/// The account and scenario run that submitted an artifact registration.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub(crate) struct RegistrationOwner {
80    pub(crate) address: ContractAddress,
81    pub(crate) namespace: NonceNamespace,
82}
83
84impl ArtifactEntry {
85    /// The contract's network identity (`sha256` of the encoded contract).
86    pub(crate) fn hash(&self) -> SHA256Hash {
87        self.hash
88    }
89
90    /// Whether the harness has observed this artifact registered this run.
91    pub(crate) fn registered(&self) -> bool {
92        self.registered.load(Ordering::Acquire)
93    }
94
95    /// Record that a registering transaction has committed.
96    pub(crate) fn mark_registered(&self) {
97        self.registered.store(true, Ordering::Release);
98    }
99
100    /// Serialize and track first-registration submission for this artifact.
101    pub(crate) async fn registration_guard(&self) -> MutexGuard<'_, Option<PendingRegistration>> {
102        self.pending_registration.lock().await
103    }
104
105    /// Resolve a matching in-flight registration attempt.
106    pub(crate) async fn finish_registration(&self, tx_hash: TxHash, succeeded: bool) {
107        let mut pending = self.pending_registration.lock().await;
108        if pending.as_ref().map(|attempt| attempt.tx_hash) != Some(tx_hash) {
109            return;
110        }
111        if succeeded {
112            self.mark_registered();
113        }
114        *pending = None;
115    }
116
117    /// Rebuild the contract object for inclusion in `contracts_to_deploy`.
118    pub(crate) fn contract(&self) -> Contract {
119        contract_from_bytes(self.bytes.clone())
120    }
121}
122
123/// A scenario's registered contract artifacts, keyed by name.
124#[derive(Debug)]
125pub(crate) struct Registry {
126    entries: HashMap<String, Arc<ArtifactEntry>>,
127}
128
129impl Registry {
130    /// Load and validate every artifact spec, computing contract hashes.
131    pub(crate) fn from_specs(specs: Vec<ArtifactSpec>) -> Result<Self> {
132        let mut entries = HashMap::with_capacity(specs.len());
133        for spec in specs {
134            let name = spec.name().to_owned();
135            if entries.contains_key(&name) {
136                return Err(TestkitError::DuplicateArtifact { name });
137            }
138            entries.insert(name, Arc::new(load_entry(spec)?));
139        }
140        Ok(Self { entries })
141    }
142
143    /// Look up an artifact by name.
144    pub(crate) fn get(&self, name: &str) -> Result<Arc<ArtifactEntry>> {
145        self.entries
146            .get(name)
147            .map(Arc::clone)
148            .ok_or_else(|| TestkitError::UnknownArtifact {
149                name: name.to_owned(),
150            })
151    }
152}
153
154pub(crate) fn contract_from_bytes(bytes: Vec<u8>) -> Contract {
155    Contract::kRv32 {
156        rv32_contract: ContractBlob {
157            data: BytesM(bytes),
158        },
159    }
160}
161
162pub(crate) fn contract_hash(contract: &Contract) -> Result<SHA256Hash> {
163    thruput_client::contract_hash(contract).map_err(TestkitError::encode)
164}
165
166fn load_entry(spec: ArtifactSpec) -> Result<ArtifactEntry> {
167    let bytes = match spec {
168        ArtifactSpec::Bytes { bytes, .. } => bytes,
169        ArtifactSpec::Path { name, path, kind } => {
170            if kind.is_none() {
171                validate_inferred_kind(&path)?;
172            }
173            std::fs::read(&path).map_err(|source| TestkitError::ArtifactLoad {
174                name,
175                path: path.display().to_string(),
176                detail: source.to_string(),
177            })?
178        }
179    };
180    let hash = contract_hash(&contract_from_bytes(bytes.clone()))?;
181    Ok(ArtifactEntry {
182        bytes,
183        hash,
184        registered: AtomicBool::new(false),
185        pending_registration: Mutex::new(None),
186    })
187}
188
189fn validate_inferred_kind(path: &Path) -> Result<()> {
190    match path.extension().and_then(|extension| extension.to_str()) {
191        Some("rv32a") => Ok(()),
192        _ => Err(TestkitError::UnknownArtifactExtension {
193            path: path.display().to_string(),
194        }),
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use std::path::{Path, PathBuf};
201
202    use thruput_json_rpc_types::{base58::Base58, transaction::TxHash};
203    use thruput_xdr::contract::Contract;
204    use thruput_xdr::types::{ContractAddress, SHA256Hash};
205
206    use crate::nonce::NonceNamespace;
207
208    use super::{
209        ArtifactKind, ArtifactSpec, RegistrationOwner, Registry, contract_from_bytes,
210        validate_inferred_kind,
211    };
212
213    #[test]
214    fn raw_artifact_bytes_build_an_rv32_contract() {
215        let bytes = vec![1, 2, 3];
216        let contract = contract_from_bytes(bytes.clone());
217
218        match contract {
219            Contract::kRv32 { rv32_contract } => assert_eq!(rv32_contract.data.0, bytes),
220            other => panic!("expected RV32 contract, got {other:?}"),
221        }
222    }
223
224    #[test]
225    fn inferred_artifact_kind_requires_rv32_extension() {
226        assert!(validate_inferred_kind(Path::new("counter.rv32a")).is_ok());
227        assert!(validate_inferred_kind(Path::new("counter.wasm")).is_err());
228        assert!(validate_inferred_kind(Path::new("counter")).is_err());
229    }
230
231    #[cfg(unix)]
232    #[test]
233    fn inferred_artifact_kind_rejects_non_utf8_extension() {
234        use std::os::unix::ffi::OsStringExt;
235
236        let path = PathBuf::from(std::ffi::OsString::from_vec(b"counter.\xff".to_vec()));
237        assert!(validate_inferred_kind(&path).is_err());
238    }
239
240    #[test]
241    fn explicit_rv32_kind_overrides_filename_extension() {
242        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/artifact.rs");
243        let registry = Registry::from_specs(vec![ArtifactSpec::Path {
244            name: "counter".to_owned(),
245            path,
246            kind: Some(ArtifactKind::Rv32),
247        }]);
248
249        assert!(registry.is_ok());
250    }
251
252    #[tokio::test]
253    async fn registration_resolution_only_clears_the_matching_transaction() {
254        let registry = Registry::from_specs(vec![ArtifactSpec::Bytes {
255            name: "counter".to_owned(),
256            bytes: vec![1, 2, 3],
257        }])
258        .expect("bug: in-memory artifact should load");
259        let entry = registry.get("counter").expect("bug: artifact exists");
260        let pending_hash = TxHash(Base58(SHA256Hash::from([0x11; 32])));
261        let other_hash = TxHash(Base58(SHA256Hash::from([0x22; 32])));
262        *entry.registration_guard().await = Some(super::PendingRegistration {
263            tx_hash: pending_hash,
264            tx_hex: "0x01".to_owned(),
265            effects: Vec::new(),
266            owner: RegistrationOwner {
267                address: [0; 32].into(),
268                namespace: NonceNamespace::for_run("seed", 0),
269            },
270            address: [0; 32].into(),
271        });
272
273        entry.finish_registration(other_hash, true).await;
274        assert_eq!(
275            entry
276                .registration_guard()
277                .await
278                .as_ref()
279                .map(|attempt| attempt.tx_hash),
280            Some(pending_hash)
281        );
282        assert!(!entry.registered());
283
284        entry.finish_registration(pending_hash, true).await;
285        assert!(entry.registration_guard().await.is_none());
286        assert!(entry.registered());
287    }
288
289    #[test]
290    fn registration_owner_distinguishes_repeated_runs_using_the_same_account() {
291        let address = ContractAddress::from([0x42; 32]);
292
293        assert_ne!(
294            RegistrationOwner {
295                address,
296                namespace: NonceNamespace::for_run("seed", 0),
297            },
298            RegistrationOwner {
299                address,
300                namespace: NonceNamespace::for_run("seed", 1),
301            }
302        );
303    }
304}