Skip to main content

ows_lib/
key_ops.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use ows_core::{ApiKeyFile, OwsError};
5use ows_signer::{
6    decrypt, encrypt_with_hkdf, signer_for_chain, CryptoEnvelope, HdDeriver, Mnemonic, SecretBytes,
7};
8
9use crate::error::OwsLibError;
10use crate::key_store;
11use crate::policy_engine;
12use crate::policy_store;
13use crate::vault;
14
15/// Create an API key for agent access to one or more wallets.
16///
17/// 1. Authenticates with the owner's passphrase
18/// 2. Decrypts the mnemonic for each wallet
19/// 3. Generates a random token (`ows_key_...`)
20/// 4. Re-encrypts each mnemonic under HKDF(token)
21/// 5. Stores the key file with token hash, policy IDs, and encrypted copies
22/// 6. Returns the raw token (shown once to the user)
23pub fn create_api_key(
24    name: &str,
25    wallet_ids: &[String],
26    policy_ids: &[String],
27    passphrase: &str,
28    expires_at: Option<&str>,
29    vault_path: Option<&Path>,
30) -> Result<(String, ApiKeyFile), OwsLibError> {
31    // Validate that all wallets exist and passphrase works
32    let mut wallet_secrets = HashMap::new();
33    let mut resolved_wallet_ids = Vec::with_capacity(wallet_ids.len());
34    let token = key_store::generate_token();
35
36    for wallet_id in wallet_ids {
37        let wallet = vault::load_wallet_by_name_or_id(wallet_id, vault_path)?;
38        let envelope: CryptoEnvelope = serde_json::from_value(wallet.crypto.clone())?;
39
40        // Decrypt with owner's passphrase to verify it works
41        let secret = decrypt(&envelope, passphrase)?;
42
43        // Re-encrypt under HKDF(token)
44        let hkdf_envelope = encrypt_with_hkdf(secret.expose(), &token)?;
45        let envelope_json = serde_json::to_value(&hkdf_envelope)?;
46
47        wallet_secrets.insert(wallet.id.clone(), envelope_json);
48        // Always persist canonical wallet IDs (UUIDs). Callers may pass names or IDs;
49        // agent signing checks `contains(wallet.id)` when verifying scope.
50        resolved_wallet_ids.push(wallet.id.clone());
51    }
52
53    // Validate that all policies exist
54    for policy_id in policy_ids {
55        policy_store::load_policy(policy_id, vault_path)?;
56    }
57
58    let id = uuid::Uuid::new_v4().to_string();
59    let key_file = ApiKeyFile {
60        id,
61        name: name.to_string(),
62        token_hash: key_store::hash_token(&token),
63        created_at: chrono::Utc::now().to_rfc3339(),
64        wallet_ids: resolved_wallet_ids,
65        policy_ids: policy_ids.to_vec(),
66        expires_at: expires_at.map(String::from),
67        wallet_secrets,
68    };
69
70    key_store::save_api_key(&key_file, vault_path)?;
71
72    Ok((token, key_file))
73}
74
75/// Sign a transaction using an API token (agent mode).
76///
77/// 1. Look up key file by SHA256(token)
78/// 2. Check expiry and wallet scope
79/// 3. Load and evaluate policies
80/// 4. HKDF(token) → decrypt mnemonic
81/// 5. HD derive → sign
82pub fn sign_with_api_key(
83    token: &str,
84    wallet_name_or_id: &str,
85    chain: &ows_core::Chain,
86    tx_bytes: &[u8],
87    index: Option<u32>,
88    vault_path: Option<&Path>,
89) -> Result<crate::types::SignResult, OwsLibError> {
90    // 1. Look up key file
91    let token_hash = key_store::hash_token(token);
92    let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?;
93
94    // 2. Check expiry
95    check_expiry(&key_file)?;
96
97    // 3. Resolve wallet and check scope
98    let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
99    if !key_file.wallet_ids.contains(&wallet.id) {
100        return Err(OwsLibError::InvalidInput(format!(
101            "API key '{}' does not have access to wallet '{}'",
102            key_file.name, wallet.id,
103        )));
104    }
105
106    // 4. Load policies and build context
107    let policies = load_policies_for_key(&key_file, vault_path)?;
108    let now = chrono::Utc::now();
109    let date = now.format("%Y-%m-%d").to_string();
110
111    let tx_hex = hex::encode(tx_bytes);
112
113    let context = ows_core::PolicyContext {
114        chain_id: chain.chain_id.to_string(),
115        wallet_id: wallet.id.clone(),
116        api_key_id: key_file.id.clone(),
117        transaction: ows_core::policy::TransactionContext {
118            to: None,
119            value: None,
120            raw_hex: tx_hex,
121            data: None,
122        },
123        spending: noop_spending_context(&date),
124        timestamp: now.to_rfc3339(),
125    };
126
127    // 5. Evaluate policies
128    let result = policy_engine::evaluate_policies(&policies, &context);
129    if !result.allow {
130        return Err(OwsLibError::Core(OwsError::PolicyDenied {
131            policy_id: result.policy_id.unwrap_or_default(),
132            reason: result.reason.unwrap_or_else(|| "denied".into()),
133        }));
134    }
135
136    // 6. Decrypt mnemonic from key file using HKDF(token)
137    let key = decrypt_key_from_api_key(&key_file, &wallet.id, token, chain.chain_type, index)?;
138
139    // 7. Sign
140    let signer = signer_for_chain(chain.chain_type);
141    let output = signer.sign_transaction(key.expose(), tx_bytes)?;
142
143    Ok(crate::types::SignResult {
144        signature: hex::encode(&output.signature),
145        recovery_id: output.recovery_id,
146    })
147}
148
149/// Sign a message using an API token (agent mode).
150pub fn sign_message_with_api_key(
151    token: &str,
152    wallet_name_or_id: &str,
153    chain: &ows_core::Chain,
154    msg_bytes: &[u8],
155    index: Option<u32>,
156    vault_path: Option<&Path>,
157) -> Result<crate::types::SignResult, OwsLibError> {
158    let token_hash = key_store::hash_token(token);
159    let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?;
160
161    check_expiry(&key_file)?;
162
163    let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
164    if !key_file.wallet_ids.contains(&wallet.id) {
165        return Err(OwsLibError::InvalidInput(format!(
166            "API key '{}' does not have access to wallet '{}'",
167            key_file.name, wallet.id,
168        )));
169    }
170
171    let policies = load_policies_for_key(&key_file, vault_path)?;
172    let now = chrono::Utc::now();
173    let date = now.format("%Y-%m-%d").to_string();
174
175    let context = ows_core::PolicyContext {
176        chain_id: chain.chain_id.to_string(),
177        wallet_id: wallet.id.clone(),
178        api_key_id: key_file.id.clone(),
179        transaction: ows_core::policy::TransactionContext {
180            to: None,
181            value: None,
182            raw_hex: hex::encode(msg_bytes),
183            data: None,
184        },
185        spending: noop_spending_context(&date),
186        timestamp: now.to_rfc3339(),
187    };
188
189    let result = policy_engine::evaluate_policies(&policies, &context);
190    if !result.allow {
191        return Err(OwsLibError::Core(OwsError::PolicyDenied {
192            policy_id: result.policy_id.unwrap_or_default(),
193            reason: result.reason.unwrap_or_else(|| "denied".into()),
194        }));
195    }
196
197    let key = decrypt_key_from_api_key(&key_file, &wallet.id, token, chain.chain_type, index)?;
198    let signer = signer_for_chain(chain.chain_type);
199    let output = signer.sign_message(key.expose(), msg_bytes)?;
200
201    Ok(crate::types::SignResult {
202        signature: hex::encode(&output.signature),
203        recovery_id: output.recovery_id,
204    })
205}
206
207/// Enforce policies for a token-based transaction and return the decrypted
208/// signing key. Used by `sign_and_send` which needs the raw key for broadcast.
209pub fn enforce_policy_and_decrypt_key(
210    token: &str,
211    wallet_name_or_id: &str,
212    chain: &ows_core::Chain,
213    tx_bytes: &[u8],
214    index: Option<u32>,
215    vault_path: Option<&Path>,
216) -> Result<(SecretBytes, ApiKeyFile), OwsLibError> {
217    let token_hash = key_store::hash_token(token);
218    let key_file = key_store::load_api_key_by_token_hash(&token_hash, vault_path)?;
219    check_expiry(&key_file)?;
220
221    let wallet = vault::load_wallet_by_name_or_id(wallet_name_or_id, vault_path)?;
222    if !key_file.wallet_ids.contains(&wallet.id) {
223        return Err(OwsLibError::InvalidInput(format!(
224            "API key '{}' does not have access to wallet '{}'",
225            key_file.name, wallet.id,
226        )));
227    }
228
229    let policies = load_policies_for_key(&key_file, vault_path)?;
230    let now = chrono::Utc::now();
231    let date = now.format("%Y-%m-%d").to_string();
232
233    let tx_hex = hex::encode(tx_bytes);
234
235    let context = ows_core::PolicyContext {
236        chain_id: chain.chain_id.to_string(),
237        wallet_id: wallet.id.clone(),
238        api_key_id: key_file.id.clone(),
239        transaction: ows_core::policy::TransactionContext {
240            to: None,
241            value: None,
242            raw_hex: tx_hex,
243            data: None,
244        },
245        spending: noop_spending_context(&date),
246        timestamp: now.to_rfc3339(),
247    };
248
249    let result = policy_engine::evaluate_policies(&policies, &context);
250    if !result.allow {
251        return Err(OwsLibError::Core(OwsError::PolicyDenied {
252            policy_id: result.policy_id.unwrap_or_default(),
253            reason: result.reason.unwrap_or_else(|| "denied".into()),
254        }));
255    }
256
257    let key = decrypt_key_from_api_key(&key_file, &wallet.id, token, chain.chain_type, index)?;
258
259    Ok((key, key_file))
260}
261
262// ---------------------------------------------------------------------------
263// Helpers
264// ---------------------------------------------------------------------------
265
266fn noop_spending_context(date: &str) -> ows_core::policy::SpendingContext {
267    ows_core::policy::SpendingContext {
268        daily_total: "0".to_string(),
269        date: date.to_string(),
270    }
271}
272
273fn check_expiry(key_file: &ApiKeyFile) -> Result<(), OwsLibError> {
274    if let Some(ref expires) = key_file.expires_at {
275        let now = chrono::Utc::now().to_rfc3339();
276        if now.as_str() > expires.as_str() {
277            return Err(OwsLibError::Core(OwsError::ApiKeyExpired {
278                id: key_file.id.clone(),
279            }));
280        }
281    }
282    Ok(())
283}
284
285fn load_policies_for_key(
286    key_file: &ApiKeyFile,
287    vault_path: Option<&Path>,
288) -> Result<Vec<ows_core::Policy>, OwsLibError> {
289    let mut policies = Vec::with_capacity(key_file.policy_ids.len());
290    for pid in &key_file.policy_ids {
291        policies.push(policy_store::load_policy(pid, vault_path)?);
292    }
293    Ok(policies)
294}
295
296fn decrypt_key_from_api_key(
297    key_file: &ApiKeyFile,
298    wallet_id: &str,
299    token: &str,
300    chain_type: ows_core::ChainType,
301    index: Option<u32>,
302) -> Result<SecretBytes, OwsLibError> {
303    let envelope_value = key_file.wallet_secrets.get(wallet_id).ok_or_else(|| {
304        OwsLibError::InvalidInput(format!(
305            "API key has no encrypted secret for wallet {wallet_id}"
306        ))
307    })?;
308
309    let envelope: CryptoEnvelope = serde_json::from_value(envelope_value.clone())?;
310    let secret = decrypt(&envelope, token)?;
311
312    // The secret is a mnemonic phrase — derive the signing key
313    let phrase = std::str::from_utf8(secret.expose())
314        .map_err(|_| OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into()))?;
315    let mnemonic = Mnemonic::from_phrase(phrase)?;
316    let signer = signer_for_chain(chain_type);
317    let path = signer.default_derivation_path(index.unwrap_or(0));
318    let curve = signer.curve();
319    Ok(HdDeriver::derive_from_mnemonic_cached(
320        &mnemonic, "", &path, curve,
321    )?)
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use ows_core::{EncryptedWallet, KeyType, PolicyAction, PolicyRule, WalletAccount};
328    use ows_signer::encrypt;
329
330    /// Create a test wallet in the vault, return its ID.
331    fn setup_test_wallet(vault: &Path, passphrase: &str) -> String {
332        let mnemonic_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
333        let envelope = encrypt(mnemonic_phrase.as_bytes(), passphrase).unwrap();
334        let crypto = serde_json::to_value(&envelope).unwrap();
335
336        let wallet = EncryptedWallet::new(
337            "test-wallet-id".to_string(),
338            "test-wallet".to_string(),
339            vec![WalletAccount {
340                account_id: "eip155:8453:0xabc".to_string(),
341                address: "0xabc".to_string(),
342                chain_id: "eip155:8453".to_string(),
343                derivation_path: "m/44'/60'/0'/0/0".to_string(),
344            }],
345            crypto,
346            KeyType::Mnemonic,
347        );
348
349        vault::save_encrypted_wallet(&wallet, Some(vault)).unwrap();
350        wallet.id
351    }
352
353    fn setup_test_policy(vault: &Path) -> String {
354        let policy = ows_core::Policy {
355            id: "test-policy".to_string(),
356            name: "Test Policy".to_string(),
357            version: 1,
358            created_at: "2026-03-22T10:00:00Z".to_string(),
359            rules: vec![PolicyRule::AllowedChains {
360                chain_ids: vec!["eip155:8453".to_string()],
361            }],
362            executable: None,
363            config: None,
364            action: PolicyAction::Deny,
365        };
366        policy_store::save_policy(&policy, Some(vault)).unwrap();
367        policy.id
368    }
369
370    #[test]
371    fn create_api_key_and_verify_token() {
372        let dir = tempfile::tempdir().unwrap();
373        let vault = dir.path().to_path_buf();
374        let passphrase = "test-pass";
375
376        let wallet_id = setup_test_wallet(&vault, passphrase);
377        let policy_id = setup_test_policy(&vault);
378
379        let (token, key_file) = create_api_key(
380            "test-agent",
381            std::slice::from_ref(&wallet_id),
382            std::slice::from_ref(&policy_id),
383            passphrase,
384            None,
385            Some(&vault),
386        )
387        .unwrap();
388
389        // Token has correct format
390        assert!(token.starts_with("ows_key_"));
391
392        // Key file has correct data
393        assert_eq!(key_file.name, "test-agent");
394        assert_eq!(key_file.wallet_ids, vec![wallet_id.clone()]);
395        assert_eq!(key_file.policy_ids, vec![policy_id]);
396        assert_eq!(key_file.token_hash, key_store::hash_token(&token));
397        assert!(key_file.expires_at.is_none());
398
399        // Wallet secret is present and decryptable
400        assert!(key_file.wallet_secrets.contains_key(&wallet_id));
401        let envelope: CryptoEnvelope =
402            serde_json::from_value(key_file.wallet_secrets[&wallet_id].clone()).unwrap();
403        let decrypted = decrypt(&envelope, &token).unwrap();
404        assert_eq!(
405            std::str::from_utf8(decrypted.expose()).unwrap(),
406            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
407        );
408
409        // Key is persisted and loadable
410        let loaded = key_store::load_api_key(&key_file.id, Some(&vault)).unwrap();
411        assert_eq!(loaded.name, "test-agent");
412    }
413
414    /// Regression: API key scope must store resolved wallet UUIDs even when the caller passes a
415    /// wallet *name* (Node bindings and other callers pass strings that may be names).
416    #[test]
417    fn create_api_key_accepts_wallet_name_and_stores_canonical_ids() {
418        let dir = tempfile::tempdir().unwrap();
419        let vault = dir.path().to_path_buf();
420        let passphrase = "test-pass";
421
422        let wallet_id = setup_test_wallet(&vault, passphrase);
423        let policy_id = setup_test_policy(&vault);
424
425        let (token, key_file) = create_api_key(
426            "name-input-agent",
427            &["test-wallet".to_string()],
428            std::slice::from_ref(&policy_id),
429            passphrase,
430            None,
431            Some(&vault),
432        )
433        .unwrap();
434
435        assert_eq!(key_file.wallet_ids, vec![wallet_id.clone()]);
436
437        let chain = ows_core::parse_chain("base").unwrap();
438        let tx_bytes = vec![0u8; 32];
439        let result =
440            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
441        assert!(
442            result.is_ok(),
443            "sign_with_api_key failed: {:?}",
444            result.err()
445        );
446    }
447
448    #[test]
449    fn create_api_key_wrong_passphrase_fails() {
450        let dir = tempfile::tempdir().unwrap();
451        let vault = dir.path().to_path_buf();
452
453        let wallet_id = setup_test_wallet(&vault, "correct");
454        let policy_id = setup_test_policy(&vault);
455
456        let result = create_api_key(
457            "agent",
458            &[wallet_id],
459            &[policy_id],
460            "wrong-passphrase",
461            None,
462            Some(&vault),
463        );
464        assert!(result.is_err());
465    }
466
467    #[test]
468    fn create_api_key_nonexistent_wallet_fails() {
469        let dir = tempfile::tempdir().unwrap();
470        let vault = dir.path().to_path_buf();
471        let policy_id = setup_test_policy(&vault);
472
473        let result = create_api_key(
474            "agent",
475            &["nonexistent".to_string()],
476            &[policy_id],
477            "pass",
478            None,
479            Some(&vault),
480        );
481        assert!(result.is_err());
482    }
483
484    #[test]
485    fn create_api_key_nonexistent_policy_fails() {
486        let dir = tempfile::tempdir().unwrap();
487        let vault = dir.path().to_path_buf();
488
489        let wallet_id = setup_test_wallet(&vault, "pass");
490
491        let result = create_api_key(
492            "agent",
493            &[wallet_id],
494            &["nonexistent-policy".to_string()],
495            "pass",
496            None,
497            Some(&vault),
498        );
499        assert!(result.is_err());
500    }
501
502    #[test]
503    fn create_api_key_with_expiry() {
504        let dir = tempfile::tempdir().unwrap();
505        let vault = dir.path().to_path_buf();
506
507        let wallet_id = setup_test_wallet(&vault, "pass");
508        let policy_id = setup_test_policy(&vault);
509
510        let (_, key_file) = create_api_key(
511            "expiring-agent",
512            &[wallet_id],
513            &[policy_id],
514            "pass",
515            Some("2026-12-31T00:00:00Z"),
516            Some(&vault),
517        )
518        .unwrap();
519
520        assert_eq!(key_file.expires_at.as_deref(), Some("2026-12-31T00:00:00Z"));
521    }
522
523    #[test]
524    fn sign_with_api_key_full_flow() {
525        let dir = tempfile::tempdir().unwrap();
526        let vault = dir.path().to_path_buf();
527        let passphrase = "test-pass";
528
529        let wallet_id = setup_test_wallet(&vault, passphrase);
530        let policy_id = setup_test_policy(&vault);
531
532        let (token, _) = create_api_key(
533            "signer-agent",
534            &[wallet_id],
535            &[policy_id],
536            passphrase,
537            None,
538            Some(&vault),
539        )
540        .unwrap();
541
542        // Sign a dummy transaction on the allowed chain
543        let chain = ows_core::parse_chain("base").unwrap();
544        let tx_bytes = vec![0u8; 32]; // dummy tx
545
546        let result =
547            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
548
549        // The signing should succeed (policy allows eip155:8453)
550        assert!(
551            result.is_ok(),
552            "sign_with_api_key failed: {:?}",
553            result.err()
554        );
555        let sign_result = result.unwrap();
556        assert!(!sign_result.signature.is_empty());
557    }
558
559    #[test]
560    fn sign_with_api_key_wrong_chain_denied() {
561        let dir = tempfile::tempdir().unwrap();
562        let vault = dir.path().to_path_buf();
563        let passphrase = "test-pass";
564
565        let wallet_id = setup_test_wallet(&vault, passphrase);
566        let policy_id = setup_test_policy(&vault); // allows only eip155:8453
567
568        let (token, _) = create_api_key(
569            "agent",
570            &[wallet_id],
571            &[policy_id],
572            passphrase,
573            None,
574            Some(&vault),
575        )
576        .unwrap();
577
578        // Try to sign on a chain NOT in the policy allowlist
579        let chain = ows_core::parse_chain("ethereum").unwrap(); // eip155:1
580        let tx_bytes = vec![0u8; 32];
581
582        let result =
583            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
584
585        assert!(result.is_err());
586        match result.unwrap_err() {
587            OwsLibError::Core(OwsError::PolicyDenied { reason, .. }) => {
588                assert!(reason.contains("not in allowlist"));
589            }
590            other => panic!("expected PolicyDenied, got: {other}"),
591        }
592    }
593
594    #[test]
595    fn sign_with_api_key_expired_key_rejected() {
596        let dir = tempfile::tempdir().unwrap();
597        let vault = dir.path().to_path_buf();
598        let passphrase = "test-pass";
599
600        let wallet_id = setup_test_wallet(&vault, passphrase);
601        let policy_id = setup_test_policy(&vault);
602
603        let (token, _) = create_api_key(
604            "agent",
605            &[wallet_id],
606            &[policy_id],
607            passphrase,
608            Some("2020-01-01T00:00:00Z"), // already expired
609            Some(&vault),
610        )
611        .unwrap();
612
613        let chain = ows_core::parse_chain("base").unwrap();
614        let tx_bytes = vec![0u8; 32];
615
616        let result =
617            sign_with_api_key(&token, "test-wallet", &chain, &tx_bytes, None, Some(&vault));
618
619        assert!(result.is_err());
620        match result.unwrap_err() {
621            OwsLibError::Core(OwsError::ApiKeyExpired { .. }) => {}
622            other => panic!("expected ApiKeyExpired, got: {other}"),
623        }
624    }
625
626    #[test]
627    fn sign_with_wrong_token_fails() {
628        let dir = tempfile::tempdir().unwrap();
629        let vault = dir.path().to_path_buf();
630        let passphrase = "test-pass";
631
632        let wallet_id = setup_test_wallet(&vault, passphrase);
633        let policy_id = setup_test_policy(&vault);
634
635        let (_token, _) = create_api_key(
636            "agent",
637            &[wallet_id],
638            &[policy_id],
639            passphrase,
640            None,
641            Some(&vault),
642        )
643        .unwrap();
644
645        let chain = ows_core::parse_chain("base").unwrap();
646        let tx_bytes = vec![0u8; 32];
647
648        let result = sign_with_api_key(
649            "ows_key_wrong_token",
650            "test-wallet",
651            &chain,
652            &tx_bytes,
653            None,
654            Some(&vault),
655        );
656
657        assert!(result.is_err());
658    }
659
660    #[test]
661    fn sign_wallet_not_in_scope_fails() {
662        let dir = tempfile::tempdir().unwrap();
663        let vault = dir.path().to_path_buf();
664        let passphrase = "test-pass";
665
666        // Create two wallets
667        let wallet_id = setup_test_wallet(&vault, passphrase);
668        let policy_id = setup_test_policy(&vault);
669
670        // Create a second wallet
671        let mnemonic2 = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong";
672        let envelope2 = encrypt(mnemonic2.as_bytes(), passphrase).unwrap();
673        let crypto2 = serde_json::to_value(&envelope2).unwrap();
674        let wallet2 = EncryptedWallet::new(
675            "wallet-2-id".to_string(),
676            "other-wallet".to_string(),
677            vec![],
678            crypto2,
679            KeyType::Mnemonic,
680        );
681        vault::save_encrypted_wallet(&wallet2, Some(&vault)).unwrap();
682
683        // API key only has access to first wallet
684        let (token, _) = create_api_key(
685            "agent",
686            &[wallet_id],
687            &[policy_id],
688            passphrase,
689            None,
690            Some(&vault),
691        )
692        .unwrap();
693
694        let chain = ows_core::parse_chain("base").unwrap();
695        let tx_bytes = vec![0u8; 32];
696
697        // Try to sign with the second wallet → should fail
698        let result = sign_with_api_key(
699            &token,
700            "other-wallet",
701            &chain,
702            &tx_bytes,
703            None,
704            Some(&vault),
705        );
706
707        assert!(result.is_err());
708        match result.unwrap_err() {
709            OwsLibError::InvalidInput(msg) => {
710                assert!(msg.contains("does not have access"));
711            }
712            other => panic!("expected InvalidInput, got: {other}"),
713        }
714    }
715}