Skip to main content

harmoniis_wallet/
wallet.rs

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
32/// SQLite-backed Harmoniis wallet.
33pub 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/// Serializable snapshot for backup/restore.
73#[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    /// Create master-database tables and indexes (DDL only, no key
336    /// materialisation).  Safe to call on every open — all statements use
337    /// `CREATE … IF NOT EXISTS`.
338    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    /// Create tables **and** materialise root key material.
482    ///
483    /// `allow_generate` – when `true` a brand-new keychain is created if no
484    /// root mnemonic / entropy hex exists yet (first-time wallet creation).
485    /// When `false` the call returns `Error::KeyMaterialMissing` instead of
486    /// silently generating new keys — protecting existing wallets from
487    /// accidental key replacement.
488    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    /// Create a new wallet at the given path, generating a fresh root key.
802    pub fn create(path: &Path) -> Result<Self> {
803        Self::open_from_disk(path, true)
804    }
805
806    /// Open an existing wallet at the given path.
807    pub fn open(path: &Path) -> Result<Self> {
808        Self::open_from_disk(path, false)
809    }
810
811    /// Open an in-memory wallet (for tests).
812    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    // ── Identity material ────────────────────────────────────────────────────
830
831    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    /// Backward-compatible alias retained for Harmonia integration naming.
894    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    /// Export the canonical recovery phrase stored in wallet metadata.
995    ///
996    /// New wallets store a 12-word BIP39 phrase at creation time. Existing wallets
997    /// may fall back to the reversible 24-word representation of the root key.
998    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    // ── PGP identities (labeled, multi-key) ─────────────────────────────────
1049
1050    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    // ── Wallet metadata ──────────────────────────────────────────────────────
1341
1342    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    // ── Payment audit ────────────────────────────────────────────────────────
1399
1400    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    // ── Contracts ─────────────────────────────────────────────────────────────
1890
1891    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    // ── Certificates ──────────────────────────────────────────────────────────
1964
1965    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    // ── Snapshot ──────────────────────────────────────────────────────────────
2004
2005    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
2441/// Write a metadata value only when it differs from what is already stored,
2442/// avoiding unnecessary SQLite writes on every wallet open.
2443fn 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        // Canonicalize all PGP private/public keys from deterministic BIP32 slots.
2494        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}