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}