1use crate::{Error, model::*};
5use bitcoin::{BlockHash, block::Header, consensus::serialize};
6use rusqlite::{Connection, OptionalExtension, params};
7use std::{
8 fs::{File, OpenOptions},
9 path::Path,
10 time::Duration,
11};
12
13mod cache;
14mod names;
15mod objects;
16mod peers;
17mod projections;
18pub(crate) use objects::ObjectMember;
19
20pub struct Archive {
21 db: Connection,
23 _lock: File,
24 config: ArchiveConfig,
25 names_cache: Option<urma_names::engine::Registry>,
28}
29
30impl Archive {
31 pub fn open(directory: &Path, config: ArchiveConfig) -> Result<Self, Error> {
34 if config.application_start.height > i64::MAX as u64 {
35 return Err(Error::Invalid(
36 "application start exceeds storage range".into(),
37 ));
38 }
39 let mut builder = std::fs::DirBuilder::new();
40 builder.recursive(true);
41 #[cfg(unix)]
42 {
43 use std::os::unix::fs::DirBuilderExt;
44 builder.mode(0o700);
45 }
46 builder.create(directory)?;
47 if std::fs::symlink_metadata(directory)?
48 .file_type()
49 .is_symlink()
50 {
51 return Err(Error::Invalid(
52 "archive directory must not be a symlink".into(),
53 ));
54 }
55 let lock_path = directory.join("owner.lock");
56 for path in [&lock_path, &directory.join("archive.sqlite3")] {
57 match std::fs::symlink_metadata(path) {
58 Ok(meta) if !meta.is_file() => {
59 return Err(Error::Invalid("archive files must be regular files".into()));
60 }
61 Ok(_) => (),
62 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
63 Err(e) => return Err(e.into()),
64 }
65 }
66 let lock = OpenOptions::new()
67 .create(true)
68 .truncate(false)
69 .read(true)
70 .write(true)
71 .open(lock_path)?;
72 lock.try_lock().map_err(|e| Error::Busy(e.to_string()))?;
73 let mut db = Connection::open(directory.join("archive.sqlite3"))?;
74 db.busy_timeout(Duration::from_secs(5))?;
75 db.execute_batch(
76 "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;",
77 )?;
78 let version: u32 = db.pragma_query_value(None, "user_version", |r| r.get(0))?;
79 let encoded = serde_json::to_string(&config)?;
80 if version == 0 {
81 let tx = db.transaction()?;
82 tx.execute_batch(include_str!("schema.sql"))?;
83 tx.execute("INSERT INTO config VALUES(1,?1)", [&encoded])?;
84 tx.commit()?;
85 } else if !(1..=5).contains(&version) {
86 return Err(Error::Invalid(format!(
87 "unsupported archive schema {version}"
88 )));
89 }
90 let saved: String =
91 db.query_row("SELECT value FROM config WHERE singleton=1", [], |r| {
92 r.get(0)
93 })?;
94 if saved != encoded {
95 return Err(Error::Invalid(
96 "archive configuration differs; explicit migration required".into(),
97 ));
98 }
99 migrate(&mut db, version)?;
102 Ok(Self {
103 db,
104 _lock: lock,
105 config,
106 names_cache: None,
107 })
108 }
109
110 pub fn config(&self) -> &ArchiveConfig {
111 &self.config
112 }
113
114 pub fn status(&self) -> Result<ArchiveStatus, Error> {
115 let generation = generation(&self.db)?;
116 let selected_tip = self
117 .db
118 .query_row(
119 "SELECT height,hash FROM selected ORDER BY height DESC LIMIT 1",
120 [],
121 point,
122 )
123 .optional()?;
124 let indexed_through = self.indexed_through()?;
125 let scanned_blocks = self.db.query_row(
126 "SELECT count(*) FROM scans c JOIN selected s ON s.hash=c.block_hash WHERE c.rules=?1",
127 [SCAN_RULES_VERSION],
128 |r| unsigned(r, 0),
129 )?;
130 let unresolved_dependencies = self.db.query_row(
131 "SELECT count(*) FROM dependencies d JOIN selected s ON s.hash=d.block_hash",
132 [],
133 |r| unsigned(r, 0),
134 )?;
135 let stored_transactions =
136 self.db
137 .query_row("SELECT count(*) FROM transactions", [], |r| unsigned(r, 0))?;
138 Ok(ArchiveStatus {
139 api_version: API_VERSION,
140 chain_genesis: self
141 .config
142 .chain
143 .genesis()
144 .map_err(|e| Error::Invalid(e.to_string()))?
145 .0,
146 application_start: self.config.application_start,
147 retention: self.config.retention,
148 generation,
149 selected_tip,
150 indexed_through,
151 scanned_blocks,
152 unresolved_dependencies,
153 stored_transactions,
154 scan_rules_version: SCAN_RULES_VERSION,
155 peer_synced_through: peers::synced_through(
156 &self.db,
157 self.config.application_start.height,
158 )?,
159 peer_omissions: peers::omission_count(&self.db)?,
160 unaudited_records: peers::unaudited_count(&self.db)?,
161 })
162 }
163
164 fn indexed_through(&self) -> Result<Option<ChainPoint>, Error> {
166 let start = self.config.application_start.height;
167 let mut next = start;
168 let mut indexed_through = None;
169 let mut rows = self.db.prepare(
170 "SELECT s.height,s.hash FROM selected s JOIN scans c ON c.block_hash=s.hash
171 WHERE s.height>=?1 AND c.rules=?2 AND c.unresolved=0 ORDER BY s.height",
172 )?;
173 for row in rows.query_map(params![sql_integer(start)?, SCAN_RULES_VERSION], point)? {
174 let current = row?;
175 if current.height != next {
176 break;
177 }
178 next += 1;
179 indexed_through = Some(current);
180 }
181 Ok(indexed_through)
182 }
183
184 pub fn ticket(&self, height: u64) -> Result<ScanTicket, Error> {
185 let point = self
186 .db
187 .query_row(
188 "SELECT height,hash FROM selected WHERE height=?1",
189 [sql_integer(height)?],
190 point,
191 )
192 .optional()?
193 .ok_or_else(|| Error::Invalid("block is not in the selected view".into()))?;
194 Ok(ScanTicket {
195 generation: generation(&self.db)?,
196 point,
197 })
198 }
199
200 pub(crate) fn header_at(&self, height: u64) -> Result<Option<Header>, Error> {
201 let raw: Option<Vec<u8>> = self
202 .db
203 .query_row(
204 "SELECT h.bytes FROM selected s JOIN headers h ON h.hash=s.hash WHERE s.height=?1",
205 [sql_integer(height)?],
206 |r| r.get(0),
207 )
208 .optional()?;
209 raw.map(|raw| {
210 bitcoin::consensus::deserialize(&raw)
211 .map_err(|e| Error::Invalid(format!("stored header: {e}")))
212 })
213 .transpose()
214 }
215
216 pub(crate) fn select(&mut self, start: u64, headers: &[Header]) -> Result<(), Error> {
220 if headers.is_empty() || start < self.config.application_start.height {
221 return Err(Error::Invalid("empty or out-of-domain chain update".into()));
222 }
223 let end = start
224 .checked_add(headers.len() as u64 - 1)
225 .filter(|h| *h <= i64::MAX as u64)
226 .ok_or_else(|| Error::Invalid("chain update height overflow".into()))?;
227 if start == self.config.application_start.height {
228 if headers[0].block_hash() != self.config.application_start.hash {
229 return Err(Error::Invalid(
230 "selected chain does not contain configured application start".into(),
231 ));
232 }
233 } else {
234 let previous = self
235 .header_at(start - 1)?
236 .ok_or_else(|| Error::Invalid("chain update has a gap".into()))?;
237 if headers[0].prev_blockhash != previous.block_hash() {
238 return Err(Error::Invalid(
239 "chain update does not join selected parent".into(),
240 ));
241 }
242 }
243 for pair in headers.windows(2) {
244 if pair[1].prev_blockhash != pair[0].block_hash() {
245 return Err(Error::Invalid("noncontiguous selected headers".into()));
246 }
247 }
248 let mut changed_at = None;
249 for (offset, header) in headers.iter().enumerate() {
250 let height = start + offset as u64;
251 if self.header_at(height)?.is_some_and(|old| old != *header) {
252 changed_at = Some(height);
253 break;
254 }
255 }
256 let tx = self.db.transaction()?;
257 if let Some(height) = changed_at {
258 tx.execute(
259 "DELETE FROM selected WHERE height>=?1",
260 [sql_integer(height)?],
261 )?;
262 tx.execute(
263 "UPDATE state SET generation=generation+1 WHERE singleton=1",
264 [],
265 )?;
266 tx.execute("INSERT INTO events(generation,kind,height) SELECT generation,'reorg',?1 FROM state", [sql_integer(height)?])?;
267 peers::reorg(&tx, height)?;
268 }
269 for (offset, header) in headers.iter().enumerate() {
270 let hash = header.block_hash().to_string();
271 tx.execute(
272 "INSERT OR IGNORE INTO headers VALUES(?1,?2)",
273 params![hash, serialize(header)],
274 )?;
275 tx.execute("INSERT INTO selected VALUES(?1,?2) ON CONFLICT(height) DO UPDATE SET hash=excluded.hash",
276 params![sql_integer(start + offset as u64)?, hash])?;
277 }
278 tx.execute("INSERT INTO events(generation,kind,height,block_hash) SELECT generation,'chain',?1,?2 FROM state",
279 params![sql_integer(end)?, headers.last().expect("nonempty checked").block_hash().to_string()])?;
280 tx.commit()?;
281 Ok(())
282 }
283
284 pub(crate) fn check_ticket(&self, ticket: ScanTicket) -> Result<(), Error> {
285 if generation(&self.db)? != ticket.generation
286 || self.ticket(ticket.point.height)?.point != ticket.point
287 {
288 return Err(Error::Stale);
289 }
290 Ok(())
291 }
292
293 pub(crate) fn next_scan(&self) -> Result<Option<ScanTicket>, Error> {
294 let height: Option<u64> = self
295 .db
296 .query_row(
297 "SELECT s.height FROM selected s LEFT JOIN scans c ON c.block_hash=s.hash
298 WHERE c.block_hash IS NULL OR c.rules!=?1 ORDER BY s.height LIMIT 1",
299 [SCAN_RULES_VERSION],
300 |r| unsigned(r, 0),
301 )
302 .optional()?;
303 height.map(|h| self.ticket(h)).transpose()
304 }
305
306 pub(crate) fn transaction(&self, txid: bitcoin::Txid) -> Result<Option<Vec<u8>>, Error> {
307 Ok(self
308 .db
309 .query_row(
310 "SELECT raw FROM transactions WHERE txid=?1 LIMIT 1",
311 [txid.to_string()],
312 |r| r.get(0),
313 )
314 .optional()?)
315 }
316
317 pub(crate) fn location(&self, txid: bitcoin::Txid) -> Result<Option<ChainPoint>, Error> {
318 Ok(self.db.query_row(
319 "SELECT s.height,s.hash FROM locators l JOIN selected s ON s.hash=l.block_hash WHERE l.txid=?1 ORDER BY s.height LIMIT 1",
320 [txid.to_string()], point,
321 ).optional()?)
322 }
323
324 pub(crate) fn inventory_page(
332 &self,
333 from: u64,
334 to: u64,
335 after: Option<(u64, u32)>,
336 limit: u32,
337 ) -> Result<InventoryRows, Error> {
338 let Some(ceiling) = self.indexed_through()? else {
339 return Ok(InventoryRows {
340 scanned_through: None,
341 entries: Vec::new(),
342 more: false,
343 });
344 };
345 let (after_height, after_position) = match after {
346 Some((height, position)) => (sql_integer(height)?, i64::from(position)),
347 None => (-1, -1),
348 };
349 let mut statement = self.db.prepare(
350 "SELECT s.height,s.hash,l.position,l.txid,l.wtxid FROM records p
351 JOIN locators l ON l.block_hash=p.block_hash AND l.position=p.position
352 JOIN selected s ON s.hash=p.block_hash
353 JOIN scans c ON c.block_hash=p.block_hash AND c.rules=?5
354 WHERE s.height>=?1 AND s.height<=?2 AND s.height<=?7
355 AND (s.height>?3 OR (s.height=?3 AND l.position>?4))
356 ORDER BY s.height,l.position LIMIT ?6",
357 )?;
358 let wanted = usize::try_from(limit).map_err(|e| Error::Invalid(e.to_string()))?;
359 let bound = params![
360 sql_integer(from)?,
361 sql_integer(to)?,
362 after_height,
363 after_position,
364 SCAN_RULES_VERSION,
365 i64::from(limit) + 1,
366 sql_integer(ceiling.height)?
367 ];
368 let mut entries = Vec::new();
369 for row in statement.query_map(bound, |row| {
370 Ok((
371 point(row)?,
372 row.get::<_, i64>(2)?,
373 row.get::<_, String>(3)?,
374 row.get::<_, String>(4)?,
375 ))
376 })? {
377 let (block, position, txid, wtxid) = row?;
378 let invalid = |e: String| Error::Invalid(format!("archived locator: {e}"));
379 entries.push((
380 block,
381 u32::try_from(position).map_err(|e| invalid(e.to_string()))?,
382 txid.parse()
383 .map_err(|e: bitcoin::hex::HexToArrayError| invalid(e.to_string()))?,
384 wtxid
385 .parse()
386 .map_err(|e: bitcoin::hex::HexToArrayError| invalid(e.to_string()))?,
387 ));
388 }
389 let more = entries.len() > wanted;
390 entries.truncate(wanted);
391 Ok(InventoryRows {
392 scanned_through: Some(ceiling.height),
393 entries,
394 more,
395 })
396 }
397
398 pub(crate) fn record(&self, txid: bitcoin::Txid) -> Result<Option<ArchivedRecord>, Error> {
400 let row: Option<(ChainPoint, u32, String, Vec<u8>, Vec<u8>)> = self
401 .db
402 .query_row(
403 "SELECT s.height,s.hash,r.position,r.wtxid,r.proof,t.raw FROM records r
404 JOIN selected s ON s.hash=r.block_hash
405 JOIN transactions reveal ON reveal.wtxid=r.wtxid
406 JOIN transactions t ON t.wtxid=r.commit_wtxid
407 WHERE reveal.txid=?1 ORDER BY s.height,r.position LIMIT 1",
408 [txid.to_string()],
409 |r| Ok((point(r)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
410 )
411 .optional()?;
412 let Some((block, position, wtxid, proof, commit)) = row else {
413 return Ok(None);
414 };
415 Ok(Some(ArchivedRecord {
416 receipt: RecordReceipt {
417 generation: generation(&self.db)?,
418 block,
419 position,
420 txid,
421 wtxid: wtxid
422 .parse()
423 .map_err(|e| Error::Invalid(format!("stored WTXID: {e}")))?,
424 },
425 package: RecordPackage {
426 version: API_VERSION,
427 chain_genesis: self
428 .config
429 .chain
430 .genesis()
431 .map_err(|e| Error::Invalid(e.to_string()))?
432 .0,
433 block,
434 reveal: serde_json::from_slice(&proof)?,
435 commit,
436 },
437 }))
438 }
439
440 pub(crate) fn next_missing_dependency(
443 &self,
444 after: Option<bitcoin::Txid>,
445 ) -> Result<Option<(bitcoin::Txid, u64)>, Error> {
446 let id: Option<String> = self
447 .db
448 .query_row(
449 "SELECT DISTINCT d.commit_txid FROM dependencies d
450 JOIN selected s ON s.hash=d.block_hash
451 WHERE NOT EXISTS(SELECT 1 FROM transactions t WHERE t.txid=d.commit_txid)
452 AND (?1 IS NULL OR d.commit_txid>?1)
453 ORDER BY d.commit_txid LIMIT 1",
454 [after.map(|id| id.to_string())],
455 |row| row.get(0),
456 )
457 .optional()?;
458 id.map(|id| {
459 let id = id
460 .parse()
461 .map_err(|error| Error::Invalid(format!("stored parent TXID: {error}")))?;
462 Ok((id, generation(&self.db)?))
463 })
464 .transpose()
465 }
466
467 pub(crate) fn check_generation(&self, expected: u64) -> Result<(), Error> {
468 if generation(&self.db)? != expected {
469 return Err(Error::Stale);
470 }
471 Ok(())
472 }
473
474 pub(crate) fn pending_for(
475 &self,
476 txid: bitcoin::Txid,
477 ) -> Result<Vec<(ScanTicket, u32, urma_chain::inclusion::TransactionProof)>, Error> {
478 let mut stmt = self.db.prepare(
479 "SELECT s.height,s.hash,d.position,d.proof FROM dependencies d
480 JOIN selected s ON s.hash=d.block_hash WHERE d.commit_txid=?1
481 ORDER BY s.height,d.position LIMIT 128",
482 )?;
483 let rows = stmt.query_map([txid.to_string()], |r| {
484 Ok((point(r)?, r.get::<_, u32>(2)?, r.get::<_, Vec<u8>>(3)?))
485 })?;
486 let epoch = generation(&self.db)?;
487 rows.map(|row| {
488 let (point, position, proof) = row?;
489 Ok((
490 ScanTicket {
491 generation: epoch,
492 point,
493 },
494 position,
495 serde_json::from_slice(&proof)?,
496 ))
497 })
498 .collect()
499 }
500
501 pub(crate) fn cached_dependency(&self) -> Result<Option<Vec<u8>>, Error> {
502 Ok(self
503 .db
504 .query_row(
505 "SELECT t.raw FROM dependencies d JOIN selected s ON s.hash=d.block_hash
506 JOIN transactions t ON t.txid=d.commit_txid ORDER BY s.height,d.position LIMIT 1",
507 [],
508 |row| row.get(0),
509 )
510 .optional()?)
511 }
512
513 pub(crate) fn finish_dependencies(
516 &mut self,
517 completed: Vec<(ScanTicket, u32, Option<crate::scan::Record>)>,
518 ) -> Result<usize, Error> {
519 for (ticket, _, _) in &completed {
520 self.check_ticket(*ticket)?;
521 }
522 let tx = self.db.transaction()?;
523 let mut count = 0;
524 for (ticket, position, record) in completed {
525 let hash = ticket.point.hash.to_string();
526 let removed = tx.execute(
527 "DELETE FROM dependencies WHERE block_hash=?1 AND position=?2",
528 params![hash, position],
529 )?;
530 if removed == 0 {
531 continue;
532 }
533 if let Some(record) = record {
534 store_record(&tx, &hash, &record)?;
535 }
536 tx.execute("UPDATE scans SET unresolved=(SELECT count(*) FROM dependencies WHERE block_hash=?1) WHERE block_hash=?1", [&hash])?;
537 tx.execute("INSERT INTO events(generation,kind,height,block_hash) VALUES(?1,'dependency',?2,?3)",
538 params![sql_integer(ticket.generation)?, sql_integer(ticket.point.height)?, hash])?;
539 count += 1;
540 }
541 tx.commit()?;
542 Ok(count)
543 }
544
545 pub(crate) fn admit_record(
546 &mut self,
547 ticket: ScanTicket,
548 record: crate::scan::Record,
549 ) -> Result<RecordReceipt, Error> {
550 self.check_ticket(ticket)?;
551 let receipt = RecordReceipt {
552 generation: ticket.generation,
553 block: ticket.point,
554 position: record.position,
555 txid: record.verified.txid(),
556 wtxid: record.wtxid,
557 };
558 let tx = self.db.transaction()?;
559 store_record(&tx, &ticket.point.hash.to_string(), &record)?;
560 tx.execute(
561 "INSERT INTO events(generation,kind,height,block_hash) VALUES(?1,'record',?2,?3)",
562 params![
563 sql_integer(ticket.generation)?,
564 sql_integer(ticket.point.height)?,
565 ticket.point.hash.to_string()
566 ],
567 )?;
568 tx.commit()?;
571 Ok(receipt)
572 }
573
574 pub(crate) fn commit_scan(&mut self, batch: crate::scan::ScanBatch) -> Result<(), Error> {
575 self.check_ticket(batch.ticket)?;
576 let point = batch.ticket.point;
577 let hash = point.hash.to_string();
578 let tx = self.db.transaction()?;
579 for (position, (txid, wtxid)) in batch.locators.iter().enumerate() {
580 tx.execute("INSERT INTO locators VALUES(?1,?2,?3,?4) ON CONFLICT(block_hash,position) DO UPDATE SET txid=excluded.txid,wtxid=excluded.wtxid",
581 params![hash, sql_integer(position as u64)?, txid.to_string(), wtxid.to_string()])?;
582 }
583 tx.execute("DELETE FROM dependencies WHERE block_hash=?1", [&hash])?;
586 peers::record_omissions(&tx, &batch)?;
587 for record in batch.records {
588 store_record(&tx, &hash, &record)?;
589 }
590 let unresolved = batch.dependencies.len() as u64;
591 for dependency in batch.dependencies {
592 tx.execute(
593 "INSERT INTO dependencies VALUES(?1,?2,?3,?4)",
594 params![
595 hash,
596 dependency.position,
597 dependency.commit.to_string(),
598 serde_json::to_vec(&dependency.proof)?
599 ],
600 )?;
601 }
602 tx.execute("INSERT INTO scans VALUES(?1,?2,?3) ON CONFLICT(block_hash) DO UPDATE SET rules=excluded.rules,unresolved=excluded.unresolved",
603 params![hash, SCAN_RULES_VERSION, sql_integer(unresolved)?])?;
604 tx.execute("INSERT INTO events(generation,kind,height,block_hash) SELECT generation,'scan',?1,?2 FROM state",
605 params![sql_integer(point.height)?, hash])?;
606 tx.commit()?;
607 Ok(())
608 }
609}
610
611fn migrate(db: &mut Connection, version: u32) -> Result<(), Error> {
612 if version >= 5 {
613 return Ok(());
614 }
615 let tx = db.transaction()?;
616 if version < 2 {
617 tx.execute_batch(objects::SCHEMA)?;
618 } else if version == 2 {
619 objects::migrate_v2(&tx)?;
620 }
621 if version < 4 {
622 tx.execute_batch(names::SCHEMA)?;
623 }
624 tx.execute_batch(peers::SCHEMA)?;
625 tx.commit()?;
626 Ok(())
627}
628
629pub(crate) struct InventoryRows {
631 pub(crate) scanned_through: Option<u64>,
632 pub(crate) entries: Vec<(ChainPoint, u32, bitcoin::Txid, bitcoin::Wtxid)>,
633 pub(crate) more: bool,
634}
635
636fn generation(db: &Connection) -> Result<u64, Error> {
637 Ok(
638 db.query_row("SELECT generation FROM state WHERE singleton=1", [], |r| {
639 unsigned(r, 0)
640 })?,
641 )
642}
643
644fn point(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainPoint> {
645 let hash: String = row.get(1)?;
646 Ok(ChainPoint {
647 height: unsigned(row, 0)?,
648 hash: hash.parse::<BlockHash>().map_err(|e| {
649 rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
650 })?,
651 })
652}
653
654fn sql_integer(value: u64) -> Result<i64, Error> {
655 i64::try_from(value).map_err(|_| Error::Invalid("integer exceeds archive range".into()))
656}
657
658fn unsigned(row: &rusqlite::Row<'_>, column: usize) -> rusqlite::Result<u64> {
659 let value: i64 = row.get(column)?;
660 u64::try_from(value).map_err(|e| {
661 rusqlite::Error::FromSqlConversionFailure(
662 column,
663 rusqlite::types::Type::Integer,
664 Box::new(e),
665 )
666 })
667}
668
669fn store_record(db: &Connection, hash: &str, record: &crate::scan::Record) -> Result<(), Error> {
670 let wtxid = record.wtxid.to_string();
671 let commit_wtxid = record.commit.compute_wtxid().to_string();
672 db.execute(
673 "INSERT OR IGNORE INTO transactions VALUES(?1,?2,?3)",
674 params![
675 wtxid,
676 record.verified.txid().to_string(),
677 record.proof.transaction
678 ],
679 )?;
680 db.execute(
681 "INSERT OR IGNORE INTO transactions VALUES(?1,?2,?3)",
682 params![
683 commit_wtxid,
684 record.commit.compute_txid().to_string(),
685 record.raw_commit
686 ],
687 )?;
688 db.execute("INSERT INTO records VALUES(?1,?2,?3,?4,?5,?6,?7) ON CONFLICT(block_hash,position) DO UPDATE SET wtxid=excluded.wtxid,author=excluded.author,record=excluded.record,commit_wtxid=excluded.commit_wtxid,proof=excluded.proof",
689 params![hash, record.position, wtxid, record.verified.author().to_string(), record.verified.record_bytes(), commit_wtxid, serde_json::to_vec(&record.proof)?])?;
690 Ok(())
691}