diff --git a/crates/ghostkey-server/src/routes.rs b/crates/ghostkey-server/src/routes.rs index 318fa7e..6c8ab15 100644 --- a/crates/ghostkey-server/src/routes.rs +++ b/crates/ghostkey-server/src/routes.rs @@ -459,6 +459,11 @@ struct Health { notifications_undelivered: i64, /// Vaults with no proof the heir's address is reachable (#327). vaults_heir_unverified: i64, + /// Vaults still holding their check-in clock closed because the + /// owner's email was never confirmed (#326). The number that would + /// have shown, months earlier, that seven of sixteen mainnet vaults + /// were running a cascade at an address nobody had ever reached. + vaults_owner_unverified: i64, /// False when the oldest due notification has been waiting past the /// stuck threshold (15 min). Alert on this alongside /// `scheduler_healthy`: a stalled notifier silently stops contacting @@ -550,6 +555,17 @@ async fn health(State(state): State>) -> Json { .await .unwrap_or(0); + let vaults_owner_unverified: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM vaults + WHERE owner_contact_ciphertext IS NOT NULL + AND owner_contact_channel = 'email' + AND owner_contact_verified_at IS NULL + AND status NOT IN ('claimed', 'cancelled')", + ) + .fetch_one(&state.db) + .await + .unwrap_or(0); + let notifications_undelivered: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM notifications WHERE delivery_status IN ('undelivered', 'failed', 'bounced', 'complained')", @@ -586,6 +602,7 @@ async fn health(State(state): State>) -> Json { notifications_failed, notifications_undelivered, vaults_heir_unverified, + vaults_owner_unverified, notifier_healthy, chain_scan_healthy, chain_scan_consecutive_failures: scan.consecutive_failures as i64, @@ -690,10 +707,24 @@ pub struct VaultView { /// tapping the verification link we sent at setup. `None` when /// the vault has no sealed owner email on file (legacy CLI rows, /// or non-email channels); the dashboard nags while this is - /// `Some(false)`. Informational only — the scheduler delivers - /// reminders to unverified addresses regardless. + /// `Some(false)`. + /// + /// Not informational any more: since #326 an unconfirmed email + /// holds the check-in clock closed. See `activation_held`. #[serde(skip_serializing_if = "Option::is_none")] pub owner_contact_verified: Option, + /// The money has arrived but the clock is not running, because the + /// owner's email is still unconfirmed (#326). + /// + /// Its own field rather than something the dashboard infers, + /// because the two states it separates look identical from + /// outside: `unfunded` means "waiting for coins" and `unfunded` + /// also means "coins are here, waiting for you". Telling a funded + /// owner to go and fund their share is the kind of wrong that + /// makes people think the money is lost. Only populated on the + /// owner-authenticated `GET /vaults/:id`. + #[serde(skip_serializing_if = "Option::is_none")] + pub activation_held: Option, /// The vault's descriptor pair (receive + change). Only populated /// on the owner-authenticated `GET /vaults/:id` — list/create /// responses leave them `None`. The dashboard embeds these in the @@ -970,6 +1001,7 @@ async fn create_vault( // Legacy CLI route stores the plaintext contact and // doesn't participate in email verification. owner_contact_verified: None, + activation_held: None, descriptor_external: None, descriptor_internal: None, has_trusted_contact: None, @@ -1675,6 +1707,9 @@ async fn create_vault_from_xpub( } else { None }, + // A brand-new vault has no coins yet, so nothing is + // being held. The owner GET reports it from then on. + activation_held: None, // Return the public descriptor pair so the setup browser // can build the heir envelope (block A) immediately, // without a second round-trip. Watch-only; no secrets. @@ -2112,6 +2147,9 @@ async fn create_vault_guardian( } else { None }, + // A brand-new vault has no coins yet, so nothing is + // being held. The owner GET reports it from then on. + activation_held: None, descriptor_external: Some(pair.external.clone()), descriptor_internal: Some(pair.internal.clone()), has_trusted_contact: None, @@ -2804,6 +2842,8 @@ async fn get_vault( /// Cached on-chain maturity scan (refreshed by the scheduler). chain_unlock_height: Option, chain_tip_height: Option, + /// Whether that scan last saw coins (0/1, null before any scan). + chain_has_unspent: Option, /// Claim fire drill progress (#223). drill_started_at: Option, drill_opened_at: Option, @@ -2819,7 +2859,7 @@ async fn get_vault( owner_contact_verified_at, descriptor_external, descriptor_internal, trusted_contact_ciphertext IS NOT NULL AS has_trusted_contact, - chain_unlock_height, chain_tip_height, + chain_unlock_height, chain_tip_height, chain_has_unspent, drill_started_at, drill_opened_at, drill_completed_at FROM vaults WHERE id = ?"#, ) @@ -2858,6 +2898,15 @@ async fn get_vault( crate::psbt_routes::UnlockEstimateView::from_estimate(&est, Utc::now()).unlock_eta }); + // Before the struct literal takes ownership of the row's fields. + let activation_held = row.status == "unfunded" + && row.chain_has_unspent == Some(1) + && crate::scheduler::owner_email_unconfirmed( + row.has_owner_contact == 1, + &row.owner_contact_channel, + &row.owner_contact_verified_at, + ); + Ok(Json(VaultView { id: row.id, label: row.label, @@ -2883,6 +2932,11 @@ async fn get_vault( } else { None }, + // The same condition the scheduler holds on, plus the two facts + // that make it visible to the owner: still `unfunded`, and the + // last scan saw coins. Anything else reads `false`, including a + // vault nobody has funded yet. + activation_held: Some(activation_held), descriptor_external: Some(row.descriptor_external), descriptor_internal: Some(row.descriptor_internal), has_trusted_contact: Some(row.has_trusted_contact == 1), diff --git a/crates/ghostkey-server/src/scheduler.rs b/crates/ghostkey-server/src/scheduler.rs index 8ccaa66..c531cc3 100644 --- a/crates/ghostkey-server/src/scheduler.rs +++ b/crates/ghostkey-server/src/scheduler.rs @@ -892,6 +892,27 @@ async fn enqueue_pre_deadline_reminder( /// leaves the vault `unfunded` and retries next tick. Demo deployments /// create vaults already `ok` (there is no real chain to scan), so this /// is a no-op there. +/// +/// # The unverified-owner hold (#326) +/// +/// Funding is necessary to start the clock but not sufficient. An owner +/// whose email was never confirmed is an owner we have no evidence we +/// can reach, and this is the whole cascade's entry point: everything +/// downstream — reminders, the ok->alarmed flip, escalations, and +/// finally the heir's claim link — filters on `status = 'ok'`. Letting +/// an unreachable owner in here is how vault `4a7aaf77` ran a reminder, +/// an alarm, three escalations and a claim link on mainnet with every +/// row reading `sent` and nobody ever seeing one of them. +/// +/// So a funded vault whose owner email is unconfirmed stays `unfunded`: +/// funded, inert, and shown as such on the dashboard. Failing here is +/// recoverable — the owner confirms and the clock starts. Failing at the +/// deadline is not. +/// +/// The hold is only for the `email` channel. There is no verification +/// flow for sms or whatsapp, and none for a vault with no owner contact +/// at all, so holding those would brick a vault permanently rather than +/// prompt anyone. async fn activate_funded_vaults(state: &AppState, _now_iso: &str) -> anyhow::Result<()> { let rows = sqlx::query_as::< _, @@ -907,12 +928,17 @@ async fn activate_funded_vaults(state: &AppState, _now_iso: &str) -> anyhow::Res Option, // chain_tip_height Option, // chain_scanned_at Option, // chain_has_unspent + Option, // owner_contact_channel + Option, // owner_contact_verified_at + i64, // has_owner_contact ), >( r#"SELECT id, checkin_period_secs, grace_period_secs, descriptor_external, descriptor_internal, network, timelock_blocks, chain_unlock_height, chain_tip_height, chain_scanned_at, - chain_has_unspent + chain_has_unspent, + owner_contact_channel, owner_contact_verified_at, + owner_contact_ciphertext IS NOT NULL AS has_owner_contact FROM vaults WHERE status = 'unfunded'"#, ) @@ -932,6 +958,9 @@ async fn activate_funded_vaults(state: &AppState, _now_iso: &str) -> anyhow::Res chain_tip_height, chain_scanned_at, chain_has_unspent, + owner_contact_channel, + owner_contact_verified_at, + has_owner_contact, ) in rows { let input = crate::psbt_routes::EstimateInput { @@ -961,6 +990,17 @@ async fn activate_funded_vaults(state: &AppState, _now_iso: &str) -> anyhow::Res } } + // Funded, but we may still have no evidence we can reach the + // owner. See the hold note on this function. + if owner_email_unconfirmed( + has_owner_contact == 1, + &owner_contact_channel, + &owner_contact_verified_at, + ) { + hold_activation(&state.db, &id).await?; + continue; + } + let next = now + chrono::Duration::seconds(checkin_secs + grace_secs); let claim_eligible = next + chrono::Duration::seconds(grace_secs); // CAS on status so a racing tick can't double-activate. @@ -1020,6 +1060,54 @@ async fn activate_funded_vaults(state: &AppState, _now_iso: &str) -> anyhow::Res Ok(()) } +/// Whether this vault's owner is reachable only through an email +/// address nobody has ever confirmed. +/// +/// False for every other channel, and for a vault with no sealed owner +/// contact, because neither has a way to become true. A legacy row with +/// a channel but no ciphertext is one we cannot mail at all, so holding +/// it would brick it rather than prompt anyone. Mirrors the condition +/// behind `VaultView::owner_contact_verified`. +/// +/// See the hold note on `activate_funded_vaults`. +pub(crate) fn owner_email_unconfirmed( + has_owner_contact: bool, + channel: &Option, + verified_at: &Option, +) -> bool { + has_owner_contact && channel.as_deref() == Some("email") && verified_at.is_none() +} + +/// Leave a funded vault `unfunded` because its owner's email is +/// unconfirmed, and say so once in its history. +/// +/// Once, not once per tick: this runs every scheduler pass for as long +/// as the owner takes to click the link, and an activity feed full of +/// the same line is a feed the owner stops reading. +async fn hold_activation(db: &sqlx::SqlitePool, vault_id: &str) -> anyhow::Result<()> { + let already: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM events WHERE vault_id = ? AND kind = 'activation_held')", + ) + .bind(vault_id) + .fetch_one(db) + .await?; + if already { + return Ok(()); + } + record_event( + db, + vault_id, + "activation_held", + Some(serde_json::json!({ "reason": "owner_email_unverified" })), + ) + .await?; + tracing::info!( + vault_id = %vault_id, + "funds detected but owner email unconfirmed; check-in clock held (#326)" + ); + Ok(()) +} + /// The mirror of `activate_funded_vaults`: stop the check-in clock once /// a healthy vault has been emptied. /// @@ -2640,6 +2728,186 @@ mod tests { assert_eq!(status, "unfunded"); } + /// Put a vault's maturity cache into the state a scan that found + /// coins leaves behind, so `activate_funded_vaults` reads "funded" + /// straight from cache instead of reaching for a chain. + async fn mark_funded(pool: &SqlitePool, id: &str, now: chrono::DateTime) { + sqlx::query( + "UPDATE vaults + SET chain_unlock_height = 200, chain_tip_height = 150, + chain_scanned_at = ?, chain_has_unspent = 1 + WHERE id = ?", + ) + .bind(now.to_rfc3339()) + .bind(id) + .execute(pool) + .await + .expect("mark funded"); + } + + async fn count_events(pool: &SqlitePool, id: &str, kind: &str) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE vault_id = ? AND kind = ?") + .bind(id) + .bind(kind) + .fetch_one(pool) + .await + .expect("count events") + } + + /// #326. Funding is not enough to start the clock: an owner whose + /// email nobody ever confirmed is an owner we have no evidence we + /// can reach, and the clock is the door to the whole cascade — + /// reminders, alarm, escalations, and the heir's claim link. Mainnet + /// vault `4a7aaf77` ran all four with every row reading `sent`. + #[tokio::test] + async fn funded_vault_with_unverified_owner_email_is_held() { + let pool = fresh_db().await; + let state = AppState { + db: pool.clone(), + lightning: std::sync::Arc::new(crate::lightning::NoopProvider), + }; + let now = Utc::now(); + insert_vault_with_sealed_owner( + &pool, + "v-held", + "unfunded", + "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00Z", + "owner@example.com", + ) + .await; + mark_funded(&pool, "v-held", now).await; + + activate_funded_vaults(&state, &now.to_rfc3339()) + .await + .expect("activate"); + + let status: String = sqlx::query_scalar("SELECT status FROM vaults WHERE id = 'v-held'") + .fetch_one(&pool) + .await + .expect("status"); + assert_eq!( + status, "unfunded", + "a funded vault whose owner email is unconfirmed must not start the clock" + ); + assert_eq!( + count_events(&pool, "v-held", "activation_held").await, + 1, + "the hold must be visible in the vault's history" + ); + assert_eq!( + count_events(&pool, "v-held", "funded").await, + 0, + "a held vault has not been activated, so it must not claim it was" + ); + } + + /// The other half: confirming the email is what opens the door. The + /// hold has to be a hold, not a wall. + #[tokio::test] + async fn funded_vault_with_verified_owner_email_activates() { + let pool = fresh_db().await; + let state = AppState { + db: pool.clone(), + lightning: std::sync::Arc::new(crate::lightning::NoopProvider), + }; + let now = Utc::now(); + insert_vault_with_sealed_owner( + &pool, + "v-ok", + "unfunded", + "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00Z", + "owner@example.com", + ) + .await; + mark_funded(&pool, "v-ok", now).await; + sqlx::query("UPDATE vaults SET owner_contact_verified_at = ? WHERE id = 'v-ok'") + .bind(now.to_rfc3339()) + .execute(&pool) + .await + .expect("verify owner"); + + activate_funded_vaults(&state, &now.to_rfc3339()) + .await + .expect("activate"); + + let (status, next_deadline): (String, String) = + sqlx::query_as("SELECT status, next_deadline_at FROM vaults WHERE id = 'v-ok'") + .fetch_one(&pool) + .await + .expect("read"); + assert_eq!(status, "ok", "a confirmed owner's funded vault activates"); + let nd = chrono::DateTime::parse_from_rfc3339(&next_deadline) + .expect("parse deadline") + .with_timezone(&Utc); + assert!(nd > now, "the clock must start from activation, got {nd}"); + assert_eq!(count_events(&pool, "v-ok", "activation_held").await, 0); + } + + /// The sweep runs every tick for as long as the owner takes to click + /// the link. One line in the activity feed, not one per tick. + #[tokio::test] + async fn activation_hold_is_recorded_once_not_every_tick() { + let pool = fresh_db().await; + let state = AppState { + db: pool.clone(), + lightning: std::sync::Arc::new(crate::lightning::NoopProvider), + }; + let now = Utc::now(); + insert_vault_with_sealed_owner( + &pool, + "v-nag", + "unfunded", + "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00Z", + "owner@example.com", + ) + .await; + mark_funded(&pool, "v-nag", now).await; + + for _ in 0..3 { + activate_funded_vaults(&state, &now.to_rfc3339()) + .await + .expect("activate"); + } + + assert_eq!(count_events(&pool, "v-nag", "activation_held").await, 1); + } + + /// A vault with no owner contact at all has no way to become + /// verified, so holding it would brick it rather than prompt + /// anyone. It activates on funding as it always did. + #[tokio::test] + async fn funded_vault_with_no_owner_contact_still_activates() { + let pool = fresh_db().await; + let state = AppState { + db: pool.clone(), + lightning: std::sync::Arc::new(crate::lightning::NoopProvider), + }; + let now = Utc::now(); + insert_vault( + &pool, + "v-nocontact", + "unfunded", + "2026-01-01T00:00:00Z", + Some("2026-01-01T00:00:00Z"), + ) + .await; + mark_funded(&pool, "v-nocontact", now).await; + + activate_funded_vaults(&state, &now.to_rfc3339()) + .await + .expect("activate"); + + let status: String = + sqlx::query_scalar("SELECT status FROM vaults WHERE id = 'v-nocontact'") + .fetch_one(&pool) + .await + .expect("status"); + assert_eq!(status, "ok"); + } + #[tokio::test] async fn drained_alarmed_vault_returns_to_unfunded_once() { let pool = fresh_db().await; diff --git a/ghostkey-web/src/Dashboard.tsx b/ghostkey-web/src/Dashboard.tsx index 0267436..c6f48ce 100644 --- a/ghostkey-web/src/Dashboard.tsx +++ b/ghostkey-web/src/Dashboard.tsx @@ -497,7 +497,10 @@ export function Dashboard({ onNavigate }: Props) { unlockEta={vault?.unlock_eta ?? null} /> ) : isUnfunded ? ( - + ) : ( +
+ +

+ Your Bitcoin arrived. Confirm your email to start. +

+

+ The money is safe in your share and only you can spend it. We + just can't start your check-in clock until we know we can + reach you, because everything after it depends on you getting + our reminders. Tap the link in the email we sent, and{" "} + {meta.heir.name || "your heir"}'s plan begins. +

+
+ + ); +} + +function AwaitingFundingCard({ + meta, + held, +}: { + meta: VaultMeta; + /** The coins have landed; what's missing is the owner's confirmed + * email (#326). Same `unfunded` status, opposite instruction. */ + held: boolean; +}) { + if (held) { + return ; + } return (
@@ -2040,9 +2091,9 @@ function ConfirmEmailCard({ state.message ) : ( <> - Confirm your email. Until - you do, we can't remind you to check in, and a missed - reminder can trigger the inheritance by accident. + Confirm your email. Your + check-in clock stays stopped until you do, so nothing starts + and nobody is contacted. Tap the link and you're running. )}

diff --git a/ghostkey-web/src/api.ts b/ghostkey-web/src/api.ts index 32fb53b..ce941c4 100644 --- a/ghostkey-web/src/api.ts +++ b/ghostkey-web/src/api.ts @@ -64,6 +64,12 @@ export interface VaultView { * verification link. Absent/null when the vault has no email on * file; `false` drives the dashboard's "confirm your email" card. */ owner_contact_verified?: boolean | null; + /** The money has arrived but the check-in clock is not running, + * because the owner's email is still unconfirmed (#326). Present + * only on the owner `GET /vaults/:id`. Distinguishes the two + * things `status: "unfunded"` means: waiting for coins, and coins + * are here and waiting for you. */ + activation_held?: boolean | null; /** Whether a trusted contact is on file. Gates the panic-stop * copy's "your trusted contact will be alerted" promise — only * rendered when true (issue #70). Absent on list/create diff --git a/ghostkey-web/src/ui.tsx b/ghostkey-web/src/ui.tsx index 62cd311..ae0751f 100644 --- a/ghostkey-web/src/ui.tsx +++ b/ghostkey-web/src/ui.tsx @@ -446,6 +446,8 @@ export function friendlyEventKind(kind: string): string { case "claim_resolved": return "Your heir opened the claim page"; case "panic_activated": return "Emergency freeze on"; case "panic_expired": return "Emergency freeze ended"; + // #326. Funded, but the clock is waiting on a confirmed email. + case "activation_held": return "Waiting for your email confirmation"; case "drill_started": return "Practice claim sent"; case "drill_opened": return "Your heir opened the practice link"; case "drill_completed": return "Your heir completed the practice claim";