diff --git a/.env.example b/.env.example index bbd095c..b82771c 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,18 @@ GRIN_NODE_API_PASS= GRIN_NODE_FOREIGN_API_URL=http://127.0.0.1:3413/v2/foreign GRIN_NODE_FOREIGN_API_SECRET= +# ── Wallet restore (import) policy ──────────────────────────────────────── +# How far back a wallet may ask THIS instance to scan on import. The backfill +# cost lands on your LWS/node, so it is your call; it is advertised via +# /capabilities so the wallet adapts its import UX. Self-sovereignty is +# preserved — a user needing a deeper restore runs their own instance or picks +# an operator with a looser policy. +# create-only — new wallets only (scan starts at the tip) +# bounded — restore up to WALLET_MAX_RESTORE_DEPTH_DAYS behind the tip +# unlimited — any restore height (the natural choice for self-hosting) +WALLET_RESTORE_POLICY=bounded +WALLET_MAX_RESTORE_DEPTH_DAYS=365 + # ── Feature flags (each feature ships in the open core, toggled per deployment) ── # The wallet client reads GET /api/v1/capabilities and greys out disabled features. FEATURE_PRICES=true diff --git a/openapi.json b/openapi.json index e123894..5bffa99 100644 --- a/openapi.json +++ b/openapi.json @@ -1891,7 +1891,8 @@ "version", "contract_version", "chains", - "features" + "features", + "restore" ], "properties": { "chains": { @@ -1906,6 +1907,10 @@ "features": { "$ref": "#/components/schemas/FeatureCapabilities" }, + "restore": { + "$ref": "#/components/schemas/RestoreCapability", + "description": "Wallet restore (import) policy for this instance." + }, "version": { "type": "string", "description": "Backend version (Cargo package version)." @@ -2972,6 +2977,27 @@ } } }, + "RestoreCapability": { + "type": "object", + "description": "This instance's wallet-restore (import) policy. The wallet uses it to adapt\nits import UX — hide/grey the restore-height field under `create-only`, warn\nwhen a chosen date exceeds the bound. `max_depth_days` is present only for\nthe `bounded` policy.", + "required": [ + "policy" + ], + "properties": { + "max_depth_days": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + }, + "policy": { + "type": "string", + "description": "`create-only` | `bounded` | `unlimited`." + } + } + }, "SetUsernameRequest": { "type": "object", "required": [ diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index c98afb4..be4ccf0 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -49,6 +49,17 @@ pub struct FeatureCapabilities { pub tips: bool, } +/// This instance's wallet-restore (import) policy. The wallet uses it to adapt +/// its import UX — hide/grey the restore-height field under `create-only`, warn +/// when a chosen date exceeds the bound. `max_depth_days` is present only for +/// the `bounded` policy. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct RestoreCapability { + /// `create-only` | `bounded` | `unlimited`. + pub policy: String, + pub max_depth_days: Option, +} + #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct CapabilitiesResponse { /// Backend version (Cargo package version). @@ -57,6 +68,8 @@ pub struct CapabilitiesResponse { pub contract_version: u32, pub chains: ChainCapabilities, pub features: FeatureCapabilities, + /// Wallet restore (import) policy for this instance. + pub restore: RestoreCapability, } /// Whether an enabled chain can actually be served (its infra secret/URL is @@ -115,6 +128,13 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse { && config.identity.public_api_url.is_some(), tips: config.features.tips, }, + restore: RestoreCapability { + policy: config.restore.policy.as_str().to_string(), + max_depth_days: match config.restore.policy { + crate::config::RestorePolicy::Bounded => Some(config.restore.max_depth_days), + _ => None, + }, + }, } } diff --git a/src/api/openapi.rs b/src/api/openapi.rs index a294a98..294919c 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -67,6 +67,7 @@ use utoipa::OpenApi; crate::api::capabilities::ChainCapabilities, crate::api::capabilities::ChainCapability, crate::api::capabilities::FeatureCapabilities, + crate::api::capabilities::RestoreCapability, crate::api::prices::PricesResponse, crate::api::auth::AuthResponse, crate::api::auth::UserInfo, diff --git a/src/api/wallet/grin.rs b/src/api/wallet/grin.rs index ad55aeb..d637134 100644 --- a/src/api/wallet/grin.rs +++ b/src/api/wallet/grin.rs @@ -124,7 +124,13 @@ pub async fn scan( extract_user_id_from_token(&state, &headers).await?; validate_hex64(&req.rewind_hash, "rewind_hash")?; - let view = grin_client(&state)? + let client = grin_client(&state)?; + if let Some(h) = req.start_height { + // Restore: gate the scan depth against this instance's policy. + let tip = client.get_height().await?; + state.config.restore.enforce("grin", h, tip)?; + } + let view = client .scan_rewind_hash(&req.rewind_hash, req.start_height) .await?; Ok(Json(GrinScanResponse { diff --git a/src/api/wallet/xmr_wow.rs b/src/api/wallet/xmr_wow.rs index fff1beb..bc634d2 100644 --- a/src/api/wallet/xmr_wow.rs +++ b/src/api/wallet/xmr_wow.rs @@ -300,6 +300,10 @@ pub async fn register( match req.start_height { Some(h) => { + // Restore: gate the scan depth against this instance's policy (the + // backfill cost lands on our LWS). `None` (create) needs no check. + let tip = client.get_blockchain_height().await?; + state.config.restore.enforce(&asset, h, tip)?; client .import_account(&req.address, &req.view_key, h) .await? diff --git a/src/config.rs b/src/config.rs index a261008..0f8ff33 100644 --- a/src/config.rs +++ b/src/config.rs @@ -128,6 +128,7 @@ pub struct Config { pub admin: AdminConfig, pub landing: LandingConfig, pub retention: RetentionConfig, + pub restore: RestoreConfig, } #[derive(Clone)] @@ -307,6 +308,90 @@ pub struct RetentionConfig { pub grace_period_hours: u64, } +/// Wallet restore (import) policy — an OPERATOR decision, because the scan cost +/// of a deep restore lands on *this* instance's LWS/node. Advertised via +/// `/capabilities` (so the wallet adapts its import UX) and enforced at the scan +/// path. Self-sovereignty is preserved at the ecosystem level: a user needing a +/// deeper restore runs their own instance or picks a looser operator. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RestorePolicy { + /// New-wallet registration only — the scan start must sit at (near) the tip. + CreateOnly, + /// Restore permitted up to `max_depth_days` behind the tip. + Bounded, + /// Any restore height accepted (the natural choice for self-hosting). + Unlimited, +} + +impl RestorePolicy { + /// The wire token used in `/capabilities`. + pub fn as_str(&self) -> &'static str { + match self { + RestorePolicy::CreateOnly => "create-only", + RestorePolicy::Bounded => "bounded", + RestorePolicy::Unlimited => "unlimited", + } + } +} + +#[derive(Clone, Copy)] +pub struct RestoreConfig { + pub policy: RestorePolicy, + /// For `Bounded`: how many days behind the tip a restore may start. + pub max_depth_days: u32, +} + +/// Approximate blocks per day per chain (from target block time). Used only to +/// turn the `max_depth_days` policy into a height floor — a soft operator bound, +/// never a consensus value, so an approximation is fine. +fn blocks_per_day(chain: &str) -> u64 { + match chain { + "xmr" | "wow" => 720, // ~120s target + "grin" => 1440, // ~60s target + "btc" => 144, // ~600s + "ltc" => 576, // ~150s + _ => 720, + } +} + +impl RestoreConfig { + /// The earliest height this instance will scan from for `chain`, given the + /// current `tip`. `0` means "no floor" (Unlimited). + pub fn min_start_height(&self, chain: &str, tip: u64) -> u64 { + let depth_days = match self.policy { + RestorePolicy::Unlimited => return 0, + // 1-day grace so a wallet "created today" still registers under + // create-only without a tight tip race. + RestorePolicy::CreateOnly => 1, + RestorePolicy::Bounded => self.max_depth_days as u64, + }; + tip.saturating_sub(depth_days.saturating_mul(blocks_per_day(chain))) + } + + /// Enforce the policy for a requested `start_height` against the live `tip`. + /// A too-deep restore is a `ValidationError` naming the bound — never the + /// caller's value. Unlimited always passes. + pub fn enforce(&self, chain: &str, start_height: u64, tip: u64) -> Result<(), AppError> { + if self.policy == RestorePolicy::Unlimited + || start_height >= self.min_start_height(chain, tip) + { + return Ok(()); + } + Err(match self.policy { + RestorePolicy::CreateOnly => AppError::ValidationError( + "this instance accepts new-wallet registration only (create-only); a restore \ + scan from an earlier height is not permitted here" + .into(), + ), + RestorePolicy::Bounded => AppError::ValidationError(format!( + "restore depth exceeds this instance's limit of {} days", + self.max_depth_days + )), + RestorePolicy::Unlimited => unreachable!(), + }) + } +} + impl Config { /// Load configuration from the environment and validate it (fail-closed). pub fn from_env() -> Result { @@ -456,6 +541,25 @@ impl Config { export_per_day: env_parse("ERASURE_EXPORT_PER_DAY", 3u32)?, grace_period_hours: env_parse("ERASURE_GRACE_PERIOD_HOURS", 72u64)?, }, + restore: { + let policy = match env_or("WALLET_RESTORE_POLICY", "bounded") + .to_lowercase() + .as_str() + { + "create-only" | "create_only" | "createonly" => RestorePolicy::CreateOnly, + "bounded" => RestorePolicy::Bounded, + "unlimited" => RestorePolicy::Unlimited, + other => { + return Err(cfg_err(format!( + "WALLET_RESTORE_POLICY must be create-only|bounded|unlimited, got {other}" + ))) + } + }; + RestoreConfig { + policy, + max_depth_days: env_parse("WALLET_MAX_RESTORE_DEPTH_DAYS", 365u32)?, + } + }, }; cfg.validate()?; @@ -500,6 +604,14 @@ impl Config { )?; require_secret("IP_SALT", &self.secrets.ip_salt, 16)?; + // Restore policy: a Bounded policy needs a positive depth (0 would + // silently behave as create-only and muddy the capabilities contract). + if self.restore.policy == RestorePolicy::Bounded && self.restore.max_depth_days == 0 { + return Err(cfg_err( + "WALLET_MAX_RESTORE_DEPTH_DAYS must be > 0 when WALLET_RESTORE_POLICY=bounded", + )); + } + // Nostr identity: PUBLIC_API_URL must be a real absolute URL. if self.features.nostr_identity { let url = self.identity.public_api_url.as_deref().ok_or_else(|| { @@ -741,6 +853,10 @@ mod tests { export_per_day: 3, grace_period_hours: 72, }, + restore: RestoreConfig { + policy: RestorePolicy::Bounded, + max_depth_days: 365, + }, } } @@ -832,4 +948,47 @@ mod tests { c.features.chains.btc = true; // btc has no electrum_primary/fallbacks assert!(c.validate().is_err()); } + + #[test] + fn restore_bounded_zero_depth_rejected() { + let mut c = valid(); + c.restore = RestoreConfig { + policy: RestorePolicy::Bounded, + max_depth_days: 0, + }; + assert!(c.validate().is_err()); + } + + #[test] + fn restore_unlimited_accepts_any_height() { + let rc = RestoreConfig { + policy: RestorePolicy::Unlimited, + max_depth_days: 0, + }; + assert!(rc.enforce("xmr", 1, 3_000_000).is_ok()); + } + + #[test] + fn restore_bounded_rejects_too_deep_accepts_within() { + let rc = RestoreConfig { + policy: RestorePolicy::Bounded, + max_depth_days: 30, + }; + let tip = 3_000_000u64; // 30 days * 720 blocks/day => floor = tip - 21_600. + assert!(rc.enforce("xmr", tip - 21_600, tip).is_ok()); // exactly at the floor + assert!(rc.enforce("xmr", tip - 21_601, tip).is_err()); // one below the floor + assert!(rc.enforce("xmr", tip, tip).is_ok()); // create (at the tip) + } + + #[test] + fn restore_create_only_rejects_old_allows_near_tip() { + let rc = RestoreConfig { + policy: RestorePolicy::CreateOnly, + max_depth_days: 365, // ignored under create-only + }; + let tip = 3_000_000u64; + assert!(rc.enforce("xmr", tip, tip).is_ok()); // create + assert!(rc.enforce("xmr", tip - 720, tip).is_ok()); // within the 1-day grace + assert!(rc.enforce("xmr", tip - 100_000, tip).is_err()); // a real restore + } }