mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
+137
-1
@@ -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<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
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user