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
18#[derive(Clone, PartialEq)]
19pub struct PreparedWrite {
20    sql: String,
21    method: String,
22    nonce: i64,
23    timestamp: f64,
24    circle: String,
25    wallet: String,
26    public_key: String,
27    owner_write: PreparedOwnerWrite,
28    safety: OperationSafety,
29}
30
31impl PreparedWrite {
32    pub fn sql(&self) -> &str {
33        &self.sql
34    }
35
36    pub fn method(&self) -> &str {
37        &self.method
38    }
39
40    pub fn nonce(&self) -> i64 {
41        self.nonce
42    }
43
44    pub fn timestamp(&self) -> f64 {
45        self.timestamp
46    }
47
48    pub fn circle(&self) -> &str {
49        &self.circle
50    }
51
52    pub fn wallet(&self) -> &str {
53        &self.wallet
54    }
55
56    pub fn public_key(&self) -> &str {
57        &self.public_key
58    }
59
60    pub fn owner_write(&self) -> &PreparedOwnerWrite {
61        &self.owner_write
62    }
63
64    pub fn safety(&self) -> OperationSafety {
65        self.safety
66    }
67}
68
69impl fmt::Debug for PreparedWrite {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.debug_struct("PreparedWrite")
72            .field("method", &self.method)
73            .field("nonce", &self.nonce)
74            .field("circle", &self.circle)
75            .field("wallet", &self.wallet)
76            .field("owner_write", &self.owner_write)
77            .field("safety", &self.safety)
78            .finish_non_exhaustive()
79    }
80}
81
82#[derive(Clone, PartialEq, Eq)]
83pub struct PreparedOwnerWrite {
84    db_id: String,
85    owner_pubkey: String,
86    sequence: u64,
87    frame_hex: String,
88}
89
90impl PreparedOwnerWrite {
91    pub fn db_id(&self) -> &str {
92        &self.db_id
93    }
94
95    pub fn owner_pubkey(&self) -> &str {
96        &self.owner_pubkey
97    }
98
99    pub fn sequence(&self) -> u64 {
100        self.sequence
101    }
102
103    pub fn frame_hex(&self) -> &str {
104        &self.frame_hex
105    }
106}
107
108impl fmt::Debug for PreparedOwnerWrite {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        f.debug_struct("PreparedOwnerWrite")
111            .field("db_id", &self.db_id)
112            .field("owner_pubkey", &self.owner_pubkey)
113            .field("sequence", &self.sequence)
114            .field("frame_hex", &"<redacted>")
115            .finish()
116    }
117}
118
119#[derive(Clone, PartialEq)]
120pub struct SignedWrite {
121    tx: Tx,
122    safety: OperationSafety,
123}
124
125impl SignedWrite {
126    pub fn tx(&self) -> &Tx {
127        &self.tx
128    }
129
130    pub fn safety(&self) -> OperationSafety {
131        self.safety
132    }
133
134    pub fn into_tx(self) -> Tx {
135        self.tx
136    }
137
138    pub(super) fn new(tx: Tx, safety: OperationSafety) -> Self {
139        Self { tx, safety }
140    }
141}
142
143impl fmt::Debug for SignedWrite {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        f.debug_struct("SignedWrite")
146            .field("circle", &self.tx.to_)
147            .field("wallet", &self.tx.from)
148            .field("nonce", &self.tx.nonce)
149            .field("method", &self.tx.encrypted_data)
150            .field("safety", &self.safety)
151            .finish_non_exhaustive()
152    }
153}
154
155pub(super) fn ensure_submit_mode(signed: &SignedWrite, expected: Operation) -> Result<()> {
156    let actual = signed.safety.operation;
157    if actual != expected {
158        return Err(Error::with_kind(
159            ErrorKind::Config,
160            format!("signed write was prepared for {actual:?}, not {expected:?}"),
161        ));
162    }
163    Ok(())
164}
165
166pub(super) fn prepare_write_with<T: Transport>(
167    transport: &T,
168    session: &Session,
169    sql: &str,
170    operation: Operation,
171) -> Result<PreparedWrite> {
172    let nonce = next_nonce_with(transport, session)?;
173    let timestamp = now_timestamp();
174    let method = if trace_sql_event_enabled() {
175        "exec_trace"
176    } else {
177        "exec"
178    };
179    let auth = auth_info_with(transport, session).map_err(|error| {
180        Error::with_kind(
181            ErrorKind::Authorization,
182            format!(
183                "could not read Circle auth_info; refusing to choose unsigned exec implicitly: {error}"
184            ),
185        )
186    })?;
187    if !auth.configured {
188        return Err(Error::with_kind(
189            ErrorKind::Authorization,
190            "database is not owner-write-personalized; refusing unsigned SQL write",
191        ));
192    }
193    prepare_write_with_owner_parts(
194        session,
195        sql,
196        operation,
197        nonce,
198        timestamp,
199        method,
200        OwnerWriteAuth {
201            db_id: &auth.db_id,
202            owner_pubkey: auth.owner_pubkey.as_deref(),
203        },
204    )
205}
206
207#[cfg(all(feature = "cli", feature = "http"))]
208pub(super) fn prepare_write_with_owner_auth<T: Transport>(
209    transport: &T,
210    session: &Session,
211    sql: &str,
212    operation: Operation,
213    db_id: &str,
214    owner_pubkey: &str,
215) -> Result<PreparedWrite> {
216    let nonce = next_nonce_with(transport, session)?;
217    let timestamp = now_timestamp();
218    let method = if trace_sql_event_enabled() {
219        "exec_trace"
220    } else {
221        "exec"
222    };
223    prepare_write_with_owner_parts(
224        session,
225        sql,
226        operation,
227        nonce,
228        timestamp,
229        method,
230        OwnerWriteAuth {
231            db_id,
232            owner_pubkey: Some(owner_pubkey),
233        },
234    )
235}
236
237struct OwnerWriteAuth<'a> {
238    db_id: &'a str,
239    owner_pubkey: Option<&'a str>,
240}
241
242fn prepare_write_with_owner_parts(
243    session: &Session,
244    sql: &str,
245    operation: Operation,
246    nonce: i64,
247    timestamp: f64,
248    method: &str,
249    auth: OwnerWriteAuth<'_>,
250) -> Result<PreparedWrite> {
251    let db_id_bytes = hex_to_32("db_id", auth.db_id)?;
252    let session_owner_pubkey = session.intent_public_key()?;
253    let owner_pubkey = match auth.owner_pubkey {
254        Some(owner_pubkey) => {
255            let configured = hex_to_32("owner_pubkey", owner_pubkey)?;
256            if configured != session_owner_pubkey {
257                return Err(Error::with_kind(
258                    ErrorKind::Authorization,
259                    "owner write metadata does not match the active wallet",
260                ));
261            }
262            hex::encode(configured)
263        }
264        None => hex::encode(session_owner_pubkey),
265    };
266    let frame = osw1::frame(&db_id_bytes, nonce as u64, method, sql)?;
267    let owner_write = PreparedOwnerWrite {
268        db_id: auth.db_id.to_string(),
269        owner_pubkey,
270        sequence: nonce as u64,
271        frame_hex: hex::encode(frame),
272    };
273    Ok(PreparedWrite {
274        sql: sql.to_string(),
275        method: method.to_string(),
276        nonce,
277        timestamp,
278        circle: session.target().circle.clone(),
279        wallet: session.caller().to_string(),
280        public_key: session.public_key_b64()?.to_string(),
281        owner_write,
282        safety: operation.safety(),
283    })
284}
285
286pub(super) fn sign_write(session: &Session, prepared: &PreparedWrite) -> Result<SignedWrite> {
287    ensure_prepared_for_session(session, prepared)?;
288    let owner_write = &prepared.owner_write;
289    let params = vec![
290        Value::String(prepared.sql.clone()),
291        Value::String(owner_write.owner_pubkey.clone()),
292        Value::String(owner_write.sequence.to_string()),
293        Value::String(session.sign_owner_write_hex(&hex::decode(&owner_write.frame_hex)?)?),
294    ];
295    let message = compact_json(&Value::Array(params))?;
296    let mut tx = Tx {
297        from: prepared.wallet.clone(),
298        to_: prepared.circle.clone(),
299        amount: "0".to_string(),
300        nonce: prepared.nonce,
301        ou: "1000".to_string(),
302        timestamp: prepared.timestamp,
303        op_type: "circle_call".to_string(),
304        encrypted_data: prepared.method.clone(),
305        message,
306        signature: String::new(),
307        public_key: prepared.public_key.clone(),
308    };
309    tx.signature = session.sign_transaction_b64(&canonical_tx(&tx))?;
310    Ok(SignedWrite::new(tx, prepared.safety))
311}
312
313pub(super) fn submit_signed_write_with<T: Transport>(
314    transport: &T,
315    session: &Session,
316    signed: SignedWrite,
317    no_wait: bool,
318) -> Result<Value> {
319    ensure_signed_for_session(session, &signed)?;
320    submit_tx_with(transport, session, signed.tx, no_wait)
321}
322
323#[cfg(any(feature = "http", test))]
324pub(super) fn sign_and_submit_tx_with<T: Transport>(
325    transport: &T,
326    session: &Session,
327    mut tx: Tx,
328    no_wait: bool,
329) -> Result<Value> {
330    tx.signature = session.sign_transaction_b64(&canonical_tx(&tx))?;
331    submit_tx_with(transport, session, tx, no_wait)
332}
333
334fn submit_tx_with<T: Transport>(
335    transport: &T,
336    session: &Session,
337    tx: Tx,
338    no_wait: bool,
339) -> Result<Value> {
340    let tx_circle = tx.to_.clone();
341    let tx_wallet = tx.from.clone();
342    let result = rpc_call(transport, session, "octra_submit", json!([tx]))?;
343    let tx_hash = result
344        .get("tx_hash")
345        .or_else(|| result.get("hash"))
346        .and_then(Value::as_str)
347        .map(str::to_string);
348    let mut out = Map::new();
349    out.insert("circle".to_string(), Value::String(tx_circle));
350    out.insert("wallet".to_string(), Value::String(tx_wallet));
351    out.insert("result".to_string(), result);
352    if let Some(hash) = tx_hash.clone() {
353        out.insert("tx_hash".to_string(), Value::String(hash.clone()));
354        if !no_wait {
355            let receipt = wait_for_receipt_with(transport, session, &hash)?;
356            if let Err(error) = ensure_receipt_success(&receipt) {
357                return Err(Error::with_kind(
358                    error.kind(),
359                    format!("{error}; tx_hash: {hash}"),
360                ));
361            }
362            out.insert("receipt".to_string(), receipt);
363        }
364    }
365    Ok(Value::Object(out))
366}
367
368fn ensure_prepared_for_session(session: &Session, prepared: &PreparedWrite) -> Result<()> {
369    if prepared.circle != session.target().circle {
370        return Err(Error::with_kind(
371            ErrorKind::Authorization,
372            "prepared write Circle does not match the active database",
373        ));
374    }
375    if prepared.wallet != session.caller() {
376        return Err(Error::with_kind(
377            ErrorKind::Authorization,
378            "prepared write wallet does not match the active session",
379        ));
380    }
381    if prepared.public_key != session.public_key_b64()? {
382        return Err(Error::with_kind(
383            ErrorKind::Authorization,
384            "prepared write public key does not match the active session",
385        ));
386    }
387    Ok(())
388}
389
390fn ensure_signed_for_session(session: &Session, signed: &SignedWrite) -> Result<()> {
391    if signed.tx.to_ != session.target().circle {
392        return Err(Error::with_kind(
393            ErrorKind::Authorization,
394            "signed write Circle does not match the active database",
395        ));
396    }
397    if signed.tx.from != session.caller() {
398        return Err(Error::with_kind(
399            ErrorKind::Authorization,
400            "signed write wallet does not match the active session",
401        ));
402    }
403    if signed.tx.public_key != session.public_key_b64()? {
404        return Err(Error::with_kind(
405            ErrorKind::Authorization,
406            "signed write public key does not match the active session",
407        ));
408    }
409    Ok(())
410}
411
412fn hex_to_32(label: &str, text: &str) -> Result<[u8; 32]> {
413    let bytes = hex::decode(text).map_err(|error| {
414        Error::with_kind(ErrorKind::Decode, format!("decoding {label} hex: {error}"))
415    })?;
416    if bytes.len() != 32 {
417        return Err(Error::with_kind(
418            ErrorKind::Decode,
419            format!("{label} must decode to 32 bytes"),
420        ));
421    }
422    let mut out = [0u8; 32];
423    out.copy_from_slice(&bytes);
424    Ok(out)
425}
426
427fn trace_sql_event_enabled() -> bool {
428    env::var("OCTRA_SQLITE_TRACE_SQL_EVENT")
429        .ok()
430        .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
431}
432
433fn now_timestamp() -> f64 {
434    let duration = SystemTime::now()
435        .duration_since(UNIX_EPOCH)
436        .unwrap_or_default();
437    duration.as_secs() as f64 + f64::from(duration.subsec_millis()) / 1000.0
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use crate::client::session::{ClientOptions, build_session};
444    use serde_json::Value;
445    use std::sync::{Arc, Mutex};
446
447    #[derive(Clone, Default)]
448    struct CaptureTransport {
449        submits: Arc<Mutex<Vec<Value>>>,
450    }
451
452    impl Transport for CaptureTransport {
453        fn call(&self, _rpc: &str, method: &str, params: Value) -> Result<Value> {
454            match method {
455                "octra_submit" => {
456                    self.submits.lock().unwrap().push(params);
457                    Ok(json!({ "tx_hash": "abc123" }))
458                }
459                _ => Err(Error::with_kind(
460                    ErrorKind::Other,
461                    format!("unexpected method {method}"),
462                )),
463            }
464        }
465    }
466
467    fn test_session() -> Session {
468        build_session(&ClientOptions {
469            target: Some("oct://devnet/octABC".to_string()),
470            rpc: Some("mock://rpc".to_string()),
471            caller: Some("octCaller".to_string()),
472            private_key: Some(
473                "0101010101010101010101010101010101010101010101010101010101010101".to_string(),
474            ),
475            ..ClientOptions::default()
476        })
477        .unwrap()
478    }
479
480    fn tx_for(session: &Session, signature: &str) -> Tx {
481        Tx {
482            from: session.caller().to_string(),
483            to_: session.target().circle.clone(),
484            amount: "0".to_string(),
485            nonce: 42,
486            ou: "1000".to_string(),
487            timestamp: 1000.0,
488            op_type: "circle_call".to_string(),
489            encrypted_data: "exec".to_string(),
490            message: "[]".to_string(),
491            signature: signature.to_string(),
492            public_key: session.public_key_b64().unwrap().to_string(),
493        }
494    }
495
496    fn submitted_signature(transport: &CaptureTransport) -> String {
497        transport.submits.lock().unwrap()[0]
498            .as_array()
499            .and_then(|params| params.first())
500            .and_then(|tx| tx.get("signature"))
501            .and_then(Value::as_str)
502            .unwrap()
503            .to_string()
504    }
505
506    #[test]
507    fn signed_write_submission_preserves_existing_signature() {
508        let transport = CaptureTransport::default();
509        let session = test_session();
510        let tx = tx_for(&session, "pre-signed");
511        let signed = SignedWrite::new(tx, Operation::ExecuteNoWait.safety());
512
513        submit_signed_write_with(&transport, &session, signed, true).unwrap();
514
515        assert_eq!(submitted_signature(&transport), "pre-signed");
516    }
517
518    #[test]
519    fn generic_transaction_submission_signs_canonical_tx() {
520        let transport = CaptureTransport::default();
521        let session = test_session();
522        let tx = tx_for(&session, "stale-signature");
523
524        sign_and_submit_tx_with(&transport, &session, tx, true).unwrap();
525
526        let signature = submitted_signature(&transport);
527        assert!(!signature.is_empty());
528        assert_ne!(signature, "stale-signature");
529    }
530}