1use super::{
2 error::{Error, ErrorKind, Result},
3 results::ensure_receipt_success,
4 rpc::{auth_info_with, compact_json, next_nonce_with, rpc_call, wait_for_receipt_with},
5 safety::{Operation, OperationSafety},
6 session::Session,
7 transport::Transport,
8};
9use crate::protocol::{
10 osw1,
11 tx::{Tx, canonical_tx},
12};
13use serde_json::{Map, Value, json};
14use std::env;
15use std::fmt;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18pub(super) const DEFAULT_WRITE_OU: &str = "1000";
19
20#[derive(Clone, PartialEq)]
22pub struct PreparedWrite {
23 sql: String,
24 method: String,
25 nonce: i64,
26 timestamp: f64,
27 circle: String,
28 wallet: String,
29 public_key: String,
30 ou: String,
31 owner_write: PreparedOwnerWrite,
32 safety: OperationSafety,
33}
34
35impl PreparedWrite {
36 pub fn sql(&self) -> &str {
38 &self.sql
39 }
40
41 pub fn method(&self) -> &str {
43 &self.method
44 }
45
46 pub fn nonce(&self) -> i64 {
48 self.nonce
49 }
50
51 pub fn timestamp(&self) -> f64 {
53 self.timestamp
54 }
55
56 pub fn circle(&self) -> &str {
58 &self.circle
59 }
60
61 pub fn wallet(&self) -> &str {
63 &self.wallet
64 }
65
66 pub fn public_key(&self) -> &str {
68 &self.public_key
69 }
70
71 pub fn ou(&self) -> &str {
73 &self.ou
74 }
75
76 pub fn owner_write(&self) -> &PreparedOwnerWrite {
78 &self.owner_write
79 }
80
81 pub fn safety(&self) -> OperationSafety {
83 self.safety
84 }
85}
86
87impl fmt::Debug for PreparedWrite {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 f.debug_struct("PreparedWrite")
90 .field("method", &self.method)
91 .field("nonce", &self.nonce)
92 .field("circle", &self.circle)
93 .field("wallet", &self.wallet)
94 .field("ou", &self.ou)
95 .field("owner_write", &self.owner_write)
96 .field("safety", &self.safety)
97 .finish_non_exhaustive()
98 }
99}
100
101#[derive(Clone, PartialEq, Eq)]
103pub struct PreparedOwnerWrite {
104 db_id: String,
105 owner_pubkey: String,
106 sequence: u64,
107 frame_hex: String,
108}
109
110impl PreparedOwnerWrite {
111 pub fn db_id(&self) -> &str {
113 &self.db_id
114 }
115
116 pub fn owner_pubkey(&self) -> &str {
118 &self.owner_pubkey
119 }
120
121 pub fn sequence(&self) -> u64 {
123 self.sequence
124 }
125
126 pub fn frame_hex(&self) -> &str {
128 &self.frame_hex
129 }
130}
131
132impl fmt::Debug for PreparedOwnerWrite {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 f.debug_struct("PreparedOwnerWrite")
135 .field("db_id", &self.db_id)
136 .field("owner_pubkey", &self.owner_pubkey)
137 .field("sequence", &self.sequence)
138 .field("frame_hex", &"<redacted>")
139 .finish()
140 }
141}
142
143#[derive(Clone, PartialEq)]
145pub struct SignedWrite {
146 tx: Tx,
147 safety: OperationSafety,
148}
149
150impl SignedWrite {
151 pub fn tx(&self) -> &Tx {
153 &self.tx
154 }
155
156 pub fn safety(&self) -> OperationSafety {
158 self.safety
159 }
160
161 pub fn into_tx(self) -> Tx {
163 self.tx
164 }
165
166 pub(super) fn new(tx: Tx, safety: OperationSafety) -> Self {
167 Self { tx, safety }
168 }
169}
170
171impl fmt::Debug for SignedWrite {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 f.debug_struct("SignedWrite")
174 .field("circle", &self.tx.to_)
175 .field("wallet", &self.tx.from)
176 .field("nonce", &self.tx.nonce)
177 .field("method", &self.tx.encrypted_data)
178 .field("safety", &self.safety)
179 .finish_non_exhaustive()
180 }
181}
182
183pub(super) fn ensure_submit_mode(signed: &SignedWrite, expected: Operation) -> Result<()> {
184 let actual = signed.safety.operation;
185 if actual != expected {
186 return Err(Error::with_kind(
187 ErrorKind::Config,
188 format!("signed write was prepared for {actual:?}, not {expected:?}"),
189 ));
190 }
191 Ok(())
192}
193
194pub(super) fn prepare_write_with<T: Transport>(
195 transport: &T,
196 session: &Session,
197 sql: &str,
198 operation: Operation,
199) -> Result<PreparedWrite> {
200 prepare_write_with_ou(transport, session, sql, operation, DEFAULT_WRITE_OU)
201}
202
203pub(super) fn prepare_write_with_ou<T: Transport>(
204 transport: &T,
205 session: &Session,
206 sql: &str,
207 operation: Operation,
208 ou: &str,
209) -> Result<PreparedWrite> {
210 let nonce = next_nonce_with(transport, session)?;
211 let timestamp = now_timestamp();
212 let method = if trace_sql_event_enabled() {
213 "exec_trace"
214 } else {
215 "exec"
216 };
217 let auth = auth_info_with(transport, session).map_err(|error| {
218 error.with_context(
219 "could not read Circle auth_info; refusing to choose unsigned exec implicitly",
220 )
221 })?;
222 if !auth.configured {
223 return Err(Error::with_kind(
224 ErrorKind::Authorization,
225 "database is not owner-write-personalized; refusing unsigned SQL write",
226 ));
227 }
228 prepare_write_with_owner_parts(
229 session,
230 sql,
231 PreparedWriteContext {
232 operation,
233 ou,
234 nonce,
235 timestamp,
236 method,
237 },
238 OwnerWriteAuth {
239 db_id: &auth.db_id,
240 owner_pubkey: auth.owner_pubkey.as_deref(),
241 },
242 )
243}
244
245#[cfg(all(feature = "cli", feature = "http"))]
246pub(super) fn prepare_write_with_owner_auth<T: Transport>(
247 transport: &T,
248 session: &Session,
249 sql: &str,
250 operation: Operation,
251 db_id: &str,
252 owner_pubkey: &str,
253 ou: &str,
254) -> Result<PreparedWrite> {
255 let nonce = next_nonce_with(transport, session)?;
256 let timestamp = now_timestamp();
257 let method = if trace_sql_event_enabled() {
258 "exec_trace"
259 } else {
260 "exec"
261 };
262 prepare_write_with_owner_parts(
263 session,
264 sql,
265 PreparedWriteContext {
266 operation,
267 ou,
268 nonce,
269 timestamp,
270 method,
271 },
272 OwnerWriteAuth {
273 db_id,
274 owner_pubkey: Some(owner_pubkey),
275 },
276 )
277}
278
279struct OwnerWriteAuth<'a> {
280 db_id: &'a str,
281 owner_pubkey: Option<&'a str>,
282}
283
284struct PreparedWriteContext<'a> {
285 operation: Operation,
286 ou: &'a str,
287 nonce: i64,
288 timestamp: f64,
289 method: &'a str,
290}
291
292fn prepare_write_with_owner_parts(
293 session: &Session,
294 sql: &str,
295 context: PreparedWriteContext<'_>,
296 auth: OwnerWriteAuth<'_>,
297) -> Result<PreparedWrite> {
298 let ou = normalize_write_ou("write OU", context.ou)?;
299 let db_id_bytes = hex_to_32("db_id", auth.db_id)?;
300 let session_owner_pubkey = session.intent_public_key()?;
301 let owner_pubkey = match auth.owner_pubkey {
302 Some(owner_pubkey) => {
303 let configured = hex_to_32("owner_pubkey", owner_pubkey)?;
304 if configured != session_owner_pubkey {
305 return Err(Error::with_kind(
306 ErrorKind::Authorization,
307 "owner write metadata does not match the active wallet",
308 ));
309 }
310 hex::encode(configured)
311 }
312 None => hex::encode(session_owner_pubkey),
313 };
314 let frame = osw1::frame(&db_id_bytes, context.nonce as u64, context.method, sql)?;
315 let owner_write = PreparedOwnerWrite {
316 db_id: auth.db_id.to_string(),
317 owner_pubkey,
318 sequence: context.nonce as u64,
319 frame_hex: hex::encode(frame),
320 };
321 Ok(PreparedWrite {
322 sql: sql.to_string(),
323 method: context.method.to_string(),
324 nonce: context.nonce,
325 timestamp: context.timestamp,
326 circle: session.target().circle.clone(),
327 wallet: session.caller().to_string(),
328 public_key: session.public_key_b64()?.to_string(),
329 ou,
330 owner_write,
331 safety: context.operation.safety(),
332 })
333}
334
335pub(super) fn sign_write(session: &Session, prepared: &PreparedWrite) -> Result<SignedWrite> {
336 ensure_prepared_for_session(session, prepared)?;
337 let owner_write = &prepared.owner_write;
338 let params = vec![
339 Value::String(prepared.sql.clone()),
340 Value::String(owner_write.owner_pubkey.clone()),
341 Value::String(owner_write.sequence.to_string()),
342 Value::String(session.sign_owner_write_hex(&hex::decode(&owner_write.frame_hex)?)?),
343 ];
344 let message = compact_json(&Value::Array(params))?;
345 let mut tx = Tx {
346 from: prepared.wallet.clone(),
347 to_: prepared.circle.clone(),
348 amount: "0".to_string(),
349 nonce: prepared.nonce,
350 ou: prepared.ou.clone(),
351 timestamp: prepared.timestamp,
352 op_type: "circle_call".to_string(),
353 encrypted_data: prepared.method.clone(),
354 message,
355 signature: String::new(),
356 public_key: prepared.public_key.clone(),
357 };
358 tx.signature = session.sign_transaction_b64(&canonical_tx(&tx))?;
359 Ok(SignedWrite::new(tx, prepared.safety))
360}
361
362pub(super) fn submit_signed_write_with<T: Transport>(
363 transport: &T,
364 session: &Session,
365 signed: SignedWrite,
366 no_wait: bool,
367) -> Result<Value> {
368 ensure_signed_for_session(session, &signed)?;
369 submit_tx_with(transport, session, signed.tx, no_wait)
370}
371
372#[cfg(any(feature = "http", test))]
373pub(super) fn sign_and_submit_tx_with<T: Transport>(
374 transport: &T,
375 session: &Session,
376 mut tx: Tx,
377 no_wait: bool,
378) -> Result<Value> {
379 tx.signature = session.sign_transaction_b64(&canonical_tx(&tx))?;
380 submit_tx_with(transport, session, tx, no_wait)
381}
382
383fn submit_tx_with<T: Transport>(
384 transport: &T,
385 session: &Session,
386 tx: Tx,
387 no_wait: bool,
388) -> Result<Value> {
389 let tx_circle = tx.to_.clone();
390 let tx_wallet = tx.from.clone();
391 let tx_nonce = tx.nonce;
392 let tx_ou = tx.ou.clone();
393 let result = rpc_call(transport, session, "octra_submit", json!([tx]))?;
394 let tx_hash = result
395 .get("tx_hash")
396 .or_else(|| result.get("hash"))
397 .and_then(Value::as_str)
398 .map(str::to_string);
399 let mut out = Map::new();
400 out.insert("circle".to_string(), Value::String(tx_circle.clone()));
401 out.insert("wallet".to_string(), Value::String(tx_wallet));
402 out.insert("nonce".to_string(), json!(tx_nonce));
403 out.insert("ou".to_string(), Value::String(tx_ou));
404 out.insert("result".to_string(), result);
405 if let Some(hash) = tx_hash.clone() {
406 out.insert("tx_hash".to_string(), Value::String(hash.clone()));
407 if !no_wait {
408 let receipt = wait_for_receipt_with(transport, session, &hash).map_err(|error| {
409 if error.kind() == ErrorKind::Timeout {
410 receipt_pending_error(session, &hash, &tx_circle, tx_nonce, &out)
411 } else {
412 error
413 }
414 })?;
415 if let Err(error) = ensure_receipt_success(&receipt) {
416 return Err(error.with_context(format!("tx_hash: {hash}")));
417 }
418 out.insert("receipt".to_string(), receipt);
419 }
420 }
421 Ok(Value::Object(out))
422}
423
424fn receipt_pending_error(
425 session: &Session,
426 tx_hash: &str,
427 circle: &str,
428 nonce: i64,
429 submitted: &Map<String, Value>,
430) -> Error {
431 let ou = submitted
432 .get("ou")
433 .and_then(Value::as_str)
434 .unwrap_or(DEFAULT_WRITE_OU);
435 let database = format!("oct://{}/{}", session.target().network, circle);
436 Error::with_code_and_details(
437 ErrorKind::Timeout,
438 "receipt_pending",
439 format!(
440 "transaction submitted but receipt is still pending; tx_hash={tx_hash}; nonce={nonce}; ou={ou}; circle={circle}"
441 ),
442 [
443 ("tx_hash", Value::String(tx_hash.to_string())),
444 ("nonce", json!(nonce)),
445 ("ou", Value::String(ou.to_string())),
446 ("circle", Value::String(circle.to_string())),
447 ("database", Value::String(database)),
448 ],
449 )
450}
451
452fn ensure_prepared_for_session(session: &Session, prepared: &PreparedWrite) -> Result<()> {
453 if prepared.circle != session.target().circle {
454 return Err(Error::with_kind(
455 ErrorKind::Authorization,
456 "prepared write Circle does not match the active database",
457 ));
458 }
459 if prepared.wallet != session.caller() {
460 return Err(Error::with_kind(
461 ErrorKind::Authorization,
462 "prepared write wallet does not match the active session",
463 ));
464 }
465 if prepared.public_key != session.public_key_b64()? {
466 return Err(Error::with_kind(
467 ErrorKind::Authorization,
468 "prepared write public key does not match the active session",
469 ));
470 }
471 Ok(())
472}
473
474fn ensure_signed_for_session(session: &Session, signed: &SignedWrite) -> Result<()> {
475 if signed.tx.to_ != session.target().circle {
476 return Err(Error::with_kind(
477 ErrorKind::Authorization,
478 "signed write Circle does not match the active database",
479 ));
480 }
481 if signed.tx.from != session.caller() {
482 return Err(Error::with_kind(
483 ErrorKind::Authorization,
484 "signed write wallet does not match the active session",
485 ));
486 }
487 if signed.tx.public_key != session.public_key_b64()? {
488 return Err(Error::with_kind(
489 ErrorKind::Authorization,
490 "signed write public key does not match the active session",
491 ));
492 }
493 Ok(())
494}
495
496fn hex_to_32(label: &str, text: &str) -> Result<[u8; 32]> {
497 let bytes = hex::decode(text).map_err(|error| {
498 Error::with_kind(ErrorKind::Decode, format!("decoding {label} hex: {error}"))
499 })?;
500 if bytes.len() != 32 {
501 return Err(Error::with_kind(
502 ErrorKind::Decode,
503 format!("{label} must decode to 32 bytes"),
504 ));
505 }
506 let mut out = [0u8; 32];
507 out.copy_from_slice(&bytes);
508 Ok(out)
509}
510
511fn trace_sql_event_enabled() -> bool {
512 env::var("OCTRA_SQLITE_EMIT_SQL_ONCHAIN_EVENT")
513 .ok()
514 .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
515}
516
517fn normalize_write_ou(label: &str, value: &str) -> Result<String> {
518 let trimmed = value.trim();
519 if trimmed.is_empty()
520 || !trimmed.as_bytes().iter().all(u8::is_ascii_digit)
521 || trimmed
522 .parse::<u64>()
523 .ok()
524 .filter(|value| *value > 0)
525 .is_none()
526 {
527 return Err(Error::with_kind(
528 ErrorKind::Config,
529 format!("{label} must be a positive decimal integer"),
530 ));
531 }
532 Ok(trimmed.to_string())
533}
534
535fn now_timestamp() -> f64 {
536 let duration = SystemTime::now()
537 .duration_since(UNIX_EPOCH)
538 .unwrap_or_default();
539 duration.as_secs() as f64 + f64::from(duration.subsec_millis()) / 1000.0
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use crate::client::session::{ClientOptions, build_session};
546 use serde_json::Value;
547 use std::sync::{Arc, Mutex};
548
549 #[derive(Clone, Default)]
550 struct CaptureTransport {
551 submits: Arc<Mutex<Vec<Value>>>,
552 }
553
554 struct AuthFailureTransport;
555
556 impl Transport for AuthFailureTransport {
557 fn call(&self, _rpc: &str, method: &str, _params: Value) -> Result<Value> {
558 match method {
559 "octra_balance" => Ok(json!({"pending_nonce": 41})),
560 "octra_circleViewAuth" => Err(Error::with_code(
561 ErrorKind::Rpc,
562 "rpc_rate_limited",
563 "auth_info RPC was rate limited",
564 )),
565 _ => Err(Error::with_kind(
566 ErrorKind::Other,
567 format!("unexpected method {method}"),
568 )),
569 }
570 }
571 }
572
573 impl Transport for CaptureTransport {
574 fn call(&self, _rpc: &str, method: &str, params: Value) -> Result<Value> {
575 match method {
576 "octra_submit" => {
577 self.submits.lock().unwrap().push(params);
578 Ok(json!({ "tx_hash": "abc123" }))
579 }
580 _ => Err(Error::with_kind(
581 ErrorKind::Other,
582 format!("unexpected method {method}"),
583 )),
584 }
585 }
586 }
587
588 fn test_session() -> Session {
589 build_session(&ClientOptions {
590 target: Some("oct://devnet/octABC?read_mode=sealed".to_string()),
591 rpc: Some("mock://rpc".to_string()),
592 caller: Some("octCaller".to_string()),
593 private_key: Some(
594 "0101010101010101010101010101010101010101010101010101010101010101".to_string(),
595 ),
596 ..ClientOptions::default()
597 })
598 .unwrap()
599 }
600
601 fn tx_for(session: &Session, signature: &str) -> Tx {
602 Tx {
603 from: session.caller().to_string(),
604 to_: session.target().circle.clone(),
605 amount: "0".to_string(),
606 nonce: 42,
607 ou: "1000".to_string(),
608 timestamp: 1000.0,
609 op_type: "circle_call".to_string(),
610 encrypted_data: "exec".to_string(),
611 message: "[]".to_string(),
612 signature: signature.to_string(),
613 public_key: session.public_key_b64().unwrap().to_string(),
614 }
615 }
616
617 fn submitted_signature(transport: &CaptureTransport) -> String {
618 transport.submits.lock().unwrap()[0]
619 .as_array()
620 .and_then(|params| params.first())
621 .and_then(|tx| tx.get("signature"))
622 .and_then(Value::as_str)
623 .unwrap()
624 .to_string()
625 }
626
627 #[test]
628 fn signed_write_submission_preserves_existing_signature() {
629 let transport = CaptureTransport::default();
630 let session = test_session();
631 let tx = tx_for(&session, "pre-signed");
632 let signed = SignedWrite::new(tx, Operation::ExecuteNoWait.safety());
633
634 let result = submit_signed_write_with(&transport, &session, signed, true).unwrap();
635
636 assert_eq!(submitted_signature(&transport), "pre-signed");
637 assert_eq!(result["nonce"], 42);
638 assert_eq!(result["ou"], "1000");
639 }
640
641 #[test]
642 fn generic_transaction_submission_signs_canonical_tx() {
643 let transport = CaptureTransport::default();
644 let session = test_session();
645 let tx = tx_for(&session, "stale-signature");
646
647 sign_and_submit_tx_with(&transport, &session, tx, true).unwrap();
648
649 let signature = submitted_signature(&transport);
650 assert!(!signature.is_empty());
651 assert_ne!(signature, "stale-signature");
652 }
653
654 #[test]
655 fn auth_preflight_preserves_source_error_code_with_context() {
656 let error = prepare_write_with(
657 &AuthFailureTransport,
658 &test_session(),
659 "create table demo(id integer);",
660 Operation::Execute,
661 )
662 .unwrap_err();
663
664 assert_eq!(error.kind(), ErrorKind::Rpc);
665 assert_eq!(error.code(), Some("rpc_rate_limited"));
666 assert!(error.to_string().contains("auth_info RPC was rate limited"));
667 assert!(
668 error
669 .to_string()
670 .contains("refusing to choose unsigned exec")
671 );
672 }
673
674 #[test]
675 fn signed_write_uses_prepared_ou() {
676 let session = test_session();
677 let owner_pubkey = hex::encode(session.intent_public_key().unwrap());
678 let prepared = prepare_write_with_owner_parts(
679 &session,
680 "create table demo(id integer);",
681 PreparedWriteContext {
682 operation: Operation::ExecuteNoWait,
683 ou: "50000",
684 nonce: 42,
685 timestamp: 1000.0,
686 method: "exec",
687 },
688 OwnerWriteAuth {
689 db_id: "0000000000000000000000000000000000000000000000000000000000000001",
690 owner_pubkey: Some(&owner_pubkey),
691 },
692 )
693 .unwrap();
694
695 let signed = sign_write(&session, &prepared).unwrap();
696
697 assert_eq!(prepared.ou(), "50000");
698 assert_eq!(signed.tx().ou, "50000");
699 }
700
701 #[test]
702 fn write_ou_must_be_positive_decimal() {
703 assert_eq!(normalize_write_ou("--ou", " 200000 ").unwrap(), "200000");
704 for value in ["", "0", "-1", "1.5", "abc"] {
705 let error = normalize_write_ou("--ou", value).unwrap_err();
706 assert_eq!(error.kind(), ErrorKind::Config);
707 }
708 }
709
710 #[test]
711 fn receipt_pending_error_carries_resumable_details() {
712 let session = test_session();
713 let mut submitted = Map::new();
714 submitted.insert("ou".to_string(), Value::String("200000".to_string()));
715
716 let error = receipt_pending_error(&session, "abc123", "octABC", 42, &submitted);
717
718 assert_eq!(error.code(), Some("receipt_pending"));
719 let details = error.details().unwrap();
720 assert_eq!(details["tx_hash"], "abc123");
721 assert_eq!(details["nonce"], json!(42));
722 assert_eq!(details["ou"], "200000");
723 assert_eq!(details["circle"], "octABC");
724 assert_eq!(details["database"], "oct://devnet/octABC");
725 assert!(details.get("next_command").is_none());
726 }
727}