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