Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,61 @@ async fn challenge_gen_with_lucky_hash(runtime: &mut Runtime) -> Result<()> {
Ok(())
}

/// The node ↔ staking-identity coupling: a membership is bound to the joining
/// signer's identity (resolvable for slashing), and each membership reserves the
/// agreement's per-node base stake k_f, released on leave.
async fn filestorage_node_staking_coupling(runtime: &mut Runtime) -> Result<()> {
let signer = runtime.identity().await?;
let created = filestorage::create_agreement(
runtime,
&signer,
make_descriptor(
"coupling_test".to_string(),
vec![7u8; 32],
16,
100,
"coupling.txt".to_string(),
),
)
.await??;
let zero: Decimal = 0u64.try_into().unwrap();
let id = signer.to_string();

// No reservation before joining.
assert_eq!(filestorage::get_node_reservation(runtime, &id).await?, zero);

// Join node_1 → membership bound to the signer's staking identity; k_f reserved.
filestorage::join_agreement(runtime, &signer, &created.agreement_id, "node_1").await??;
assert_eq!(
filestorage::get_node_owner(runtime, &created.agreement_id, "node_1").await?,
Some(id.clone()),
"membership bound to the joiner's staking identity"
);
let r1 = filestorage::get_node_reservation(runtime, &id).await?;
assert!(r1 > zero, "k_f reserved on join");

// Unknown node has no owner.
assert_eq!(
filestorage::get_node_owner(runtime, &created.agreement_id, "ghost").await?,
None
);

// A second membership accumulates another k_f against the same identity.
filestorage::join_agreement(runtime, &signer, &created.agreement_id, "node_2").await??;
let r2 = filestorage::get_node_reservation(runtime, &id).await?;
assert!(r2 > r1, "second membership reserves additional collateral");

// Leaving (inactive agreement → fee-free) releases exactly that membership's k_f.
filestorage::leave_agreement(runtime, &signer, &created.agreement_id, "node_1").await??;
assert_eq!(
filestorage::get_node_reservation(runtime, &id).await?,
r1,
"leaving releases one k_f back to the single-membership reservation"
);

Ok(())
}

pub async fn run_regtest(runtime: &mut Runtime) -> Result<()> {
filestorage_defaults(runtime).await?;
filestorage_empty_file_id_fails(runtime).await?;
Expand All @@ -744,6 +799,7 @@ pub async fn run_regtest(runtime: &mut Runtime) -> Result<()> {
filestorage_is_node_in_nonexistent_agreement(runtime).await?;
filestorage_rejoin_after_leave(runtime).await?;
filestorage_join_after_activation_not_reactivated(runtime).await?;
filestorage_node_staking_coupling(runtime).await?;
Ok(())
}

Expand Down
Binary file modified native-contracts/binaries/filestorage.wasm.br
Binary file not shown.
98 changes: 91 additions & 7 deletions native-contracts/filestorage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ const DEFAULT_LAMBDA_SLASH: u64 = 30;
struct AgreementNodes {
/// node_id -> is_active (true means active, false means left)
pub nodes: Map<String, bool>,
/// node_id -> the staking identity (x-only pubkey / Holder string) that
/// authenticated the membership at join time. This is the coupling that
/// makes a failed challenge's `prover_id` resolve to a slashable stake.
pub owners: Map<String, String>,
pub node_count: u64,
}

Expand Down Expand Up @@ -111,6 +115,17 @@ struct ProtocolState {
pub lambda_slash: u64,
/// Per-agreement emission weights, fixed at creation.
pub agreement_economics: Map<String, AgreementEconomics>,
/// Storage collateral reserved per staking identity (Holder string) — the
/// running `Σ k_f` over every agreement that identity has joined. Collateral
/// is the same stake as consensus: storage providers are validators, and a
/// node's stake both backs its storage obligations and counts as its
/// consensus voting power (unified pool). This field is the portion of that
/// stake committed to storage; incremented on join, decremented on leave.
/// The solvency check (`stake ≥ reserved`) and the slash itself are
/// reactor-orchestrated (core-context) — reading staking stake and calling
/// `staking::slash` are cross-contract operations the reactor performs each
/// block; this contract owns the per-identity accounting they read.
pub node_reservations: Map<String, Decimal>,
}

// ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -143,6 +158,7 @@ impl Guest for Filestorage {
chi_fee_bps: DEFAULT_CHI_FEE_BPS,
lambda_slash: DEFAULT_LAMBDA_SLASH,
agreement_economics: Map::default(),
node_reservations: Map::default(),
}
.init(ctx);
ctx.contract()
Expand Down Expand Up @@ -284,6 +300,33 @@ impl Guest for Filestorage {
)));
}

// ── Authenticated node ↔ staking-identity coupling ─────────────
// A storage node IS a staker: bind this membership to the joining
// signer's staking identity (its x-only pubkey / Holder). This is the
// lynchpin that lets a failed PoR challenge's `prover_id` resolve to a
// slashable stake (Phase 2 reactor: get_failed_challenges → owner →
// staking::slash(owner, λ_slash·k_f)).
let owner: Holder = (&ctx.signer()).into();
let owner_str = owner.to_string();

// Reserve this agreement's per-node base stake k_f against the owner's
// (unified) staking collateral — `Σ k_f` over all its memberships — so a
// node cannot back more storage than its stake covers. The solvency
// check (`stake ≥ reserved`) is reactor-side (reading staking stake is a
// cross-contract op); here we own the accounting it reads against. k_f
// is fixed at agreement creation.
let zero: Decimal = 0u64.try_into()?;
let k_f = model
.agreement_economics()
.get(&agreement_id)
.map(|e| e.k_f())
.unwrap_or(zero);
let reserved = model.node_reservations().get(&owner_str).unwrap_or(zero);
model
.node_reservations()
.set(&owner_str, reserved.add(k_f)?);
nodes_state.owners().set(&node_id, owner_str);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Owner Overwrite Causes Wrong Identity Slashed

Medium Severity

leave_agreement never clears owners[node_id], and join_agreement unconditionally overwrites it with the new joiner's identity at line 328. The active-membership guard (line 296) only blocks re-join if the node is currently active, so after a departure any other identity can claim the same node_id label, silently replacing the owners entry. Any pending failed challenges from the previous owner's tenure then resolve through get_node_owner to the new identity, causing the Phase 2 slasher to slash the wrong staker for failures it had no part in.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3e8892d. Configure here.

// Add node to agreement (or reactivate if previously left)
nodes_state.nodes().set(&node_id, true);

Expand Down Expand Up @@ -348,11 +391,29 @@ impl Guest for Filestorage {
)));
}

// Only the staking identity that joined may leave its own membership.
let owner: Holder = (&ctx.signer()).into();
let owner_str = owner.to_string();
if nodes_state.owners().get(&node_id).unwrap_or_default() != owner_str {
return Err(Error::Message(format!(
"node {} in agreement {} is owned by a different staking identity",
node_id, agreement_id
)));
}

// The agreement's per-node base stake k_f (fixed at creation) drives both
// the leave fee (active agreements only) and the collateral release.
let zero: Decimal = 0u64.try_into()?;
let k_f = model
.agreement_economics()
.get(&agreement_id)
.map(|e| e.k_f())
.unwrap_or(zero);

// The min-replication guard and leave fee apply only to *active*
// agreements, where a stored file's replication must be protected. A node
// may leave an inactive agreement (one that never reached n_min, so no
// file is being stored/challenged) freely and fee-free.
let zero: Decimal = 0u64.try_into()?;
let node_count = nodes_state.node_count();
let min_nodes = model.min_nodes();
let fee = if agreement.active() {
Expand All @@ -367,11 +428,6 @@ impl Guest for Filestorage {
}
// φ_leave = k_f · (n_min/|N_f|)² — quadratic, escalating as replication
// nears n_min — burned from the signer's spendable balance.
let k_f = model
.agreement_economics()
.get(&agreement_id)
.map(|e| e.k_f())
.unwrap_or(zero);
let n_min_d: Decimal = min_nodes.try_into()?;
let n_f_d: Decimal = node_count.try_into()?;
let ratio = n_min_d.div(n_f_d)?;
Expand All @@ -380,9 +436,17 @@ impl Guest for Filestorage {
zero
};

// Effects before the burn interaction (CEI): mark inactive + decrement.
// Effects before the burn interaction (CEI): mark inactive + decrement,
// and release this membership's storage-collateral reservation.
nodes_state.nodes().set(&node_id, false);
nodes_state.update_node_count(|c| c.saturating_sub(1));
let reserved = model.node_reservations().get(&owner_str).unwrap_or(zero);
let released = if reserved > k_f {
reserved.sub(k_f)?
} else {
zero
};
model.node_reservations().set(&owner_str, released);

if fee > zero {
token::burn(ctx.signer(), fee)?;
Expand Down Expand Up @@ -420,6 +484,26 @@ impl Guest for Filestorage {
.unwrap_or(false)
}

fn get_node_owner(ctx: &ViewContext, agreement_id: String, node_id: String) -> Option<String> {
// The staking identity (x-only pubkey) bound to a membership at join.
// The Phase 2 slasher resolves a failed challenge's prover_id (= node_id)
// to this identity before calling staking::slash.
ctx.model()
.agreement_nodes()
.get(&agreement_id)
.and_then(|s| s.owners().get(&node_id))
.filter(|o| !o.is_empty())
}

fn get_node_reservation(ctx: &ViewContext, x_only_pubkey: String) -> Decimal {
// Total storage collateral (`Σ k_f`) this identity has committed across
// its agreements. The reactor enforces `staking_stake ≥ this`.
ctx.model()
.node_reservations()
.get(&x_only_pubkey)
.unwrap_or_else(|| 0u64.try_into().expect("zero fits in Decimal"))
}

fn get_min_nodes(ctx: &ViewContext) -> u64 {
ctx.model().min_nodes()
}
Expand Down
14 changes: 14 additions & 0 deletions native-contracts/filestorage/wit/contract.wit
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,20 @@ world root {
node-id: string
) -> bool;

// The staking identity (x-only pubkey) bound to a membership at join — the
// bridge from a challenge's prover_id to a slashable stake.
export get-node-owner: async func(
ctx: borrow<view-context>,
agreement-id: string,
node-id: string
) -> option<string>;

// Total storage collateral (Σ k_f) an identity has committed across agreements.
export get-node-reservation: async func(
ctx: borrow<view-context>,
x-only-pubkey: string
) -> decimal;

// ─────────────────────────────────────────────────────────────────
// Storage economics
// ─────────────────────────────────────────────────────────────────
Expand Down
Loading