1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use rusqlite::{params, Connection};
5use serde::{Deserialize, Serialize};
6
7use crate::{
8 crypto::generate_secret_hex,
9 error::{Error, Result},
10 identity::Identity,
11 keychain::{
12 HdKeychain, KEY_MODEL_VERSION_V3, MAX_VAULT_KEYS, SLOT_FAMILY_HARMONIA_VAULT,
13 SLOT_FAMILY_VAULT,
14 },
15 types::{Certificate, Contract, ContractStatus, ContractType, Role},
16};
17
18const META_RGB_PRIVATE_KEY_HEX: &str = "rgb_private_key_hex";
19const META_ROOT_PRIVATE_KEY_HEX: &str = "root_private_key_hex";
20const META_ROOT_MNEMONIC: &str = "root_mnemonic";
21const META_NICKNAME: &str = "nickname";
22const META_WALLET_LABEL: &str = "wallet_label";
23const META_KEY_MODEL_VERSION: &str = "key_model_version";
24
25pub const MAX_PGP_KEYS: u32 = 1_000;
26const MASTER_DB_FILENAME: &str = "master.db";
27const RGB_DB_FILENAME: &str = "rgb.db";
28const VOUCHER_DB_FILENAME: &str = "voucher.db";
29const WALLET_DB_FILENAME: &str = "wallet.db";
30const RGB_SHARD_DIR: &str = "identities";
31
32pub struct RgbWallet {
34 master_conn: Connection,
35 rgb_conn: Connection,
36}
37
38struct WalletDiskPaths {
39 base_dir: PathBuf,
40 master_path: PathBuf,
41 rgb_path: PathBuf,
42 wallet_migration_path: PathBuf,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct PgpIdentityRecord {
47 pub label: String,
48 pub key_index: u32,
49 pub public_key_hex: String,
50 pub is_active: bool,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct PgpIdentitySnapshot {
55 pub label: String,
56 pub key_index: u32,
57 pub private_key_hex: String,
58 pub is_active: bool,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct WalletSlotRecord {
63 pub family: String,
64 pub slot_index: u32,
65 pub descriptor: String,
66 pub db_rel_path: Option<String>,
67 pub label: Option<String>,
68 pub created_at: String,
69 pub updated_at: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct WalletSnapshot {
75 pub private_key_hex: String,
76 #[serde(default)]
77 pub root_private_key_hex: Option<String>,
78 #[serde(default)]
79 pub root_mnemonic: Option<String>,
80 #[serde(default)]
81 pub wallet_label: Option<String>,
82 #[serde(default)]
83 pub pgp_identities: Vec<PgpIdentitySnapshot>,
84 pub nickname: Option<String>,
85 pub contracts: Vec<Contract>,
86 pub certificates: Vec<Certificate>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct PaymentAttemptRecord {
91 pub attempt_id: String,
92 pub created_at: String,
93 pub updated_at: String,
94 pub service_origin: String,
95 pub endpoint_path: String,
96 pub method: String,
97 pub rail: String,
98 pub action_hint: String,
99 pub required_amount: String,
100 pub payment_unit: String,
101 pub payment_reference: Option<String>,
102 pub request_hash: String,
103 pub response_status: Option<u16>,
104 pub response_code: Option<String>,
105 pub response_body: Option<String>,
106 pub recovery_state: String,
107 pub final_state: String,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct PaymentLossRecord {
112 pub loss_id: String,
113 pub attempt_id: String,
114 pub created_at: String,
115 pub service_origin: String,
116 pub endpoint_path: String,
117 pub method: String,
118 pub rail: String,
119 pub amount: String,
120 pub payment_reference: Option<String>,
121 pub failure_stage: String,
122 pub response_status: Option<u16>,
123 pub response_code: Option<String>,
124 pub response_body: Option<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct PaymentBlacklistRecord {
129 pub service_origin: String,
130 pub endpoint_path: String,
131 pub method: String,
132 pub rail: String,
133 pub blacklisted_until: Option<String>,
134 pub reason: String,
135 pub triggered_by_loss_id: Option<String>,
136 pub created_at: String,
137 pub updated_at: String,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct PaymentTransactionRecord {
142 pub txn_id: String,
143 pub attempt_id: Option<String>,
144 pub created_at: String,
145 pub updated_at: String,
146 pub occurred_at: String,
147 pub direction: String,
148 pub role: String,
149 pub source_system: String,
150 pub service_origin: Option<String>,
151 pub frontend_kind: Option<String>,
152 pub transport_kind: Option<String>,
153 pub endpoint_path: Option<String>,
154 pub method: Option<String>,
155 pub session_id: Option<String>,
156 pub action_kind: String,
157 pub resource_ref: Option<String>,
158 pub contract_ref: Option<String>,
159 pub invoice_ref: Option<String>,
160 pub challenge_id: Option<String>,
161 pub rail: String,
162 pub payment_unit: String,
163 pub quoted_amount: Option<String>,
164 pub settled_amount: Option<String>,
165 pub fee_amount: Option<String>,
166 pub proof_ref: Option<String>,
167 pub proof_kind: Option<String>,
168 pub payer_ref: Option<String>,
169 pub payee_ref: Option<String>,
170 pub request_hash: Option<String>,
171 pub response_code: Option<String>,
172 pub status: String,
173 pub metadata_json: Option<String>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct PaymentTransactionEventRecord {
178 pub event_id: String,
179 pub txn_id: String,
180 pub created_at: String,
181 pub event_type: String,
182 pub status: String,
183 pub actor: String,
184 pub details_json: Option<String>,
185}
186
187#[derive(Debug, Clone)]
188pub struct NewPaymentAttempt<'a> {
189 pub service_origin: &'a str,
190 pub endpoint_path: &'a str,
191 pub method: &'a str,
192 pub rail: &'a str,
193 pub action_hint: &'a str,
194 pub required_amount: &'a str,
195 pub payment_unit: &'a str,
196 pub payment_reference: Option<&'a str>,
197 pub request_hash: &'a str,
198}
199
200#[derive(Debug, Clone)]
201pub struct PaymentAttemptUpdate<'a> {
202 pub payment_reference: Option<&'a str>,
203 pub response_status: Option<u16>,
204 pub response_code: Option<&'a str>,
205 pub response_body: Option<&'a str>,
206 pub recovery_state: &'a str,
207 pub final_state: &'a str,
208}
209
210#[derive(Debug, Clone)]
211pub struct NewPaymentTransaction<'a> {
212 pub attempt_id: Option<&'a str>,
213 pub occurred_at: Option<&'a str>,
214 pub direction: &'a str,
215 pub role: &'a str,
216 pub source_system: &'a str,
217 pub service_origin: Option<&'a str>,
218 pub frontend_kind: Option<&'a str>,
219 pub transport_kind: Option<&'a str>,
220 pub endpoint_path: Option<&'a str>,
221 pub method: Option<&'a str>,
222 pub session_id: Option<&'a str>,
223 pub action_kind: &'a str,
224 pub resource_ref: Option<&'a str>,
225 pub contract_ref: Option<&'a str>,
226 pub invoice_ref: Option<&'a str>,
227 pub challenge_id: Option<&'a str>,
228 pub rail: &'a str,
229 pub payment_unit: &'a str,
230 pub quoted_amount: Option<&'a str>,
231 pub settled_amount: Option<&'a str>,
232 pub fee_amount: Option<&'a str>,
233 pub proof_ref: Option<&'a str>,
234 pub proof_kind: Option<&'a str>,
235 pub payer_ref: Option<&'a str>,
236 pub payee_ref: Option<&'a str>,
237 pub request_hash: Option<&'a str>,
238 pub response_code: Option<&'a str>,
239 pub status: &'a str,
240 pub metadata_json: Option<&'a str>,
241}
242
243#[derive(Debug, Clone)]
244pub struct PaymentTransactionUpdate<'a> {
245 pub occurred_at: Option<&'a str>,
246 pub service_origin: Option<&'a str>,
247 pub frontend_kind: Option<&'a str>,
248 pub transport_kind: Option<&'a str>,
249 pub endpoint_path: Option<&'a str>,
250 pub method: Option<&'a str>,
251 pub session_id: Option<&'a str>,
252 pub action_kind: Option<&'a str>,
253 pub resource_ref: Option<&'a str>,
254 pub contract_ref: Option<&'a str>,
255 pub invoice_ref: Option<&'a str>,
256 pub challenge_id: Option<&'a str>,
257 pub quoted_amount: Option<&'a str>,
258 pub settled_amount: Option<&'a str>,
259 pub fee_amount: Option<&'a str>,
260 pub proof_ref: Option<&'a str>,
261 pub proof_kind: Option<&'a str>,
262 pub payer_ref: Option<&'a str>,
263 pub payee_ref: Option<&'a str>,
264 pub request_hash: Option<&'a str>,
265 pub response_code: Option<&'a str>,
266 pub status: &'a str,
267 pub metadata_json: Option<&'a str>,
268}
269
270#[derive(Debug, Clone)]
271pub struct NewPaymentTransactionEvent<'a> {
272 pub txn_id: &'a str,
273 pub event_type: &'a str,
274 pub status: &'a str,
275 pub actor: &'a str,
276 pub details_json: Option<&'a str>,
277}
278
279impl RgbWallet {
280 fn resolve_disk_paths(path: &Path) -> Result<WalletDiskPaths> {
281 let normalized = if path
282 .file_name()
283 .and_then(|n| n.to_str())
284 .unwrap_or_default()
285 .is_empty()
286 {
287 PathBuf::from(MASTER_DB_FILENAME)
288 } else {
289 path.to_path_buf()
290 };
291 let file_name = normalized
292 .file_name()
293 .and_then(|n| n.to_str())
294 .unwrap_or(MASTER_DB_FILENAME);
295 let base_dir = normalized
296 .parent()
297 .map(ToOwned::to_owned)
298 .unwrap_or_else(|| PathBuf::from("."));
299 let master_path = if file_name.eq_ignore_ascii_case(MASTER_DB_FILENAME) {
300 normalized.clone()
301 } else if file_name.eq_ignore_ascii_case(RGB_DB_FILENAME)
302 || file_name.eq_ignore_ascii_case(WALLET_DB_FILENAME)
303 {
304 base_dir.join(MASTER_DB_FILENAME)
305 } else {
306 normalized.clone()
307 };
308 Ok(WalletDiskPaths {
309 base_dir: base_dir.clone(),
310 master_path,
311 rgb_path: base_dir.join(RGB_DB_FILENAME),
312 wallet_migration_path: base_dir.join(WALLET_DB_FILENAME),
313 })
314 }
315
316 fn derive_wallet_label(path: &Path) -> String {
317 let stem = path
318 .file_stem()
319 .and_then(|x| x.to_str())
320 .unwrap_or("wallet");
321 if stem.eq_ignore_ascii_case("master")
322 || stem.eq_ignore_ascii_case("rgb")
323 || stem.eq_ignore_ascii_case("wallet")
324 {
325 path.parent()
326 .and_then(|p| p.file_name())
327 .and_then(|x| x.to_str())
328 .map(ToString::to_string)
329 .unwrap_or_else(|| "wallet".to_string())
330 } else {
331 stem.to_string()
332 }
333 }
334
335 fn init_master_tables(conn: &Connection) -> Result<()> {
339 conn.execute_batch(
340 "
341 PRAGMA journal_mode=WAL;
342 PRAGMA foreign_keys=ON;
343
344 CREATE TABLE IF NOT EXISTS wallet_metadata (
345 key TEXT PRIMARY KEY,
346 value TEXT NOT NULL
347 );
348
349 CREATE TABLE IF NOT EXISTS pgp_identities (
350 label TEXT PRIMARY KEY,
351 key_index INTEGER NOT NULL UNIQUE,
352 private_key_hex TEXT NOT NULL,
353 public_key_hex TEXT NOT NULL,
354 created_at TEXT NOT NULL,
355 is_active INTEGER NOT NULL DEFAULT 0
356 );
357
358 CREATE TABLE IF NOT EXISTS wallet_slots (
359 family TEXT NOT NULL,
360 slot_index INTEGER NOT NULL,
361 descriptor TEXT NOT NULL,
362 db_rel_path TEXT,
363 label TEXT,
364 created_at TEXT NOT NULL,
365 updated_at TEXT NOT NULL,
366 PRIMARY KEY (family, slot_index)
367 );
368
369 CREATE TABLE IF NOT EXISTS payment_attempts (
370 attempt_id TEXT PRIMARY KEY,
371 created_at TEXT NOT NULL,
372 updated_at TEXT NOT NULL,
373 service_origin TEXT NOT NULL,
374 endpoint_path TEXT NOT NULL,
375 method TEXT NOT NULL,
376 rail TEXT NOT NULL,
377 action_hint TEXT NOT NULL,
378 required_amount TEXT NOT NULL,
379 payment_unit TEXT NOT NULL,
380 payment_reference TEXT,
381 request_hash TEXT NOT NULL,
382 response_status INTEGER,
383 response_code TEXT,
384 response_body TEXT,
385 recovery_state TEXT NOT NULL,
386 final_state TEXT NOT NULL
387 );
388
389 CREATE TABLE IF NOT EXISTS payment_losses (
390 loss_id TEXT PRIMARY KEY,
391 attempt_id TEXT NOT NULL,
392 created_at TEXT NOT NULL,
393 service_origin TEXT NOT NULL,
394 endpoint_path TEXT NOT NULL,
395 method TEXT NOT NULL,
396 rail TEXT NOT NULL,
397 amount TEXT NOT NULL,
398 payment_reference TEXT,
399 failure_stage TEXT NOT NULL,
400 response_status INTEGER,
401 response_code TEXT,
402 response_body TEXT
403 );
404
405 CREATE TABLE IF NOT EXISTS payment_blacklist (
406 service_origin TEXT NOT NULL,
407 endpoint_path TEXT NOT NULL,
408 method TEXT NOT NULL,
409 rail TEXT NOT NULL,
410 blacklisted_until TEXT,
411 reason TEXT NOT NULL,
412 triggered_by_loss_id TEXT,
413 created_at TEXT NOT NULL,
414 updated_at TEXT NOT NULL,
415 PRIMARY KEY (service_origin, endpoint_path, method, rail)
416 );
417
418 CREATE TABLE IF NOT EXISTS payment_transactions (
419 txn_id TEXT PRIMARY KEY,
420 attempt_id TEXT,
421 created_at TEXT NOT NULL,
422 updated_at TEXT NOT NULL,
423 occurred_at TEXT NOT NULL,
424 direction TEXT NOT NULL,
425 role TEXT NOT NULL,
426 source_system TEXT NOT NULL,
427 service_origin TEXT,
428 frontend_kind TEXT,
429 transport_kind TEXT,
430 endpoint_path TEXT,
431 method TEXT,
432 session_id TEXT,
433 action_kind TEXT NOT NULL,
434 resource_ref TEXT,
435 contract_ref TEXT,
436 invoice_ref TEXT,
437 challenge_id TEXT,
438 rail TEXT NOT NULL,
439 payment_unit TEXT NOT NULL,
440 quoted_amount TEXT,
441 settled_amount TEXT,
442 fee_amount TEXT,
443 proof_ref TEXT,
444 proof_kind TEXT,
445 payer_ref TEXT,
446 payee_ref TEXT,
447 request_hash TEXT,
448 response_code TEXT,
449 status TEXT NOT NULL,
450 metadata_json TEXT
451 );
452
453 CREATE TABLE IF NOT EXISTS payment_transaction_events (
454 event_id TEXT PRIMARY KEY,
455 txn_id TEXT NOT NULL,
456 created_at TEXT NOT NULL,
457 event_type TEXT NOT NULL,
458 status TEXT NOT NULL,
459 actor TEXT NOT NULL,
460 details_json TEXT NOT NULL DEFAULT '{}'
461 );
462
463 CREATE INDEX IF NOT EXISTS idx_payment_transactions_created_at
464 ON payment_transactions(created_at DESC);
465 CREATE INDEX IF NOT EXISTS idx_payment_transactions_status
466 ON payment_transactions(status, created_at DESC);
467 CREATE INDEX IF NOT EXISTS idx_payment_transactions_direction
468 ON payment_transactions(direction, created_at DESC);
469 CREATE INDEX IF NOT EXISTS idx_payment_transactions_action_kind
470 ON payment_transactions(action_kind, created_at DESC);
471 CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_transactions_proof_ref
472 ON payment_transactions(direction, rail, proof_ref)
473 WHERE proof_ref IS NOT NULL AND proof_ref != '';
474 CREATE INDEX IF NOT EXISTS idx_payment_transaction_events_txn_id
475 ON payment_transaction_events(txn_id, created_at ASC);
476 ",
477 )?;
478 Ok(())
479 }
480
481 fn init_master_schema(conn: &Connection, allow_generate: bool) -> Result<()> {
489 Self::init_master_tables(conn)?;
490 ensure_root_and_identity_materialized(conn, allow_generate)?;
491 ensure_default_pgp_identity(conn)?;
492 Ok(())
493 }
494
495 fn init_identity_schema(conn: &Connection) -> Result<()> {
496 conn.execute_batch(
497 "
498 PRAGMA journal_mode=WAL;
499 PRAGMA foreign_keys=ON;
500
501 CREATE TABLE IF NOT EXISTS contracts (
502 contract_id TEXT PRIMARY KEY,
503 contract_type TEXT NOT NULL DEFAULT 'service',
504 status TEXT NOT NULL DEFAULT 'issued',
505 witness_secret TEXT,
506 witness_proof TEXT,
507 amount_units INTEGER NOT NULL DEFAULT 0,
508 work_spec TEXT NOT NULL DEFAULT '',
509 buyer_fingerprint TEXT NOT NULL DEFAULT '',
510 seller_fingerprint TEXT,
511 reference_post TEXT,
512 delivery_deadline TEXT,
513 role TEXT NOT NULL DEFAULT 'buyer',
514 delivered_text TEXT,
515 certificate_id TEXT,
516 created_at TEXT NOT NULL,
517 updated_at TEXT NOT NULL
518 );
519
520 CREATE TABLE IF NOT EXISTS certificates (
521 certificate_id TEXT PRIMARY KEY,
522 contract_id TEXT,
523 witness_secret TEXT,
524 witness_proof TEXT,
525 created_at TEXT NOT NULL
526 );
527
528 CREATE TABLE IF NOT EXISTS timeline_posts (
529 post_id TEXT PRIMARY KEY,
530 created_at TEXT NOT NULL,
531 updated_at TEXT NOT NULL,
532 metadata_json TEXT NOT NULL DEFAULT '{}'
533 );
534
535 CREATE TABLE IF NOT EXISTS timeline_comments (
536 comment_id TEXT PRIMARY KEY,
537 post_id TEXT NOT NULL DEFAULT '',
538 created_at TEXT NOT NULL,
539 updated_at TEXT NOT NULL,
540 metadata_json TEXT NOT NULL DEFAULT '{}'
541 );
542
543 CREATE TABLE IF NOT EXISTS timeline_bids (
544 bid_post_id TEXT PRIMARY KEY,
545 contract_id TEXT NOT NULL DEFAULT '',
546 service_post_id TEXT NOT NULL DEFAULT '',
547 created_at TEXT NOT NULL,
548 metadata_json TEXT NOT NULL DEFAULT '{}'
549 );
550 ",
551 )?;
552 migrate_identity_schema(conn)?;
553 Ok(())
554 }
555
556 fn with_identity_conn<T, F>(&self, f: F) -> Result<T>
557 where
558 F: FnOnce(&Connection) -> Result<T>,
559 {
560 f(&self.rgb_conn)
561 }
562
563 fn refresh_slot_registry(&self) -> Result<()> {
564 let now = chrono::Utc::now().to_rfc3339();
565 let root_hex = self.derive_slot_hex("root", 0)?;
566 let rgb_hex = self.derive_slot_hex("rgb", 0)?;
567 let webcash_hex = self.derive_slot_hex("webcash", 0)?;
568 let voucher_hex = self.derive_slot_hex("voucher", 0)?;
569 let bitcoin_hex = self.derive_slot_hex("bitcoin", 0)?;
570 let vault_hex = self.derive_slot_hex(SLOT_FAMILY_VAULT, 0)?;
571
572 self.master_conn.execute(
573 "INSERT OR REPLACE INTO wallet_slots (family, slot_index, descriptor, db_rel_path, label, created_at, updated_at)
574 VALUES ('rgb', 0, ?1, NULL, (SELECT label FROM wallet_slots WHERE family='rgb' AND slot_index=0), COALESCE((SELECT created_at FROM wallet_slots WHERE family='rgb' AND slot_index=0), ?2), ?2)",
575 params![rgb_hex, now],
576 )?;
577 self.master_conn.execute(
578 "INSERT OR REPLACE INTO wallet_slots (family, slot_index, descriptor, db_rel_path, label, created_at, updated_at)
579 VALUES ('webcash', 0, ?1, NULL, (SELECT label FROM wallet_slots WHERE family='webcash' AND slot_index=0), COALESCE((SELECT created_at FROM wallet_slots WHERE family='webcash' AND slot_index=0), ?2), ?2)",
580 params![webcash_hex, now],
581 )?;
582 self.master_conn.execute(
583 "INSERT OR REPLACE INTO wallet_slots (family, slot_index, descriptor, db_rel_path, label, created_at, updated_at)
584 VALUES ('bitcoin', 0, ?1, NULL, (SELECT label FROM wallet_slots WHERE family='bitcoin' AND slot_index=0), COALESCE((SELECT created_at FROM wallet_slots WHERE family='bitcoin' AND slot_index=0), ?2), ?2)",
585 params![bitcoin_hex, now],
586 )?;
587 self.master_conn.execute(
588 "INSERT OR REPLACE INTO wallet_slots (family, slot_index, descriptor, db_rel_path, label, created_at, updated_at)
589 VALUES ('voucher', 0, ?1, ?2, (SELECT label FROM wallet_slots WHERE family='voucher' AND slot_index=0), COALESCE((SELECT created_at FROM wallet_slots WHERE family='voucher' AND slot_index=0), ?3), ?3)",
590 params![voucher_hex, Some(VOUCHER_DB_FILENAME.to_string()), now],
591 )?;
592 self.master_conn.execute(
593 "INSERT OR REPLACE INTO wallet_slots (family, slot_index, descriptor, db_rel_path, label, created_at, updated_at)
594 VALUES ('root', 0, ?1, NULL, (SELECT label FROM wallet_slots WHERE family='root' AND slot_index=0), COALESCE((SELECT created_at FROM wallet_slots WHERE family='root' AND slot_index=0), ?2), ?2)",
595 params![root_hex, now],
596 )?;
597 self.master_conn.execute(
598 "INSERT OR REPLACE INTO wallet_slots (family, slot_index, descriptor, db_rel_path, label, created_at, updated_at)
599 VALUES (?1, 0, ?2, NULL, (SELECT label FROM wallet_slots WHERE family=?1 AND slot_index=0), COALESCE((SELECT created_at FROM wallet_slots WHERE family=?1 AND slot_index=0), ?3), ?3)",
600 params![SLOT_FAMILY_VAULT, vault_hex, now],
601 )?;
602
603 let mut vault_stmt = self.master_conn.prepare(
604 "SELECT slot_index, label, created_at
605 FROM wallet_slots
606 WHERE family = ?1 AND slot_index > 0
607 ORDER BY slot_index ASC",
608 )?;
609 let vault_rows = vault_stmt.query_map(params![SLOT_FAMILY_VAULT], |row| {
610 Ok((
611 row.get::<_, u32>(0)?,
612 row.get::<_, Option<String>>(1)?,
613 row.get::<_, String>(2)?,
614 ))
615 })?;
616 let vault_rows = vault_rows.collect::<std::result::Result<Vec<_>, _>>()?;
617 drop(vault_stmt);
618 for row in vault_rows {
619 let (slot_index, label, created_at) = row;
620 let public_key_hex = self
621 .derive_vault_identity_for_index(slot_index)?
622 .public_key_hex();
623 self.master_conn.execute(
624 "INSERT OR REPLACE INTO wallet_slots (family, slot_index, descriptor, db_rel_path, label, created_at, updated_at)
625 VALUES (?1, ?2, ?3, NULL, ?4, ?5, ?6)",
626 params![
627 SLOT_FAMILY_VAULT,
628 i64::from(slot_index),
629 public_key_hex,
630 label,
631 created_at,
632 now
633 ],
634 )?;
635 }
636
637 let mut stmt = self.master_conn.prepare(
638 "SELECT key_index, public_key_hex, label FROM pgp_identities ORDER BY key_index ASC",
639 )?;
640 let rows = stmt.query_map([], |row| {
641 let key_index_i: i64 = row.get(0)?;
642 let key_index = u32::try_from(key_index_i)
643 .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, key_index_i))?;
644 let public_key_hex: String = row.get(1)?;
645 let label: String = row.get(2)?;
646 Ok((key_index, public_key_hex, label))
647 })?;
648 for row in rows {
649 let (key_index, public_key_hex, label) = row?;
650 self.master_conn.execute(
651 "INSERT OR REPLACE INTO wallet_slots (family, slot_index, descriptor, db_rel_path, label, created_at, updated_at)
652 VALUES ('pgp', ?1, ?2, ?3, ?4, COALESCE((SELECT created_at FROM wallet_slots WHERE family='pgp' AND slot_index=?1), ?5), ?5)",
653 params![
654 i64::from(key_index),
655 public_key_hex,
656 Some(RGB_DB_FILENAME.to_string()),
657 label,
658 now
659 ],
660 )?;
661 }
662 Ok(())
663 }
664
665 fn import_previous_layout(paths: &WalletDiskPaths) -> Result<()> {
666 let source_path = if paths.rgb_path.exists() {
667 paths.rgb_path.clone()
668 } else if paths.wallet_migration_path.exists() {
669 paths.wallet_migration_path.clone()
670 } else {
671 return Err(Error::NotFound("no wallet data source found".to_string()));
672 };
673
674 let source_conn = Connection::open(&source_path)?;
675 migrate_identity_schema_if_present(&source_conn)?;
676
677 let master_conn = Connection::open(&paths.master_path)?;
678 Self::init_master_tables(&master_conn)?;
679
680 if table_exists(&source_conn, "wallet_metadata")? {
681 let mut stmt = source_conn.prepare("SELECT key, value FROM wallet_metadata")?;
682 let rows = stmt.query_map([], |row| {
683 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
684 })?;
685 for row in rows {
686 let (key, value) = row?;
687 master_conn.execute(
688 "INSERT OR REPLACE INTO wallet_metadata (key, value) VALUES (?1, ?2)",
689 params![key, value],
690 )?;
691 }
692 }
693
694 if table_exists(&source_conn, "pgp_identities")? {
695 let mut stmt = source_conn.prepare(
696 "SELECT label, key_index, private_key_hex, public_key_hex, created_at, is_active
697 FROM pgp_identities",
698 )?;
699 let rows = stmt.query_map([], |row| {
700 Ok((
701 row.get::<_, String>(0)?,
702 row.get::<_, i64>(1)?,
703 row.get::<_, String>(2)?,
704 row.get::<_, String>(3)?,
705 row.get::<_, String>(4)?,
706 row.get::<_, i64>(5)?,
707 ))
708 })?;
709 master_conn.execute("DELETE FROM pgp_identities", [])?;
710 for row in rows {
711 let (label, key_index, private_key_hex, public_key_hex, created_at, is_active) =
712 row?;
713 master_conn.execute(
714 "INSERT OR REPLACE INTO pgp_identities
715 (label, key_index, private_key_hex, public_key_hex, created_at, is_active)
716 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
717 params![
718 label,
719 key_index,
720 private_key_hex,
721 public_key_hex,
722 created_at,
723 is_active
724 ],
725 )?;
726 }
727 }
728 ensure_root_and_identity_materialized(&master_conn, false)?;
729 ensure_default_pgp_identity(&master_conn)?;
730 drop(master_conn);
731
732 let rgb_conn = Connection::open(&paths.rgb_path)?;
733 Self::init_identity_schema(&rgb_conn)?;
734 if !same_path(&source_path, &paths.rgb_path) {
735 migrate_rgb_state(&source_conn, &rgb_conn)?;
736 }
737 Self::merge_sharded_rgb_data(&paths.base_dir, &rgb_conn)?;
738 Ok(())
739 }
740
741 fn merge_sharded_rgb_data(base_dir: &Path, rgb_conn: &Connection) -> Result<()> {
742 let shard_dir = base_dir.join(RGB_SHARD_DIR);
743 if !shard_dir.exists() {
744 return Ok(());
745 }
746 for entry in std::fs::read_dir(&shard_dir)
747 .map_err(|e| Error::Other(anyhow::anyhow!("cannot read rgb shard dir: {e}")))?
748 {
749 let entry = entry
750 .map_err(|e| Error::Other(anyhow::anyhow!("cannot read rgb shard entry: {e}")))?;
751 let path = entry.path();
752 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
753 continue;
754 };
755 if !name.starts_with("identity-") || !name.ends_with(".db") {
756 continue;
757 }
758 let shard_conn = Connection::open(&path)?;
759 migrate_identity_schema_if_present(&shard_conn)?;
760 migrate_rgb_state(&shard_conn, rgb_conn)?;
761 }
762 Ok(())
763 }
764
765 fn open_from_disk(path: &Path, allow_create: bool) -> Result<Self> {
766 let paths = Self::resolve_disk_paths(path)?;
767 std::fs::create_dir_all(&paths.base_dir)
768 .map_err(|e| Error::Other(anyhow::anyhow!("cannot create wallet dir: {e}")))?;
769
770 if !paths.master_path.exists() {
771 if paths.rgb_path.exists() || paths.wallet_migration_path.exists() {
772 Self::import_previous_layout(&paths)?;
773 } else if !allow_create {
774 return Err(Error::NotFound(format!(
775 "master wallet database not found at {}",
776 paths.master_path.display()
777 )));
778 }
779 }
780
781 let master_conn = Connection::open(&paths.master_path)?;
782 Self::init_master_schema(&master_conn, allow_create)?;
783 let rgb_conn = Connection::open(&paths.rgb_path)?;
784 Self::init_identity_schema(&rgb_conn)?;
785 Self::merge_sharded_rgb_data(&paths.base_dir, &rgb_conn)?;
786 let wallet = Self {
787 master_conn,
788 rgb_conn,
789 };
790 wallet.refresh_slot_registry()?;
791
792 if wallet.wallet_label()?.as_deref().unwrap_or("default") == "default" {
793 let derived = Self::derive_wallet_label(path);
794 wallet.set_wallet_label(&derived)?;
795 let _ = wallet.rename_pgp_label("default", &derived);
796 wallet.refresh_slot_registry()?;
797 }
798 Ok(wallet)
799 }
800
801 pub fn create(path: &Path) -> Result<Self> {
803 Self::open_from_disk(path, true)
804 }
805
806 pub fn open(path: &Path) -> Result<Self> {
808 Self::open_from_disk(path, false)
809 }
810
811 pub fn open_memory() -> Result<Self> {
813 let master_conn = Connection::open_in_memory()?;
814 Self::init_master_schema(&master_conn, true)?;
815 let rgb_conn = Connection::open_in_memory()?;
816 Self::init_identity_schema(&rgb_conn)?;
817 let wallet = Self {
818 master_conn,
819 rgb_conn,
820 };
821 if wallet.wallet_label()?.as_deref().unwrap_or("default") == "default" {
822 wallet.set_wallet_label("memory-wallet")?;
823 let _ = wallet.rename_pgp_label("default", "memory-wallet");
824 }
825 wallet.refresh_slot_registry()?;
826 Ok(wallet)
827 }
828
829 pub fn root_private_key_hex(&self) -> Result<String> {
832 metadata_value(&self.master_conn, META_ROOT_PRIVATE_KEY_HEX)?
833 .ok_or_else(|| Error::Other(anyhow::anyhow!("missing master entropy hex")))
834 }
835
836 fn keychain(&self) -> Result<HdKeychain> {
837 if let Some(words) = metadata_value(&self.master_conn, META_ROOT_MNEMONIC)? {
838 return HdKeychain::from_mnemonic_words(&words);
839 }
840 let entropy_hex = self.root_private_key_hex()?;
841 HdKeychain::from_entropy_hex(&entropy_hex)
842 }
843
844 fn set_master_keychain_material(&self, keychain: &HdKeychain) -> Result<()> {
845 set_metadata_value(
846 &self.master_conn,
847 META_ROOT_PRIVATE_KEY_HEX,
848 &keychain.entropy_hex(),
849 )?;
850 set_metadata_value(
851 &self.master_conn,
852 META_ROOT_MNEMONIC,
853 &keychain.mnemonic_words(),
854 )?;
855 set_metadata_value(
856 &self.master_conn,
857 META_RGB_PRIVATE_KEY_HEX,
858 &keychain.derive_slot_hex("rgb", 0)?,
859 )?;
860 set_metadata_value(
861 &self.master_conn,
862 META_KEY_MODEL_VERSION,
863 KEY_MODEL_VERSION_V3,
864 )?;
865 Ok(())
866 }
867
868 pub fn identity(&self) -> Result<Identity> {
869 self.rgb_identity()
870 }
871
872 pub fn rgb_identity(&self) -> Result<Identity> {
873 let hex = self.derive_slot_hex("rgb", 0)?;
874 Identity::from_hex(&hex)
875 }
876
877 pub fn derive_webcash_master_secret_hex(&self) -> Result<String> {
878 self.derive_slot_hex("webcash", 0)
879 }
880
881 pub fn derive_voucher_master_secret_hex(&self) -> Result<String> {
882 self.derive_slot_hex("voucher", 0)
883 }
884
885 pub fn derive_bitcoin_master_key_hex(&self) -> Result<String> {
886 self.derive_slot_hex("bitcoin", 0)
887 }
888
889 pub fn derive_vault_master_key_hex(&self) -> Result<String> {
890 self.derive_slot_hex(SLOT_FAMILY_VAULT, 0)
891 }
892
893 pub fn derive_harmonia_vault_master_key_hex(&self) -> Result<String> {
895 self.derive_slot_hex(SLOT_FAMILY_HARMONIA_VAULT, 0)
896 }
897
898 pub fn derive_vault_identity_for_index(&self, key_index: u32) -> Result<Identity> {
899 if key_index == 0 {
900 return Err(Error::Other(anyhow::anyhow!(
901 "vault key index 0 is reserved for the vault root"
902 )));
903 }
904 let private_key_hex = self.derive_slot_hex(SLOT_FAMILY_VAULT, key_index)?;
905 Identity::from_hex(&private_key_hex)
906 }
907
908 pub fn create_vault_identity(&self, label: Option<&str>) -> Result<WalletSlotRecord> {
909 let key_index = self.next_vault_key_index()?;
910 self.ensure_vault_identity_index(key_index, label)
911 }
912
913 pub fn ensure_vault_identity_index(
914 &self,
915 key_index: u32,
916 preferred_label: Option<&str>,
917 ) -> Result<WalletSlotRecord> {
918 if key_index == 0 || key_index >= MAX_VAULT_KEYS {
919 return Err(Error::Other(anyhow::anyhow!(
920 "vault key index out of range (valid: 1..{})",
921 MAX_VAULT_KEYS - 1
922 )));
923 }
924
925 let fallback_label = format!("vault-{key_index}-key-pairs");
926 let desired_raw = preferred_label.unwrap_or(fallback_label.as_str());
927 let desired = canonical_label(desired_raw)?;
928 let label = self.unique_vault_label(&desired, key_index)?;
929 let identity = self.derive_vault_identity_for_index(key_index)?;
930 let public_key_hex = identity.public_key_hex();
931
932 let tx = self.master_conn.unchecked_transaction()?;
933 tx.execute(
934 "DELETE FROM wallet_slots WHERE family = ?1 AND slot_index = ?2",
935 params![SLOT_FAMILY_VAULT, i64::from(key_index)],
936 )?;
937 tx.execute(
938 "DELETE FROM wallet_slots WHERE family = ?1 AND label = ?2",
939 params![SLOT_FAMILY_VAULT, label.clone()],
940 )?;
941 tx.execute(
942 "INSERT INTO wallet_slots (family, slot_index, descriptor, db_rel_path, label, created_at, updated_at)
943 VALUES (?1, ?2, ?3, NULL, ?4, ?5, ?5)",
944 params![
945 SLOT_FAMILY_VAULT,
946 i64::from(key_index),
947 public_key_hex,
948 label,
949 chrono::Utc::now().to_rfc3339(),
950 ],
951 )?;
952 tx.commit()?;
953 self.refresh_slot_registry()?;
954 self.vault_identity_by_index(key_index)
955 }
956
957 pub fn list_vault_identities(&self) -> Result<Vec<WalletSlotRecord>> {
958 self.list_wallet_slots(Some(SLOT_FAMILY_VAULT))
959 .map(|items| {
960 items
961 .into_iter()
962 .filter(|item| item.slot_index > 0)
963 .collect()
964 })
965 }
966
967 pub fn vault_identity_by_label(&self, label: &str) -> Result<WalletSlotRecord> {
968 let canonical = canonical_label(label)?;
969 self.list_vault_identities()?
970 .into_iter()
971 .find(|item| item.label.as_deref() == Some(canonical.as_str()))
972 .ok_or_else(|| Error::NotFound(format!("vault identity label '{canonical}' not found")))
973 }
974
975 pub fn vault_identity_by_index(&self, key_index: u32) -> Result<WalletSlotRecord> {
976 self.list_vault_identities()?
977 .into_iter()
978 .find(|item| item.slot_index == key_index)
979 .ok_or_else(|| Error::NotFound(format!("vault identity index '{key_index}' not found")))
980 }
981
982 pub fn derive_slot_hex(&self, family: &str, index: u32) -> Result<String> {
983 self.keychain()?.derive_slot_hex(family, index)
984 }
985
986 pub fn export_master_key_hex(&self) -> Result<String> {
987 self.root_private_key_hex()
988 }
989
990 pub fn export_master_key_mnemonic(&self) -> Result<String> {
991 self.keychain().map(|k| k.mnemonic_words())
992 }
993
994 pub fn export_recovery_mnemonic(&self) -> Result<String> {
999 if let Some(mnemonic) = metadata_value(&self.master_conn, META_ROOT_MNEMONIC)? {
1000 return Ok(mnemonic);
1001 }
1002 self.export_master_key_mnemonic()
1003 }
1004
1005 pub fn apply_master_key_hex(&self, root_private_key_hex: &str) -> Result<()> {
1006 let keychain = HdKeychain::from_entropy_hex(root_private_key_hex)?;
1007 self.set_master_keychain_material(&keychain)?;
1008
1009 let wallet_label = self
1010 .wallet_label()?
1011 .unwrap_or_else(|| "default".to_string());
1012 let label = canonical_label(&wallet_label)?;
1013 let pgp0_hex = keychain.derive_slot_hex("pgp", 0)?;
1014 let pgp0 = Identity::from_hex(&pgp0_hex)?;
1015 let tx = self.master_conn.unchecked_transaction()?;
1016 tx.execute("DELETE FROM pgp_identities", [])?;
1017 tx.execute(
1018 "INSERT INTO pgp_identities (label, key_index, private_key_hex, public_key_hex, created_at, is_active)
1019 VALUES (?1, 0, ?2, ?3, ?4, 1)",
1020 params![
1021 label,
1022 pgp0_hex,
1023 pgp0.public_key_hex(),
1024 chrono::Utc::now().to_rfc3339(),
1025 ],
1026 )?;
1027 tx.commit()?;
1028 self.refresh_slot_registry()?;
1029 Ok(())
1030 }
1031
1032 pub fn apply_master_key_mnemonic(&self, mnemonic: &str) -> Result<()> {
1033 let keychain = HdKeychain::from_mnemonic_words(mnemonic)?;
1034 self.apply_master_key_hex(&keychain.entropy_hex())
1035 }
1036
1037 pub fn has_local_state(&self) -> Result<bool> {
1038 let (contracts, certs) = self.with_identity_conn(|conn| {
1039 let mut stmt = conn.prepare("SELECT COUNT(*) FROM contracts")?;
1040 let contracts: i64 = stmt.query_row([], |row| row.get(0))?;
1041 let mut stmt = conn.prepare("SELECT COUNT(*) FROM certificates")?;
1042 let certs: i64 = stmt.query_row([], |row| row.get(0))?;
1043 Ok((contracts, certs))
1044 })?;
1045 Ok(contracts > 0 || certs > 0)
1046 }
1047
1048 pub fn active_pgp_identity(&self) -> Result<(PgpIdentityRecord, Identity)> {
1051 let mut stmt = self.master_conn.prepare(
1052 "SELECT label, key_index, private_key_hex, public_key_hex, is_active
1053 FROM pgp_identities
1054 WHERE is_active = 1
1055 ORDER BY key_index ASC
1056 LIMIT 1",
1057 )?;
1058 let mut rows = stmt.query([])?;
1059 let row = rows
1060 .next()?
1061 .ok_or_else(|| Error::Other(anyhow::anyhow!("no active PGP identity")))?;
1062
1063 let label: String = row.get(0)?;
1064 let key_index_i: i64 = row.get(1)?;
1065 let private_key_hex: String = row.get(2)?;
1066 let public_key_hex: String = row.get(3)?;
1067 let is_active_i: i64 = row.get(4)?;
1068 let key_index = u32::try_from(key_index_i)
1069 .map_err(|_| Error::Other(anyhow::anyhow!("invalid PGP key index in wallet")))?;
1070 let identity = Identity::from_hex(&private_key_hex)?;
1071
1072 Ok((
1073 PgpIdentityRecord {
1074 label,
1075 key_index,
1076 public_key_hex,
1077 is_active: is_active_i == 1,
1078 },
1079 identity,
1080 ))
1081 }
1082
1083 pub fn pgp_identity_by_label(&self, label: &str) -> Result<(PgpIdentityRecord, Identity)> {
1084 let canonical = canonical_label(label)?;
1085 let mut stmt = self.master_conn.prepare(
1086 "SELECT label, key_index, private_key_hex, public_key_hex, is_active
1087 FROM pgp_identities WHERE label = ?1 LIMIT 1",
1088 )?;
1089 let mut rows = stmt.query(params![canonical])?;
1090 let row = rows
1091 .next()?
1092 .ok_or_else(|| Error::NotFound(format!("PGP identity label '{label}' not found")))?;
1093
1094 let label: String = row.get(0)?;
1095 let key_index_i: i64 = row.get(1)?;
1096 let private_key_hex: String = row.get(2)?;
1097 let public_key_hex: String = row.get(3)?;
1098 let is_active_i: i64 = row.get(4)?;
1099 let key_index = u32::try_from(key_index_i)
1100 .map_err(|_| Error::Other(anyhow::anyhow!("invalid PGP key index in wallet")))?;
1101 let identity = Identity::from_hex(&private_key_hex)?;
1102
1103 Ok((
1104 PgpIdentityRecord {
1105 label,
1106 key_index,
1107 public_key_hex,
1108 is_active: is_active_i == 1,
1109 },
1110 identity,
1111 ))
1112 }
1113
1114 pub fn list_pgp_identities(&self) -> Result<Vec<PgpIdentityRecord>> {
1115 let mut stmt = self.master_conn.prepare(
1116 "SELECT label, key_index, public_key_hex, is_active
1117 FROM pgp_identities
1118 ORDER BY key_index ASC",
1119 )?;
1120 let rows = stmt.query_map([], |row| {
1121 let key_index: u32 = row.get(1)?;
1122 Ok(PgpIdentityRecord {
1123 label: row.get(0)?,
1124 key_index,
1125 public_key_hex: row.get(2)?,
1126 is_active: row.get::<_, i64>(3)? == 1,
1127 })
1128 })?;
1129 rows.collect::<std::result::Result<Vec<_>, _>>()
1130 .map_err(Error::Storage)
1131 }
1132
1133 pub fn create_pgp_identity(&self, label: &str) -> Result<PgpIdentityRecord> {
1134 let canonical = canonical_label(label)?;
1135 let mut exists_stmt = self
1136 .master_conn
1137 .prepare("SELECT COUNT(*) FROM pgp_identities WHERE label = ?1")?;
1138 let exists: i64 = exists_stmt.query_row(params![canonical.clone()], |row| row.get(0))?;
1139 if exists > 0 {
1140 return Err(Error::Other(anyhow::anyhow!(
1141 "PGP identity label '{canonical}' already exists"
1142 )));
1143 }
1144
1145 let key_index = self.next_pgp_key_index()?;
1146 let private_key_hex = self.derive_slot_hex("pgp", key_index)?;
1147 let identity = Identity::from_hex(&private_key_hex)?;
1148 let public_key_hex = identity.public_key_hex();
1149
1150 self.master_conn.execute(
1151 "INSERT INTO pgp_identities (label, key_index, private_key_hex, public_key_hex, created_at, is_active)
1152 VALUES (?1, ?2, ?3, ?4, ?5, 0)",
1153 params![
1154 canonical,
1155 i64::from(key_index),
1156 private_key_hex,
1157 public_key_hex,
1158 chrono::Utc::now().to_rfc3339(),
1159 ],
1160 )?;
1161
1162 self.refresh_slot_registry()?;
1163 self.pgp_identity_by_label(label).map(|(meta, _)| meta)
1164 }
1165
1166 pub fn derive_pgp_identity_for_index(&self, key_index: u32) -> Result<Identity> {
1167 let private_key_hex = self.derive_slot_hex("pgp", key_index)?;
1168 Identity::from_hex(&private_key_hex)
1169 }
1170
1171 pub fn ensure_pgp_identity_index(
1172 &self,
1173 key_index: u32,
1174 preferred_label: Option<&str>,
1175 set_active: bool,
1176 ) -> Result<PgpIdentityRecord> {
1177 if key_index >= MAX_PGP_KEYS {
1178 return Err(Error::Other(anyhow::anyhow!(
1179 "PGP key index out of range (max {})",
1180 MAX_PGP_KEYS - 1
1181 )));
1182 }
1183
1184 let fallback_label = format!("pgp-{key_index}");
1185 let desired_raw = preferred_label.unwrap_or(fallback_label.as_str());
1186 let desired = canonical_label(desired_raw)?;
1187 let label = self.unique_pgp_label(&desired, key_index)?;
1188 let identity = self.derive_pgp_identity_for_index(key_index)?;
1189 let public_key_hex = identity.public_key_hex();
1190
1191 let tx = self.master_conn.unchecked_transaction()?;
1192 tx.execute(
1193 "DELETE FROM pgp_identities WHERE key_index = ?1",
1194 params![i64::from(key_index)],
1195 )?;
1196 tx.execute(
1197 "DELETE FROM pgp_identities WHERE label = ?1",
1198 params![label.clone()],
1199 )?;
1200 if set_active {
1201 tx.execute("UPDATE pgp_identities SET is_active = 0", [])?;
1202 }
1203 tx.execute(
1204 "INSERT INTO pgp_identities (label, key_index, private_key_hex, public_key_hex, created_at, is_active)
1205 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1206 params![
1207 label.clone(),
1208 i64::from(key_index),
1209 identity.private_key_hex(),
1210 public_key_hex.clone(),
1211 chrono::Utc::now().to_rfc3339(),
1212 if set_active { 1 } else { 0 },
1213 ],
1214 )?;
1215 tx.commit()?;
1216 self.refresh_slot_registry()?;
1217 Ok(PgpIdentityRecord {
1218 label,
1219 key_index,
1220 public_key_hex,
1221 is_active: set_active,
1222 })
1223 }
1224
1225 pub fn set_active_pgp_identity(&self, label: &str) -> Result<()> {
1226 let canonical = canonical_label(label)?;
1227 let mut exists_stmt = self
1228 .master_conn
1229 .prepare("SELECT COUNT(*) FROM pgp_identities WHERE label = ?1")?;
1230 let exists: i64 = exists_stmt.query_row(params![canonical.clone()], |row| row.get(0))?;
1231 if exists == 0 {
1232 return Err(Error::NotFound(format!(
1233 "PGP identity label '{canonical}' not found"
1234 )));
1235 }
1236 let tx = self.master_conn.unchecked_transaction()?;
1237 tx.execute("UPDATE pgp_identities SET is_active = 0", [])?;
1238 tx.execute(
1239 "UPDATE pgp_identities SET is_active = 1 WHERE label = ?1",
1240 params![canonical],
1241 )?;
1242 tx.commit()?;
1243 self.refresh_slot_registry()?;
1244 Ok(())
1245 }
1246
1247 pub fn rename_pgp_label(&self, from: &str, to: &str) -> Result<()> {
1248 let from_c = canonical_label(from)?;
1249 let to_c = canonical_label(to)?;
1250 if from_c == to_c {
1251 return Ok(());
1252 }
1253 self.master_conn.execute(
1254 "UPDATE pgp_identities SET label = ?1 WHERE label = ?2",
1255 params![to_c, from_c],
1256 )?;
1257 self.refresh_slot_registry()?;
1258 Ok(())
1259 }
1260
1261 fn next_pgp_key_index(&self) -> Result<u32> {
1262 let mut stmt = self
1263 .master_conn
1264 .prepare("SELECT COALESCE(MAX(key_index), -1) FROM pgp_identities")?;
1265 let max_idx: i64 = stmt.query_row([], |row| row.get(0))?;
1266 let next = max_idx.saturating_add(1);
1267 let next = u32::try_from(next)
1268 .map_err(|_| Error::Other(anyhow::anyhow!("too many PGP identities in wallet")))?;
1269 if next >= MAX_PGP_KEYS {
1270 return Err(Error::Other(anyhow::anyhow!(
1271 "PGP key index out of range (max {})",
1272 MAX_PGP_KEYS - 1
1273 )));
1274 }
1275 Ok(next)
1276 }
1277
1278 fn next_vault_key_index(&self) -> Result<u32> {
1279 let mut stmt = self
1280 .master_conn
1281 .prepare("SELECT COALESCE(MAX(slot_index), 0) FROM wallet_slots WHERE family = ?1")?;
1282 let max_idx: i64 = stmt.query_row(params![SLOT_FAMILY_VAULT], |row| row.get(0))?;
1283 let next = max_idx.saturating_add(1);
1284 let next = u32::try_from(next)
1285 .map_err(|_| Error::Other(anyhow::anyhow!("too many vault identities in wallet")))?;
1286 if next >= MAX_VAULT_KEYS {
1287 return Err(Error::Other(anyhow::anyhow!(
1288 "vault key index out of range (max {})",
1289 MAX_VAULT_KEYS - 1
1290 )));
1291 }
1292 Ok(next)
1293 }
1294
1295 fn unique_pgp_label(&self, desired: &str, key_index: u32) -> Result<String> {
1296 let mut candidate = desired.to_string();
1297 let mut suffix = 1u32;
1298 loop {
1299 let mut stmt = self
1300 .master_conn
1301 .prepare("SELECT key_index FROM pgp_identities WHERE label = ?1 LIMIT 1")?;
1302 let mut rows = stmt.query(params![candidate.clone()])?;
1303 let Some(row) = rows.next()? else {
1304 return Ok(candidate);
1305 };
1306 let existing: u32 = row.get(0)?;
1307 if existing == key_index {
1308 return Ok(candidate);
1309 }
1310 candidate = format!("{desired}-{suffix}");
1311 suffix = suffix.saturating_add(1);
1312 }
1313 }
1314
1315 fn unique_vault_label(&self, desired: &str, key_index: u32) -> Result<String> {
1316 let mut candidate = desired.to_string();
1317 let mut suffix = 1u32;
1318 loop {
1319 let mut stmt = self.master_conn.prepare(
1320 "SELECT slot_index FROM wallet_slots
1321 WHERE family = ?1 AND label = ?2
1322 LIMIT 1",
1323 )?;
1324 let mut rows = stmt.query(params![SLOT_FAMILY_VAULT, candidate.clone()])?;
1325 let Some(row) = rows.next()? else {
1326 return Ok(candidate);
1327 };
1328 let existing_i: i64 = row.get(0)?;
1329 let existing = u32::try_from(existing_i).map_err(|_| {
1330 Error::Other(anyhow::anyhow!("invalid vault key index in wallet_slots"))
1331 })?;
1332 if existing == key_index {
1333 return Ok(candidate);
1334 }
1335 candidate = format!("{desired}-{suffix}");
1336 suffix = suffix.saturating_add(1);
1337 }
1338 }
1339
1340 pub fn fingerprint(&self) -> Result<String> {
1343 Ok(self.rgb_identity()?.fingerprint())
1344 }
1345
1346 pub fn nickname(&self) -> Result<Option<String>> {
1347 metadata_value(&self.master_conn, META_NICKNAME)
1348 }
1349
1350 pub fn set_nickname(&self, nick: &str) -> Result<()> {
1351 set_metadata_value(&self.master_conn, META_NICKNAME, nick)
1352 }
1353
1354 pub fn wallet_label(&self) -> Result<Option<String>> {
1355 metadata_value(&self.master_conn, META_WALLET_LABEL)
1356 }
1357
1358 pub fn set_wallet_label(&self, label: &str) -> Result<()> {
1359 let canonical = canonical_label(label)?;
1360 let out = set_metadata_value(&self.master_conn, META_WALLET_LABEL, &canonical);
1361 if out.is_ok() {
1362 let _ = self.refresh_slot_registry();
1363 }
1364 out
1365 }
1366
1367 pub fn list_wallet_slots(&self, family: Option<&str>) -> Result<Vec<WalletSlotRecord>> {
1368 let sql = if family.is_some() {
1369 "SELECT family, slot_index, descriptor, db_rel_path, label, created_at, updated_at
1370 FROM wallet_slots
1371 WHERE family = ?1
1372 ORDER BY family ASC, slot_index ASC"
1373 } else {
1374 "SELECT family, slot_index, descriptor, db_rel_path, label, created_at, updated_at
1375 FROM wallet_slots
1376 ORDER BY family ASC, slot_index ASC"
1377 };
1378 let mut stmt = self.master_conn.prepare(sql)?;
1379 let mapper = |row: &rusqlite::Row<'_>| -> rusqlite::Result<WalletSlotRecord> {
1380 Ok(WalletSlotRecord {
1381 family: row.get(0)?,
1382 slot_index: row.get(1)?,
1383 descriptor: row.get(2)?,
1384 db_rel_path: row.get(3)?,
1385 label: row.get(4)?,
1386 created_at: row.get(5)?,
1387 updated_at: row.get(6)?,
1388 })
1389 };
1390 let rows = match family {
1391 Some(name) => stmt.query_map(params![name], mapper)?,
1392 None => stmt.query_map([], mapper)?,
1393 };
1394 rows.collect::<std::result::Result<Vec<_>, _>>()
1395 .map_err(Error::Storage)
1396 }
1397
1398 pub fn record_payment_attempt_start(&self, input: &NewPaymentAttempt<'_>) -> Result<String> {
1401 let attempt_id = format!("pay_{}", generate_secret_hex());
1402 let now = chrono::Utc::now().to_rfc3339();
1403 self.master_conn.execute(
1404 "INSERT INTO payment_attempts (
1405 attempt_id, created_at, updated_at, service_origin, endpoint_path,
1406 method, rail, action_hint, required_amount, payment_unit,
1407 payment_reference, request_hash, response_status, response_code,
1408 response_body, recovery_state, final_state
1409 ) VALUES (?1, ?2, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL, NULL, NULL, 'pending', 'pending')",
1410 params![
1411 attempt_id,
1412 now,
1413 input.service_origin,
1414 input.endpoint_path,
1415 input.method,
1416 input.rail,
1417 input.action_hint,
1418 input.required_amount,
1419 input.payment_unit,
1420 input.payment_reference,
1421 input.request_hash,
1422 ],
1423 )?;
1424 Ok(attempt_id)
1425 }
1426
1427 pub fn update_payment_attempt(
1428 &self,
1429 attempt_id: &str,
1430 update: &PaymentAttemptUpdate<'_>,
1431 ) -> Result<()> {
1432 self.master_conn.execute(
1433 "UPDATE payment_attempts
1434 SET updated_at = ?2,
1435 payment_reference = COALESCE(?3, payment_reference),
1436 response_status = ?4,
1437 response_code = ?5,
1438 response_body = ?6,
1439 recovery_state = ?7,
1440 final_state = ?8
1441 WHERE attempt_id = ?1",
1442 params![
1443 attempt_id,
1444 chrono::Utc::now().to_rfc3339(),
1445 update.payment_reference,
1446 update.response_status.map(i64::from),
1447 update.response_code,
1448 update.response_body,
1449 update.recovery_state,
1450 update.final_state,
1451 ],
1452 )?;
1453 Ok(())
1454 }
1455
1456 pub fn store_payment_loss(
1457 &self,
1458 attempt_id: &str,
1459 service_origin: &str,
1460 endpoint_path: &str,
1461 method: &str,
1462 rail: &str,
1463 amount: &str,
1464 payment_reference: Option<&str>,
1465 failure_stage: &str,
1466 response_status: Option<u16>,
1467 response_code: Option<&str>,
1468 response_body: Option<&str>,
1469 ) -> Result<String> {
1470 let loss_id = format!("loss_{}", generate_secret_hex());
1471 let now = chrono::Utc::now().to_rfc3339();
1472 self.master_conn.execute(
1473 "INSERT INTO payment_losses (
1474 loss_id, attempt_id, created_at, service_origin, endpoint_path,
1475 method, rail, amount, payment_reference, failure_stage,
1476 response_status, response_code, response_body
1477 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
1478 params![
1479 loss_id,
1480 attempt_id,
1481 now,
1482 service_origin,
1483 endpoint_path,
1484 method,
1485 rail,
1486 amount,
1487 payment_reference,
1488 failure_stage,
1489 response_status.map(i64::from),
1490 response_code,
1491 response_body,
1492 ],
1493 )?;
1494 self.blacklist_if_needed(service_origin, endpoint_path, method, rail, &loss_id)?;
1495 Ok(loss_id)
1496 }
1497
1498 pub fn list_payment_losses(&self) -> Result<Vec<PaymentLossRecord>> {
1499 let mut stmt = self.master_conn.prepare(
1500 "SELECT loss_id, attempt_id, created_at, service_origin, endpoint_path,
1501 method, rail, amount, payment_reference, failure_stage,
1502 response_status, response_code, response_body
1503 FROM payment_losses
1504 ORDER BY created_at DESC",
1505 )?;
1506 let rows = stmt.query_map([], |row| {
1507 Ok(PaymentLossRecord {
1508 loss_id: row.get(0)?,
1509 attempt_id: row.get(1)?,
1510 created_at: row.get(2)?,
1511 service_origin: row.get(3)?,
1512 endpoint_path: row.get(4)?,
1513 method: row.get(5)?,
1514 rail: row.get(6)?,
1515 amount: row.get(7)?,
1516 payment_reference: row.get(8)?,
1517 failure_stage: row.get(9)?,
1518 response_status: row
1519 .get::<_, Option<i64>>(10)?
1520 .and_then(|v| u16::try_from(v).ok()),
1521 response_code: row.get(11)?,
1522 response_body: row.get(12)?,
1523 })
1524 })?;
1525 rows.collect::<std::result::Result<Vec<_>, _>>()
1526 .map_err(Error::Storage)
1527 }
1528
1529 pub fn list_payment_blacklist(&self) -> Result<Vec<PaymentBlacklistRecord>> {
1530 let mut stmt = self.master_conn.prepare(
1531 "SELECT service_origin, endpoint_path, method, rail,
1532 blacklisted_until, reason, triggered_by_loss_id, created_at, updated_at
1533 FROM payment_blacklist
1534 ORDER BY updated_at DESC",
1535 )?;
1536 let rows = stmt.query_map([], |row| {
1537 Ok(PaymentBlacklistRecord {
1538 service_origin: row.get(0)?,
1539 endpoint_path: row.get(1)?,
1540 method: row.get(2)?,
1541 rail: row.get(3)?,
1542 blacklisted_until: row.get(4)?,
1543 reason: row.get(5)?,
1544 triggered_by_loss_id: row.get(6)?,
1545 created_at: row.get(7)?,
1546 updated_at: row.get(8)?,
1547 })
1548 })?;
1549 rows.collect::<std::result::Result<Vec<_>, _>>()
1550 .map_err(Error::Storage)
1551 }
1552
1553 pub fn clear_payment_blacklist(
1554 &self,
1555 service_origin: &str,
1556 endpoint_path: &str,
1557 method: &str,
1558 rail: &str,
1559 ) -> Result<bool> {
1560 let changed = self.master_conn.execute(
1561 "DELETE FROM payment_blacklist
1562 WHERE service_origin = ?1 AND endpoint_path = ?2 AND method = ?3 AND rail = ?4",
1563 params![service_origin, endpoint_path, method, rail],
1564 )?;
1565 Ok(changed > 0)
1566 }
1567
1568 pub fn payment_blacklist_entry(
1569 &self,
1570 service_origin: &str,
1571 endpoint_path: &str,
1572 method: &str,
1573 rail: &str,
1574 ) -> Result<Option<PaymentBlacklistRecord>> {
1575 let mut stmt = self.master_conn.prepare(
1576 "SELECT service_origin, endpoint_path, method, rail,
1577 blacklisted_until, reason, triggered_by_loss_id, created_at, updated_at
1578 FROM payment_blacklist
1579 WHERE service_origin = ?1 AND endpoint_path = ?2 AND method = ?3 AND rail = ?4",
1580 )?;
1581 let mut rows = stmt.query(params![service_origin, endpoint_path, method, rail])?;
1582 let Some(row) = rows.next()? else {
1583 return Ok(None);
1584 };
1585 Ok(Some(PaymentBlacklistRecord {
1586 service_origin: row.get(0)?,
1587 endpoint_path: row.get(1)?,
1588 method: row.get(2)?,
1589 rail: row.get(3)?,
1590 blacklisted_until: row.get(4)?,
1591 reason: row.get(5)?,
1592 triggered_by_loss_id: row.get(6)?,
1593 created_at: row.get(7)?,
1594 updated_at: row.get(8)?,
1595 }))
1596 }
1597
1598 pub fn is_payment_blacklisted(
1599 &self,
1600 service_origin: &str,
1601 endpoint_path: &str,
1602 method: &str,
1603 rail: &str,
1604 ) -> Result<bool> {
1605 let Some(entry) =
1606 self.payment_blacklist_entry(service_origin, endpoint_path, method, rail)?
1607 else {
1608 return Ok(false);
1609 };
1610 Ok(match entry.blacklisted_until.as_deref() {
1611 Some(until) => until >= chrono::Utc::now().to_rfc3339().as_str(),
1612 None => true,
1613 })
1614 }
1615
1616 fn blacklist_if_needed(
1617 &self,
1618 service_origin: &str,
1619 endpoint_path: &str,
1620 method: &str,
1621 rail: &str,
1622 loss_id: &str,
1623 ) -> Result<()> {
1624 let cutoff = (chrono::Utc::now() - chrono::TimeDelta::hours(24)).to_rfc3339();
1625 let mut stmt = self.master_conn.prepare(
1626 "SELECT COUNT(*)
1627 FROM payment_losses
1628 WHERE service_origin = ?1
1629 AND endpoint_path = ?2
1630 AND method = ?3
1631 AND rail = ?4
1632 AND created_at >= ?5",
1633 )?;
1634 let count: i64 = stmt.query_row(
1635 params![service_origin, endpoint_path, method, rail, cutoff],
1636 |row| row.get(0),
1637 )?;
1638 if count < 3 {
1639 return Ok(());
1640 }
1641
1642 let now = chrono::Utc::now().to_rfc3339();
1643 self.master_conn.execute(
1644 "INSERT OR REPLACE INTO payment_blacklist (
1645 service_origin, endpoint_path, method, rail, blacklisted_until,
1646 reason, triggered_by_loss_id, created_at, updated_at
1647 ) VALUES (
1648 ?1, ?2, ?3, ?4, NULL,
1649 'service returned errors after consuming payment 3 times in the last 24 hours',
1650 ?5,
1651 COALESCE((SELECT created_at FROM payment_blacklist
1652 WHERE service_origin = ?1 AND endpoint_path = ?2 AND method = ?3 AND rail = ?4), ?6),
1653 ?6
1654 )",
1655 params![service_origin, endpoint_path, method, rail, loss_id, now],
1656 )?;
1657 Ok(())
1658 }
1659
1660 pub fn record_payment_transaction(&self, input: &NewPaymentTransaction<'_>) -> Result<String> {
1661 let txn_id = format!("txn_{}", generate_secret_hex());
1662 let now = chrono::Utc::now().to_rfc3339();
1663 self.master_conn.execute(
1664 "INSERT INTO payment_transactions (
1665 txn_id, attempt_id, created_at, updated_at, occurred_at, direction, role,
1666 source_system, service_origin, frontend_kind, transport_kind, endpoint_path,
1667 method, session_id, action_kind, resource_ref, contract_ref, invoice_ref,
1668 challenge_id, rail, payment_unit, quoted_amount, settled_amount, fee_amount,
1669 proof_ref, proof_kind, payer_ref, payee_ref, request_hash, response_code,
1670 status, metadata_json
1671 ) VALUES (
1672 ?1, ?2, ?3, ?3, ?4, ?5, ?6,
1673 ?7, ?8, ?9, ?10, ?11,
1674 ?12, ?13, ?14, ?15, ?16, ?17,
1675 ?18, ?19, ?20, ?21, ?22, ?23,
1676 ?24, ?25, ?26, ?27, ?28, ?29,
1677 ?30, ?31
1678 )",
1679 params![
1680 txn_id,
1681 input.attempt_id,
1682 now,
1683 input.occurred_at.unwrap_or(&now),
1684 input.direction,
1685 input.role,
1686 input.source_system,
1687 input.service_origin,
1688 input.frontend_kind,
1689 input.transport_kind,
1690 input.endpoint_path,
1691 input.method,
1692 input.session_id,
1693 input.action_kind,
1694 input.resource_ref,
1695 input.contract_ref,
1696 input.invoice_ref,
1697 input.challenge_id,
1698 input.rail,
1699 input.payment_unit,
1700 input.quoted_amount,
1701 input.settled_amount,
1702 input.fee_amount,
1703 input.proof_ref,
1704 input.proof_kind,
1705 input.payer_ref,
1706 input.payee_ref,
1707 input.request_hash,
1708 input.response_code,
1709 input.status,
1710 input.metadata_json,
1711 ],
1712 )?;
1713 Ok(txn_id)
1714 }
1715
1716 pub fn update_payment_transaction(
1717 &self,
1718 txn_id: &str,
1719 update: &PaymentTransactionUpdate<'_>,
1720 ) -> Result<()> {
1721 self.master_conn.execute(
1722 "UPDATE payment_transactions
1723 SET updated_at = ?2,
1724 occurred_at = COALESCE(?3, occurred_at),
1725 service_origin = COALESCE(?4, service_origin),
1726 frontend_kind = COALESCE(?5, frontend_kind),
1727 transport_kind = COALESCE(?6, transport_kind),
1728 endpoint_path = COALESCE(?7, endpoint_path),
1729 method = COALESCE(?8, method),
1730 session_id = COALESCE(?9, session_id),
1731 action_kind = COALESCE(?10, action_kind),
1732 resource_ref = COALESCE(?11, resource_ref),
1733 contract_ref = COALESCE(?12, contract_ref),
1734 invoice_ref = COALESCE(?13, invoice_ref),
1735 challenge_id = COALESCE(?14, challenge_id),
1736 quoted_amount = COALESCE(?15, quoted_amount),
1737 settled_amount = COALESCE(?16, settled_amount),
1738 fee_amount = COALESCE(?17, fee_amount),
1739 proof_ref = COALESCE(?18, proof_ref),
1740 proof_kind = COALESCE(?19, proof_kind),
1741 payer_ref = COALESCE(?20, payer_ref),
1742 payee_ref = COALESCE(?21, payee_ref),
1743 request_hash = COALESCE(?22, request_hash),
1744 response_code = COALESCE(?23, response_code),
1745 status = ?24,
1746 metadata_json = COALESCE(?25, metadata_json)
1747 WHERE txn_id = ?1",
1748 params![
1749 txn_id,
1750 chrono::Utc::now().to_rfc3339(),
1751 update.occurred_at,
1752 update.service_origin,
1753 update.frontend_kind,
1754 update.transport_kind,
1755 update.endpoint_path,
1756 update.method,
1757 update.session_id,
1758 update.action_kind,
1759 update.resource_ref,
1760 update.contract_ref,
1761 update.invoice_ref,
1762 update.challenge_id,
1763 update.quoted_amount,
1764 update.settled_amount,
1765 update.fee_amount,
1766 update.proof_ref,
1767 update.proof_kind,
1768 update.payer_ref,
1769 update.payee_ref,
1770 update.request_hash,
1771 update.response_code,
1772 update.status,
1773 update.metadata_json,
1774 ],
1775 )?;
1776 Ok(())
1777 }
1778
1779 pub fn append_payment_transaction_event(
1780 &self,
1781 event: &NewPaymentTransactionEvent<'_>,
1782 ) -> Result<String> {
1783 let event_id = format!("txe_{}", generate_secret_hex());
1784 let now = chrono::Utc::now().to_rfc3339();
1785 self.master_conn.execute(
1786 "INSERT INTO payment_transaction_events (
1787 event_id, txn_id, created_at, event_type, status, actor, details_json
1788 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1789 params![
1790 event_id,
1791 event.txn_id,
1792 now,
1793 event.event_type,
1794 event.status,
1795 event.actor,
1796 event.details_json.unwrap_or("{}"),
1797 ],
1798 )?;
1799 Ok(event_id)
1800 }
1801
1802 pub fn list_payment_transactions(&self) -> Result<Vec<PaymentTransactionRecord>> {
1803 let mut stmt = self.master_conn.prepare(
1804 "SELECT txn_id, attempt_id, created_at, updated_at, occurred_at, direction, role,
1805 source_system, service_origin, frontend_kind, transport_kind, endpoint_path,
1806 method, session_id, action_kind, resource_ref, contract_ref, invoice_ref,
1807 challenge_id, rail, payment_unit, quoted_amount, settled_amount, fee_amount,
1808 proof_ref, proof_kind, payer_ref, payee_ref, request_hash, response_code,
1809 status, metadata_json
1810 FROM payment_transactions
1811 ORDER BY occurred_at DESC, created_at DESC",
1812 )?;
1813 let rows = stmt.query_map([], |row| {
1814 Ok(PaymentTransactionRecord {
1815 txn_id: row.get(0)?,
1816 attempt_id: row.get(1)?,
1817 created_at: row.get(2)?,
1818 updated_at: row.get(3)?,
1819 occurred_at: row.get(4)?,
1820 direction: row.get(5)?,
1821 role: row.get(6)?,
1822 source_system: row.get(7)?,
1823 service_origin: row.get(8)?,
1824 frontend_kind: row.get(9)?,
1825 transport_kind: row.get(10)?,
1826 endpoint_path: row.get(11)?,
1827 method: row.get(12)?,
1828 session_id: row.get(13)?,
1829 action_kind: row.get(14)?,
1830 resource_ref: row.get(15)?,
1831 contract_ref: row.get(16)?,
1832 invoice_ref: row.get(17)?,
1833 challenge_id: row.get(18)?,
1834 rail: row.get(19)?,
1835 payment_unit: row.get(20)?,
1836 quoted_amount: row.get(21)?,
1837 settled_amount: row.get(22)?,
1838 fee_amount: row.get(23)?,
1839 proof_ref: row.get(24)?,
1840 proof_kind: row.get(25)?,
1841 payer_ref: row.get(26)?,
1842 payee_ref: row.get(27)?,
1843 request_hash: row.get(28)?,
1844 response_code: row.get(29)?,
1845 status: row.get(30)?,
1846 metadata_json: row.get(31)?,
1847 })
1848 })?;
1849 rows.collect::<std::result::Result<Vec<_>, _>>()
1850 .map_err(Error::Storage)
1851 }
1852
1853 pub fn list_payment_transaction_events(
1854 &self,
1855 txn_id: Option<&str>,
1856 ) -> Result<Vec<PaymentTransactionEventRecord>> {
1857 let sql = if txn_id.is_some() {
1858 "SELECT event_id, txn_id, created_at, event_type, status, actor, details_json
1859 FROM payment_transaction_events
1860 WHERE txn_id = ?1
1861 ORDER BY created_at ASC"
1862 } else {
1863 "SELECT event_id, txn_id, created_at, event_type, status, actor, details_json
1864 FROM payment_transaction_events
1865 ORDER BY created_at ASC"
1866 };
1867 let mut stmt = self.master_conn.prepare(sql)?;
1868 let mapper = |row: &rusqlite::Row<'_>| -> std::result::Result<PaymentTransactionEventRecord, rusqlite::Error> {
1869 Ok(PaymentTransactionEventRecord {
1870 event_id: row.get(0)?,
1871 txn_id: row.get(1)?,
1872 created_at: row.get(2)?,
1873 event_type: row.get(3)?,
1874 status: row.get(4)?,
1875 actor: row.get(5)?,
1876 details_json: row
1877 .get::<_, Option<String>>(6)?
1878 .filter(|value| !value.is_empty() && value != "{}"),
1879 })
1880 };
1881 let rows = match txn_id {
1882 Some(id) => stmt.query_map(params![id], mapper)?,
1883 None => stmt.query_map([], mapper)?,
1884 };
1885 rows.collect::<std::result::Result<Vec<_>, _>>()
1886 .map_err(Error::Storage)
1887 }
1888
1889 pub fn store_contract(&self, c: &Contract) -> Result<()> {
1892 self.with_identity_conn(|conn| {
1893 conn.execute(
1894 "INSERT OR REPLACE INTO contracts (
1895 contract_id, contract_type, status, witness_secret, witness_proof,
1896 amount_units, work_spec, buyer_fingerprint, seller_fingerprint,
1897 reference_post, delivery_deadline, role, delivered_text,
1898 certificate_id, created_at, updated_at
1899 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)",
1900 params![
1901 c.contract_id,
1902 c.contract_type.as_str(),
1903 c.status.as_str(),
1904 c.witness_secret,
1905 c.witness_proof,
1906 c.amount_units as i64,
1907 c.work_spec,
1908 c.buyer_fingerprint,
1909 c.seller_fingerprint,
1910 c.reference_post,
1911 c.delivery_deadline,
1912 c.role.as_str(),
1913 c.delivered_text,
1914 c.certificate_id,
1915 c.created_at,
1916 c.updated_at,
1917 ],
1918 )?;
1919 Ok(())
1920 })
1921 }
1922
1923 pub fn update_contract(&self, c: &Contract) -> Result<()> {
1924 self.store_contract(c)
1925 }
1926
1927 pub fn get_contract(&self, id: &str) -> Result<Option<Contract>> {
1928 self.with_identity_conn(|conn| {
1929 let mut stmt = conn.prepare(
1930 "SELECT contract_id, contract_type, status, witness_secret, witness_proof,
1931 amount_units, work_spec, buyer_fingerprint, seller_fingerprint,
1932 reference_post, delivery_deadline, role, delivered_text,
1933 certificate_id, created_at, updated_at
1934 FROM contracts WHERE contract_id = ?1",
1935 )?;
1936 let mut rows = stmt.query(params![id])?;
1937 if let Some(row) = rows.next()? {
1938 Ok(Some(row_to_contract(row)?))
1939 } else {
1940 Ok(None)
1941 }
1942 })
1943 }
1944
1945 pub fn list_contracts(&self) -> Result<Vec<Contract>> {
1946 self.with_identity_conn(|conn| {
1947 let mut stmt = conn.prepare(
1948 "SELECT contract_id, contract_type, status, witness_secret, witness_proof,
1949 amount_units, work_spec, buyer_fingerprint, seller_fingerprint,
1950 reference_post, delivery_deadline, role, delivered_text,
1951 certificate_id, created_at, updated_at
1952 FROM contracts ORDER BY created_at DESC",
1953 )?;
1954 let rows = stmt.query_map([], |row| {
1955 row_to_contract(row)
1956 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))
1957 })?;
1958 rows.collect::<std::result::Result<Vec<_>, _>>()
1959 .map_err(Error::Storage)
1960 })
1961 }
1962
1963 pub fn store_certificate(&self, cert: &Certificate) -> Result<()> {
1966 self.with_identity_conn(|conn| {
1967 conn.execute(
1968 "INSERT OR REPLACE INTO certificates (
1969 certificate_id, contract_id, witness_secret, witness_proof, created_at
1970 ) VALUES (?1,?2,?3,?4,?5)",
1971 params![
1972 cert.certificate_id,
1973 cert.contract_id,
1974 cert.witness_secret,
1975 cert.witness_proof,
1976 cert.created_at,
1977 ],
1978 )?;
1979 Ok(())
1980 })
1981 }
1982
1983 pub fn list_certificates(&self) -> Result<Vec<Certificate>> {
1984 self.with_identity_conn(|conn| {
1985 let mut stmt = conn.prepare(
1986 "SELECT certificate_id, contract_id, witness_secret, witness_proof, created_at
1987 FROM certificates ORDER BY created_at DESC",
1988 )?;
1989 let rows = stmt.query_map([], |row| {
1990 Ok(Certificate {
1991 certificate_id: row.get(0)?,
1992 contract_id: row.get(1)?,
1993 witness_secret: row.get(2)?,
1994 witness_proof: row.get(3)?,
1995 created_at: row.get(4)?,
1996 })
1997 })?;
1998 rows.collect::<std::result::Result<Vec<_>, _>>()
1999 .map_err(Error::Storage)
2000 })
2001 }
2002
2003 pub fn export_snapshot(&self) -> Result<WalletSnapshot> {
2006 let rgb_id = self.rgb_identity()?;
2007 let root = self.root_private_key_hex()?;
2008 let mut stmt = self.master_conn.prepare(
2009 "SELECT label, key_index, private_key_hex, is_active
2010 FROM pgp_identities
2011 ORDER BY key_index ASC",
2012 )?;
2013 let pgp_rows = stmt.query_map([], |row| {
2014 let key_index: u32 = row.get(1)?;
2015 Ok(PgpIdentitySnapshot {
2016 label: row.get(0)?,
2017 key_index,
2018 private_key_hex: row.get(2)?,
2019 is_active: row.get::<_, i64>(3)? == 1,
2020 })
2021 })?;
2022
2023 let pgp_identities = pgp_rows
2024 .collect::<std::result::Result<Vec<_>, _>>()
2025 .map_err(Error::Storage)?;
2026
2027 Ok(WalletSnapshot {
2028 private_key_hex: rgb_id.private_key_hex(),
2029 root_private_key_hex: Some(root),
2030 root_mnemonic: Some(self.export_recovery_mnemonic()?),
2031 wallet_label: self.wallet_label()?,
2032 pgp_identities,
2033 nickname: self.nickname()?,
2034 contracts: self.list_contracts()?,
2035 certificates: self.list_certificates()?,
2036 })
2037 }
2038
2039 pub fn import_snapshot(&self, snap: &WalletSnapshot) -> Result<()> {
2040 let keychain = if let Some(words) = snap
2041 .root_mnemonic
2042 .as_deref()
2043 .map(str::trim)
2044 .filter(|s| !s.is_empty())
2045 {
2046 HdKeychain::from_mnemonic_words(words)?
2047 } else {
2048 let root = snap.root_private_key_hex.clone().ok_or_else(|| {
2049 Error::Other(anyhow::anyhow!(
2050 "snapshot missing root mnemonic/entropy; master key is mandatory"
2051 ))
2052 })?;
2053 HdKeychain::from_entropy_hex(&root)?
2054 };
2055 if let Some(root_hex) = &snap.root_private_key_hex {
2056 if !root_hex.trim().is_empty()
2057 && !root_hex.eq_ignore_ascii_case(&keychain.entropy_hex())
2058 {
2059 return Err(Error::Other(anyhow::anyhow!(
2060 "snapshot root entropy does not match mnemonic entropy"
2061 )));
2062 }
2063 }
2064 let derived_rgb = keychain.derive_slot_hex("rgb", 0)?;
2065 if !snap.private_key_hex.trim().is_empty()
2066 && !snap.private_key_hex.eq_ignore_ascii_case(&derived_rgb)
2067 {
2068 return Err(Error::Other(anyhow::anyhow!(
2069 "snapshot RGB key does not match the derived RGB slot from root key"
2070 )));
2071 }
2072
2073 self.set_master_keychain_material(&keychain)?;
2074
2075 if let Some(label) = &snap.wallet_label {
2076 self.set_wallet_label(label)?;
2077 }
2078
2079 if let Some(nick) = &snap.nickname {
2080 self.set_nickname(nick)?;
2081 }
2082
2083 let tx = self.master_conn.unchecked_transaction()?;
2084 tx.execute("DELETE FROM pgp_identities", [])?;
2085 if snap.pgp_identities.is_empty() {
2086 let wallet_label = self
2087 .wallet_label()?
2088 .unwrap_or_else(|| "default".to_string());
2089 let private_key_hex = keychain.derive_slot_hex("pgp", 0)?;
2090 let id = Identity::from_hex(&private_key_hex)?;
2091 tx.execute(
2092 "INSERT INTO pgp_identities (label, key_index, private_key_hex, public_key_hex, created_at, is_active)
2093 VALUES (?1, 0, ?2, ?3, ?4, 1)",
2094 params![
2095 wallet_label,
2096 private_key_hex,
2097 id.public_key_hex(),
2098 chrono::Utc::now().to_rfc3339(),
2099 ],
2100 )?;
2101 } else {
2102 let mut saw_active = false;
2103 for rec in &snap.pgp_identities {
2104 let label = canonical_label(&rec.label)?;
2105 let id = Identity::from_hex(&rec.private_key_hex)?;
2106 let active = if rec.is_active && !saw_active {
2107 saw_active = true;
2108 1
2109 } else {
2110 0
2111 };
2112 tx.execute(
2113 "INSERT INTO pgp_identities (label, key_index, private_key_hex, public_key_hex, created_at, is_active)
2114 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2115 params![
2116 label,
2117 i64::from(rec.key_index),
2118 rec.private_key_hex,
2119 id.public_key_hex(),
2120 chrono::Utc::now().to_rfc3339(),
2121 active,
2122 ],
2123 )?;
2124 }
2125 if !saw_active {
2126 tx.execute(
2127 "UPDATE pgp_identities SET is_active = 1 WHERE key_index = (
2128 SELECT MIN(key_index) FROM pgp_identities
2129 )",
2130 [],
2131 )?;
2132 }
2133 }
2134 tx.commit()?;
2135
2136 self.with_identity_conn(|conn| {
2137 conn.execute("DELETE FROM contracts", [])?;
2138 conn.execute("DELETE FROM certificates", [])?;
2139 for c in &snap.contracts {
2140 conn.execute(
2141 "INSERT OR REPLACE INTO contracts (
2142 contract_id, contract_type, status, witness_secret, witness_proof,
2143 amount_units, work_spec, buyer_fingerprint, seller_fingerprint,
2144 reference_post, delivery_deadline, role, delivered_text,
2145 certificate_id, created_at, updated_at
2146 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)",
2147 params![
2148 c.contract_id,
2149 c.contract_type.as_str(),
2150 c.status.as_str(),
2151 c.witness_secret,
2152 c.witness_proof,
2153 c.amount_units as i64,
2154 c.work_spec,
2155 c.buyer_fingerprint,
2156 c.seller_fingerprint,
2157 c.reference_post,
2158 c.delivery_deadline,
2159 c.role.as_str(),
2160 c.delivered_text,
2161 c.certificate_id,
2162 c.created_at,
2163 c.updated_at,
2164 ],
2165 )?;
2166 }
2167 for cert in &snap.certificates {
2168 conn.execute(
2169 "INSERT OR REPLACE INTO certificates (
2170 certificate_id, contract_id, witness_secret, witness_proof, created_at
2171 ) VALUES (?1,?2,?3,?4,?5)",
2172 params![
2173 cert.certificate_id,
2174 cert.contract_id,
2175 cert.witness_secret,
2176 cert.witness_proof,
2177 cert.created_at,
2178 ],
2179 )?;
2180 }
2181 Ok(())
2182 })?;
2183
2184 self.refresh_slot_registry()?;
2185 Ok(())
2186 }
2187}
2188
2189fn same_path(a: &Path, b: &Path) -> bool {
2190 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
2191 (Ok(left), Ok(right)) => left == right,
2192 _ => a == b,
2193 }
2194}
2195
2196fn migrate_rgb_state(source_conn: &Connection, target_conn: &Connection) -> Result<()> {
2197 if table_exists(source_conn, "contracts")? {
2198 let mut stmt = source_conn.prepare(
2199 "SELECT contract_id, contract_type, status, witness_secret, witness_proof,
2200 amount_units, work_spec, buyer_fingerprint, seller_fingerprint,
2201 reference_post, delivery_deadline, role, delivered_text,
2202 certificate_id, created_at, updated_at
2203 FROM contracts",
2204 )?;
2205 let rows = stmt.query_map([], |row| {
2206 Ok(Contract {
2207 contract_id: row.get(0)?,
2208 contract_type: ContractType::parse(&row.get::<_, String>(1)?)
2209 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
2210 status: ContractStatus::parse(&row.get::<_, String>(2)?)
2211 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
2212 witness_secret: row.get(3)?,
2213 witness_proof: row.get(4)?,
2214 amount_units: row.get::<_, i64>(5)? as u64,
2215 work_spec: row.get(6)?,
2216 buyer_fingerprint: row.get(7)?,
2217 seller_fingerprint: row.get(8)?,
2218 reference_post: row.get(9)?,
2219 delivery_deadline: row.get(10)?,
2220 role: Role::parse(&row.get::<_, String>(11)?)
2221 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
2222 delivered_text: row.get(12)?,
2223 certificate_id: row.get(13)?,
2224 created_at: row.get(14)?,
2225 updated_at: row.get(15)?,
2226 arbitration_profit_wats: None,
2227 seller_value_wats: None,
2228 })
2229 })?;
2230 for row in rows {
2231 let c = row?;
2232 target_conn.execute(
2233 "INSERT OR REPLACE INTO contracts (
2234 contract_id, contract_type, status, witness_secret, witness_proof,
2235 amount_units, work_spec, buyer_fingerprint, seller_fingerprint,
2236 reference_post, delivery_deadline, role, delivered_text,
2237 certificate_id, created_at, updated_at
2238 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)",
2239 params![
2240 c.contract_id,
2241 c.contract_type.as_str(),
2242 c.status.as_str(),
2243 c.witness_secret,
2244 c.witness_proof,
2245 c.amount_units as i64,
2246 c.work_spec,
2247 c.buyer_fingerprint,
2248 c.seller_fingerprint,
2249 c.reference_post,
2250 c.delivery_deadline,
2251 c.role.as_str(),
2252 c.delivered_text,
2253 c.certificate_id,
2254 c.created_at,
2255 c.updated_at,
2256 ],
2257 )?;
2258 }
2259 }
2260
2261 if table_exists(source_conn, "certificates")? {
2262 let mut stmt = source_conn.prepare(
2263 "SELECT certificate_id, contract_id, witness_secret, witness_proof, created_at
2264 FROM certificates",
2265 )?;
2266 let rows = stmt.query_map([], |row| {
2267 Ok(Certificate {
2268 certificate_id: row.get(0)?,
2269 contract_id: row.get(1)?,
2270 witness_secret: row.get(2)?,
2271 witness_proof: row.get(3)?,
2272 created_at: row.get(4)?,
2273 })
2274 })?;
2275 for row in rows {
2276 let cert = row?;
2277 target_conn.execute(
2278 "INSERT OR REPLACE INTO certificates (
2279 certificate_id, contract_id, witness_secret, witness_proof, created_at
2280 ) VALUES (?1,?2,?3,?4,?5)",
2281 params![
2282 cert.certificate_id,
2283 cert.contract_id,
2284 cert.witness_secret,
2285 cert.witness_proof,
2286 cert.created_at,
2287 ],
2288 )?;
2289 }
2290 }
2291
2292 if table_exists(source_conn, "timeline_posts")? {
2293 let mut stmt = source_conn
2294 .prepare("SELECT post_id, created_at, updated_at, metadata_json FROM timeline_posts")?;
2295 let rows = stmt.query_map([], |row| {
2296 Ok((
2297 row.get::<_, String>(0)?,
2298 row.get::<_, String>(1)?,
2299 row.get::<_, String>(2)?,
2300 row.get::<_, String>(3)?,
2301 ))
2302 })?;
2303 for row in rows {
2304 let (post_id, created_at, updated_at, metadata_json) = row?;
2305 target_conn.execute(
2306 "INSERT OR REPLACE INTO timeline_posts (post_id, created_at, updated_at, metadata_json)
2307 VALUES (?1, ?2, ?3, ?4)",
2308 params![post_id, created_at, updated_at, metadata_json],
2309 )?;
2310 }
2311 }
2312
2313 if table_exists(source_conn, "timeline_comments")? {
2314 let mut stmt = source_conn.prepare(
2315 "SELECT comment_id, post_id, created_at, updated_at, metadata_json FROM timeline_comments",
2316 )?;
2317 let rows = stmt.query_map([], |row| {
2318 Ok((
2319 row.get::<_, String>(0)?,
2320 row.get::<_, String>(1)?,
2321 row.get::<_, String>(2)?,
2322 row.get::<_, String>(3)?,
2323 row.get::<_, String>(4)?,
2324 ))
2325 })?;
2326 for row in rows {
2327 let (comment_id, post_id, created_at, updated_at, metadata_json) = row?;
2328 target_conn.execute(
2329 "INSERT OR REPLACE INTO timeline_comments (comment_id, post_id, created_at, updated_at, metadata_json)
2330 VALUES (?1, ?2, ?3, ?4, ?5)",
2331 params![comment_id, post_id, created_at, updated_at, metadata_json],
2332 )?;
2333 }
2334 }
2335
2336 if table_exists(source_conn, "timeline_bids")? {
2337 let mut stmt = source_conn.prepare(
2338 "SELECT bid_post_id, contract_id, service_post_id, created_at, metadata_json FROM timeline_bids",
2339 )?;
2340 let rows = stmt.query_map([], |row| {
2341 Ok((
2342 row.get::<_, String>(0)?,
2343 row.get::<_, String>(1)?,
2344 row.get::<_, String>(2)?,
2345 row.get::<_, String>(3)?,
2346 row.get::<_, String>(4)?,
2347 ))
2348 })?;
2349 for row in rows {
2350 let (bid_post_id, contract_id, service_post_id, created_at, metadata_json) = row?;
2351 target_conn.execute(
2352 "INSERT OR REPLACE INTO timeline_bids (bid_post_id, contract_id, service_post_id, created_at, metadata_json)
2353 VALUES (?1, ?2, ?3, ?4, ?5)",
2354 params![
2355 bid_post_id,
2356 contract_id,
2357 service_post_id,
2358 created_at,
2359 metadata_json
2360 ],
2361 )?;
2362 }
2363 }
2364 Ok(())
2365}
2366
2367fn migrate_identity_schema(conn: &Connection) -> Result<()> {
2368 ensure_columns(
2369 conn,
2370 "contracts",
2371 &[
2372 ("contract_type", "TEXT NOT NULL DEFAULT 'service'"),
2373 ("status", "TEXT NOT NULL DEFAULT 'issued'"),
2374 ("witness_secret", "TEXT"),
2375 ("witness_proof", "TEXT"),
2376 ("amount_units", "INTEGER NOT NULL DEFAULT 0"),
2377 ("work_spec", "TEXT NOT NULL DEFAULT ''"),
2378 ("buyer_fingerprint", "TEXT NOT NULL DEFAULT ''"),
2379 ("seller_fingerprint", "TEXT"),
2380 ("reference_post", "TEXT"),
2381 ("delivery_deadline", "TEXT"),
2382 ("role", "TEXT NOT NULL DEFAULT 'buyer'"),
2383 ("delivered_text", "TEXT"),
2384 ("certificate_id", "TEXT"),
2385 ("created_at", "TEXT NOT NULL DEFAULT ''"),
2386 ("updated_at", "TEXT NOT NULL DEFAULT ''"),
2387 ],
2388 )?;
2389 ensure_columns(
2390 conn,
2391 "certificates",
2392 &[
2393 ("contract_id", "TEXT"),
2394 ("witness_secret", "TEXT"),
2395 ("witness_proof", "TEXT"),
2396 ("created_at", "TEXT NOT NULL DEFAULT ''"),
2397 ],
2398 )?;
2399 Ok(())
2400}
2401
2402fn migrate_identity_schema_if_present(conn: &Connection) -> Result<()> {
2403 if table_exists(conn, "contracts")? {
2404 ensure_columns(
2405 conn,
2406 "contracts",
2407 &[
2408 ("contract_type", "TEXT NOT NULL DEFAULT 'service'"),
2409 ("status", "TEXT NOT NULL DEFAULT 'issued'"),
2410 ("witness_secret", "TEXT"),
2411 ("witness_proof", "TEXT"),
2412 ("amount_units", "INTEGER NOT NULL DEFAULT 0"),
2413 ("work_spec", "TEXT NOT NULL DEFAULT ''"),
2414 ("buyer_fingerprint", "TEXT NOT NULL DEFAULT ''"),
2415 ("seller_fingerprint", "TEXT"),
2416 ("reference_post", "TEXT"),
2417 ("delivery_deadline", "TEXT"),
2418 ("role", "TEXT NOT NULL DEFAULT 'buyer'"),
2419 ("delivered_text", "TEXT"),
2420 ("certificate_id", "TEXT"),
2421 ("created_at", "TEXT NOT NULL DEFAULT ''"),
2422 ("updated_at", "TEXT NOT NULL DEFAULT ''"),
2423 ],
2424 )?;
2425 }
2426 if table_exists(conn, "certificates")? {
2427 ensure_columns(
2428 conn,
2429 "certificates",
2430 &[
2431 ("contract_id", "TEXT"),
2432 ("witness_secret", "TEXT"),
2433 ("witness_proof", "TEXT"),
2434 ("created_at", "TEXT NOT NULL DEFAULT ''"),
2435 ],
2436 )?;
2437 }
2438 Ok(())
2439}
2440
2441fn set_metadata_if_changed(conn: &Connection, key: &str, value: &str) -> Result<()> {
2444 if metadata_value(conn, key)?.as_deref() == Some(value) {
2445 return Ok(());
2446 }
2447 set_metadata_value(conn, key, value)
2448}
2449
2450fn ensure_root_and_identity_materialized(conn: &Connection, allow_generate: bool) -> Result<()> {
2451 let mnemonic = metadata_value(conn, META_ROOT_MNEMONIC)?
2452 .map(|v| v.trim().to_string())
2453 .filter(|v| !v.is_empty());
2454 let entropy_hex = metadata_value(conn, META_ROOT_PRIVATE_KEY_HEX)?
2455 .map(|v| v.trim().to_string())
2456 .filter(|v| !v.is_empty());
2457 let keychain = if let Some(words) = mnemonic.as_deref() {
2458 HdKeychain::from_mnemonic_words(words)?
2459 } else if let Some(root_hex) = entropy_hex.as_deref() {
2460 HdKeychain::from_entropy_hex(root_hex)?
2461 } else if allow_generate {
2462 HdKeychain::generate_new()?
2463 } else {
2464 return Err(Error::KeyMaterialMissing(
2465 "root_mnemonic / root_private_key_hex missing; \
2466 refusing to generate new keys for an existing wallet \
2467 — restore from backup or re-import your mnemonic"
2468 .into(),
2469 ));
2470 };
2471
2472 set_metadata_if_changed(conn, META_ROOT_PRIVATE_KEY_HEX, &keychain.entropy_hex())?;
2473 set_metadata_if_changed(conn, META_ROOT_MNEMONIC, &keychain.mnemonic_words())?;
2474 set_metadata_if_changed(
2475 conn,
2476 META_RGB_PRIVATE_KEY_HEX,
2477 &keychain.derive_slot_hex("rgb", 0)?,
2478 )?;
2479 set_metadata_if_changed(conn, META_KEY_MODEL_VERSION, KEY_MODEL_VERSION_V3)?;
2480
2481 if metadata_value(conn, META_WALLET_LABEL)?.is_none() {
2482 set_metadata_value(conn, META_WALLET_LABEL, "default")?;
2483 }
2484
2485 Ok(())
2486}
2487
2488fn ensure_default_pgp_identity(conn: &Connection) -> Result<()> {
2489 let keychain = keychain_from_metadata(conn)?;
2490 let mut count_stmt = conn.prepare("SELECT COUNT(*) FROM pgp_identities")?;
2491 let count: i64 = count_stmt.query_row([], |row| row.get(0))?;
2492 if count > 0 {
2493 let mut stmt = conn.prepare(
2495 "SELECT label, key_index, created_at, is_active
2496 FROM pgp_identities
2497 ORDER BY key_index ASC",
2498 )?;
2499 let rows = stmt.query_map([], |row| {
2500 Ok((
2501 row.get::<_, String>(0)?,
2502 row.get::<_, u32>(1)?,
2503 row.get::<_, String>(2)?,
2504 row.get::<_, i64>(3)?,
2505 ))
2506 })?;
2507 let mut entries = Vec::new();
2508 for row in rows {
2509 let (label, key_index, created_at, is_active) = row?;
2510 let private_key_hex = keychain.derive_slot_hex("pgp", key_index)?;
2511 let identity = Identity::from_hex(&private_key_hex)?;
2512 entries.push((
2513 canonical_label(&label)?,
2514 key_index,
2515 private_key_hex,
2516 identity.public_key_hex(),
2517 created_at,
2518 is_active,
2519 ));
2520 }
2521 let tx = conn.unchecked_transaction()?;
2522 tx.execute("DELETE FROM pgp_identities", [])?;
2523 let mut saw_active = false;
2524 for (label, key_index, private_key_hex, public_key_hex, created_at, was_active) in entries {
2525 let is_active = if was_active == 1 && !saw_active {
2526 saw_active = true;
2527 1
2528 } else {
2529 0
2530 };
2531 tx.execute(
2532 "INSERT INTO pgp_identities (label, key_index, private_key_hex, public_key_hex, created_at, is_active)
2533 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2534 params![
2535 label,
2536 i64::from(key_index),
2537 private_key_hex,
2538 public_key_hex,
2539 created_at,
2540 is_active,
2541 ],
2542 )?;
2543 }
2544 if !saw_active {
2545 tx.execute(
2546 "UPDATE pgp_identities SET is_active = 1 WHERE key_index = (
2547 SELECT MIN(key_index) FROM pgp_identities
2548 )",
2549 [],
2550 )?;
2551 }
2552 tx.commit()?;
2553 return Ok(());
2554 }
2555
2556 let wallet_label =
2557 metadata_value(conn, META_WALLET_LABEL)?.unwrap_or_else(|| "default".to_string());
2558 let private_key_hex = keychain.derive_slot_hex("pgp", 0)?;
2559
2560 let identity = Identity::from_hex(&private_key_hex)?;
2561 conn.execute(
2562 "INSERT INTO pgp_identities (label, key_index, private_key_hex, public_key_hex, created_at, is_active)
2563 VALUES (?1, 0, ?2, ?3, ?4, 1)",
2564 params![
2565 canonical_label(&wallet_label)?,
2566 private_key_hex,
2567 identity.public_key_hex(),
2568 chrono::Utc::now().to_rfc3339(),
2569 ],
2570 )?;
2571 Ok(())
2572}
2573
2574fn keychain_from_metadata(conn: &Connection) -> Result<HdKeychain> {
2575 if let Some(words) = metadata_value(conn, META_ROOT_MNEMONIC)? {
2576 return HdKeychain::from_mnemonic_words(&words);
2577 }
2578 let entropy_hex = metadata_value(conn, META_ROOT_PRIVATE_KEY_HEX)?.ok_or_else(|| {
2579 Error::Other(anyhow::anyhow!("missing master entropy in wallet metadata"))
2580 })?;
2581 HdKeychain::from_entropy_hex(&entropy_hex)
2582}
2583
2584fn set_metadata_value(conn: &Connection, key: &str, value: &str) -> Result<()> {
2585 conn.execute(
2586 "INSERT OR REPLACE INTO wallet_metadata (key, value) VALUES (?1, ?2)",
2587 params![key, value],
2588 )?;
2589 Ok(())
2590}
2591
2592fn metadata_value(conn: &Connection, key: &str) -> Result<Option<String>> {
2593 let mut stmt = conn.prepare("SELECT value FROM wallet_metadata WHERE key = ?1")?;
2594 let mut rows = stmt.query(params![key])?;
2595 let Some(row) = rows.next()? else {
2596 return Ok(None);
2597 };
2598 Ok(Some(row.get(0)?))
2599}
2600
2601fn table_exists(conn: &Connection, table: &str) -> Result<bool> {
2602 let mut stmt = conn
2603 .prepare("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = ?1 LIMIT 1")?;
2604 let count: i64 = stmt.query_row(params![table], |row| row.get(0))?;
2605 Ok(count > 0)
2606}
2607
2608fn canonical_label(label: &str) -> Result<String> {
2609 let canonical = label.trim();
2610 if canonical.is_empty() {
2611 return Err(Error::Other(anyhow::anyhow!("label cannot be empty")));
2612 }
2613 if canonical.len() > 64 {
2614 return Err(Error::Other(anyhow::anyhow!(
2615 "label too long (max 64 chars)"
2616 )));
2617 }
2618 Ok(canonical.to_string())
2619}
2620
2621fn ensure_columns(conn: &Connection, table: &str, required: &[(&str, &str)]) -> Result<()> {
2622 let existing = current_columns(conn, table)?;
2623 for (name, ddl) in required {
2624 if existing.contains(*name) {
2625 continue;
2626 }
2627 let sql = format!("ALTER TABLE {table} ADD COLUMN {name} {ddl}");
2628 conn.execute(&sql, [])?;
2629 }
2630 Ok(())
2631}
2632
2633fn current_columns(conn: &Connection, table: &str) -> Result<HashSet<String>> {
2634 let mut columns = HashSet::new();
2635 let sql = format!("PRAGMA table_info({table})");
2636 let mut stmt = conn.prepare(&sql)?;
2637 let mut rows = stmt.query([])?;
2638 while let Some(row) = rows.next()? {
2639 let name: String = row.get(1)?;
2640 columns.insert(name);
2641 }
2642 Ok(columns)
2643}
2644
2645fn row_to_contract(row: &rusqlite::Row<'_>) -> Result<Contract> {
2646 let amount_units_i: i64 = row.get(5)?;
2647 Ok(Contract {
2648 contract_id: row.get(0)?,
2649 contract_type: ContractType::parse(&row.get::<_, String>(1)?)?,
2650 status: ContractStatus::parse(&row.get::<_, String>(2)?)?,
2651 witness_secret: row.get(3)?,
2652 witness_proof: row.get(4)?,
2653 amount_units: amount_units_i as u64,
2654 work_spec: row.get(6)?,
2655 buyer_fingerprint: row.get(7)?,
2656 seller_fingerprint: row.get(8)?,
2657 reference_post: row.get(9)?,
2658 delivery_deadline: row.get(10)?,
2659 role: Role::parse(&row.get::<_, String>(11)?)?,
2660 delivered_text: row.get(12)?,
2661 certificate_id: row.get(13)?,
2662 created_at: row.get(14)?,
2663 updated_at: row.get(15)?,
2664 arbitration_profit_wats: None,
2665 seller_value_wats: None,
2666 })
2667}
2668
2669#[cfg(test)]
2670mod tests {
2671 use super::{
2672 NewPaymentTransaction, NewPaymentTransactionEvent, PaymentTransactionUpdate, RgbWallet,
2673 };
2674
2675 #[test]
2676 fn payment_transactions_round_trip_in_memory_wallet() {
2677 let wallet = RgbWallet::open_memory().expect("memory wallet");
2678 let txn_id = wallet
2679 .record_payment_transaction(&NewPaymentTransaction {
2680 attempt_id: Some("pay_123"),
2681 occurred_at: Some("2026-03-17T10:00:00Z"),
2682 direction: "inbound",
2683 role: "payee",
2684 source_system: "harmonia",
2685 service_origin: Some("https://node.example"),
2686 frontend_kind: Some("http2"),
2687 transport_kind: Some("http2"),
2688 endpoint_path: Some("/v1/session"),
2689 method: Some("POST"),
2690 session_id: Some("session-1"),
2691 action_kind: "identity-claim",
2692 resource_ref: Some("identity:alice"),
2693 contract_ref: None,
2694 invoice_ref: None,
2695 challenge_id: Some("challenge-1"),
2696 rail: "webcash",
2697 payment_unit: "wats",
2698 quoted_amount: Some("42"),
2699 settled_amount: None,
2700 fee_amount: None,
2701 proof_ref: None,
2702 proof_kind: None,
2703 payer_ref: Some("payer:alice"),
2704 payee_ref: Some("payee:harmonia"),
2705 request_hash: Some("hash-1"),
2706 response_code: Some("payment_required"),
2707 status: "challenge_received",
2708 metadata_json: Some("{\"carrier\":\"http2\"}"),
2709 })
2710 .expect("record transaction");
2711 wallet
2712 .append_payment_transaction_event(&NewPaymentTransactionEvent {
2713 txn_id: &txn_id,
2714 event_type: "challenge_received",
2715 status: "challenge_received",
2716 actor: "gateway",
2717 details_json: Some("{\"price\":\"42\"}"),
2718 })
2719 .expect("append event");
2720 wallet
2721 .update_payment_transaction(
2722 &txn_id,
2723 &PaymentTransactionUpdate {
2724 occurred_at: None,
2725 service_origin: None,
2726 frontend_kind: None,
2727 transport_kind: None,
2728 endpoint_path: None,
2729 method: None,
2730 session_id: None,
2731 action_kind: None,
2732 resource_ref: None,
2733 contract_ref: None,
2734 invoice_ref: None,
2735 challenge_id: Some("challenge-1"),
2736 quoted_amount: None,
2737 settled_amount: Some("42"),
2738 fee_amount: Some("1"),
2739 proof_ref: Some("proof-hash-1"),
2740 proof_kind: Some("webcash_secret_hash"),
2741 payer_ref: None,
2742 payee_ref: None,
2743 request_hash: None,
2744 response_code: Some("accepted"),
2745 status: "succeeded",
2746 metadata_json: Some("{\"settled\":true}"),
2747 },
2748 )
2749 .expect("update transaction");
2750
2751 let txns = wallet
2752 .list_payment_transactions()
2753 .expect("list transactions");
2754 assert_eq!(txns.len(), 1);
2755 let txn = &txns[0];
2756 assert_eq!(txn.txn_id, txn_id);
2757 assert_eq!(txn.direction, "inbound");
2758 assert_eq!(txn.role, "payee");
2759 assert_eq!(txn.action_kind, "identity-claim");
2760 assert_eq!(txn.challenge_id.as_deref(), Some("challenge-1"));
2761 assert_eq!(txn.settled_amount.as_deref(), Some("42"));
2762 assert_eq!(txn.fee_amount.as_deref(), Some("1"));
2763 assert_eq!(txn.proof_kind.as_deref(), Some("webcash_secret_hash"));
2764 assert_eq!(txn.proof_ref.as_deref(), Some("proof-hash-1"));
2765 assert_eq!(txn.status, "succeeded");
2766
2767 let events = wallet
2768 .list_payment_transaction_events(Some(&txn_id))
2769 .expect("list txn events");
2770 assert_eq!(events.len(), 1);
2771 assert_eq!(events[0].event_type, "challenge_received");
2772 assert_eq!(events[0].status, "challenge_received");
2773 assert_eq!(events[0].actor, "gateway");
2774 }
2775
2776 #[test]
2777 fn payment_transactions_enforce_unique_proof_refs_per_direction_and_rail() {
2778 let wallet = RgbWallet::open_memory().expect("memory wallet");
2779 wallet
2780 .record_payment_transaction(&NewPaymentTransaction {
2781 attempt_id: None,
2782 occurred_at: None,
2783 direction: "inbound",
2784 role: "payee",
2785 source_system: "harmonia",
2786 service_origin: Some("https://node.example"),
2787 frontend_kind: Some("http2"),
2788 transport_kind: Some("http2"),
2789 endpoint_path: Some("/v1/session"),
2790 method: Some("POST"),
2791 session_id: Some("session-1"),
2792 action_kind: "post",
2793 resource_ref: None,
2794 contract_ref: None,
2795 invoice_ref: None,
2796 challenge_id: None,
2797 rail: "voucher",
2798 payment_unit: "credits",
2799 quoted_amount: Some("10"),
2800 settled_amount: Some("10"),
2801 fee_amount: None,
2802 proof_ref: Some("proof-ref-1"),
2803 proof_kind: Some("voucher_public_hash"),
2804 payer_ref: None,
2805 payee_ref: None,
2806 request_hash: None,
2807 response_code: None,
2808 status: "succeeded",
2809 metadata_json: None,
2810 })
2811 .expect("insert first proof ref");
2812
2813 let duplicate = wallet.record_payment_transaction(&NewPaymentTransaction {
2814 attempt_id: None,
2815 occurred_at: None,
2816 direction: "inbound",
2817 role: "payee",
2818 source_system: "harmonia",
2819 service_origin: Some("https://node.example"),
2820 frontend_kind: Some("mqtt"),
2821 transport_kind: Some("mqtt"),
2822 endpoint_path: Some("/topic/posts"),
2823 method: Some("PUBLISH"),
2824 session_id: Some("session-2"),
2825 action_kind: "comment",
2826 resource_ref: None,
2827 contract_ref: None,
2828 invoice_ref: None,
2829 challenge_id: None,
2830 rail: "voucher",
2831 payment_unit: "credits",
2832 quoted_amount: Some("10"),
2833 settled_amount: Some("10"),
2834 fee_amount: None,
2835 proof_ref: Some("proof-ref-1"),
2836 proof_kind: Some("voucher_public_hash"),
2837 payer_ref: None,
2838 payee_ref: None,
2839 request_hash: None,
2840 response_code: None,
2841 status: "succeeded",
2842 metadata_json: None,
2843 });
2844
2845 assert!(duplicate.is_err(), "duplicate proof ref should be rejected");
2846 }
2847}