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 scanned_from = self.config.application_start.height;
337 let Some(ceiling) = self.indexed_through()? else {
338 return Ok(InventoryRows {
339 scanned_from,
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_from,
393 scanned_through: Some(ceiling.height),
394 entries,
395 more,
396 })
397 }
398
399 pub(crate) fn record(&self, txid: bitcoin::Txid) -> Result<Option<ArchivedRecord>, Error> {
401 let row: Option<(ChainPoint, u32, String, Vec<u8>, Vec<u8>)> = self
402 .db
403 .query_row(
404 "SELECT s.height,s.hash,r.position,r.wtxid,r.proof,t.raw FROM records r
405 JOIN selected s ON s.hash=r.block_hash
406 JOIN transactions reveal ON reveal.wtxid=r.wtxid
407 JOIN transactions t ON t.wtxid=r.commit_wtxid
408 WHERE reveal.txid=?1 ORDER BY s.height,r.position LIMIT 1",
409 [txid.to_string()],
410 |r| Ok((point(r)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
411 )
412 .optional()?;
413 let Some((block, position, wtxid, proof, commit)) = row else {
414 return Ok(None);
415 };
416 Ok(Some(ArchivedRecord {
417 receipt: RecordReceipt {
418 generation: generation(&self.db)?,
419 block,
420 position,
421 txid,
422 wtxid: wtxid
423 .parse()
424 .map_err(|e| Error::Invalid(format!("stored WTXID: {e}")))?,
425 },
426 package: RecordPackage {
427 version: API_VERSION,
428 chain_genesis: self
429 .config
430 .chain
431 .genesis()
432 .map_err(|e| Error::Invalid(e.to_string()))?
433 .0,
434 block,
435 reveal: serde_json::from_slice(&proof)?,
436 commit,
437 },
438 }))
439 }
440
441 pub(crate) fn next_missing_dependency(
444 &self,
445 after: Option<bitcoin::Txid>,
446 ) -> Result<Option<(bitcoin::Txid, u64)>, Error> {
447 let id: Option<String> = self
448 .db
449 .query_row(
450 "SELECT DISTINCT d.commit_txid FROM dependencies d
451 JOIN selected s ON s.hash=d.block_hash
452 WHERE NOT EXISTS(SELECT 1 FROM transactions t WHERE t.txid=d.commit_txid)
453 AND (?1 IS NULL OR d.commit_txid>?1)
454 ORDER BY d.commit_txid LIMIT 1",
455 [after.map(|id| id.to_string())],
456 |row| row.get(0),
457 )
458 .optional()?;
459 id.map(|id| {
460 let id = id
461 .parse()
462 .map_err(|error| Error::Invalid(format!("stored parent TXID: {error}")))?;
463 Ok((id, generation(&self.db)?))
464 })
465 .transpose()
466 }
467
468 pub(crate) fn check_generation(&self, expected: u64) -> Result<(), Error> {
469 if generation(&self.db)? != expected {
470 return Err(Error::Stale);
471 }
472 Ok(())
473 }
474
475 pub(crate) fn pending_for(
476 &self,
477 txid: bitcoin::Txid,
478 ) -> Result<Vec<(ScanTicket, u32, urma_chain::inclusion::TransactionProof)>, Error> {
479 let mut stmt = self.db.prepare(
480 "SELECT s.height,s.hash,d.position,d.proof FROM dependencies d
481 JOIN selected s ON s.hash=d.block_hash WHERE d.commit_txid=?1
482 ORDER BY s.height,d.position LIMIT 128",
483 )?;
484 let rows = stmt.query_map([txid.to_string()], |r| {
485 Ok((point(r)?, r.get::<_, u32>(2)?, r.get::<_, Vec<u8>>(3)?))
486 })?;
487 let epoch = generation(&self.db)?;
488 rows.map(|row| {
489 let (point, position, proof) = row?;
490 Ok((
491 ScanTicket {
492 generation: epoch,
493 point,
494 },
495 position,
496 serde_json::from_slice(&proof)?,
497 ))
498 })
499 .collect()
500 }
501
502 pub(crate) fn cached_dependency(&self) -> Result<Option<Vec<u8>>, Error> {
503 Ok(self
504 .db
505 .query_row(
506 "SELECT t.raw FROM dependencies d JOIN selected s ON s.hash=d.block_hash
507 JOIN transactions t ON t.txid=d.commit_txid ORDER BY s.height,d.position LIMIT 1",
508 [],
509 |row| row.get(0),
510 )
511 .optional()?)
512 }
513
514 pub(crate) fn finish_dependencies(
517 &mut self,
518 completed: Vec<(ScanTicket, u32, Option<crate::scan::Record>)>,
519 ) -> Result<usize, Error> {
520 for (ticket, _, _) in &completed {
521 self.check_ticket(*ticket)?;
522 }
523 let tx = self.db.transaction()?;
524 let mut count = 0;
525 for (ticket, position, record) in completed {
526 let hash = ticket.point.hash.to_string();
527 let removed = tx.execute(
528 "DELETE FROM dependencies WHERE block_hash=?1 AND position=?2",
529 params![hash, position],
530 )?;
531 if removed == 0 {
532 continue;
533 }
534 if let Some(record) = record {
535 store_record(&tx, &hash, &record)?;
536 }
537 tx.execute("UPDATE scans SET unresolved=(SELECT count(*) FROM dependencies WHERE block_hash=?1) WHERE block_hash=?1", [&hash])?;
538 tx.execute("INSERT INTO events(generation,kind,height,block_hash) VALUES(?1,'dependency',?2,?3)",
539 params![sql_integer(ticket.generation)?, sql_integer(ticket.point.height)?, hash])?;
540 count += 1;
541 }
542 tx.commit()?;
543 Ok(count)
544 }
545
546 pub(crate) fn admit_record(
547 &mut self,
548 ticket: ScanTicket,
549 record: crate::scan::Record,
550 ) -> Result<RecordReceipt, Error> {
551 self.check_ticket(ticket)?;
552 let receipt = RecordReceipt {
553 generation: ticket.generation,
554 block: ticket.point,
555 position: record.position,
556 txid: record.verified.txid(),
557 wtxid: record.wtxid,
558 };
559 let tx = self.db.transaction()?;
560 store_record(&tx, &ticket.point.hash.to_string(), &record)?;
561 tx.execute(
562 "INSERT INTO events(generation,kind,height,block_hash) VALUES(?1,'record',?2,?3)",
563 params![
564 sql_integer(ticket.generation)?,
565 sql_integer(ticket.point.height)?,
566 ticket.point.hash.to_string()
567 ],
568 )?;
569 tx.commit()?;
572 Ok(receipt)
573 }
574
575 pub(crate) fn commit_scan(&mut self, batch: crate::scan::ScanBatch) -> Result<(), Error> {
576 self.check_ticket(batch.ticket)?;
577 let point = batch.ticket.point;
578 let hash = point.hash.to_string();
579 let tx = self.db.transaction()?;
580 for (position, (txid, wtxid)) in batch.locators.iter().enumerate() {
581 tx.execute("INSERT INTO locators VALUES(?1,?2,?3,?4) ON CONFLICT(block_hash,position) DO UPDATE SET txid=excluded.txid,wtxid=excluded.wtxid",
582 params![hash, sql_integer(position as u64)?, txid.to_string(), wtxid.to_string()])?;
583 }
584 tx.execute("DELETE FROM dependencies WHERE block_hash=?1", [&hash])?;
587 peers::record_omissions(&tx, &batch)?;
588 for record in batch.records {
589 store_record(&tx, &hash, &record)?;
590 }
591 let unresolved = batch.dependencies.len() as u64;
592 for dependency in batch.dependencies {
593 tx.execute(
594 "INSERT INTO dependencies VALUES(?1,?2,?3,?4)",
595 params![
596 hash,
597 dependency.position,
598 dependency.commit.to_string(),
599 serde_json::to_vec(&dependency.proof)?
600 ],
601 )?;
602 }
603 tx.execute("INSERT INTO scans VALUES(?1,?2,?3) ON CONFLICT(block_hash) DO UPDATE SET rules=excluded.rules,unresolved=excluded.unresolved",
604 params![hash, SCAN_RULES_VERSION, sql_integer(unresolved)?])?;
605 tx.execute("INSERT INTO events(generation,kind,height,block_hash) SELECT generation,'scan',?1,?2 FROM state",
606 params![sql_integer(point.height)?, hash])?;
607 tx.commit()?;
608 Ok(())
609 }
610}
611
612fn migrate(db: &mut Connection, version: u32) -> Result<(), Error> {
613 if version >= 5 {
614 return Ok(());
615 }
616 let tx = db.transaction()?;
617 if version < 2 {
618 tx.execute_batch(objects::SCHEMA)?;
619 } else if version == 2 {
620 objects::migrate_v2(&tx)?;
621 }
622 if version < 4 {
623 tx.execute_batch(names::SCHEMA)?;
624 }
625 tx.execute_batch(peers::SCHEMA)?;
626 tx.commit()?;
627 Ok(())
628}
629
630pub(crate) struct InventoryRows {
632 pub(crate) scanned_from: u64,
633 pub(crate) scanned_through: Option<u64>,
634 pub(crate) entries: Vec<(ChainPoint, u32, bitcoin::Txid, bitcoin::Wtxid)>,
635 pub(crate) more: bool,
636}
637
638fn generation(db: &Connection) -> Result<u64, Error> {
639 Ok(
640 db.query_row("SELECT generation FROM state WHERE singleton=1", [], |r| {
641 unsigned(r, 0)
642 })?,
643 )
644}
645
646fn point(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainPoint> {
647 let hash: String = row.get(1)?;
648 Ok(ChainPoint {
649 height: unsigned(row, 0)?,
650 hash: hash.parse::<BlockHash>().map_err(|e| {
651 rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
652 })?,
653 })
654}
655
656fn sql_integer(value: u64) -> Result<i64, Error> {
657 i64::try_from(value).map_err(|_| Error::Invalid("integer exceeds archive range".into()))
658}
659
660fn unsigned(row: &rusqlite::Row<'_>, column: usize) -> rusqlite::Result<u64> {
661 let value: i64 = row.get(column)?;
662 u64::try_from(value).map_err(|e| {
663 rusqlite::Error::FromSqlConversionFailure(
664 column,
665 rusqlite::types::Type::Integer,
666 Box::new(e),
667 )
668 })
669}
670
671fn store_record(db: &Connection, hash: &str, record: &crate::scan::Record) -> Result<(), Error> {
672 let wtxid = record.wtxid.to_string();
673 let commit_wtxid = record.commit.compute_wtxid().to_string();
674 db.execute(
675 "INSERT OR IGNORE INTO transactions VALUES(?1,?2,?3)",
676 params![
677 wtxid,
678 record.verified.txid().to_string(),
679 record.proof.transaction
680 ],
681 )?;
682 db.execute(
683 "INSERT OR IGNORE INTO transactions VALUES(?1,?2,?3)",
684 params![
685 commit_wtxid,
686 record.commit.compute_txid().to_string(),
687 record.raw_commit
688 ],
689 )?;
690 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",
691 params![hash, record.position, wtxid, record.verified.author().to_string(), record.verified.record_bytes(), commit_wtxid, serde_json::to_vec(&record.proof)?])?;
692 Ok(())
693}