Skip to main content

lws_core/
wallet_file.rs

1use crate::chain::ChainType;
2use serde::{Deserialize, Serialize};
3
4/// The full on-disk wallet file format (extended Ethereum Keystore v3).
5/// Written to `~/.lws/wallets/<id>.json`.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct EncryptedWallet {
8    pub lws_version: u32,
9    pub id: String,
10    pub name: String,
11    pub created_at: String,
12    /// Deprecated in v2. Kept for backward compat when deserializing v1 wallets.
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub chain_type: Option<ChainType>,
15    pub accounts: Vec<WalletAccount>,
16    pub crypto: serde_json::Value,
17    pub key_type: KeyType,
18    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
19    pub metadata: serde_json::Value,
20}
21
22/// An account entry within an encrypted wallet file.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct WalletAccount {
25    pub account_id: String,
26    pub address: String,
27    pub chain_id: String,
28    pub derivation_path: String,
29}
30
31/// Type of key material stored in the ciphertext.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum KeyType {
35    Mnemonic,
36    /// Multi-curve key pair: encrypted JSON `{"secp256k1":"hex","ed25519":"hex"}`.
37    /// Supports all 6 chains.
38    PrivateKey,
39}
40
41impl EncryptedWallet {
42    pub fn new(
43        id: String,
44        name: String,
45        accounts: Vec<WalletAccount>,
46        crypto: serde_json::Value,
47        key_type: KeyType,
48    ) -> Self {
49        EncryptedWallet {
50            lws_version: 2,
51            id,
52            name,
53            created_at: chrono::Utc::now().to_rfc3339(),
54            chain_type: None,
55            accounts,
56            crypto,
57            key_type,
58            metadata: serde_json::Value::Null,
59        }
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    fn dummy_wallet() -> EncryptedWallet {
68        EncryptedWallet::new(
69            "test-id".to_string(),
70            "test-wallet".to_string(),
71            vec![WalletAccount {
72                account_id: "eip155:1:0xabc".to_string(),
73                address: "0xabc".to_string(),
74                chain_id: "eip155:1".to_string(),
75                derivation_path: "m/44'/60'/0'/0/0".to_string(),
76            }],
77            serde_json::json!({"cipher": "aes-256-gcm"}),
78            KeyType::Mnemonic,
79        )
80    }
81
82    #[test]
83    fn test_serde_roundtrip() {
84        let wallet = dummy_wallet();
85        let json = serde_json::to_string_pretty(&wallet).unwrap();
86        let deserialized: EncryptedWallet = serde_json::from_str(&json).unwrap();
87        assert_eq!(deserialized.id, "test-id");
88        assert_eq!(deserialized.name, "test-wallet");
89        assert_eq!(deserialized.lws_version, 2);
90        assert!(deserialized.chain_type.is_none());
91    }
92
93    #[test]
94    fn test_key_type_serde() {
95        let json = serde_json::to_string(&KeyType::Mnemonic).unwrap();
96        assert_eq!(json, "\"mnemonic\"");
97        let json = serde_json::to_string(&KeyType::PrivateKey).unwrap();
98        assert_eq!(json, "\"private_key\"");
99    }
100
101    #[test]
102    fn test_v2_no_chain_type_field() {
103        let wallet = dummy_wallet();
104        let json = serde_json::to_value(&wallet).unwrap();
105        assert!(
106            json.get("chain_type").is_none(),
107            "v2 wallets should not serialize chain_type"
108        );
109    }
110
111    #[test]
112    fn test_matches_spec_format() {
113        let wallet = dummy_wallet();
114        let json = serde_json::to_value(&wallet).unwrap();
115        for key in [
116            "lws_version",
117            "id",
118            "name",
119            "created_at",
120            "accounts",
121            "crypto",
122            "key_type",
123        ] {
124            assert!(json.get(key).is_some(), "missing key: {key}");
125        }
126    }
127
128    #[test]
129    fn test_metadata_omitted_when_null() {
130        let wallet = dummy_wallet();
131        let json = serde_json::to_value(&wallet).unwrap();
132        assert!(json.get("metadata").is_none());
133    }
134
135    #[test]
136    fn test_v1_backward_compat() {
137        // Simulate a v1 wallet JSON with chain_type field
138        let v1_json = serde_json::json!({
139            "lws_version": 1,
140            "id": "old-id",
141            "name": "old-wallet",
142            "created_at": "2024-01-01T00:00:00Z",
143            "chain_type": "evm",
144            "accounts": [{
145                "account_id": "eip155:1:0xabc",
146                "address": "0xabc",
147                "chain_id": "eip155:1",
148                "derivation_path": "m/44'/60'/0'/0/0"
149            }],
150            "crypto": {"cipher": "aes-256-gcm"},
151            "key_type": "mnemonic"
152        });
153        let wallet: EncryptedWallet = serde_json::from_value(v1_json).unwrap();
154        assert_eq!(wallet.lws_version, 1);
155        assert_eq!(wallet.chain_type, Some(ChainType::Evm));
156    }
157}