Skip to main content

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