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
View File
@@ -58,6 +58,13 @@ pub struct RestoreCapability {
/// `create-only` | `bounded` | `unlimited`.
pub policy: String,
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
@@ -147,6 +154,9 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
crate::config::RestorePolicy::Bounded => Some(config.restore.max_depth_days),
_ => 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 {
invite_required: config.registration.require_invite,
+12
View File
@@ -48,6 +48,11 @@ pub struct GrinScanRequest {
pub rewind_hash: String,
/// Scan from this block height (wallet birthday / last scanned). Omit for full.
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.
@@ -129,6 +134,13 @@ pub async fn scan(
// Restore: gate the scan depth against this instance's policy.
let tip = client.get_height().await?;
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
.scan_rewind_hash(&req.rewind_hash, req.start_height)
+12
View File
@@ -78,6 +78,11 @@ pub struct RegisterRequest {
pub view_key: String,
/// Scan from this block height (wallet birthday). Omit to scan from now.
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.
@@ -316,6 +321,13 @@ pub async fn register(
// 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)?;
state.config.restore.enforce_restore_pow(
&asset,
&req.address,
h,
tip,
req.restore_pow_nonce,
)?;
client
.import_account(&req.address, &req.view_key, h)
.await?