Skip to main content

octra_sqlite/client/
session.rs

1use super::config::{Config, load_config};
2use super::error::{Error, ErrorKind, Result};
3use super::wallet::{
4    discover_wallet_path, load_wallet, normalized_public_key_b64, signing_key_from_text,
5};
6use crate::protocol::target::{DatabaseTarget, ReadMode, parse_database_target};
7use base64::{Engine as _, engine::general_purpose};
8use ed25519_dalek::{Signer, SigningKey};
9use std::collections::BTreeSet;
10use std::env;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use zeroize::Zeroize;
14
15const PUBLIC_VIEW_CALLER: &str = "oct11111111111111111111111111111111111111111111";
16
17/// Explicit options for opening a client or database.
18///
19/// Prefer `wallet` for normal use. Inline `private_key` and `public_key`
20/// fields exist for controlled services and tests that already manage secret
21/// material outside the local wallet file.
22///
23/// `ClientOptions` intentionally does not implement `Debug` because it may
24/// contain private key material.
25///
26/// ```compile_fail
27/// let _ = format!("{:?}", octra_sqlite::ClientOptions::default());
28/// ```
29#[derive(Clone, Default)]
30pub struct ClientOptions {
31    /// Saved database name, raw Circle ID, or `oct://` database URI.
32    pub target: Option<String>,
33    /// Local wallet JSON path.
34    pub wallet: Option<PathBuf>,
35    /// Octra RPC URL override.
36    pub rpc: Option<String>,
37    /// Caller address override for read/session construction.
38    pub caller: Option<String>,
39    /// Inline private key material. Prefer `wallet` outside controlled tests.
40    pub private_key: Option<String>,
41    /// Optional public key material used to verify the private key.
42    pub public_key: Option<String>,
43}
44
45/// Resolved Octra session used by the raw client layer.
46#[derive(Clone)]
47pub struct Session {
48    target: DatabaseTarget,
49    wallet_path: Option<PathBuf>,
50    wallet_load_error: Option<String>,
51    rpc: String,
52    rpc_override: bool,
53    caller: String,
54    signer: Option<Arc<LocalSigner>>,
55}
56
57struct LocalSigner {
58    key: SigningKey,
59    public_key_b64: String,
60}
61
62impl LocalSigner {
63    fn from_private_key_text(private_key: &str, public_key: Option<String>) -> Result<Self> {
64        let key = signing_key_from_text(private_key)?;
65        let derived_public_key = key.verifying_key().to_bytes();
66        let public_key_b64 = match public_key {
67            Some(text) => normalized_public_key_b64(&text, &derived_public_key)?,
68            None => general_purpose::STANDARD.encode(derived_public_key),
69        };
70        Ok(Self {
71            key,
72            public_key_b64,
73        })
74    }
75
76    fn public_key_b64(&self) -> &str {
77        &self.public_key_b64
78    }
79
80    fn intent_public_key(&self) -> [u8; 32] {
81        self.key.verifying_key().to_bytes()
82    }
83
84    fn sign_text_b64(&self, message: &str) -> String {
85        general_purpose::STANDARD.encode(self.key.sign(message.as_bytes()).to_bytes())
86    }
87
88    fn sign_bytes_hex(&self, message: &[u8]) -> String {
89        hex::encode(self.key.sign(message).to_bytes())
90    }
91}
92
93impl Session {
94    pub fn target(&self) -> &DatabaseTarget {
95        &self.target
96    }
97
98    pub fn wallet_path(&self) -> Option<&Path> {
99        self.wallet_path.as_deref()
100    }
101
102    pub fn wallet_load_error(&self) -> Option<&str> {
103        self.wallet_load_error.as_deref()
104    }
105
106    pub fn rpc(&self) -> &str {
107        &self.rpc
108    }
109
110    pub fn caller(&self) -> &str {
111        &self.caller
112    }
113
114    pub fn public_key_b64(&self) -> Result<&str> {
115        Ok(self.signer()?.public_key_b64())
116    }
117
118    pub fn with_database_target(&self, target: DatabaseTarget) -> Session {
119        Session {
120            target,
121            wallet_path: self.wallet_path.clone(),
122            wallet_load_error: self.wallet_load_error.clone(),
123            rpc: self.rpc.clone(),
124            rpc_override: self.rpc_override,
125            caller: self.caller.clone(),
126            signer: self.signer.clone(),
127        }
128    }
129
130    pub fn open_database(&self, target: impl Into<String>) -> Result<Session> {
131        let config = load_config().unwrap_or_default();
132        let mut target = resolve_database_target(&target.into(), &config)?;
133        if target.rpc.is_empty() {
134            target.rpc = self.rpc.clone();
135        }
136        Ok(Session {
137            rpc: open_database_rpc(&self.rpc, self.rpc_override, Some(target.rpc.clone())),
138            target,
139            wallet_path: self.wallet_path.clone(),
140            wallet_load_error: self.wallet_load_error.clone(),
141            rpc_override: self.rpc_override,
142            caller: self.caller.clone(),
143            signer: self.signer.clone(),
144        })
145    }
146
147    pub fn intent_public_key(&self) -> Result<[u8; 32]> {
148        Ok(self.signer()?.intent_public_key())
149    }
150
151    pub(crate) fn sign_view_auth_b64(&self, message: &str) -> Result<String> {
152        Ok(self.signer()?.sign_text_b64(message))
153    }
154
155    pub(crate) fn sign_program_info_b64(&self, message: &str) -> Result<String> {
156        Ok(self.signer()?.sign_text_b64(message))
157    }
158
159    pub(crate) fn sign_transaction_b64(&self, message: &str) -> Result<String> {
160        Ok(self.signer()?.sign_text_b64(message))
161    }
162
163    pub(crate) fn sign_owner_write_hex(&self, message: &[u8]) -> Result<String> {
164        Ok(self.signer()?.sign_bytes_hex(message))
165    }
166
167    fn signer(&self) -> Result<&LocalSigner> {
168        if let Some(error) = &self.wallet_load_error {
169            return Err(Error::with_kind(
170                ErrorKind::Wallet,
171                format!(
172                    "wallet failed to load; public reads can continue without it, but signed operations require a valid wallet: {error}"
173                ),
174            ));
175        }
176        self.signer.as_deref().ok_or_else(|| {
177            Error::with_kind(
178                ErrorKind::Wallet,
179                "wallet private key is required for signed Octra operations",
180            )
181        })
182    }
183}
184
185pub fn build_session(options: &ClientOptions) -> Result<Session> {
186    let config = load_config().unwrap_or_default();
187    let target_value = options
188        .target
189        .clone()
190        .or_else(|| config.default_database.clone())
191        .or_else(|| env::var("OCTRA_SQLITE_DATABASE").ok())
192        .or_else(|| env::var("OCTRA_SQLITE_TARGET").ok())
193        .or_else(|| env::var("OCTRA_CIRCLE_ID").ok())
194        .ok_or_else(|| {
195            Error::with_kind(
196                ErrorKind::Config,
197                "no database supplied and no default database is configured",
198            )
199        })?;
200    let target = resolve_database_target(&target_value, &config)?;
201    build_session_for_target(options, &config, target)
202}
203
204pub fn build_control_session(options: &ClientOptions, network: &str) -> Result<Session> {
205    let config = load_config().unwrap_or_default();
206    let target = DatabaseTarget {
207        raw: format!("oct://{network}"),
208        network: network.to_string(),
209        circle: String::new(),
210        rpc: config.rpc_for_network(network).unwrap_or_default(),
211        read_mode: ReadMode::Sealed,
212    };
213    build_session_for_target(options, &config, target)
214}
215
216pub fn resolve_wallet_path(options: &ClientOptions, config: &Config) -> Option<PathBuf> {
217    options
218        .wallet
219        .clone()
220        .or_else(|| env::var("OCTRA_WALLET").ok().map(PathBuf::from))
221        .or_else(|| config.wallet.as_ref().map(PathBuf::from))
222        .or_else(discover_wallet_path)
223}
224
225pub fn resolve_database_target(value: &str, config: &Config) -> Result<DatabaseTarget> {
226    let mut seen = BTreeSet::new();
227    let mut chain = Vec::new();
228    resolve_database_target_inner(value, config, &mut seen, &mut chain)
229}
230
231fn resolve_database_target_inner(
232    value: &str,
233    config: &Config,
234    seen: &mut BTreeSet<String>,
235    chain: &mut Vec<String>,
236) -> Result<DatabaseTarget> {
237    if let Some(database) = config.databases.get(value) {
238        if !seen.insert(value.to_string()) {
239            chain.push(value.to_string());
240            return Err(Error::with_kind(
241                ErrorKind::Config,
242                format!("cyclic database alias: {}", chain.join(" -> ")),
243            ));
244        }
245        chain.push(value.to_string());
246        let mut target = resolve_database_target_inner(database, config, seen, chain)?;
247        chain.pop();
248        seen.remove(value);
249        apply_target_metadata(value, config, &mut target);
250        return Ok(target);
251    }
252    let mut target = parse_database_target(value, config.network.as_deref(), None)?;
253    if target.rpc.is_empty() {
254        target.rpc = config.rpc_for_network(&target.network).unwrap_or_default();
255    }
256    apply_target_metadata(value, config, &mut target);
257    Ok(target)
258}
259
260fn apply_target_metadata(requested: &str, config: &Config, target: &mut DatabaseTarget) {
261    if let Some(metadata) = config.metadata_for_target(requested, target) {
262        target.read_mode = metadata.read_mode;
263    }
264}
265
266fn build_session_for_target(
267    options: &ClientOptions,
268    config: &Config,
269    mut target: DatabaseTarget,
270) -> Result<Session> {
271    let explicit_rpc = first_string([options.rpc.clone(), env::var("OCTRA_RPC_URL").ok()]);
272    if let Some(rpc) = explicit_rpc.clone() {
273        target.rpc = rpc;
274    }
275    let rpc_override = explicit_rpc.is_some();
276    let wallet_path = resolve_wallet_path(options, config);
277    let mut wallet_load_error = None;
278    let wallet = match load_wallet(wallet_path.as_deref()) {
279        Ok(wallet) => wallet,
280        Err(error) if target.read_mode.allows_unsigned_read() => {
281            wallet_load_error = Some(error.to_string());
282            Default::default()
283        }
284        Err(error) => return Err(error),
285    };
286    let wallet_rpc = wallet.rpc;
287    let rpc = choose_session_rpc(
288        explicit_rpc,
289        Some(target.rpc.clone()),
290        config.rpc.clone(),
291        wallet_rpc,
292    )
293    .ok_or_else(|| {
294        Error::with_kind(
295            ErrorKind::Config,
296            "RPC is required; run octra-sqlite setup, pass --rpc, or set OCTRA_RPC_URL",
297        )
298    })?;
299    let caller = first_string([
300        options.caller.clone(),
301        wallet.addr,
302        wallet.address,
303        env::var("OCTRA_CALLER").ok(),
304    ])
305    .unwrap_or_else(|| PUBLIC_VIEW_CALLER.to_string());
306    let private_key = first_secret_string([
307        options.private_key.clone(),
308        wallet.priv_field,
309        wallet.priv_,
310        wallet.private_key,
311        wallet.private_key_b64,
312        env::var("OCTRA_PRIVATE_KEY_B64").ok(),
313    ]);
314    let supplied_public_key = first_string([
315        options.public_key.clone(),
316        wallet.pub_field,
317        wallet.pub_,
318        wallet.public_key,
319        wallet.public_key_b64,
320        env::var("OCTRA_PUBLIC_KEY_B64").ok(),
321    ]);
322    let signer = match private_key {
323        Some(mut private_key) => {
324            let signer = LocalSigner::from_private_key_text(&private_key, supplied_public_key);
325            private_key.zeroize();
326            Some(Arc::new(signer?))
327        }
328        None if target.read_mode.allows_unsigned_read() => None,
329        None => {
330            return Err(Error::with_kind(
331                ErrorKind::Wallet,
332                "wallet private key is required; pass --wallet or OCTRA_PRIVATE_KEY_B64",
333            ));
334        }
335    };
336    Ok(Session {
337        target,
338        wallet_path,
339        wallet_load_error,
340        rpc,
341        rpc_override,
342        caller,
343        signer,
344    })
345}
346
347fn first_string(values: impl IntoIterator<Item = Option<String>>) -> Option<String> {
348    values
349        .into_iter()
350        .find_map(|value| value.filter(|v| !v.is_empty()))
351}
352
353fn first_secret_string(values: impl IntoIterator<Item = Option<String>>) -> Option<String> {
354    let mut selected = None;
355    for mut value in values.into_iter().flatten() {
356        if value.is_empty() {
357            value.zeroize();
358            continue;
359        }
360        if selected.is_none() {
361            selected = Some(value);
362        } else {
363            value.zeroize();
364        }
365    }
366    selected
367}
368
369fn open_database_rpc(current_rpc: &str, rpc_override: bool, target_rpc: Option<String>) -> String {
370    if rpc_override {
371        return current_rpc.to_string();
372    }
373    first_string([target_rpc, Some(current_rpc.to_string())]).unwrap()
374}
375
376fn choose_session_rpc(
377    explicit_rpc: Option<String>,
378    target_rpc: Option<String>,
379    config_rpc: Option<String>,
380    wallet_rpc: Option<String>,
381) -> Option<String> {
382    first_string([explicit_rpc, target_rpc, config_rpc, wallet_rpc])
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn target_network_rpc_wins_over_wallet_rpc() {
391        assert_eq!(
392            choose_session_rpc(
393                None,
394                Some("https://devnet.octrascan.io/rpc".to_string()),
395                Some("https://config.example/rpc".to_string()),
396                Some("http://wallet.example/rpc".to_string()),
397            )
398            .as_deref(),
399            Some("https://devnet.octrascan.io/rpc")
400        );
401    }
402
403    #[test]
404    fn explicit_rpc_wins_over_target_network_rpc() {
405        assert_eq!(
406            choose_session_rpc(
407                Some("https://override.example/rpc".to_string()),
408                Some("https://devnet.octrascan.io/rpc".to_string()),
409                Some("https://config.example/rpc".to_string()),
410                Some("http://wallet.example/rpc".to_string()),
411            )
412            .as_deref(),
413            Some("https://override.example/rpc")
414        );
415    }
416
417    #[test]
418    fn wallet_rpc_is_only_a_fallback() {
419        assert_eq!(
420            choose_session_rpc(
421                None,
422                Some(String::new()),
423                None,
424                Some("http://wallet.example/rpc".to_string()),
425            )
426            .as_deref(),
427            Some("http://wallet.example/rpc")
428        );
429    }
430
431    #[test]
432    fn open_database_rpc_uses_target_network_unless_rpc_was_explicit() {
433        assert_eq!(
434            open_database_rpc(
435                "https://devnet.octrascan.io/rpc",
436                false,
437                Some("https://octra.network/rpc".to_string()),
438            ),
439            "https://octra.network/rpc"
440        );
441        assert_eq!(
442            open_database_rpc(
443                "http://127.0.0.1:8080/rpc",
444                true,
445                Some("https://octra.network/rpc".to_string()),
446            ),
447            "http://127.0.0.1:8080/rpc"
448        );
449    }
450
451    #[test]
452    fn resolve_database_target_follows_aliases() {
453        let mut config = Config {
454            network: Some("devnet".to_string()),
455            ..Config::default()
456        };
457        config.databases.insert("a".to_string(), "b".to_string());
458        config
459            .databases
460            .insert("b".to_string(), "oct://devnet/octABC".to_string());
461        let target = resolve_database_target("a", &config).unwrap();
462        assert_eq!(target.circle, "octABC");
463    }
464
465    #[test]
466    fn resolve_database_target_rejects_self_alias_cycle() {
467        let mut config = Config::default();
468        config.databases.insert("a".to_string(), "a".to_string());
469        let error = resolve_database_target("a", &config).unwrap_err();
470        assert_eq!(error.kind(), ErrorKind::Config);
471        assert!(error.to_string().contains("a -> a"));
472    }
473
474    #[test]
475    fn resolve_database_target_rejects_multi_alias_cycle() {
476        let mut config = Config::default();
477        config.databases.insert("a".to_string(), "b".to_string());
478        config.databases.insert("b".to_string(), "a".to_string());
479        let error = resolve_database_target("a", &config).unwrap_err();
480        assert_eq!(error.kind(), ErrorKind::Config);
481        assert!(error.to_string().contains("a -> b -> a"));
482    }
483
484    #[test]
485    fn supplied_public_key_must_match_private_key() {
486        let error = match build_session(&ClientOptions {
487            target: Some("oct://devnet/octABC".to_string()),
488            rpc: Some("mock://rpc".to_string()),
489            caller: Some("octCaller".to_string()),
490            private_key: Some(
491                "0101010101010101010101010101010101010101010101010101010101010101".to_string(),
492            ),
493            public_key: Some(general_purpose::STANDARD.encode([2u8; 32])),
494            ..ClientOptions::default()
495        }) {
496            Ok(_) => panic!("mismatched public key should fail"),
497            Err(error) => error,
498        };
499        assert_eq!(error.kind(), ErrorKind::Wallet);
500        assert!(
501            error
502                .to_string()
503                .contains("wallet public key does not match private key")
504        );
505    }
506
507    #[test]
508    fn accepts_explicit_64_byte_keypair_form() {
509        let seed = [3u8; 32];
510        let key = SigningKey::from_bytes(&seed);
511        let public_key = key.verifying_key().to_bytes();
512        let mut keypair = Vec::from(seed);
513        keypair.extend_from_slice(&public_key);
514        let session = build_session(&ClientOptions {
515            target: Some("oct://devnet/octABC".to_string()),
516            rpc: Some("mock://rpc".to_string()),
517            caller: Some("octCaller".to_string()),
518            private_key: Some(hex::encode(keypair)),
519            public_key: Some(general_purpose::STANDARD.encode(public_key)),
520            ..ClientOptions::default()
521        })
522        .unwrap();
523        assert_eq!(
524            session.public_key_b64().unwrap(),
525            general_purpose::STANDARD.encode(public_key)
526        );
527    }
528
529    #[test]
530    fn public_read_preserves_wallet_load_error_for_signed_operations() {
531        let path = std::env::temp_dir().join(format!(
532            "octra-sqlite-invalid-wallet-{}.json",
533            std::process::id()
534        ));
535        std::fs::write(&path, "{").unwrap();
536        let session = build_session(&ClientOptions {
537            target: Some("oct://devnet/octABC?read_mode=public".to_string()),
538            wallet: Some(path.clone()),
539            rpc: Some("mock://rpc".to_string()),
540            ..ClientOptions::default()
541        })
542        .unwrap();
543        let _ = std::fs::remove_file(&path);
544        assert!(
545            session
546                .wallet_load_error()
547                .is_some_and(|error| error.contains("parsing wallet"))
548        );
549        let error = session.intent_public_key().unwrap_err();
550        assert_eq!(error.kind(), ErrorKind::Wallet);
551        assert!(error.to_string().contains("wallet failed to load"));
552        assert!(error.to_string().contains("parsing wallet"));
553    }
554
555    #[test]
556    fn rejects_private_keys_with_ambiguous_length() {
557        let error = signing_key_from_text("0102").unwrap_err();
558        assert_eq!(error.kind(), ErrorKind::Wallet);
559        assert!(
560            error
561                .to_string()
562                .contains("32-byte seed or 64-byte keypair")
563        );
564    }
565}