Skip to main content

thruput_testkit/
network.rs

1//! The network a scenario runs against: how to reach it ([`NetworkConfig`])
2//! and the live handle agents use ([`Network`]).
3
4use std::fmt;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::time::Duration;
8
9use thruput_node_supervisor::{BlockDriverConfig, LogLevel, ManualBlockProducer, ProducedBlock};
10
11use thruput_json_rpc_client::native::{
12    NativeClient, NativeClientError,
13    receipt::{ReceiptPollConfig, poll_receipt},
14};
15use thruput_json_rpc_client::{HttpClient, HttpClientBuilder};
16use thruput_json_rpc_types::runtime_manifest::RuntimeManifest;
17use thruput_json_rpc_types::transaction::TxHash;
18use thruput_json_rpc_types::{
19    NetworkHash,
20    requests::{self, EmptyRequest, EventQuery, TransactionQuery},
21    responses::{
22        ChainTip, DecodedResponse, EventPage, SimulationResult, StorageResult, TransactionReceipt,
23        TransactionStatus,
24    },
25};
26use thruput_json_rpc_types::{base58::Base58, hex::HexBytes};
27use thruput_xdr::contract::ContractEntrypointID;
28use thruput_xdr::types::ContractAddress;
29
30use crate::error::{Result, TestkitError};
31
32const RECEIPT_POLL_INTERVAL: Duration = Duration::from_millis(250);
33const RECEIPT_TIMEOUT: Duration = Duration::from_secs(10);
34const PENDING_POLL_INTERVAL: Duration = Duration::from_millis(25);
35const PENDING_POLL_ATTEMPTS: usize = 400; // ~10 seconds at the interval above.
36
37/// A cheap, cloneable handle onto the target network.
38///
39/// Chain verbs return [`TestkitError::Offline`] when the scenario has no live
40/// target. [`Network::client`] exposes the underlying typed JSON-RPC client as
41/// an escape hatch for methods this handle does not wrap.
42#[derive(Clone)]
43pub struct Network {
44    inner: Arc<NetworkInner>,
45}
46
47/// A submitted transaction whose pending status and receipt can be awaited separately.
48#[derive(Debug, Clone)]
49#[must_use = "submitted transactions should be awaited or intentionally retained"]
50pub struct SubmittedTransaction {
51    network: Network,
52    tx_hash: TxHash,
53    tx_hex: String,
54}
55
56impl SubmittedTransaction {
57    pub(crate) fn new(network: Network, tx_hash: TxHash, tx_hex: String) -> Self {
58        Self {
59            network,
60            tx_hash,
61            tx_hex,
62        }
63    }
64
65    /// Signed bytes retained for reconciliation of the original attempt.
66    pub fn signed_transaction(&self) -> &str {
67        &self.tx_hex
68    }
69
70    /// Canonical transaction hash computed before submission.
71    pub fn tx_hash(&self) -> TxHash {
72        self.tx_hash
73    }
74
75    /// Wait until the scheduler reports this transaction as pending.
76    pub async fn await_pending(&self) -> Result<()> {
77        self.network.await_pending(self.tx_hash).await
78    }
79
80    /// Wait until the indexer exposes this transaction's receipt.
81    pub async fn await_receipt(&self) -> Result<TransactionReceipt> {
82        self.network.await_receipt(self.tx_hash).await
83    }
84}
85
86#[derive(Debug, PartialEq, Eq)]
87struct FundingEvidence {
88    tx_hash: TxHash,
89    signed_transaction: HexBytes,
90}
91
92#[derive(Debug)]
93struct LocalFundingAttempt {
94    request: requests::FundAccountRequest,
95    admitted: Option<thruput_json_rpc_types::responses::FundingSubmission>,
96    evidence: Option<FundingEvidence>,
97}
98
99impl LocalFundingAttempt {
100    fn retained_evidence(&self) -> Option<Box<(TxHash, HexBytes)>> {
101        self.evidence
102            .as_ref()
103            .map(|e| Box::new((e.tx_hash, e.signed_transaction.clone())))
104    }
105
106    fn accept_response(
107        &mut self,
108        response: &thruput_json_rpc_types::responses::FundingSubmission,
109    ) -> Result<()> {
110        validate_funding_response(response, &self.request).map_err(|error| {
111            TestkitError::InvalidFundingResponse {
112                response: Box::new(response.clone()),
113                retained: self.retained_evidence(),
114                detail: error.to_string(),
115            }
116        })?;
117        let evidence = FundingEvidence {
118            tx_hash: response.transaction_hash,
119            signed_transaction: response.signed_transaction.clone(),
120        };
121        if self
122            .evidence
123            .as_ref()
124            .is_some_and(|original| original != &evidence)
125        {
126            return Err(TestkitError::InvalidFundingResponse {
127                response: Box::new(response.clone()),
128                retained: self.retained_evidence(),
129                detail: "local funding replaced retained signed evidence".into(),
130            });
131        }
132        self.admitted = Some(response.clone());
133        Ok(())
134    }
135
136    fn retain_error_evidence(&mut self, error: &NativeClientError) -> Result<()> {
137        use thruput_json_rpc_types::error::native::{DecodedError, ErrorData};
138        if let NativeClientError::Application { data, .. } = error
139            && let DecodedError::Known { data, .. } = data.as_ref()
140        {
141            let (tx_hash, signed_transaction) = match data.as_ref() {
142                ErrorData::LocalFundingUnresolved {
143                    transaction_hash,
144                    signed_transaction,
145                    ..
146                } => (*transaction_hash, signed_transaction.clone()),
147                ErrorData::FundingAttemptStopped(stopped) => {
148                    let (Some(hash), Some(bytes)) =
149                        (stopped.transaction_hash, &stopped.signed_transaction)
150                    else {
151                        return Ok(());
152                    };
153                    (hash, bytes.clone())
154                }
155                _ => return Ok(()),
156            };
157            let evidence = FundingEvidence {
158                tx_hash,
159                signed_transaction,
160            };
161            validate_funding_evidence(&evidence)?;
162            if self
163                .evidence
164                .as_ref()
165                .is_some_and(|original| original != &evidence)
166            {
167                return Err(TestkitError::signing(
168                    "local funding replaced retained signed evidence",
169                ));
170            }
171            self.evidence = Some(evidence);
172        }
173        Ok(())
174    }
175}
176
177struct NetworkInner {
178    rpc_url: Option<String>,
179    client: Option<NativeClient<HttpClient>>,
180    network_hash: Option<NetworkHash>,
181    faucet: tokio::sync::Mutex<Option<crate::manifest::TestAccount>>,
182    local_faucet: Option<ContractAddress>,
183    local_funding:
184        tokio::sync::Mutex<std::collections::HashMap<ContractAddress, LocalFundingAttempt>>,
185    manual_block_producer: Option<ManualBlockProducer>,
186}
187
188impl fmt::Debug for Network {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        f.debug_struct("Network")
191            .field("rpc_url", &self.inner.rpc_url)
192            .field("online", &self.inner.client.is_some())
193            .field(
194                "manual_block_production",
195                &self.inner.manual_block_producer.is_some(),
196            )
197            .finish_non_exhaustive()
198    }
199}
200
201impl Network {
202    /// Connect to a live target, require it to serve the manifest's network, and
203    /// retain its explicit bootstrap faucet adapter.
204    pub(crate) async fn connect(
205        rpc_url: &str,
206        manifest: &RuntimeManifest,
207        manual_block_producer: Option<ManualBlockProducer>,
208    ) -> Result<Self> {
209        let transport = HttpClientBuilder::default()
210            .build(rpc_url)
211            .map_err(|e| TestkitError::rpc("connect", e))?;
212        let client = NativeClient::new(transport);
213        thruput_client::check_endpoint(
214            &client,
215            &[
216                "getNetworkInfo",
217                "sendTransaction",
218                "getTransactionStatus",
219                "getTransactionReceipt",
220            ],
221        )
222        .await
223        .map_err(TestkitError::native)?;
224        thruput_client::validate_network(&client, &manifest.network_hash.to_network_hash())
225            .await
226            .map_err(|error| match error {
227                thruput_client::NativeRpcError::NetworkMismatch { .. } => TestkitError::Manifest {
228                    field: "networkHash",
229                    detail: format!("{error} at {rpc_url}"),
230                },
231                error => TestkitError::native(error),
232            })?;
233        Ok(Self {
234            inner: Arc::new(NetworkInner {
235                rpc_url: Some(rpc_url.to_owned()),
236                client: Some(client),
237                network_hash: Some(Base58(manifest.network_hash.to_network_hash().into_inner())),
238                faucet: tokio::sync::Mutex::new(crate::manifest::derive_faucet(manifest)?),
239                local_faucet: local_faucet(manifest)?,
240                local_funding: tokio::sync::Mutex::new(std::collections::HashMap::new()),
241                manual_block_producer,
242            }),
243        })
244    }
245
246    /// A handle with no live target; every chain verb reports [`TestkitError::Offline`].
247    pub(crate) fn offline() -> Self {
248        Self {
249            inner: Arc::new(NetworkInner {
250                rpc_url: None,
251                client: None,
252                network_hash: None,
253                faucet: tokio::sync::Mutex::new(None),
254                local_faucet: None,
255                local_funding: tokio::sync::Mutex::new(std::collections::HashMap::new()),
256                manual_block_producer: None,
257            }),
258        }
259    }
260
261    /// The public JSON-RPC URL, when the target has one.
262    pub fn rpc_url(&self) -> Option<&str> {
263        self.inner.rpc_url.as_deref()
264    }
265
266    /// The underlying typed JSON-RPC client, for methods this handle does not wrap.
267    pub fn client(&self) -> Option<&NativeClient<HttpClient>> {
268        self.inner.client.as_ref()
269    }
270
271    /// Current chain tip.
272    pub async fn chain_tip(&self) -> Result<ChainTip> {
273        known(
274            self.require_client("getChainTip")?
275                .get_chain_tip(EmptyRequest {})
276                .await,
277            "getChainTip",
278        )
279    }
280
281    /// Submit a pre-signed transaction once, verifying the returned identity.
282    pub async fn submit_raw(&self, tx_hex: String) -> Result<TxHash> {
283        let bytes = thruput_json_rpc_types::hex::decode_prefixed_hex(&tx_hex)
284            .map_err(TestkitError::signing)?;
285        let signed: thruput_xdr::transaction::SignedTransaction =
286            xdrs::txs::decode(&bytes).map_err(TestkitError::signing)?;
287        let hash = TxHash(Base58(
288            thruput_signing::transaction_hash(&signed.tx).map_err(TestkitError::signing)?,
289        ));
290        let canonical = thruput_json_rpc_types::hex::prefixed_hex(&bytes);
291        crate::account::submit_signed(self, hash, &canonical).await?;
292        Ok(hash)
293    }
294
295    /// Fetch a transaction receipt if it is already available.
296    pub async fn receipt(&self, tx_hash: TxHash) -> Result<Option<TransactionReceipt>> {
297        let receipt: Option<TransactionReceipt> = known(
298            self.require_client("getTransactionReceipt")?
299                .get_transaction_receipt(TransactionQuery {
300                    transaction_hash: tx_hash,
301                })
302                .await,
303            "getTransactionReceipt",
304        )?;
305        if let Some(observed) = &receipt
306            && observed.transaction_hash != tx_hash
307        {
308            return Err(TestkitError::ReceiptHashMismatch {
309                expected: tx_hash,
310                receipt: Box::new(observed.clone()),
311            });
312        }
313        if receipt
314            .as_ref()
315            .is_some_and(|r| r.transaction_hash == tx_hash)
316        {
317            self.inner.local_funding.lock().await.retain(|_, attempt| {
318                !attempt
319                    .admitted
320                    .as_ref()
321                    .is_some_and(|admitted| admitted.transaction_hash == tx_hash)
322                    && !attempt
323                        .evidence
324                        .as_ref()
325                        .is_some_and(|evidence| evidence.tx_hash == tx_hash)
326            });
327        }
328        Ok(receipt)
329    }
330
331    /// Read-only receipt wait. Errors retain commitment and advisory status evidence;
332    /// no status authorizes resubmission or proves that execution cannot occur.
333    pub async fn await_receipt(&self, tx_hash: TxHash) -> Result<TransactionReceipt> {
334        let client = self.require_client("getTransactionReceipt")?;
335        let network_hash = self.inner.network_hash.ok_or(TestkitError::Offline {
336            operation: "getTransactionReceipt",
337        })?;
338        let receipt = poll_receipt(
339            client,
340            network_hash,
341            tx_hash,
342            None,
343            ReceiptPollConfig {
344                timeout: RECEIPT_TIMEOUT,
345                interval: RECEIPT_POLL_INTERVAL,
346            },
347            std::future::pending(),
348        )
349        .await
350        .map_err(|source| TestkitError::ReceiptWait {
351            source: Box::new(source),
352        })?;
353        self.inner.local_funding.lock().await.retain(|_, attempt| {
354            !attempt
355                .admitted
356                .as_ref()
357                .is_some_and(|r| r.transaction_hash == tx_hash)
358                && !attempt
359                    .evidence
360                    .as_ref()
361                    .is_some_and(|r| r.tx_hash == tx_hash)
362        });
363        Ok(receipt)
364    }
365
366    /// Fetch a transaction's advisory status.
367    pub async fn transaction_status(&self, tx_hash: TxHash) -> Result<TransactionStatus> {
368        known(
369            self.require_client("getTransactionStatus")?
370                .get_transaction_status(TransactionQuery {
371                    transaction_hash: tx_hash,
372                })
373                .await,
374            "getTransactionStatus",
375        )
376    }
377
378    /// Wait until the scheduler reports a submitted transaction as pending.
379    ///
380    /// A successful submit RPC confirms admission, not nomination. Manual
381    /// block tests should await this barrier before requesting nomination.
382    /// If the transaction commits before `Pending` is observed, this returns
383    /// [`TestkitError::TransactionNotPending`] because the requested nomination
384    /// boundary has already passed.
385    pub async fn await_pending(&self, tx_hash: TxHash) -> Result<()> {
386        self.await_pending_inner(tx_hash).await
387    }
388
389    async fn await_pending_inner(&self, tx_hash: TxHash) -> Result<()> {
390        let mut last_status = TransactionStatus::Unknown {};
391        for _ in 0..PENDING_POLL_ATTEMPTS {
392            let status = self.transaction_status(tx_hash).await?;
393            match classify_pending_status(&status) {
394                PendingStatusClassification::Ready => return Ok(()),
395                PendingStatusClassification::Retry => {
396                    last_status = status;
397                    tokio::time::sleep(PENDING_POLL_INTERVAL).await;
398                }
399                PendingStatusClassification::Terminal => {
400                    return Err(TestkitError::TransactionNotPending {
401                        tx_hash: format!("{tx_hash:?}"),
402                        status: Box::new(status),
403                    });
404                }
405            }
406        }
407        Err(TestkitError::PendingTimeout {
408            tx_hash: format!("{tx_hash:?}"),
409            last_status: Box::new(last_status),
410        })
411    }
412
413    /// Produce one block through a harness-owned local node in manual mode.
414    pub async fn produce_block(&self) -> Result<ProducedBlock> {
415        self.inner
416            .manual_block_producer
417            .as_ref()
418            .ok_or(TestkitError::ManualBlockProductionUnavailable)?
419            .produce_block()
420            .await
421            .map_err(|source| TestkitError::BlockProduction { source })
422    }
423
424    /// Read one latest-committed storage item at `address`/`key`.
425    pub async fn get_storage_at(
426        &self,
427        address: ContractAddress,
428        key: &[u8],
429    ) -> Result<StorageResult> {
430        known(
431            self.require_client("getStorage")?
432                .get_storage(requests::StorageQuery {
433                    address: Base58(address),
434                    key: HexBytes(key.to_vec()),
435                })
436                .await,
437            "getStorage",
438        )
439    }
440
441    /// Simulate a contract call through `simulateInvocation`.
442    ///
443    /// `caller` sets the simulated `msg.sender`; pass an agent's own address to
444    /// read state keyed by that caller.
445    pub async fn view_call(
446        &self,
447        contract: ContractAddress,
448        entrypoint: ContractEntrypointID,
449        calldata: &[u8],
450        caller: Option<ContractAddress>,
451    ) -> Result<SimulationResult> {
452        let request = requests::SimulateInvocationRequest {
453            assumed_auth: None,
454            min_state_block_height: None,
455            address: Base58(contract),
456            entrypoint: HexBytes(entrypoint.into_inner().to_vec()),
457            calldata: HexBytes(calldata.to_vec()),
458            caller: caller.map(Base58),
459            gas_limit: None,
460        };
461        known(
462            self.require_client("simulateInvocation")?
463                .simulate_invocation(request)
464                .await,
465            "simulateInvocation",
466        )
467    }
468
469    /// Query events using native opaque pagination cursors.
470    pub async fn get_events(&self, query: EventQuery) -> Result<EventPage> {
471        known(
472            self.require_client("getEvents")?.get_events(query).await,
473            "getEvents",
474        )
475    }
476
477    pub(crate) async fn submit_fund(
478        &self,
479        recipient: ContractAddress,
480        amount: u128,
481    ) -> Result<SubmittedTransaction> {
482        self.require_client("fund")?;
483        if let Some(faucet) = self.inner.local_faucet {
484            return self.submit_local_fund(recipient, amount, faucet).await;
485        }
486        let calldata = thruput_signing::native_token_transfer_calldata(&recipient, amount)
487            .map_err(TestkitError::signing)?;
488        let faucet = self.inner.faucet.lock().await;
489        let faucet = faucet.as_ref().ok_or(TestkitError::MissingFaucet)?;
490        // The bootstrap faucet is shared across scenarios and processes. Its
491        // funding transactions must not replay a deterministic scenario nonce.
492        let mut nonce = [0; 8];
493        getrandom::fill(&mut nonce).map_err(|error| {
494            TestkitError::signing(format!("generate faucet transaction nonce: {error}"))
495        })?;
496        let (tx_hash, tx_hex) = crate::account::prepare_call(
497            faucet,
498            thruput_xdr::genesis_addresses::ADDRESS_NATIVE_TOKEN,
499            thruput_signing::NATIVE_TOKEN_TRANSFER_ENTRYPOINT,
500            &calldata,
501            u64::from_le_bytes(nonce),
502        )?;
503        crate::account::submit_signed(self, tx_hash, &tx_hex).await?;
504        Ok(SubmittedTransaction::new(self.clone(), tx_hash, tx_hex))
505    }
506
507    // Pending intent is retained on this Network handle before sending. Callers
508    // retry on the same handle after uncertainty. Admission and signed bytes are
509    // retained until an authoritative receipt, including cancellation of fund().
510    async fn submit_local_fund(
511        &self,
512        recipient: ContractAddress,
513        amount: u128,
514        faucet: ContractAddress,
515    ) -> Result<SubmittedTransaction> {
516        use thruput_json_rpc_types::{airdrop::ClientNonce, requests::FundAccountRequest};
517        if amount == 0 {
518            return Err(TestkitError::signing("funding amount must be positive"));
519        }
520        let mut pending = self.inner.local_funding.lock().await;
521        if let Some(request) = pending.get(&recipient) {
522            if request.request.raw_amount != amount {
523                return Err(TestkitError::FundingRequestMismatch { recipient });
524            }
525        } else {
526            let mut nonce = [0; 16];
527            getrandom::fill(&mut nonce).map_err(TestkitError::signing)?;
528            pending.insert(
529                recipient,
530                LocalFundingAttempt {
531                    request: FundAccountRequest {
532                        recipient: Base58(recipient),
533                        raw_amount: amount,
534                        faucet: Base58(faucet),
535                        client_nonce: ClientNonce::new(nonce.to_vec())
536                            .map_err(TestkitError::signing)?,
537                    },
538                    admitted: None,
539                    evidence: None,
540                },
541            );
542        }
543        let attempt = pending
544            .get(&recipient)
545            .expect("bug: retained funding intent");
546        let request = attempt.request.clone();
547        let response = if let Some(response) = &attempt.admitted {
548            response.clone()
549        } else {
550            match self
551                .require_client("fund")?
552                .fund_account(request.clone())
553                .await
554            {
555                Ok(response) => known(Ok(response), "dev_fundAccount")?,
556                Err(error) => {
557                    let attempt = pending
558                        .get_mut(&recipient)
559                        .expect("bug: retained funding intent");
560                    if let Err(invalid) = attempt.retain_error_evidence(&error) {
561                        return Err(TestkitError::InvalidFundingEvidence {
562                            detail: invalid.to_string(),
563                            retained: attempt.retained_evidence(),
564                            source: Box::new(error),
565                        });
566                    }
567                    return Err(match &attempt.evidence {
568                        Some(evidence) => TestkitError::Submission {
569                            tx_hash: evidence.tx_hash,
570                            signed_transaction: thruput_json_rpc_types::hex::prefixed_hex(
571                                &evidence.signed_transaction.0,
572                            ),
573                            detail: error.to_string(),
574                            source: Box::new(error),
575                        },
576                        None => TestkitError::NativeCall {
577                            method: "dev_fundAccount",
578                            source: Box::new(error),
579                        },
580                    });
581                }
582            }
583        };
584        pending
585            .get_mut(&recipient)
586            .expect("bug: retained funding intent")
587            .accept_response(&response)?;
588        Ok(SubmittedTransaction::new(
589            self.clone(),
590            response.transaction_hash,
591            thruput_json_rpc_types::hex::prefixed_hex(&response.signed_transaction.0),
592        ))
593    }
594
595    pub(crate) fn require_client(
596        &self,
597        operation: &'static str,
598    ) -> Result<&NativeClient<HttpClient>> {
599        self.inner
600            .client
601            .as_ref()
602            .ok_or(TestkitError::Offline { operation })
603    }
604}
605
606fn known<T>(
607    result: std::result::Result<DecodedResponse<T>, NativeClientError>,
608    method: &'static str,
609) -> Result<T> {
610    match result {
611        Ok(DecodedResponse::Known(value)) => Ok(value),
612        Ok(DecodedResponse::Unsupported { raw }) => {
613            Err(TestkitError::UnsupportedResponse { method, raw })
614        }
615        Ok(_) => Err(TestkitError::rpc(
616            method,
617            "unsupported response representation",
618        )),
619        Err(source) => Err(TestkitError::NativeCall {
620            method,
621            source: Box::new(source),
622        }),
623    }
624}
625
626fn validate_funding_response(
627    response: &thruput_json_rpc_types::responses::FundingSubmission,
628    request: &requests::FundAccountRequest,
629) -> Result<()> {
630    let signed: thruput_xdr::transaction::SignedTransaction =
631        xdrs::txs::decode(&response.signed_transaction.0).map_err(TestkitError::signing)?;
632    let hash = thruput_signing::transaction_hash(&signed.tx).map_err(TestkitError::signing)?;
633    if response.recipient != request.recipient
634        || response.raw_amount != request.raw_amount
635        || response.transaction_hash.0.0 != hash
636    {
637        return Err(TestkitError::signing(
638            "local funding response does not match retained request and signed hash",
639        ));
640    }
641    Ok(())
642}
643
644fn validate_funding_evidence(evidence: &FundingEvidence) -> Result<()> {
645    let signed: thruput_xdr::transaction::SignedTransaction =
646        xdrs::txs::decode(&evidence.signed_transaction.0).map_err(TestkitError::signing)?;
647    let hash = thruput_signing::transaction_hash(&signed.tx).map_err(TestkitError::signing)?;
648    if evidence.tx_hash.0.0 != hash {
649        return Err(TestkitError::signing(
650            "local funding evidence hash does not match signed bytes",
651        ));
652    }
653    Ok(())
654}
655
656fn local_faucet(manifest: &RuntimeManifest) -> Result<Option<ContractAddress>> {
657    use thruput_json_rpc_types::runtime_manifest::{
658        BOOTSTRAP_ACCOUNT_ROLE_FAUCET, HOGD_DEV_RUNTIME_MANIFEST_KIND,
659    };
660    if manifest.kind != HOGD_DEV_RUNTIME_MANIFEST_KIND {
661        return Ok(None);
662    }
663    let mut faucets = manifest
664        .bootstrap_accounts
665        .iter()
666        .filter(|a| a.role == BOOTSTRAP_ACCOUNT_ROLE_FAUCET);
667    let faucet = faucets.next().ok_or(TestkitError::MissingFaucet)?;
668    if faucets.next().is_some()
669        || thruput_signing::PubkeyAccount::from_public_key_bytes(*faucet.public_key.0, 0).address()
670            != faucet.wallet_address.0
671    {
672        return Err(TestkitError::Manifest {
673            field: "bootstrapAccounts",
674            detail: "local faucet must uniquely match its public nonce-zero account".into(),
675        });
676    }
677    Ok(Some(faucet.wallet_address.0))
678}
679
680#[derive(Debug, Clone, Copy, PartialEq, Eq)]
681enum PendingStatusClassification {
682    Ready,
683    Retry,
684    Terminal,
685}
686
687fn classify_pending_status(status: &TransactionStatus) -> PendingStatusClassification {
688    match status {
689        TransactionStatus::Pending { .. } => PendingStatusClassification::Ready,
690        TransactionStatus::Unknown {} => PendingStatusClassification::Retry,
691        _ => PendingStatusClassification::Terminal,
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use thruput_json_rpc_types::responses::TransactionStatus;
698    use thruput_node_supervisor::BlockDriverMode;
699
700    use super::{
701        LocalNetworkConfig, Network, PendingStatusClassification, classify_pending_status,
702    };
703    use crate::TestkitError;
704
705    #[test]
706    fn local_network_keeps_instant_block_production_by_default() {
707        assert_eq!(
708            LocalNetworkConfig::default().block_driver.mode,
709            BlockDriverMode::Instant
710        );
711    }
712
713    #[test]
714    fn pending_status_classification_never_resubmits() {
715        use thruput_json_rpc_types::responses::{SchedulerError, SchedulerErrorSource};
716        assert_eq!(
717            classify_pending_status(&TransactionStatus::Pending {
718                recent_failure: None
719            }),
720            PendingStatusClassification::Ready
721        );
722        assert_eq!(
723            classify_pending_status(&TransactionStatus::Unknown {}),
724            PendingStatusClassification::Retry
725        );
726        for status in [
727            TransactionStatus::Committed {
728                block_height: 2.into(),
729            },
730            TransactionStatus::Retry {
731                recent_failure: None,
732                error: SchedulerError {
733                    source: SchedulerErrorSource::Executor,
734                    code: 7,
735                },
736            },
737            TransactionStatus::Dropped {
738                recent_failure: None,
739                error: SchedulerError {
740                    source: SchedulerErrorSource::Executor,
741                    code: 7,
742                },
743            },
744        ] {
745            assert_eq!(
746                classify_pending_status(&status),
747                PendingStatusClassification::Terminal
748            );
749        }
750    }
751
752    #[tokio::test]
753    async fn offline_network_rejects_manual_block_production() {
754        let error = Network::offline()
755            .produce_block()
756            .await
757            .expect_err("offline network must not expose manual production");
758        assert!(matches!(
759            error,
760            TestkitError::ManualBlockProductionUnavailable
761        ));
762    }
763}
764
765/// Which network a scenario runs against, and how to reach it.
766#[derive(Debug, Clone)]
767#[non_exhaustive]
768pub enum NetworkConfig {
769    /// An already-running network reached over its public JSON-RPC URL.
770    Existing {
771        /// Public JSON-RPC URL.
772        rpc_url: String,
773        /// Path to the network's runtime manifest.
774        runtime_manifest: PathBuf,
775    },
776    /// A local node the harness launches and owns.
777    Local(LocalNetworkConfig),
778    /// No live network: agents run with locally-derived accounts and no chain.
779    ///
780    /// Useful for exercising agent coordination, timing, and messaging logic
781    /// without a node. Chain verbs return [`crate::TestkitError::Offline`].
782    Offline {
783        /// Number of empty native accounts to make available to agents.
784        accounts: usize,
785    },
786}
787
788impl NetworkConfig {
789    /// Configure an already-running (`existing`) network.
790    pub fn existing(rpc_url: impl Into<String>, runtime_manifest: impl Into<PathBuf>) -> Self {
791        Self::Existing {
792            rpc_url: rpc_url.into(),
793            runtime_manifest: runtime_manifest.into(),
794        }
795    }
796
797    /// Configure a harness-managed (`local`) network.
798    pub fn local(config: LocalNetworkConfig) -> Self {
799        Self::Local(config)
800    }
801
802    /// Configure an offline network with `accounts` locally-derived accounts.
803    pub fn offline(accounts: usize) -> Self {
804        Self::Offline { accounts }
805    }
806}
807
808/// Settings for a harness-managed local node.
809///
810/// The harness binds the public JSON-RPC facade on an ephemeral port and keeps
811/// no PID file, so many local networks can run in one process without
812/// colliding.
813#[derive(Debug, Clone)]
814#[non_exhaustive]
815pub struct LocalNetworkConfig {
816    /// Explicit `hog-devnode` binary path. Falls back to discovery when unset
817    /// (the `THRUPUT_DEVNODE_BINARY` env var, then a sibling of the current
818    /// executable, then `PATH`).
819    pub hog_devnode_binary: Option<PathBuf>,
820    /// Artifact directories searched before the selected devnode's defaults.
821    pub contract_artifact_directories: Vec<PathBuf>,
822    /// Requested public JSON-RPC port; `0` asks the OS to choose.
823    pub rpc_port: u16,
824    /// Optional protocol token override for `hog-devnode` intro handshakes.
825    ///
826    /// Use when a local `hog-devnode` build is ahead of the protocol compiled
827    /// into the supervisor; it only overrides the intro token.
828    pub hogd_protocol: Option<String>,
829    /// Devnode log verbosity. When set, `hog-devnode` is started at this level
830    /// and testkit installs a stderr `tracing` subscriber (via `try_init`,
831    /// honoring `RUST_LOG`) so the forwarded logs print. Leave `None` for a
832    /// quiet node; call [`crate::init_tracing`] directly for finer control.
833    pub log_level: Option<LogLevel>,
834    /// Initial raw native token allocation per scenario-owned nonce-zero fixture account.
835    pub initial_account_tokens: u64,
836    /// Maximum time to wait for the node to report readiness.
837    pub startup_timeout: Duration,
838    /// Local block production policy. Defaults to submit-triggered production.
839    pub block_driver: BlockDriverConfig,
840}
841
842impl Default for LocalNetworkConfig {
843    fn default() -> Self {
844        Self {
845            hog_devnode_binary: None,
846            contract_artifact_directories: Vec::new(),
847            rpc_port: 0,
848            hogd_protocol: None,
849            log_level: None,
850            initial_account_tokens: thruput_node_supervisor::config::DEFAULT_LOCAL_ACCOUNT_TOKENS,
851            startup_timeout: Duration::from_secs(10),
852            block_driver: BlockDriverConfig::default(),
853        }
854    }
855}