Skip to main content

tenzro_wallet/
state_sync.rs

1//! Wallet state synchronization for Tenzro Network.
2//!
3//! Defines the interface for synchronizing wallet state with on-chain data.
4//! This includes balance queries, nonce synchronization, and transaction
5//! status tracking from the blockchain.
6
7use crate::balance::{Balance, BalanceTracker};
8use crate::error::{Result, WalletError};
9use crate::history::{TransactionHistory, TxRecord, TxStatus};
10use crate::nonce::NonceManager;
11use async_trait::async_trait;
12use std::sync::Arc;
13use tenzro_types::primitives::{Address, Hash, Signature};
14use tenzro_types::{AssetId, Transaction};
15use tracing::{debug, info, warn};
16
17/// Trait for providing on-chain state to the wallet.
18///
19/// Implementations connect to a Tenzro node (or light client) to query
20/// current blockchain state. The wallet service uses this to sync
21/// local state with the canonical chain.
22#[async_trait]
23pub trait ChainStateProvider: Send + Sync {
24    /// Get the current balance for an address and asset from on-chain state.
25    async fn get_on_chain_balance(
26        &self,
27        address: &Address,
28        asset_id: &AssetId,
29    ) -> Result<u128>;
30
31    /// Get all asset balances for an address from on-chain state.
32    async fn get_on_chain_balances(
33        &self,
34        address: &Address,
35    ) -> Result<Vec<(AssetId, u128)>>;
36
37    /// Get the current confirmed nonce for an address.
38    async fn get_on_chain_nonce(&self, address: &Address) -> Result<u64>;
39
40    /// Get the status of a transaction by its hash.
41    async fn get_transaction_status(&self, tx_hash: &Hash) -> Result<TxStatus>;
42
43    /// Get the current block height.
44    async fn get_block_height(&self) -> Result<u64>;
45
46    /// Submit a hybrid post-quantum signed transaction to the network.
47    ///
48    /// The wire format matches Tenzro's `eth_sendRawTransaction` JSON-RPC
49    /// shape: a structured object with explicit `from`, `to`, `value`,
50    /// `nonce`, `chain_id`, `timestamp`, `tx_type`, plus the classical
51    /// Ed25519 (`signature` + `public_key`, 64 + 32 bytes) and post-quantum
52    /// ML-DSA-65 (`pq_signature` 3309 bytes + `pq_public_key` 1952 bytes)
53    /// legs. Both legs MUST verify against `Transaction::hash()` server-side
54    /// or the node returns JSON-RPC error `-32003`.
55    ///
56    /// The classical signature (and its embedded `public_key`) and `pq_sig`
57    /// are passed separately so the trait can be satisfied by callers that
58    /// produce signatures via `WalletService::sign_transaction` without
59    /// re-serializing into a hex blob.
60    async fn submit_signed_transaction(
61        &self,
62        tx: &Transaction,
63        classical_sig: &Signature,
64        pq_sig: &[u8],
65    ) -> Result<Hash>;
66}
67
68/// Wallet state synchronizer.
69///
70/// Coordinates between local wallet state (balances, nonces, history)
71/// and on-chain state via a ChainStateProvider. Handles:
72/// - Initial sync when connecting to a node
73/// - Periodic balance refresh
74/// - Transaction confirmation tracking
75/// - Nonce synchronization after reconnection
76pub struct WalletStateSync {
77    /// Balance tracker (local cache)
78    balances: Arc<BalanceTracker>,
79    /// Nonce manager
80    nonces: Arc<NonceManager>,
81    /// Transaction history
82    history: Arc<TransactionHistory>,
83    /// Chain state provider (optional — works offline without it)
84    chain_provider: Option<Arc<dyn ChainStateProvider>>,
85}
86
87impl WalletStateSync {
88    /// Create a new state sync manager.
89    pub fn new(
90        balances: Arc<BalanceTracker>,
91        nonces: Arc<NonceManager>,
92        history: Arc<TransactionHistory>,
93    ) -> Self {
94        Self {
95            balances,
96            nonces,
97            history,
98            chain_provider: None,
99        }
100    }
101
102    /// Connect a chain state provider for on-chain synchronization.
103    pub fn with_chain_provider(mut self, provider: Arc<dyn ChainStateProvider>) -> Self {
104        self.chain_provider = Some(provider);
105        self
106    }
107
108    /// Check if connected to a chain state provider.
109    pub fn is_connected(&self) -> bool {
110        self.chain_provider.is_some()
111    }
112
113    /// Perform a full sync for an address.
114    ///
115    /// Syncs balances, nonces, and pending transaction statuses
116    /// from on-chain state.
117    pub async fn sync_address(&self, address: &Address, assets: &[AssetId]) -> Result<()> {
118        let provider = self.chain_provider.as_ref().ok_or_else(|| {
119            WalletError::Other("No chain state provider connected".to_string())
120        })?;
121
122        // Sync balances
123        self.sync_balances(address, assets, provider.as_ref()).await?;
124
125        // Sync nonce
126        self.sync_nonce(address, provider.as_ref()).await?;
127
128        // Sync pending transactions
129        self.sync_pending_transactions(address, provider.as_ref()).await?;
130
131        info!("Completed full sync for address {}", address);
132        Ok(())
133    }
134
135    /// Sync balances for an address from on-chain state.
136    async fn sync_balances(
137        &self,
138        address: &Address,
139        assets: &[AssetId],
140        provider: &dyn ChainStateProvider,
141    ) -> Result<()> {
142        for asset_id in assets {
143            match provider.get_on_chain_balance(address, asset_id).await {
144                Ok(on_chain_balance) => {
145                    let current = self.balances.get_balance(address, asset_id);
146
147                    // Update available balance from chain, preserving local pending state
148                    let synced = Balance {
149                        available: on_chain_balance,
150                        locked: current.locked,
151                        pending_in: current.pending_in,
152                        pending_out: current.pending_out,
153                    };
154                    self.balances.set_balance(address, asset_id, synced);
155
156                    debug!(
157                        "Synced balance for {} {}: {} → {}",
158                        address,
159                        asset_id.as_str(),
160                        current.available,
161                        on_chain_balance
162                    );
163                }
164                Err(e) => {
165                    warn!(
166                        "Failed to sync balance for {} {}: {}",
167                        address,
168                        asset_id.as_str(),
169                        e
170                    );
171                }
172            }
173        }
174        Ok(())
175    }
176
177    /// Sync the nonce for an address from on-chain state.
178    async fn sync_nonce(
179        &self,
180        address: &Address,
181        provider: &dyn ChainStateProvider,
182    ) -> Result<()> {
183        match provider.get_on_chain_nonce(address).await {
184            Ok(on_chain_nonce) => {
185                self.nonces.sync_from_chain(address, on_chain_nonce);
186                debug!(
187                    "Synced nonce for {}: on-chain={}",
188                    address, on_chain_nonce
189                );
190            }
191            Err(e) => {
192                warn!("Failed to sync nonce for {}: {}", address, e);
193            }
194        }
195        Ok(())
196    }
197
198    /// Check and update the status of pending transactions.
199    async fn sync_pending_transactions(
200        &self,
201        address: &Address,
202        provider: &dyn ChainStateProvider,
203    ) -> Result<()> {
204        let pending_records: Vec<TxRecord> = self
205            .history
206            .get_history(address)
207            .into_iter()
208            .filter(|r| r.is_pending())
209            .collect();
210
211        for record in pending_records {
212            match provider.get_transaction_status(&record.tx_hash).await {
213                Ok(new_status) => {
214                    if new_status != record.status {
215                        if let Err(e) = self.history.update_status(&record.tx_hash, new_status) {
216                            warn!("Failed to update tx status for {}: {}", record.tx_hash, e);
217                        } else {
218                            debug!(
219                                "Updated tx {} status: {} → {}",
220                                record.tx_hash, record.status, new_status
221                            );
222                        }
223                    }
224                }
225                Err(e) => {
226                    warn!(
227                        "Failed to check status for tx {}: {}",
228                        record.tx_hash, e
229                    );
230                }
231            }
232        }
233
234        Ok(())
235    }
236
237    /// Get the balance, preferring on-chain data when available.
238    pub async fn get_balance(&self, address: &Address, asset_id: &AssetId) -> Result<Balance> {
239        // If connected, try to get fresh on-chain balance
240        if let Some(provider) = &self.chain_provider {
241            match provider.get_on_chain_balance(address, asset_id).await {
242                Ok(on_chain) => {
243                    let current = self.balances.get_balance(address, asset_id);
244                    return Ok(Balance {
245                        available: on_chain,
246                        locked: current.locked,
247                        pending_in: current.pending_in,
248                        pending_out: current.pending_out,
249                    });
250                }
251                Err(e) => {
252                    debug!(
253                        "Falling back to cached balance for {} {}: {}",
254                        address,
255                        asset_id.as_str(),
256                        e
257                    );
258                }
259            }
260        }
261
262        // Fall back to local cached balance
263        Ok(self.balances.get_balance(address, asset_id))
264    }
265
266    /// Submit a hybrid post-quantum signed transaction and track it in history.
267    ///
268    /// `tx`, `classical_sig`, and `pq_sig` together form the wire payload the
269    /// node's `eth_sendRawTransaction` parser expects. Both signature legs
270    /// must verify against `Transaction::hash()` server-side or the call
271    /// returns `WalletError::Other` carrying JSON-RPC error `-32003`.
272    pub async fn submit_and_track(
273        &self,
274        address: &Address,
275        tx: &Transaction,
276        classical_sig: &Signature,
277        pq_sig: &[u8],
278        record: TxRecord,
279    ) -> Result<Hash> {
280        let provider = self.chain_provider.as_ref().ok_or_else(|| {
281            WalletError::Other("No chain state provider connected".to_string())
282        })?;
283
284        // Submit to network
285        let tx_hash = provider
286            .submit_signed_transaction(tx, classical_sig, pq_sig)
287            .await?;
288
289        // Record in history
290        let mut tracked = record;
291        tracked.tx_hash = tx_hash;
292        tracked.mark_pending();
293        self.history.record(address, tracked);
294
295        info!("Submitted and tracking transaction {}", tx_hash);
296        Ok(tx_hash)
297    }
298}
299
300/// Local-only chain state provider for offline/testing use.
301///
302/// Uses the in-memory balance tracker and nonce manager as the
303/// "source of truth" — no actual chain connection.
304pub struct LocalStateProvider {
305    balances: Arc<BalanceTracker>,
306    nonces: Arc<NonceManager>,
307}
308
309impl LocalStateProvider {
310    /// Create a new local state provider.
311    pub fn new(balances: Arc<BalanceTracker>, nonces: Arc<NonceManager>) -> Self {
312        Self { balances, nonces }
313    }
314}
315
316#[async_trait]
317impl ChainStateProvider for LocalStateProvider {
318    async fn get_on_chain_balance(
319        &self,
320        address: &Address,
321        asset_id: &AssetId,
322    ) -> Result<u128> {
323        Ok(self.balances.get_balance(address, asset_id).available)
324    }
325
326    async fn get_on_chain_balances(
327        &self,
328        address: &Address,
329    ) -> Result<Vec<(AssetId, u128)>> {
330        Ok(self
331            .balances
332            .get_all_balances(address)
333            .into_iter()
334            .map(|(id, bal)| (id, bal.available))
335            .collect())
336    }
337
338    async fn get_on_chain_nonce(&self, address: &Address) -> Result<u64> {
339        Ok(self.nonces.confirmed_nonce(address).0)
340    }
341
342    async fn get_transaction_status(&self, _tx_hash: &Hash) -> Result<TxStatus> {
343        // Local provider: assume pending until proven otherwise
344        Ok(TxStatus::Pending)
345    }
346
347    async fn get_block_height(&self) -> Result<u64> {
348        Ok(0)
349    }
350
351    async fn submit_signed_transaction(
352        &self,
353        tx: &Transaction,
354        _classical_sig: &Signature,
355        _pq_sig: &[u8],
356    ) -> Result<Hash> {
357        // Local provider: just return the canonical transaction hash. There
358        // is no real network to submit to; signature legs are not verified
359        // here because the wallet has already signed and the test caller
360        // wants the same hash the network would emit.
361        Ok(tx.hash())
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[tokio::test]
370    async fn test_local_state_provider() {
371        let balances = Arc::new(BalanceTracker::new());
372        let nonces = Arc::new(NonceManager::new());
373
374        let addr = Address::new([1u8; 32]);
375        let asset = AssetId::tnzo();
376
377        // Set up local state
378        balances.add_balance(&addr, &asset, 1000);
379        nonces.next_nonce(&addr); // 0
380        nonces.confirm_nonce(&addr, 0);
381
382        let provider = LocalStateProvider::new(balances.clone(), nonces.clone());
383
384        let balance = provider.get_on_chain_balance(&addr, &asset).await.unwrap();
385        assert_eq!(balance, 1000);
386
387        let nonce = provider.get_on_chain_nonce(&addr).await.unwrap();
388        assert_eq!(nonce, 1);
389    }
390
391    #[tokio::test]
392    async fn test_wallet_state_sync() {
393        let balances = Arc::new(BalanceTracker::new());
394        let nonces = Arc::new(NonceManager::new());
395        let history = Arc::new(TransactionHistory::new());
396
397        let addr = Address::new([1u8; 32]);
398        let asset = AssetId::tnzo();
399
400        // Set up local state
401        balances.add_balance(&addr, &asset, 1000);
402
403        let provider = Arc::new(LocalStateProvider::new(
404            balances.clone(),
405            nonces.clone(),
406        ));
407
408        let sync = WalletStateSync::new(
409            balances.clone(),
410            nonces.clone(),
411            history.clone(),
412        )
413        .with_chain_provider(provider);
414
415        assert!(sync.is_connected());
416
417        let balance = sync.get_balance(&addr, &asset).await.unwrap();
418        assert_eq!(balance.available, 1000);
419    }
420
421    #[tokio::test]
422    async fn test_sync_address() {
423        let balances = Arc::new(BalanceTracker::new());
424        let nonces = Arc::new(NonceManager::new());
425        let history = Arc::new(TransactionHistory::new());
426
427        let addr = Address::new([1u8; 32]);
428        let asset = AssetId::tnzo();
429
430        balances.add_balance(&addr, &asset, 5000);
431
432        let provider = Arc::new(LocalStateProvider::new(
433            balances.clone(),
434            nonces.clone(),
435        ));
436
437        let sync = WalletStateSync::new(
438            balances.clone(),
439            nonces.clone(),
440            history.clone(),
441        )
442        .with_chain_provider(provider);
443
444        sync.sync_address(&addr, std::slice::from_ref(&asset)).await.unwrap();
445
446        let balance = balances.get_balance(&addr, &asset);
447        assert_eq!(balance.available, 5000);
448    }
449
450    #[tokio::test]
451    async fn test_offline_fallback() {
452        let balances = Arc::new(BalanceTracker::new());
453        let nonces = Arc::new(NonceManager::new());
454        let history = Arc::new(TransactionHistory::new());
455
456        let addr = Address::new([1u8; 32]);
457        let asset = AssetId::tnzo();
458
459        balances.add_balance(&addr, &asset, 2000);
460
461        // No chain provider — offline mode
462        let sync = WalletStateSync::new(
463            balances.clone(),
464            nonces.clone(),
465            history.clone(),
466        );
467
468        assert!(!sync.is_connected());
469
470        // Should fall back to cached balance
471        let balance = sync.get_balance(&addr, &asset).await.unwrap();
472        assert_eq!(balance.available, 2000);
473    }
474}