feat(restore): operator-configurable wallet restore policy

How far back a wallet may ask THIS instance to scan on import is an operator
decision — the backfill cost lands on its LWS/node. Add WALLET_RESTORE_POLICY
(create-only | bounded | unlimited, default bounded) + WALLET_MAX_RESTORE_DEPTH_DAYS,
advertise it via /capabilities so the wallet adapts its import UX, and enforce
it at the scan path (xmr/wow register, grin scan): a too-deep start_height is a
ValidationError naming the bound, never the caller's value. Create needs no
check (no tip lookup). Self-sovereignty stays intact at the ecosystem level —
a deeper restore means self-hosting or a looser operator. Fail-closed config
validation; a unit test per policy; OpenAPI drift clean.
This commit is contained in:
jwinterm
2026-06-28 22:15:42 -04:00
parent ff89ca6584
commit 224ef36ad3
7 changed files with 230 additions and 2 deletions
+20
View File
@@ -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<u32>,
}
#[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,
},
},
}
}
+1
View File
@@ -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,
+7 -1
View File
@@ -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 {
+4
View File
@@ -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?