Skip to main content

octra_sqlite/client/
database.rs

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/// Configured client used to open Circle-backed SQLite databases.
21#[derive(Clone)]
22pub struct Client<T = HttpTransport> {
23    options: ClientOptions,
24    transport: Arc<T>,
25}
26
27#[cfg(not(feature = "http"))]
28/// Configured client used to open Circle-backed SQLite databases.
29#[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    /// Load the default local config and construct a client with the HTTP transport.
48    ///
49    /// This is fallible because it reads the configured octra-sqlite config
50    /// file. Use [`Client::with_options`] when construction should be purely
51    /// in-memory.
52    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    /// Construct a client from explicit options using the default HTTP transport.
63    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    /// Construct a client from explicit options and a custom transport.
73    pub fn with_transport(options: ClientOptions, transport: T) -> Self {
74        Self {
75            options,
76            transport: Arc::new(transport),
77        }
78    }
79
80    /// Open a database by saved name, Circle ID, or `oct://` URI.
81    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/// Opened Circle-backed SQLite database.
90#[derive(Clone)]
91pub struct Database<T = HttpTransport> {
92    session: Session,
93    transport: Arc<T>,
94}
95
96#[cfg(not(feature = "http"))]
97/// Opened Circle-backed SQLite database.
98#[derive(Clone)]
99pub struct Database<T> {
100    session: Session,
101    transport: Arc<T>,
102}
103
104#[cfg(feature = "http")]
105impl Database<HttpTransport> {
106    /// Open a database directly with the default HTTP transport.
107    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    /// Open a database directly with a custom transport.
122    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    /// Run read-only SQL and return typed rows.
134    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    /// Submit a write and wait for its receipt.
143    pub fn execute(&self, sql: &str) -> Result<ExecuteResult> {
144        ExecuteResult::from_value(self.execute_value(sql, false)?)
145    }
146
147    /// Submit a write without waiting for confirmation.
148    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    /// Prepare a write for later signing and no-wait submission.
180    pub fn prepare_write_no_wait(&self, sql: &str) -> Result<PreparedWrite> {
181        self.prepare_write_for(sql, Operation::ExecuteNoWait)
182    }
183
184    /// Prepare a write for later signing and confirmed execution.
185    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    /// Sign a prepared write with the database session wallet.
210    pub fn sign_write(&self, prepared: &PreparedWrite) -> Result<SignedWrite> {
211        sign_write(&self.session, prepared)
212    }
213
214    /// Submit a signed write without waiting for confirmation.
215    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    /// Submit a signed write and wait for its receipt.
226    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    /// Wait for a submitted transaction receipt.
237    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    /// Read owner-write authorization metadata.
268    pub fn auth_info(&self) -> Result<AuthInfo> {
269        auth_info_with(self.transport.as_ref(), &self.session)
270    }
271
272    /// Read deployed Circle program metadata.
273    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}