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
112impl<T: Transport> Database<T> {
113    /// Open a database directly with a custom transport.
114    pub fn open_with_transport(options: ClientOptions, transport: T) -> Result<Self> {
115        Self::open_with_shared_transport(options, Arc::new(transport))
116    }
117
118    fn open_with_shared_transport(options: ClientOptions, transport: Arc<T>) -> Result<Self> {
119        Ok(Self {
120            session: build_session(&options)?,
121            transport,
122        })
123    }
124
125    /// Run read-only SQL and return typed rows.
126    pub fn query(&self, sql: &str) -> Result<QueryResult> {
127        QueryResult::from_value(query_typed_with(
128            self.transport.as_ref(),
129            &self.session,
130            sql,
131        )?)
132    }
133
134    /// Submit a write and wait for its receipt.
135    pub fn execute(&self, sql: &str) -> Result<ExecuteResult> {
136        let prepared = self.prepare_write(sql)?;
137        let signed = self.sign_write(&prepared)?;
138        self.submit_signed_write_and_wait(signed)
139    }
140
141    /// Submit a write without waiting for confirmation.
142    pub fn execute_no_wait(&self, sql: &str) -> Result<SubmittedTransaction> {
143        let prepared = self.prepare_write_no_wait(sql)?;
144        let signed = self.sign_write(&prepared)?;
145        self.submit_signed_write(signed)
146    }
147
148    /// Prepare a write for later signing and no-wait submission.
149    pub fn prepare_write_no_wait(&self, sql: &str) -> Result<PreparedWrite> {
150        self.prepare_write_for(sql, Operation::ExecuteNoWait)
151    }
152
153    /// Prepare a write for later signing and confirmed execution.
154    pub fn prepare_write(&self, sql: &str) -> Result<PreparedWrite> {
155        self.prepare_write_for(sql, Operation::Execute)
156    }
157
158    fn prepare_write_for(&self, sql: &str, operation: Operation) -> Result<PreparedWrite> {
159        prepare_write_with(self.transport.as_ref(), &self.session, sql, operation)
160    }
161
162    /// Sign a prepared write with the database session wallet.
163    pub fn sign_write(&self, prepared: &PreparedWrite) -> Result<SignedWrite> {
164        sign_write(&self.session, prepared)
165    }
166
167    /// Submit a signed write without waiting for confirmation.
168    pub fn submit_signed_write(&self, signed: SignedWrite) -> Result<SubmittedTransaction> {
169        ensure_submit_mode(&signed, Operation::ExecuteNoWait)?;
170        SubmittedTransaction::from_value(submit_signed_write_with(
171            self.transport.as_ref(),
172            &self.session,
173            signed,
174            true,
175        )?)
176    }
177
178    /// Submit a signed write and wait for its receipt.
179    pub fn submit_signed_write_and_wait(&self, signed: SignedWrite) -> Result<ExecuteResult> {
180        ensure_submit_mode(&signed, Operation::Execute)?;
181        ExecuteResult::from_value(submit_signed_write_with(
182            self.transport.as_ref(),
183            &self.session,
184            signed,
185            false,
186        )?)
187    }
188
189    /// Wait for a submitted transaction receipt.
190    pub fn wait(&self, submitted: &SubmittedTransaction) -> Result<ExecuteResult> {
191        let tx_hash = submitted.tx_hash.as_deref().ok_or_else(|| {
192            Error::with_kind(
193                ErrorKind::Config,
194                "submitted transaction is missing tx_hash",
195            )
196        })?;
197        let receipt = wait_for_receipt_with(self.transport.as_ref(), &self.session, tx_hash)?;
198        let mut value = serde_json::Map::new();
199        if let Some(circle) = &submitted.circle {
200            value.insert(
201                "circle".to_string(),
202                serde_json::Value::String(circle.clone()),
203            );
204        }
205        if let Some(wallet) = &submitted.wallet {
206            value.insert(
207                "wallet".to_string(),
208                serde_json::Value::String(wallet.clone()),
209            );
210        }
211        value.insert(
212            "tx_hash".to_string(),
213            serde_json::Value::String(tx_hash.to_string()),
214        );
215        value.insert("result".to_string(), submitted.result.clone());
216        value.insert("receipt".to_string(), receipt);
217        ExecuteResult::from_value(serde_json::Value::Object(value))
218    }
219
220    /// Read owner-write authorization metadata.
221    pub fn auth_info(&self) -> Result<AuthInfo> {
222        auth_info_with(self.transport.as_ref(), &self.session)
223    }
224
225    /// Read deployed Circle program metadata.
226    pub fn program_info(&self) -> Result<ProgramInfo> {
227        ProgramInfo::from_value(program_info_with(self.transport.as_ref(), &self.session)?)
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::super::write::sign_and_submit_tx_with;
234    use super::*;
235    use crate::client::{Error, ErrorKind};
236    use crate::protocol::tx::Tx;
237    use serde_json::{Value, json};
238    use std::sync::{Arc, Mutex};
239
240    #[derive(Clone)]
241    struct MockTransport {
242        calls: Arc<Mutex<Vec<String>>>,
243        receipt: Arc<Mutex<Value>>,
244        public_info: bool,
245    }
246
247    impl Default for MockTransport {
248        fn default() -> Self {
249            Self {
250                calls: Arc::new(Mutex::new(Vec::new())),
251                receipt: Arc::new(Mutex::new(json!({
252                    "success": true,
253                    "error": null,
254                    "method": "exec",
255                }))),
256                public_info: false,
257            }
258        }
259    }
260
261    impl MockTransport {
262        fn with_receipt(receipt: Value) -> Self {
263            Self {
264                receipt: Arc::new(Mutex::new(receipt)),
265                ..Self::default()
266            }
267        }
268
269        fn public_read_circle() -> Self {
270            Self {
271                public_info: true,
272                ..Self::default()
273            }
274        }
275    }
276
277    impl Transport for MockTransport {
278        fn call(&self, _rpc: &str, method: &str, params: Value) -> Result<Value> {
279            self.calls.lock().unwrap().push(method.to_string());
280            match method {
281                "octra_circleInfo" => {
282                    if self.public_info {
283                        Ok(json!({
284                            "privacy_class": "public",
285                            "browser_mode": "gateway_allowed",
286                            "resource_mode": "public_resources",
287                        }))
288                    } else {
289                        Ok(json!({
290                            "privacy_class": "sealed",
291                            "browser_mode": "native_sealed",
292                            "resource_mode": "sealed_read",
293                        }))
294                    }
295                }
296                "octra_circleViewAuth" => {
297                    let circle_method = params
298                        .as_array()
299                        .and_then(|params| params.get(1))
300                        .and_then(Value::as_str)
301                        .unwrap_or_default();
302                    if circle_method == "auth_info" {
303                        return Ok(json!({
304                            "configured": true,
305                            "db_id": "1111111111111111111111111111111111111111111111111111111111111111",
306                        }));
307                    }
308                    let vector: Value =
309                        serde_json::from_str(include_str!("../../tests/fixtures/osr1/basic.json"))
310                            .unwrap();
311                    Ok(Value::String(format!(
312                        "OSR1:{}",
313                        vector["payload_b64"].as_str().unwrap()
314                    )))
315                }
316                "octra_circleView" => {
317                    let vector: Value =
318                        serde_json::from_str(include_str!("../../tests/fixtures/osr1/basic.json"))
319                            .unwrap();
320                    Ok(Value::String(format!(
321                        "OSR1:{}",
322                        vector["payload_b64"].as_str().unwrap()
323                    )))
324                }
325                "octra_balance" => Ok(json!({ "pending_nonce": 41 })),
326                "octra_submit" => Ok(json!({ "tx_hash": "abc123" })),
327                "contract_receipt" => Ok(self.receipt.lock().unwrap().clone()),
328                _ => Err(Error::with_kind(
329                    ErrorKind::Other,
330                    format!("unexpected method {method}"),
331                )),
332            }
333        }
334    }
335
336    struct ContractErrorTransport;
337
338    impl Transport for ContractErrorTransport {
339        fn call(&self, _rpc: &str, method: &str, _params: Value) -> Result<Value> {
340            match method {
341                "octra_circleViewAuth" => Ok(Value::String(
342                    r#"{"ok":false,"error":"sqlite_prepare_failed","detail":"no such table: companion"}"#.to_string(),
343                )),
344                _ => Err(Error::with_kind(
345                    ErrorKind::Other,
346                    format!("unexpected method {method}"),
347                )),
348            }
349        }
350    }
351
352    fn test_options() -> ClientOptions {
353        ClientOptions {
354            target: Some("oct://devnet/octABC?read_mode=sealed".to_string()),
355            rpc: Some("mock://rpc".to_string()),
356            caller: Some("octCaller".to_string()),
357            private_key: Some(
358                "0101010101010101010101010101010101010101010101010101010101010101".to_string(),
359            ),
360            ..ClientOptions::default()
361        }
362    }
363
364    fn test_options_for(target: &str) -> ClientOptions {
365        ClientOptions {
366            target: Some(target.to_string()),
367            ..test_options()
368        }
369    }
370
371    #[test]
372    fn database_query_uses_transport_and_returns_typed_rows() {
373        let transport = MockTransport::default();
374        let calls = transport.calls.clone();
375        let db = Database::open_with_transport(test_options(), transport).unwrap();
376        let result = db.query("select * from demo").unwrap();
377        assert_eq!(result.columns[0], "nil");
378        assert_eq!(result.row_count, 1);
379        assert_eq!(calls.lock().unwrap().as_slice(), ["octra_circleViewAuth"]);
380    }
381
382    #[test]
383    fn public_database_query_uses_unsigned_circle_view() {
384        let transport = MockTransport::default();
385        let calls = transport.calls.clone();
386        let db = Database::open_with_transport(
387            ClientOptions {
388                target: Some("oct://devnet/octABC?read_mode=public".to_string()),
389                rpc: Some("mock://rpc".to_string()),
390                ..ClientOptions::default()
391            },
392            transport,
393        )
394        .unwrap();
395        let result = db.query("select * from demo").unwrap();
396        assert_eq!(result.row_count, 1);
397        assert_eq!(calls.lock().unwrap().as_slice(), ["octra_circleView"]);
398    }
399
400    #[test]
401    fn auto_database_query_detects_public_circle_without_wallet() {
402        let transport = MockTransport::public_read_circle();
403        let calls = transport.calls.clone();
404        let db = Database::open_with_transport(
405            ClientOptions {
406                target: Some("oct://devnet/octABC".to_string()),
407                rpc: Some("mock://rpc".to_string()),
408                ..ClientOptions::default()
409            },
410            transport,
411        )
412        .unwrap();
413        let result = db.query("select * from demo").unwrap();
414        assert_eq!(result.row_count, 1);
415        assert_eq!(
416            calls.lock().unwrap().as_slice(),
417            ["octra_circleInfo", "octra_circleView"]
418        );
419    }
420
421    #[test]
422    fn database_query_surfaces_contract_sql_errors() {
423        let db = Database::open_with_transport(test_options(), ContractErrorTransport).unwrap();
424        let error = db.query("select * from companion;").unwrap_err();
425        assert_eq!(error.kind(), ErrorKind::Rpc);
426        assert!(error.to_string().contains("sqlite_prepare_failed"));
427        assert!(error.to_string().contains("no such table: companion"));
428    }
429
430    #[test]
431    fn database_execute_errors_on_failed_receipt() {
432        let transport = MockTransport::with_receipt(json!({
433            "success": false,
434            "error": "near \"bad\": syntax error",
435            "method": "exec",
436        }));
437        let db = Database::open_with_transport(test_options(), transport).unwrap();
438        let error = db.execute("bad sql").unwrap_err();
439        assert_eq!(error.kind(), ErrorKind::Receipt);
440        assert!(error.to_string().contains("syntax error"));
441        assert!(error.to_string().contains("tx_hash: abc123"));
442    }
443
444    #[test]
445    fn signed_write_submit_mode_must_match_prepare_mode() {
446        let transport = MockTransport::default();
447        let db = Database::open_with_transport(test_options(), transport).unwrap();
448        let prepared = db.prepare_write("create table demo(id integer);").unwrap();
449        let signed = db.sign_write(&prepared).unwrap();
450        let error = db.submit_signed_write(signed).unwrap_err();
451        assert_eq!(error.kind(), ErrorKind::Config);
452    }
453
454    #[test]
455    fn generic_tx_submit_allows_deploy_destination() {
456        let transport = MockTransport::default();
457        let calls = transport.calls.clone();
458        let session = build_session(&test_options()).unwrap();
459        let tx = Tx {
460            from: session.caller().to_string(),
461            to_: "octNewCircle".to_string(),
462            amount: "0".to_string(),
463            nonce: 42,
464            ou: "1000".to_string(),
465            timestamp: 1000.0,
466            op_type: "deploy_circle".to_string(),
467            encrypted_data: String::new(),
468            message: "{}".to_string(),
469            signature: String::new(),
470            public_key: session.public_key_b64().unwrap().to_string(),
471        };
472        let result = sign_and_submit_tx_with(&transport, &session, tx, true).unwrap();
473        assert_eq!(result["circle"], "octNewCircle");
474        assert_eq!(result["tx_hash"], "abc123");
475        assert_eq!(calls.lock().unwrap().as_slice(), ["octra_submit"]);
476    }
477
478    #[test]
479    fn prepared_write_must_match_signing_database() {
480        let db_a = Database::open_with_transport(
481            test_options_for("oct://devnet/octABC"),
482            MockTransport::default(),
483        )
484        .unwrap();
485        let db_b = Database::open_with_transport(
486            test_options_for("oct://devnet/octDEF"),
487            MockTransport::default(),
488        )
489        .unwrap();
490        let prepared = db_a
491            .prepare_write("create table demo(id integer);")
492            .unwrap();
493        let error = db_b.sign_write(&prepared).unwrap_err();
494        assert_eq!(error.kind(), ErrorKind::Authorization);
495    }
496}