diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f169a5..1c66be0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +## [0.12.0] — 2026-04-25 + +### Added +- `nu-state/slash_evidence.rs` — `SlashEvidence` struct; DB key `slash_evidence::` +- `nu_reportDoubleSign` RPC handler — iki farklı block imzasını k256 ile doğrular, geçerliyse DB'ye yazar; idempotent (aynı kanıt tekrar kabul edilmez) +- `block_loop::apply_pending_slashes` — her slot'ta uygulanmamış kanıtları işler: `slash_double_sign` çağırır, slashed stake'in %50'si BURN_WALLET'a, %50'si reporter'a akar + ## [0.11.0] — 2026-04-25 ### Added diff --git a/Cargo.lock b/Cargo.lock index 2dc8b79..01d012f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,12 +154,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.69.5" @@ -333,6 +345,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -368,6 +386,18 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -378,6 +408,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -385,7 +425,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -399,12 +441,45 @@ dependencies = [ "syn", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -436,6 +511,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -519,6 +604,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -550,6 +636,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -587,6 +684,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -915,6 +1021,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1157,6 +1277,8 @@ dependencies = [ "anyhow", "axum", "chrono", + "hex", + "k256", "nu-block", "nu-mempool", "nu-state", @@ -1293,6 +1415,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -1332,6 +1464,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1410,6 +1551,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1519,6 +1670,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1644,6 +1809,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "slab" version = "0.4.12" @@ -1666,6 +1841,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/crates/nu-rpc/Cargo.toml b/crates/nu-rpc/Cargo.toml index 3acf9d8..b910875 100644 --- a/crates/nu-rpc/Cargo.toml +++ b/crates/nu-rpc/Cargo.toml @@ -11,6 +11,8 @@ anyhow.workspace = true tracing.workspace = true axum.workspace = true chrono.workspace = true +k256 = { version = "0.13", features = ["ecdsa"] } +hex.workspace = true nu-state = { path = "../nu-state" } nu-mempool = { path = "../nu-mempool" } nu-block = { path = "../nu-block" } diff --git a/crates/nu-rpc/src/handlers.rs b/crates/nu-rpc/src/handlers.rs index 54bce92..085c15c 100644 --- a/crates/nu-rpc/src/handlers.rs +++ b/crates/nu-rpc/src/handlers.rs @@ -4,8 +4,9 @@ use crate::{ server::AppState, types::{JsonRpcRequest, JsonRpcResponse}, }; +use k256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; use nu_block::types::{Block, RawTransaction}; -use nu_state::{account::AccountState, ValidatorState}; +use nu_state::{account::AccountState, SlashEvidence, ValidatorState}; pub async fn dispatch(req: JsonRpcRequest, state: &AppState) -> JsonRpcResponse { match req.method.as_str() { @@ -15,6 +16,7 @@ pub async fn dispatch(req: JsonRpcRequest, state: &AppState) -> JsonRpcResponse "nu_getBlock" => handle_get_block(&req, state).await, "nu_getValidator" => handle_get_validator(&req, state).await, "nu_listValidators" => handle_list_validators(&req, state).await, + "nu_reportDoubleSign" => handle_report_double_sign(&req, state).await, "nu_getTx" => not_implemented(&req, "nu_getTx"), "nu_getStory" => not_implemented(&req, "nu_getStory"), "nu_getNode" => not_implemented(&req, "nu_getNode"), @@ -137,6 +139,83 @@ async fn handle_list_validators(req: &JsonRpcRequest, state: &AppState) -> JsonR JsonRpcResponse::ok(req.id.clone(), serde_json::to_value(validators).unwrap()) } +async fn handle_report_double_sign(req: &JsonRpcRequest, state: &AppState) -> JsonRpcResponse { + // params: [validator, slot, block_hash_1, sig_1_hex, block_hash_2, sig_2_hex, reporter] + let get_str = |i: usize| req.params.get(i).and_then(|v| v.as_str()).map(String::from); + let get_u32 = |i: usize| req.params.get(i).and_then(|v| v.as_u64()).map(|n| n as u32); + + let (Some(validator), Some(slot), Some(hash1), Some(sig1_hex), Some(hash2), Some(sig2_hex), Some(reporter)) = ( + get_str(0), get_u32(1), get_str(2), get_str(3), get_str(4), get_str(5), get_str(6), + ) else { + return JsonRpcResponse::err(req.id.clone(), -32602, "Missing params".into()); + }; + + if hash1 == hash2 { + return JsonRpcResponse::err(req.id.clone(), -32602, "Block hashes must differ".into()); + } + + // Verify both signatures are from the same validator key + let sig1_bytes = match hex::decode(&sig1_hex) { + Ok(b) => b, + Err(_) => return JsonRpcResponse::err(req.id.clone(), -32602, "Invalid sig_1 hex".into()), + }; + let sig2_bytes = match hex::decode(&sig2_hex) { + Ok(b) => b, + Err(_) => return JsonRpcResponse::err(req.id.clone(), -32602, "Invalid sig_2 hex".into()), + }; + let val_pubkey_bytes = match hex::decode(&validator) { + Ok(b) => b, + Err(_) => return JsonRpcResponse::err(req.id.clone(), -32602, "Invalid validator address".into()), + }; + let verifying_key = match VerifyingKey::from_sec1_bytes(&val_pubkey_bytes) { + Ok(k) => k, + Err(_) => return JsonRpcResponse::err(req.id.clone(), -32602, "Invalid validator pubkey".into()), + }; + let sig1 = match Signature::from_slice(&sig1_bytes) { + Ok(s) => s, + Err(_) => return JsonRpcResponse::err(req.id.clone(), -32602, "Invalid sig_1".into()), + }; + let sig2 = match Signature::from_slice(&sig2_bytes) { + Ok(s) => s, + Err(_) => return JsonRpcResponse::err(req.id.clone(), -32602, "Invalid sig_2".into()), + }; + + if verifying_key.verify(hash1.as_bytes(), &sig1).is_err() { + return JsonRpcResponse::err(req.id.clone(), -32602, "sig_1 does not verify".into()); + } + if verifying_key.verify(hash2.as_bytes(), &sig2).is_err() { + return JsonRpcResponse::err(req.id.clone(), -32602, "sig_2 does not verify".into()); + } + + let evidence = SlashEvidence { + validator: validator.clone(), + slot, + block_hash_1: hash1, + sig_1: sig1_bytes, + block_hash_2: hash2, + sig_2: sig2_bytes, + reporter, + submitted_at: chrono::Utc::now().timestamp_millis(), + applied: false, + }; + + let key = SlashEvidence::db_key(&validator, slot); + let db = state.db.lock().await; + + // Idempotent — don't store duplicates + if let Ok(Some(_)) = db.get::(&key) { + return JsonRpcResponse::ok(req.id.clone(), json!({ "status": "already_reported" })); + } + + match db.put(&key, &evidence) { + Ok(_) => { + tracing::warn!(validator = %validator, slot, "double-sign evidence submitted"); + JsonRpcResponse::ok(req.id.clone(), json!({ "status": "accepted", "key": key })) + } + Err(e) => JsonRpcResponse::err(req.id.clone(), -32000, e.to_string()), + } +} + fn not_implemented(req: &JsonRpcRequest, method: &str) -> JsonRpcResponse { JsonRpcResponse::err(req.id.clone(), -32000, format!("{method} not implemented yet")) } diff --git a/crates/nu-state/src/lib.rs b/crates/nu-state/src/lib.rs index 504077e..dae5d50 100644 --- a/crates/nu-state/src/lib.rs +++ b/crates/nu-state/src/lib.rs @@ -2,11 +2,13 @@ pub mod accessor; pub mod account; pub mod db; pub mod nft; +pub mod slash_evidence; pub mod story_node; pub mod validator; pub use accessor::StateAccessor; pub use db::StateDb; pub use nft::NftState; +pub use slash_evidence::SlashEvidence; pub use story_node::{NodeStatus, StoryNodeState, WeightedVote}; pub use validator::ValidatorState; diff --git a/crates/nu-state/src/slash_evidence.rs b/crates/nu-state/src/slash_evidence.rs new file mode 100644 index 0000000..6d1aa79 --- /dev/null +++ b/crates/nu-state/src/slash_evidence.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +/// Double-sign fraud proof submitted via nu_reportDoubleSign RPC. +/// Stored as "slash_evidence::" in DB. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlashEvidence { + pub validator: String, + pub slot: u32, + pub block_hash_1: String, + pub sig_1: Vec, + pub block_hash_2: String, + pub sig_2: Vec, + pub reporter: String, + pub submitted_at: i64, + pub applied: bool, +} + +impl SlashEvidence { + pub fn db_key(validator: &str, slot: u32) -> String { + format!("slash_evidence:{validator}:{slot:010}") + } +} diff --git a/src/block_loop.rs b/src/block_loop.rs index 2d07661..ada58d1 100644 --- a/src/block_loop.rs +++ b/src/block_loop.rs @@ -9,12 +9,12 @@ use nu_block::{ }; use nu_consensus::{ slot::current_slot, - slashing::record_skip, + slashing::{record_skip, slash_double_sign}, types::ValidatorRecord, validator_set::ValidatorSet, }; use nu_mempool::Mempool; -use nu_state::{story_node::StoryNodeState, ValidatorState, StateDb}; +use nu_state::{account::AccountState, story_node::StoryNodeState, SlashEvidence, ValidatorState, StateDb}; use nu_vm::execute_block; use crate::p2p::P2pSender; @@ -86,6 +86,12 @@ pub async fn run( } } + // Apply any pending double-sign slash evidence + { + let db_guard = db.lock().await; + apply_pending_slashes(&db_guard, slot); + } + // Scheduler: inject auto-txs for pending nodes { let db_guard = db.lock().await; @@ -168,6 +174,70 @@ pub async fn run( } } +fn apply_pending_slashes(db: &StateDb, current_slot: u32) { + let burn_wallet = std::env::var("BURN_WALLET").unwrap_or_default(); + let evidences: Vec = db.scan_prefix("slash_evidence:"); + + for mut ev in evidences { + if ev.applied { + continue; + } + let vs_key = format!("validator:{}", ev.validator); + let mut vs = match db.get::(&vs_key).ok().flatten() { + Some(v) => v, + None => continue, + }; + + let mut record = ValidatorRecord { + address: vs.address.clone(), + stake: vs.stake, + pon_score: vs.pon_score, + is_active: vs.is_active, + last_block: vs.last_block, + slash_count: vs.slash_count, + skip_count: vs.skip_count, + consecutive_blocks: vs.consecutive_blocks, + ban_until_slot: vs.ban_until_slot, + }; + + let result = slash_double_sign(&mut record, current_slot); + + vs.stake = record.stake; + vs.pon_score = record.pon_score; + vs.is_active = record.is_active; + vs.ban_until_slot = record.ban_until_slot; + vs.slash_count = record.slash_count; + + let _ = db.put(&vs_key, &vs); + + // Burn half + if !burn_wallet.is_empty() { + if let Ok(Some(mut burn_acc)) = db.get::(&format!("account:{burn_wallet}")) { + burn_acc.balance = burn_acc.balance.saturating_add(result.burn_amount); + let _ = db.put(&format!("account:{burn_wallet}"), &burn_acc); + } + } + + // Reporter reward + if !ev.reporter.is_empty() { + if let Ok(Some(mut reporter_acc)) = db.get::(&format!("account:{}", ev.reporter)) { + reporter_acc.balance = reporter_acc.balance.saturating_add(result.reporter_reward); + let _ = db.put(&format!("account:{}", ev.reporter), &reporter_acc); + } + } + + ev.applied = true; + let _ = db.put(&SlashEvidence::db_key(&ev.validator, ev.slot), &ev); + + tracing::warn!( + validator = %ev.validator, + slot = ev.slot, + slashed = result.slashed_amount, + "double-sign slash applied" + ); + } +} + fn unban_expired_validators(db: &StateDb, current_slot: u32) { let records: Vec = db.scan_prefix("validator:"); for mut vs in records {