Skip to main content

blueprint_client_tangle/
client.rs

1//! Tangle Client
2//!
3//! Provides connectivity to Tangle contracts for blueprint operators.
4
5extern crate alloc;
6
7use alloc::format;
8use alloc::string::{String, ToString};
9use alloc::vec;
10use alloy_network::Ethereum;
11use alloy_primitives::{Address, B256, Bytes, TxKind, U256, keccak256};
12use alloy_provider::{DynProvider, Provider, ProviderBuilder};
13use alloy_rpc_types::{
14    Block, BlockNumberOrTag, Filter, Log, TransactionReceipt,
15    transaction::{TransactionInput, TransactionRequest},
16};
17use alloy_sol_types::{SolCall, SolEvent};
18use blueprint_client_core::{BlueprintServicesClient, OperatorSet};
19use blueprint_crypto::k256::K256Ecdsa;
20use blueprint_keystore::Keystore;
21use blueprint_keystore::backends::Backend;
22use blueprint_std::collections::BTreeMap;
23use blueprint_std::sync::Arc;
24use blueprint_std::vec::Vec;
25use core::fmt;
26use core::time::Duration;
27use k256::elliptic_curve::sec1::ToEncodedPoint;
28use tokio::sync::Mutex;
29
30use crate::config::TangleClientConfig;
31use crate::contracts::{
32    IBlueprintServiceManager, IMasterBlueprintServiceManager, IMultiAssetDelegation,
33    IMultiAssetDelegationTypes, IOperatorStatusRegistry, ITangle, ITangleBlueprints, ITangleTypes,
34};
35use crate::error::{Error, Result};
36use crate::services::ServiceRequestParams;
37use IMultiAssetDelegation::IMultiAssetDelegationInstance;
38use IOperatorStatusRegistry::IOperatorStatusRegistryInstance;
39use ITangle::ITangleInstance;
40
41const SUBMIT_RESULT_MIN_GAS_LIMIT: u64 = 8_000_000;
42const SUBMIT_RESULT_GAS_BUFFER_NUMERATOR: u64 = 13;
43const SUBMIT_RESULT_GAS_BUFFER_DENOMINATOR: u64 = 10;
44const CREATE_BLUEPRINT_MIN_GAS_LIMIT: u64 = 5_000_000;
45const REGISTER_BLUEPRINT_OPERATOR_MIN_GAS_LIMIT: u64 = 1_000_000;
46const REQUEST_SERVICE_MIN_GAS_LIMIT: u64 = 2_000_000;
47const APPROVE_SERVICE_MIN_GAS_LIMIT: u64 = 1_000_000;
48
49/// Default max `eth_getLogs` block span per request. Hosted RPCs commonly cap
50/// the range (Base Sepolia's public node rejects >2000 with error -32602), and
51/// it varies by provider — override per chain with `MAX_GETLOGS_RANGE` in
52/// `settings.env`. A single poll scans at most this many blocks; the caller
53/// catches up to head over successive polls.
54const DEFAULT_MAX_GETLOGS_RANGE: u64 = 2_000;
55
56/// Resolve the per-chain `eth_getLogs` window from the environment (set via
57/// `settings.env`, which the CLI dotenv-loads), falling back to the default.
58fn resolve_max_getlogs_range() -> u64 {
59    std::env::var("MAX_GETLOGS_RANGE")
60        .ok()
61        .and_then(|v| v.parse::<u64>().ok())
62        .filter(|v| *v > 0)
63        .unwrap_or(DEFAULT_MAX_GETLOGS_RANGE)
64}
65const ERC20_APPROVE_MIN_GAS_LIMIT: u64 = 100_000;
66const REGISTER_OPERATOR_RESTAKING_MIN_GAS_LIMIT: u64 = 500_000;
67#[allow(missing_docs)]
68mod erc20 {
69    alloy_sol_types::sol! {
70        #[sol(rpc)]
71        interface IERC20 {
72            function approve(address spender, uint256 amount) external returns (bool);
73            function allowance(address owner, address spender) external view returns (uint256);
74            function balanceOf(address owner) external view returns (uint256);
75        }
76    }
77}
78
79use erc20::IERC20;
80
81use IMasterBlueprintServiceManager::BlueprintDefinitionRecorded;
82
83/// Compute the gas limit to submit with: buffered estimate if available, else the
84/// caller-provided minimum. Always `>= min_gas_limit`.
85fn buffered_gas_limit(estimated_gas: Option<u64>, min_gas_limit: u64) -> u64 {
86    estimated_gas
87        .map(|gas| {
88            gas.saturating_mul(SUBMIT_RESULT_GAS_BUFFER_NUMERATOR)
89                / SUBMIT_RESULT_GAS_BUFFER_DENOMINATOR
90        })
91        .unwrap_or(min_gas_limit)
92        .max(min_gas_limit)
93}
94
95/// Send a transaction with buffered estimated gas, falling back to a conservative
96/// minimum when estimation fails.
97///
98/// `from` must be the operator/wallet address that will sign the tx. It is applied
99/// before `eth_estimateGas` so operator-gated calls simulate correctly — without
100/// it, alloy's `WalletFiller` only populates `from` on `send_transaction`, so
101/// estimation runs as `0x0` and reverts for any auth-checked entrypoint,
102/// causing the helper to always take the fallback path (and masking real reverts).
103///
104/// An on-chain revert (`receipt.status() == false`) is surfaced as
105/// `Error::Contract` carrying the tx hash, gas used, and — when available —
106/// the estimator's revert reason. Without this, callers that fold the receipt
107/// into `TransactionResult { success, .. }` silently return `Ok(success=false)`,
108/// which looks like a passing path to anything that only checks `Result::is_ok`.
109async fn send_transaction_with_fallback_gas<P>(
110    provider: &P,
111    from: Address,
112    tx_request: TransactionRequest,
113    min_gas_limit: u64,
114) -> Result<TransactionReceipt>
115where
116    P: Provider<Ethereum>,
117{
118    let tx_request = tx_request.from(from);
119    let (estimated_gas, estimate_error) = match provider.estimate_gas(tx_request.clone()).await {
120        Ok(gas) => (Some(gas), None),
121        Err(err) => {
122            let msg = err.to_string();
123            tracing::warn!(
124                "eth_estimateGas failed; falling back to min_gas_limit={min_gas_limit}: {msg}"
125            );
126            (None, Some(msg))
127        }
128    };
129    let gas_limit = buffered_gas_limit(estimated_gas, min_gas_limit);
130    let pending_tx = provider
131        .send_transaction(tx_request.gas_limit(gas_limit))
132        .await
133        .map_err(Error::Transport)?;
134
135    let receipt = pending_tx
136        .get_receipt()
137        .await
138        .map_err(Error::PendingTransaction)?;
139
140    if !receipt.status() {
141        let tail = estimate_error
142            .map(|e| format!(" (estimate_gas reported: {e})"))
143            .unwrap_or_default();
144        return Err(Error::Contract(format!(
145            "transaction {} reverted on-chain (block={:?}, gas_used={}){tail}",
146            receipt.transaction_hash, receipt.block_number, receipt.gas_used,
147        )));
148    }
149
150    Ok(receipt)
151}
152
153/// Type alias for the dynamic provider
154pub type TangleProvider = DynProvider<Ethereum>;
155
156/// Type alias for ECDSA public key (uncompressed, 65 bytes)
157pub type EcdsaPublicKey = [u8; 65];
158
159/// Type alias for compressed ECDSA public key (33 bytes)
160pub type CompressedEcdsaPublicKey = [u8; 33];
161
162/// Restaking-specific metadata for an operator.
163#[derive(Debug, Clone)]
164pub struct RestakingMetadata {
165    /// Operator self-stake amount (in wei).
166    pub stake: U256,
167    /// Number of delegations attached to this operator.
168    pub delegation_count: u32,
169    /// Whether the operator is active inside MultiAssetDelegation.
170    pub status: RestakingStatus,
171    /// Round when the operator scheduled a voluntary exit.
172    pub leaving_round: u64,
173}
174
175/// Restaking status reported by MultiAssetDelegation.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum RestakingStatus {
178    /// Operator is active.
179    Active,
180    /// Operator is inactive (e.g., kicked or never joined).
181    Inactive,
182    /// Operator scheduled a leave operation.
183    Leaving,
184    /// Unknown status value (future-proofing).
185    Unknown(u8),
186}
187
188impl From<u8> for RestakingStatus {
189    fn from(value: u8) -> Self {
190        match value {
191            0 => RestakingStatus::Active,
192            1 => RestakingStatus::Inactive,
193            2 => RestakingStatus::Leaving,
194            other => RestakingStatus::Unknown(other),
195        }
196    }
197}
198
199/// Delegation mode for an operator.
200///
201/// Controls who can delegate to the operator.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum DelegationMode {
204    /// Disabled: Only operator can self-stake (default).
205    Disabled,
206    /// Whitelist: Only approved addresses can delegate.
207    Whitelist,
208    /// Open: Anyone can delegate.
209    Open,
210    /// Unknown mode value (future-proofing).
211    Unknown(u8),
212}
213
214impl From<u8> for DelegationMode {
215    fn from(value: u8) -> Self {
216        match value {
217            0 => DelegationMode::Disabled,
218            1 => DelegationMode::Whitelist,
219            2 => DelegationMode::Open,
220            other => DelegationMode::Unknown(other),
221        }
222    }
223}
224
225impl fmt::Display for DelegationMode {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        match self {
228            DelegationMode::Disabled => write!(f, "disabled"),
229            DelegationMode::Whitelist => write!(f, "whitelist"),
230            DelegationMode::Open => write!(f, "open"),
231            DelegationMode::Unknown(value) => write!(f, "unknown({value})"),
232        }
233    }
234}
235
236/// Asset kinds supported by MultiAssetDelegation.
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum AssetKind {
239    /// Native asset (e.g. ETH).
240    Native,
241    /// ERC-20 token.
242    Erc20,
243    /// Unknown asset kind value.
244    Unknown(u8),
245}
246
247impl From<u8> for AssetKind {
248    fn from(value: u8) -> Self {
249        match value {
250            0 => AssetKind::Native,
251            1 => AssetKind::Erc20,
252            other => AssetKind::Unknown(other),
253        }
254    }
255}
256
257impl fmt::Display for AssetKind {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        match self {
260            AssetKind::Native => write!(f, "native"),
261            AssetKind::Erc20 => write!(f, "erc20"),
262            AssetKind::Unknown(value) => write!(f, "unknown({value})"),
263        }
264    }
265}
266
267/// Blueprint selection mode for a delegation.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum BlueprintSelectionMode {
270    /// Delegate across all blueprints.
271    All,
272    /// Delegate to a fixed set of blueprints.
273    Fixed,
274    /// Unknown selection mode value.
275    Unknown(u8),
276}
277
278impl From<u8> for BlueprintSelectionMode {
279    fn from(value: u8) -> Self {
280        match value {
281            0 => BlueprintSelectionMode::All,
282            1 => BlueprintSelectionMode::Fixed,
283            other => BlueprintSelectionMode::Unknown(other),
284        }
285    }
286}
287
288impl fmt::Display for BlueprintSelectionMode {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        match self {
291            BlueprintSelectionMode::All => write!(f, "all"),
292            BlueprintSelectionMode::Fixed => write!(f, "fixed"),
293            BlueprintSelectionMode::Unknown(value) => write!(f, "unknown({value})"),
294        }
295    }
296}
297
298/// Lock multiplier tier for a deposit.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum LockMultiplier {
301    /// No lock multiplier.
302    None,
303    /// One-month lock.
304    OneMonth,
305    /// Two-month lock.
306    TwoMonths,
307    /// Three-month lock.
308    ThreeMonths,
309    /// Six-month lock.
310    SixMonths,
311    /// Unknown multiplier value.
312    Unknown(u8),
313}
314
315impl From<u8> for LockMultiplier {
316    fn from(value: u8) -> Self {
317        match value {
318            0 => LockMultiplier::None,
319            1 => LockMultiplier::OneMonth,
320            2 => LockMultiplier::TwoMonths,
321            3 => LockMultiplier::ThreeMonths,
322            4 => LockMultiplier::SixMonths,
323            other => LockMultiplier::Unknown(other),
324        }
325    }
326}
327
328impl fmt::Display for LockMultiplier {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        match self {
331            LockMultiplier::None => write!(f, "none"),
332            LockMultiplier::OneMonth => write!(f, "one-month"),
333            LockMultiplier::TwoMonths => write!(f, "two-months"),
334            LockMultiplier::ThreeMonths => write!(f, "three-months"),
335            LockMultiplier::SixMonths => write!(f, "six-months"),
336            LockMultiplier::Unknown(value) => write!(f, "unknown({value})"),
337        }
338    }
339}
340
341/// Asset specification returned by MultiAssetDelegation.
342#[derive(Debug, Clone)]
343pub struct AssetInfo {
344    /// Asset kind identifier.
345    pub kind: AssetKind,
346    /// Token contract address (zero for native).
347    pub token: Address,
348}
349
350/// Deposit summary for a delegator and token.
351#[derive(Debug, Clone)]
352pub struct DepositInfo {
353    /// Total deposited amount.
354    pub amount: U256,
355    /// Portion already delegated.
356    pub delegated_amount: U256,
357}
358
359/// Lock details for a delegator and token.
360#[derive(Debug, Clone)]
361pub struct LockInfo {
362    /// Locked amount.
363    pub amount: U256,
364    /// Multiplier tier.
365    pub multiplier: LockMultiplier,
366    /// Unix timestamp when the lock expires (tnt-core 0.18: expiryTimestamp).
367    pub expiry_timestamp: u64,
368}
369
370/// Delegation info for a delegator.
371#[derive(Debug, Clone)]
372pub struct DelegationInfo {
373    /// Operator address.
374    pub operator: Address,
375    /// Delegated shares.
376    pub shares: U256,
377    /// Asset metadata.
378    pub asset: AssetInfo,
379    /// Blueprint selection mode.
380    pub selection_mode: BlueprintSelectionMode,
381}
382
383/// Delegation with optional blueprint selections.
384#[derive(Debug, Clone)]
385pub struct DelegationRecord {
386    /// Delegation metadata.
387    pub info: DelegationInfo,
388    /// Selected blueprint IDs (fixed mode only).
389    pub blueprint_ids: Vec<u64>,
390}
391
392/// Pending delegator unstake request.
393#[derive(Debug, Clone)]
394pub struct PendingUnstake {
395    /// Operator address.
396    pub operator: Address,
397    /// Asset metadata.
398    pub asset: AssetInfo,
399    /// Shares scheduled to unstake.
400    pub shares: U256,
401    /// Round when the unstake was requested.
402    pub requested_round: u64,
403    /// Blueprint selection mode.
404    pub selection_mode: BlueprintSelectionMode,
405    /// Slash factor snapshot at request time.
406    pub slash_factor_snapshot: U256,
407}
408
409/// Pending delegator withdrawal request.
410#[derive(Debug, Clone)]
411pub struct PendingWithdrawal {
412    /// Asset metadata.
413    pub asset: AssetInfo,
414    /// Amount requested for withdrawal.
415    pub amount: U256,
416    /// Round when the withdrawal was requested.
417    pub requested_round: u64,
418}
419
420/// Metadata associated with a registered operator.
421#[derive(Debug, Clone)]
422pub struct OperatorMetadata {
423    /// Operator's uncompressed ECDSA public key used for gossip/aggregation.
424    pub public_key: EcdsaPublicKey,
425    /// Operator-provided RPC endpoint.
426    pub rpc_endpoint: String,
427    /// Restaking information pulled from MultiAssetDelegation.
428    pub restaking: RestakingMetadata,
429}
430
431/// Snapshot of an operator's heartbeat/status entry.
432#[derive(Debug, Clone)]
433pub struct OperatorStatusSnapshot {
434    /// Service being inspected.
435    pub service_id: u64,
436    /// Operator address.
437    pub operator: Address,
438    /// Raw status code recorded on-chain.
439    pub status_code: u8,
440    /// Last heartbeat timestamp (Unix seconds).
441    pub last_heartbeat: u64,
442    /// Whether the operator is currently marked online.
443    pub online: bool,
444}
445
446/// Event from Tangle contracts
447#[derive(Clone, Debug)]
448pub struct TangleEvent {
449    /// Block number
450    pub block_number: u64,
451    /// Block hash
452    pub block_hash: B256,
453    /// Block timestamp
454    pub timestamp: u64,
455    /// Logs from the block
456    pub logs: Vec<Log>,
457}
458
459/// Tangle Client for interacting with Tangle v2 contracts
460#[derive(Clone)]
461pub struct TangleClient {
462    /// RPC provider
463    provider: Arc<TangleProvider>,
464    /// Tangle contract address
465    tangle_address: Address,
466    /// MultiAssetDelegation contract address
467    restaking_address: Address,
468    /// Operator status registry contract address
469    status_registry_address: Address,
470    /// Operator's account address
471    account: Address,
472    /// Client configuration
473    pub config: TangleClientConfig,
474    /// Keystore for signing
475    keystore: Arc<Keystore>,
476    /// Latest block tracking
477    latest_block: Arc<Mutex<Option<TangleEvent>>>,
478    /// Current block subscription
479    block_subscription: Arc<Mutex<Option<u64>>>,
480    /// Per-chain `eth_getLogs` block-span cap (see `resolve_max_getlogs_range`)
481    max_getlogs_range: u64,
482}
483
484#[allow(clippy::missing_fields_in_debug)] // provider/signer/subscription intentionally omitted
485impl fmt::Debug for TangleClient {
486    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487        f.debug_struct("TangleClient")
488            .field("tangle_address", &self.tangle_address)
489            .field("restaking_address", &self.restaking_address)
490            .field("status_registry_address", &self.status_registry_address)
491            .field("account", &self.account)
492            .finish()
493    }
494}
495
496impl TangleClient {
497    /// Create a new Tangle client from configuration
498    ///
499    /// # Arguments
500    /// * `config` - Client configuration
501    ///
502    /// # Errors
503    /// Returns error if keystore initialization fails or RPC connection fails
504    pub async fn new(config: TangleClientConfig) -> Result<Self> {
505        let keystore = Keystore::new(config.keystore_config())?;
506        Self::with_keystore(config, keystore).await
507    }
508
509    /// Create a new Tangle client with an existing keystore
510    ///
511    /// # Arguments
512    /// * `config` - Client configuration
513    /// * `keystore` - Keystore instance
514    ///
515    /// # Errors
516    /// Returns error if RPC connection fails
517    pub async fn with_keystore(config: TangleClientConfig, keystore: Keystore) -> Result<Self> {
518        let rpc_url = config.http_rpc_endpoint.as_str();
519
520        // Create provider and wrap in DynProvider for type erasure
521        let provider = ProviderBuilder::new()
522            .connect(rpc_url)
523            .await
524            .map_err(|e| Error::Config(e.to_string()))?;
525
526        let dyn_provider = DynProvider::new(provider);
527
528        // Get operator's address from keystore (using ECDSA key)
529        let ecdsa_key = keystore
530            .first_local::<K256Ecdsa>()
531            .map_err(Error::Keystore)?;
532
533        // Convert ECDSA public key to Ethereum address
534        // The key.0 is a VerifyingKey - extract the bytes from it
535        let pubkey_bytes = ecdsa_key.0.to_encoded_point(false);
536        let account = ecdsa_public_key_to_address(pubkey_bytes.as_bytes())?;
537
538        Ok(Self {
539            provider: Arc::new(dyn_provider),
540            tangle_address: config.settings.tangle_contract,
541            restaking_address: config.settings.staking_contract,
542            status_registry_address: config.settings.status_registry_contract,
543            account,
544            config,
545            keystore: Arc::new(keystore),
546            latest_block: Arc::new(Mutex::new(None)),
547            block_subscription: Arc::new(Mutex::new(None)),
548            max_getlogs_range: resolve_max_getlogs_range(),
549        })
550    }
551
552    /// Get the Tangle contract instance
553    pub fn tangle_contract(&self) -> ITangleInstance<Arc<TangleProvider>> {
554        ITangleInstance::new(self.tangle_address, Arc::clone(&self.provider))
555    }
556
557    /// Get the MultiAssetDelegation contract instance
558    pub fn staking_contract(&self) -> IMultiAssetDelegationInstance<Arc<TangleProvider>> {
559        IMultiAssetDelegation::new(self.restaking_address, Arc::clone(&self.provider))
560    }
561
562    /// Get the operator status registry contract instance
563    pub fn status_registry_contract(&self) -> IOperatorStatusRegistryInstance<Arc<TangleProvider>> {
564        IOperatorStatusRegistryInstance::new(
565            self.status_registry_address,
566            Arc::clone(&self.provider),
567        )
568    }
569
570    /// Get the operator's account address
571    #[must_use]
572    pub fn account(&self) -> Address {
573        self.account
574    }
575
576    /// Get the keystore
577    #[must_use]
578    pub fn keystore(&self) -> &Arc<Keystore> {
579        &self.keystore
580    }
581
582    /// Get the provider
583    #[must_use]
584    pub fn provider(&self) -> &Arc<TangleProvider> {
585        &self.provider
586    }
587
588    /// Get the Tangle contract address
589    #[must_use]
590    pub fn tangle_address(&self) -> Address {
591        self.tangle_address
592    }
593
594    /// Get the ECDSA signing key from the keystore
595    ///
596    /// # Errors
597    /// Returns error if the key is not found in the keystore
598    pub fn ecdsa_signing_key(&self) -> Result<blueprint_crypto::k256::K256SigningKey> {
599        let public = self
600            .keystore
601            .first_local::<K256Ecdsa>()
602            .map_err(Error::Keystore)?;
603        self.keystore
604            .get_secret::<K256Ecdsa>(&public)
605            .map_err(Error::Keystore)
606    }
607
608    /// Get an Ethereum wallet for signing transactions
609    ///
610    /// # Errors
611    /// Returns error if the key is not found or wallet creation fails
612    pub fn wallet(&self) -> Result<alloy_network::EthereumWallet> {
613        let signing_key = self.ecdsa_signing_key()?;
614        let local_signer = signing_key
615            .alloy_key()
616            .map_err(|e| Error::Keystore(blueprint_keystore::Error::Other(e.to_string())))?;
617        Ok(alloy_network::EthereumWallet::from(local_signer))
618    }
619
620    /// Get the current block number
621    pub async fn block_number(&self) -> Result<u64> {
622        self.provider
623            .get_block_number()
624            .await
625            .map_err(Error::Transport)
626    }
627
628    /// Get a block by number
629    pub async fn get_block(&self, number: BlockNumberOrTag) -> Result<Option<Block>> {
630        self.provider
631            .get_block_by_number(number)
632            .await
633            .map_err(Error::Transport)
634    }
635
636    /// Get logs matching a filter
637    pub async fn get_logs(&self, filter: &Filter) -> Result<Vec<Log>> {
638        self.provider
639            .get_logs(filter)
640            .await
641            .map_err(Error::Transport)
642    }
643
644    /// Get the next event (polls for new blocks)
645    ///
646    /// On the first call, scans a recent bounded window to catch up on
647    /// historical events while staying compatible with hosted RPC plans
648    /// that cap `eth_getLogs` block ranges. Older active services are still
649    /// discovered by the manager's contract-state fallback during initialize.
650    /// Subsequent calls only scan new blocks.
651    pub async fn next_event(&self) -> Option<TangleEvent> {
652        loop {
653            let current_block = match self.block_number().await {
654                Ok(block) => block,
655                Err(err) => {
656                    tracing::warn!(error = %err, "Failed to fetch current block number");
657                    tokio::time::sleep(Duration::from_secs(1)).await;
658                    continue;
659                }
660            };
661
662            let mut last_block = self.block_subscription.lock().await;
663            let from_block = last_block
664                .map(|block| block.saturating_add(1))
665                .unwrap_or_else(|| current_block.saturating_sub(9_999));
666
667            if from_block > current_block {
668                drop(last_block);
669                tokio::time::sleep(Duration::from_secs(1)).await;
670                continue;
671            }
672
673            // Cap the scan to the RPC's `eth_getLogs` range limit. When catching
674            // up from a wide initial lookback, advance one bounded window per poll
675            // (the caller loops, so head is reached over successive calls).
676            let to_block = core::cmp::min(
677                from_block.saturating_add(self.max_getlogs_range.saturating_sub(1)),
678                current_block,
679            );
680
681            // Get block info
682            let Some(block) = (match self.get_block(BlockNumberOrTag::Number(to_block)).await {
683                Ok(block) => block,
684                Err(err) => {
685                    tracing::warn!(error = %err, block = to_block, "Failed to fetch block");
686                    drop(last_block);
687                    tokio::time::sleep(Duration::from_secs(1)).await;
688                    continue;
689                }
690            }) else {
691                tracing::warn!(block = to_block, "Latest block was unavailable");
692                drop(last_block);
693                tokio::time::sleep(Duration::from_secs(1)).await;
694                continue;
695            };
696
697            // Create filter for Tangle contract events
698            let filter = Filter::new()
699                .address(self.tangle_address)
700                .from_block(from_block)
701                .to_block(to_block);
702
703            let logs = match self.get_logs(&filter).await {
704                Ok(logs) => logs,
705                Err(err) => {
706                    tracing::warn!(
707                        error = %err,
708                        from_block,
709                        to_block,
710                        "Failed to fetch Tangle logs"
711                    );
712                    drop(last_block);
713                    tokio::time::sleep(Duration::from_secs(1)).await;
714                    continue;
715                }
716            };
717
718            *last_block = Some(to_block);
719
720            let event = TangleEvent {
721                block_number: to_block,
722                block_hash: block.header.hash,
723                timestamp: block.header.timestamp,
724                logs,
725            };
726
727            // Update latest
728            *self.latest_block.lock().await = Some(event.clone());
729
730            return Some(event);
731        }
732    }
733
734    /// Get the latest observed event
735    pub async fn latest_event(&self) -> Option<TangleEvent> {
736        let latest = self.latest_block.lock().await;
737        match &*latest {
738            Some(event) => Some(event.clone()),
739            None => {
740                drop(latest);
741                self.next_event().await
742            }
743        }
744    }
745
746    /// Get the current block hash
747    pub async fn now(&self) -> Option<B256> {
748        Some(self.latest_event().await?.block_hash)
749    }
750
751    // ═══════════════════════════════════════════════════════════════════════════
752    // BLUEPRINT QUERIES
753    // ═══════════════════════════════════════════════════════════════════════════
754
755    /// Get blueprint information
756    pub async fn get_blueprint(&self, blueprint_id: u64) -> Result<ITangleTypes::Blueprint> {
757        let contract = self.tangle_contract();
758        let result = contract
759            .getBlueprint(blueprint_id)
760            .call()
761            .await
762            .map_err(|e| Error::Contract(e.to_string()))?;
763        Ok(result)
764    }
765
766    /// Get the live operator count for a blueprint.
767    ///
768    /// tnt-core v0.18.0 removed the stored `operatorCount` from the Blueprint
769    /// struct; `blueprintOperatorCount` derives it from the operator set.
770    pub async fn get_blueprint_operator_count(&self, blueprint_id: u64) -> Result<u32> {
771        let contract = self.tangle_contract();
772        let count = contract
773            .blueprintOperatorCount(blueprint_id)
774            .call()
775            .await
776            .map_err(|e| Error::Contract(e.to_string()))?;
777        u32::try_from(count).map_err(|_| {
778            Error::Contract(format!(
779                "blueprintOperatorCount({blueprint_id}) = {count} exceeds u32"
780            ))
781        })
782    }
783
784    /// Fetch the raw ABI-encoded blueprint definition bytes.
785    pub async fn get_raw_blueprint_definition(&self, blueprint_id: u64) -> Result<Vec<u8>> {
786        let mut data = Vec::with_capacity(4 + 32);
787        let method_hash = keccak256("getBlueprintDefinition(uint64)".as_bytes());
788        data.extend_from_slice(&method_hash[..4]);
789        let mut arg = [0u8; 32];
790        arg[24..].copy_from_slice(&blueprint_id.to_be_bytes());
791        data.extend_from_slice(&arg);
792
793        let mut request = TransactionRequest::default();
794        request.to = Some(TxKind::Call(self.tangle_address));
795        request.input = TransactionInput::new(Bytes::from(data));
796
797        let response = self
798            .provider
799            .call(request)
800            .await
801            .map_err(Error::Transport)?;
802
803        Ok(response.to_vec())
804    }
805
806    /// Fetch the raw ABI-encoded blueprint definition from the
807    /// `BlueprintDefinitionRecorded` event instead of Tangle-core storage.
808    ///
809    /// Tangle-core keeps schemas, sources, config, `metadata.name`,
810    /// `profilingData`, and `metadataUri` in storage, but the display prose
811    /// (job names/descriptions and the blueprint metadata strings) now lives
812    /// only in the event payload. This scans `eth_getLogs` for the event keyed
813    /// by `blueprintId`, takes the `encodedDefinition` bytes, and verifies
814    /// `keccak256(encodedDefinition) == blueprintDefinitionHash(blueprintId)`
815    /// so the log emitter is never trusted. The returned bytes are the same
816    /// `abi.encode(BlueprintDefinition)` layout as [`get_raw_blueprint_definition`],
817    /// so existing decoders apply unchanged.
818    ///
819    /// # Errors
820    /// Returns [`Error::Contract`] if no matching event is found, if the
821    /// on-chain integrity hash is unset, or if the payload hash does not match.
822    pub async fn get_raw_blueprint_definition_from_event(
823        &self,
824        blueprint_id: u64,
825    ) -> Result<Vec<u8>> {
826        let expected_hash = ITangleBlueprints::new(self.tangle_address, &self.provider)
827            .blueprintDefinitionHash(blueprint_id)
828            .call()
829            .await
830            .map_err(|e| Error::Contract(e.to_string()))?;
831        if expected_hash == B256::ZERO {
832            return Err(Error::Contract(format!(
833                "blueprint {blueprint_id} has no recorded definition hash on-chain"
834            )));
835        }
836
837        // topic1 is the indexed `blueprintId`, left-padded to 32 bytes.
838        let mut id_topic = [0u8; 32];
839        id_topic[24..].copy_from_slice(&blueprint_id.to_be_bytes());
840        let id_topic = B256::from(id_topic);
841
842        let head = self.block_number().await?;
843        let mut to_block = head;
844        loop {
845            let from_block = to_block.saturating_sub(self.max_getlogs_range.saturating_sub(1));
846            let filter = Filter::new()
847                .event_signature(BlueprintDefinitionRecorded::SIGNATURE_HASH)
848                .topic1(id_topic)
849                .from_block(from_block)
850                .to_block(to_block);
851
852            let logs = self.get_logs(&filter).await?;
853            // Newest wins: the latest recording is the current definition.
854            for log in logs.iter().rev() {
855                // A log matching only topic0 could be emitted by another contract; a decode
856                // failure there must not abort the scan. Skip it — the keccak check below is
857                // the real integrity anchor, so a foreign/forged log can never be accepted.
858                let Ok(decoded) = BlueprintDefinitionRecorded::decode_log(&log.inner) else {
859                    continue;
860                };
861                let encoded = decoded.encodedDefinition.to_vec();
862                if keccak256(&encoded) == expected_hash {
863                    return Ok(encoded);
864                }
865            }
866
867            if from_block == 0 {
868                break;
869            }
870            to_block = from_block.saturating_sub(1);
871        }
872
873        Err(Error::Contract(format!(
874            "no BlueprintDefinitionRecorded event matching the on-chain hash for blueprint {blueprint_id}"
875        )))
876    }
877
878    /// Get blueprint configuration
879    pub async fn get_blueprint_config(
880        &self,
881        blueprint_id: u64,
882    ) -> Result<ITangleTypes::BlueprintConfig> {
883        let contract = self.tangle_contract();
884        let result = contract
885            .getBlueprintConfig(blueprint_id)
886            .call()
887            .await
888            .map_err(|e| Error::Contract(e.to_string()))?;
889        Ok(result)
890    }
891
892    /// Get the full blueprint definition.
893    pub async fn get_blueprint_definition(
894        &self,
895        blueprint_id: u64,
896    ) -> Result<ITangleTypes::BlueprintDefinition> {
897        let contract = self.tangle_contract();
898        let result = contract
899            .getBlueprintDefinition(blueprint_id)
900            .call()
901            .await
902            .map_err(|e| Error::Contract(e.to_string()))?;
903        Ok(result)
904    }
905
906    /// Create a new blueprint from ABI-encoded calldata.
907    pub async fn create_blueprint(&self, calldata: Vec<u8>) -> Result<(TransactionResult, u64)> {
908        let wallet = self.wallet()?;
909        let from_address = wallet.default_signer().address();
910        let provider = ProviderBuilder::new()
911            .wallet(wallet)
912            .connect(self.config.http_rpc_endpoint.as_str())
913            .await
914            .map_err(Error::Transport)?;
915        let tx_request = TransactionRequest::default()
916            .to(self.tangle_address)
917            .input(Bytes::from(calldata).into());
918        let receipt = send_transaction_with_fallback_gas(
919            &provider,
920            from_address,
921            tx_request,
922            CREATE_BLUEPRINT_MIN_GAS_LIMIT,
923        )
924        .await?;
925        let blueprint_id = self.extract_blueprint_id(&receipt)?;
926
927        Ok((transaction_result_from_receipt(&receipt), blueprint_id))
928    }
929
930    /// Check if operator is registered for blueprint
931    pub async fn is_operator_registered(
932        &self,
933        blueprint_id: u64,
934        operator: Address,
935    ) -> Result<bool> {
936        let contract = self.tangle_contract();
937        contract
938            .isOperatorRegistered(blueprint_id, operator)
939            .call()
940            .await
941            .map_err(|e| Error::Contract(e.to_string()))
942    }
943
944    // ═══════════════════════════════════════════════════════════════════════════
945    // SERVICE QUERIES
946    // ═══════════════════════════════════════════════════════════════════════════
947
948    /// Get service information
949    pub async fn get_service(&self, service_id: u64) -> Result<ITangleTypes::Service> {
950        let contract = self.tangle_contract();
951        let result = contract
952            .getService(service_id)
953            .call()
954            .await
955            .map_err(|e| Error::Contract(e.to_string()))?;
956        Ok(result)
957    }
958
959    /// Get service operators
960    pub async fn get_service_operators(&self, service_id: u64) -> Result<Vec<Address>> {
961        let contract = self.tangle_contract();
962        contract
963            .getServiceOperators(service_id)
964            .call()
965            .await
966            .map_err(|e| Error::Contract(e.to_string()))
967    }
968
969    /// Check if address is a service operator
970    pub async fn is_service_operator(&self, service_id: u64, operator: Address) -> Result<bool> {
971        let contract = self.tangle_contract();
972        contract
973            .isServiceOperator(service_id, operator)
974            .call()
975            .await
976            .map_err(|e| Error::Contract(e.to_string()))
977    }
978
979    /// Get service operator info including exposure
980    ///
981    /// Returns the `ServiceOperator` struct which contains `exposureBps`.
982    pub async fn get_service_operator(
983        &self,
984        service_id: u64,
985        operator: Address,
986    ) -> Result<ITangleTypes::ServiceOperator> {
987        let contract = self.tangle_contract();
988        let result = contract
989            .getServiceOperator(service_id, operator)
990            .call()
991            .await
992            .map_err(|e| Error::Contract(e.to_string()))?;
993        Ok(result)
994    }
995
996    /// Get total exposure for a service
997    ///
998    /// Returns the sum of all operator exposureBps values.
999    pub async fn get_service_total_exposure(&self, service_id: u64) -> Result<U256> {
1000        let mut total = U256::ZERO;
1001        for operator in self.get_service_operators(service_id).await? {
1002            let op_info = self.get_service_operator(service_id, operator).await?;
1003            if op_info.active {
1004                total = total.saturating_add(U256::from(op_info.exposureBps));
1005            }
1006        }
1007        Ok(total)
1008    }
1009
1010    /// Get operator weights (exposureBps) for all operators in a service
1011    ///
1012    /// Returns a map of operator address to their exposure in basis points.
1013    /// This is useful for stake-weighted BLS signature threshold calculations.
1014    pub async fn get_service_operator_weights(
1015        &self,
1016        service_id: u64,
1017    ) -> Result<BTreeMap<Address, u16>> {
1018        let operators = self.get_service_operators(service_id).await?;
1019        let mut weights = BTreeMap::new();
1020
1021        for operator in operators {
1022            let op_info = self.get_service_operator(service_id, operator).await?;
1023            if op_info.active {
1024                weights.insert(operator, op_info.exposureBps);
1025            }
1026        }
1027
1028        Ok(weights)
1029    }
1030
1031    /// Register the current operator for a blueprint.
1032    pub async fn register_operator(
1033        &self,
1034        blueprint_id: u64,
1035        rpc_endpoint: impl Into<String>,
1036        registration_inputs: Option<Bytes>,
1037    ) -> Result<TransactionResult> {
1038        use crate::contracts::ITangle::{registerOperator_0Call, registerOperator_1Call};
1039
1040        let wallet = self.wallet()?;
1041        let from_address = wallet.default_signer().address();
1042        let provider = ProviderBuilder::new()
1043            .wallet(wallet)
1044            .connect(self.config.http_rpc_endpoint.as_str())
1045            .await
1046            .map_err(Error::Transport)?;
1047
1048        let signing_key = self.ecdsa_signing_key()?;
1049        let verifying = signing_key.verifying_key();
1050        // Use uncompressed SEC1 format (65 bytes starting with 0x04)
1051        // The contract expects uncompressed public keys, not compressed (33 bytes)
1052        let encoded_point = verifying.0.to_encoded_point(false);
1053        let ecdsa_bytes = Bytes::copy_from_slice(encoded_point.as_bytes());
1054        let rpc_endpoint = rpc_endpoint.into();
1055
1056        let receipt = if let Some(inputs) = registration_inputs {
1057            let tx_request = TransactionRequest::default().to(self.tangle_address).input(
1058                registerOperator_0Call {
1059                    blueprintId: blueprint_id,
1060                    ecdsaPublicKey: ecdsa_bytes.clone(),
1061                    rpcAddress: rpc_endpoint.clone(),
1062                    registrationInputs: inputs,
1063                }
1064                .abi_encode()
1065                .into(),
1066            );
1067            send_transaction_with_fallback_gas(
1068                &provider,
1069                from_address,
1070                tx_request,
1071                REGISTER_BLUEPRINT_OPERATOR_MIN_GAS_LIMIT,
1072            )
1073            .await?
1074        } else {
1075            let tx_request = TransactionRequest::default().to(self.tangle_address).input(
1076                registerOperator_1Call {
1077                    blueprintId: blueprint_id,
1078                    ecdsaPublicKey: ecdsa_bytes.clone(),
1079                    rpcAddress: rpc_endpoint.clone(),
1080                }
1081                .abi_encode()
1082                .into(),
1083            );
1084            send_transaction_with_fallback_gas(
1085                &provider,
1086                from_address,
1087                tx_request,
1088                REGISTER_BLUEPRINT_OPERATOR_MIN_GAS_LIMIT,
1089            )
1090            .await?
1091        };
1092
1093        Ok(transaction_result_from_receipt(&receipt))
1094    }
1095
1096    /// Unregister the current operator from a blueprint.
1097    pub async fn unregister_operator(&self, blueprint_id: u64) -> Result<TransactionResult> {
1098        let wallet = self.wallet()?;
1099        let provider = ProviderBuilder::new()
1100            .wallet(wallet)
1101            .connect(self.config.http_rpc_endpoint.as_str())
1102            .await
1103            .map_err(Error::Transport)?;
1104        let contract = ITangle::new(self.tangle_address, &provider);
1105
1106        let receipt = contract
1107            .unregisterOperator(blueprint_id)
1108            .send()
1109            .await
1110            .map_err(|e| Error::Contract(e.to_string()))?
1111            .get_receipt()
1112            .await?;
1113
1114        Ok(transaction_result_from_receipt(&receipt))
1115    }
1116
1117    /// Get the number of registered blueprints.
1118    pub async fn blueprint_count(&self) -> Result<u64> {
1119        let contract = self.tangle_contract();
1120        contract
1121            .blueprintCount()
1122            .call()
1123            .await
1124            .map_err(|e| Error::Contract(e.to_string()))
1125    }
1126
1127    /// Get the number of registered services.
1128    pub async fn service_count(&self) -> Result<u64> {
1129        let contract = self.tangle_contract();
1130        contract
1131            .serviceCount()
1132            .call()
1133            .await
1134            .map_err(|e| Error::Contract(e.to_string()))
1135    }
1136
1137    /// Get a service request by ID.
1138    pub async fn get_service_request(
1139        &self,
1140        request_id: u64,
1141    ) -> Result<ITangleTypes::ServiceRequest> {
1142        let contract = self.tangle_contract();
1143        contract
1144            .getServiceRequest(request_id)
1145            .call()
1146            .await
1147            .map_err(|e| Error::Contract(e.to_string()))
1148    }
1149
1150    /// Get the total number of service requests ever created.
1151    pub async fn service_request_count(&self) -> Result<u64> {
1152        let mut data = Vec::with_capacity(4);
1153        let selector = keccak256("serviceRequestCount()".as_bytes());
1154        data.extend_from_slice(&selector[..4]);
1155
1156        let mut request = TransactionRequest::default();
1157        request.to = Some(TxKind::Call(self.tangle_address));
1158        request.input = TransactionInput::new(Bytes::from(data));
1159
1160        let response = self
1161            .provider
1162            .call(request)
1163            .await
1164            .map_err(Error::Transport)?;
1165
1166        if response.len() < 32 {
1167            return Err(Error::Contract(
1168                "serviceRequestCount returned malformed data".into(),
1169            ));
1170        }
1171
1172        let raw = response.as_ref();
1173        let mut buf = [0u8; 8];
1174        buf.copy_from_slice(&raw[24..32]);
1175        Ok(u64::from_be_bytes(buf))
1176    }
1177
1178    /// Fetch metadata recorded for a specific job call.
1179    pub async fn get_job_call(
1180        &self,
1181        service_id: u64,
1182        call_id: u64,
1183    ) -> Result<ITangleTypes::JobCall> {
1184        let contract = self.tangle_contract();
1185        contract
1186            .getJobCall(service_id, call_id)
1187            .call()
1188            .await
1189            .map_err(|e| Error::Contract(e.to_string()))
1190    }
1191
1192    /// Fetch the operator's current RPC endpoint for a blueprint from events.
1193    ///
1194    /// tnt-core v0.18.0 stopped persisting `rpcAddress` (getOperatorPreferences
1195    /// always returns an empty string); the endpoint rides `OperatorRegistered` /
1196    /// `OperatorPreferencesUpdated` in full on every change. This scans backward
1197    /// from the chain head, filtered to the Tangle contract + `(blueprintId,
1198    /// operator)` topics, and returns the newest event's `rpcAddress`.
1199    ///
1200    /// # Errors
1201    /// Returns [`Error::Contract`] when no matching event exists in the scanned
1202    /// history (unregistered operator, or a pruned non-archive RPC).
1203    pub async fn get_operator_rpc_endpoint(
1204        &self,
1205        blueprint_id: u64,
1206        operator: Address,
1207    ) -> Result<String> {
1208        use crate::contracts::ITangle::{OperatorPreferencesUpdated, OperatorRegistered};
1209
1210        let mut id_topic = [0u8; 32];
1211        id_topic[24..].copy_from_slice(&blueprint_id.to_be_bytes());
1212        let id_topic = B256::from(id_topic);
1213
1214        let head = self.block_number().await?;
1215        let mut to_block = head;
1216        loop {
1217            let from_block = to_block.saturating_sub(self.max_getlogs_range.saturating_sub(1));
1218            let filter = Filter::new()
1219                .address(self.tangle_address)
1220                .event_signature(vec![
1221                    OperatorRegistered::SIGNATURE_HASH,
1222                    OperatorPreferencesUpdated::SIGNATURE_HASH,
1223                ])
1224                .topic1(id_topic)
1225                .topic2(operator.into_word())
1226                .from_block(from_block)
1227                .to_block(to_block);
1228
1229            let logs = self.get_logs(&filter).await?;
1230            // Newest wins: the latest registration/update carries the live endpoint.
1231            for log in logs.iter().rev() {
1232                let topic0 = log.topic0().copied();
1233                let rpc_address = if topic0 == Some(OperatorRegistered::SIGNATURE_HASH) {
1234                    OperatorRegistered::decode_log(&log.inner)
1235                        .map(|e| e.rpcAddress.clone())
1236                        .ok()
1237                } else if topic0 == Some(OperatorPreferencesUpdated::SIGNATURE_HASH) {
1238                    OperatorPreferencesUpdated::decode_log(&log.inner)
1239                        .map(|e| e.rpcAddress.clone())
1240                        .ok()
1241                } else {
1242                    None
1243                };
1244                if let Some(rpc_address) = rpc_address {
1245                    return Ok(rpc_address);
1246                }
1247            }
1248
1249            if from_block == 0 {
1250                break;
1251            }
1252            to_block = from_block.saturating_sub(1);
1253        }
1254
1255        Err(Error::Contract(format!(
1256            "no OperatorRegistered/OperatorPreferencesUpdated event found for blueprint \
1257             {blueprint_id} operator {operator} (unregistered, or logs pruned on this RPC)"
1258        )))
1259    }
1260
1261    /// Fetch operator metadata (ECDSA public key + RPC endpoint) for a blueprint.
1262    ///
1263    /// `rpc_endpoint` is event-sourced (tnt-core v0.18.0 keeps storage empty).
1264    /// When the event history is unavailable (pruned RPC), the endpoint degrades
1265    /// to an empty string with a warning — key and restaking data still come
1266    /// from storage. Use [`Self::get_operator_rpc_endpoint`] directly when a
1267    /// missing endpoint must be a hard error.
1268    pub async fn get_operator_metadata(
1269        &self,
1270        blueprint_id: u64,
1271        operator: Address,
1272    ) -> Result<OperatorMetadata> {
1273        let contract = self.tangle_contract();
1274        let prefs = contract
1275            .getOperatorPreferences(blueprint_id, operator)
1276            .call()
1277            .await
1278            .map_err(|e| Error::Contract(format!("getOperatorPreferences failed: {e}")))?;
1279        let restaking_meta = self
1280            .staking_contract()
1281            .getOperatorMetadata(operator)
1282            .call()
1283            .await
1284            .map_err(|e| Error::Contract(format!("getOperatorMetadata failed: {e}")))?;
1285        let public_key = normalize_public_key(&prefs.ecdsaPublicKey.0)?;
1286        let rpc_endpoint = match self.get_operator_rpc_endpoint(blueprint_id, operator).await {
1287            Ok(endpoint) => endpoint,
1288            Err(e) => {
1289                tracing::warn!(
1290                    blueprint_id,
1291                    %operator,
1292                    error = %e,
1293                    "operator rpc endpoint unavailable from events; returning empty endpoint"
1294                );
1295                String::new()
1296            }
1297        };
1298        Ok(OperatorMetadata {
1299            public_key,
1300            rpc_endpoint,
1301            restaking: RestakingMetadata {
1302                stake: restaking_meta.stake,
1303                delegation_count: restaking_meta.delegationCount,
1304                status: RestakingStatus::from(restaking_meta.status),
1305                leaving_round: restaking_meta.leavingRound,
1306            },
1307        })
1308    }
1309
1310    /// Submit a service request.
1311    #[allow(clippy::too_many_arguments)]
1312    pub async fn request_service(
1313        &self,
1314        params: ServiceRequestParams,
1315    ) -> Result<(TransactionResult, u64)> {
1316        use crate::contracts::ITangle::{
1317            requestServiceCall, requestServiceWithExposureCall, requestServiceWithSecurityCall,
1318        };
1319
1320        let wallet = self.wallet()?;
1321        let from_address = wallet.default_signer().address();
1322        let provider = ProviderBuilder::new()
1323            .wallet(wallet)
1324            .connect(self.config.http_rpc_endpoint.as_str())
1325            .await
1326            .map_err(Error::Transport)?;
1327        let contract = ITangle::new(self.tangle_address, &provider);
1328
1329        let ServiceRequestParams {
1330            blueprint_id,
1331            operators,
1332            operator_exposures,
1333            permitted_callers,
1334            config,
1335            ttl,
1336            payment_token,
1337            payment_amount,
1338            security_requirements,
1339        } = params;
1340        let confidentiality = 0u8;
1341
1342        let is_native_payment = payment_token == Address::ZERO && payment_amount > U256::ZERO;
1343
1344        // Auto-approve ERC-20 spending before the contract call (matches restaking pattern).
1345        if payment_token != Address::ZERO && payment_amount > U256::ZERO {
1346            self.erc20_approve(payment_token, self.tangle_address, payment_amount)
1347                .await?;
1348        }
1349
1350        let request_id_hint = if !security_requirements.is_empty() {
1351            let mut call = contract.requestServiceWithSecurity(
1352                blueprint_id,
1353                operators.clone(),
1354                security_requirements.clone(),
1355                config.clone(),
1356                permitted_callers.clone(),
1357                ttl,
1358                payment_token,
1359                payment_amount,
1360                confidentiality,
1361            );
1362            call = call.from(self.account());
1363            if is_native_payment {
1364                call = call.value(payment_amount);
1365            }
1366            call.call().await.ok()
1367        } else if let Some(ref exposures) = operator_exposures {
1368            let mut call = contract.requestServiceWithExposure(
1369                blueprint_id,
1370                operators.clone(),
1371                exposures.clone(),
1372                config.clone(),
1373                permitted_callers.clone(),
1374                ttl,
1375                payment_token,
1376                payment_amount,
1377                confidentiality,
1378            );
1379            call = call.from(self.account());
1380            if is_native_payment {
1381                call = call.value(payment_amount);
1382            }
1383            call.call().await.ok()
1384        } else {
1385            let mut call = contract.requestService(
1386                blueprint_id,
1387                operators.clone(),
1388                config.clone(),
1389                permitted_callers.clone(),
1390                ttl,
1391                payment_token,
1392                payment_amount,
1393                confidentiality,
1394            );
1395            call = call.from(self.account());
1396            if is_native_payment {
1397                call = call.value(payment_amount);
1398            }
1399            call.call().await.ok()
1400        };
1401        let pre_count = self.service_request_count().await.ok();
1402
1403        let receipt = if !security_requirements.is_empty() {
1404            let mut tx_request = TransactionRequest::default().to(self.tangle_address).input(
1405                requestServiceWithSecurityCall {
1406                    blueprintId: blueprint_id,
1407                    operators: operators.clone(),
1408                    securityRequirements: security_requirements.clone(),
1409                    config: config.clone(),
1410                    permittedCallers: permitted_callers.clone(),
1411                    ttl,
1412                    paymentToken: payment_token,
1413                    paymentAmount: payment_amount,
1414                    confidentiality,
1415                }
1416                .abi_encode()
1417                .into(),
1418            );
1419            if is_native_payment {
1420                tx_request = tx_request.value(payment_amount);
1421            }
1422            send_transaction_with_fallback_gas(
1423                &provider,
1424                from_address,
1425                tx_request,
1426                REQUEST_SERVICE_MIN_GAS_LIMIT,
1427            )
1428            .await
1429        } else if let Some(exposures) = operator_exposures {
1430            let mut tx_request = TransactionRequest::default().to(self.tangle_address).input(
1431                requestServiceWithExposureCall {
1432                    blueprintId: blueprint_id,
1433                    operators: operators.clone(),
1434                    exposureBps: exposures,
1435                    config: config.clone(),
1436                    permittedCallers: permitted_callers.clone(),
1437                    ttl,
1438                    paymentToken: payment_token,
1439                    paymentAmount: payment_amount,
1440                    confidentiality,
1441                }
1442                .abi_encode()
1443                .into(),
1444            );
1445            if is_native_payment {
1446                tx_request = tx_request.value(payment_amount);
1447            }
1448            send_transaction_with_fallback_gas(
1449                &provider,
1450                from_address,
1451                tx_request,
1452                REQUEST_SERVICE_MIN_GAS_LIMIT,
1453            )
1454            .await
1455        } else {
1456            let mut tx_request = TransactionRequest::default().to(self.tangle_address).input(
1457                requestServiceCall {
1458                    blueprintId: blueprint_id,
1459                    operators: operators.clone(),
1460                    config: config.clone(),
1461                    permittedCallers: permitted_callers.clone(),
1462                    ttl,
1463                    paymentToken: payment_token,
1464                    paymentAmount: payment_amount,
1465                    confidentiality,
1466                }
1467                .abi_encode()
1468                .into(),
1469            );
1470            if is_native_payment {
1471                tx_request = tx_request.value(payment_amount);
1472            }
1473            send_transaction_with_fallback_gas(
1474                &provider,
1475                from_address,
1476                tx_request,
1477                REQUEST_SERVICE_MIN_GAS_LIMIT,
1478            )
1479            .await
1480        }
1481        .map_err(|e| Error::Contract(e.to_string()))?;
1482        if !receipt.status() {
1483            return Err(Error::Contract(
1484                "requestService transaction reverted".into(),
1485            ));
1486        }
1487
1488        let request_id = match self.extract_request_id(&receipt, blueprint_id).await {
1489            Ok(id) => id,
1490            Err(err) => {
1491                if let Some(id) = request_id_hint {
1492                    return Ok((transaction_result_from_receipt(&receipt), id));
1493                }
1494                if let Some(count) = pre_count {
1495                    return Ok((transaction_result_from_receipt(&receipt), count));
1496                }
1497                return Err(err);
1498            }
1499        };
1500
1501        Ok((transaction_result_from_receipt(&receipt), request_id))
1502    }
1503
1504    /// Join a dynamic service with the requested exposure.
1505    pub async fn join_service(
1506        &self,
1507        service_id: u64,
1508        exposure_bps: u16,
1509    ) -> Result<TransactionResult> {
1510        let wallet = self.wallet()?;
1511        let provider = ProviderBuilder::new()
1512            .wallet(wallet)
1513            .connect(self.config.http_rpc_endpoint.as_str())
1514            .await
1515            .map_err(Error::Transport)?;
1516        let contract = ITangle::new(self.tangle_address, &provider);
1517
1518        let receipt = contract
1519            .joinService(service_id, exposure_bps)
1520            .send()
1521            .await
1522            .map_err(|e| Error::Contract(e.to_string()))?
1523            .get_receipt()
1524            .await?;
1525
1526        Ok(transaction_result_from_receipt(&receipt))
1527    }
1528
1529    /// Join a dynamic service with the requested exposure and explicit security commitments.
1530    ///
1531    /// Use this method when the service has security requirements that mandate operators
1532    /// provide asset commitments when joining.
1533    pub async fn join_service_with_commitments(
1534        &self,
1535        service_id: u64,
1536        exposure_bps: u16,
1537        commitments: Vec<ITangleTypes::AssetSecurityCommitment>,
1538    ) -> Result<TransactionResult> {
1539        let wallet = self.wallet()?;
1540        let provider = ProviderBuilder::new()
1541            .wallet(wallet)
1542            .connect(self.config.http_rpc_endpoint.as_str())
1543            .await
1544            .map_err(Error::Transport)?;
1545        let contract = ITangle::new(self.tangle_address, &provider);
1546
1547        let receipt = contract
1548            .joinServiceWithCommitments(service_id, exposure_bps, commitments)
1549            .send()
1550            .await
1551            .map_err(|e| Error::Contract(e.to_string()))?
1552            .get_receipt()
1553            .await?;
1554
1555        Ok(transaction_result_from_receipt(&receipt))
1556    }
1557
1558    /// Leave a dynamic service using the legacy immediate exit helper.
1559    ///
1560    /// Note: This only works when `exitQueueDuration == 0`. For services with
1561    /// an exit queue (default 7 days), use the exit queue workflow:
1562    /// 1. `schedule_exit()` - Enter the exit queue
1563    /// 2. Wait for exit queue duration
1564    /// 3. `execute_exit()` - Complete the exit
1565    pub async fn leave_service(&self, service_id: u64) -> Result<TransactionResult> {
1566        let wallet = self.wallet()?;
1567        let provider = ProviderBuilder::new()
1568            .wallet(wallet)
1569            .connect(self.config.http_rpc_endpoint.as_str())
1570            .await
1571            .map_err(Error::Transport)?;
1572        let contract = ITangle::new(self.tangle_address, &provider);
1573
1574        let receipt = contract
1575            .leaveService(service_id)
1576            .send()
1577            .await
1578            .map_err(|e| Error::Contract(e.to_string()))?
1579            .get_receipt()
1580            .await?;
1581
1582        Ok(transaction_result_from_receipt(&receipt))
1583    }
1584
1585    /// Schedule an exit from a dynamic service.
1586    ///
1587    /// This enters the operator into the exit queue. After the exit queue duration
1588    /// has passed (default 7 days), call `execute_exit()` to complete the exit.
1589    ///
1590    /// Requires that the operator has fulfilled the minimum commitment duration
1591    /// since joining the service.
1592    pub async fn schedule_exit(&self, service_id: u64) -> Result<TransactionResult> {
1593        let wallet = self.wallet()?;
1594        let provider = ProviderBuilder::new()
1595            .wallet(wallet)
1596            .connect(self.config.http_rpc_endpoint.as_str())
1597            .await
1598            .map_err(Error::Transport)?;
1599        let contract = ITangle::new(self.tangle_address, &provider);
1600
1601        let receipt = contract
1602            .scheduleExit(service_id)
1603            .send()
1604            .await
1605            .map_err(|e| Error::Contract(e.to_string()))?
1606            .get_receipt()
1607            .await?;
1608
1609        Ok(transaction_result_from_receipt(&receipt))
1610    }
1611
1612    /// Execute a previously scheduled exit from a dynamic service.
1613    ///
1614    /// This completes the exit after the exit queue duration has passed.
1615    /// Must be called after `schedule_exit()` and waiting for the queue duration.
1616    pub async fn execute_exit(&self, service_id: u64) -> Result<TransactionResult> {
1617        let wallet = self.wallet()?;
1618        let provider = ProviderBuilder::new()
1619            .wallet(wallet)
1620            .connect(self.config.http_rpc_endpoint.as_str())
1621            .await
1622            .map_err(Error::Transport)?;
1623        let contract = ITangle::new(self.tangle_address, &provider);
1624
1625        let receipt = contract
1626            .executeExit(service_id)
1627            .send()
1628            .await
1629            .map_err(|e| Error::Contract(e.to_string()))?
1630            .get_receipt()
1631            .await?;
1632
1633        Ok(transaction_result_from_receipt(&receipt))
1634    }
1635
1636    /// Cancel a previously scheduled exit from a dynamic service.
1637    ///
1638    /// This cancels the exit and keeps the operator in the service.
1639    /// Can only be called before `execute_exit()`.
1640    pub async fn cancel_exit(&self, service_id: u64) -> Result<TransactionResult> {
1641        let wallet = self.wallet()?;
1642        let provider = ProviderBuilder::new()
1643            .wallet(wallet)
1644            .connect(self.config.http_rpc_endpoint.as_str())
1645            .await
1646            .map_err(Error::Transport)?;
1647        let contract = ITangle::new(self.tangle_address, &provider);
1648
1649        let receipt = contract
1650            .cancelExit(service_id)
1651            .send()
1652            .await
1653            .map_err(|e| Error::Contract(e.to_string()))?
1654            .get_receipt()
1655            .await?;
1656
1657        Ok(transaction_result_from_receipt(&receipt))
1658    }
1659
1660    /// Approve a pending service request via the unified `approveService(ApprovalParams)`
1661    /// entrypoint. Pass empty `commitments`, zero `bls_pubkey`, zero `bls_pop_signature`,
1662    /// and empty `tee_commitments` to opt out of those capabilities.
1663    ///
1664    /// Convenience wrappers `approve_service` and `approve_service_with_commitments` build
1665    /// the `ApprovalParams` for the common shapes; use this method directly when you also
1666    /// need to register a BLS pubkey or pin TEE attestation profiles.
1667    pub async fn approve_service_with_params(
1668        &self,
1669        params: ITangleTypes::ApprovalParams,
1670    ) -> Result<TransactionResult> {
1671        use ITangle::approveServiceCall;
1672
1673        let wallet = self.wallet()?;
1674        let from_address = wallet.default_signer().address();
1675        let provider = ProviderBuilder::new()
1676            .wallet(wallet)
1677            .connect(self.config.http_rpc_endpoint.as_str())
1678            .await
1679            .map_err(Error::Transport)?;
1680
1681        let calldata = approveServiceCall { params }.abi_encode();
1682        let tx_request = TransactionRequest::default()
1683            .to(self.tangle_address)
1684            .input(Bytes::from(calldata).into());
1685
1686        // Approval gas is operator-linear under the v0.11+ root storage; the floor
1687        // covers the no-estimate fallback (e.g. node returning estimateGas error).
1688        let receipt = send_transaction_with_fallback_gas(
1689            &provider,
1690            from_address,
1691            tx_request,
1692            APPROVE_SERVICE_MIN_GAS_LIMIT,
1693        )
1694        .await?;
1695
1696        Ok(transaction_result_from_receipt(&receipt))
1697    }
1698
1699    /// Approve a pending service request without any optional capabilities (no per-asset
1700    /// commitments, no BLS, no TEE binding). Acceptable when the request has no security
1701    /// requirements, or only the protocol-default TNT requirement (auto-filled on-chain).
1702    pub async fn approve_service(&self, request_id: u64) -> Result<TransactionResult> {
1703        self.approve_service_with_params(ITangleTypes::ApprovalParams {
1704            requestId: request_id,
1705            securityCommitments: Vec::new(),
1706            blsPubkey: [U256::ZERO; 4],
1707            blsPopSignature: [U256::ZERO; 2],
1708            teeCommitments: Vec::new(),
1709        })
1710        .await
1711    }
1712
1713    /// Approve a pending service request with explicit per-asset security commitments.
1714    pub async fn approve_service_with_commitments(
1715        &self,
1716        request_id: u64,
1717        commitments: Vec<ITangleTypes::AssetSecurityCommitment>,
1718    ) -> Result<TransactionResult> {
1719        self.approve_service_with_params(ITangleTypes::ApprovalParams {
1720            requestId: request_id,
1721            securityCommitments: commitments,
1722            blsPubkey: [U256::ZERO; 4],
1723            blsPopSignature: [U256::ZERO; 2],
1724            teeCommitments: Vec::new(),
1725        })
1726        .await
1727    }
1728
1729    /// Reject a pending service request.
1730    pub async fn reject_service(&self, request_id: u64) -> Result<TransactionResult> {
1731        let wallet = self.wallet()?;
1732        let provider = ProviderBuilder::new()
1733            .wallet(wallet)
1734            .connect(self.config.http_rpc_endpoint.as_str())
1735            .await
1736            .map_err(Error::Transport)?;
1737        let contract = ITangle::new(self.tangle_address, &provider);
1738
1739        let receipt = contract
1740            .rejectService(request_id)
1741            .send()
1742            .await
1743            .map_err(|e| Error::Contract(e.to_string()))?
1744            .get_receipt()
1745            .await?;
1746
1747        Ok(transaction_result_from_receipt(&receipt))
1748    }
1749
1750    // ═══════════════════════════════════════════════════════════════════════════
1751    // OPERATOR QUERIES (Restaking)
1752    // ═══════════════════════════════════════════════════════════════════════════
1753
1754    /// Check if address is a registered operator
1755    pub async fn is_operator(&self, operator: Address) -> Result<bool> {
1756        let contract = self.staking_contract();
1757        contract
1758            .isOperator(operator)
1759            .call()
1760            .await
1761            .map_err(|e| Error::Contract(e.to_string()))
1762    }
1763
1764    /// Check if operator is active
1765    pub async fn is_operator_active(&self, operator: Address) -> Result<bool> {
1766        let contract = self.staking_contract();
1767        contract
1768            .isOperatorActive(operator)
1769            .call()
1770            .await
1771            .map_err(|e| Error::Contract(e.to_string()))
1772    }
1773
1774    /// Get operator's total stake
1775    pub async fn get_operator_stake(&self, operator: Address) -> Result<U256> {
1776        let contract = self.staking_contract();
1777        contract
1778            .getOperatorStake(operator)
1779            .call()
1780            .await
1781            .map_err(|e| Error::Contract(e.to_string()))
1782    }
1783
1784    /// Get minimum operator stake requirement
1785    pub async fn min_operator_stake(&self) -> Result<U256> {
1786        let contract = self.staking_contract();
1787        contract
1788            .minOperatorStake()
1789            .call()
1790            .await
1791            .map_err(|e| Error::Contract(e.to_string()))
1792    }
1793
1794    /// Fetch status registry metadata for an operator/service pair.
1795    pub async fn operator_status(
1796        &self,
1797        service_id: u64,
1798        operator: Address,
1799    ) -> Result<OperatorStatusSnapshot> {
1800        if self.status_registry_address.is_zero() {
1801            return Err(Error::MissingStatusRegistry);
1802        }
1803        let contract = self.status_registry_contract();
1804
1805        let last_heartbeat = contract
1806            .getLastHeartbeat(service_id, operator)
1807            .call()
1808            .await
1809            .map_err(|e| Error::Contract(e.to_string()))?;
1810        let status_code = contract
1811            .getOperatorStatus(service_id, operator)
1812            .call()
1813            .await
1814            .map_err(|e| Error::Contract(e.to_string()))?;
1815        let online = contract
1816            .isOnline(service_id, operator)
1817            .call()
1818            .await
1819            .map_err(|e| Error::Contract(e.to_string()))?;
1820
1821        let last_heartbeat = u64::try_from(last_heartbeat).unwrap_or(u64::MAX);
1822
1823        Ok(OperatorStatusSnapshot {
1824            service_id,
1825            operator,
1826            status_code,
1827            last_heartbeat,
1828            online,
1829        })
1830    }
1831
1832    /// Fetch restaking metadata for an operator.
1833    pub async fn get_restaking_metadata(&self, operator: Address) -> Result<RestakingMetadata> {
1834        let restaking_meta = self
1835            .staking_contract()
1836            .getOperatorMetadata(operator)
1837            .call()
1838            .await
1839            .map_err(|e| Error::Contract(format!("getOperatorMetadata failed: {e}")))?;
1840        Ok(RestakingMetadata {
1841            stake: restaking_meta.stake,
1842            delegation_count: restaking_meta.delegationCount,
1843            status: RestakingStatus::from(restaking_meta.status),
1844            leaving_round: restaking_meta.leavingRound,
1845        })
1846    }
1847
1848    /// Get operator self stake from MultiAssetDelegation.
1849    pub async fn get_operator_self_stake(&self, operator: Address) -> Result<U256> {
1850        let contract = self.staking_contract();
1851        contract
1852            .getOperatorSelfStake(operator)
1853            .call()
1854            .await
1855            .map_err(|e| Error::Contract(e.to_string()))
1856    }
1857
1858    /// Get operator delegated stake from MultiAssetDelegation.
1859    pub async fn get_operator_delegated_stake(&self, operator: Address) -> Result<U256> {
1860        let contract = self.staking_contract();
1861        contract
1862            .getOperatorDelegatedStake(operator)
1863            .call()
1864            .await
1865            .map_err(|e| Error::Contract(e.to_string()))
1866    }
1867
1868    /// Get delegators for the operator.
1869    pub async fn get_operator_delegators(&self, operator: Address) -> Result<Vec<Address>> {
1870        let contract = self.staking_contract();
1871        contract
1872            .getOperatorDelegators(operator)
1873            .call()
1874            .await
1875            .map_err(|e| Error::Contract(e.to_string()))
1876    }
1877
1878    /// Get delegator count for the operator.
1879    pub async fn get_operator_delegator_count(&self, operator: Address) -> Result<u64> {
1880        let contract = self.staking_contract();
1881        let count = contract
1882            .getOperatorDelegatorCount(operator)
1883            .call()
1884            .await
1885            .map_err(|e| Error::Contract(e.to_string()))?;
1886        Ok(u64::try_from(count).unwrap_or(u64::MAX))
1887    }
1888
1889    /// Get current restaking round.
1890    pub async fn restaking_round(&self) -> Result<u64> {
1891        let contract = self.staking_contract();
1892        contract
1893            .currentRound()
1894            .call()
1895            .await
1896            .map_err(|e| Error::Contract(e.to_string()))
1897    }
1898
1899    /// Get operator commission (basis points).
1900    pub async fn operator_commission_bps(&self) -> Result<u16> {
1901        let contract = self.staking_contract();
1902        contract
1903            .operatorCommissionBps()
1904            .call()
1905            .await
1906            .map_err(|e| Error::Contract(e.to_string()))
1907    }
1908
1909    // ═══════════════════════════════════════════════════════════════════════════
1910    // OPERATOR DELEGATION CONFIG
1911    // ═══════════════════════════════════════════════════════════════════════════
1912
1913    /// Get operator's delegation mode.
1914    ///
1915    /// Returns the delegation policy for the operator:
1916    /// - Disabled: Only operator can self-stake
1917    /// - Whitelist: Only approved addresses can delegate
1918    /// - Open: Anyone can delegate
1919    pub async fn get_delegation_mode(&self, operator: Address) -> Result<DelegationMode> {
1920        let contract = self.staking_contract();
1921        let mode = contract
1922            .getDelegationMode(operator)
1923            .call()
1924            .await
1925            .map_err(|e| Error::Contract(e.to_string()))?;
1926        Ok(DelegationMode::from(mode))
1927    }
1928
1929    /// Check if delegator is whitelisted for operator.
1930    pub async fn is_delegator_whitelisted(
1931        &self,
1932        operator: Address,
1933        delegator: Address,
1934    ) -> Result<bool> {
1935        let contract = self.staking_contract();
1936        contract
1937            .isWhitelisted(operator, delegator)
1938            .call()
1939            .await
1940            .map_err(|e| Error::Contract(e.to_string()))
1941    }
1942
1943    /// Check if delegator can delegate to operator.
1944    ///
1945    /// This checks the operator's delegation mode and whitelist status.
1946    pub async fn can_delegate(&self, operator: Address, delegator: Address) -> Result<bool> {
1947        let contract = self.staking_contract();
1948        contract
1949            .canDelegate(operator, delegator)
1950            .call()
1951            .await
1952            .map_err(|e| Error::Contract(e.to_string()))
1953    }
1954
1955    /// Set delegation mode for the calling operator.
1956    ///
1957    /// Changes take effect immediately for NEW delegations only.
1958    /// Existing delegations remain valid regardless of mode change.
1959    ///
1960    /// # Arguments
1961    /// * `mode` - Delegation mode: Disabled (0), Whitelist (1), or Open (2)
1962    pub async fn set_delegation_mode(&self, mode: DelegationMode) -> Result<TransactionResult> {
1963        let wallet = self.wallet()?;
1964        let provider = ProviderBuilder::new()
1965            .wallet(wallet)
1966            .connect(self.config.http_rpc_endpoint.as_str())
1967            .await
1968            .map_err(Error::Transport)?;
1969        let contract = IMultiAssetDelegation::new(self.restaking_address, provider);
1970
1971        let mode_value: u8 = match mode {
1972            DelegationMode::Disabled => 0,
1973            DelegationMode::Whitelist => 1,
1974            DelegationMode::Open => 2,
1975            DelegationMode::Unknown(v) => v,
1976        };
1977
1978        let receipt = contract
1979            .setDelegationMode(mode_value)
1980            .send()
1981            .await
1982            .map_err(|e| Error::Contract(e.to_string()))?
1983            .get_receipt()
1984            .await
1985            .map_err(|e| Error::Contract(e.to_string()))?;
1986
1987        Ok(transaction_result_from_receipt(&receipt))
1988    }
1989
1990    /// Update delegation whitelist for the calling operator.
1991    ///
1992    /// Whitelist only applies when delegation mode is set to Whitelist.
1993    /// Can be called regardless of current mode to pre-configure.
1994    ///
1995    /// # Arguments
1996    /// * `delegators` - Array of delegator addresses to update
1997    /// * `approved` - True to approve for delegation, false to revoke
1998    pub async fn set_delegation_whitelist(
1999        &self,
2000        delegators: Vec<Address>,
2001        approved: bool,
2002    ) -> Result<TransactionResult> {
2003        let wallet = self.wallet()?;
2004        let provider = ProviderBuilder::new()
2005            .wallet(wallet)
2006            .connect(self.config.http_rpc_endpoint.as_str())
2007            .await
2008            .map_err(Error::Transport)?;
2009        let contract = IMultiAssetDelegation::new(self.restaking_address, provider);
2010
2011        let receipt = contract
2012            .setDelegationWhitelist(delegators, approved)
2013            .send()
2014            .await
2015            .map_err(|e| Error::Contract(e.to_string()))?
2016            .get_receipt()
2017            .await
2018            .map_err(|e| Error::Contract(e.to_string()))?;
2019
2020        Ok(transaction_result_from_receipt(&receipt))
2021    }
2022
2023    /// Fetch ERC20 allowance for an owner/spender pair.
2024    pub async fn erc20_allowance(
2025        &self,
2026        token: Address,
2027        owner: Address,
2028        spender: Address,
2029    ) -> Result<U256> {
2030        let contract = IERC20::new(token, Arc::clone(&self.provider));
2031        contract
2032            .allowance(owner, spender)
2033            .call()
2034            .await
2035            .map_err(|e| Error::Contract(e.to_string()))
2036    }
2037
2038    /// Fetch ERC20 balance for an owner.
2039    pub async fn erc20_balance(&self, token: Address, owner: Address) -> Result<U256> {
2040        let contract = IERC20::new(token, Arc::clone(&self.provider));
2041        contract
2042            .balanceOf(owner)
2043            .call()
2044            .await
2045            .map_err(|e| Error::Contract(e.to_string()))
2046    }
2047
2048    /// Approve ERC20 spending for the given spender.
2049    pub async fn erc20_approve(
2050        &self,
2051        token: Address,
2052        spender: Address,
2053        amount: U256,
2054    ) -> Result<TransactionResult> {
2055        use crate::client::IERC20::approveCall;
2056
2057        let wallet = self.wallet()?;
2058        let from_address = wallet.default_signer().address();
2059        let provider = ProviderBuilder::new()
2060            .wallet(wallet)
2061            .connect(self.config.http_rpc_endpoint.as_str())
2062            .await
2063            .map_err(Error::Transport)?;
2064        let tx_request = TransactionRequest::default()
2065            .to(token)
2066            .input(approveCall { spender, amount }.abi_encode().into());
2067        let receipt = send_transaction_with_fallback_gas(
2068            &provider,
2069            from_address,
2070            tx_request,
2071            ERC20_APPROVE_MIN_GAS_LIMIT,
2072        )
2073        .await?;
2074
2075        Ok(transaction_result_from_receipt(&receipt))
2076    }
2077
2078    /// Fetch delegator deposit info for a token.
2079    pub async fn get_deposit_info(
2080        &self,
2081        delegator: Address,
2082        token: Address,
2083    ) -> Result<DepositInfo> {
2084        let contract = self.staking_contract();
2085        let deposit = contract
2086            .getDeposit(delegator, token)
2087            .call()
2088            .await
2089            .map_err(|e| Error::Contract(e.to_string()))?;
2090        Ok(DepositInfo {
2091            amount: deposit.amount,
2092            delegated_amount: deposit.delegatedAmount,
2093        })
2094    }
2095
2096    /// Fetch lock info for a token.
2097    pub async fn get_locks(&self, delegator: Address, token: Address) -> Result<Vec<LockInfo>> {
2098        let contract = self.staking_contract();
2099        let locks = contract
2100            .getLocks(delegator, token)
2101            .call()
2102            .await
2103            .map_err(|e| Error::Contract(e.to_string()))?;
2104        Ok(locks
2105            .into_iter()
2106            .map(|lock| LockInfo {
2107                amount: lock.amount,
2108                multiplier: LockMultiplier::from(lock.multiplier),
2109                expiry_timestamp: lock.expiryTimestamp,
2110            })
2111            .collect())
2112    }
2113
2114    /// Fetch delegations for a delegator.
2115    pub async fn get_delegations(&self, delegator: Address) -> Result<Vec<DelegationInfo>> {
2116        let contract = self.staking_contract();
2117        let delegations = contract
2118            .getDelegations(delegator)
2119            .call()
2120            .await
2121            .map_err(|e| Error::Contract(e.to_string()))?;
2122        Ok(delegations
2123            .into_iter()
2124            .map(|delegation| DelegationInfo {
2125                operator: delegation.operator,
2126                shares: delegation.shares,
2127                asset: asset_info_from_types(delegation.asset),
2128                selection_mode: BlueprintSelectionMode::from(delegation.selectionMode),
2129            })
2130            .collect())
2131    }
2132
2133    /// Fetch delegations with blueprint selections attached.
2134    pub async fn get_delegations_with_blueprints(
2135        &self,
2136        delegator: Address,
2137    ) -> Result<Vec<DelegationRecord>> {
2138        let delegations = self.get_delegations(delegator).await?;
2139        let mut records = Vec::with_capacity(delegations.len());
2140        for (idx, info) in delegations.into_iter().enumerate() {
2141            let blueprint_ids = if matches!(info.selection_mode, BlueprintSelectionMode::Fixed) {
2142                self.get_delegation_blueprints(delegator, idx as u64)
2143                    .await?
2144            } else {
2145                Vec::new()
2146            };
2147            records.push(DelegationRecord {
2148                info,
2149                blueprint_ids,
2150            });
2151        }
2152        Ok(records)
2153    }
2154
2155    /// Fetch blueprint IDs for a fixed delegation.
2156    pub async fn get_delegation_blueprints(
2157        &self,
2158        delegator: Address,
2159        index: u64,
2160    ) -> Result<Vec<u64>> {
2161        let contract = self.staking_contract();
2162        let ids = contract
2163            .getDelegationBlueprints(delegator, U256::from(index))
2164            .call()
2165            .await
2166            .map_err(|e| Error::Contract(e.to_string()))?;
2167        Ok(ids)
2168    }
2169
2170    /// Fetch pending delegator unstakes.
2171    pub async fn get_pending_unstakes(&self, delegator: Address) -> Result<Vec<PendingUnstake>> {
2172        let contract = self.staking_contract();
2173        let unstakes = contract
2174            .getPendingUnstakes(delegator)
2175            .call()
2176            .await
2177            .map_err(|e| Error::Contract(e.to_string()))?;
2178        Ok(unstakes
2179            .into_iter()
2180            .map(|request| PendingUnstake {
2181                operator: request.operator,
2182                asset: asset_info_from_types(request.asset),
2183                shares: request.shares,
2184                requested_round: request.requestedRound,
2185                selection_mode: BlueprintSelectionMode::from(request.selectionMode),
2186                slash_factor_snapshot: request.slashFactorSnapshot,
2187            })
2188            .collect())
2189    }
2190
2191    /// Fetch pending delegator withdrawals.
2192    pub async fn get_pending_withdrawals(
2193        &self,
2194        delegator: Address,
2195    ) -> Result<Vec<PendingWithdrawal>> {
2196        let contract = self.staking_contract();
2197        let withdrawals = contract
2198            .getPendingWithdrawals(delegator)
2199            .call()
2200            .await
2201            .map_err(|e| Error::Contract(e.to_string()))?;
2202        Ok(withdrawals
2203            .into_iter()
2204            .map(|request| PendingWithdrawal {
2205                asset: asset_info_from_types(request.asset),
2206                amount: request.amount,
2207                requested_round: request.requestedRound,
2208            })
2209            .collect())
2210    }
2211
2212    /// Deposit and delegate in a single call.
2213    pub async fn deposit_and_delegate_with_options(
2214        &self,
2215        operator: Address,
2216        token: Address,
2217        amount: U256,
2218        selection_mode: BlueprintSelectionMode,
2219        blueprint_ids: Vec<u64>,
2220    ) -> Result<TransactionResult> {
2221        let wallet = self.wallet()?;
2222        let provider = ProviderBuilder::new()
2223            .wallet(wallet)
2224            .connect(self.config.http_rpc_endpoint.as_str())
2225            .await
2226            .map_err(Error::Transport)?;
2227        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2228
2229        let mut call = contract.depositAndDelegateWithOptions(
2230            operator,
2231            token,
2232            amount,
2233            selection_mode_to_u8(selection_mode),
2234            blueprint_ids,
2235        );
2236        if token == Address::ZERO {
2237            call = call.value(amount);
2238        }
2239
2240        let receipt = call
2241            .send()
2242            .await
2243            .map_err(|e| Error::Contract(e.to_string()))?
2244            .get_receipt()
2245            .await?;
2246
2247        Ok(transaction_result_from_receipt(&receipt))
2248    }
2249
2250    /// Delegate existing deposits with explicit selection.
2251    pub async fn delegate_with_options(
2252        &self,
2253        operator: Address,
2254        token: Address,
2255        amount: U256,
2256        selection_mode: BlueprintSelectionMode,
2257        blueprint_ids: Vec<u64>,
2258    ) -> Result<TransactionResult> {
2259        let wallet = self.wallet()?;
2260        let provider = ProviderBuilder::new()
2261            .wallet(wallet)
2262            .connect(self.config.http_rpc_endpoint.as_str())
2263            .await
2264            .map_err(Error::Transport)?;
2265        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2266
2267        let receipt = contract
2268            .delegateWithOptions(
2269                operator,
2270                token,
2271                amount,
2272                selection_mode_to_u8(selection_mode),
2273                blueprint_ids,
2274            )
2275            .send()
2276            .await
2277            .map_err(|e| Error::Contract(e.to_string()))?
2278            .get_receipt()
2279            .await?;
2280
2281        Ok(transaction_result_from_receipt(&receipt))
2282    }
2283
2284    /// Schedule a delegator unstake (bond-less).
2285    pub async fn schedule_delegator_unstake(
2286        &self,
2287        operator: Address,
2288        token: Address,
2289        amount: U256,
2290    ) -> Result<TransactionResult> {
2291        let wallet = self.wallet()?;
2292        let provider = ProviderBuilder::new()
2293            .wallet(wallet)
2294            .connect(self.config.http_rpc_endpoint.as_str())
2295            .await
2296            .map_err(Error::Transport)?;
2297        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2298
2299        let receipt = contract
2300            .scheduleDelegatorUnstake(operator, token, amount)
2301            .send()
2302            .await
2303            .map_err(|e| Error::Contract(e.to_string()))?
2304            .get_receipt()
2305            .await?;
2306
2307        Ok(transaction_result_from_receipt(&receipt))
2308    }
2309
2310    /// Execute any matured delegator unstake requests.
2311    pub async fn execute_delegator_unstake(&self) -> Result<TransactionResult> {
2312        let wallet = self.wallet()?;
2313        let provider = ProviderBuilder::new()
2314            .wallet(wallet)
2315            .connect(self.config.http_rpc_endpoint.as_str())
2316            .await
2317            .map_err(Error::Transport)?;
2318        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2319
2320        let receipt = contract
2321            .executeDelegatorUnstake()
2322            .send()
2323            .await
2324            .map_err(|e| Error::Contract(e.to_string()))?
2325            .get_receipt()
2326            .await?;
2327
2328        Ok(transaction_result_from_receipt(&receipt))
2329    }
2330
2331    /// Execute a specific delegator unstake and withdraw.
2332    pub async fn execute_delegator_unstake_and_withdraw(
2333        &self,
2334        operator: Address,
2335        token: Address,
2336        shares: U256,
2337        requested_round: u64,
2338        receiver: Address,
2339    ) -> Result<TransactionResult> {
2340        let wallet = self.wallet()?;
2341        let provider = ProviderBuilder::new()
2342            .wallet(wallet)
2343            .connect(self.config.http_rpc_endpoint.as_str())
2344            .await
2345            .map_err(Error::Transport)?;
2346        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2347
2348        let receipt = contract
2349            .executeDelegatorUnstakeAndWithdraw(operator, token, shares, requested_round, receiver)
2350            .send()
2351            .await
2352            .map_err(|e| Error::Contract(e.to_string()))?
2353            .get_receipt()
2354            .await?;
2355
2356        Ok(transaction_result_from_receipt(&receipt))
2357    }
2358
2359    /// Schedule a withdrawal for a token.
2360    pub async fn schedule_withdraw(
2361        &self,
2362        token: Address,
2363        amount: U256,
2364    ) -> Result<TransactionResult> {
2365        let wallet = self.wallet()?;
2366        let provider = ProviderBuilder::new()
2367            .wallet(wallet)
2368            .connect(self.config.http_rpc_endpoint.as_str())
2369            .await
2370            .map_err(Error::Transport)?;
2371        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2372
2373        let receipt = contract
2374            .scheduleWithdraw(token, amount)
2375            .send()
2376            .await
2377            .map_err(|e| Error::Contract(e.to_string()))?
2378            .get_receipt()
2379            .await?;
2380
2381        Ok(transaction_result_from_receipt(&receipt))
2382    }
2383
2384    /// Execute any matured withdrawal requests.
2385    pub async fn execute_withdraw(&self) -> Result<TransactionResult> {
2386        let wallet = self.wallet()?;
2387        let provider = ProviderBuilder::new()
2388            .wallet(wallet)
2389            .connect(self.config.http_rpc_endpoint.as_str())
2390            .await
2391            .map_err(Error::Transport)?;
2392        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2393
2394        let receipt = contract
2395            .executeWithdraw()
2396            .send()
2397            .await
2398            .map_err(|e| Error::Contract(e.to_string()))?
2399            .get_receipt()
2400            .await?;
2401
2402        Ok(transaction_result_from_receipt(&receipt))
2403    }
2404
2405    /// Schedule an operator unstake.
2406    pub async fn schedule_operator_unstake(&self, amount: U256) -> Result<TransactionResult> {
2407        let wallet = self.wallet()?;
2408        let provider = ProviderBuilder::new()
2409            .wallet(wallet)
2410            .connect(self.config.http_rpc_endpoint.as_str())
2411            .await
2412            .map_err(Error::Transport)?;
2413        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2414
2415        let receipt = contract
2416            .scheduleOperatorUnstake(amount)
2417            .send()
2418            .await
2419            .map_err(|e| Error::Contract(e.to_string()))?
2420            .get_receipt()
2421            .await?;
2422
2423        Ok(transaction_result_from_receipt(&receipt))
2424    }
2425
2426    /// Execute an operator unstake.
2427    pub async fn execute_operator_unstake(&self) -> Result<TransactionResult> {
2428        let wallet = self.wallet()?;
2429        let provider = ProviderBuilder::new()
2430            .wallet(wallet)
2431            .connect(self.config.http_rpc_endpoint.as_str())
2432            .await
2433            .map_err(Error::Transport)?;
2434        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2435
2436        let receipt = contract
2437            .executeOperatorUnstake()
2438            .send()
2439            .await
2440            .map_err(|e| Error::Contract(e.to_string()))?
2441            .get_receipt()
2442            .await?;
2443
2444        Ok(transaction_result_from_receipt(&receipt))
2445    }
2446
2447    /// Start leaving the operator set.
2448    pub async fn start_leaving(&self) -> Result<TransactionResult> {
2449        let wallet = self.wallet()?;
2450        let provider = ProviderBuilder::new()
2451            .wallet(wallet)
2452            .connect(self.config.http_rpc_endpoint.as_str())
2453            .await
2454            .map_err(Error::Transport)?;
2455        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2456
2457        let receipt = contract
2458            .startLeaving()
2459            .send()
2460            .await
2461            .map_err(|e| Error::Contract(e.to_string()))?
2462            .get_receipt()
2463            .await?;
2464
2465        Ok(transaction_result_from_receipt(&receipt))
2466    }
2467
2468    /// Complete leaving after delay.
2469    pub async fn complete_leaving(&self) -> Result<TransactionResult> {
2470        let wallet = self.wallet()?;
2471        let provider = ProviderBuilder::new()
2472            .wallet(wallet)
2473            .connect(self.config.http_rpc_endpoint.as_str())
2474            .await
2475            .map_err(Error::Transport)?;
2476        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2477
2478        let receipt = contract
2479            .completeLeaving()
2480            .send()
2481            .await
2482            .map_err(|e| Error::Contract(e.to_string()))?
2483            .get_receipt()
2484            .await?;
2485
2486        Ok(transaction_result_from_receipt(&receipt))
2487    }
2488
2489    // ═══════════════════════════════════════════════════════════════════════════
2490    // OPERATOR RESTAKING REGISTRATION
2491    // ═══════════════════════════════════════════════════════════════════════════
2492
2493    /// Get the operator bond token address.
2494    ///
2495    /// Returns `Address::ZERO` if native ETH is used for operator bonds,
2496    /// otherwise returns the ERC20 token address (e.g., TNT).
2497    pub async fn operator_bond_token(&self) -> Result<Address> {
2498        let contract = self.staking_contract();
2499        contract
2500            .operatorBondToken()
2501            .call()
2502            .await
2503            .map_err(|e| Error::Contract(e.to_string()))
2504    }
2505
2506    /// Register as an operator on the restaking layer.
2507    ///
2508    /// Automatically uses the configured bond token (native ETH or ERC20 like TNT).
2509    /// For ERC20 tokens, automatically approves the staking contract first.
2510    pub async fn register_operator_restaking(
2511        &self,
2512        stake_amount: U256,
2513    ) -> Result<TransactionResult> {
2514        use crate::contracts::IMultiAssetDelegation::{
2515            registerOperatorCall, registerOperatorWithAssetCall,
2516        };
2517
2518        let bond_token = self.operator_bond_token().await?;
2519
2520        // Auto-approve ERC20 bond token if needed
2521        if bond_token != Address::ZERO {
2522            self.erc20_approve(bond_token, self.restaking_address, stake_amount)
2523                .await?;
2524        }
2525
2526        let wallet = self.wallet()?;
2527        let from_address = wallet.default_signer().address();
2528        let provider = ProviderBuilder::new()
2529            .wallet(wallet)
2530            .connect(self.config.http_rpc_endpoint.as_str())
2531            .await
2532            .map_err(Error::Transport)?;
2533
2534        let receipt = if bond_token == Address::ZERO {
2535            let tx_request = TransactionRequest::default()
2536                .to(self.restaking_address)
2537                .input(registerOperatorCall {}.abi_encode().into())
2538                .value(stake_amount);
2539            send_transaction_with_fallback_gas(
2540                &provider,
2541                from_address,
2542                tx_request,
2543                REGISTER_OPERATOR_RESTAKING_MIN_GAS_LIMIT,
2544            )
2545            .await?
2546        } else {
2547            let tx_request = TransactionRequest::default()
2548                .to(self.restaking_address)
2549                .input(
2550                    registerOperatorWithAssetCall {
2551                        token: bond_token,
2552                        amount: stake_amount,
2553                    }
2554                    .abi_encode()
2555                    .into(),
2556                );
2557            send_transaction_with_fallback_gas(
2558                &provider,
2559                from_address,
2560                tx_request,
2561                REGISTER_OPERATOR_RESTAKING_MIN_GAS_LIMIT,
2562            )
2563            .await?
2564        };
2565
2566        Ok(transaction_result_from_receipt(&receipt))
2567    }
2568
2569    /// Increase operator stake.
2570    ///
2571    /// Automatically uses the configured bond token (native ETH or ERC20 like TNT).
2572    /// For ERC20 tokens, automatically approves the staking contract first.
2573    pub async fn increase_stake(&self, amount: U256) -> Result<TransactionResult> {
2574        let bond_token = self.operator_bond_token().await?;
2575
2576        // Auto-approve ERC20 bond token if needed
2577        if bond_token != Address::ZERO {
2578            self.erc20_approve(bond_token, self.restaking_address, amount)
2579                .await?;
2580        }
2581
2582        let wallet = self.wallet()?;
2583        let provider = ProviderBuilder::new()
2584            .wallet(wallet)
2585            .connect(self.config.http_rpc_endpoint.as_str())
2586            .await
2587            .map_err(Error::Transport)?;
2588        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2589
2590        let receipt = if bond_token == Address::ZERO {
2591            // Native ETH bond
2592            contract
2593                .increaseStake()
2594                .value(amount)
2595                .send()
2596                .await
2597                .map_err(|e| Error::Contract(e.to_string()))?
2598                .get_receipt()
2599                .await?
2600        } else {
2601            // ERC20 bond (e.g., TNT)
2602            contract
2603                .increaseStakeWithAsset(bond_token, amount)
2604                .send()
2605                .await
2606                .map_err(|e| Error::Contract(e.to_string()))?
2607                .get_receipt()
2608                .await?
2609        };
2610
2611        Ok(transaction_result_from_receipt(&receipt))
2612    }
2613
2614    /// Deposit native ETH without delegating.
2615    ///
2616    /// Use this to pre-fund your account before delegating.
2617    pub async fn deposit_native(&self, amount: U256) -> Result<TransactionResult> {
2618        let wallet = self.wallet()?;
2619        let provider = ProviderBuilder::new()
2620            .wallet(wallet)
2621            .connect(self.config.http_rpc_endpoint.as_str())
2622            .await
2623            .map_err(Error::Transport)?;
2624        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2625
2626        let receipt = contract
2627            .deposit()
2628            .value(amount)
2629            .send()
2630            .await
2631            .map_err(|e| Error::Contract(e.to_string()))?
2632            .get_receipt()
2633            .await?;
2634
2635        Ok(transaction_result_from_receipt(&receipt))
2636    }
2637
2638    /// Deposit ERC20 tokens without delegating.
2639    ///
2640    /// Use this to pre-fund your account before delegating.
2641    pub async fn deposit_erc20(&self, token: Address, amount: U256) -> Result<TransactionResult> {
2642        let wallet = self.wallet()?;
2643        let provider = ProviderBuilder::new()
2644            .wallet(wallet)
2645            .connect(self.config.http_rpc_endpoint.as_str())
2646            .await
2647            .map_err(Error::Transport)?;
2648        let contract = IMultiAssetDelegation::new(self.restaking_address, &provider);
2649
2650        let receipt = contract
2651            .depositERC20(token, amount)
2652            .send()
2653            .await
2654            .map_err(|e| Error::Contract(e.to_string()))?
2655            .get_receipt()
2656            .await?;
2657
2658        Ok(transaction_result_from_receipt(&receipt))
2659    }
2660
2661    // ═══════════════════════════════════════════════════════════════════════════
2662    // BLS AGGREGATION QUERIES
2663    // ═══════════════════════════════════════════════════════════════════════════
2664
2665    /// Get the blueprint manager address for a service
2666    pub async fn get_blueprint_manager(&self, service_id: u64) -> Result<Option<Address>> {
2667        let service = self.get_service(service_id).await?;
2668        let blueprint = self.get_blueprint(service.blueprintId).await?;
2669        if blueprint.manager == Address::ZERO {
2670            Ok(None)
2671        } else {
2672            Ok(Some(blueprint.manager))
2673        }
2674    }
2675
2676    /// Check if a job requires BLS aggregation
2677    ///
2678    /// Queries the blueprint's service manager contract to determine if the specified
2679    /// job index requires aggregated BLS signatures instead of individual results.
2680    pub async fn requires_aggregation(&self, service_id: u64, job_index: u8) -> Result<bool> {
2681        let manager = match self.get_blueprint_manager(service_id).await? {
2682            Some(m) => m,
2683            None => return Ok(false), // No manager means no aggregation required
2684        };
2685
2686        let bsm = IBlueprintServiceManager::new(manager, Arc::clone(&self.provider));
2687        match bsm.requiresAggregation(service_id, job_index).call().await {
2688            Ok(required) => Ok(required),
2689            Err(_) => Ok(false), // If call fails, assume no aggregation required
2690        }
2691    }
2692
2693    /// Get the aggregation threshold configuration for a job
2694    ///
2695    /// Returns (threshold_bps, threshold_type) where:
2696    /// - threshold_bps: Threshold in basis points (e.g., 6700 = 67%)
2697    /// - threshold_type: 0 = CountBased (% of operators), 1 = StakeWeighted (% of stake)
2698    pub async fn get_aggregation_threshold(
2699        &self,
2700        service_id: u64,
2701        job_index: u8,
2702    ) -> Result<(u16, u8)> {
2703        let manager = match self.get_blueprint_manager(service_id).await? {
2704            Some(m) => m,
2705            None => return Ok((6700, 0)), // Default: 67% count-based
2706        };
2707
2708        let bsm = IBlueprintServiceManager::new(manager, Arc::clone(&self.provider));
2709        match bsm
2710            .getAggregationThreshold(service_id, job_index)
2711            .call()
2712            .await
2713        {
2714            Ok(result) => Ok((result.thresholdBps, result.thresholdType)),
2715            Err(_) => Ok((6700, 0)), // Default if call fails
2716        }
2717    }
2718
2719    /// Get the aggregation configuration for a specific job
2720    ///
2721    /// Returns the full aggregation config including whether it's required and threshold settings
2722    pub async fn get_aggregation_config(
2723        &self,
2724        service_id: u64,
2725        job_index: u8,
2726    ) -> Result<AggregationConfig> {
2727        let requires_aggregation = self.requires_aggregation(service_id, job_index).await?;
2728        let (threshold_bps, threshold_type) = self
2729            .get_aggregation_threshold(service_id, job_index)
2730            .await?;
2731
2732        Ok(AggregationConfig {
2733            required: requires_aggregation,
2734            threshold_bps,
2735            threshold_type: if threshold_type == 0 {
2736                ThresholdType::CountBased
2737            } else {
2738                ThresholdType::StakeWeighted
2739            },
2740        })
2741    }
2742
2743    // ═══════════════════════════════════════════════════════════════════════════
2744    // TRANSACTION SUBMISSION
2745    // ═══════════════════════════════════════════════════════════════════════════
2746
2747    /// Submit a job invocation to the Tangle contract.
2748    pub async fn submit_job(
2749        &self,
2750        service_id: u64,
2751        job_index: u8,
2752        inputs: Bytes,
2753    ) -> Result<JobSubmissionResult> {
2754        self.submit_job_with_value(service_id, job_index, inputs, U256::ZERO)
2755            .await
2756    }
2757
2758    /// Submit a job invocation with native token payment.
2759    ///
2760    /// For `EventDriven` services the caller should send `event_rate` as `value`.
2761    /// For free jobs or services that don't charge per-event, pass `U256::ZERO`.
2762    pub async fn submit_job_with_value(
2763        &self,
2764        service_id: u64,
2765        job_index: u8,
2766        inputs: Bytes,
2767        value: U256,
2768    ) -> Result<JobSubmissionResult> {
2769        use crate::contracts::ITangle::submitJobCall;
2770        use alloy_sol_types::SolCall;
2771
2772        let wallet = self.wallet()?;
2773        let provider = ProviderBuilder::new()
2774            .wallet(wallet)
2775            .connect(self.config.http_rpc_endpoint.as_str())
2776            .await
2777            .map_err(Error::Transport)?;
2778
2779        let call = submitJobCall {
2780            serviceId: service_id,
2781            jobIndex: job_index,
2782            inputs,
2783        };
2784        let calldata = call.abi_encode();
2785
2786        let mut tx_request = TransactionRequest::default()
2787            .to(self.tangle_address)
2788            .input(calldata.into());
2789        if value > U256::ZERO {
2790            tx_request = tx_request.value(value);
2791        }
2792
2793        let pending_tx = provider
2794            .send_transaction(tx_request)
2795            .await
2796            .map_err(Error::Transport)?;
2797
2798        let receipt = pending_tx
2799            .get_receipt()
2800            .await
2801            .map_err(Error::PendingTransaction)?;
2802
2803        self.parse_job_submitted(&receipt)
2804    }
2805
2806    /// Submit a job from operator-signed quotes with payment.
2807    ///
2808    /// Calls the on-chain `submitJobFromQuote` function. The total quoted
2809    /// price is sent as native value with the transaction.
2810    pub async fn submit_job_from_quote(
2811        &self,
2812        service_id: u64,
2813        job_index: u8,
2814        inputs: Bytes,
2815        quotes: Vec<ITangleTypes::SignedJobQuote>,
2816    ) -> Result<JobSubmissionResult> {
2817        use crate::contracts::ITangle::submitJobFromQuoteCall;
2818        use alloy_sol_types::SolCall;
2819
2820        // Sum all quote prices to determine total native value to send.
2821        let total_value: U256 = quotes.iter().map(|q| q.details.price).sum();
2822
2823        let wallet = self.wallet()?;
2824        let provider = ProviderBuilder::new()
2825            .wallet(wallet)
2826            .connect(self.config.http_rpc_endpoint.as_str())
2827            .await
2828            .map_err(Error::Transport)?;
2829
2830        let call = submitJobFromQuoteCall {
2831            serviceId: service_id,
2832            jobIndex: job_index,
2833            inputs,
2834            quotes,
2835        };
2836        let calldata = call.abi_encode();
2837
2838        let mut tx_request = TransactionRequest::default()
2839            .to(self.tangle_address)
2840            .input(calldata.into());
2841        if total_value > U256::ZERO {
2842            tx_request = tx_request.value(total_value);
2843        }
2844
2845        let pending_tx = provider
2846            .send_transaction(tx_request)
2847            .await
2848            .map_err(Error::Transport)?;
2849
2850        let receipt = pending_tx
2851            .get_receipt()
2852            .await
2853            .map_err(Error::PendingTransaction)?;
2854
2855        self.parse_job_submitted(&receipt)
2856    }
2857
2858    /// Parse a `JobSubmitted` event from a transaction receipt.
2859    fn parse_job_submitted(&self, receipt: &TransactionReceipt) -> Result<JobSubmissionResult> {
2860        let tx = TransactionResult {
2861            tx_hash: receipt.transaction_hash,
2862            block_number: receipt.block_number,
2863            gas_used: receipt.gas_used,
2864            success: receipt.status(),
2865        };
2866
2867        let job_submitted_sig = keccak256("JobSubmitted(uint64,uint64,uint8,address,bytes)");
2868        let call_id = receipt
2869            .logs()
2870            .iter()
2871            .find_map(|log| {
2872                let topics = log.topics();
2873                if log.address() != self.tangle_address || topics.len() < 3 {
2874                    return None;
2875                }
2876                if topics[0].0 != job_submitted_sig {
2877                    return None;
2878                }
2879                let mut buf = [0u8; 32];
2880                buf.copy_from_slice(topics[2].as_slice());
2881                Some(U256::from_be_bytes(buf).to::<u64>())
2882            })
2883            .ok_or_else(|| {
2884                let status = receipt.status();
2885                let log_count = receipt.logs().len();
2886                let topics: Vec<String> = receipt
2887                    .logs()
2888                    .iter()
2889                    .map(|log| {
2890                        log.topics()
2891                            .iter()
2892                            .map(|topic| format!("{topic:#x}"))
2893                            .collect::<Vec<_>>()
2894                            .join(",")
2895                    })
2896                    .collect();
2897                Error::Contract(format!(
2898                    "submitJob receipt missing JobSubmitted event (status={status:?}, logs={log_count}, topics={topics:?})"
2899                ))
2900            })?;
2901
2902        Ok(JobSubmissionResult { tx, call_id })
2903    }
2904
2905    /// Submit a job result to the Tangle contract
2906    ///
2907    /// This sends a signed transaction to submit a single operator's result.
2908    ///
2909    /// # Arguments
2910    /// * `service_id` - The service ID
2911    /// * `call_id` - The call/job ID
2912    /// * `output` - The encoded result output
2913    ///
2914    /// # Returns
2915    /// The transaction hash and receipt on success
2916    pub async fn submit_result(
2917        &self,
2918        service_id: u64,
2919        call_id: u64,
2920        output: Bytes,
2921    ) -> Result<TransactionResult> {
2922        use crate::contracts::ITangle::submitResultCall;
2923
2924        let wallet = self.wallet()?;
2925        let from_address = wallet.default_signer().address();
2926        let provider = ProviderBuilder::new()
2927            .wallet(wallet)
2928            .connect(self.config.http_rpc_endpoint.as_str())
2929            .await
2930            .map_err(Error::Transport)?;
2931
2932        let tx_request = TransactionRequest::default().to(self.tangle_address).input(
2933            submitResultCall {
2934                serviceId: service_id,
2935                callId: call_id,
2936                result: output,
2937            }
2938            .abi_encode()
2939            .into(),
2940        );
2941
2942        let receipt = send_transaction_with_fallback_gas(
2943            &provider,
2944            from_address,
2945            tx_request,
2946            SUBMIT_RESULT_MIN_GAS_LIMIT,
2947        )
2948        .await?;
2949
2950        Ok(TransactionResult {
2951            tx_hash: receipt.transaction_hash,
2952            block_number: receipt.block_number,
2953            gas_used: receipt.gas_used,
2954            success: receipt.status(),
2955        })
2956    }
2957
2958    /// Submit an aggregated BLS signature result to the Tangle contract
2959    ///
2960    /// This sends a signed transaction to submit an aggregated result with BLS signature.
2961    ///
2962    /// # Arguments
2963    /// * `service_id` - The service ID
2964    /// * `call_id` - The call/job ID
2965    /// * `output` - The encoded result output
2966    /// * `signer_bitmap` - Bitmap indicating which operators signed
2967    /// * `aggregated_signature` - The aggregated BLS signature [2]
2968    /// * `aggregated_pubkey` - The aggregated BLS public key [4]
2969    ///
2970    /// # Returns
2971    /// The transaction hash and receipt on success
2972    pub async fn submit_aggregated_result(
2973        &self,
2974        service_id: u64,
2975        call_id: u64,
2976        output: Bytes,
2977        signer_bitmap: U256,
2978        aggregated_signature: [U256; 2],
2979        aggregated_pubkey: [U256; 4],
2980    ) -> Result<TransactionResult> {
2981        use crate::contracts::ITangle::submitAggregatedResultCall;
2982        use alloy_sol_types::SolCall;
2983
2984        let wallet = self.wallet()?;
2985        let provider = ProviderBuilder::new()
2986            .wallet(wallet)
2987            .connect(self.config.http_rpc_endpoint.as_str())
2988            .await
2989            .map_err(Error::Transport)?;
2990
2991        let call = submitAggregatedResultCall {
2992            serviceId: service_id,
2993            callId: call_id,
2994            output,
2995            signerBitmap: signer_bitmap,
2996            aggregatedSignature: aggregated_signature,
2997            aggregatedPubkey: aggregated_pubkey,
2998        };
2999        let calldata = call.abi_encode();
3000
3001        let tx_request = TransactionRequest::default()
3002            .to(self.tangle_address)
3003            .input(calldata.into());
3004
3005        let pending_tx = provider
3006            .send_transaction(tx_request)
3007            .await
3008            .map_err(Error::Transport)?;
3009
3010        let receipt = pending_tx
3011            .get_receipt()
3012            .await
3013            .map_err(Error::PendingTransaction)?;
3014
3015        Ok(TransactionResult {
3016            tx_hash: receipt.transaction_hash,
3017            block_number: receipt.block_number,
3018            gas_used: receipt.gas_used,
3019            success: receipt.status(),
3020        })
3021    }
3022
3023    async fn extract_request_id(
3024        &self,
3025        receipt: &TransactionReceipt,
3026        blueprint_id: u64,
3027    ) -> Result<u64> {
3028        if let Some(event) = receipt.decoded_log::<ITangle::ServiceRequested>() {
3029            return Ok(event.data.requestId);
3030        }
3031        if let Some(event) = receipt.decoded_log::<ITangle::ServiceRequestedWithSecurity>() {
3032            return Ok(event.data.requestId);
3033        }
3034
3035        let requested_sig = keccak256("ServiceRequested(uint64,uint64,address)".as_bytes());
3036        let requested_with_security_sig = keccak256(
3037            "ServiceRequestedWithSecurity(uint64,uint64,address,address[],((uint8,address),uint16,uint16)[])"
3038                .as_bytes(),
3039        );
3040
3041        for log in receipt.logs() {
3042            let topics = log.topics();
3043            if topics.is_empty() {
3044                continue;
3045            }
3046            let sig = topics[0].0;
3047            if sig != requested_sig && sig != requested_with_security_sig {
3048                continue;
3049            }
3050            if topics.len() < 2 {
3051                continue;
3052            }
3053
3054            let mut buf = [0u8; 32];
3055            buf.copy_from_slice(topics[1].as_slice());
3056            let id = U256::from_be_bytes(buf).to::<u64>();
3057            return Ok(id);
3058        }
3059
3060        if let Some(block_number) = receipt.block_number {
3061            let filter = Filter::new()
3062                .select(block_number)
3063                .address(self.tangle_address)
3064                .event_signature(vec![requested_sig, requested_with_security_sig]);
3065            if let Ok(logs) = self.get_logs(&filter).await {
3066                for log in logs {
3067                    let topics = log.topics();
3068                    if topics.len() < 2 {
3069                        continue;
3070                    }
3071                    let mut buf = [0u8; 32];
3072                    buf.copy_from_slice(topics[1].as_slice());
3073                    let id = U256::from_be_bytes(buf).to::<u64>();
3074                    return Ok(id);
3075                }
3076            }
3077        }
3078
3079        let count = self.service_request_count().await?;
3080        if count == 0 {
3081            return Err(Error::Contract(
3082                "requestService receipt missing ServiceRequested event".into(),
3083            ));
3084        }
3085
3086        let account = self.account();
3087        let start = count.saturating_sub(5);
3088        for candidate in (start..count).rev() {
3089            if let Ok(request) = self.get_service_request(candidate).await
3090                && request.blueprintId == blueprint_id
3091                && request.requester == account
3092            {
3093                return Ok(candidate);
3094            }
3095        }
3096
3097        Ok(count - 1)
3098    }
3099
3100    fn extract_blueprint_id(&self, receipt: &TransactionReceipt) -> Result<u64> {
3101        for log in receipt.logs() {
3102            if let Ok(event) = log.log_decode::<ITangle::BlueprintCreated>() {
3103                return Ok(event.inner.blueprintId);
3104            }
3105        }
3106
3107        Err(Error::Contract(
3108            "createBlueprint receipt missing BlueprintCreated event".into(),
3109        ))
3110    }
3111}
3112
3113/// Result of a submitted transaction
3114#[derive(Debug, Clone)]
3115pub struct TransactionResult {
3116    /// Transaction hash
3117    pub tx_hash: B256,
3118    /// Block number the transaction was included in
3119    pub block_number: Option<u64>,
3120    /// Gas used by the transaction
3121    pub gas_used: u64,
3122    /// Whether the transaction succeeded
3123    pub success: bool,
3124}
3125
3126/// Result of submitting a job via `submitJob`.
3127#[derive(Debug, Clone)]
3128pub struct JobSubmissionResult {
3129    /// Transaction metadata.
3130    pub tx: TransactionResult,
3131    /// Call identifier assigned by the contract.
3132    pub call_id: u64,
3133}
3134
3135/// Threshold type for BLS aggregation
3136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3137pub enum ThresholdType {
3138    /// Threshold based on number of operators (e.g., 67% of operators must sign)
3139    CountBased,
3140    /// Threshold based on stake weight (e.g., 67% of total stake must sign)
3141    StakeWeighted,
3142}
3143
3144/// Configuration for BLS signature aggregation
3145#[derive(Debug, Clone)]
3146pub struct AggregationConfig {
3147    /// Whether aggregation is required for this job
3148    pub required: bool,
3149    /// Threshold in basis points (e.g., 6700 = 67%)
3150    pub threshold_bps: u16,
3151    /// Type of threshold calculation
3152    pub threshold_type: ThresholdType,
3153}
3154
3155/// Convert ECDSA public key to Ethereum address
3156fn ecdsa_public_key_to_address(pubkey: &[u8]) -> Result<Address> {
3157    use alloy_primitives::keccak256;
3158
3159    // Handle both compressed (33 bytes) and uncompressed (65 bytes) keys
3160    let uncompressed = if pubkey.len() == 33 {
3161        // Decompress the key using k256
3162        use k256::EncodedPoint;
3163        use k256::elliptic_curve::sec1::FromEncodedPoint;
3164
3165        let point = EncodedPoint::from_bytes(pubkey)
3166            .map_err(|e| Error::InvalidAddress(format!("Invalid compressed key: {e}")))?;
3167
3168        let pubkey: k256::PublicKey = Option::from(k256::PublicKey::from_encoded_point(&point))
3169            .ok_or_else(|| Error::InvalidAddress("Failed to decompress public key".into()))?;
3170
3171        pubkey.to_encoded_point(false).as_bytes().to_vec()
3172    } else if pubkey.len() == 65 {
3173        pubkey.to_vec()
3174    } else if pubkey.len() == 64 {
3175        // Already without prefix
3176        let mut full = vec![0x04];
3177        full.extend_from_slice(pubkey);
3178        full
3179    } else {
3180        return Err(Error::InvalidAddress(format!(
3181            "Invalid public key length: {}",
3182            pubkey.len()
3183        )));
3184    };
3185
3186    // Skip the 0x04 prefix and hash the rest
3187    let hash = keccak256(&uncompressed[1..]);
3188
3189    // Take the last 20 bytes as the address
3190    Ok(Address::from_slice(&hash[12..]))
3191}
3192
3193fn normalize_public_key(raw: &[u8]) -> Result<EcdsaPublicKey> {
3194    match raw.len() {
3195        65 => {
3196            let mut key = [0u8; 65];
3197            key.copy_from_slice(raw);
3198            Ok(key)
3199        }
3200        64 => {
3201            let mut key = [0u8; 65];
3202            key[0] = 0x04;
3203            key[1..].copy_from_slice(raw);
3204            Ok(key)
3205        }
3206        33 => {
3207            use k256::EncodedPoint;
3208            use k256::elliptic_curve::sec1::FromEncodedPoint;
3209
3210            let point = EncodedPoint::from_bytes(raw)
3211                .map_err(|e| Error::InvalidAddress(format!("Invalid compressed key: {e}")))?;
3212            let public_key: k256::PublicKey =
3213                Option::from(k256::PublicKey::from_encoded_point(&point)).ok_or_else(|| {
3214                    Error::InvalidAddress("Failed to decompress public key".into())
3215                })?;
3216            let encoded = public_key.to_encoded_point(false);
3217            let bytes = encoded.as_bytes();
3218            let mut key = [0u8; 65];
3219            key.copy_from_slice(bytes);
3220            Ok(key)
3221        }
3222        0 => Err(Error::Other(
3223            "Operator has not published an ECDSA public key".into(),
3224        )),
3225        len => Err(Error::InvalidAddress(format!(
3226            "Unexpected operator key length: {len}"
3227        ))),
3228    }
3229}
3230
3231fn asset_info_from_types(asset: IMultiAssetDelegationTypes::Asset) -> AssetInfo {
3232    AssetInfo {
3233        kind: AssetKind::from(asset.kind),
3234        token: asset.token,
3235    }
3236}
3237
3238fn selection_mode_to_u8(mode: BlueprintSelectionMode) -> u8 {
3239    match mode {
3240        BlueprintSelectionMode::All => 0,
3241        BlueprintSelectionMode::Fixed => 1,
3242        BlueprintSelectionMode::Unknown(value) => value,
3243    }
3244}
3245
3246fn transaction_result_from_receipt(receipt: &TransactionReceipt) -> TransactionResult {
3247    TransactionResult {
3248        tx_hash: receipt.transaction_hash,
3249        block_number: receipt.block_number,
3250        gas_used: receipt.gas_used,
3251        success: receipt.status(),
3252    }
3253}
3254
3255// ═══════════════════════════════════════════════════════════════════════════════
3256// BLUEPRINT SERVICES CLIENT IMPLEMENTATION
3257// ═══════════════════════════════════════════════════════════════════════════════
3258
3259impl BlueprintServicesClient for TangleClient {
3260    type PublicApplicationIdentity = EcdsaPublicKey;
3261    type PublicAccountIdentity = Address;
3262    type Id = u64;
3263    type Error = Error;
3264
3265    /// Get all operators for the current service with their ECDSA keys
3266    async fn get_operators(
3267        &self,
3268    ) -> core::result::Result<
3269        OperatorSet<Self::PublicAccountIdentity, Self::PublicApplicationIdentity>,
3270        Self::Error,
3271    > {
3272        let service_id = self
3273            .config
3274            .settings
3275            .service_id
3276            .ok_or_else(|| Error::Other("No service ID configured".into()))?;
3277
3278        // Get service operators
3279        let operators = self.get_service_operators(service_id).await?;
3280
3281        let mut map = BTreeMap::new();
3282
3283        for operator in operators {
3284            let metadata = self
3285                .get_operator_metadata(self.config.settings.blueprint_id, operator)
3286                .await?;
3287            map.insert(operator, metadata.public_key);
3288        }
3289
3290        Ok(map)
3291    }
3292
3293    /// Get the current operator's ECDSA public key
3294    async fn operator_id(
3295        &self,
3296    ) -> core::result::Result<Self::PublicApplicationIdentity, Self::Error> {
3297        let key = self
3298            .keystore
3299            .first_local::<K256Ecdsa>()
3300            .map_err(Error::Keystore)?;
3301
3302        // Convert VerifyingKey to 65-byte uncompressed format
3303        let encoded = key.0.to_encoded_point(false);
3304        let bytes = encoded.as_bytes();
3305
3306        let mut uncompressed = [0u8; 65];
3307        uncompressed.copy_from_slice(bytes);
3308
3309        Ok(uncompressed)
3310    }
3311
3312    /// Get the current blueprint ID
3313    async fn blueprint_id(&self) -> core::result::Result<Self::Id, Self::Error> {
3314        Ok(self.config.settings.blueprint_id)
3315    }
3316}
3317
3318#[cfg(test)]
3319mod gas_fallback_tests {
3320    use super::{
3321        APPROVE_SERVICE_MIN_GAS_LIMIT, CREATE_BLUEPRINT_MIN_GAS_LIMIT, ERC20_APPROVE_MIN_GAS_LIMIT,
3322        REGISTER_BLUEPRINT_OPERATOR_MIN_GAS_LIMIT, REGISTER_OPERATOR_RESTAKING_MIN_GAS_LIMIT,
3323        REQUEST_SERVICE_MIN_GAS_LIMIT, SUBMIT_RESULT_GAS_BUFFER_DENOMINATOR,
3324        SUBMIT_RESULT_GAS_BUFFER_NUMERATOR, SUBMIT_RESULT_MIN_GAS_LIMIT, buffered_gas_limit,
3325    };
3326
3327    #[test]
3328    fn fallback_to_min_when_estimation_unavailable() {
3329        assert_eq!(
3330            buffered_gas_limit(None, SUBMIT_RESULT_MIN_GAS_LIMIT),
3331            SUBMIT_RESULT_MIN_GAS_LIMIT
3332        );
3333    }
3334
3335    #[test]
3336    fn buffered_estimate_applied_when_above_min() {
3337        let estimate = SUBMIT_RESULT_MIN_GAS_LIMIT * 2;
3338        let expected = estimate.saturating_mul(SUBMIT_RESULT_GAS_BUFFER_NUMERATOR)
3339            / SUBMIT_RESULT_GAS_BUFFER_DENOMINATOR;
3340        assert_eq!(
3341            buffered_gas_limit(Some(estimate), SUBMIT_RESULT_MIN_GAS_LIMIT),
3342            expected
3343        );
3344        assert!(expected > estimate, "buffer must increase estimate");
3345    }
3346
3347    #[test]
3348    fn min_floor_applied_when_buffered_estimate_is_small() {
3349        // Tiny estimate still gets bumped to min_gas_limit.
3350        assert_eq!(
3351            buffered_gas_limit(Some(1), SUBMIT_RESULT_MIN_GAS_LIMIT),
3352            SUBMIT_RESULT_MIN_GAS_LIMIT
3353        );
3354        assert_eq!(
3355            buffered_gas_limit(Some(21_000), ERC20_APPROVE_MIN_GAS_LIMIT),
3356            ERC20_APPROVE_MIN_GAS_LIMIT
3357        );
3358    }
3359
3360    #[test]
3361    fn saturating_math_never_overflows() {
3362        // Degenerate estimate near u64::MAX must not wrap.
3363        let out = buffered_gas_limit(Some(u64::MAX), SUBMIT_RESULT_MIN_GAS_LIMIT);
3364        assert!(out >= SUBMIT_RESULT_MIN_GAS_LIMIT);
3365    }
3366
3367    #[test]
3368    fn min_limits_are_nonzero_sanity() {
3369        // Fail-open floors must be non-zero for every call-site constant.
3370        for min in [
3371            SUBMIT_RESULT_MIN_GAS_LIMIT,
3372            CREATE_BLUEPRINT_MIN_GAS_LIMIT,
3373            REGISTER_BLUEPRINT_OPERATOR_MIN_GAS_LIMIT,
3374            REQUEST_SERVICE_MIN_GAS_LIMIT,
3375            APPROVE_SERVICE_MIN_GAS_LIMIT,
3376            ERC20_APPROVE_MIN_GAS_LIMIT,
3377            REGISTER_OPERATOR_RESTAKING_MIN_GAS_LIMIT,
3378        ] {
3379            assert!(min > 21_000, "gas floor {min} below 21k base tx cost");
3380        }
3381    }
3382}
3383
3384#[cfg(test)]
3385mod definition_event_tests {
3386    use super::BlueprintDefinitionRecorded;
3387    use alloy_primitives::{Address, Bytes, LogData, U256, b256, keccak256};
3388    use alloy_sol_types::{SolEvent, SolValue};
3389
3390    /// The event topic0 must equal the one emitted by the deployed
3391    /// `MasterBlueprintServiceManager` (from the LocalTestnet broadcast). A
3392    /// wrong signature string would silently match no logs.
3393    #[test]
3394    fn event_signature_hash_matches_deployed_topic() {
3395        assert_eq!(
3396            BlueprintDefinitionRecorded::SIGNATURE_HASH,
3397            b256!("e0bc1e42405ffea94dcef03a915081f9674bc9eea38d46bad147b5cce7438e3e")
3398        );
3399    }
3400
3401    fn sample_log(blueprint_id: u64, encoded: &[u8]) -> LogData {
3402        let mut id_topic = [0u8; 32];
3403        id_topic[24..].copy_from_slice(&blueprint_id.to_be_bytes());
3404        let owner = Address::repeat_byte(0x11);
3405        let mut owner_topic = [0u8; 32];
3406        owner_topic[12..].copy_from_slice(owner.as_slice());
3407        // ABI-encode the single non-indexed `bytes` param as event data.
3408        let data = Bytes::from(encoded.to_vec()).abi_encode();
3409        LogData::new_unchecked(
3410            vec![
3411                BlueprintDefinitionRecorded::SIGNATURE_HASH,
3412                id_topic.into(),
3413                owner_topic.into(),
3414            ],
3415            data.into(),
3416        )
3417    }
3418
3419    /// The helper accepts a payload only when `keccak256(encodedDefinition)`
3420    /// matches the on-chain hash; this proves the decode + integrity check that
3421    /// makes trusting the log emitter unnecessary.
3422    #[test]
3423    fn decodes_payload_and_matches_integrity_hash() {
3424        let encoded = U256::from(0xABCDu64).to_be_bytes::<32>().to_vec();
3425        let log = sample_log(7, &encoded);
3426
3427        let decoded = BlueprintDefinitionRecorded::decode_log_data(&log).expect("decode event");
3428        assert_eq!(decoded.encodedDefinition.as_ref(), encoded.as_slice());
3429        assert_eq!(decoded.blueprintId, 7);
3430
3431        let expected = keccak256(&encoded);
3432        assert_eq!(keccak256(decoded.encodedDefinition.as_ref()), expected);
3433    }
3434
3435    /// A tampered payload must fail the integrity check the helper enforces.
3436    #[test]
3437    fn tampered_payload_fails_integrity_hash() {
3438        let encoded = vec![1u8, 2, 3, 4];
3439        let log = sample_log(7, &encoded);
3440        let decoded = BlueprintDefinitionRecorded::decode_log_data(&log).expect("decode event");
3441
3442        let honest_hash = keccak256(&encoded);
3443        let mut tampered = decoded.encodedDefinition.to_vec();
3444        tampered[0] ^= 0xff;
3445        assert_ne!(keccak256(&tampered), honest_hash);
3446    }
3447}