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 let mut status = ArchiveStatus::default();
139 status.api_version = API_VERSION;
140 status.chain_genesis = self
141 .config
142 .chain
143 .genesis()
144 .map_err(|e| Error::Invalid(e.to_string()))?
145 .0;
146 status.application_start = self.config.application_start;
147 status.retention = self.config.retention;
148 status.generation = generation;
149 status.selected_tip = selected_tip;
150 status.indexed_through = indexed_through;
151 status.scanned_blocks = scanned_blocks;
152 status.unresolved_dependencies = unresolved_dependencies;
153 status.stored_transactions = stored_transactions;
154 status.scan_rules_version = SCAN_RULES_VERSION;
155 status.peer_synced_through =
156 peers::synced_through(&self.db, self.config.application_start.height)?;
157 status.peer_omissions = peers::omission_count(&self.db)?;
158 status.unaudited_records = peers::unaudited_count(&self.db)?;
159 Ok(status)
160 }
161
162 fn indexed_through(&self) -> Result<Option<ChainPoint>, Error> {
164 let start = self.config.application_start.height;
165 let mut next = start;
166 let mut indexed_through = None;
167 let mut rows = self.db.prepare(
168 "SELECT s.height,s.hash FROM selected s JOIN scans c ON c.block_hash=s.hash
169 WHERE s.height>=?1 AND c.rules=?2 AND c.unresolved=0 ORDER BY s.height",
170 )?;
171 for row in rows.query_map(params![sql_integer(start)?, SCAN_RULES_VERSION], point)? {
172 let current = row?;
173 if current.height != next {
174 break;
175 }
176 next += 1;
177 indexed_through = Some(current);
178 }
179 Ok(indexed_through)
180 }
181
182 pub fn ticket(&self, height: u64) -> Result<ScanTicket, Error> {
183 let point = self
184 .db
185 .query_row(
186 "SELECT height,hash FROM selected WHERE height=?1",
187 [sql_integer(height)?],
188 point,
189 )
190 .optional()?
191 .ok_or_else(|| Error::Invalid("block is not in the selected view".into()))?;
192 Ok(ScanTicket {
193 generation: generation(&self.db)?,
194 point,
195 })
196 }
197
198 pub(crate) fn header_at(&self, height: u64) -> Result<Option<Header>, Error> {
199 let raw: Option<Vec<u8>> = self
200 .db
201 .query_row(
202 "SELECT h.bytes FROM selected s JOIN headers h ON h.hash=s.hash WHERE s.height=?1",
203 [sql_integer(height)?],
204 |r| r.get(0),
205 )
206 .optional()?;
207 raw.map(|raw| {
208 bitcoin::consensus::deserialize(&raw)
209 .map_err(|e| Error::Invalid(format!("stored header: {e}")))
210 })
211 .transpose()
212 }
213
214 pub(crate) fn select(&mut self, start: u64, headers: &[Header]) -> Result<(), Error> {
218 if headers.is_empty() || start < self.config.application_start.height {
219 return Err(Error::Invalid("empty or out-of-domain chain update".into()));
220 }
221 let end = start
222 .checked_add(headers.len() as u64 - 1)
223 .filter(|h| *h <= i64::MAX as u64)
224 .ok_or_else(|| Error::Invalid("chain update height overflow".into()))?;
225 if start == self.config.application_start.height {
226 if headers[0].block_hash() != self.config.application_start.hash {
227 return Err(Error::Invalid(
228 "selected chain does not contain configured application start".into(),
229 ));
230 }
231 } else {
232 let previous = self
233 .header_at(start - 1)?
234 .ok_or_else(|| Error::Invalid("chain update has a gap".into()))?;
235 if headers[0].prev_blockhash != previous.block_hash() {
236 return Err(Error::Invalid(
237 "chain update does not join selected parent".into(),
238 ));
239 }
240 }
241 for pair in headers.windows(2) {
242 if pair[1].prev_blockhash != pair[0].block_hash() {
243 return Err(Error::Invalid("noncontiguous selected headers".into()));
244 }
245 }
246 let mut changed_at = None;
247 for (offset, header) in headers.iter().enumerate() {
248 let height = start + offset as u64;
249 if self.header_at(height)?.is_some_and(|old| old != *header) {
250 changed_at = Some(height);
251 break;
252 }
253 }
254 let tx = self.db.transaction()?;
255 if let Some(height) = changed_at {
256 tx.execute(
257 "DELETE FROM selected WHERE height>=?1",
258 [sql_integer(height)?],
259 )?;
260 tx.execute(
261 "UPDATE state SET generation=generation+1 WHERE singleton=1",
262 [],
263 )?;
264 tx.execute("INSERT INTO events(generation,kind,height) SELECT generation,'reorg',?1 FROM state", [sql_integer(height)?])?;
265 peers::reorg(&tx, height)?;
266 }
267 for (offset, header) in headers.iter().enumerate() {
268 let hash = header.block_hash().to_string();
269 tx.execute(
270 "INSERT OR IGNORE INTO headers VALUES(?1,?2)",
271 params![hash, serialize(header)],
272 )?;
273 tx.execute("INSERT INTO selected VALUES(?1,?2) ON CONFLICT(height) DO UPDATE SET hash=excluded.hash",
274 params![sql_integer(start + offset as u64)?, hash])?;
275 }
276 tx.execute("INSERT INTO events(generation,kind,height,block_hash) SELECT generation,'chain',?1,?2 FROM state",
277 params![sql_integer(end)?, headers.last().expect("nonempty checked").block_hash().to_string()])?;
278 tx.commit()?;
279 Ok(())
280 }
281
282 pub(crate) fn check_ticket(&self, ticket: ScanTicket) -> Result<(), Error> {
283 if generation(&self.db)? != ticket.generation
284 || self.ticket(ticket.point.height)?.point != ticket.point
285 {
286 return Err(Error::Stale);
287 }
288 Ok(())
289 }
290
291 pub(crate) fn next_scan(&self) -> Result<Option<ScanTicket>, Error> {
292 let height: Option<u64> = self
293 .db
294 .query_row(
295 "SELECT s.height FROM selected s LEFT JOIN scans c ON c.block_hash=s.hash
296 WHERE c.block_hash IS NULL OR c.rules!=?1 ORDER BY s.height LIMIT 1",
297 [SCAN_RULES_VERSION],
298 |r| unsigned(r, 0),
299 )
300 .optional()?;
301 height.map(|h| self.ticket(h)).transpose()
302 }
303
304 pub(crate) fn transaction(&self, txid: bitcoin::Txid) -> Result<Option<Vec<u8>>, Error> {
305 Ok(self
306 .db
307 .query_row(
308 "SELECT raw FROM transactions WHERE txid=?1 LIMIT 1",
309 [txid.to_string()],
310 |r| r.get(0),
311 )
312 .optional()?)
313 }
314
315 pub(crate) fn location(&self, txid: bitcoin::Txid) -> Result<Option<ChainPoint>, Error> {
316 Ok(self.db.query_row(
317 "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",
318 [txid.to_string()], point,
319 ).optional()?)
320 }
321
322 pub(crate) fn inventory_page(
330 &self,
331 from: u64,
332 to: u64,
333 after: Option<(u64, u32)>,
334 limit: u32,
335 ) -> Result<InventoryRows, Error> {
336 let Some(ceiling) = self.indexed_through()? else {
337 return Ok(InventoryRows {
338 scanned_through: None,
339 entries: Vec::new(),
340 more: false,
341 });
342 };
343 let (after_height, after_position) = match after {
344 Some((height, position)) => (sql_integer(height)?, i64::from(position)),
345 None => (-1, -1),
346 };
347 let mut statement = self.db.prepare(
348 "SELECT s.height,s.hash,l.position,l.txid,l.wtxid FROM records p
349 JOIN locators l ON l.block_hash=p.block_hash AND l.position=p.position
350 JOIN selected s ON s.hash=p.block_hash
351 JOIN scans c ON c.block_hash=p.block_hash AND c.rules=?5
352 WHERE s.height>=?1 AND s.height<=?2 AND s.height<=?7
353 AND (s.height>?3 OR (s.height=?3 AND l.position>?4))
354 ORDER BY s.height,l.position LIMIT ?6",
355 )?;
356 let wanted = usize::try_from(limit).map_err(|e| Error::Invalid(e.to_string()))?;
357 let bound = params![
358 sql_integer(from)?,
359 sql_integer(to)?,
360 after_height,
361 after_position,
362 SCAN_RULES_VERSION,
363 i64::from(limit) + 1,
364 sql_integer(ceiling.height)?
365 ];
366 let mut entries = Vec::new();
367 for row in statement.query_map(bound, |row| {
368 Ok((
369 point(row)?,
370 row.get::<_, i64>(2)?,
371 row.get::<_, String>(3)?,
372 row.get::<_, String>(4)?,
373 ))
374 })? {
375 let (block, position, txid, wtxid) = row?;
376 let invalid = |e: String| Error::Invalid(format!("archived locator: {e}"));
377 entries.push((
378 block,
379 u32::try_from(position).map_err(|e| invalid(e.to_string()))?,
380 txid.parse()
381 .map_err(|e: bitcoin::hex::HexToArrayError| invalid(e.to_string()))?,
382 wtxid
383 .parse()
384 .map_err(|e: bitcoin::hex::HexToArrayError| invalid(e.to_string()))?,
385 ));
386 }
387 let more = entries.len() > wanted;
388 entries.truncate(wanted);
389 Ok(InventoryRows {
390 scanned_through: Some(ceiling.height),
391 entries,
392 more,
393 })
394 }
395
396 pub(crate) fn record(&self, txid: bitcoin::Txid) -> Result<Option<ArchivedRecord>, Error> {
398 let row: Option<(ChainPoint, u32, String, Vec<u8>, Vec<u8>)> = self
399 .db
400 .query_row(
401 "SELECT s.height,s.hash,r.position,r.wtxid,r.proof,t.raw FROM records r
402 JOIN selected s ON s.hash=r.block_hash
403 JOIN transactions reveal ON reveal.wtxid=r.wtxid
404 JOIN transactions t ON t.wtxid=r.commit_wtxid
405 WHERE reveal.txid=?1 ORDER BY s.height,r.position LIMIT 1",
406 [txid.to_string()],
407 |r| Ok((point(r)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
408 )
409 .optional()?;
410 let Some((block, position, wtxid, proof, commit)) = row else {
411 return Ok(None);
412 };
413 Ok(Some(ArchivedRecord {
414 receipt: RecordReceipt {
415 generation: generation(&self.db)?,
416 block,
417 position,
418 txid,
419 wtxid: wtxid
420 .parse()
421 .map_err(|e| Error::Invalid(format!("stored WTXID: {e}")))?,
422 },
423 package: RecordPackage {
424 version: API_VERSION,
425 chain_genesis: self
426 .config
427 .chain
428 .genesis()
429 .map_err(|e| Error::Invalid(e.to_string()))?
430 .0,
431 block,
432 reveal: serde_json::from_slice(&proof)?,
433 commit,
434 },
435 }))
436 }
437
438 pub(crate) fn next_missing_dependency(
441 &self,
442 after: Option<bitcoin::Txid>,
443 ) -> Result<Option<(bitcoin::Txid, u64)>, Error> {
444 let id: Option<String> = self
445 .db
446 .query_row(
447 "SELECT DISTINCT d.commit_txid FROM dependencies d
448 JOIN selected s ON s.hash=d.block_hash
449 WHERE NOT EXISTS(SELECT 1 FROM transactions t WHERE t.txid=d.commit_txid)
450 AND (?1 IS NULL OR d.commit_txid>?1)
451 ORDER BY d.commit_txid LIMIT 1",
452 [after.map(|id| id.to_string())],
453 |row| row.get(0),
454 )
455 .optional()?;
456 id.map(|id| {
457 let id = id
458 .parse()
459 .map_err(|error| Error::Invalid(format!("stored parent TXID: {error}")))?;
460 Ok((id, generation(&self.db)?))
461 })
462 .transpose()
463 }
464
465 pub(crate) fn check_generation(&self, expected: u64) -> Result<(), Error> {
466 if generation(&self.db)? != expected {
467 return Err(Error::Stale);
468 }
469 Ok(())
470 }
471
472 pub(crate) fn pending_for(
473 &self,
474 txid: bitcoin::Txid,
475 ) -> Result<Vec<(ScanTicket, u32, urma_chain::inclusion::TransactionProof)>, Error> {
476 let mut stmt = self.db.prepare(
477 "SELECT s.height,s.hash,d.position,d.proof FROM dependencies d
478 JOIN selected s ON s.hash=d.block_hash WHERE d.commit_txid=?1
479 ORDER BY s.height,d.position LIMIT 128",
480 )?;
481 let rows = stmt.query_map([txid.to_string()], |r| {
482 Ok((point(r)?, r.get::<_, u32>(2)?, r.get::<_, Vec<u8>>(3)?))
483 })?;
484 let epoch = generation(&self.db)?;
485 rows.map(|row| {
486 let (point, position, proof) = row?;
487 Ok((
488 ScanTicket {
489 generation: epoch,
490 point,
491 },
492 position,
493 serde_json::from_slice(&proof)?,
494 ))
495 })
496 .collect()
497 }
498
499 pub(crate) fn cached_dependency(&self) -> Result<Option<Vec<u8>>, Error> {
500 Ok(self
501 .db
502 .query_row(
503 "SELECT t.raw FROM dependencies d JOIN selected s ON s.hash=d.block_hash
504 JOIN transactions t ON t.txid=d.commit_txid ORDER BY s.height,d.position LIMIT 1",
505 [],
506 |row| row.get(0),
507 )
508 .optional()?)
509 }
510
511 pub(crate) fn finish_dependencies(
514 &mut self,
515 completed: Vec<(ScanTicket, u32, Option<crate::scan::Record>)>,
516 ) -> Result<usize, Error> {
517 for (ticket, _, _) in &completed {
518 self.check_ticket(*ticket)?;
519 }
520 let tx = self.db.transaction()?;
521 let mut count = 0;
522 for (ticket, position, record) in completed {
523 let hash = ticket.point.hash.to_string();
524 let removed = tx.execute(
525 "DELETE FROM dependencies WHERE block_hash=?1 AND position=?2",
526 params![hash, position],
527 )?;
528 if removed == 0 {
529 continue;
530 }
531 if let Some(record) = record {
532 store_record(&tx, &hash, &record)?;
533 }
534 tx.execute("UPDATE scans SET unresolved=(SELECT count(*) FROM dependencies WHERE block_hash=?1) WHERE block_hash=?1", [&hash])?;
535 tx.execute("INSERT INTO events(generation,kind,height,block_hash) VALUES(?1,'dependency',?2,?3)",
536 params![sql_integer(ticket.generation)?, sql_integer(ticket.point.height)?, hash])?;
537 count += 1;
538 }
539 tx.commit()?;
540 Ok(count)
541 }
542
543 pub(crate) fn admit_record(
544 &mut self,
545 ticket: ScanTicket,
546 record: crate::scan::Record,
547 ) -> Result<RecordReceipt, Error> {
548 self.check_ticket(ticket)?;
549 let receipt = RecordReceipt {
550 generation: ticket.generation,
551 block: ticket.point,
552 position: record.position,
553 txid: record.verified.txid(),
554 wtxid: record.wtxid,
555 };
556 let tx = self.db.transaction()?;
557 store_record(&tx, &ticket.point.hash.to_string(), &record)?;
558 tx.execute(
559 "INSERT INTO events(generation,kind,height,block_hash) VALUES(?1,'record',?2,?3)",
560 params![
561 sql_integer(ticket.generation)?,
562 sql_integer(ticket.point.height)?,
563 ticket.point.hash.to_string()
564 ],
565 )?;
566 tx.commit()?;
569 Ok(receipt)
570 }
571
572 pub(crate) fn commit_scan(&mut self, batch: crate::scan::ScanBatch) -> Result<(), Error> {
573 self.check_ticket(batch.ticket)?;
574 let point = batch.ticket.point;
575 let hash = point.hash.to_string();
576 let tx = self.db.transaction()?;
577 for (position, (txid, wtxid)) in batch.locators.iter().enumerate() {
578 tx.execute("INSERT INTO locators VALUES(?1,?2,?3,?4) ON CONFLICT(block_hash,position) DO UPDATE SET txid=excluded.txid,wtxid=excluded.wtxid",
579 params![hash, sql_integer(position as u64)?, txid.to_string(), wtxid.to_string()])?;
580 }
581 tx.execute("DELETE FROM dependencies WHERE block_hash=?1", [&hash])?;
584 peers::record_omissions(&tx, &batch)?;
585 for record in batch.records {
586 store_record(&tx, &hash, &record)?;
587 }
588 let unresolved = batch.dependencies.len() as u64;
589 for dependency in batch.dependencies {
590 tx.execute(
591 "INSERT INTO dependencies VALUES(?1,?2,?3,?4)",
592 params![
593 hash,
594 dependency.position,
595 dependency.commit.to_string(),
596 serde_json::to_vec(&dependency.proof)?
597 ],
598 )?;
599 }
600 tx.execute("INSERT INTO scans VALUES(?1,?2,?3) ON CONFLICT(block_hash) DO UPDATE SET rules=excluded.rules,unresolved=excluded.unresolved",
601 params![hash, SCAN_RULES_VERSION, sql_integer(unresolved)?])?;
602 tx.execute("INSERT INTO events(generation,kind,height,block_hash) SELECT generation,'scan',?1,?2 FROM state",
603 params![sql_integer(point.height)?, hash])?;
604 tx.commit()?;
605 Ok(())
606 }
607}
608
609fn migrate(db: &mut Connection, version: u32) -> Result<(), Error> {
610 if version >= 5 {
611 return Ok(());
612 }
613 let tx = db.transaction()?;
614 if version < 2 {
615 tx.execute_batch(objects::SCHEMA)?;
616 } else if version == 2 {
617 objects::migrate_v2(&tx)?;
618 }
619 if version < 4 {
620 tx.execute_batch(names::SCHEMA)?;
621 }
622 tx.execute_batch(peers::SCHEMA)?;
623 tx.commit()?;
624 Ok(())
625}
626
627pub(crate) struct InventoryRows {
629 pub(crate) scanned_through: Option<u64>,
630 pub(crate) entries: Vec<(ChainPoint, u32, bitcoin::Txid, bitcoin::Wtxid)>,
631 pub(crate) more: bool,
632}
633
634fn generation(db: &Connection) -> Result<u64, Error> {
635 Ok(
636 db.query_row("SELECT generation FROM state WHERE singleton=1", [], |r| {
637 unsigned(r, 0)
638 })?,
639 )
640}
641
642fn point(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainPoint> {
643 let hash: String = row.get(1)?;
644 Ok(ChainPoint {
645 height: unsigned(row, 0)?,
646 hash: hash.parse::<BlockHash>().map_err(|e| {
647 rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
648 })?,
649 })
650}
651
652fn sql_integer(value: u64) -> Result<i64, Error> {
653 i64::try_from(value).map_err(|_| Error::Invalid("integer exceeds archive range".into()))
654}
655
656fn unsigned(row: &rusqlite::Row<'_>, column: usize) -> rusqlite::Result<u64> {
657 let value: i64 = row.get(column)?;
658 u64::try_from(value).map_err(|e| {
659 rusqlite::Error::FromSqlConversionFailure(
660 column,
661 rusqlite::types::Type::Integer,
662 Box::new(e),
663 )
664 })
665}
666
667fn store_record(db: &Connection, hash: &str, record: &crate::scan::Record) -> Result<(), Error> {
668 let wtxid = record.wtxid.to_string();
669 let commit_wtxid = record.commit.compute_wtxid().to_string();
670 db.execute(
671 "INSERT OR IGNORE INTO transactions VALUES(?1,?2,?3)",
672 params![
673 wtxid,
674 record.verified.txid().to_string(),
675 record.proof.transaction
676 ],
677 )?;
678 db.execute(
679 "INSERT OR IGNORE INTO transactions VALUES(?1,?2,?3)",
680 params![
681 commit_wtxid,
682 record.commit.compute_txid().to_string(),
683 record.raw_commit
684 ],
685 )?;
686 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",
687 params![hash, record.position, wtxid, record.verified.author().to_string(), record.verified.record_bytes(), commit_wtxid, serde_json::to_vec(&record.proof)?])?;
688 Ok(())
689}