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