From 56238dcac8b412dd162152265f5b93595974b482 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 30 Jun 2026 18:35:20 -0400 Subject: [PATCH] feat(restore): default create-only + PoW-priced restore depth (gate #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 12 +++- openapi.json | 39 ++++++++++- src/api/capabilities.rs | 10 +++ src/api/wallet/grin.rs | 12 ++++ src/api/wallet/xmr_wow.rs | 12 ++++ src/config.rs | 138 +++++++++++++++++++++++++++++++++++++- src/core/mod.rs | 1 + src/core/restore_pow.rs | 105 +++++++++++++++++++++++++++++ 8 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 src/core/restore_pow.rs diff --git a/.env.example b/.env.example index 84862fe..f8911f9 100644 --- a/.env.example +++ b/.env.example @@ -81,11 +81,19 @@ GRIN_NODE_FOREIGN_API_SECRET= # /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) +# 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 # 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 +# 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) ── # The wallet client reads GET /api/v1/capabilities and greys out disabled features. diff --git a/openapi.json b/openapi.json index f4b45e5..4311835 100644 --- a/openapi.json +++ b/openapi.json @@ -2339,6 +2339,15 @@ "rewind_hash" ], "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": { "type": "string", "description": "The wallet's `rewind_hash` (64 hex). Forwarded to grin-wallet; not stored." @@ -2909,6 +2918,15 @@ "asset": { "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": { "type": [ "integer", @@ -3026,7 +3044,10 @@ "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" + "policy", + "pow_free_days", + "pow_days_per_bit", + "pow_max_bits" ], "properties": { "max_depth_days": { @@ -3040,6 +3061,22 @@ "policy": { "type": "string", "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 } } }, diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index dae4b1a..c54dbae 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -58,6 +58,13 @@ pub struct RestoreCapability { /// `create-only` | `bounded` | `unlimited`. pub policy: String, pub max_depth_days: Option, + /// 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, diff --git a/src/api/wallet/grin.rs b/src/api/wallet/grin.rs index d637134..fa3c613 100644 --- a/src/api/wallet/grin.rs +++ b/src/api/wallet/grin.rs @@ -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, + /// 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, } /// 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) diff --git a/src/api/wallet/xmr_wow.rs b/src/api/wallet/xmr_wow.rs index 3439b62..aa5e9eb 100644 --- a/src/api/wallet/xmr_wow.rs +++ b/src/api/wallet/xmr_wow.rs @@ -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, + /// 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, } /// 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? diff --git a/src/config.rs b/src/config.rs index 6b37552..b63966c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -340,6 +340,15 @@ pub struct RestoreConfig { pub policy: RestorePolicy, /// For `Bounded`: how many days behind the tip a restore may start. 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 @@ -391,6 +400,49 @@ impl RestoreConfig { 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, + ) -> 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 @@ -553,7 +605,7 @@ impl Config { grace_period_hours: env_parse("ERASURE_GRACE_PERIOD_HOURS", 72u64)?, }, restore: { - let policy = match env_or("WALLET_RESTORE_POLICY", "bounded") + let policy = match env_or("WALLET_RESTORE_POLICY", "create-only") .to_lowercase() .as_str() { @@ -569,6 +621,9 @@ impl Config { RestoreConfig { policy, 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 { @@ -870,6 +925,9 @@ mod tests { restore: RestoreConfig { policy: RestorePolicy::Bounded, max_depth_days: 365, + pow_free_days: 90, + pow_days_per_bit: 0, + pow_max_bits: 24, }, registration: RegistrationConfig { require_invite: false, @@ -972,6 +1030,9 @@ mod tests { c.restore = RestoreConfig { policy: RestorePolicy::Bounded, max_depth_days: 0, + pow_free_days: 90, + pow_days_per_bit: 0, + pow_max_bits: 24, }; assert!(c.validate().is_err()); } @@ -981,6 +1042,9 @@ mod tests { let rc = RestoreConfig { policy: RestorePolicy::Unlimited, 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()); } @@ -990,6 +1054,9 @@ mod tests { let rc = RestoreConfig { policy: RestorePolicy::Bounded, 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. assert!(rc.enforce("xmr", tip - 21_600, tip).is_ok()); // exactly at the floor @@ -1002,10 +1069,79 @@ mod tests { let rc = RestoreConfig { policy: RestorePolicy::CreateOnly, 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; 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 } + + 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) + ); + } } diff --git a/src/core/mod.rs b/src/core/mod.rs index ce27ce0..f5d3b51 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -7,6 +7,7 @@ pub mod admin_session; pub mod crypto; pub mod invite; pub mod pow; +pub mod restore_pow; pub mod secret; pub mod session; diff --git a/src/core/restore_pow.rs b/src/core/restore_pow.rs new file mode 100644 index 0000000..4c15dda --- /dev/null +++ b/src/core/restore_pow.rs @@ -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 { + 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)); + } +}