diff --git a/core/indexer/tests/contracts/mod.rs b/core/indexer/tests/contracts/mod.rs index c3773f00..c9c03172 100644 --- a/core/indexer/tests/contracts/mod.rs +++ b/core/indexer/tests/contracts/mod.rs @@ -24,6 +24,7 @@ mod shared_cluster; mod simulate_contract; mod simulate_errors; mod staking_contract; +mod staking_slash; mod status_classification; mod token_contract; mod wit_contract; diff --git a/core/indexer/tests/contracts/staking_slash.rs b/core/indexer/tests/contracts/staking_slash.rs new file mode 100644 index 00000000..b00ff368 --- /dev/null +++ b/core/indexer/tests/contracts/staking_slash.rs @@ -0,0 +1,409 @@ +//! Lite (in-process, no cluster) tests for the staking contract's slashing +//! surface: `slash` and `distribute_slash`. Uses `test_runtime_with_genesis` +//! to stand up active, funded validators, then drives the core-context methods +//! directly via the auto-generated `staking::api` host wrappers. + +use anyhow::{Result, anyhow}; +use indexer::keygen::derive_validator; +use indexer::runtime::staking::api as staking; +use indexer::runtime::{Decimal, GenesisValidator}; +use indexer::test_utils::test_runtime_with_genesis; +use testlib::Signer; + +fn dec(n: u64) -> Decimal { + Decimal::try_from(n).unwrap() +} + +fn core() -> Signer { + Signer::Core(Box::new(Signer::Nobody)) +} + +/// `n` genesis validators each staking `stake`, returning their x-only pubkeys. +fn validators(n: u32, stake: u64) -> (Vec, Vec) { + let seed = [0x11u8; 32]; + let (mut gvs, mut pks) = (Vec::new(), Vec::new()); + for i in 0..n { + let k = derive_validator(&seed, i); + let pk = hex::encode(k.x_only_pubkey); + gvs.push(GenesisValidator { + x_only_pubkey: pk.clone(), + stake: dec(stake), + ed25519_pubkey: k.ed25519_pubkey.to_vec(), + }); + pks.push(pk); + } + (gvs, pks) +} + +#[tokio::test] +async fn slash_reduces_stake_burns_tau_and_returns_remainder() -> Result<()> { + let (gvs, pks) = validators(3, 100); + let (mut rt, _dir, _name) = test_runtime_with_genesis(&gvs).await?; + + // τ_slash default = 50% (5000 bps): slashing 40 burns 20, leaves 20 to redistribute. + let res = staking::slash(&mut rt, &core(), &pks[0], dec(40)) + .await? + .map_err(|e| anyhow!("{e:?}"))?; + assert_eq!(res.slashed, dec(40), "slashed"); + assert_eq!(res.burned, dec(20), "burned (τ_slash share)"); + assert_eq!(res.redistributable, dec(20), "redistributable remainder"); + + let v = staking::get_validator(&mut rt, &pks[0]) + .await? + .expect("validator 0 exists"); + assert_eq!(v.stake, dec(60), "stake reduced 100 -> 60"); + Ok(()) +} + +#[tokio::test] +async fn slash_saturates_at_stake() -> Result<()> { + let (gvs, pks) = validators(1, 100); + let (mut rt, _dir, _name) = test_runtime_with_genesis(&gvs).await?; + + // Slash more than the stake: deduction saturates at k_n, shortfall not carried. + let res = staking::slash(&mut rt, &core(), &pks[0], dec(1000)) + .await? + .map_err(|e| anyhow!("{e:?}"))?; + assert_eq!(res.slashed, dec(100), "saturates at the node's full stake"); + + let v = staking::get_validator(&mut rt, &pks[0]) + .await? + .expect("validator 0 exists"); + assert_eq!(v.stake, dec(0), "stake driven to zero"); + Ok(()) +} + +#[tokio::test] +async fn distribute_slash_credits_recipients_evenly() -> Result<()> { + let (gvs, pks) = validators(3, 100); + let (mut rt, _dir, _name) = test_runtime_with_genesis(&gvs).await?; + + // Redistribute 30 across nodes 1 and 2 -> 15 each (even split, exact). + staking::distribute_slash( + &mut rt, + &core(), + vec![pks[1].as_str(), pks[2].as_str()], + dec(30), + ) + .await? + .map_err(|e| anyhow!("{e:?}"))?; + + for pk in [&pks[1], &pks[2]] { + let v = staking::get_validator(&mut rt, pk) + .await? + .expect("recipient exists"); + assert_eq!(v.stake, dec(115), "recipient credited 100 + 15"); + } + // Non-recipient untouched. + let v0 = staking::get_validator(&mut rt, &pks[0]) + .await? + .expect("validator 0 exists"); + assert_eq!(v0.stake, dec(100), "non-recipient unchanged"); + Ok(()) +} + +#[tokio::test] +async fn slash_equivocation_takes_all_ejects_and_splits_bounty() -> Result<()> { + let (gvs, pks) = validators(3, 100); // 3 active validators, 100 each (total 300) + let (mut rt, _dir, _name) = test_runtime_with_genesis(&gvs).await?; + + let before = staking::get_staking_info(&mut rt).await?; + assert_eq!(before.active_count, 3); + assert_eq!(before.total_stake, dec(300)); + + // pks[0] equivocates; pks[2] publishes the evidence. + let res = staking::slash_equivocation(&mut rt, &core(), &pks[0], &pks[2]) + .await? + .map_err(|e| anyhow!("{e:?}"))?; + + // λ_equiv = 100% (entire stake); r_evid default = 5% (500 bps): 100 -> bounty 5, burn 95. + assert_eq!( + res.slashed, + dec(100), + "entire stake slashed (λ_equiv = 100%)" + ); + assert_eq!(res.bounty, dec(5), "5% evidence-publication bounty"); + assert_eq!(res.burned, dec(95), "remaining 95% burned"); + assert_eq!(res.publisher, pks[2], "publisher echoed back"); + + // Offender is zeroed and ejected from the set. + let off = staking::get_validator(&mut rt, &pks[0]) + .await? + .expect("offender exists"); + assert_eq!(off.stake, dec(0), "offender stake driven to zero"); + assert_eq!( + off.status, + staking::ValidatorStatus::Inactive, + "offender ejected from the set" + ); + + // The bounty is paid to the publisher's *spendable* balance, NOT its stake. + let pubv = staking::get_validator(&mut rt, &pks[2]) + .await? + .expect("publisher exists"); + assert_eq!( + pubv.stake, + dec(100), + "publisher's staked balance is unchanged (bounty is spendable)" + ); + + // Active set shrank by one; total active stake dropped by the offender's stake. + let after = staking::get_staking_info(&mut rt).await?; + assert_eq!(after.active_count, 2, "ejected from the active set"); + assert_eq!(after.total_stake, dec(200), "total active stake 300 -> 200"); + Ok(()) +} + +#[tokio::test] +async fn slash_equivocation_self_publication_burns_everything() -> Result<()> { + let (gvs, pks) = validators(2, 100); + let (mut rt, _dir, _name) = test_runtime_with_genesis(&gvs).await?; + + // The offender names their OWN key as the evidence publisher to try to + // recover the 5% bounty. Self-publication is forbidden: bounty must be 0 and + // the entire stake burned (no rebate). + let res = staking::slash_equivocation(&mut rt, &core(), &pks[0], &pks[0]) + .await? + .map_err(|e| anyhow!("{e:?}"))?; + assert_eq!(res.slashed, dec(100), "entire stake slashed"); + assert_eq!(res.bounty, dec(0), "no self-publication bounty"); + assert_eq!(res.burned, dec(100), "entire penalty burned"); + + let off = staking::get_validator(&mut rt, &pks[0]) + .await? + .expect("offender exists"); + assert_eq!(off.stake, dec(0), "offender zeroed"); + assert_eq!(off.status, staking::ValidatorStatus::Inactive, "ejected"); + Ok(()) +} + +#[tokio::test] +async fn distribute_ordering_reward_is_stake_weighted() -> Result<()> { + // Two validators with unequal stake: 100 and 300 (total 400). + let seed = [0x33u8; 32]; + let mk = |i: u32, stake: u64| { + let k = derive_validator(&seed, i); + let pk = hex::encode(k.x_only_pubkey); + ( + GenesisValidator { + x_only_pubkey: pk.clone(), + stake: dec(stake), + ed25519_pubkey: k.ed25519_pubkey.to_vec(), + }, + pk, + ) + }; + let (g0, pk0) = mk(0, 100); + let (g1, pk1) = mk(1, 300); + let (mut rt, _dir, _name) = test_runtime_with_genesis(&[g0, g1]).await?; + + // Distribute 40 in proportion to stake: 40·100/400 = 10, 40·300/400 = 30. + staking::distribute_ordering_reward(&mut rt, &core(), dec(40)) + .await? + .map_err(|e| anyhow!("{e:?}"))?; + + let v0 = staking::get_validator(&mut rt, &pk0).await?.expect("v0"); + let v1 = staking::get_validator(&mut rt, &pk1).await?.expect("v1"); + assert_eq!(v0.stake, dec(110), "100 + 40·100/400"); + assert_eq!(v1.stake, dec(330), "300 + 40·300/400"); + + let info = staking::get_staking_info(&mut rt).await?; + assert_eq!( + info.total_stake, + dec(440), + "total active stake grew by the reward" + ); + Ok(()) +} + +/// Property: `distribute_ordering_reward` conserves value exactly — for ANY +/// stake distribution and pool size, the active-stake total grows by exactly +/// the pool and the sum of individual stakes still equals that total (the +/// last-recipient-absorbs-remainder rule leaves no dust unaccounted). Driven +/// over many deterministic pseudo-random cases. +#[tokio::test] +async fn distribute_ordering_reward_conserves_over_random_cases() -> Result<()> { + for seed in 0u64..24 { + let n = 2 + (seed % 5) as u32; // 2..=6 validators + let seed_bytes = [(seed as u8).wrapping_mul(37).wrapping_add(1); 32]; + + let mut gvs = Vec::new(); + let mut pks = Vec::new(); + let mut staked_total = 0u64; + for i in 0..n { + let k = derive_validator(&seed_bytes, i); + // ≤ 1000: genesis stake is minted via token::issue_to, which on this + // branch still carries main's 1000 dev-mint cap (lifted by #437). + let stake = 1 + (seed.wrapping_mul(101).wrapping_add(i as u64 * 17) % 1000); + staked_total += stake; + pks.push(hex::encode(k.x_only_pubkey)); + gvs.push(GenesisValidator { + x_only_pubkey: pks[i as usize].clone(), + stake: dec(stake), + ed25519_pubkey: k.ed25519_pubkey.to_vec(), + }); + } + + let (mut rt, _dir, _name) = test_runtime_with_genesis(&gvs).await?; + let pool = 1 + (seed.wrapping_mul(7).wrapping_add(3) % 9999); + + staking::distribute_ordering_reward(&mut rt, &core(), dec(pool)) + .await? + .map_err(|e| anyhow!("{e:?}"))?; + + // Aggregate grew by exactly the pool. + let info = staking::get_staking_info(&mut rt).await?; + assert_eq!( + info.total_stake, + dec(staked_total + pool), + "case {seed}: total must grow by exactly the pool" + ); + + // Sum of the per-validator stakes equals the reported total — no dust + // created or lost across the distribution. + let mut summed = dec(0); + for pk in &pks { + let v = staking::get_validator(&mut rt, pk) + .await? + .expect("validator"); + summed = summed + v.stake; + } + assert_eq!( + summed, info.total_stake, + "case {seed}: Σ individual stakes must equal the reported total" + ); + } + Ok(()) +} + +/// `slash` conserves: `burned + redistributable == slashed`, `burned` is exactly +/// the τ_slash (50%) share, `slashed == min(amount, stake)` (saturates, never +/// negative), and the validator's stake drops by exactly `slashed`. +#[tokio::test] +async fn slash_conserves_burn_and_redistribute_over_random_cases() -> Result<()> { + for seed in 0u64..24 { + let stake = 10 + (seed.wrapping_mul(131).wrapping_add(7) % 990); // 10..=999 + // amount can exceed stake to exercise saturation. + let amount = 1 + (seed.wrapping_mul(97).wrapping_add(3) % (stake + 50)); + let (gvs, pks) = validators(1, stake); + let (mut rt, _dir, _name) = test_runtime_with_genesis(&gvs).await?; + + let res = staking::slash(&mut rt, &core(), &pks[0], dec(amount)) + .await? + .map_err(|e| anyhow!("{e:?}"))?; + + let expected = amount.min(stake); + assert_eq!( + res.slashed, + dec(expected), + "seed {seed}: slashed == min(amount, stake)" + ); + assert_eq!( + res.burned + res.redistributable, + res.slashed, + "seed {seed}: burned + redistributable == slashed (conservation)" + ); + // τ_slash = 50%, so burned is exactly half (checked via burned·2 == slashed). + assert_eq!( + res.burned + res.burned, + res.slashed, + "seed {seed}: burned == 50% of slashed" + ); + + let v = staking::get_validator(&mut rt, &pks[0]) + .await? + .expect("validator"); + assert_eq!( + v.stake, + dec(stake - expected), + "seed {seed}: stake reduced by slashed, never negative" + ); + } + Ok(()) +} + +/// `distribute_slash` conserves exactly: the recipients' total stake rises by the +/// full `amount` with no dust lost or created (last recipient absorbs rounding). +#[tokio::test] +async fn distribute_slash_conserves_over_random_cases() -> Result<()> { + let base = 100u64; + for seed in 0u64..20 { + let n = 2 + (seed % 5) as u32; // 2..=6 recipients + let amount = 1 + (seed.wrapping_mul(83).wrapping_add(11) % 1000); + let (gvs, pks) = validators(n, base); + let (mut rt, _dir, _name) = test_runtime_with_genesis(&gvs).await?; + + let recipients: Vec<&str> = pks.iter().map(|s| s.as_str()).collect(); + staking::distribute_slash(&mut rt, &core(), recipients, dec(amount)) + .await? + .map_err(|e| anyhow!("{e:?}"))?; + + let mut summed = dec(0); + for pk in &pks { + let v = staking::get_validator(&mut rt, pk) + .await? + .expect("validator"); + summed = summed + v.stake; + } + assert_eq!( + summed, + dec(n as u64 * base + amount), + "seed {seed}: Σ recipient stake == n·base + amount (exact, no dust)" + ); + } + Ok(()) +} + +/// `slash_equivocation` (λ_equiv = 100%): the whole stake is taken, `burned + +/// bounty == slashed`, the offender is zeroed, and self-publication earns no +/// bounty (the entire penalty is burned). +#[tokio::test] +async fn slash_equivocation_conserves_over_random_cases() -> Result<()> { + for seed in 0u64..20 { + let stake = 20 + (seed.wrapping_mul(149).wrapping_add(5) % 980); + let self_pub = seed % 2 == 0; + let (gvs, pks) = validators(2, stake); // offender = pks[0] + let (mut rt, _dir, _name) = test_runtime_with_genesis(&gvs).await?; + + let publisher = if self_pub { + pks[0].clone() + } else { + pks[1].clone() + }; + let res = staking::slash_equivocation(&mut rt, &core(), &pks[0], &publisher) + .await? + .map_err(|e| anyhow!("{e:?}"))?; + + assert_eq!( + res.slashed, + dec(stake), + "seed {seed}: equivocation slashes the full stake" + ); + assert_eq!( + res.burned + res.bounty, + res.slashed, + "seed {seed}: burned + bounty == slashed" + ); + if self_pub { + assert_eq!( + res.bounty, + dec(0), + "seed {seed}: self-publication earns no bounty" + ); + } else { + assert!( + res.bounty > dec(0), + "seed {seed}: external publisher earns the r_evid bounty" + ); + } + let v = staking::get_validator(&mut rt, &pks[0]) + .await? + .expect("validator"); + assert_eq!( + v.stake, + dec(0), + "seed {seed}: offender stake zeroed (ejected)" + ); + } + Ok(()) +} diff --git a/native-contracts/binaries/staking.wasm.br b/native-contracts/binaries/staking.wasm.br index 06be3602..e7643097 100644 Binary files a/native-contracts/binaries/staking.wasm.br and b/native-contracts/binaries/staking.wasm.br differ diff --git a/native-contracts/staking/src/lib.rs b/native-contracts/staking/src/lib.rs index 28a8e6ef..6e6fc121 100644 --- a/native-contracts/staking/src/lib.rs +++ b/native-contracts/staking/src/lib.rs @@ -28,8 +28,26 @@ struct StakingStorage { pub validators: Map, pub active_count: u64, pub total_active_stake: Decimal, + /// Burn share of a slash, in basis points (spec `τ_slash`, 0–10000). The + /// remaining `(10000 − τ_slash_bps)` is returned to the caller to + /// redistribute to the file's other nodes. + pub tau_slash_bps: u64, + /// Evidence-publication bounty for an equivocation slash, in basis points + /// (spec `r_evid`, 0–10000). This fraction of the slashed stake is paid to + /// the evidence publisher; the remaining `(10000 − r_evid_bps)` is burned. + pub r_evid_bps: u64, } +/// Basis-points denominator for fractional params (e.g. `τ_slash`). +const BPS_DENOM: u64 = 10_000; + +/// Default `τ_slash` = 50% burn share. Source: `specs/params.typ` `econ.tauSlash`. +const DEFAULT_TAU_SLASH_BPS: u64 = 5_000; +/// Default `r_evid` = 5% evidence-publication bounty. Source: optimistic-consensus +/// spec (`econ.rEvid`); calibrated to make report publication strictly profitable +/// while keeping the burn share dominant. +const DEFAULT_R_EVID_BPS: u64 = 500; + #[allow(dead_code)] const STATUS_INACTIVE: u64 = 0; const STATUS_ACTIVE: u64 = 1; @@ -62,6 +80,8 @@ impl Guest for Staking { storage.init(ctx); let model = ctx.model(); model.set_min_stake(1u64.try_into().unwrap()); + model.set_tau_slash_bps(DEFAULT_TAU_SLASH_BPS); + model.set_r_evid_bps(DEFAULT_R_EVID_BPS); ctx.contract() } @@ -327,4 +347,269 @@ impl Guest for Staking { fn get_active_count(ctx: &ViewContext) -> u64 { ctx.model().active_count() } + + /// Slash a node's pooled stake by `amount` (saturating at the node's + /// balance, per the spec's `reduce_stake`). The `τ_slash` share is burned + /// from the contract's escrowed stake; the remainder is reported back to the + /// caller (the reactor) to redistribute to the file's other nodes. The node + /// is not removed from the validator set here — the caller handles role + /// unwinding. Core-context only (reactor-orchestrated). + fn slash( + ctx: &CoreContext, + x_only_pubkey: String, + amount: Decimal, + ) -> Result { + let model = ctx.proc_context().model(); + let holder: Holder = x_only_pubkey + .parse() + .map_err(|_| Error::Message("invalid x_only_pubkey".to_string()))?; + let entry = model + .validators() + .get(&holder) + .ok_or(Error::Message("not registered".to_string()))?; + + let zero: Decimal = 0u64.try_into()?; + let stake = entry.stake(); + // Saturating reduction: deduct min(amount, stake); shortfall not carried. + let actual = if amount > stake { stake } else { amount }; + if actual <= zero { + return Ok(SlashResult { + slashed: zero, + burned: zero, + redistributable: zero, + }); + } + + entry.set_stake(stake.sub(actual)?); + if entry.status() == STATUS_ACTIVE { + model.try_update_total_active_stake(|s| s.sub(actual))?; + } + + // Burn the τ_slash share from the contract's escrowed stake. + let tau_bps: Decimal = model.tau_slash_bps().try_into()?; + let denom: Decimal = BPS_DENOM.try_into()?; + let burned = actual.mul(tau_bps)?.div(denom)?; + if burned > zero { + token::burn(ctx.proc_context().contract_signer(), burned)?; + } + let redistributable = actual.sub(burned)?; + + Ok(SlashResult { + slashed: actual, + burned, + redistributable, + }) + } + + /// Slash an equivocating staker. Equivocation is provable malice, so the + /// penalty is the **entire** stake (`λ_equiv = 100%`) and the staker is + /// **ejected** from the set immediately. A fraction `r_evid` of the slashed + /// stake is paid to the evidence `publisher` as a bounty (making report + /// publication strictly profitable for any observer); the remaining + /// `(1 − r_evid)` is burned. Core-context only — the reactor verifies the + /// two-conflicting-batches evidence and supplies the offender + publisher. + fn slash_equivocation( + ctx: &CoreContext, + x_only_pubkey: String, + publisher: String, + ) -> Result { + let model = ctx.proc_context().model(); + let holder: Holder = x_only_pubkey + .parse() + .map_err(|_| Error::Message("invalid x_only_pubkey".to_string()))?; + let pub_holder: Holder = publisher + .parse() + .map_err(|_| Error::Message("invalid publisher key".to_string()))?; + let entry = model + .validators() + .get(&holder) + .ok_or(Error::Message("not registered".to_string()))?; + + let zero: Decimal = 0u64.try_into()?; + let penalty = entry.stake(); // λ_equiv = 100% — the entire stake + + // Eject from the active set (effective immediately). Aggregate active + // counters are only adjusted if the offender was currently active. + if entry.status() == STATUS_ACTIVE { + model.try_update_total_active_stake(|s| s.sub(penalty))?; + model.update_active_count(|c| c.saturating_sub(1)); + } + entry.set_status(STATUS_INACTIVE); + entry.set_stake(zero); + + if penalty <= zero { + return Ok(EquivocationResult { + slashed: zero, + burned: zero, + bounty: zero, + publisher, + }); + } + + // Split the slashed stake: r_evid bounty to the publisher, rest burned. + // Both move out of the contract's escrowed-stake balance. + // + // Self-publication is forbidden (spec §Evidence): an equivocator must not + // recover any of their own slashed stake by naming their own key as the + // publisher — so when publisher == offender the bounty is zero and the + // entire penalty is burned. The broader rule (publisher must not be a + // co-signer of either conflicting batch) requires the batch signer sets, + // which the contract does not hold; the reactor MUST reject a publisher + // that signed either batch before calling this. + let r_evid_bps: Decimal = model.r_evid_bps().try_into()?; + let denom: Decimal = BPS_DENOM.try_into()?; + let bounty = if pub_holder == holder { + zero + } else { + penalty.mul(r_evid_bps)?.div(denom)? + }; + let burned = penalty.sub(bounty)?; + + if burned > zero { + token::burn(ctx.proc_context().contract_signer(), burned)?; + } + if bounty > zero { + token::transfer( + ctx.proc_context().contract_signer(), + pub_holder.as_ref(), + bounty, + )?; + } + + Ok(EquivocationResult { + slashed: penalty, + burned, + bounty, + publisher, + }) + } + + /// Core-context (reactor/admin) setter for the slashing parameters. Only + /// the consensus-domain shares live here: `τ_slash` (burn fraction) and + /// `r_evid` (equivocation-evidence bounty). The storage-slash *magnitude* + /// `λ_slash` is filestorage-domain policy and lives in that contract. + fn set_slash_params( + ctx: &CoreContext, + tau_slash_bps: u64, + r_evid_bps: u64, + ) -> Result<(), Error> { + if tau_slash_bps > BPS_DENOM { + return Err(Error::Message("tau_slash_bps must be <= 10000".to_string())); + } + if r_evid_bps > BPS_DENOM { + return Err(Error::Message("r_evid_bps must be <= 10000".to_string())); + } + let model = ctx.proc_context().model(); + model.set_tau_slash_bps(tau_slash_bps); + model.set_r_evid_bps(r_evid_bps); + Ok(()) + } + + fn get_slash_params(ctx: &ViewContext) -> SlashParams { + let model = ctx.model(); + SlashParams { + tau_slash_bps: model.tau_slash_bps(), + r_evid_bps: model.r_evid_bps(), + } + } + + /// Redistribute `amount` (the `(1 − τ_slash)` remainder of a slash, already + /// held by the contract as escrowed stake) equally across `recipients` by + /// crediting their pooled stake. Core-context only; the reactor supplies the + /// file's other nodes. Conservation is exact regardless of `Decimal` rounding: + /// the first `n−1` (in sorted order) each get `amount / n` and the last + /// absorbs the remainder. Recipients are processed in sorted order for + /// cross-indexer determinism. + fn distribute_slash( + ctx: &CoreContext, + recipients: Vec, + amount: Decimal, + ) -> Result<(), Error> { + let model = ctx.proc_context().model(); + let zero: Decimal = 0u64.try_into()?; + if amount <= zero { + return Ok(()); + } + if recipients.is_empty() { + return Err(Error::Message( + "no recipients for slash redistribution".to_string(), + )); + } + + let mut sorted = recipients; + sorted.sort(); + let n = sorted.len(); + let n_dec: Decimal = (n as u64).try_into()?; + let per = amount.div(n_dec)?; + let head_total = per.mul(((n - 1) as u64).try_into()?)?; + let last_share = amount.sub(head_total)?; // absorbs the rounding remainder + + for (i, pk) in sorted.iter().enumerate() { + let credit = if i + 1 == n { last_share } else { per }; + let holder: Holder = pk + .parse() + .map_err(|_| Error::Message("invalid x_only_pubkey".to_string()))?; + let entry = model + .validators() + .get(&holder) + .ok_or(Error::Message("slash recipient not registered".to_string()))?; + entry.set_stake(entry.stake().add(credit)?); + if entry.status() == STATUS_ACTIVE { + model.try_update_total_active_stake(|s| s.add(credit))?; + } + } + Ok(()) + } + + /// Distribute `amount` — one block's ordering emission, already moved into + /// the staking contract by the reactor — across active validators in + /// proportion to their stake, crediting pooled stake. Exact conservation: + /// the last active validator (sorted) absorbs the rounding remainder. + /// Processed in sorted order for cross-indexer determinism. Core-context only. + fn distribute_ordering_reward(ctx: &CoreContext, amount: Decimal) -> Result<(), Error> { + let model = ctx.proc_context().model(); + let zero: Decimal = 0u64.try_into()?; + if amount <= zero { + return Ok(()); + } + let total = model.total_active_stake(); + if total <= zero { + return Err(Error::Message("no active stake to reward".to_string())); + } + + let mut keys: Vec = model.validators().keys().collect(); + keys.sort_by_key(|k| k.to_string()); + let actives: Vec = keys + .into_iter() + .filter(|h| { + model + .validators() + .get(h) + .map(|e| e.status() == STATUS_ACTIVE) + .unwrap_or(false) + }) + .collect(); + if actives.is_empty() { + return Err(Error::Message("no active validators".to_string())); + } + + let n = actives.len(); + let mut distributed: Decimal = zero; + for (i, h) in actives.iter().enumerate() { + let entry = model + .validators() + .get(h) + .ok_or(Error::Message("validator gone".to_string()))?; + let share = if i + 1 == n { + amount.sub(distributed)? + } else { + let s = amount.mul(entry.stake())?.div(total)?; + distributed = distributed.add(s)?; + s + }; + entry.set_stake(entry.stake().add(share)?); + } + model.try_update_total_active_stake(|s| s.add(amount))?; + Ok(()) + } } diff --git a/native-contracts/staking/wit/contract.wit b/native-contracts/staking/wit/contract.wit index a68e43d7..126bcc61 100644 --- a/native-contracts/staking/wit/contract.wit +++ b/native-contracts/staking/wit/contract.wit @@ -38,6 +38,27 @@ world root { deactivated: u64, } + record slash-result { + slashed: decimal, + burned: decimal, + redistributable: decimal, + } + + record slash-params { + tau-slash-bps: u64, + r-evid-bps: u64, + } + + // Result of an equivocation slash: the whole stake is taken (λ_equiv = 100%), + // `bounty` (= r_evid · slashed) is paid to the evidence `publisher`, and the + // remainder is burned. + record equivocation-result { + slashed: decimal, + burned: decimal, + bounty: decimal, + publisher: string, + } + export init: async func(ctx: borrow) -> contract; // --- Validator lifecycle (ProcContext — user-facing) --- @@ -51,6 +72,14 @@ world root { // --- Per-block validator processing (CoreContext — called by block handler) --- export process-pending-validators: async func(ctx: borrow, block-height: u64) -> result; + // --- Slashing (CoreContext — reactor-orchestrated) --- + export slash: async func(ctx: borrow, x-only-pubkey: string, amount: decimal) -> result; + export slash-equivocation: async func(ctx: borrow, x-only-pubkey: string, publisher: string) -> result; + export distribute-slash: async func(ctx: borrow, recipients: list, amount: decimal) -> result<_, error>; + export distribute-ordering-reward: async func(ctx: borrow, amount: decimal) -> result<_, error>; + export set-slash-params: async func(ctx: borrow, tau-slash-bps: u64, r-evid-bps: u64) -> result<_, error>; + export get-slash-params: async func(ctx: borrow) -> slash-params; + // --- Queries (ViewContext) --- export get-active-set: async func(ctx: borrow) -> list; export get-validator: async func(ctx: borrow, x-only-pubkey: string) -> option;