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