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 {
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}