diff --git a/bindings/node/__test__/store.spec.mjs b/bindings/node/__test__/store.spec.mjs new file mode 100644 index 00000000..e3545d47 --- /dev/null +++ b/bindings/node/__test__/store.spec.mjs @@ -0,0 +1,128 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createOws } from '../index.js'; + +describe('OWS with custom store', () => { + + it('creates and lists wallets with an in-memory JS store', () => { + const data = new Map(); + + const ows = createOws({ + store: { + get: (key) => data.get(key) ?? null, + set: (key, value) => { data.set(key, value); }, + remove: (key) => { data.delete(key); }, + list: (prefix) => [...data.keys()].filter(k => k.startsWith(prefix + '/')), + } + }); + + // Create a wallet + const wallet = ows.createWallet('test-wallet', 'pass123'); + assert.ok(wallet.id, 'wallet should have an id'); + assert.equal(wallet.name, 'test-wallet'); + assert.ok(wallet.accounts.length > 0, 'wallet should have accounts'); + + // List wallets + const wallets = ows.listWallets(); + assert.equal(wallets.length, 1); + assert.equal(wallets[0].name, 'test-wallet'); + + // Get wallet by id + const fetched = ows.getWallet(wallet.id); + assert.equal(fetched.id, wallet.id); + + // Get wallet by name + const byName = ows.getWallet('test-wallet'); + assert.equal(byName.id, wallet.id); + + // Verify data is in our Map (not on the filesystem) + assert.ok(data.size > 0, 'store should contain data'); + const walletKey = [...data.keys()].find(k => k.startsWith('wallets/')); + assert.ok(walletKey, 'should have a wallets/ key'); + const walletJson = JSON.parse(data.get(walletKey)); + assert.equal(walletJson.name, 'test-wallet'); + + console.log(` stored ${data.size} entries in JS Map:`); + for (const key of data.keys()) { + console.log(` ${key}`); + } + }); + + it('exports and re-imports a wallet via custom store', () => { + const data = new Map(); + const store = { + get: (key) => data.get(key) ?? null, + set: (key, value) => { data.set(key, value); }, + remove: (key) => { data.delete(key); }, + list: (prefix) => [...data.keys()].filter(k => k.startsWith(prefix + '/')), + }; + const ows = createOws({ store }); + + const wallet = ows.createWallet('exportable', 'mypass'); + + // Export the mnemonic + const mnemonic = ows.exportWallet(wallet.id, 'mypass'); + assert.equal(mnemonic.split(' ').length, 12, 'should export a 12-word mnemonic'); + + // Delete it + ows.deleteWallet(wallet.id); + assert.equal(ows.listWallets().length, 0); + + // Re-import + const reimported = ows.importWalletMnemonic('reimported', mnemonic, 'newpass'); + assert.equal(reimported.name, 'reimported'); + assert.equal(ows.listWallets().length, 1); + }); + + it('signs a message with a wallet in a custom store', () => { + const data = new Map(); + const ows = createOws({ + store: { + get: (key) => data.get(key) ?? null, + set: (key, value) => { data.set(key, value); }, + remove: (key) => { data.delete(key); }, + list: (prefix) => [...data.keys()].filter(k => k.startsWith(prefix + '/')), + } + }); + + const wallet = ows.createWallet('signer', 'pass'); + const result = ows.signMessage(wallet.id, 'evm', 'hello world', 'pass'); + assert.ok(result.signature, 'should return a signature'); + assert.ok(result.signature.length > 0, 'signature should be non-empty'); + console.log(` signature: ${result.signature.substring(0, 40)}...`); + }); + + it('works with a store that has no list method (uses index)', () => { + const data = new Map(); + + // No `list` method — library should maintain _index keys automatically + const ows = createOws({ + store: { + get: (key) => data.get(key) ?? null, + set: (key, value) => { data.set(key, value); }, + remove: (key) => { data.delete(key); }, + } + }); + + ows.createWallet('w1', 'p'); + ows.createWallet('w2', 'p'); + + const wallets = ows.listWallets(); + assert.equal(wallets.length, 2, 'should list both wallets using index'); + + // Verify the index key exists + const indexKey = '_index/wallets'; + const indexValue = data.get(indexKey); + assert.ok(indexValue, 'should have an _index/wallets key'); + const index = JSON.parse(indexValue); + assert.equal(index.length, 2, 'index should have 2 entries'); + console.log(` index keys: ${JSON.stringify(index)}`); + }); + + it('default store (no options) uses filesystem', () => { + // Just verify it constructs without error + const ows = createOws(); + assert.ok(ows, 'should create with default FsStore'); + }); +}); diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index dfbe6508..a1ced606 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -1,5 +1,7 @@ use napi::bindgen_prelude::*; +use napi::{Env, NapiRaw}; use napi_derive::napi; +use ows_core::{Store, StoreError}; use std::path::PathBuf; fn vault_path(p: Option) -> Option { @@ -10,6 +12,143 @@ fn map_err(e: ows_lib::OwsLibError) -> napi::Error { napi::Error::from_reason(e.to_string()) } +// --------------------------------------------------------------------------- +// JsStore — bridges a JS { get, set, remove, list? } object to Rust Store +// --------------------------------------------------------------------------- + +/// Store backed by a JavaScript object with `get`, `set`, `remove`, and +/// optional `list` methods. Uses raw NAPI pointers internally because +/// `napi::Env` is not `Send + Sync`, but all calls are synchronous on the +/// main JS thread so this is safe. +struct JsStore { + raw_env: napi::sys::napi_env, + store_ref: napi::sys::napi_ref, + has_list: bool, +} + +// SAFETY: All Store methods are called synchronously on the main JS thread. +// The raw_env and store_ref are only accessed from that thread. +unsafe impl Send for JsStore {} +unsafe impl Sync for JsStore {} + +impl Drop for JsStore { + fn drop(&mut self) { + unsafe { + napi::sys::napi_delete_reference(self.raw_env, self.store_ref); + } + } +} + +impl JsStore { + /// Create a JsStore from a NAPI Env and a JS store object. + fn new(env: &napi::Env, store_obj: &napi::JsObject) -> std::result::Result { + let raw_env = env.raw(); + let has_list = store_obj.has_named_property("list")?; + + // Create a strong reference to prevent GC. + let mut store_ref: napi::sys::napi_ref = std::ptr::null_mut(); + let status = unsafe { + napi::sys::napi_create_reference(raw_env, store_obj.raw(), 1, &mut store_ref) + }; + if status != napi::sys::Status::napi_ok { + return Err(napi::Error::from_reason("failed to create reference to store object")); + } + + Ok(JsStore { raw_env, store_ref, has_list }) + } + + /// Reconstruct the Env and store JsObject for a call. + fn env_and_obj(&self) -> std::result::Result<(napi::Env, napi::JsObject), StoreError> { + unsafe { + let env = napi::Env::from_raw(self.raw_env); + let mut js_value: napi::sys::napi_value = std::ptr::null_mut(); + let status = napi::sys::napi_get_reference_value(self.raw_env, self.store_ref, &mut js_value); + if status != napi::sys::Status::napi_ok || js_value.is_null() { + return Err(StoreError("store object has been garbage collected".to_string().into())); + } + let obj = napi::JsObject::from_napi_value(self.raw_env, js_value) + .map_err(|e| StoreError(e.to_string().into()))?; + Ok((env, obj)) + } + } +} + +fn napi_to_store_err(e: napi::Error) -> StoreError { + StoreError(e.to_string().into()) +} + +impl Store for JsStore { + fn get(&self, key: &str) -> std::result::Result, StoreError> { + let (env, obj) = self.env_and_obj()?; + let func: napi::JsFunction = obj.get_named_property("get").map_err(napi_to_store_err)?; + let js_key = env.create_string(key).map_err(napi_to_store_err)?; + let result: napi::JsUnknown = func.call(Some(&obj), &[js_key]).map_err(napi_to_store_err)?; + let value_type = result.get_type().map_err(napi_to_store_err)?; + match value_type { + napi::ValueType::Null | napi::ValueType::Undefined => Ok(None), + _ => { + let js_str: napi::JsString = result.coerce_to_string().map_err(napi_to_store_err)?; + let utf8 = js_str.into_utf8().map_err(napi_to_store_err)?; + let s = utf8.as_str().map_err(|e| StoreError(e.to_string().into()))?.to_string(); + Ok(Some(s)) + } + } + } + + fn set(&self, key: &str, value: &str) -> std::result::Result<(), StoreError> { + let (env, obj) = self.env_and_obj()?; + let func: napi::JsFunction = obj.get_named_property("set").map_err(napi_to_store_err)?; + let js_key = env.create_string(key).map_err(napi_to_store_err)?; + let js_value = env.create_string(value).map_err(napi_to_store_err)?; + func.call(Some(&obj), &[js_key, js_value]).map_err(napi_to_store_err)?; + Ok(()) + } + + fn remove(&self, key: &str) -> std::result::Result<(), StoreError> { + let (env, obj) = self.env_and_obj()?; + let func: napi::JsFunction = obj.get_named_property("remove").map_err(napi_to_store_err)?; + let js_key = env.create_string(key).map_err(napi_to_store_err)?; + func.call(Some(&obj), &[js_key]).map_err(napi_to_store_err)?; + Ok(()) + } + + fn list(&self, prefix: &str) -> std::result::Result, StoreError> { + if !self.has_list { + // Default index-based list. + let index_key = format!("_index/{prefix}"); + return match self.get(&index_key)? { + Some(json) => Ok(serde_json::from_str(&json)?), + None => Ok(vec![]), + }; + } + + let (env, obj) = self.env_and_obj()?; + let func: napi::JsFunction = obj.get_named_property("list").map_err(napi_to_store_err)?; + let js_prefix = env.create_string(prefix).map_err(napi_to_store_err)?; + let result: napi::JsObject = func + .call(Some(&obj), &[js_prefix]) + .map_err(napi_to_store_err)? + .coerce_to_object() + .map_err(napi_to_store_err)?; + + let length: u32 = result + .get_named_property::("length") + .map_err(napi_to_store_err)? + .get_uint32() + .map_err(napi_to_store_err)?; + + let mut keys = Vec::with_capacity(length as usize); + for i in 0..length { + let item: napi::JsString = result.get_element::(i).map_err(napi_to_store_err)?; + let utf8 = item.into_utf8().map_err(napi_to_store_err)?; + let s = utf8.as_str().map_err(|e| StoreError(e.to_string().into()))?.to_string(); + keys.push(s); + } + + Ok(keys) + } +} + /// A single account within a wallet (one per chain family). #[napi(object)] pub struct AccountInfo { @@ -399,3 +538,331 @@ pub fn sign_and_send( .map(|r| SendResult { tx_hash: r.tx_hash }) .map_err(map_err) } + +// --------------------------------------------------------------------------- +// OWS class — pluggable store support +// --------------------------------------------------------------------------- + +/// OWS client with a pluggable store backend. +/// +/// ```js +/// // Default filesystem store (~/.ows) +/// const ows = new OWS(); +/// +/// // Custom vault path +/// const ows = new OWS({ vaultPath: "/custom/path" }); +/// +/// // Custom store +/// const ows = new OWS({ +/// store: { +/// get: (key) => myDb.get(key), +/// set: (key, value) => myDb.set(key, value), +/// remove: (key) => myDb.delete(key), +/// list: (prefix) => myDb.keysWithPrefix(prefix), // optional +/// } +/// }); +/// ``` +/// Create an OWS instance with an optional custom store. +/// +/// ```js +/// // Default filesystem store +/// const ows = createOWS(); +/// +/// // Custom store +/// const ows = createOWS({ +/// store: { +/// get: (key) => myDb.get(key), +/// set: (key, value) => myDb.set(key, value), +/// remove: (key) => myDb.delete(key), +/// list: (prefix) => myDb.keysWithPrefix(prefix), // optional +/// } +/// }); +/// ``` +/// Create an OWS instance. Called as `createOws()` or `createOws({ store: ... })`. +/// +/// The store object must implement: `get(key: string): string | null`, +/// `set(key: string, value: string): void`, `remove(key: string): void`, +/// and optionally `list(prefix: string): string[]`. +#[napi( + ts_args_type = "options?: { store?: { get: (key: string) => string | null, set: (key: string, value: string) => void, remove: (key: string) => void, list?: (prefix: string) => string[] }, vaultPath?: string }" +)] +pub fn create_ows(env: Env, options: Option) -> Result { + let store: Box = match options { + Some(opts) => { + let has_store = opts.has_named_property("store") + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + + if has_store { + let store_obj: napi::JsObject = opts.get_named_property("store") + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Box::new(JsStore::new(&env, &store_obj)?) + } else { + let vault_path: Option = opts.get_named_property::("vaultPath") + .ok() + .and_then(|v| v.coerce_to_string().ok()) + .and_then(|s| s.into_utf8().ok()) + .and_then(|u| u.as_str().ok().map(|s| s.to_string())); + Box::new(ows_lib::FsStore::new( + vault_path.as_deref().map(std::path::Path::new), + )) + } + } + None => Box::new(ows_lib::FsStore::new(None)), + }; + Ok(OWS { store }) +} + +#[napi] +pub struct OWS { + store: Box, +} + +#[napi] +impl OWS { + + #[napi] + pub fn create_wallet( + &self, + name: String, + passphrase: Option, + words: Option, + ) -> Result { + ows_lib::create_wallet_with_store( + &name, + words, + passphrase.as_deref(), + &*self.store, + ) + .map(WalletInfo::from) + .map_err(map_err) + } + + #[napi] + pub fn import_wallet_mnemonic( + &self, + name: String, + mnemonic: String, + passphrase: Option, + index: Option, + ) -> Result { + ows_lib::import_wallet_mnemonic_with_store( + &name, + &mnemonic, + passphrase.as_deref(), + index, + &*self.store, + ) + .map(WalletInfo::from) + .map_err(map_err) + } + + #[napi] + pub fn import_wallet_private_key( + &self, + name: String, + private_key_hex: String, + passphrase: Option, + chain: Option, + secp256k1_key: Option, + ed25519_key: Option, + ) -> Result { + ows_lib::import_wallet_private_key_with_store( + &name, + &private_key_hex, + chain.as_deref(), + passphrase.as_deref(), + &*self.store, + secp256k1_key.as_deref(), + ed25519_key.as_deref(), + ) + .map(WalletInfo::from) + .map_err(map_err) + } + + #[napi] + pub fn list_wallets(&self) -> Result> { + ows_lib::list_wallets_with_store(&*self.store) + .map(|ws| ws.into_iter().map(WalletInfo::from).collect()) + .map_err(map_err) + } + + #[napi] + pub fn get_wallet(&self, name_or_id: String) -> Result { + ows_lib::get_wallet_with_store(&name_or_id, &*self.store) + .map(WalletInfo::from) + .map_err(map_err) + } + + #[napi] + pub fn delete_wallet(&self, name_or_id: String) -> Result<()> { + ows_lib::delete_wallet_with_store(&name_or_id, &*self.store).map_err(map_err) + } + + #[napi] + pub fn export_wallet( + &self, + name_or_id: String, + passphrase: Option, + ) -> Result { + ows_lib::export_wallet_with_store( + &name_or_id, + passphrase.as_deref(), + &*self.store, + ) + .map_err(map_err) + } + + #[napi] + pub fn rename_wallet(&self, name_or_id: String, new_name: String) -> Result<()> { + ows_lib::rename_wallet_with_store(&name_or_id, &new_name, &*self.store) + .map_err(map_err) + } + + #[napi] + pub fn sign_transaction( + &self, + wallet: String, + chain: String, + tx_hex: String, + passphrase: Option, + index: Option, + ) -> Result { + ows_lib::sign_transaction_with_store( + &wallet, + &chain, + &tx_hex, + passphrase.as_deref(), + index, + &*self.store, + ) + .map(|r| SignResult { + signature: r.signature, + recovery_id: r.recovery_id.map(|v| v as u32), + }) + .map_err(map_err) + } + + #[napi] + pub fn sign_message( + &self, + wallet: String, + chain: String, + message: String, + passphrase: Option, + encoding: Option, + index: Option, + ) -> Result { + ows_lib::sign_message_with_store( + &wallet, + &chain, + &message, + passphrase.as_deref(), + encoding.as_deref(), + index, + &*self.store, + ) + .map(|r| SignResult { + signature: r.signature, + recovery_id: r.recovery_id.map(|v| v as u32), + }) + .map_err(map_err) + } + + #[napi] + pub fn sign_typed_data( + &self, + wallet: String, + chain: String, + typed_data_json: String, + passphrase: Option, + index: Option, + ) -> Result { + ows_lib::sign_typed_data_with_store( + &wallet, + &chain, + &typed_data_json, + passphrase.as_deref(), + index, + &*self.store, + ) + .map(|r| SignResult { + signature: r.signature, + recovery_id: r.recovery_id.map(|v| v as u32), + }) + .map_err(map_err) + } + + #[napi] + pub fn create_policy(&self, policy_json: String) -> Result<()> { + let policy: ows_core::Policy = serde_json::from_str(&policy_json) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + ows_lib::policy_store::save_policy_with_store(&policy, &*self.store).map_err(map_err) + } + + #[napi] + pub fn list_policies(&self) -> Result> { + let policies = + ows_lib::policy_store::list_policies_with_store(&*self.store).map_err(map_err)?; + policies + .iter() + .map(|p| serde_json::to_value(p).map_err(|e| napi::Error::from_reason(e.to_string()))) + .collect() + } + + #[napi] + pub fn get_policy(&self, id: String) -> Result { + let policy = + ows_lib::policy_store::load_policy_with_store(&id, &*self.store).map_err(map_err)?; + serde_json::to_value(&policy).map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub fn delete_policy(&self, id: String) -> Result<()> { + ows_lib::policy_store::delete_policy_with_store(&id, &*self.store).map_err(map_err) + } + + #[napi] + pub fn create_api_key( + &self, + name: String, + wallet_ids: Vec, + policy_ids: Vec, + passphrase: String, + expires_at: Option, + ) -> Result { + let (token, key_file) = ows_lib::key_ops::create_api_key_with_store( + &name, + &wallet_ids, + &policy_ids, + &passphrase, + expires_at.as_deref(), + &*self.store, + ) + .map_err(map_err)?; + + Ok(ApiKeyResult { + token, + id: key_file.id, + name: key_file.name, + }) + } + + #[napi] + pub fn list_api_keys(&self) -> Result> { + let keys = + ows_lib::key_store::list_api_keys_with_store(&*self.store).map_err(map_err)?; + keys.iter() + .map(|k| { + let mut v = serde_json::to_value(k) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + v.as_object_mut().map(|m| m.remove("wallet_secrets")); + Ok(v) + }) + .collect() + } + + #[napi] + pub fn revoke_api_key(&self, id: String) -> Result<()> { + ows_lib::key_store::delete_api_key_with_store(&id, &*self.store).map_err(map_err) + } +} diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 56a3c934..e0f72115 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1,3 +1,4 @@ +use ows_core::{Store, StoreError}; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; use std::path::PathBuf; @@ -10,6 +11,85 @@ fn map_err(e: ows_lib::OwsLibError) -> PyErr { PyRuntimeError::new_err(e.to_string()) } +// --------------------------------------------------------------------------- +// PyStore — bridges a Python store object to Rust Store +// --------------------------------------------------------------------------- + +struct PyStore { + obj: PyObject, +} + +// SAFETY: PyStore methods always acquire the GIL before accessing the Python object. +unsafe impl Send for PyStore {} +unsafe impl Sync for PyStore {} + +impl Store for PyStore { + fn get(&self, key: &str) -> Result, StoreError> { + Python::with_gil(|py| { + let result = self + .obj + .call_method1(py, "get", (key,)) + .map_err(|e| StoreError(e.to_string().into()))?; + if result.is_none(py) { + Ok(None) + } else { + let s: String = result + .extract(py) + .map_err(|e| StoreError(e.to_string().into()))?; + Ok(Some(s)) + } + }) + } + + fn set(&self, key: &str, value: &str) -> Result<(), StoreError> { + Python::with_gil(|py| { + self.obj + .call_method1(py, "set", (key, value)) + .map_err(|e| StoreError(e.to_string().into()))?; + Ok(()) + }) + } + + fn remove(&self, key: &str) -> Result<(), StoreError> { + Python::with_gil(|py| { + self.obj + .call_method1(py, "remove", (key,)) + .map_err(|e| StoreError(e.to_string().into()))?; + Ok(()) + }) + } + + fn list(&self, prefix: &str) -> Result, StoreError> { + Python::with_gil(|py| { + let has_list = self + .obj + .getattr(py, "list") + .is_ok(); + + if !has_list { + let index_key = format!("_index/{prefix}"); + return match self.get(&index_key)? { + Some(json) => { + let keys: Vec = serde_json::from_str(&json) + .map_err(|e| StoreError(e.to_string().into()))?; + Ok(keys) + } + None => Ok(vec![]), + }; + } + + let result = self + .obj + .call_method1(py, "list", (prefix,)) + .map_err(|e| StoreError(e.to_string().into()))?; + let keys: Vec = result + .extract(py) + .map_err(|e| StoreError(e.to_string().into()))?; + Ok(keys) + }) + } +} + /// Generate a new BIP-39 mnemonic phrase. #[pyfunction] #[pyo3(signature = (words=12))] @@ -410,6 +490,268 @@ fn wallet_info_to_dict_inner<'py>( Ok(dict) } +// --------------------------------------------------------------------------- +// OWS class — pluggable store support +// --------------------------------------------------------------------------- + +/// OWS client with a custom store backend. +/// +/// ```python +/// ows = OWS(store=MyStore()) +/// wallet = ows.create_wallet("my-wallet") +/// ``` +#[pyclass] +struct OWS { + store: Box, +} + +#[pymethods] +impl OWS { + #[new] + #[pyo3(signature = (store=None, vault_path=None))] + fn new(store: Option, vault_path: Option) -> Self { + let store: Box = match store { + Some(py_obj) => Box::new(PyStore { obj: py_obj }), + None => Box::new(ows_lib::FsStore::new( + vault_path.as_deref().map(std::path::Path::new), + )), + }; + OWS { store } + } + + #[pyo3(signature = (name, passphrase=None, words=None))] + fn create_wallet( + &self, + name: &str, + passphrase: Option<&str>, + words: Option, + ) -> PyResult { + let info = ows_lib::create_wallet_with_store(name, words, passphrase, &*self.store) + .map_err(map_err)?; + Python::with_gil(|py| wallet_info_to_dict(py, &info)) + } + + #[pyo3(signature = (name, mnemonic, passphrase=None, index=None))] + fn import_wallet_mnemonic( + &self, + name: &str, + mnemonic: &str, + passphrase: Option<&str>, + index: Option, + ) -> PyResult { + let info = ows_lib::import_wallet_mnemonic_with_store( + name, mnemonic, passphrase, index, &*self.store, + ) + .map_err(map_err)?; + Python::with_gil(|py| wallet_info_to_dict(py, &info)) + } + + #[pyo3(signature = (name, private_key_hex, chain=None, passphrase=None, secp256k1_key=None, ed25519_key=None))] + fn import_wallet_private_key( + &self, + name: &str, + private_key_hex: &str, + chain: Option<&str>, + passphrase: Option<&str>, + secp256k1_key: Option<&str>, + ed25519_key: Option<&str>, + ) -> PyResult { + let info = ows_lib::import_wallet_private_key_with_store( + name, + private_key_hex, + chain, + passphrase, + &*self.store, + secp256k1_key, + ed25519_key, + ) + .map_err(map_err)?; + Python::with_gil(|py| wallet_info_to_dict(py, &info)) + } + + fn list_wallets(&self) -> PyResult { + let wallets = ows_lib::list_wallets_with_store(&*self.store).map_err(map_err)?; + Python::with_gil(|py| { + let list = pyo3::types::PyList::empty(py); + for w in &wallets { + let dict = wallet_info_to_dict_inner(py, w)?; + list.append(dict)?; + } + Ok(list.unbind().into()) + }) + } + + fn get_wallet(&self, name_or_id: &str) -> PyResult { + let info = ows_lib::get_wallet_with_store(name_or_id, &*self.store).map_err(map_err)?; + Python::with_gil(|py| wallet_info_to_dict(py, &info)) + } + + fn delete_wallet(&self, name_or_id: &str) -> PyResult<()> { + ows_lib::delete_wallet_with_store(name_or_id, &*self.store).map_err(map_err) + } + + #[pyo3(signature = (name_or_id, passphrase=None))] + fn export_wallet(&self, name_or_id: &str, passphrase: Option<&str>) -> PyResult { + ows_lib::export_wallet_with_store(name_or_id, passphrase, &*self.store).map_err(map_err) + } + + fn rename_wallet(&self, name_or_id: &str, new_name: &str) -> PyResult<()> { + ows_lib::rename_wallet_with_store(name_or_id, new_name, &*self.store).map_err(map_err) + } + + #[pyo3(signature = (wallet, chain, tx_hex, passphrase=None, index=None))] + fn sign_transaction( + &self, + wallet: &str, + chain: &str, + tx_hex: &str, + passphrase: Option<&str>, + index: Option, + ) -> PyResult { + let result = ows_lib::sign_transaction_with_store( + wallet, chain, tx_hex, passphrase, index, &*self.store, + ) + .map_err(map_err)?; + Python::with_gil(|py| { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("signature", &result.signature)?; + dict.set_item("recovery_id", result.recovery_id)?; + Ok(dict.unbind().into()) + }) + } + + #[pyo3(signature = (wallet, chain, message, passphrase=None, encoding=None, index=None))] + fn sign_message( + &self, + wallet: &str, + chain: &str, + message: &str, + passphrase: Option<&str>, + encoding: Option<&str>, + index: Option, + ) -> PyResult { + let result = ows_lib::sign_message_with_store( + wallet, chain, message, passphrase, encoding, index, &*self.store, + ) + .map_err(map_err)?; + Python::with_gil(|py| { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("signature", &result.signature)?; + dict.set_item("recovery_id", result.recovery_id)?; + Ok(dict.unbind().into()) + }) + } + + #[pyo3(signature = (wallet, chain, typed_data_json, passphrase=None, index=None))] + fn sign_typed_data( + &self, + wallet: &str, + chain: &str, + typed_data_json: &str, + passphrase: Option<&str>, + index: Option, + ) -> PyResult { + let result = ows_lib::sign_typed_data_with_store( + wallet, chain, typed_data_json, passphrase, index, &*self.store, + ) + .map_err(map_err)?; + Python::with_gil(|py| { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("signature", &result.signature)?; + dict.set_item("recovery_id", result.recovery_id)?; + Ok(dict.unbind().into()) + }) + } + + fn create_policy(&self, policy_json: &str) -> PyResult<()> { + let policy: ows_core::Policy = serde_json::from_str(policy_json) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + ows_lib::policy_store::save_policy_with_store(&policy, &*self.store).map_err(map_err) + } + + fn list_policies(&self) -> PyResult { + let policies = + ows_lib::policy_store::list_policies_with_store(&*self.store).map_err(map_err)?; + let json_str = + serde_json::to_string(&policies).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + Python::with_gil(|py| { + let json_mod = py.import("json")?; + json_mod + .call_method1("loads", (json_str,)) + .map(|o| o.unbind()) + }) + } + + fn get_policy(&self, id: &str) -> PyResult { + let policy = + ows_lib::policy_store::load_policy_with_store(id, &*self.store).map_err(map_err)?; + let json_str = + serde_json::to_string(&policy).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + Python::with_gil(|py| { + let json_mod = py.import("json")?; + json_mod + .call_method1("loads", (json_str,)) + .map(|o| o.unbind()) + }) + } + + fn delete_policy(&self, id: &str) -> PyResult<()> { + ows_lib::policy_store::delete_policy_with_store(id, &*self.store).map_err(map_err) + } + + #[pyo3(signature = (name, wallet_ids, policy_ids, passphrase, expires_at=None))] + fn create_api_key( + &self, + name: &str, + wallet_ids: Vec, + policy_ids: Vec, + passphrase: &str, + expires_at: Option<&str>, + ) -> PyResult { + let (token, key_file) = ows_lib::key_ops::create_api_key_with_store( + name, + &wallet_ids, + &policy_ids, + passphrase, + expires_at, + &*self.store, + ) + .map_err(map_err)?; + Python::with_gil(|py| { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("token", token)?; + dict.set_item("id", &key_file.id)?; + dict.set_item("name", &key_file.name)?; + Ok(dict.unbind().into()) + }) + } + + fn list_api_keys(&self) -> PyResult { + let keys = + ows_lib::key_store::list_api_keys_with_store(&*self.store).map_err(map_err)?; + let sanitized: Vec = keys + .iter() + .map(|k| { + let mut v = serde_json::to_value(k).unwrap_or_default(); + v.as_object_mut().map(|m| m.remove("wallet_secrets")); + v + }) + .collect(); + let json_str = serde_json::to_string(&sanitized) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + Python::with_gil(|py| { + let json_mod = py.import("json")?; + json_mod + .call_method1("loads", (json_str,)) + .map(|o| o.unbind()) + }) + } + + fn revoke_api_key(&self, id: &str) -> PyResult<()> { + ows_lib::key_store::delete_api_key_with_store(id, &*self.store).map_err(map_err) + } +} + /// Python module definition. #[pymodule] fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { @@ -434,5 +776,6 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(create_api_key, m)?)?; m.add_function(wrap_pyfunction!(list_api_keys, m)?)?; m.add_function(wrap_pyfunction!(revoke_api_key, m)?)?; + m.add_class::()?; Ok(()) } diff --git a/ows/crates/ows-core/src/lib.rs b/ows/crates/ows-core/src/lib.rs index 096b9a0a..63c934c3 100644 --- a/ows/crates/ows-core/src/lib.rs +++ b/ows/crates/ows-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod chain; pub mod config; pub mod error; pub mod policy; +pub mod store; pub mod types; pub mod wallet_file; @@ -15,5 +16,6 @@ pub use chain::{ pub use config::Config; pub use error::{OwsError, OwsErrorCode}; pub use policy::{Policy, PolicyAction, PolicyContext, PolicyResult, PolicyRule}; +pub use store::{InMemoryStore, Store, StoreError, store_remove_indexed, store_set_indexed}; pub use types::*; pub use wallet_file::*; diff --git a/ows/crates/ows-core/src/store.rs b/ows/crates/ows-core/src/store.rs new file mode 100644 index 00000000..e0c452cb --- /dev/null +++ b/ows/crates/ows-core/src/store.rs @@ -0,0 +1,333 @@ +use std::collections::HashMap; +use std::sync::RwLock; + +// --------------------------------------------------------------------------- +// StoreError +// --------------------------------------------------------------------------- + +/// A concrete, object-safe error type for Store implementations. +#[derive(Debug)] +pub struct StoreError(pub Box); + +impl std::fmt::Display for StoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "store error: {}", self.0) + } +} + +impl std::error::Error for StoreError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&*self.0) + } +} + +impl From for StoreError { + fn from(e: std::io::Error) -> Self { + StoreError(Box::new(e)) + } +} + +impl From for StoreError { + fn from(e: serde_json::Error) -> Self { + StoreError(Box::new(e)) + } +} + +// --------------------------------------------------------------------------- +// Store trait +// --------------------------------------------------------------------------- + +/// Minimal key-value storage backend for OWS. +/// +/// The library owns the key namespace (`wallets/{id}`, `keys/{id}`, +/// `policies/{id}`) and serialization. Implementations just move strings +/// by key. +/// +/// # List behaviour +/// +/// The default `list` implementation reads an internal index key +/// (`_index/{prefix}`) maintained by the library's index helpers. +/// Backends with native prefix scanning (filesystem, databases) should +/// override `list` for better performance. +pub trait Store: Send + Sync { + /// Get a value by key. Returns `Ok(None)` if not found. + fn get(&self, key: &str) -> Result, StoreError>; + + /// Set a value by key, creating or overwriting. + fn set(&self, key: &str, value: &str) -> Result<(), StoreError>; + + /// Remove a value by key. Returns `Ok(())` even if the key didn't exist. + fn remove(&self, key: &str) -> Result<(), StoreError>; + + /// List all keys under a prefix (e.g. `"wallets"`). + /// + /// Returns full keys like `["wallets/abc", "wallets/def"]`. + /// + /// The default implementation reads the `_index/{prefix}` key maintained + /// by [`store_set_indexed`] / [`store_remove_indexed`]. Override this if + /// your backend supports native prefix enumeration. + fn list(&self, prefix: &str) -> Result, StoreError> { + let index_key = format!("_index/{prefix}"); + match self.get(&index_key)? { + Some(json) => { + let keys: Vec = serde_json::from_str(&json)?; + Ok(keys) + } + None => Ok(vec![]), + } + } +} + +// --------------------------------------------------------------------------- +// Index helpers +// --------------------------------------------------------------------------- + +/// Set a value and update the internal index for the given prefix. +/// +/// Calls `store.set(key, value)` then appends `key` to `_index/{prefix}` +/// (if not already present). Backends that override `list` with native +/// enumeration can ignore the index — it is harmless but unused. +pub fn store_set_indexed( + store: &dyn Store, + key: &str, + value: &str, + prefix: &str, +) -> Result<(), StoreError> { + store.set(key, value)?; + + let index_key = format!("_index/{prefix}"); + let mut keys: Vec = match store.get(&index_key)? { + Some(json) => serde_json::from_str(&json)?, + None => vec![], + }; + + let key_str = key.to_string(); + if !keys.contains(&key_str) { + keys.push(key_str); + let json = serde_json::to_string(&keys)?; + store.set(&index_key, &json)?; + } + + Ok(()) +} + +/// Remove a value and update the internal index for the given prefix. +/// +/// Calls `store.remove(key)` then removes `key` from `_index/{prefix}`. +pub fn store_remove_indexed( + store: &dyn Store, + key: &str, + prefix: &str, +) -> Result<(), StoreError> { + store.remove(key)?; + + let index_key = format!("_index/{prefix}"); + if let Some(json) = store.get(&index_key)? { + let mut keys: Vec = serde_json::from_str(&json)?; + keys.retain(|k| k != key); + let json = serde_json::to_string(&keys)?; + store.set(&index_key, &json)?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// InMemoryStore +// --------------------------------------------------------------------------- + +/// In-memory Store implementation for testing. +/// +/// Uses the default `list` implementation (index-based), so it exercises +/// the full index helper path. +pub struct InMemoryStore { + data: RwLock>, +} + +impl InMemoryStore { + pub fn new() -> Self { + Self { + data: RwLock::new(HashMap::new()), + } + } +} + +impl Default for InMemoryStore { + fn default() -> Self { + Self::new() + } +} + +impl Store for InMemoryStore { + fn get(&self, key: &str) -> Result, StoreError> { + let data = self.data.read().map_err(|e| StoreError(e.to_string().into()))?; + Ok(data.get(key).cloned()) + } + + fn set(&self, key: &str, value: &str) -> Result<(), StoreError> { + let mut data = self.data.write().map_err(|e| StoreError(e.to_string().into()))?; + data.insert(key.to_string(), value.to_string()); + Ok(()) + } + + fn remove(&self, key: &str) -> Result<(), StoreError> { + let mut data = self.data.write().map_err(|e| StoreError(e.to_string().into()))?; + data.remove(key); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // == Step 1: Store trait == + + #[test] + fn store_is_object_safe() { + // This compiles only if Store is object-safe. + fn assert_object_safe(_: &dyn Store) {} + let store = InMemoryStore::new(); + assert_object_safe(&store); + } + + #[test] + fn default_list_returns_empty_when_no_index() { + let store = InMemoryStore::new(); + let result = store.list("wallets").unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn default_list_reads_index_key() { + let store = InMemoryStore::new(); + let index = serde_json::to_string(&vec!["wallets/a", "wallets/b"]).unwrap(); + store.set("_index/wallets", &index).unwrap(); + + let result = store.list("wallets").unwrap(); + assert_eq!(result, vec!["wallets/a", "wallets/b"]); + } + + // == Step 2: InMemoryStore == + + #[test] + fn get_missing_returns_none() { + let store = InMemoryStore::new(); + assert_eq!(store.get("nonexistent").unwrap(), None); + } + + #[test] + fn set_then_get_roundtrip() { + let store = InMemoryStore::new(); + store.set("key", "value").unwrap(); + assert_eq!(store.get("key").unwrap(), Some("value".to_string())); + } + + #[test] + fn set_overwrites_existing() { + let store = InMemoryStore::new(); + store.set("key", "v1").unwrap(); + store.set("key", "v2").unwrap(); + assert_eq!(store.get("key").unwrap(), Some("v2".to_string())); + } + + #[test] + fn remove_deletes_key() { + let store = InMemoryStore::new(); + store.set("key", "value").unwrap(); + store.remove("key").unwrap(); + assert_eq!(store.get("key").unwrap(), None); + } + + #[test] + fn remove_missing_is_ok() { + let store = InMemoryStore::new(); + assert!(store.remove("nonexistent").is_ok()); + } + + // == Step 3: Index helpers == + + #[test] + fn set_indexed_writes_value_and_updates_index() { + let store = InMemoryStore::new(); + store_set_indexed(&store, "wallets/abc", r#"{"id":"abc"}"#, "wallets").unwrap(); + + // Value is stored + assert_eq!( + store.get("wallets/abc").unwrap(), + Some(r#"{"id":"abc"}"#.to_string()) + ); + + // Index is updated + let keys = store.list("wallets").unwrap(); + assert_eq!(keys, vec!["wallets/abc"]); + } + + #[test] + fn set_indexed_is_idempotent() { + let store = InMemoryStore::new(); + store_set_indexed(&store, "wallets/abc", r#"{"v":1}"#, "wallets").unwrap(); + store_set_indexed(&store, "wallets/abc", r#"{"v":2}"#, "wallets").unwrap(); + + // Value is updated + assert_eq!( + store.get("wallets/abc").unwrap(), + Some(r#"{"v":2}"#.to_string()) + ); + + // Index has only one entry (no duplicates) + let keys = store.list("wallets").unwrap(); + assert_eq!(keys, vec!["wallets/abc"]); + } + + #[test] + fn remove_indexed_removes_from_index() { + let store = InMemoryStore::new(); + store_set_indexed(&store, "wallets/a", "v1", "wallets").unwrap(); + store_set_indexed(&store, "wallets/b", "v2", "wallets").unwrap(); + + store_remove_indexed(&store, "wallets/a", "wallets").unwrap(); + + // Value is gone + assert_eq!(store.get("wallets/a").unwrap(), None); + + // Index reflects removal + let keys = store.list("wallets").unwrap(); + assert_eq!(keys, vec!["wallets/b"]); + } + + #[test] + fn remove_indexed_noop_when_absent() { + let store = InMemoryStore::new(); + // No index exists at all — should not error + assert!(store_remove_indexed(&store, "wallets/x", "wallets").is_ok()); + } + + #[test] + fn list_returns_correct_keys_after_mutations() { + let store = InMemoryStore::new(); + store_set_indexed(&store, "wallets/a", "1", "wallets").unwrap(); + store_set_indexed(&store, "wallets/b", "2", "wallets").unwrap(); + store_set_indexed(&store, "wallets/c", "3", "wallets").unwrap(); + store_remove_indexed(&store, "wallets/b", "wallets").unwrap(); + + let keys = store.list("wallets").unwrap(); + assert_eq!(keys, vec!["wallets/a", "wallets/c"]); + } + + #[test] + fn separate_prefixes_are_independent() { + let store = InMemoryStore::new(); + store_set_indexed(&store, "wallets/w1", "wallet", "wallets").unwrap(); + store_set_indexed(&store, "keys/k1", "key", "keys").unwrap(); + store_set_indexed(&store, "policies/p1", "policy", "policies").unwrap(); + + assert_eq!(store.list("wallets").unwrap(), vec!["wallets/w1"]); + assert_eq!(store.list("keys").unwrap(), vec!["keys/k1"]); + assert_eq!(store.list("policies").unwrap(), vec!["policies/p1"]); + } +} diff --git a/ows/crates/ows-lib/src/error.rs b/ows/crates/ows-lib/src/error.rs index 29e2ae87..e07cb082 100644 --- a/ows/crates/ows-lib/src/error.rs +++ b/ows/crates/ows-lib/src/error.rs @@ -40,4 +40,7 @@ pub enum OwsLibError { #[error("JSON error: {0}")] Json(#[from] serde_json::Error), + + #[error("store error: {0}")] + Store(#[from] ows_core::StoreError), } diff --git a/ows/crates/ows-lib/src/fs_store.rs b/ows/crates/ows-lib/src/fs_store.rs new file mode 100644 index 00000000..f16c999c --- /dev/null +++ b/ows/crates/ows-lib/src/fs_store.rs @@ -0,0 +1,442 @@ +use ows_core::{Config, Store, StoreError}; +use std::fs; +use std::path::{Path, PathBuf}; + +// --------------------------------------------------------------------------- +// FsStore +// --------------------------------------------------------------------------- + +/// Filesystem-backed Store implementation. +/// +/// Maps keys like `wallets/{id}` to files at `{vault_path}/wallets/{id}.json`. +/// Applies strict UNIX permissions for sensitive namespaces (wallets, keys). +pub struct FsStore { + vault_path: PathBuf, +} + +impl FsStore { + /// Create a new FsStore. If `vault_path` is `None`, uses the default `~/.ows`. + pub fn new(vault_path: Option<&Path>) -> Self { + Self { + vault_path: vault_path + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| Config::default().vault_path), + } + } + + /// Resolve a key to a filesystem path: `{vault_path}/{key}.json`. + fn key_to_path(&self, key: &str) -> PathBuf { + self.vault_path.join(format!("{key}.json")) + } + + /// Returns true if the key is in a sensitive namespace (wallets, keys). + fn is_sensitive(key: &str) -> bool { + key.starts_with("wallets/") || key.starts_with("keys/") + } +} + +/// Set directory permissions to 0o700 (owner-only). +#[cfg(unix)] +fn set_dir_permissions(path: &Path) { + use std::os::unix::fs::PermissionsExt; + let perms = fs::Permissions::from_mode(0o700); + if let Err(e) = fs::set_permissions(path, perms) { + eprintln!( + "warning: failed to set permissions on {}: {e}", + path.display() + ); + } +} + +/// Set file permissions to 0o600 (owner read/write only). +#[cfg(unix)] +fn set_file_permissions(path: &Path) { + use std::os::unix::fs::PermissionsExt; + let perms = fs::Permissions::from_mode(0o600); + if let Err(e) = fs::set_permissions(path, perms) { + eprintln!( + "warning: failed to set permissions on {}: {e}", + path.display() + ); + } +} + +#[cfg(not(unix))] +fn set_dir_permissions(_path: &Path) {} + +#[cfg(not(unix))] +fn set_file_permissions(_path: &Path) {} + +impl Store for FsStore { + fn get(&self, key: &str) -> Result, StoreError> { + let path = self.key_to_path(key); + match fs::read_to_string(&path) { + Ok(contents) => Ok(Some(contents)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } + } + + fn set(&self, key: &str, value: &str) -> Result<(), StoreError> { + let path = self.key_to_path(key); + + // Create parent directories. + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + if FsStore::is_sensitive(key) { + set_dir_permissions(parent); + // Also secure the vault root. + set_dir_permissions(&self.vault_path); + } + } + + fs::write(&path, value)?; + + if FsStore::is_sensitive(key) { + set_file_permissions(&path); + } + + Ok(()) + } + + fn remove(&self, key: &str) -> Result<(), StoreError> { + let path = self.key_to_path(key); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } + } + + /// Native directory listing — ignores the `_index` keys entirely. + fn list(&self, prefix: &str) -> Result, StoreError> { + let dir = self.vault_path.join(prefix); + + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(e) => return Err(e.into()), + }; + + let mut keys = Vec::new(); + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + keys.push(format!("{prefix}/{stem}")); + } + } + } + + Ok(keys) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn test_store() -> (tempfile::TempDir, FsStore) { + let dir = tempfile::tempdir().unwrap(); + let store = FsStore::new(Some(dir.path())); + (dir, store) + } + + // == Step 4: FsStore basic CRUD == + + #[test] + fn fs_get_missing_returns_none() { + let (_dir, store) = test_store(); + assert_eq!(store.get("wallets/nonexistent").unwrap(), None); + } + + #[test] + fn fs_set_then_get_roundtrip() { + let (_dir, store) = test_store(); + store.set("wallets/abc", r#"{"id":"abc"}"#).unwrap(); + assert_eq!( + store.get("wallets/abc").unwrap(), + Some(r#"{"id":"abc"}"#.to_string()) + ); + } + + #[test] + fn fs_set_creates_parent_dirs() { + let (dir, store) = test_store(); + store.set("wallets/abc", "test").unwrap(); + assert!(dir.path().join("wallets").exists()); + } + + #[test] + fn fs_remove_deletes_file() { + let (dir, store) = test_store(); + store.set("wallets/abc", "test").unwrap(); + assert!(dir.path().join("wallets/abc.json").exists()); + + store.remove("wallets/abc").unwrap(); + assert!(!dir.path().join("wallets/abc.json").exists()); + } + + #[test] + fn fs_remove_missing_is_ok() { + let (_dir, store) = test_store(); + assert!(store.remove("wallets/nonexistent").is_ok()); + } + + #[test] + fn fs_list_uses_readdir_not_index() { + let (_dir, store) = test_store(); + // Write directly via set (no index helpers) — list should still find them. + store.set("wallets/a", "1").unwrap(); + store.set("wallets/b", "2").unwrap(); + + let mut keys = store.list("wallets").unwrap(); + keys.sort(); + assert_eq!(keys, vec!["wallets/a", "wallets/b"]); + } + + #[test] + fn fs_list_scoped_to_prefix() { + let (_dir, store) = test_store(); + store.set("wallets/w1", "wallet").unwrap(); + store.set("keys/k1", "key").unwrap(); + store.set("policies/p1", "policy").unwrap(); + + assert_eq!(store.list("wallets").unwrap(), vec!["wallets/w1"]); + assert_eq!(store.list("keys").unwrap(), vec!["keys/k1"]); + assert_eq!(store.list("policies").unwrap(), vec!["policies/p1"]); + } + + #[test] + fn fs_list_empty_dir_returns_empty() { + let (_dir, store) = test_store(); + let keys = store.list("wallets").unwrap(); + assert!(keys.is_empty()); + } + + #[test] + fn fs_list_ignores_non_json_files() { + let (dir, store) = test_store(); + store.set("wallets/a", "1").unwrap(); + // Write a non-json file manually. + std::fs::write(dir.path().join("wallets/readme.txt"), "ignore me").unwrap(); + + let keys = store.list("wallets").unwrap(); + assert_eq!(keys, vec!["wallets/a"]); + } + + #[cfg(unix)] + #[test] + fn fs_set_applies_0600_for_wallets_and_keys() { + use std::os::unix::fs::PermissionsExt; + + let (dir, store) = test_store(); + store.set("wallets/w1", "secret").unwrap(); + store.set("keys/k1", "also-secret").unwrap(); + + // Check file permissions. + let w_mode = std::fs::metadata(dir.path().join("wallets/w1.json")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(w_mode, 0o600, "wallet file should be 0600, got {:04o}", w_mode); + + let k_mode = std::fs::metadata(dir.path().join("keys/k1.json")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(k_mode, 0o600, "key file should be 0600, got {:04o}", k_mode); + + // Check directory permissions. + let wd_mode = std::fs::metadata(dir.path().join("wallets")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(wd_mode, 0o700, "wallets dir should be 0700, got {:04o}", wd_mode); + } + + // == Step 5: Characterization tests — FsStore vs existing modules == + + #[test] + fn char_fs_store_reads_wallet_saved_by_vault_module() { + use ows_core::{EncryptedWallet, KeyType, WalletAccount}; + + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().to_path_buf(); + + let wallet = EncryptedWallet::new( + "char-w1".to_string(), + "char-wallet".to_string(), + vec![WalletAccount { + account_id: "eip155:1:0xabc".to_string(), + address: "0xabc".to_string(), + chain_id: "eip155:1".to_string(), + derivation_path: "m/44'/60'/0'/0/0".to_string(), + }], + serde_json::json!({"cipher": "aes-256-gcm"}), + KeyType::Mnemonic, + ); + + // Save via the old vault module. + crate::vault::save_encrypted_wallet(&wallet, Some(&vault)).unwrap(); + + // Read via FsStore. + let store = FsStore::new(Some(&vault)); + let json = store.get("wallets/char-w1").unwrap().expect("wallet not found via FsStore"); + let loaded: EncryptedWallet = serde_json::from_str(&json).unwrap(); + + assert_eq!(loaded.id, wallet.id); + assert_eq!(loaded.name, wallet.name); + assert_eq!(loaded.accounts.len(), 1); + assert_eq!(loaded.accounts[0].address, "0xabc"); + } + + #[test] + fn char_vault_module_reads_wallet_saved_by_fs_store() { + use ows_core::{EncryptedWallet, KeyType, WalletAccount}; + + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().to_path_buf(); + + let wallet = EncryptedWallet::new( + "char-w2".to_string(), + "fs-store-wallet".to_string(), + vec![WalletAccount { + account_id: "eip155:1:0xdef".to_string(), + address: "0xdef".to_string(), + chain_id: "eip155:1".to_string(), + derivation_path: "m/44'/60'/0'/0/0".to_string(), + }], + serde_json::json!({"cipher": "aes-256-gcm"}), + KeyType::Mnemonic, + ); + + // Save via FsStore. + let store = FsStore::new(Some(&vault)); + let json = serde_json::to_string_pretty(&wallet).unwrap(); + store.set("wallets/char-w2", &json).unwrap(); + + // Read via the old vault module. + let loaded = crate::vault::load_wallet_by_name_or_id("char-w2", Some(&vault)).unwrap(); + assert_eq!(loaded.id, "char-w2"); + assert_eq!(loaded.name, "fs-store-wallet"); + } + + #[test] + fn char_fs_store_reads_api_key_saved_by_key_store_module() { + use ows_core::ApiKeyFile; + use std::collections::HashMap; + + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().to_path_buf(); + + let key = ApiKeyFile { + id: "char-k1".to_string(), + name: "char-key".to_string(), + token_hash: "abc123hash".to_string(), + created_at: "2026-03-22T10:00:00Z".to_string(), + wallet_ids: vec!["w1".to_string()], + policy_ids: vec![], + expires_at: None, + wallet_secrets: HashMap::new(), + }; + + // Save via old module. + crate::key_store::save_api_key(&key, Some(&vault)).unwrap(); + + // Read via FsStore. + let store = FsStore::new(Some(&vault)); + let json = store.get("keys/char-k1").unwrap().expect("key not found via FsStore"); + let loaded: ApiKeyFile = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.id, "char-k1"); + assert_eq!(loaded.name, "char-key"); + } + + #[test] + fn char_fs_store_reads_policy_saved_by_policy_store_module() { + use ows_core::{Policy, PolicyAction, PolicyRule}; + + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().to_path_buf(); + + let policy = Policy { + id: "char-p1".to_string(), + name: "Char Policy".to_string(), + version: 1, + created_at: "2026-03-22T10:00:00Z".to_string(), + rules: vec![PolicyRule::AllowedChains { + chain_ids: vec!["eip155:1".to_string()], + }], + executable: None, + config: None, + action: PolicyAction::Deny, + }; + + // Save via old module. + crate::policy_store::save_policy(&policy, Some(&vault)).unwrap(); + + // Read via FsStore. + let store = FsStore::new(Some(&vault)); + let json = store.get("policies/char-p1").unwrap().expect("policy not found via FsStore"); + let loaded: Policy = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.id, "char-p1"); + assert_eq!(loaded.name, "Char Policy"); + } + + #[test] + fn char_fs_store_list_matches_vault_list() { + use ows_core::{EncryptedWallet, KeyType}; + + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().to_path_buf(); + + // Save 3 wallets via old module. + for i in 0..3 { + let wallet = EncryptedWallet::new( + format!("list-w{i}"), + format!("wallet-{i}"), + vec![], + serde_json::json!({}), + KeyType::Mnemonic, + ); + crate::vault::save_encrypted_wallet(&wallet, Some(&vault)).unwrap(); + } + + // List via FsStore. + let store = FsStore::new(Some(&vault)); + let mut fs_keys = store.list("wallets").unwrap(); + fs_keys.sort(); + + // List via old module. + let old_wallets = crate::vault::list_encrypted_wallets(Some(&vault)).unwrap(); + let mut old_ids: Vec = old_wallets.iter().map(|w| format!("wallets/{}", w.id)).collect(); + old_ids.sort(); + + assert_eq!(fs_keys, old_ids); + } + + #[cfg(unix)] + #[test] + fn fs_set_no_strict_perms_for_policies() { + use std::os::unix::fs::PermissionsExt; + + let (dir, store) = test_store(); + store.set("policies/p1", "not-secret").unwrap(); + + let mode = std::fs::metadata(dir.path().join("policies/p1.json")) + .unwrap() + .permissions() + .mode() + & 0o777; + // Should NOT be 0600 — policies are not sensitive. + assert_ne!(mode, 0o600, "policy file should not have restricted permissions"); + } +} diff --git a/ows/crates/ows-lib/src/key_ops.rs b/ows/crates/ows-lib/src/key_ops.rs index 605e3cf4..305a84da 100644 --- a/ows/crates/ows-lib/src/key_ops.rs +++ b/ows/crates/ows-lib/src/key_ops.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::path::Path; -use ows_core::{ApiKeyFile, EncryptedWallet, OwsError}; +use ows_core::{ApiKeyFile, EncryptedWallet, OwsError, Store}; use ows_signer::{decrypt, encrypt_with_hkdf, signer_for_chain, CryptoEnvelope, SecretBytes}; use crate::error::OwsLibError; @@ -25,6 +25,19 @@ pub fn create_api_key( passphrase: &str, expires_at: Option<&str>, vault_path: Option<&Path>, +) -> Result<(String, ApiKeyFile), OwsLibError> { + let store = crate::FsStore::new(vault_path); + create_api_key_with_store(name, wallet_ids, policy_ids, passphrase, expires_at, &store) +} + +/// Create an API key using a pluggable Store. +pub fn create_api_key_with_store( + name: &str, + wallet_ids: &[String], + policy_ids: &[String], + passphrase: &str, + expires_at: Option<&str>, + store: &dyn Store, ) -> Result<(String, ApiKeyFile), OwsLibError> { // Validate that all wallets exist and passphrase works let mut wallet_secrets = HashMap::new(); @@ -32,7 +45,7 @@ pub fn create_api_key( let token = key_store::generate_token(); for wallet_id in wallet_ids { - let wallet = vault::load_wallet_by_name_or_id(wallet_id, vault_path)?; + let wallet = vault::load_wallet_by_name_or_id_with_store(wallet_id, store)?; let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?; // Decrypt with owner's passphrase to verify it works @@ -50,7 +63,7 @@ pub fn create_api_key( // Validate that all policies exist for policy_id in policy_ids { - policy_store::load_policy(policy_id, vault_path)?; + policy_store::load_policy_with_store(policy_id, store)?; } let id = uuid::Uuid::new_v4().to_string(); @@ -65,7 +78,7 @@ pub fn create_api_key( wallet_secrets, }; - key_store::save_api_key(&key_file, vault_path)?; + key_store::save_api_key_with_store(&key_file, store)?; Ok((token, key_file)) } @@ -85,15 +98,25 @@ pub fn sign_with_api_key( index: Option, vault_path: Option<&Path>, ) -> Result { - // 1. Look up key file + let store = crate::FsStore::new(vault_path); + sign_with_api_key_with_store(token, wallet_name_or_id, chain, tx_bytes, index, &store) +} + +/// Sign a transaction with an API key using a pluggable Store. +pub fn sign_with_api_key_with_store( + token: &str, + wallet_name_or_id: &str, + chain: &ows_core::Chain, + tx_bytes: &[u8], + index: Option, + store: &dyn Store, +) -> Result { let token_hash = key_store::hash_token(token); - let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?; + let key_file = key_store::load_api_key_by_token_hash_with_store(&token_hash, store)?; - // 2. Check expiry check_expiry(&key_file)?; - // 3. Resolve wallet and check scope - let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?; + let wallet = vault::load_wallet_by_name_or_id_with_store(wallet_name_or_id, store)?; if !key_file.wallet_ids.contains(&wallet.id) { return Err(OwsLibError::InvalidInput(format!( "API key '{}' does not have access to wallet '{}'", @@ -101,8 +124,7 @@ pub fn sign_with_api_key( ))); } - // 4. Load policies and build context - let policies = load_policies_for_key(&key_file, vault_path)?; + let policies = load_policies_for_key_with_store(&key_file, store)?; let now = chrono::Utc::now(); let date = now.format("%Y-%m-%d").to_string(); @@ -122,7 +144,6 @@ pub fn sign_with_api_key( timestamp: now.to_rfc3339(), }; - // 5. Evaluate policies let result = policy_engine::evaluate_policies(&policies, &context); if !result.allow { return Err(OwsLibError::Core(OwsError::PolicyDenied { @@ -131,10 +152,8 @@ pub fn sign_with_api_key( })); } - // 6. Decrypt wallet secret from key file using HKDF(token) let key = decrypt_key_from_api_key(&key_file, &wallet, token, chain.chain_type, index)?; - // 7. Sign (extract signable portion first — e.g. strips Solana sig-slot headers) let signer = signer_for_chain(chain.chain_type); let signable = signer.extract_signable_bytes(tx_bytes)?; let output = signer.sign_transaction(key.expose(), signable)?; @@ -153,13 +172,26 @@ pub fn sign_message_with_api_key( msg_bytes: &[u8], index: Option, vault_path: Option<&Path>, +) -> Result { + let store = crate::FsStore::new(vault_path); + sign_message_with_api_key_with_store(token, wallet_name_or_id, chain, msg_bytes, index, &store) +} + +/// Sign a message with an API key using a pluggable Store. +pub fn sign_message_with_api_key_with_store( + token: &str, + wallet_name_or_id: &str, + chain: &ows_core::Chain, + msg_bytes: &[u8], + index: Option, + store: &dyn Store, ) -> Result { let token_hash = key_store::hash_token(token); - let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?; + let key_file = key_store::load_api_key_by_token_hash_with_store(&token_hash, store)?; check_expiry(&key_file)?; - let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?; + let wallet = vault::load_wallet_by_name_or_id_with_store(wallet_name_or_id, store)?; if !key_file.wallet_ids.contains(&wallet.id) { return Err(OwsLibError::InvalidInput(format!( "API key '{}' does not have access to wallet '{}'", @@ -167,7 +199,7 @@ pub fn sign_message_with_api_key( ))); } - let policies = load_policies_for_key(&key_file, vault_path)?; + let policies = load_policies_for_key_with_store(&key_file, store)?; let now = chrono::Utc::now(); let date = now.format("%Y-%m-%d").to_string(); @@ -212,12 +244,27 @@ pub fn enforce_policy_and_decrypt_key( tx_bytes: &[u8], index: Option, vault_path: Option<&Path>, +) -> Result<(SecretBytes, ApiKeyFile), OwsLibError> { + let store = crate::FsStore::new(vault_path); + enforce_policy_and_decrypt_key_with_store( + token, wallet_name_or_id, chain, tx_bytes, index, &store, + ) +} + +/// Enforce policies and decrypt key using a pluggable Store. +pub fn enforce_policy_and_decrypt_key_with_store( + token: &str, + wallet_name_or_id: &str, + chain: &ows_core::Chain, + tx_bytes: &[u8], + index: Option, + store: &dyn Store, ) -> Result<(SecretBytes, ApiKeyFile), OwsLibError> { let token_hash = key_store::hash_token(token); - let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?; + let key_file = key_store::load_api_key_by_token_hash_with_store(&token_hash, store)?; check_expiry(&key_file)?; - let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?; + let wallet = vault::load_wallet_by_name_or_id_with_store(wallet_name_or_id, store)?; if !key_file.wallet_ids.contains(&wallet.id) { return Err(OwsLibError::InvalidInput(format!( "API key '{}' does not have access to wallet '{}'", @@ -225,7 +272,7 @@ pub fn enforce_policy_and_decrypt_key( ))); } - let policies = load_policies_for_key(&key_file, vault_path)?; + let policies = load_policies_for_key_with_store(&key_file, store)?; let now = chrono::Utc::now(); let date = now.format("%Y-%m-%d").to_string(); @@ -286,13 +333,13 @@ fn check_expiry(key_file: &ApiKeyFile) -> Result<(), OwsLibError> { Ok(()) } -fn load_policies_for_key( +fn load_policies_for_key_with_store( key_file: &ApiKeyFile, - vault_path: Option<&Path>, + store: &dyn Store, ) -> Result, OwsLibError> { let mut policies = Vec::with_capacity(key_file.policy_ids.len()); for pid in &key_file.policy_ids { - policies.push(policy_store::load_policy(pid, vault_path)?); + policies.push(policy_store::load_policy_with_store(pid, store)?); } Ok(policies) } diff --git a/ows/crates/ows-lib/src/key_store.rs b/ows/crates/ows-lib/src/key_store.rs index b6992eb6..f399367d 100644 --- a/ows/crates/ows-lib/src/key_store.rs +++ b/ows/crates/ows-lib/src/key_store.rs @@ -149,6 +149,79 @@ pub fn delete_api_key(id: &str, vault_path: Option<&Path>) -> Result<(), OwsLibE Ok(()) } +// --------------------------------------------------------------------------- +// Store-aware API key CRUD +// --------------------------------------------------------------------------- + +use ows_core::{store_set_indexed, store_remove_indexed, Store}; + +/// Save an API key via a Store. +pub fn save_api_key_with_store( + key: &ApiKeyFile, + store: &dyn Store, +) -> Result<(), OwsLibError> { + let store_key = format!("keys/{}", key.id); + let json = serde_json::to_string_pretty(key)?; + store_set_indexed(store, &store_key, &json, "keys")?; + Ok(()) +} + +/// Load an API key by ID via a Store. +pub fn load_api_key_with_store( + id: &str, + store: &dyn Store, +) -> Result { + let key = format!("keys/{id}"); + match store.get(&key)? { + Some(json) => Ok(serde_json::from_str(&json)?), + None => Err(OwsLibError::Core(ows_core::OwsError::ApiKeyNotFound)), + } +} + +/// Look up an API key by token hash via a Store. +pub fn load_api_key_by_token_hash_with_store( + token_hash: &str, + store: &dyn Store, +) -> Result { + let keys = list_api_keys_with_store(store)?; + keys.into_iter() + .find(|k| k.token_hash == token_hash) + .ok_or(OwsLibError::Core(ows_core::OwsError::ApiKeyNotFound)) +} + +/// List all API keys via a Store, sorted by creation time (newest first). +pub fn list_api_keys_with_store( + store: &dyn Store, +) -> Result, OwsLibError> { + let store_keys = store.list("keys")?; + let mut keys = Vec::new(); + + for store_key in store_keys { + if let Some(json) = store.get(&store_key)? { + match serde_json::from_str::(&json) { + Ok(k) => keys.push(k), + Err(e) => eprintln!("warning: skipping {store_key}: {e}"), + } + } + } + + keys.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(keys) +} + +/// Delete an API key by ID via a Store. +pub fn delete_api_key_with_store( + id: &str, + store: &dyn Store, +) -> Result<(), OwsLibError> { + let key = format!("keys/{id}"); + if store.get(&key)?.is_none() { + return Err(OwsLibError::Core(ows_core::OwsError::ApiKeyNotFound)); + } + store_remove_indexed(store, &key, "keys")?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -312,4 +385,73 @@ mod tests { dir_mode ); } + + // == Store-aware API key CRUD tests == + + #[test] + fn store_save_and_load_api_key() { + let store = ows_core::InMemoryStore::new(); + let key = test_key("sk1", "store-key", "ows_key_test"); + + save_api_key_with_store(&key, &store).unwrap(); + let loaded = load_api_key_with_store("sk1", &store).unwrap(); + assert_eq!(loaded.id, "sk1"); + assert_eq!(loaded.name, "store-key"); + } + + #[test] + fn store_load_api_key_not_found() { + let store = ows_core::InMemoryStore::new(); + let result = load_api_key_with_store("nonexistent", &store); + assert!(result.is_err()); + } + + #[test] + fn store_load_api_key_by_token_hash() { + let store = ows_core::InMemoryStore::new(); + let token = "ows_key_findme"; + let key = test_key("sk2", "finder", token); + + save_api_key_with_store(&key, &store).unwrap(); + let found = load_api_key_by_token_hash_with_store(&hash_token(token), &store).unwrap(); + assert_eq!(found.id, "sk2"); + } + + #[test] + fn store_list_api_keys_sorted() { + let store = ows_core::InMemoryStore::new(); + + let mut k1 = test_key("k1", "first", "t1"); + k1.created_at = "2026-03-20T10:00:00Z".to_string(); + + let mut k2 = test_key("k2", "second", "t2"); + k2.created_at = "2026-03-22T10:00:00Z".to_string(); + + save_api_key_with_store(&k1, &store).unwrap(); + save_api_key_with_store(&k2, &store).unwrap(); + + let keys = list_api_keys_with_store(&store).unwrap(); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].id, "k2"); // newest first + assert_eq!(keys[1].id, "k1"); + } + + #[test] + fn store_delete_api_key() { + let store = ows_core::InMemoryStore::new(); + let key = test_key("del-k", "delete-me", "token"); + + save_api_key_with_store(&key, &store).unwrap(); + assert_eq!(list_api_keys_with_store(&store).unwrap().len(), 1); + + delete_api_key_with_store("del-k", &store).unwrap(); + assert_eq!(list_api_keys_with_store(&store).unwrap().len(), 0); + } + + #[test] + fn store_delete_api_key_not_found() { + let store = ows_core::InMemoryStore::new(); + let result = delete_api_key_with_store("nonexistent", &store); + assert!(result.is_err()); + } } diff --git a/ows/crates/ows-lib/src/lib.rs b/ows/crates/ows-lib/src/lib.rs index cfa6e859..233a453d 100644 --- a/ows/crates/ows-lib/src/lib.rs +++ b/ows/crates/ows-lib/src/lib.rs @@ -1,4 +1,5 @@ pub mod error; +pub mod fs_store; pub mod key_ops; pub mod key_store; pub mod migrate; @@ -11,5 +12,7 @@ pub mod vault; // Re-export the primary API. pub use error::OwsLibError; +pub use fs_store::FsStore; +pub use ows_core::{InMemoryStore, Store, StoreError, store_remove_indexed, store_set_indexed}; pub use ops::*; pub use types::*; diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index d0b4d190..fa741d94 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::process::Command; use ows_core::{ - default_chain_for_type, ChainType, Config, EncryptedWallet, KeyType, WalletAccount, + default_chain_for_type, ChainType, Config, EncryptedWallet, KeyType, Store, WalletAccount, ALL_CHAIN_TYPES, }; use ows_signer::{ @@ -195,6 +195,17 @@ pub fn create_wallet( words: Option, passphrase: Option<&str>, vault_path: Option<&Path>, +) -> Result { + let store = crate::FsStore::new(vault_path); + create_wallet_with_store(name, words, passphrase, &store) +} + +/// Create a new universal wallet using a pluggable Store. +pub fn create_wallet_with_store( + name: &str, + words: Option, + passphrase: Option<&str>, + store: &dyn Store, ) -> Result { let passphrase = passphrase.unwrap_or(""); let words = words.unwrap_or(12); @@ -204,7 +215,7 @@ pub fn create_wallet( _ => return Err(OwsLibError::InvalidInput("words must be 12 or 24".into())), }; - if vault::wallet_name_exists(name, vault_path)? { + if vault::wallet_name_exists_with_store(name, store)? { return Err(OwsLibError::WalletNameExists(name.to_string())); } @@ -225,7 +236,7 @@ pub fn create_wallet( KeyType::Mnemonic, ); - vault::save_encrypted_wallet(&wallet, vault_path)?; + vault::save_wallet_with_store(&wallet, store)?; Ok(wallet_to_info(&wallet)) } @@ -236,11 +247,23 @@ pub fn import_wallet_mnemonic( passphrase: Option<&str>, index: Option, vault_path: Option<&Path>, +) -> Result { + let store = crate::FsStore::new(vault_path); + import_wallet_mnemonic_with_store(name, mnemonic_phrase, passphrase, index, &store) +} + +/// Import a wallet from a mnemonic phrase using a pluggable Store. +pub fn import_wallet_mnemonic_with_store( + name: &str, + mnemonic_phrase: &str, + passphrase: Option<&str>, + index: Option, + store: &dyn Store, ) -> Result { let passphrase = passphrase.unwrap_or(""); let index = index.unwrap_or(0); - if vault::wallet_name_exists(name, vault_path)? { + if vault::wallet_name_exists_with_store(name, store)? { return Err(OwsLibError::WalletNameExists(name.to_string())); } @@ -261,7 +284,7 @@ pub fn import_wallet_mnemonic( KeyType::Mnemonic, ); - vault::save_encrypted_wallet(&wallet, vault_path)?; + vault::save_wallet_with_store(&wallet, store)?; Ok(wallet_to_info(&wallet)) } @@ -288,24 +311,37 @@ pub fn import_wallet_private_key( vault_path: Option<&Path>, secp256k1_key_hex: Option<&str>, ed25519_key_hex: Option<&str>, +) -> Result { + let store = crate::FsStore::new(vault_path); + import_wallet_private_key_with_store( + name, private_key_hex, chain, passphrase, &store, + secp256k1_key_hex, ed25519_key_hex, + ) +} + +/// Import a wallet from a private key using a pluggable Store. +pub fn import_wallet_private_key_with_store( + name: &str, + private_key_hex: &str, + chain: Option<&str>, + passphrase: Option<&str>, + store: &dyn Store, + secp256k1_key_hex: Option<&str>, + ed25519_key_hex: Option<&str>, ) -> Result { let passphrase = passphrase.unwrap_or(""); - if vault::wallet_name_exists(name, vault_path)? { + if vault::wallet_name_exists_with_store(name, store)? { return Err(OwsLibError::WalletNameExists(name.to_string())); } let keys = match (secp256k1_key_hex, ed25519_key_hex) { - // Both curve keys explicitly provided — use them directly (Some(secp_hex), Some(ed_hex)) => KeyPair { secp256k1: decode_hex_key(secp_hex)?, ed25519: decode_hex_key(ed_hex)?, }, - // Existing single-key behavior _ => { let key_bytes = decode_hex_key(private_key_hex)?; - - // Determine curve from the source chain (default: secp256k1) let source_curve = match chain { Some(c) => { let parsed = parse_chain(c)?; @@ -314,7 +350,6 @@ pub fn import_wallet_private_key( None => ows_signer::Curve::Secp256k1, }; - // Build key pair: provided key for its curve, random 32 bytes for the other let mut other_key = vec![0u8; 32]; getrandom::getrandom(&mut other_key).map_err(|e| { OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) @@ -340,11 +375,9 @@ pub fn import_wallet_private_key( }; let accounts = derive_all_accounts_from_keys(&keys)?; - let payload = keys.to_json_bytes(); let crypto_envelope = encrypt(&payload, passphrase)?; let crypto_json = serde_json::to_value(&crypto_envelope)?; - let wallet_id = uuid::Uuid::new_v4().to_string(); let wallet = EncryptedWallet::new( @@ -355,26 +388,50 @@ pub fn import_wallet_private_key( KeyType::PrivateKey, ); - vault::save_encrypted_wallet(&wallet, vault_path)?; + vault::save_wallet_with_store(&wallet, store)?; Ok(wallet_to_info(&wallet)) } /// List all wallets in the vault. pub fn list_wallets(vault_path: Option<&Path>) -> Result, OwsLibError> { - let wallets = vault::list_encrypted_wallets(vault_path)?; + let store = crate::FsStore::new(vault_path); + list_wallets_with_store(&store) +} + +/// List all wallets using a pluggable Store. +pub fn list_wallets_with_store(store: &dyn Store) -> Result, OwsLibError> { + let wallets = vault::list_wallets_with_store(store)?; Ok(wallets.iter().map(wallet_to_info).collect()) } /// Get a single wallet by name or ID. pub fn get_wallet(name_or_id: &str, vault_path: Option<&Path>) -> Result { - let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?; + let store = crate::FsStore::new(vault_path); + get_wallet_with_store(name_or_id, &store) +} + +/// Get a single wallet by name or ID using a pluggable Store. +pub fn get_wallet_with_store( + name_or_id: &str, + store: &dyn Store, +) -> Result { + let wallet = vault::load_wallet_by_name_or_id_with_store(name_or_id, store)?; Ok(wallet_to_info(&wallet)) } /// Delete a wallet from the vault. pub fn delete_wallet(name_or_id: &str, vault_path: Option<&Path>) -> Result<(), OwsLibError> { - let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?; - vault::delete_wallet_file(&wallet.id, vault_path)?; + let store = crate::FsStore::new(vault_path); + delete_wallet_with_store(name_or_id, &store) +} + +/// Delete a wallet using a pluggable Store. +pub fn delete_wallet_with_store( + name_or_id: &str, + store: &dyn Store, +) -> Result<(), OwsLibError> { + let wallet = vault::load_wallet_by_name_or_id_with_store(name_or_id, store)?; + vault::delete_wallet_with_store(&wallet.id, store)?; Ok(()) } @@ -384,9 +441,19 @@ pub fn export_wallet( name_or_id: &str, passphrase: Option<&str>, vault_path: Option<&Path>, +) -> Result { + let store = crate::FsStore::new(vault_path); + export_wallet_with_store(name_or_id, passphrase, &store) +} + +/// Export a wallet's secret using a pluggable Store. +pub fn export_wallet_with_store( + name_or_id: &str, + passphrase: Option<&str>, + store: &dyn Store, ) -> Result { let passphrase = passphrase.unwrap_or(""); - let wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?; + let wallet = vault::load_wallet_by_name_or_id_with_store(name_or_id, store)?; let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?; let secret = decrypt(&envelope, passphrase)?; @@ -395,7 +462,6 @@ pub fn export_wallet( OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into()) }), KeyType::PrivateKey => { - // Return the JSON key pair as-is String::from_utf8(secret.expose().to_vec()) .map_err(|_| OwsLibError::InvalidInput("wallet contains invalid key data".into())) } @@ -408,18 +474,28 @@ pub fn rename_wallet( new_name: &str, vault_path: Option<&Path>, ) -> Result<(), OwsLibError> { - let mut wallet = vault::load_wallet_by_name_or_id(name_or_id, vault_path)?; + let store = crate::FsStore::new(vault_path); + rename_wallet_with_store(name_or_id, new_name, &store) +} + +/// Rename a wallet using a pluggable Store. +pub fn rename_wallet_with_store( + name_or_id: &str, + new_name: &str, + store: &dyn Store, +) -> Result<(), OwsLibError> { + let mut wallet = vault::load_wallet_by_name_or_id_with_store(name_or_id, store)?; if wallet.name == new_name { return Ok(()); } - if vault::wallet_name_exists(new_name, vault_path)? { + if vault::wallet_name_exists_with_store(new_name, store)? { return Err(OwsLibError::WalletNameExists(new_name.to_string())); } wallet.name = new_name.to_string(); - vault::save_encrypted_wallet(&wallet, vault_path)?; + vault::save_wallet_with_store(&wallet, store)?; Ok(()) } @@ -435,6 +511,19 @@ pub fn sign_transaction( passphrase: Option<&str>, index: Option, vault_path: Option<&Path>, +) -> Result { + let store = crate::FsStore::new(vault_path); + sign_transaction_with_store(wallet, chain, tx_hex, passphrase, index, &store) +} + +/// Sign a transaction using a pluggable Store. +pub fn sign_transaction_with_store( + wallet: &str, + chain: &str, + tx_hex: &str, + passphrase: Option<&str>, + index: Option, + store: &dyn Store, ) -> Result { let credential = passphrase.unwrap_or(""); @@ -442,17 +531,15 @@ pub fn sign_transaction( let tx_bytes = hex::decode(tx_hex_clean) .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?; - // Agent mode: token-based signing with policy enforcement if credential.starts_with(crate::key_store::TOKEN_PREFIX) { let chain = parse_chain(chain)?; - return crate::key_ops::sign_with_api_key( - credential, wallet, &chain, &tx_bytes, index, vault_path, + return crate::key_ops::sign_with_api_key_with_store( + credential, wallet, &chain, &tx_bytes, index, store, ); } - // Owner mode: existing passphrase-based signing (unchanged) let chain = parse_chain(chain)?; - let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?; + let key = decrypt_signing_key_with_store(wallet, chain.chain_type, credential, index, store)?; let signer = signer_for_chain(chain.chain_type); let signable = signer.extract_signable_bytes(&tx_bytes)?; let output = signer.sign_transaction(key.expose(), signable)?; @@ -475,6 +562,20 @@ pub fn sign_message( encoding: Option<&str>, index: Option, vault_path: Option<&Path>, +) -> Result { + let store = crate::FsStore::new(vault_path); + sign_message_with_store(wallet, chain, message, passphrase, encoding, index, &store) +} + +/// Sign a message using a pluggable Store. +pub fn sign_message_with_store( + wallet: &str, + chain: &str, + message: &str, + passphrase: Option<&str>, + encoding: Option<&str>, + index: Option, + store: &dyn Store, ) -> Result { let credential = passphrase.unwrap_or(""); @@ -490,17 +591,15 @@ pub fn sign_message( } }; - // Agent mode if credential.starts_with(crate::key_store::TOKEN_PREFIX) { let chain = parse_chain(chain)?; - return crate::key_ops::sign_message_with_api_key( - credential, wallet, &chain, &msg_bytes, index, vault_path, + return crate::key_ops::sign_message_with_api_key_with_store( + credential, wallet, &chain, &msg_bytes, index, store, ); } - // Owner mode let chain = parse_chain(chain)?; - let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?; + let key = decrypt_signing_key_with_store(wallet, chain.chain_type, credential, index, store)?; let signer = signer_for_chain(chain.chain_type); let output = signer.sign_message(key.expose(), &msg_bytes)?; @@ -522,6 +621,19 @@ pub fn sign_typed_data( passphrase: Option<&str>, index: Option, vault_path: Option<&Path>, +) -> Result { + let store = crate::FsStore::new(vault_path); + sign_typed_data_with_store(wallet, chain, typed_data_json, passphrase, index, &store) +} + +/// Sign EIP-712 typed data using a pluggable Store. +pub fn sign_typed_data_with_store( + wallet: &str, + chain: &str, + typed_data_json: &str, + passphrase: Option<&str>, + index: Option, + store: &dyn Store, ) -> Result { let credential = passphrase.unwrap_or(""); let chain = parse_chain(chain)?; @@ -539,7 +651,7 @@ pub fn sign_typed_data( )); } - let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?; + let key = decrypt_signing_key_with_store(wallet, chain.chain_type, credential, index, store)?; let evm_signer = ows_signer::chains::EvmSigner; let output = evm_signer.sign_typed_data(key.expose(), typed_data_json)?; @@ -562,6 +674,20 @@ pub fn sign_and_send( index: Option, rpc_url: Option<&str>, vault_path: Option<&Path>, +) -> Result { + let store = crate::FsStore::new(vault_path); + sign_and_send_with_store(wallet, chain, tx_hex, passphrase, index, rpc_url, &store) +} + +/// Sign and broadcast a transaction using a pluggable Store. +pub fn sign_and_send_with_store( + wallet: &str, + chain: &str, + tx_hex: &str, + passphrase: Option<&str>, + index: Option, + rpc_url: Option<&str>, + store: &dyn Store, ) -> Result { let credential = passphrase.unwrap_or(""); @@ -569,23 +695,21 @@ pub fn sign_and_send( let tx_bytes = hex::decode(tx_hex_clean) .map_err(|e| OwsLibError::InvalidInput(format!("invalid hex transaction: {e}")))?; - // Agent mode: enforce policies, decrypt key, then sign + broadcast if credential.starts_with(crate::key_store::TOKEN_PREFIX) { let chain_info = parse_chain(chain)?; - let (key, _) = crate::key_ops::enforce_policy_and_decrypt_key( + let (key, _) = crate::key_ops::enforce_policy_and_decrypt_key_with_store( credential, wallet, &chain_info, &tx_bytes, index, - vault_path, + store, )?; return sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url); } - // Owner mode let chain_info = parse_chain(chain)?; - let key = decrypt_signing_key(wallet, chain_info.chain_type, credential, index, vault_path)?; + let key = decrypt_signing_key_with_store(wallet, chain_info.chain_type, credential, index, store)?; sign_encode_and_broadcast(key.expose(), chain, &tx_bytes, rpc_url) } @@ -636,7 +760,19 @@ pub fn decrypt_signing_key( index: Option, vault_path: Option<&Path>, ) -> Result { - let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?; + let store = crate::FsStore::new(vault_path); + decrypt_signing_key_with_store(wallet_name_or_id, chain_type, passphrase, index, &store) +} + +/// Decrypt a wallet and return the private key using a pluggable Store. +pub fn decrypt_signing_key_with_store( + wallet_name_or_id: &str, + chain_type: ChainType, + passphrase: &str, + index: Option, + store: &dyn Store, +) -> Result { + let wallet = vault::load_wallet_by_name_or_id_with_store(wallet_name_or_id, store)?; let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?; let secret = decrypt(&envelope, passphrase)?; secret_to_signing_key(&secret, &wallet.key_type, chain_type, index) @@ -2910,4 +3046,88 @@ mod tests { } } } + + // ================================================================ + // Store-aware ops tests (InMemoryStore) + // ================================================================ + + #[test] + fn create_wallet_with_store_works() { + let store = ows_core::InMemoryStore::new(); + let info = create_wallet_with_store("test-wallet", None, Some("pass"), &store).unwrap(); + assert!(!info.id.is_empty()); + assert_eq!(info.name, "test-wallet"); + assert!(!info.accounts.is_empty()); + } + + #[test] + fn create_wallet_with_store_duplicate_name_fails() { + let store = ows_core::InMemoryStore::new(); + create_wallet_with_store("dup", None, Some("pass"), &store).unwrap(); + let err = create_wallet_with_store("dup", None, Some("pass"), &store); + assert!(matches!(err, Err(OwsLibError::WalletNameExists(_)))); + } + + #[test] + fn list_wallets_with_store_works() { + let store = ows_core::InMemoryStore::new(); + create_wallet_with_store("w1", None, Some("p"), &store).unwrap(); + create_wallet_with_store("w2", None, Some("p"), &store).unwrap(); + let wallets = list_wallets_with_store(&store).unwrap(); + assert_eq!(wallets.len(), 2); + } + + #[test] + fn get_wallet_with_store_works() { + let store = ows_core::InMemoryStore::new(); + let created = create_wallet_with_store("gw", None, Some("p"), &store).unwrap(); + let fetched = get_wallet_with_store(&created.id, &store).unwrap(); + assert_eq!(fetched.id, created.id); + assert_eq!(fetched.name, "gw"); + } + + #[test] + fn delete_wallet_with_store_works() { + let store = ows_core::InMemoryStore::new(); + let info = create_wallet_with_store("del", None, Some("p"), &store).unwrap(); + delete_wallet_with_store(&info.id, &store).unwrap(); + assert!(list_wallets_with_store(&store).unwrap().is_empty()); + } + + #[test] + fn export_wallet_with_store_works() { + let store = ows_core::InMemoryStore::new(); + let info = create_wallet_with_store("exp", None, Some("pass"), &store).unwrap(); + let secret = export_wallet_with_store(&info.id, Some("pass"), &store).unwrap(); + // Mnemonic wallet — export should be 12 words + assert_eq!(secret.split_whitespace().count(), 12); + } + + #[test] + fn rename_wallet_with_store_works() { + let store = ows_core::InMemoryStore::new(); + let info = create_wallet_with_store("old-name", None, Some("p"), &store).unwrap(); + rename_wallet_with_store(&info.id, "new-name", &store).unwrap(); + let fetched = get_wallet_with_store(&info.id, &store).unwrap(); + assert_eq!(fetched.name, "new-name"); + } + + #[test] + fn import_wallet_mnemonic_with_store_works() { + let store = ows_core::InMemoryStore::new(); + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let info = import_wallet_mnemonic_with_store("imported", mnemonic, Some("pass"), None, &store).unwrap(); + assert_eq!(info.name, "imported"); + assert!(!info.accounts.is_empty()); + } + + #[test] + fn sign_message_with_store_works() { + let store = ows_core::InMemoryStore::new(); + let info = create_wallet_with_store("signer", None, Some("pass"), &store).unwrap(); + let result = sign_message_with_store( + &info.id, "evm", "hello", Some("pass"), None, None, &store, + ).unwrap(); + assert!(!result.signature.is_empty()); + } } diff --git a/ows/crates/ows-lib/src/policy_store.rs b/ows/crates/ows-lib/src/policy_store.rs index 4078b661..4a37962c 100644 --- a/ows/crates/ows-lib/src/policy_store.rs +++ b/ows/crates/ows-lib/src/policy_store.rs @@ -76,6 +76,68 @@ pub fn delete_policy(id: &str, vault_path: Option<&Path>) -> Result<(), OwsLibEr Ok(()) } +// --------------------------------------------------------------------------- +// Store-aware policy CRUD +// --------------------------------------------------------------------------- + +use ows_core::{store_set_indexed, store_remove_indexed, Store}; + +/// Save a policy via a Store. +pub fn save_policy_with_store( + policy: &Policy, + store: &dyn Store, +) -> Result<(), OwsLibError> { + let key = format!("policies/{}", policy.id); + let json = serde_json::to_string_pretty(policy)?; + store_set_indexed(store, &key, &json, "policies")?; + Ok(()) +} + +/// Load a policy by ID via a Store. +pub fn load_policy_with_store( + id: &str, + store: &dyn Store, +) -> Result { + let key = format!("policies/{id}"); + match store.get(&key)? { + Some(json) => Ok(serde_json::from_str(&json)?), + None => Err(OwsLibError::InvalidInput(format!("policy not found: {id}"))), + } +} + +/// List all policies via a Store, sorted alphabetically by name. +pub fn list_policies_with_store( + store: &dyn Store, +) -> Result, OwsLibError> { + let store_keys = store.list("policies")?; + let mut policies = Vec::new(); + + for store_key in store_keys { + if let Some(json) = store.get(&store_key)? { + match serde_json::from_str::(&json) { + Ok(p) => policies.push(p), + Err(e) => eprintln!("warning: skipping {store_key}: {e}"), + } + } + } + + policies.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(policies) +} + +/// Delete a policy by ID via a Store. +pub fn delete_policy_with_store( + id: &str, + store: &dyn Store, +) -> Result<(), OwsLibError> { + let key = format!("policies/{id}"); + if store.get(&key)?.is_none() { + return Err(OwsLibError::InvalidInput(format!("policy not found: {id}"))); + } + store_remove_indexed(store, &key, "policies")?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -204,4 +266,57 @@ mod tests { assert_eq!(loaded.executable.unwrap(), "/usr/local/bin/simulate-tx"); assert!(loaded.config.is_some()); } + + // == Store-aware policy CRUD tests == + + #[test] + fn store_save_and_load_policy() { + let store = ows_core::InMemoryStore::new(); + let policy = test_policy("sp1", "Store Policy"); + + save_policy_with_store(&policy, &store).unwrap(); + let loaded = load_policy_with_store("sp1", &store).unwrap(); + assert_eq!(loaded.id, "sp1"); + assert_eq!(loaded.name, "Store Policy"); + } + + #[test] + fn store_load_policy_not_found() { + let store = ows_core::InMemoryStore::new(); + let result = load_policy_with_store("nonexistent", &store); + assert!(result.is_err()); + } + + #[test] + fn store_list_policies_sorted_by_name() { + let store = ows_core::InMemoryStore::new(); + + save_policy_with_store(&test_policy("z-p", "Zebra"), &store).unwrap(); + save_policy_with_store(&test_policy("a-p", "Alpha"), &store).unwrap(); + save_policy_with_store(&test_policy("m-p", "Middle"), &store).unwrap(); + + let policies = list_policies_with_store(&store).unwrap(); + assert_eq!(policies.len(), 3); + assert_eq!(policies[0].name, "Alpha"); + assert_eq!(policies[1].name, "Middle"); + assert_eq!(policies[2].name, "Zebra"); + } + + #[test] + fn store_delete_policy() { + let store = ows_core::InMemoryStore::new(); + + save_policy_with_store(&test_policy("del-p", "Delete Me"), &store).unwrap(); + assert_eq!(list_policies_with_store(&store).unwrap().len(), 1); + + delete_policy_with_store("del-p", &store).unwrap(); + assert_eq!(list_policies_with_store(&store).unwrap().len(), 0); + } + + #[test] + fn store_delete_policy_not_found() { + let store = ows_core::InMemoryStore::new(); + let result = delete_policy_with_store("nonexistent", &store); + assert!(result.is_err()); + } } diff --git a/ows/crates/ows-lib/src/vault.rs b/ows/crates/ows-lib/src/vault.rs index 7de8d008..ed61df34 100644 --- a/ows/crates/ows-lib/src/vault.rs +++ b/ows/crates/ows-lib/src/vault.rs @@ -168,6 +168,89 @@ pub fn wallet_name_exists(name: &str, vault_path: Option<&Path>) -> Result Result<(), OwsLibError> { + let key = format!("wallets/{}", wallet.id); + let json = serde_json::to_string_pretty(wallet)?; + store_set_indexed(store, &key, &json, "wallets")?; + Ok(()) +} + +/// List all encrypted wallets via a Store, sorted by created_at descending. +pub fn list_wallets_with_store( + store: &dyn Store, +) -> Result, OwsLibError> { + let keys = store.list("wallets")?; + let mut wallets = Vec::new(); + + for key in keys { + if let Some(json) = store.get(&key)? { + match serde_json::from_str::(&json) { + Ok(w) => wallets.push(w), + Err(e) => eprintln!("warning: skipping {key}: {e}"), + } + } + } + + wallets.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(wallets) +} + +/// Look up a wallet by exact ID first, then by name (case-sensitive) via a Store. +pub fn load_wallet_by_name_or_id_with_store( + name_or_id: &str, + store: &dyn Store, +) -> Result { + // Try direct ID lookup first (fast path — avoids listing all). + let key = format!("wallets/{name_or_id}"); + if let Some(json) = store.get(&key)? { + if let Ok(w) = serde_json::from_str::(&json) { + return Ok(w); + } + } + + // Fall back to name search. + let wallets = list_wallets_with_store(store)?; + let matches: Vec<&EncryptedWallet> = wallets.iter().filter(|w| w.name == name_or_id).collect(); + match matches.len() { + 0 => Err(OwsLibError::WalletNotFound(name_or_id.to_string())), + 1 => Ok(matches[0].clone()), + n => Err(OwsLibError::AmbiguousWallet { + name: name_or_id.to_string(), + count: n, + }), + } +} + +/// Delete a wallet by ID via a Store. +pub fn delete_wallet_with_store(id: &str, store: &dyn Store) -> Result<(), OwsLibError> { + // Check existence first. + let key = format!("wallets/{id}"); + if store.get(&key)?.is_none() { + return Err(OwsLibError::WalletNotFound(id.to_string())); + } + store_remove_indexed(store, &key, "wallets")?; + Ok(()) +} + +/// Check whether a wallet with the given name already exists via a Store. +pub fn wallet_name_exists_with_store( + name: &str, + store: &dyn Store, +) -> Result { + let wallets = list_wallets_with_store(store)?; + Ok(wallets.iter().any(|w| w.name == name)) +} + #[cfg(test)] mod tests { use super::*; @@ -501,4 +584,137 @@ mod tests { dir_mode ); } + + // == Store-aware CRUD tests == + + #[test] + fn store_save_and_list_wallets() { + let store = ows_core::InMemoryStore::new(); + let wallet = EncryptedWallet::new( + "s-w1".to_string(), + "store-wallet".to_string(), + vec![], + serde_json::json!({}), + KeyType::Mnemonic, + ); + + save_wallet_with_store(&wallet, &store).unwrap(); + let wallets = list_wallets_with_store(&store).unwrap(); + assert_eq!(wallets.len(), 1); + assert_eq!(wallets[0].id, "s-w1"); + } + + #[test] + fn store_load_wallet_by_id() { + let store = ows_core::InMemoryStore::new(); + let wallet = EncryptedWallet::new( + "id-123".to_string(), + "my-wallet".to_string(), + vec![], + serde_json::json!({}), + KeyType::Mnemonic, + ); + + save_wallet_with_store(&wallet, &store).unwrap(); + let loaded = load_wallet_by_name_or_id_with_store("id-123", &store).unwrap(); + assert_eq!(loaded.name, "my-wallet"); + } + + #[test] + fn store_load_wallet_by_name() { + let store = ows_core::InMemoryStore::new(); + let wallet = EncryptedWallet::new( + "uuid-456".to_string(), + "named-wallet".to_string(), + vec![], + serde_json::json!({}), + KeyType::Mnemonic, + ); + + save_wallet_with_store(&wallet, &store).unwrap(); + let loaded = load_wallet_by_name_or_id_with_store("named-wallet", &store).unwrap(); + assert_eq!(loaded.id, "uuid-456"); + } + + #[test] + fn store_load_wallet_not_found() { + let store = ows_core::InMemoryStore::new(); + let result = load_wallet_by_name_or_id_with_store("nonexistent", &store); + assert!(result.is_err()); + match result.unwrap_err() { + OwsLibError::WalletNotFound(name) => assert_eq!(name, "nonexistent"), + other => panic!("expected WalletNotFound, got: {other}"), + } + } + + #[test] + fn store_delete_wallet() { + let store = ows_core::InMemoryStore::new(); + let wallet = EncryptedWallet::new( + "del-w".to_string(), + "delete-me".to_string(), + vec![], + serde_json::json!({}), + KeyType::Mnemonic, + ); + + save_wallet_with_store(&wallet, &store).unwrap(); + assert_eq!(list_wallets_with_store(&store).unwrap().len(), 1); + + delete_wallet_with_store("del-w", &store).unwrap(); + assert_eq!(list_wallets_with_store(&store).unwrap().len(), 0); + } + + #[test] + fn store_delete_wallet_not_found() { + let store = ows_core::InMemoryStore::new(); + let result = delete_wallet_with_store("nonexistent", &store); + assert!(result.is_err()); + } + + #[test] + fn store_wallet_name_exists() { + let store = ows_core::InMemoryStore::new(); + let wallet = EncryptedWallet::new( + "e-w1".to_string(), + "existing".to_string(), + vec![], + serde_json::json!({}), + KeyType::Mnemonic, + ); + + save_wallet_with_store(&wallet, &store).unwrap(); + assert!(wallet_name_exists_with_store("existing", &store).unwrap()); + assert!(!wallet_name_exists_with_store("other", &store).unwrap()); + } + + #[test] + fn store_list_wallets_sorted_newest_first() { + let store = ows_core::InMemoryStore::new(); + + let w1 = EncryptedWallet::new( + "w1".to_string(), + "first".to_string(), + vec![], + serde_json::json!({}), + KeyType::Mnemonic, + ); + save_wallet_with_store(&w1, &store).unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(10)); + + let w2 = EncryptedWallet::new( + "w2".to_string(), + "second".to_string(), + vec![], + serde_json::json!({}), + KeyType::Mnemonic, + ); + save_wallet_with_store(&w2, &store).unwrap(); + + let wallets = list_wallets_with_store(&store).unwrap(); + assert_eq!(wallets.len(), 2); + assert_eq!(wallets[0].id, "w2"); // newest first + assert_eq!(wallets[1].id, "w1"); + } }