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:
+10
-2
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user