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#[derive(Clone, Default)]
30pub struct ClientOptions {
31 pub target: Option<String>,
33 pub wallet: Option<PathBuf>,
35 pub rpc: Option<String>,
37 pub caller: Option<String>,
39 pub private_key: Option<String>,
41 pub public_key: Option<String>,
43}
44
45#[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 {
96 &self.target
97 }
98
99 pub fn wallet_path(&self) -> Option<&Path> {
101 self.wallet_path.as_deref()
102 }
103
104 pub fn wallet_load_error(&self) -> Option<&str> {
106 self.wallet_load_error.as_deref()
107 }
108
109 pub fn rpc(&self) -> &str {
111 &self.rpc
112 }
113
114 pub fn caller(&self) -> &str {
116 &self.caller
117 }
118
119 pub fn public_key_b64(&self) -> Result<&str> {
121 Ok(self.signer()?.public_key_b64())
122 }
123
124 pub fn with_database_target(&self, target: DatabaseTarget) -> Session {
126 Session {
127 target,
128 wallet_path: self.wallet_path.clone(),
129 wallet_load_error: self.wallet_load_error.clone(),
130 rpc: self.rpc.clone(),
131 rpc_override: self.rpc_override,
132 caller: self.caller.clone(),
133 signer: self.signer.clone(),
134 }
135 }
136
137 pub fn open_database(&self, target: impl Into<String>) -> Result<Session> {
139 let config = load_config()?;
140 let mut target = resolve_database_target(&target.into(), &config)?;
141 if target.rpc.is_empty() {
142 target.rpc = self.rpc.clone();
143 }
144 Ok(Session {
145 rpc: open_database_rpc(&self.rpc, self.rpc_override, Some(target.rpc.clone())),
146 target,
147 wallet_path: self.wallet_path.clone(),
148 wallet_load_error: self.wallet_load_error.clone(),
149 rpc_override: self.rpc_override,
150 caller: self.caller.clone(),
151 signer: self.signer.clone(),
152 })
153 }
154
155 pub fn intent_public_key(&self) -> Result<[u8; 32]> {
157 Ok(self.signer()?.intent_public_key())
158 }
159
160 pub(crate) fn sign_view_auth_b64(&self, message: &str) -> Result<String> {
161 Ok(self.signer()?.sign_text_b64(message))
162 }
163
164 pub(crate) fn sign_program_info_b64(&self, message: &str) -> Result<String> {
165 Ok(self.signer()?.sign_text_b64(message))
166 }
167
168 pub(crate) fn sign_transaction_b64(&self, message: &str) -> Result<String> {
169 Ok(self.signer()?.sign_text_b64(message))
170 }
171
172 pub(crate) fn sign_owner_write_hex(&self, message: &[u8]) -> Result<String> {
173 Ok(self.signer()?.sign_bytes_hex(message))
174 }
175
176 fn signer(&self) -> Result<&LocalSigner> {
177 if let Some(error) = &self.wallet_load_error {
178 return Err(Error::with_kind(
179 ErrorKind::Wallet,
180 format!(
181 "wallet failed to load; public reads can continue without it, but signed operations require a valid wallet: {error}"
182 ),
183 ));
184 }
185 self.signer.as_deref().ok_or_else(|| {
186 Error::with_kind(
187 ErrorKind::Wallet,
188 "wallet private key is required for signed Octra operations",
189 )
190 })
191 }
192}
193
194pub fn build_session(options: &ClientOptions) -> Result<Session> {
196 let config = load_config()?;
197 let target_value = options
198 .target
199 .clone()
200 .or_else(|| config.default_database.clone())
201 .or_else(|| env::var("OCTRA_SQLITE_DATABASE").ok())
202 .or_else(|| env::var("OCTRA_SQLITE_TARGET").ok())
203 .or_else(|| env::var("OCTRA_CIRCLE_ID").ok())
204 .ok_or_else(|| {
205 Error::with_kind(
206 ErrorKind::Config,
207 "no database supplied and no default database is configured",
208 )
209 })?;
210 let target = resolve_database_target(&target_value, &config)?;
211 build_session_for_target(options, &config, target)
212}
213
214pub fn build_control_session(options: &ClientOptions, network: &str) -> Result<Session> {
216 let config = load_config()?;
217 let target = DatabaseTarget {
218 raw: format!("oct://{network}"),
219 network: network.to_string(),
220 circle: String::new(),
221 rpc: config.rpc_for_network(network).unwrap_or_default(),
222 read_mode: ReadMode::Sealed,
223 };
224 build_session_for_target(options, &config, target)
225}
226
227pub fn resolve_wallet_path(options: &ClientOptions, config: &Config) -> Option<PathBuf> {
229 options
230 .wallet
231 .clone()
232 .or_else(|| env::var("OCTRA_WALLET").ok().map(PathBuf::from))
233 .or_else(|| config.wallet.as_ref().map(PathBuf::from))
234 .or_else(discover_wallet_path)
235}
236
237pub fn resolve_database_target(value: &str, config: &Config) -> Result<DatabaseTarget> {
239 let mut seen = BTreeSet::new();
240 let mut chain = Vec::new();
241 resolve_database_target_inner(value, config, &mut seen, &mut chain)
242}
243
244fn resolve_database_target_inner(
245 value: &str,
246 config: &Config,
247 seen: &mut BTreeSet<String>,
248 chain: &mut Vec<String>,
249) -> Result<DatabaseTarget> {
250 if let Some(database) = config.databases.get(value) {
251 if !seen.insert(value.to_string()) {
252 chain.push(value.to_string());
253 return Err(Error::with_kind(
254 ErrorKind::Config,
255 format!("cyclic database alias: {}", chain.join(" -> ")),
256 ));
257 }
258 chain.push(value.to_string());
259 let mut target = resolve_database_target_inner(database, config, seen, chain)?;
260 chain.pop();
261 seen.remove(value);
262 apply_target_metadata(value, config, &mut target);
263 return Ok(target);
264 }
265 let mut target = parse_database_target(value, config.network.as_deref(), None)
266 .map_err(|error| Error::with_code(ErrorKind::Config, "target_error", error.to_string()))?;
267 if target.rpc.is_empty() {
268 target.rpc = config.rpc_for_network(&target.network).unwrap_or_default();
269 }
270 apply_target_metadata(value, config, &mut target);
271 Ok(target)
272}
273
274fn apply_target_metadata(requested: &str, config: &Config, target: &mut DatabaseTarget) {
275 if target.read_mode == ReadMode::Auto
276 && let Some(metadata) = config.metadata_for_target(requested, target)
277 {
278 target.read_mode = metadata.read_mode;
279 }
280}
281
282fn build_session_for_target(
283 options: &ClientOptions,
284 config: &Config,
285 mut target: DatabaseTarget,
286) -> Result<Session> {
287 let explicit_rpc = first_string([options.rpc.clone(), env::var("OCTRA_RPC_URL").ok()]);
288 if let Some(rpc) = explicit_rpc.clone() {
289 target.rpc = rpc;
290 }
291 let rpc_override = explicit_rpc.is_some();
292 let wallet_path = resolve_wallet_path(options, config);
293 let mut wallet_load_error = None;
294 let wallet = match load_wallet(wallet_path.as_deref()) {
295 Ok(wallet) => wallet,
296 Err(error) if target.read_mode.allows_unsigned_read() => {
297 wallet_load_error = Some(error.to_string());
298 Default::default()
299 }
300 Err(error) => return Err(error),
301 };
302 let wallet_rpc = wallet.rpc;
303 let rpc = choose_session_rpc(
304 explicit_rpc,
305 Some(target.rpc.clone()),
306 config.rpc.clone(),
307 wallet_rpc,
308 )
309 .ok_or_else(|| {
310 Error::with_kind(
311 ErrorKind::Config,
312 "RPC is required; run octra-sqlite setup, pass --rpc, or set OCTRA_RPC_URL",
313 )
314 })?;
315 let caller = first_string([
316 options.caller.clone(),
317 wallet.addr,
318 wallet.address,
319 env::var("OCTRA_CALLER").ok(),
320 ])
321 .unwrap_or_else(|| PUBLIC_VIEW_CALLER.to_string());
322 let private_key = first_secret_string([
323 options.private_key.clone(),
324 wallet.priv_field,
325 wallet.priv_,
326 wallet.private_key,
327 wallet.private_key_b64,
328 env::var("OCTRA_PRIVATE_KEY_B64").ok(),
329 ]);
330 let supplied_public_key = first_string([
331 options.public_key.clone(),
332 wallet.pub_field,
333 wallet.pub_,
334 wallet.public_key,
335 wallet.public_key_b64,
336 env::var("OCTRA_PUBLIC_KEY_B64").ok(),
337 ]);
338 let signer = match private_key {
339 Some(mut private_key) => {
340 let signer = LocalSigner::from_private_key_text(&private_key, supplied_public_key);
341 private_key.zeroize();
342 Some(Arc::new(signer?))
343 }
344 None if target.read_mode.allows_unsigned_read() => None,
345 None => {
346 return Err(Error::with_kind(
347 ErrorKind::Wallet,
348 "wallet private key is required; pass --wallet or OCTRA_PRIVATE_KEY_B64",
349 ));
350 }
351 };
352 Ok(Session {
353 target,
354 wallet_path,
355 wallet_load_error,
356 rpc,
357 rpc_override,
358 caller,
359 signer,
360 })
361}
362
363fn first_string(values: impl IntoIterator<Item = Option<String>>) -> Option<String> {
364 values
365 .into_iter()
366 .find_map(|value| value.filter(|v| !v.is_empty()))
367}
368
369fn first_secret_string(values: impl IntoIterator<Item = Option<String>>) -> Option<String> {
370 let mut selected = None;
371 for mut value in values.into_iter().flatten() {
372 if value.is_empty() {
373 value.zeroize();
374 continue;
375 }
376 if selected.is_none() {
377 selected = Some(value);
378 } else {
379 value.zeroize();
380 }
381 }
382 selected
383}
384
385fn open_database_rpc(current_rpc: &str, rpc_override: bool, target_rpc: Option<String>) -> String {
386 if rpc_override {
387 return current_rpc.to_string();
388 }
389 first_string([target_rpc, Some(current_rpc.to_string())]).unwrap()
390}
391
392fn choose_session_rpc(
393 explicit_rpc: Option<String>,
394 target_rpc: Option<String>,
395 config_rpc: Option<String>,
396 wallet_rpc: Option<String>,
397) -> Option<String> {
398 first_string([explicit_rpc, target_rpc, config_rpc, wallet_rpc])
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn target_network_rpc_wins_over_wallet_rpc() {
407 assert_eq!(
408 choose_session_rpc(
409 None,
410 Some("https://devnet.octrascan.io/rpc".to_string()),
411 Some("https://config.example/rpc".to_string()),
412 Some("http://wallet.example/rpc".to_string()),
413 )
414 .as_deref(),
415 Some("https://devnet.octrascan.io/rpc")
416 );
417 }
418
419 #[test]
420 fn explicit_rpc_wins_over_target_network_rpc() {
421 assert_eq!(
422 choose_session_rpc(
423 Some("https://override.example/rpc".to_string()),
424 Some("https://devnet.octrascan.io/rpc".to_string()),
425 Some("https://config.example/rpc".to_string()),
426 Some("http://wallet.example/rpc".to_string()),
427 )
428 .as_deref(),
429 Some("https://override.example/rpc")
430 );
431 }
432
433 #[test]
434 fn wallet_rpc_is_only_a_fallback() {
435 assert_eq!(
436 choose_session_rpc(
437 None,
438 Some(String::new()),
439 None,
440 Some("http://wallet.example/rpc".to_string()),
441 )
442 .as_deref(),
443 Some("http://wallet.example/rpc")
444 );
445 }
446
447 #[test]
448 fn open_database_rpc_uses_target_network_unless_rpc_was_explicit() {
449 assert_eq!(
450 open_database_rpc(
451 "https://devnet.octrascan.io/rpc",
452 false,
453 Some("https://octra.network/rpc".to_string()),
454 ),
455 "https://octra.network/rpc"
456 );
457 assert_eq!(
458 open_database_rpc(
459 "http://127.0.0.1:8080/rpc",
460 true,
461 Some("https://octra.network/rpc".to_string()),
462 ),
463 "http://127.0.0.1:8080/rpc"
464 );
465 }
466
467 #[test]
468 fn resolve_database_target_follows_aliases() {
469 let mut config = Config {
470 network: Some("devnet".to_string()),
471 ..Config::default()
472 };
473 config.databases.insert("a".to_string(), "b".to_string());
474 config
475 .databases
476 .insert("b".to_string(), "oct://devnet/octABC".to_string());
477 let target = resolve_database_target("a", &config).unwrap();
478 assert_eq!(target.circle, "octABC");
479 }
480
481 #[test]
482 fn explicit_uri_read_mode_wins_over_saved_metadata() {
483 let mut config = Config::default();
484 config.database_metadata.insert(
485 "database".to_string(),
486 super::super::config::DatabaseMetadata {
487 uri: "oct://devnet/octABC".to_string(),
488 network: "devnet".to_string(),
489 circle: "octABC".to_string(),
490 read_mode: ReadMode::Sealed,
491 ..super::super::config::DatabaseMetadata::default()
492 },
493 );
494 let target =
495 resolve_database_target("oct://devnet/octABC?read_mode=public", &config).unwrap();
496 assert_eq!(target.read_mode, ReadMode::Public);
497 }
498
499 #[test]
500 fn resolve_database_target_rejects_self_alias_cycle() {
501 let mut config = Config::default();
502 config.databases.insert("a".to_string(), "a".to_string());
503 let error = resolve_database_target("a", &config).unwrap_err();
504 assert_eq!(error.kind(), ErrorKind::Config);
505 assert!(error.to_string().contains("a -> a"));
506 }
507
508 #[test]
509 fn resolve_database_target_rejects_multi_alias_cycle() {
510 let mut config = Config::default();
511 config.databases.insert("a".to_string(), "b".to_string());
512 config.databases.insert("b".to_string(), "a".to_string());
513 let error = resolve_database_target("a", &config).unwrap_err();
514 assert_eq!(error.kind(), ErrorKind::Config);
515 assert!(error.to_string().contains("a -> b -> a"));
516 }
517
518 #[test]
519 fn supplied_public_key_must_match_private_key() {
520 let error = match build_session(&ClientOptions {
521 target: Some("oct://devnet/octABC".to_string()),
522 rpc: Some("mock://rpc".to_string()),
523 caller: Some("octCaller".to_string()),
524 private_key: Some(
525 "0101010101010101010101010101010101010101010101010101010101010101".to_string(),
526 ),
527 public_key: Some(general_purpose::STANDARD.encode([2u8; 32])),
528 ..ClientOptions::default()
529 }) {
530 Ok(_) => panic!("mismatched public key should fail"),
531 Err(error) => error,
532 };
533 assert_eq!(error.kind(), ErrorKind::Wallet);
534 assert!(
535 error
536 .to_string()
537 .contains("wallet public key does not match private key")
538 );
539 }
540
541 #[test]
542 fn accepts_explicit_64_byte_keypair_form() {
543 let seed = [3u8; 32];
544 let key = SigningKey::from_bytes(&seed);
545 let public_key = key.verifying_key().to_bytes();
546 let mut keypair = Vec::from(seed);
547 keypair.extend_from_slice(&public_key);
548 let session = build_session(&ClientOptions {
549 target: Some("oct://devnet/octABC".to_string()),
550 rpc: Some("mock://rpc".to_string()),
551 caller: Some("octCaller".to_string()),
552 private_key: Some(hex::encode(keypair)),
553 public_key: Some(general_purpose::STANDARD.encode(public_key)),
554 ..ClientOptions::default()
555 })
556 .unwrap();
557 assert_eq!(
558 session.public_key_b64().unwrap(),
559 general_purpose::STANDARD.encode(public_key)
560 );
561 }
562
563 #[test]
564 fn public_read_preserves_wallet_load_error_for_signed_operations() {
565 let path = std::env::temp_dir().join(format!(
566 "octra-sqlite-invalid-wallet-{}.json",
567 std::process::id()
568 ));
569 std::fs::write(&path, "{").unwrap();
570 let session = build_session(&ClientOptions {
571 target: Some("oct://devnet/octABC?read_mode=public".to_string()),
572 wallet: Some(path.clone()),
573 rpc: Some("mock://rpc".to_string()),
574 ..ClientOptions::default()
575 })
576 .unwrap();
577 let _ = std::fs::remove_file(&path);
578 assert!(
579 session
580 .wallet_load_error()
581 .is_some_and(|error| error.contains("parsing wallet"))
582 );
583 let error = session.intent_public_key().unwrap_err();
584 assert_eq!(error.kind(), ErrorKind::Wallet);
585 assert!(error.to_string().contains("wallet failed to load"));
586 assert!(error.to_string().contains("parsing wallet"));
587 }
588
589 #[test]
590 fn rejects_private_keys_with_ambiguous_length() {
591 let error = signing_key_from_text("0102").unwrap_err();
592 assert_eq!(error.kind(), ErrorKind::Wallet);
593 assert!(
594 error
595 .to_string()
596 .contains("32-byte seed or 64-byte keypair")
597 );
598 }
599}