1#[cfg(feature = "http")]
2use super::transport::HttpTransport;
3use super::{
4 error::{Error, ErrorKind, Result},
5 results::{AuthInfo, ExecuteResult, ProgramInfo, QueryResult, SubmittedTransaction},
6 rpc::{auth_info_with, program_info_with, query_typed_with, wait_for_receipt_with},
7 safety::Operation,
8 session::{ClientOptions, Session, build_session},
9 transport::Transport,
10 write::{
11 PreparedWrite, SignedWrite, ensure_submit_mode, prepare_write_with, sign_write,
12 submit_signed_write_with,
13 },
14};
15#[cfg(feature = "http")]
16use std::path::PathBuf;
17use std::sync::Arc;
18
19#[cfg(feature = "http")]
20#[derive(Clone)]
22pub struct Client<T = HttpTransport> {
23 options: ClientOptions,
24 transport: Arc<T>,
25}
26
27#[cfg(not(feature = "http"))]
28#[derive(Clone)]
30pub struct Client<T> {
31 options: ClientOptions,
32 transport: Arc<T>,
33}
34
35#[cfg(feature = "http")]
36impl Default for Client<HttpTransport> {
37 fn default() -> Self {
38 Self {
39 options: ClientOptions::default(),
40 transport: Arc::new(HttpTransport::default()),
41 }
42 }
43}
44
45#[cfg(feature = "http")]
46impl Client<HttpTransport> {
47 pub fn from_default_config() -> Result<Self> {
53 let config = super::config::load_config()?;
54 let options = ClientOptions {
55 target: config.default_database.clone(),
56 wallet: config.wallet.as_ref().map(PathBuf::from),
57 ..ClientOptions::default()
58 };
59 Ok(Self::with_options(options))
60 }
61
62 pub fn with_options(options: ClientOptions) -> Self {
64 Self {
65 options,
66 transport: Arc::new(HttpTransport::default()),
67 }
68 }
69}
70
71impl<T: Transport> Client<T> {
72 pub fn with_transport(options: ClientOptions, transport: T) -> Self {
74 Self {
75 options,
76 transport: Arc::new(transport),
77 }
78 }
79
80 pub fn database(&self, target: impl Into<String>) -> Result<Database<T>> {
82 let mut options = self.options.clone();
83 options.target = Some(target.into());
84 Database::open_with_shared_transport(options, Arc::clone(&self.transport))
85 }
86}
87
88#[cfg(feature = "http")]
89#[derive(Clone)]
91pub struct Database<T = HttpTransport> {
92 session: Session,
93 transport: Arc<T>,
94}
95
96#[cfg(not(feature = "http"))]
97#[derive(Clone)]
99pub struct Database<T> {
100 session: Session,
101 transport: Arc<T>,
102}
103
104#[cfg(feature = "http")]
105impl Database<HttpTransport> {
106 pub fn open(options: ClientOptions) -> Result<Self> {
108 Self::open_with_transport(options, HttpTransport::default())
109 }
110
111 #[cfg(feature = "cli")]
112 pub(crate) fn from_session(session: Session) -> Self {
113 Self {
114 session,
115 transport: Arc::new(HttpTransport::default()),
116 }
117 }
118}
119
120impl<T: Transport> Database<T> {
121 pub fn open_with_transport(options: ClientOptions, transport: T) -> Result<Self> {
123 Self::open_with_shared_transport(options, Arc::new(transport))
124 }
125
126 fn open_with_shared_transport(options: ClientOptions, transport: Arc<T>) -> Result<Self> {
127 Ok(Self {
128 session: build_session(&options)?,
129 transport,
130 })
131 }
132
133 pub fn query(&self, sql: &str) -> Result<QueryResult> {
135 QueryResult::from_value(self.query_value(sql)?)
136 }
137
138 pub(crate) fn query_value(&self, sql: &str) -> Result<serde_json::Value> {
139 query_typed_with(self.transport.as_ref(), &self.session, sql)
140 }
141
142 pub fn execute(&self, sql: &str) -> Result<ExecuteResult> {
144 ExecuteResult::from_value(self.execute_value(sql, false)?)
145 }
146
147 pub fn execute_no_wait(&self, sql: &str) -> Result<SubmittedTransaction> {
149 SubmittedTransaction::from_value(self.execute_value(sql, true)?)
150 }
151
152 pub(crate) fn execute_value(&self, sql: &str, no_wait: bool) -> Result<serde_json::Value> {
153 let prepared = if no_wait {
154 self.prepare_write_no_wait(sql)?
155 } else {
156 self.prepare_write(sql)?
157 };
158 let signed = self.sign_write(&prepared)?;
159 submit_signed_write_with(self.transport.as_ref(), &self.session, signed, no_wait)
160 }
161
162 #[cfg(feature = "cli")]
163 pub(crate) fn execute_value_with_ou(
164 &self,
165 sql: &str,
166 no_wait: bool,
167 ou: &str,
168 ) -> Result<serde_json::Value> {
169 let operation = if no_wait {
170 Operation::ExecuteNoWait
171 } else {
172 Operation::Execute
173 };
174 let prepared = self.prepare_write_for_with_ou(sql, operation, ou)?;
175 let signed = self.sign_write(&prepared)?;
176 submit_signed_write_with(self.transport.as_ref(), &self.session, signed, no_wait)
177 }
178
179 pub fn prepare_write_no_wait(&self, sql: &str) -> Result<PreparedWrite> {
181 self.prepare_write_for(sql, Operation::ExecuteNoWait)
182 }
183
184 pub fn prepare_write(&self, sql: &str) -> Result<PreparedWrite> {
186 self.prepare_write_for(sql, Operation::Execute)
187 }
188
189 fn prepare_write_for(&self, sql: &str, operation: Operation) -> Result<PreparedWrite> {
190 prepare_write_with(self.transport.as_ref(), &self.session, sql, operation)
191 }
192
193 #[cfg(feature = "cli")]
194 fn prepare_write_for_with_ou(
195 &self,
196 sql: &str,
197 operation: Operation,
198 ou: &str,
199 ) -> Result<PreparedWrite> {
200 super::write::prepare_write_with_ou(
201 self.transport.as_ref(),
202 &self.session,
203 sql,
204 operation,
205 ou,
206 )
207 }
208
209 pub fn sign_write(&self, prepared: &PreparedWrite) -> Result<SignedWrite> {
211 sign_write(&self.session, prepared)
212 }
213
214 pub fn submit_signed_write(&self, signed: SignedWrite) -> Result<SubmittedTransaction> {
216 ensure_submit_mode(&signed, Operation::ExecuteNoWait)?;
217 SubmittedTransaction::from_value(submit_signed_write_with(
218 self.transport.as_ref(),
219 &self.session,
220 signed,
221 true,
222 )?)
223 }
224
225 pub fn submit_signed_write_and_wait(&self, signed: SignedWrite) -> Result<ExecuteResult> {
227 ensure_submit_mode(&signed, Operation::Execute)?;
228 ExecuteResult::from_value(submit_signed_write_with(
229 self.transport.as_ref(),
230 &self.session,
231 signed,
232 false,
233 )?)
234 }
235
236 pub fn wait(&self, submitted: &SubmittedTransaction) -> Result<ExecuteResult> {
238 let tx_hash = submitted.tx_hash.as_deref().ok_or_else(|| {
239 Error::with_kind(
240 ErrorKind::Config,
241 "submitted transaction is missing tx_hash",
242 )
243 })?;
244 let receipt = wait_for_receipt_with(self.transport.as_ref(), &self.session, tx_hash)?;
245 let mut value = serde_json::Map::new();
246 if let Some(circle) = &submitted.circle {
247 value.insert(
248 "circle".to_string(),
249 serde_json::Value::String(circle.clone()),
250 );
251 }
252 if let Some(wallet) = &submitted.wallet {
253 value.insert(
254 "wallet".to_string(),
255 serde_json::Value::String(wallet.clone()),
256 );
257 }
258 value.insert(
259 "tx_hash".to_string(),
260 serde_json::Value::String(tx_hash.to_string()),
261 );
262 value.insert("result".to_string(), submitted.result.clone());
263 value.insert("receipt".to_string(), receipt);
264 ExecuteResult::from_value(serde_json::Value::Object(value))
265 }
266
267 pub fn auth_info(&self) -> Result<AuthInfo> {
269 auth_info_with(self.transport.as_ref(), &self.session)
270 }
271
272 pub fn program_info(&self) -> Result<ProgramInfo> {
274 ProgramInfo::from_value(program_info_with(self.transport.as_ref(), &self.session)?)
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::super::write::sign_and_submit_tx_with;
281 use super::*;
282 use crate::client::{Error, ErrorKind};
283 use crate::protocol::tx::Tx;
284 use serde_json::{Value, json};
285 use std::sync::{Arc, Mutex};
286
287 #[derive(Clone)]
288 struct MockTransport {
289 calls: Arc<Mutex<Vec<String>>>,
290 receipt: Arc<Mutex<Value>>,
291 public_info: bool,
292 }
293
294 impl Default for MockTransport {
295 fn default() -> Self {
296 Self {
297 calls: Arc::new(Mutex::new(Vec::new())),
298 receipt: Arc::new(Mutex::new(json!({
299 "success": true,
300 "error": null,
301 "method": "exec",
302 }))),
303 public_info: false,
304 }
305 }
306 }
307
308 impl MockTransport {
309 fn with_receipt(receipt: Value) -> Self {
310 Self {
311 receipt: Arc::new(Mutex::new(receipt)),
312 ..Self::default()
313 }
314 }
315
316 fn public_read_circle() -> Self {
317 Self {
318 public_info: true,
319 ..Self::default()
320 }
321 }
322 }
323
324 impl Transport for MockTransport {
325 fn call(&self, _rpc: &str, method: &str, params: Value) -> Result<Value> {
326 self.calls.lock().unwrap().push(method.to_string());
327 match method {
328 "octra_circleInfo" => {
329 if self.public_info {
330 Ok(json!({
331 "privacy_class": "public",
332 "browser_mode": "gateway_allowed",
333 "resource_mode": "public_resources",
334 }))
335 } else {
336 Ok(json!({
337 "privacy_class": "sealed",
338 "browser_mode": "native_sealed",
339 "resource_mode": "sealed_read",
340 }))
341 }
342 }
343 "octra_circleViewAuth" => {
344 let circle_method = params
345 .as_array()
346 .and_then(|params| params.get(1))
347 .and_then(Value::as_str)
348 .unwrap_or_default();
349 if circle_method == "auth_info" {
350 return Ok(json!({
351 "configured": true,
352 "db_id": "1111111111111111111111111111111111111111111111111111111111111111",
353 }));
354 }
355 let vector: Value =
356 serde_json::from_str(include_str!("../../tests/fixtures/osr1/basic.json"))
357 .unwrap();
358 Ok(Value::String(format!(
359 "OSR1:{}",
360 vector["payload_b64"].as_str().unwrap()
361 )))
362 }
363 "octra_circleView" => {
364 let vector: Value =
365 serde_json::from_str(include_str!("../../tests/fixtures/osr1/basic.json"))
366 .unwrap();
367 Ok(Value::String(format!(
368 "OSR1:{}",
369 vector["payload_b64"].as_str().unwrap()
370 )))
371 }
372 "octra_circleProgramInfo" | "octra_circleProgramInfoAuth" => Ok(json!({
373 "version": "wasm-v1",
374 "code_hash": "abc",
375 "code_bytes": 123,
376 })),
377 "octra_balance" => Ok(json!({ "pending_nonce": 41 })),
378 "octra_submit" => Ok(json!({ "tx_hash": "abc123" })),
379 "contract_receipt" => Ok(self.receipt.lock().unwrap().clone()),
380 _ => Err(Error::with_kind(
381 ErrorKind::Other,
382 format!("unexpected method {method}"),
383 )),
384 }
385 }
386 }
387
388 struct ContractErrorTransport;
389
390 impl Transport for ContractErrorTransport {
391 fn call(&self, _rpc: &str, method: &str, _params: Value) -> Result<Value> {
392 match method {
393 "octra_circleViewAuth" => Ok(Value::String(
394 r#"{"ok":false,"error":"sqlite_prepare_failed","detail":"no such table: companion"}"#.to_string(),
395 )),
396 _ => Err(Error::with_kind(
397 ErrorKind::Other,
398 format!("unexpected method {method}"),
399 )),
400 }
401 }
402 }
403
404 fn test_options() -> ClientOptions {
405 ClientOptions {
406 target: Some("oct://devnet/octABC?read_mode=sealed".to_string()),
407 rpc: Some("mock://rpc".to_string()),
408 caller: Some("octCaller".to_string()),
409 private_key: Some(
410 "0101010101010101010101010101010101010101010101010101010101010101".to_string(),
411 ),
412 ..ClientOptions::default()
413 }
414 }
415
416 fn test_options_for(target: &str) -> ClientOptions {
417 ClientOptions {
418 target: Some(target.to_string()),
419 ..test_options()
420 }
421 }
422
423 #[test]
424 fn database_query_uses_transport_and_returns_typed_rows() {
425 let transport = MockTransport::default();
426 let calls = transport.calls.clone();
427 let db = Database::open_with_transport(test_options(), transport).unwrap();
428 let result = db.query("select * from demo").unwrap();
429 assert_eq!(result.columns[0], "nil");
430 assert_eq!(result.row_count, 1);
431 assert_eq!(calls.lock().unwrap().as_slice(), ["octra_circleViewAuth"]);
432 }
433
434 #[test]
435 fn public_database_query_uses_unsigned_circle_view() {
436 let transport = MockTransport::default();
437 let calls = transport.calls.clone();
438 let db = Database::open_with_transport(
439 ClientOptions {
440 target: Some("oct://devnet/octABC?read_mode=public".to_string()),
441 rpc: Some("mock://rpc".to_string()),
442 ..ClientOptions::default()
443 },
444 transport,
445 )
446 .unwrap();
447 let result = db.query("select * from demo").unwrap();
448 assert_eq!(result.row_count, 1);
449 assert_eq!(calls.lock().unwrap().as_slice(), ["octra_circleView"]);
450 }
451
452 #[test]
453 fn auto_database_query_detects_public_circle_without_wallet() {
454 let transport = MockTransport::public_read_circle();
455 let calls = transport.calls.clone();
456 let db = Database::open_with_transport(
457 ClientOptions {
458 target: Some("oct://devnet/octABC".to_string()),
459 rpc: Some("mock://rpc".to_string()),
460 ..ClientOptions::default()
461 },
462 transport,
463 )
464 .unwrap();
465 let result = db.query("select * from demo").unwrap();
466 assert_eq!(result.row_count, 1);
467 assert_eq!(
468 calls.lock().unwrap().as_slice(),
469 ["octra_circleInfo", "octra_circleView"]
470 );
471 }
472
473 #[test]
474 fn public_program_info_uses_unsigned_rpc_without_wallet() {
475 let transport = MockTransport::default();
476 let calls = transport.calls.clone();
477 let db = Database::open_with_transport(
478 ClientOptions {
479 target: Some("oct://devnet/octABC?read_mode=public".to_string()),
480 rpc: Some("mock://rpc".to_string()),
481 ..ClientOptions::default()
482 },
483 transport,
484 )
485 .unwrap();
486 let info = db.program_info().unwrap();
487 assert_eq!(info.code_hash.as_deref(), Some("abc"));
488 assert_eq!(
489 calls.lock().unwrap().as_slice(),
490 ["octra_circleProgramInfo"]
491 );
492 }
493
494 #[test]
495 fn auto_program_info_detects_public_circle_without_wallet() {
496 let transport = MockTransport::public_read_circle();
497 let calls = transport.calls.clone();
498 let db = Database::open_with_transport(
499 ClientOptions {
500 target: Some("oct://devnet/octABC".to_string()),
501 rpc: Some("mock://rpc".to_string()),
502 ..ClientOptions::default()
503 },
504 transport,
505 )
506 .unwrap();
507 assert_eq!(db.program_info().unwrap().code_hash.as_deref(), Some("abc"));
508 assert_eq!(
509 calls.lock().unwrap().as_slice(),
510 ["octra_circleInfo", "octra_circleProgramInfo"]
511 );
512 }
513
514 #[test]
515 fn database_query_surfaces_contract_sql_errors() {
516 let db = Database::open_with_transport(test_options(), ContractErrorTransport).unwrap();
517 let error = db.query("select * from companion;").unwrap_err();
518 assert_eq!(error.kind(), ErrorKind::Rpc);
519 assert!(error.to_string().contains("sqlite_prepare_failed"));
520 assert!(error.to_string().contains("no such table: companion"));
521 }
522
523 #[test]
524 fn database_execute_errors_on_failed_receipt() {
525 let transport = MockTransport::with_receipt(json!({
526 "success": false,
527 "error": "near \"bad\": syntax error",
528 "method": "exec",
529 }));
530 let db = Database::open_with_transport(test_options(), transport).unwrap();
531 let error = db.execute("bad sql").unwrap_err();
532 assert_eq!(error.kind(), ErrorKind::Receipt);
533 assert!(error.to_string().contains("syntax error"));
534 assert!(error.to_string().contains("tx_hash: abc123"));
535 }
536
537 #[test]
538 fn database_execute_preserves_receipt_error_code_with_tx_context() {
539 let transport = MockTransport::with_receipt(json!({
540 "success": true,
541 "error": null,
542 "events": [{
543 "event": "octra.sqlite.error",
544 "values": ["exec_budget_exceeded:statement exceeded execution budget"]
545 }]
546 }));
547 let db = Database::open_with_transport(test_options(), transport).unwrap();
548 let error = db.execute("select expensive_work();").unwrap_err();
549 assert_eq!(error.kind(), ErrorKind::Receipt);
550 assert_eq!(error.code(), Some("exec_budget_exceeded"));
551 assert!(error.to_string().contains("execution budget"));
552 assert!(error.to_string().contains("tx_hash: abc123"));
553 }
554
555 #[test]
556 fn signed_write_submit_mode_must_match_prepare_mode() {
557 let transport = MockTransport::default();
558 let db = Database::open_with_transport(test_options(), transport).unwrap();
559 let prepared = db.prepare_write("create table demo(id integer);").unwrap();
560 let signed = db.sign_write(&prepared).unwrap();
561 let error = db.submit_signed_write(signed).unwrap_err();
562 assert_eq!(error.kind(), ErrorKind::Config);
563 }
564
565 #[test]
566 fn generic_tx_submit_allows_deploy_destination() {
567 let transport = MockTransport::default();
568 let calls = transport.calls.clone();
569 let session = build_session(&test_options()).unwrap();
570 let tx = Tx {
571 from: session.caller().to_string(),
572 to_: "octNewCircle".to_string(),
573 amount: "0".to_string(),
574 nonce: 42,
575 ou: "1000".to_string(),
576 timestamp: 1000.0,
577 op_type: "deploy_circle".to_string(),
578 encrypted_data: String::new(),
579 message: "{}".to_string(),
580 signature: String::new(),
581 public_key: session.public_key_b64().unwrap().to_string(),
582 };
583 let result = sign_and_submit_tx_with(&transport, &session, tx, true).unwrap();
584 assert_eq!(result["circle"], "octNewCircle");
585 assert_eq!(result["tx_hash"], "abc123");
586 assert_eq!(calls.lock().unwrap().as_slice(), ["octra_submit"]);
587 }
588
589 #[test]
590 fn prepared_write_must_match_signing_database() {
591 let db_a = Database::open_with_transport(
592 test_options_for("oct://devnet/octABC"),
593 MockTransport::default(),
594 )
595 .unwrap();
596 let db_b = Database::open_with_transport(
597 test_options_for("oct://devnet/octDEF"),
598 MockTransport::default(),
599 )
600 .unwrap();
601 let prepared = db_a
602 .prepare_write("create table demo(id integer);")
603 .unwrap();
604 let error = db_b.sign_write(&prepared).unwrap_err();
605 assert_eq!(error.kind(), ErrorKind::Authorization);
606 }
607}