Skip to main content

nautilus_blockchain/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{any::Any, fmt::Debug};
17
18use nautilus_common::factories::ClientConfig;
19use nautilus_core::string::secret::REDACTED;
20use nautilus_infrastructure::sql::pg::PostgresConnectOptions;
21use nautilus_model::{
22    defi::{Chain, DexType, SharedChain},
23    identifiers::AccountId,
24};
25use nautilus_network::websocket::TransportBackend;
26use serde::{Deserialize, Serialize};
27
28/// Defines filtering criteria for the DEX pool universe that the data client will operate on.
29#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
30#[serde(default, deny_unknown_fields)]
31#[cfg_attr(
32    feature = "python",
33    pyo3::pyclass(module = "nautilus_trader.adapters.blockchain", from_py_object)
34)]
35#[cfg_attr(
36    feature = "python",
37    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.blockchain")
38)]
39pub struct DexPoolFilters {
40    /// Whether to exclude pools containing tokens with empty name or symbol fields.
41    #[builder(default = true)]
42    pub remove_pools_with_empty_erc20fields: bool,
43}
44
45impl Default for DexPoolFilters {
46    fn default() -> Self {
47        Self::builder().build()
48    }
49}
50
51/// Configuration for blockchain data clients.
52#[derive(Clone, Serialize, Deserialize, bon::Builder)]
53#[serde(deny_unknown_fields)]
54#[cfg_attr(
55    feature = "python",
56    pyo3::pyclass(module = "nautilus_trader.adapters.blockchain", from_py_object)
57)]
58#[cfg_attr(
59    feature = "python",
60    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.blockchain")
61)]
62pub struct BlockchainDataClientConfig {
63    /// The blockchain chain configuration.
64    pub chain: SharedChain,
65    /// List of decentralized exchange IDs to register and sync during connection.
66    #[builder(default)]
67    #[serde(default)]
68    pub dex_ids: Vec<DexType>,
69    /// Determines if the client should use Hypersync for live data streaming.
70    #[builder(default)]
71    #[serde(default)]
72    pub use_hypersync_for_live_data: bool,
73    /// The HTTP URL for the blockchain RPC endpoint.
74    pub http_rpc_url: String,
75    /// The maximum number of RPC requests allowed per second.
76    pub rpc_requests_per_second: Option<u32>,
77    /// The maximum number of Multicall calls per one RPC request.
78    #[builder(default = 200)]
79    #[serde(default = "default_multicall_calls_per_rpc_request")]
80    pub multicall_calls_per_rpc_request: u32,
81    /// The WebSocket secure URL for the blockchain RPC endpoint.
82    pub wss_rpc_url: Option<String>,
83    /// Optional proxy URL for HTTP and WebSocket transports.
84    pub proxy_url: Option<String>,
85    /// The block from which to sync historical data.
86    pub from_block: Option<u64>,
87    /// Filtering criteria that define which DEX pools to include in the data universe.
88    #[builder(default)]
89    #[serde(default)]
90    pub pool_filters: DexPoolFilters,
91    /// Optional configuration for data client's Postgres cache database
92    pub postgres_cache_database_config: Option<PostgresConnectOptions>,
93    /// WebSocket transport backend (defaults to `Sockudo`).
94    #[builder(default)]
95    #[serde(default)]
96    pub transport_backend: TransportBackend,
97}
98
99impl Debug for BlockchainDataClientConfig {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct(stringify!(BlockchainDataClientConfig))
102            .field("chain", &self.chain)
103            .field("dex_ids", &self.dex_ids)
104            .field(
105                "use_hypersync_for_live_data",
106                &self.use_hypersync_for_live_data,
107            )
108            .field("http_rpc_url", &REDACTED)
109            .field("rpc_requests_per_second", &self.rpc_requests_per_second)
110            .field(
111                "multicall_calls_per_rpc_request",
112                &self.multicall_calls_per_rpc_request,
113            )
114            .field("wss_rpc_url", &self.wss_rpc_url.as_ref().map(|_| REDACTED))
115            .field("proxy_url", &self.proxy_url.as_ref().map(|_| REDACTED))
116            .field("from_block", &self.from_block)
117            .field("pool_filters", &self.pool_filters)
118            .field(
119                "postgres_cache_database_config",
120                &self.postgres_cache_database_config,
121            )
122            .field("transport_backend", &self.transport_backend)
123            .finish()
124    }
125}
126
127#[cfg(feature = "python")]
128nautilus_core::impl_pyo3_config_getters!(BlockchainDataClientConfig {
129    dex_ids: Vec<DexType>,
130    multicall_calls_per_rpc_request: u32,
131    pool_filters: DexPoolFilters,
132    transport_backend: TransportBackend,
133});
134
135const fn default_multicall_calls_per_rpc_request() -> u32 {
136    200
137}
138
139/// Stable local identity for one RPC provider and its failure domains.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
141#[serde(deny_unknown_fields)]
142#[cfg_attr(
143    feature = "python",
144    pyo3::pyclass(module = "nautilus_trader.adapters.blockchain", from_py_object)
145)]
146#[cfg_attr(
147    feature = "python",
148    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.blockchain")
149)]
150pub struct BlockchainProviderIdentity {
151    /// Stable local provider identifier.
152    pub provider_id: String,
153    /// Stable local operator identifier.
154    pub operator_id: String,
155    /// Opaque identifiers for every known shared infrastructure failure domain.
156    pub failure_domain_ids: Vec<String>,
157}
158
159#[cfg(feature = "python")]
160nautilus_core::impl_pyo3_config_getters!(BlockchainProviderIdentity {
161    failure_domain_ids: Vec<String>,
162    operator_id: String,
163    provider_id: String,
164});
165
166/// Configuration for one read-only verification RPC provider.
167#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
168#[serde(deny_unknown_fields)]
169#[cfg_attr(
170    feature = "python",
171    pyo3::pyclass(module = "nautilus_trader.adapters.blockchain", from_py_object)
172)]
173#[cfg_attr(
174    feature = "python",
175    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.blockchain")
176)]
177pub struct BlockchainVerificationProviderConfig {
178    /// Stable provider and failure-domain identity.
179    pub identity: BlockchainProviderIdentity,
180    /// The read-only JSON-RPC endpoint.
181    pub http_rpc_url: String,
182}
183
184impl Debug for BlockchainVerificationProviderConfig {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.debug_struct(stringify!(BlockchainVerificationProviderConfig))
187            .field("identity", &self.identity)
188            .field("http_rpc_url", &REDACTED)
189            .finish()
190    }
191}
192
193#[cfg(feature = "python")]
194nautilus_core::impl_pyo3_config_getters!(BlockchainVerificationProviderConfig {
195    http_rpc_url: String,
196    identity: BlockchainProviderIdentity,
197});
198
199/// Locally trusted finalized chain checkpoint and freshness policy.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
201#[serde(deny_unknown_fields)]
202#[cfg_attr(
203    feature = "python",
204    pyo3::pyclass(module = "nautilus_trader.adapters.blockchain", from_py_object)
205)]
206#[cfg_attr(
207    feature = "python",
208    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.blockchain")
209)]
210pub struct BlockchainChainAnchorConfig {
211    /// Chain ID obtained independently from the configured providers.
212    pub chain_id: u32,
213    /// Chain name obtained independently from the configured providers.
214    pub chain_name: String,
215    /// Finalized checkpoint height.
216    pub checkpoint_height: u64,
217    /// Finalized checkpoint hash as a 32-byte hexadecimal string.
218    pub checkpoint_hash: String,
219    /// Finalized checkpoint timestamp in Unix seconds.
220    pub checkpoint_timestamp: u64,
221    /// Maximum permitted height difference among provider heads.
222    pub max_head_skew_blocks: u64,
223    /// Maximum permitted age of a decision head in seconds.
224    pub max_head_age_secs: u64,
225    /// Maximum permitted future drift of a decision head in seconds.
226    pub max_future_drift_secs: u64,
227}
228
229/// A role assigned to one reviewed deployment contract.
230#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum BlockchainContractRole {
233    Router,
234    Factory,
235    WrappedNative,
236    Quote,
237    Token,
238    Pool,
239    Implementation,
240}
241
242/// A reviewed explicit-height call used to prove a contract relationship.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(deny_unknown_fields)]
245pub struct BlockchainContractProbe {
246    /// ABI-encoded call data.
247    pub call_data: String,
248    /// Exact expected ABI-encoded output.
249    pub expected_output: String,
250}
251
252/// A reviewed proxy implementation binding.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct BlockchainProxyManifest {
256    /// Reviewed proxy kind: EIP-1967 implementation or Zeppelinos implementation.
257    pub kind: String,
258    /// Storage slot containing the implementation address.
259    pub storage_slot: String,
260    /// Exact expected 32-byte storage value.
261    pub storage_value: String,
262    /// Selected implementation address.
263    pub target_address: String,
264    /// Runtime code hash of the selected target.
265    pub target_code_hash: String,
266}
267
268/// One code-bearing contract pinned by the deployment manifest.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct BlockchainContractManifest {
272    /// Contract address.
273    pub address: String,
274    /// Contract role.
275    pub role: BlockchainContractRole,
276    /// Keccak-256 runtime code hash.
277    pub runtime_code_hash: String,
278    /// Proxy implementation binding when the contract is upgradeable.
279    pub proxy: Option<BlockchainProxyManifest>,
280    /// Role-specific identity probes.
281    #[serde(default)]
282    pub probes: Vec<BlockchainContractProbe>,
283}
284
285/// Locally reviewed token identity and asset orientation.
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[serde(deny_unknown_fields)]
288pub struct BlockchainTokenManifest {
289    pub address: String,
290    pub name: String,
291    pub symbol: String,
292    pub decimals: u8,
293    /// `base`, `quote`, or `both` for the supported pool set.
294    pub asset_role: String,
295}
296
297/// One supported pool definition.
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct BlockchainPoolManifest {
301    pub address: String,
302    pub token0: String,
303    pub token1: String,
304    pub fee: u32,
305    pub factory: String,
306    pub quote_contract: String,
307}
308
309/// One permitted internal call edge for a transaction purpose.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct BlockchainCallEdgeManifest {
313    /// `wrap`, `approve`, `swap_sell`, or `swap_buy`.
314    pub purpose: String,
315    pub caller: String,
316    pub target: String,
317    /// `call`, `staticcall`, `delegatecall`, or `callcode`.
318    pub call_type: String,
319}
320
321/// Reviewed deployment and call-graph manifest for one chain.
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(deny_unknown_fields)]
324pub struct BlockchainDeploymentManifest {
325    pub version: String,
326    pub chain_id: u32,
327    pub chain_name: String,
328    pub contracts: Vec<BlockchainContractManifest>,
329    pub tokens: Vec<BlockchainTokenManifest>,
330    pub pools: Vec<BlockchainPoolManifest>,
331    pub call_edges: Vec<BlockchainCallEdgeManifest>,
332}
333
334#[cfg(feature = "python")]
335nautilus_core::impl_pyo3_config_getters!(BlockchainChainAnchorConfig {
336    chain_id: u32,
337    chain_name: String,
338    checkpoint_hash: String,
339    checkpoint_height: u64,
340    checkpoint_timestamp: u64,
341    max_future_drift_secs: u64,
342    max_head_age_secs: u64,
343    max_head_skew_blocks: u64,
344});
345
346/// Independent verification topology and reviewed local deployment identity.
347#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
348#[serde(deny_unknown_fields)]
349#[cfg_attr(
350    feature = "python",
351    pyo3::pyclass(module = "nautilus_trader.adapters.blockchain", from_py_object)
352)]
353#[cfg_attr(
354    feature = "python",
355    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.blockchain")
356)]
357pub struct BlockchainVerificationConfig {
358    /// Identity of the authoritative provider in `http_rpc_url`.
359    pub authoritative: BlockchainProviderIdentity,
360    /// Exactly two read-only providers.
361    pub verifiers: Vec<BlockchainVerificationProviderConfig>,
362    /// Locally trusted chain checkpoint and freshness policy.
363    pub chain_anchor: BlockchainChainAnchorConfig,
364    /// Reviewed deployment manifest version.
365    pub manifest_version: String,
366    /// Digest of the canonical reviewed deployment manifest.
367    pub manifest_digest: String,
368    /// Reviewed deployment identities and permitted call graph.
369    pub deployment_manifest: BlockchainDeploymentManifest,
370}
371
372impl Debug for BlockchainVerificationConfig {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        f.debug_struct(stringify!(BlockchainVerificationConfig))
375            .field("authoritative", &self.authoritative)
376            .field("verifiers", &self.verifiers)
377            .field("chain_anchor", &self.chain_anchor)
378            .field("manifest_version", &self.manifest_version)
379            .field("manifest_digest", &self.manifest_digest)
380            .field("deployment_manifest", &REDACTED)
381            .finish()
382    }
383}
384
385#[cfg(feature = "python")]
386nautilus_core::impl_pyo3_config_getters!(BlockchainVerificationConfig {
387    authoritative: BlockchainProviderIdentity,
388    chain_anchor: BlockchainChainAnchorConfig,
389    manifest_digest: String,
390    manifest_version: String,
391    verifiers: Vec<BlockchainVerificationProviderConfig>,
392});
393
394/// Defines the maximum quote-token spend for a directed BUY swap pair.
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
396#[serde(deny_unknown_fields)]
397#[cfg_attr(
398    feature = "python",
399    pyo3::pyclass(module = "nautilus_trader.adapters.blockchain", from_py_object)
400)]
401#[cfg_attr(
402    feature = "python",
403    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.blockchain")
404)]
405pub struct QuoteSpendLimit {
406    /// The swap input-token address used as the directed pair key.
407    pub token_in: String,
408    /// The swap output-token address used as the directed pair key.
409    pub token_out: String,
410    /// The token address that denominates `max_amount`.
411    pub spend_token: String,
412    /// The decimals of `spend_token` used to interpret its raw units.
413    pub spend_token_decimals: u8,
414    /// The maximum raw input amount as a base-10 unsigned integer string.
415    pub max_amount: String,
416}
417
418#[cfg(feature = "python")]
419nautilus_core::impl_pyo3_config_getters!(QuoteSpendLimit {
420    max_amount: String,
421    spend_token: String,
422    spend_token_decimals: u8,
423    token_in: String,
424    token_out: String,
425});
426
427#[derive(Clone, Serialize, Deserialize, bon::Builder)]
428#[serde(deny_unknown_fields)]
429#[cfg_attr(
430    feature = "python",
431    pyo3::pyclass(module = "nautilus_trader.adapters.blockchain", from_py_object)
432)]
433#[cfg_attr(
434    feature = "python",
435    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.blockchain")
436)]
437pub struct BlockchainExecutionClientConfig {
438    /// The account ID for the client.
439    pub client_id: AccountId,
440    /// The blockchain chain configuration.
441    pub chain: Chain,
442    /// The wallet address of the execution client.
443    pub wallet_address: String,
444    /// Token universe: set of ERC-20 token addresses to monitor for balance tracking.
445    pub tokens: Option<Vec<String>>,
446    /// The HTTP URL for the blockchain RPC endpoint.
447    pub http_rpc_url: String,
448    /// Independent provider topology, chain checkpoint, and deployment manifest identity.
449    #[serde(default)]
450    pub verification: Option<BlockchainVerificationConfig>,
451    /// The maximum number of RPC requests allowed per second.
452    pub rpc_requests_per_second: Option<u32>,
453    /// Name of the environment variable holding the signer private key.
454    pub signer_private_key_env: String,
455    /// Name of the environment variable holding the active transaction payload sealing key.
456    #[serde(default)]
457    pub payload_key_env: Option<String>,
458    /// Names of environment variables holding retired payload keys used only for unsealing.
459    #[builder(default)]
460    #[serde(default)]
461    pub payload_key_retired_env: Vec<String>,
462    /// Stable identifier bound to this execution database's sealed payloads.
463    #[serde(default)]
464    pub payload_deployment_id: Option<String>,
465    /// Allowed SwapRouter addresses for approval and swap transactions.
466    pub router_addresses: Vec<String>,
467    /// Wrapped native token address for wrap operations.
468    pub weth_address: String,
469    /// Whether to approve routers with an unlimited allowance instead of the exact amount.
470    #[builder(default)]
471    #[serde(default)]
472    pub unlimited_approval: bool,
473    /// Hard ceiling for the derived max fee per gas in wei; conditions above it reject the
474    /// transaction.
475    pub max_fee_per_gas_wei: u64,
476    /// Buffer in basis points applied over the latest base fee.
477    pub base_fee_buffer_bps: u32,
478    /// Gas ceiling in units; buffered estimates above it reject the transaction before signing
479    /// (never clamp).
480    pub gas_limit: u64,
481    /// Buffer in basis points applied over the `eth_estimateGas` result.
482    pub gas_buffer_bps: u32,
483    /// Allowed (input token, output token) address pairs for swaps.
484    pub allowed_token_pairs: Option<Vec<(String, String)>>,
485    /// Pair-specific maximum quote-token spends for BUY swaps.
486    pub quote_spend_limits: Option<Vec<QuoteSpendLimit>>,
487    /// Default slippage in basis points applied to derive the swap minimum output.
488    pub slippage_bps: Option<u32>,
489    /// Maximum slippage in basis points accepted from a per-order parameter override.
490    pub max_slippage_bps: Option<u32>,
491    /// Per-order ceiling for the submitted base quantity, in raw units of the pool's base token.
492    pub max_order_amount: Option<u64>,
493    /// Swap deadline offset in seconds from the latest block timestamp.
494    pub deadline_seconds: Option<u64>,
495    /// Maximum age of the local pool state in blocks for a quote to be usable.
496    pub max_quote_age_blocks: Option<u64>,
497    /// Inclusion timeout in seconds before a broadcast transaction is treated as dropped.
498    pub receipt_timeout_secs: Option<u64>,
499    /// Durable store for execution transaction records; the client refuses to submit any
500    /// transaction without it.
501    pub postgres_cache_database_config: Option<PostgresConnectOptions>,
502    /// WebSocket transport backend (defaults to `Sockudo`).
503    #[builder(default)]
504    #[serde(default)]
505    pub transport_backend: TransportBackend,
506}
507
508impl Debug for BlockchainExecutionClientConfig {
509    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510        f.debug_struct(stringify!(BlockchainExecutionClientConfig))
511            .field("client_id", &self.client_id)
512            .field("chain", &self.chain)
513            .field("wallet_address", &self.wallet_address)
514            .field("tokens", &self.tokens)
515            .field("http_rpc_url", &REDACTED)
516            .field("verification", &self.verification)
517            .field("rpc_requests_per_second", &self.rpc_requests_per_second)
518            .field("signer_private_key_env", &self.signer_private_key_env)
519            .field("payload_key_env", &self.payload_key_env)
520            .field("payload_key_retired_env", &self.payload_key_retired_env)
521            .field("payload_deployment_id", &self.payload_deployment_id)
522            .field("router_addresses", &self.router_addresses)
523            .field("weth_address", &self.weth_address)
524            .field("unlimited_approval", &self.unlimited_approval)
525            .field("max_fee_per_gas_wei", &self.max_fee_per_gas_wei)
526            .field("base_fee_buffer_bps", &self.base_fee_buffer_bps)
527            .field("gas_limit", &self.gas_limit)
528            .field("gas_buffer_bps", &self.gas_buffer_bps)
529            .field("allowed_token_pairs", &self.allowed_token_pairs)
530            .field("quote_spend_limits", &self.quote_spend_limits)
531            .field("slippage_bps", &self.slippage_bps)
532            .field("max_slippage_bps", &self.max_slippage_bps)
533            .field("max_order_amount", &self.max_order_amount)
534            .field("deadline_seconds", &self.deadline_seconds)
535            .field("max_quote_age_blocks", &self.max_quote_age_blocks)
536            .field("receipt_timeout_secs", &self.receipt_timeout_secs)
537            .field(
538                "postgres_cache_database_config",
539                &self.postgres_cache_database_config,
540            )
541            .field("transport_backend", &self.transport_backend)
542            .finish()
543    }
544}
545
546impl ClientConfig for BlockchainExecutionClientConfig {
547    fn as_any(&self) -> &dyn Any {
548        self
549    }
550}
551
552#[cfg(feature = "python")]
553nautilus_core::impl_pyo3_config_getters!(BlockchainExecutionClientConfig {
554    base_fee_buffer_bps: u32,
555    deadline_seconds: Option<u64>,
556    gas_buffer_bps: u32,
557    gas_limit: u64,
558    http_rpc_url: String,
559    verification: Option<BlockchainVerificationConfig>,
560    max_fee_per_gas_wei: u64,
561    max_order_amount: Option<u64>,
562    max_quote_age_blocks: Option<u64>,
563    max_slippage_bps: Option<u32>,
564    quote_spend_limits: Option<Vec<QuoteSpendLimit>>,
565    receipt_timeout_secs: Option<u64>,
566    router_addresses: Vec<String>,
567    payload_deployment_id: Option<String>,
568    payload_key_env: Option<String>,
569    payload_key_retired_env: Vec<String>,
570    signer_private_key_env: String,
571    slippage_bps: Option<u32>,
572    tokens: Option<Vec<String>>,
573    transport_backend: TransportBackend,
574    unlimited_approval: bool,
575    wallet_address: String,
576    weth_address: String,
577});
578
579#[cfg(test)]
580mod tests {
581    use std::sync::Arc;
582
583    use nautilus_model::defi::chain::chains;
584    use rstest::rstest;
585
586    use super::*;
587
588    #[rstest]
589    fn test_data_config_toml_minimal() {
590        let config: BlockchainDataClientConfig = toml::from_str(
591            r#"
592http_rpc_url = "https://eth-mainnet.example.com"
593
594[chain]
595name = "Ethereum"
596chain_id = 1
597hypersync_url = "https://1.hypersync.xyz"
598native_currency_decimals = 18
599"#,
600        )
601        .unwrap();
602
603        assert_eq!(config.http_rpc_url, "https://eth-mainnet.example.com");
604        assert_eq!(config.chain.chain_id, 1);
605        assert!(config.dex_ids.is_empty());
606        assert!(!config.use_hypersync_for_live_data);
607        assert_eq!(config.multicall_calls_per_rpc_request, 200);
608        assert!(config.pool_filters.remove_pools_with_empty_erc20fields);
609        assert_eq!(config.transport_backend, TransportBackend::default());
610    }
611
612    #[rstest]
613    fn test_execution_config_toml_minimal() {
614        let config: BlockchainExecutionClientConfig = toml::from_str(
615            r#"
616client_id = "BLOCKCHAIN-001"
617wallet_address = "0x0000000000000000000000000000000000000000"
618http_rpc_url = "https://eth-mainnet.example.com"
619signer_private_key_env = "BLOCKCHAIN_PRIVATE_KEY"
620payload_key_env = "BLOCKCHAIN_PAYLOAD_KEY"
621payload_key_retired_env = ["BLOCKCHAIN_PAYLOAD_KEY_OLD"]
622payload_deployment_id = "primary-execution"
623router_addresses = ["0xE592427A0AEce92De3Edee1F18E0157C05861564"]
624weth_address = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"
625max_fee_per_gas_wei = 1000000000
626base_fee_buffer_bps = 2000
627gas_limit = 1000000
628gas_buffer_bps = 2000
629allowed_token_pairs = [
630    ["0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"],
631    ["0xaf88d065e77c8cC2239327C5EDb3A432268e5831", "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"],
632]
633slippage_bps = 50
634max_slippage_bps = 200
635max_order_amount = 1000000000000000000
636deadline_seconds = 300
637max_quote_age_blocks = 100
638receipt_timeout_secs = 60
639
640[[quote_spend_limits]]
641token_in = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
642token_out = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"
643spend_token = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
644spend_token_decimals = 6
645max_amount = "1000000000"
646
647[chain]
648name = "Ethereum"
649chain_id = 1
650hypersync_url = "https://1.hypersync.xyz"
651native_currency_decimals = 18
652"#,
653        )
654        .unwrap();
655
656        assert_eq!(config.http_rpc_url, "https://eth-mainnet.example.com");
657        assert_eq!(config.chain.chain_id, 1);
658        assert_eq!(
659            config.wallet_address,
660            "0x0000000000000000000000000000000000000000",
661        );
662        assert!(config.tokens.is_none());
663        assert!(config.rpc_requests_per_second.is_none());
664        assert_eq!(config.signer_private_key_env, "BLOCKCHAIN_PRIVATE_KEY");
665        assert_eq!(
666            config.payload_key_env.as_deref(),
667            Some("BLOCKCHAIN_PAYLOAD_KEY")
668        );
669        assert_eq!(
670            config.payload_key_retired_env,
671            vec!["BLOCKCHAIN_PAYLOAD_KEY_OLD".to_string()]
672        );
673        assert_eq!(
674            config.payload_deployment_id.as_deref(),
675            Some("primary-execution")
676        );
677        assert_eq!(
678            config.router_addresses,
679            vec!["0xE592427A0AEce92De3Edee1F18E0157C05861564".to_string()],
680        );
681        assert_eq!(
682            config.weth_address,
683            "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
684        );
685        assert!(!config.unlimited_approval);
686        assert_eq!(config.max_fee_per_gas_wei, 1_000_000_000);
687        assert_eq!(config.base_fee_buffer_bps, 2_000);
688        assert_eq!(config.gas_limit, 1_000_000);
689        assert_eq!(config.gas_buffer_bps, 2_000);
690        assert_eq!(
691            config.allowed_token_pairs,
692            Some(vec![
693                (
694                    "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1".to_string(),
695                    "0xaf88d065e77c8cC2239327C5EDb3A432268e5831".to_string(),
696                ),
697                (
698                    "0xaf88d065e77c8cC2239327C5EDb3A432268e5831".to_string(),
699                    "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1".to_string(),
700                )
701            ]),
702        );
703        assert_eq!(
704            config.quote_spend_limits,
705            Some(vec![QuoteSpendLimit {
706                token_in: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831".to_string(),
707                token_out: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1".to_string(),
708                spend_token: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831".to_string(),
709                spend_token_decimals: 6,
710                max_amount: "1000000000".to_string(),
711            }]),
712        );
713        assert_eq!(config.slippage_bps, Some(50));
714        assert_eq!(config.max_slippage_bps, Some(200));
715        assert_eq!(config.max_order_amount, Some(1_000_000_000_000_000_000));
716        assert_eq!(config.deadline_seconds, Some(300));
717        assert_eq!(config.max_quote_age_blocks, Some(100));
718        assert_eq!(config.receipt_timeout_secs, Some(60));
719        assert!(config.postgres_cache_database_config.is_none());
720        assert_eq!(config.transport_backend, TransportBackend::default());
721    }
722
723    #[rstest]
724    fn test_execution_config_toml_accepts_legacy_shape_without_transaction_limits() {
725        let config: BlockchainExecutionClientConfig = toml::from_str(
726            r#"
727client_id = "BLOCKCHAIN-001"
728wallet_address = "0x0000000000000000000000000000000000000000"
729http_rpc_url = "https://eth-mainnet.example.com"
730signer_private_key_env = "BLOCKCHAIN_PRIVATE_KEY"
731router_addresses = ["0xE592427A0AEce92De3Edee1F18E0157C05861564"]
732weth_address = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"
733max_fee_per_gas_wei = 1000000000
734base_fee_buffer_bps = 2000
735gas_limit = 1000000
736gas_buffer_bps = 2000
737
738[chain]
739name = "Ethereum"
740chain_id = 1
741hypersync_url = "https://1.hypersync.xyz"
742native_currency_decimals = 18
743"#,
744        )
745        .unwrap();
746
747        assert!(config.allowed_token_pairs.is_none());
748        assert!(config.payload_key_env.is_none());
749        assert!(config.payload_key_retired_env.is_empty());
750        assert!(config.payload_deployment_id.is_none());
751        assert!(config.quote_spend_limits.is_none());
752        assert!(config.slippage_bps.is_none());
753        assert!(config.max_slippage_bps.is_none());
754        assert!(config.max_order_amount.is_none());
755        assert!(config.deadline_seconds.is_none());
756        assert!(config.max_quote_age_blocks.is_none());
757        assert!(config.receipt_timeout_secs.is_none());
758    }
759
760    #[rstest]
761    fn test_execution_config_toml_rejects_unknown_fields() {
762        let result: Result<BlockchainExecutionClientConfig, _> = toml::from_str(
763            r#"
764client_id = "BLOCKCHAIN-001"
765wallet_address = "0x0000000000000000000000000000000000000000"
766http_rpc_url = "https://eth-mainnet.example.com"
767signer_private_key_env = "BLOCKCHAIN_PRIVATE_KEY"
768router_addresses = ["0xE592427A0AEce92De3Edee1F18E0157C05861564"]
769weth_address = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"
770max_fee_per_gas_wei = 1000000000
771base_fee_buffer_bps = 2000
772gas_limit = 1000000
773gas_buffer_bps = 2000
774allowed_token_pairs = [["0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"]]
775slippage_bps = 50
776max_slippage_bps = 200
777max_order_amount = 1000000000000000000
778deadline_seconds = 300
779max_quote_age_blocks = 100
780receipt_timeout_secs = 60
781unknown_field = 1
782
783[chain]
784name = "Ethereum"
785chain_id = 1
786hypersync_url = "https://1.hypersync.xyz"
787native_currency_decimals = 18
788"#,
789        );
790
791        assert!(result.is_err());
792    }
793
794    #[rstest]
795    fn test_data_config_debug_redacts_rpc_urls() {
796        const HTTP_USERINFO_SECRET: &str = "data-http-userinfo-secret";
797        const WSS_QUERY_SECRET: &str = "data-wss-query-secret";
798        let http_rpc_url = format!(
799            "https://rpc-user:{HTTP_USERINFO_SECRET}@rpc.example.com/data-http-path-secret"
800        );
801        let wss_rpc_url = format!("wss://rpc.example.com/ws?api_key={WSS_QUERY_SECRET}");
802        let config = BlockchainDataClientConfig::builder()
803            .chain(Arc::new(chains::ETHEREUM.clone()))
804            .http_rpc_url(http_rpc_url.clone())
805            .wss_rpc_url(wss_rpc_url.clone())
806            .build();
807
808        let debug = format!("{config:?}");
809
810        assert!(debug.contains("http_rpc_url: \"<redacted>\""));
811        assert!(debug.contains("wss_rpc_url: Some(\"<redacted>\")"));
812        assert!(!debug.contains(HTTP_USERINFO_SECRET));
813        assert!(!debug.contains(WSS_QUERY_SECRET));
814        assert!(!debug.contains(&http_rpc_url));
815        assert!(!debug.contains(&wss_rpc_url));
816    }
817
818    #[rstest]
819    fn test_execution_config_debug_redacts_rpc_url() {
820        const PATH_SECRET: &str = "execution-http-path-secret";
821        const QUERY_SECRET: &str = "execution-http-query-secret";
822        let http_rpc_url = format!("https://rpc.example.com/{PATH_SECRET}?api_key={QUERY_SECRET}");
823        let config = BlockchainExecutionClientConfig::builder()
824            .client_id(AccountId::from("BLOCKCHAIN-001"))
825            .chain(chains::ETHEREUM.clone())
826            .wallet_address("0x0000000000000000000000000000000000000000".to_string())
827            .http_rpc_url(http_rpc_url.clone())
828            .signer_private_key_env("BLOCKCHAIN_PRIVATE_KEY".to_string())
829            .router_addresses(vec![
830                "0xE592427A0AEce92De3Edee1F18E0157C05861564".to_string(),
831            ])
832            .weth_address("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1".to_string())
833            .max_fee_per_gas_wei(1_000_000_000)
834            .base_fee_buffer_bps(2_000)
835            .gas_limit(1_000_000)
836            .gas_buffer_bps(2_000)
837            .allowed_token_pairs(vec![(
838                "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1".to_string(),
839                "0xaf88d065e77c8cC2239327C5EDb3A432268e5831".to_string(),
840            )])
841            .slippage_bps(50)
842            .max_slippage_bps(200)
843            .max_order_amount(1_000_000_000_000_000_000)
844            .deadline_seconds(300)
845            .max_quote_age_blocks(100)
846            .receipt_timeout_secs(60)
847            .build();
848
849        let debug = format!("{config:?}");
850
851        assert!(debug.contains("http_rpc_url: \"<redacted>\""));
852        assert!(!debug.contains(PATH_SECRET));
853        assert!(!debug.contains(QUERY_SECRET));
854        assert!(!debug.contains(&http_rpc_url));
855    }
856}