mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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
|
||||
|
||||
+27
-1
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?
|
||||
|
||||
+159
@@ -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<Self, AppError> {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user