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
112impl<T: Transport> Database<T> {
113 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 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 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 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 pub fn prepare_write_no_wait(&self, sql: &str) -> Result<PreparedWrite> {
150 self.prepare_write_for(sql, Operation::ExecuteNoWait)
151 }
152
153 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 pub fn sign_write(&self, prepared: &PreparedWrite) -> Result<SignedWrite> {
164 sign_write(&self.session, prepared)
165 }
166
167 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 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 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 pub fn auth_info(&self) -> Result<AuthInfo> {
222 auth_info_with(self.transport.as_ref(), &self.session)
223 }
224
225 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}