feat(restore): default create-only + PoW-priced restore depth (gate #2)

Default restore policy is now create-only — which betrays no self-sovereignty,
since that lives at the ECOSYSTEM level (run your own backend). On top of the
create-only|bounded|unlimited depth gate, restore depth can be PRICED in hashcash
PoW so an importer pays compute proportional to the chain-scan cost they impose
on the operator (deeper restore => more work).

- core::restore_pow: a self-contained hashcash bound to (asset, address,
  start_height) — a solved nonce is valid only for that exact restore, so no
  challenge state, no replay, no depth-downgrade. Difficulty = leading zero bits.
- RestoreConfig: pow_free_days / pow_days_per_bit (0 = off) / pow_max_bits curve;
  required_restore_pow_bits + enforce_restore_pow (literal errors, no oracle).
- Enforced at xmr/wow register + grin scan via a new restore_pow_nonce; the curve
  is advertised via /capabilities so the wallet computes + solves it.
- Default WALLET_RESTORE_POLICY flipped bounded -> create-only.
- Unit tests (curve scaling/cap, off-when-zero, valid/invalid nonce, hashcash
  roundtrip + param-binding); .env.example; OpenAPI drift clean.
This commit is contained in:
jwinterm
2026-06-30 18:35:20 -04:00
parent 610819bb5d
commit 56238dcac8
8 changed files with 325 additions and 4 deletions
+10 -2
View File
@@ -81,11 +81,19 @@ GRIN_NODE_FOREIGN_API_SECRET=
# /capabilities so the wallet adapts its import UX. Self-sovereignty is # /capabilities so the wallet adapts its import UX. Self-sovereignty is
# preserved — a user needing a deeper restore runs their own instance or picks # preserved — a user needing a deeper restore runs their own instance or picks
# an operator with a looser policy. # an operator with a looser policy.
# create-only — new wallets only (scan starts at the tip) # create-only — new wallets only (scan starts at the tip). DEFAULT — and it
# betrays nothing: self-sovereignty is run-your-own-backend.
# bounded — restore up to WALLET_MAX_RESTORE_DEPTH_DAYS behind the tip # bounded — restore up to WALLET_MAX_RESTORE_DEPTH_DAYS behind the tip
# unlimited — any restore height (the natural choice for self-hosting) # unlimited — any restore height (the natural choice for self-hosting)
WALLET_RESTORE_POLICY=bounded WALLET_RESTORE_POLICY=create-only
WALLET_MAX_RESTORE_DEPTH_DAYS=365 WALLET_MAX_RESTORE_DEPTH_DAYS=365
# PoW-priced restore: charge the importer compute proportional to the scan depth
# they impose on you. Free below WALLET_RESTORE_POW_FREE_DAYS, then +1 hashcash
# difficulty bit per WALLET_RESTORE_POW_DAYS_PER_BIT days of depth (0 = pricing
# OFF), capped at WALLET_RESTORE_POW_MAX_BITS. Applies within bounded/unlimited.
WALLET_RESTORE_POW_FREE_DAYS=90
WALLET_RESTORE_POW_DAYS_PER_BIT=0
WALLET_RESTORE_POW_MAX_BITS=24
# ── Feature flags (each feature ships in the open core, toggled per deployment) ── # ── 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. # The wallet client reads GET /api/v1/capabilities and greys out disabled features.
+38 -1
View File
@@ -2339,6 +2339,15 @@
"rewind_hash" "rewind_hash"
], ],
"properties": { "properties": {
"restore_pow_nonce": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Restore proof-of-work nonce. Required when the instance prices the\nrequested restore depth (see `/capabilities` → `restore.pow_*`); bound to\n`(grin, rewind_hash, start_height)` (see `restore_pow`).",
"minimum": 0
},
"rewind_hash": { "rewind_hash": {
"type": "string", "type": "string",
"description": "The wallet's `rewind_hash` (64 hex). Forwarded to grin-wallet; not stored." "description": "The wallet's `rewind_hash` (64 hex). Forwarded to grin-wallet; not stored."
@@ -2909,6 +2918,15 @@
"asset": { "asset": {
"type": "string" "type": "string"
}, },
"restore_pow_nonce": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Restore proof-of-work nonce. Required when the instance prices the\nrequested restore depth (see `/capabilities` → `restore.pow_*`); ignored\notherwise. Bound to `(asset, address, start_height)` (see `restore_pow`).",
"minimum": 0
},
"start_height": { "start_height": {
"type": [ "type": [
"integer", "integer",
@@ -3026,7 +3044,10 @@
"type": "object", "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.", "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": [ "required": [
"policy" "policy",
"pow_free_days",
"pow_days_per_bit",
"pow_max_bits"
], ],
"properties": { "properties": {
"max_depth_days": { "max_depth_days": {
@@ -3040,6 +3061,22 @@
"policy": { "policy": {
"type": "string", "type": "string",
"description": "`create-only` | `bounded` | `unlimited`." "description": "`create-only` | `bounded` | `unlimited`."
},
"pow_days_per_bit": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"pow_free_days": {
"type": "integer",
"format": "int32",
"description": "Restore PoW pricing curve: a restore depth (days) free of PoW, then `+1`\nhashcash difficulty bit per `pow_days_per_bit` days beyond it\n(`0` = pricing off), capped at `pow_max_bits`. The wallet computes its\nrequired difficulty from this + the restore date and solves the hashcash.",
"minimum": 0
},
"pow_max_bits": {
"type": "integer",
"format": "int32",
"minimum": 0
} }
} }
}, },
+10
View File
@@ -58,6 +58,13 @@ pub struct RestoreCapability {
/// `create-only` | `bounded` | `unlimited`. /// `create-only` | `bounded` | `unlimited`.
pub policy: String, pub policy: String,
pub max_depth_days: Option<u32>, pub max_depth_days: Option<u32>,
/// Restore PoW pricing curve: a restore depth (days) free of PoW, then `+1`
/// hashcash difficulty bit per `pow_days_per_bit` days beyond it
/// (`0` = pricing off), capped at `pow_max_bits`. The wallet computes its
/// required difficulty from this + the restore date and solves the hashcash.
pub pow_free_days: u32,
pub pow_days_per_bit: u32,
pub pow_max_bits: u32,
} }
/// Registration gates this instance enforces for a NEW wallet (returning wallets /// Registration gates this instance enforces for a NEW wallet (returning wallets
@@ -147,6 +154,9 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
crate::config::RestorePolicy::Bounded => Some(config.restore.max_depth_days), crate::config::RestorePolicy::Bounded => Some(config.restore.max_depth_days),
_ => None, _ => None,
}, },
pow_free_days: config.restore.pow_free_days,
pow_days_per_bit: config.restore.pow_days_per_bit,
pow_max_bits: config.restore.pow_max_bits,
}, },
registration: RegistrationCapability { registration: RegistrationCapability {
invite_required: config.registration.require_invite, invite_required: config.registration.require_invite,
+12
View File
@@ -48,6 +48,11 @@ pub struct GrinScanRequest {
pub rewind_hash: String, pub rewind_hash: String,
/// Scan from this block height (wallet birthday / last scanned). Omit for full. /// Scan from this block height (wallet birthday / last scanned). Omit for full.
pub start_height: Option<u64>, pub start_height: Option<u64>,
/// Restore proof-of-work nonce. Required when the instance prices the
/// requested restore depth (see `/capabilities` → `restore.pow_*`); bound to
/// `(grin, rewind_hash, start_height)` (see `restore_pow`).
#[serde(default)]
pub restore_pow_nonce: Option<u64>,
} }
/// A single output recovered by a view-only scan. Amounts are nanogrin. /// A single output recovered by a view-only scan. Amounts are nanogrin.
@@ -129,6 +134,13 @@ pub async fn scan(
// Restore: gate the scan depth against this instance's policy. // Restore: gate the scan depth against this instance's policy.
let tip = client.get_height().await?; let tip = client.get_height().await?;
state.config.restore.enforce("grin", h, tip)?; state.config.restore.enforce("grin", h, tip)?;
state.config.restore.enforce_restore_pow(
"grin",
&req.rewind_hash,
h,
tip,
req.restore_pow_nonce,
)?;
} }
let view = client let view = client
.scan_rewind_hash(&req.rewind_hash, req.start_height) .scan_rewind_hash(&req.rewind_hash, req.start_height)
+12
View File
@@ -78,6 +78,11 @@ pub struct RegisterRequest {
pub view_key: String, pub view_key: String,
/// Scan from this block height (wallet birthday). Omit to scan from now. /// Scan from this block height (wallet birthday). Omit to scan from now.
pub start_height: Option<u64>, pub start_height: Option<u64>,
/// Restore proof-of-work nonce. Required when the instance prices the
/// requested restore depth (see `/capabilities` → `restore.pow_*`); ignored
/// otherwise. Bound to `(asset, address, start_height)` (see `restore_pow`).
#[serde(default)]
pub restore_pow_nonce: Option<u64>,
} }
/// Request decoy outputs for ring construction. /// Request decoy outputs for ring construction.
@@ -316,6 +321,13 @@ pub async fn register(
// backfill cost lands on our LWS). `None` (create) needs no check. // backfill cost lands on our LWS). `None` (create) needs no check.
let tip = client.get_blockchain_height().await?; let tip = client.get_blockchain_height().await?;
state.config.restore.enforce(&asset, h, tip)?; state.config.restore.enforce(&asset, h, tip)?;
state.config.restore.enforce_restore_pow(
&asset,
&req.address,
h,
tip,
req.restore_pow_nonce,
)?;
client client
.import_account(&req.address, &req.view_key, h) .import_account(&req.address, &req.view_key, h)
.await? .await?
+137 -1
View File
@@ -340,6 +340,15 @@ pub struct RestoreConfig {
pub policy: RestorePolicy, pub policy: RestorePolicy,
/// For `Bounded`: how many days behind the tip a restore may start. /// For `Bounded`: how many days behind the tip a restore may start.
pub max_depth_days: u32, pub max_depth_days: u32,
/// Restore depth (days behind tip) that is FREE of PoW pricing.
pub pow_free_days: u32,
/// `+1` hashcash difficulty bit per this many days of restore depth beyond
/// the free window. `0` disables restore pricing (the depth gate above still
/// applies). Each bit ~doubles the wallet's solve time, so the requester pays
/// compute proportional to the scan cost they impose on the operator.
pub pow_days_per_bit: u32,
/// Hard cap on the priced difficulty (bounds wallet solve time on a phone).
pub pow_max_bits: u32,
} }
/// Approximate blocks per day per chain (from target block time). Used only to /// Approximate blocks per day per chain (from target block time). Used only to
@@ -391,6 +400,49 @@ impl RestoreConfig {
RestorePolicy::Unlimited => unreachable!(), RestorePolicy::Unlimited => unreachable!(),
}) })
} }
/// Hashcash difficulty (leading zero BITS) required to restore `chain` from
/// `start_height` against `tip` — `0` within the free window or when pricing
/// is off. The depth→bits curve is the operator's "pay for the scan you cost
/// me" knob; it sits ON TOP of the [`enforce`](Self::enforce) depth gate.
pub fn required_restore_pow_bits(&self, chain: &str, start_height: u64, tip: u64) -> u32 {
if self.pow_days_per_bit == 0 {
return 0;
}
let depth_days = tip.saturating_sub(start_height) / blocks_per_day(chain).max(1);
let over = depth_days.saturating_sub(self.pow_free_days as u64);
((over / self.pow_days_per_bit as u64) as u32).min(self.pow_max_bits)
}
/// Enforce restore PoW pricing: when the requested depth is priced, a valid
/// hashcash `nonce` (bound to `chain`/`address`/`start_height`) of the
/// required difficulty must accompany the restore. A literal error names the
/// requirement, never the caller's value.
pub fn enforce_restore_pow(
&self,
chain: &str,
address: &str,
start_height: u64,
tip: u64,
nonce: Option<u64>,
) -> Result<(), AppError> {
let bits = self.required_restore_pow_bits(chain, start_height, tip);
if bits == 0 {
return Ok(());
}
let nonce = nonce.ok_or_else(|| {
AppError::ValidationError(format!(
"this restore depth requires a {bits}-bit proof-of-work nonce; \
upgrade to a newer Smirk client"
))
})?;
if !crate::core::restore_pow::verify(chain, address, start_height, nonce, bits) {
return Err(AppError::ValidationError(
"restore proof-of-work is insufficient for the requested depth".into(),
));
}
Ok(())
}
} }
/// Registration gates beyond PoW — composable, operator-configured, advertised /// Registration gates beyond PoW — composable, operator-configured, advertised
@@ -553,7 +605,7 @@ impl Config {
grace_period_hours: env_parse("ERASURE_GRACE_PERIOD_HOURS", 72u64)?, grace_period_hours: env_parse("ERASURE_GRACE_PERIOD_HOURS", 72u64)?,
}, },
restore: { restore: {
let policy = match env_or("WALLET_RESTORE_POLICY", "bounded") let policy = match env_or("WALLET_RESTORE_POLICY", "create-only")
.to_lowercase() .to_lowercase()
.as_str() .as_str()
{ {
@@ -569,6 +621,9 @@ impl Config {
RestoreConfig { RestoreConfig {
policy, policy,
max_depth_days: env_parse("WALLET_MAX_RESTORE_DEPTH_DAYS", 365u32)?, max_depth_days: env_parse("WALLET_MAX_RESTORE_DEPTH_DAYS", 365u32)?,
pow_free_days: env_parse("WALLET_RESTORE_POW_FREE_DAYS", 90u32)?,
pow_days_per_bit: env_parse("WALLET_RESTORE_POW_DAYS_PER_BIT", 0u32)?,
pow_max_bits: env_parse("WALLET_RESTORE_POW_MAX_BITS", 24u32)?,
} }
}, },
registration: RegistrationConfig { registration: RegistrationConfig {
@@ -870,6 +925,9 @@ mod tests {
restore: RestoreConfig { restore: RestoreConfig {
policy: RestorePolicy::Bounded, policy: RestorePolicy::Bounded,
max_depth_days: 365, max_depth_days: 365,
pow_free_days: 90,
pow_days_per_bit: 0,
pow_max_bits: 24,
}, },
registration: RegistrationConfig { registration: RegistrationConfig {
require_invite: false, require_invite: false,
@@ -972,6 +1030,9 @@ mod tests {
c.restore = RestoreConfig { c.restore = RestoreConfig {
policy: RestorePolicy::Bounded, policy: RestorePolicy::Bounded,
max_depth_days: 0, max_depth_days: 0,
pow_free_days: 90,
pow_days_per_bit: 0,
pow_max_bits: 24,
}; };
assert!(c.validate().is_err()); assert!(c.validate().is_err());
} }
@@ -981,6 +1042,9 @@ mod tests {
let rc = RestoreConfig { let rc = RestoreConfig {
policy: RestorePolicy::Unlimited, policy: RestorePolicy::Unlimited,
max_depth_days: 0, max_depth_days: 0,
pow_free_days: 0,
pow_days_per_bit: 0,
pow_max_bits: 0,
}; };
assert!(rc.enforce("xmr", 1, 3_000_000).is_ok()); assert!(rc.enforce("xmr", 1, 3_000_000).is_ok());
} }
@@ -990,6 +1054,9 @@ mod tests {
let rc = RestoreConfig { let rc = RestoreConfig {
policy: RestorePolicy::Bounded, policy: RestorePolicy::Bounded,
max_depth_days: 30, max_depth_days: 30,
pow_free_days: 0,
pow_days_per_bit: 0,
pow_max_bits: 0,
}; };
let tip = 3_000_000u64; // 30 days * 720 blocks/day => floor = tip - 21_600. 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_600, tip).is_ok()); // exactly at the floor
@@ -1002,10 +1069,79 @@ mod tests {
let rc = RestoreConfig { let rc = RestoreConfig {
policy: RestorePolicy::CreateOnly, policy: RestorePolicy::CreateOnly,
max_depth_days: 365, // ignored under create-only max_depth_days: 365, // ignored under create-only
pow_free_days: 0,
pow_days_per_bit: 0,
pow_max_bits: 0,
}; };
let tip = 3_000_000u64; let tip = 3_000_000u64;
assert!(rc.enforce("xmr", tip, tip).is_ok()); // create 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 - 720, tip).is_ok()); // within the 1-day grace
assert!(rc.enforce("xmr", tip - 100_000, tip).is_err()); // a real restore assert!(rc.enforce("xmr", tip - 100_000, tip).is_err()); // a real restore
} }
fn priced(free: u32, per_bit: u32, max: u32) -> RestoreConfig {
RestoreConfig {
policy: RestorePolicy::Unlimited,
max_depth_days: 0,
pow_free_days: free,
pow_days_per_bit: per_bit,
pow_max_bits: max,
}
}
#[test]
fn restore_pow_bits_scale_with_depth_and_cap() {
let rc = priced(30, 30, 10); // free 30d, +1 bit / 30d, cap 10
let tip = 3_000_000u64;
let d = |days: u64| tip.saturating_sub(days * 720); // xmr = 720 blocks/day
assert_eq!(
rc.required_restore_pow_bits("xmr", d(20), tip),
0,
"within free window"
);
assert_eq!(
rc.required_restore_pow_bits("xmr", d(90), tip),
2,
"30 free + 60 over => 2 bits"
);
assert_eq!(
rc.required_restore_pow_bits("xmr", d(100_000), tip),
10,
"capped"
);
}
#[test]
fn restore_pow_off_when_days_per_bit_zero() {
let rc = priced(0, 0, 24);
assert_eq!(rc.required_restore_pow_bits("xmr", 1, 3_000_000), 0);
assert!(rc
.enforce_restore_pow("xmr", "addr", 1, 3_000_000, None)
.is_ok());
}
#[test]
fn restore_pow_requires_a_valid_nonce_when_priced() {
let rc = priced(0, 1, 8);
let tip = 3_000_000u64;
let start = tip - 8 * 720; // 8 days depth => 8 bits
assert_eq!(rc.required_restore_pow_bits("xmr", start, tip), 8);
// Missing nonce is rejected.
assert!(rc
.enforce_restore_pow("xmr", "addr", start, tip, None)
.is_err());
// A correctly-solved nonce is accepted.
let nonce = (0u64..)
.find(|&n| crate::core::restore_pow::verify("xmr", "addr", start, n, 8))
.unwrap();
assert!(rc
.enforce_restore_pow("xmr", "addr", start, tip, Some(nonce))
.is_ok());
// A nonce that doesn't clear the bar is rejected.
assert!(
rc.enforce_restore_pow("xmr", "addr", start, tip, Some(nonce.wrapping_add(1)))
.is_err()
|| crate::core::restore_pow::verify("xmr", "addr", start, nonce.wrapping_add(1), 8)
);
}
} }
+1
View File
@@ -7,6 +7,7 @@ pub mod admin_session;
pub mod crypto; pub mod crypto;
pub mod invite; pub mod invite;
pub mod pow; pub mod pow;
pub mod restore_pow;
pub mod secret; pub mod secret;
pub mod session; pub mod session;
+105
View File
@@ -0,0 +1,105 @@
//! Restore proof-of-work — a hashcash gate sized to the RESTORE DEPTH.
//!
//! A wallet importing a foreign seed pays compute proportional to the chain-scan
//! cost it imposes on the operator's LWS/node: deeper restore ⇒ more work. This
//! is the "pay for what you cost the operator" mechanism (see the restore-policy
//! decision), distinct from the ALTCHA registration gate ([`crate::core::pow`]).
//!
//! It is deliberately self-contained — no server-issued challenge, no state —
//! because the work is bound to the restore parameters themselves: a solved
//! nonce satisfies the difficulty ONLY for this exact `(asset, address,
//! start_height)`, so it cannot be precomputed generically, replayed against a
//! different account, or down-graded to a shallower (cheaper) restore.
use sha2::{Digest, Sha256};
/// Domain tag so a restore-PoW hash can never collide with another protocol hash.
const DOMAIN: &[u8] = b"smirk-restore-pow-v1";
/// The hashcash preimage (minus the nonce) that pins the work to one restore.
fn challenge_input(asset: &str, address: &str, start_height: u64) -> Vec<u8> {
let mut v = Vec::with_capacity(DOMAIN.len() + asset.len() + address.len() + 16);
v.extend_from_slice(DOMAIN);
v.push(0x1f);
v.extend_from_slice(asset.as_bytes());
v.push(0x1f);
v.extend_from_slice(address.as_bytes());
v.push(0x1f);
v.extend_from_slice(&start_height.to_le_bytes());
v
}
/// Leading zero BITS of a digest — the hashcash difficulty metric (each bit
/// doubles expected solver work).
fn leading_zero_bits(digest: &[u8]) -> u32 {
let mut n = 0;
for &b in digest {
if b == 0 {
n += 8;
} else {
n += b.leading_zeros();
break;
}
}
n
}
/// Verify a restore-PoW nonce: `sha256(domain ‖ asset ‖ address ‖ start_height ‖
/// nonce)` must have at least `required_bits` leading zero bits.
/// `required_bits == 0` (no pricing / within the free depth) accepts anything.
pub fn verify(
asset: &str,
address: &str,
start_height: u64,
nonce: u64,
required_bits: u32,
) -> bool {
if required_bits == 0 {
return true;
}
let mut hasher = Sha256::new();
hasher.update(challenge_input(asset, address, start_height));
hasher.update(nonce.to_le_bytes());
leading_zero_bits(&hasher.finalize()) >= required_bits
}
#[cfg(test)]
mod tests {
use super::*;
/// Brute-force the first satisfying nonce (small difficulty → fast).
fn solve(asset: &str, address: &str, h: u64, bits: u32) -> u64 {
(0u64..)
.find(|&n| verify(asset, address, h, n, bits))
.unwrap()
}
#[test]
fn solve_then_verify_roundtrips() {
let n = solve("xmr", "Wo123", 100, 8);
assert!(verify("xmr", "Wo123", 100, n, 8));
}
#[test]
fn zero_bits_accepts_any_nonce() {
assert!(verify("xmr", "addr", 1, 0, 0));
assert!(verify("xmr", "addr", 1, 12345, 0));
}
#[test]
fn unsolved_nonce_fails_high_difficulty() {
// A fixed un-mined nonce essentially never clears a 20-bit bar (~1/2^20).
assert!(!verify("xmr", "addr", 100, 0, 20));
}
#[test]
fn nonce_is_bound_to_restore_params() {
let n = solve("xmr", "addrA", 100, 12);
assert!(verify("xmr", "addrA", 100, n, 12));
// The same nonce against a different address recomputes an independent
// hash, which must not meet the SAME bar (false-accept ~1/2^12).
assert!(!verify("xmr", "addrB", 100, n, 12));
assert!(!verify("wow", "addrA", 100, n, 12));
assert!(!verify("xmr", "addrA", 101, n, 12));
}
}