Skip to main content

octra_sqlite/client/
write.rs

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/// Unsigned owner-write transaction plus the SQL and OSW1 intent it commits to.
21#[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    /// Return the SQL committed to by this write.
37    pub fn sql(&self) -> &str {
38        &self.sql
39    }
40
41    /// Return the Circle method selected for execution.
42    pub fn method(&self) -> &str {
43        &self.method
44    }
45
46    /// Return the Octra account nonce captured during preparation.
47    pub fn nonce(&self) -> i64 {
48        self.nonce
49    }
50
51    /// Return the Octra transaction timestamp captured during preparation.
52    pub fn timestamp(&self) -> f64 {
53        self.timestamp
54    }
55
56    /// Return the target Circle ID.
57    pub fn circle(&self) -> &str {
58        &self.circle
59    }
60
61    /// Return the submitting wallet address.
62    pub fn wallet(&self) -> &str {
63        &self.wallet
64    }
65
66    /// Return the signing public key encoded for Octra RPC.
67    pub fn public_key(&self) -> &str {
68        &self.public_key
69    }
70
71    /// Return the Octra unit budget that will be signed into the transaction.
72    pub fn ou(&self) -> &str {
73        &self.ou
74    }
75
76    /// Return the prepared OSW1 owner-write metadata.
77    pub fn owner_write(&self) -> &PreparedOwnerWrite {
78        &self.owner_write
79    }
80
81    /// Return the operation's declarative safety metadata.
82    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/// Database identity and sequence bound into a prepared OSW1 intent.
102#[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    /// Return the OSW1 database identity as hex.
112    pub fn db_id(&self) -> &str {
113        &self.db_id
114    }
115
116    /// Return the expected owner public key as hex.
117    pub fn owner_pubkey(&self) -> &str {
118        &self.owner_pubkey
119    }
120
121    /// Return the owner-write sequence committed to by the intent.
122    pub fn sequence(&self) -> u64 {
123        self.sequence
124    }
125
126    /// Return the complete OSW1 frame as hex.
127    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/// Signed Octra transaction ready for submission.
144#[derive(Clone, PartialEq)]
145pub struct SignedWrite {
146    tx: Tx,
147    safety: OperationSafety,
148}
149
150impl SignedWrite {
151    /// Borrow the signed Octra transaction.
152    pub fn tx(&self) -> &Tx {
153        &self.tx
154    }
155
156    /// Return the operation's declarative safety metadata.
157    pub fn safety(&self) -> OperationSafety {
158        self.safety
159    }
160
161    /// Consume the wrapper and return the signed transaction.
162    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}