diff --git a/cli/tri/src/depin/prove.rs b/cli/tri/src/depin/prove.rs index 937bab00f..0daa719b3 100644 --- a/cli/tri/src/depin/prove.rs +++ b/cli/tri/src/depin/prove.rs @@ -1,49 +1,58 @@ -use crate::depin::phi_challenge::{compute_epoch_hash, derive_phi_challenge, verify_phi_response}; +use crate::depin::phi_challenge::{ + compute_epoch_hash, derive_phi_challenge, derive_phi_challenge_v2, verify_phi_response, + verify_phi_response_v2, +}; use crate::depin::types::{AppState, EpochChallengeResponse, ProveRequest, ProveResponse}; use crate::depin::types::sha2_hash; +fn err(reason: &str) -> axum::Json { + axum::Json(ProveResponse { + valid: false, + reward_lamports: 0, + epoch_hash: String::new(), + next_challenge: String::new(), + tokens_count: 0, + reason: Some(reason.into()), + }) +} + pub async fn post_prove( axum::extract::State(state): axum::extract::State>>, axum::Json(req): axum::Json, ) -> axum::Json { let node_id = match hex::decode(&req.node_id) { Ok(v) if v.len() == 32 => v, - _ => { - return axum::Json(ProveResponse { - valid: false, - reward_lamports: 0, - epoch_hash: String::new(), - next_challenge: String::new(), - tokens_count: 0, - reason: Some("invalid_node_id".into()), - }); - } + _ => return err("invalid_node_id"), }; + if req.version != 1 && req.version != 2 { + return err("unsupported_version"); + } + + let expected_resp_len = if req.version == 2 { 32 } else { 4 }; let phi_response = match hex::decode(&req.phi_response) { - Ok(v) if v.len() == 4 => v, - _ => { - return axum::Json(ProveResponse { - valid: false, - reward_lamports: 0, - epoch_hash: String::new(), - next_challenge: String::new(), - tokens_count: 0, - reason: Some("invalid_phi_response".into()), - }); - } + Ok(v) if v.len() == expected_resp_len => v, + _ => return err("invalid_phi_response"), }; - let challenge = derive_phi_challenge(req.epoch, &node_id); - if !verify_phi_response(&challenge, &phi_response, &node_id) { - return axum::Json(ProveResponse { - valid: false, - reward_lamports: 0, - epoch_hash: String::new(), - next_challenge: String::new(), - tokens_count: 0, - reason: Some("phi_challenge_mismatch".into()), - }); + let challenge_ok = if req.version == 2 { + let node_arr: [u8; 32] = match node_id.as_slice().try_into() { + Ok(a) => a, + Err(_) => return err("invalid_node_id"), + }; + let resp_arr: [u8; 32] = match phi_response.as_slice().try_into() { + Ok(a) => a, + Err(_) => return err("invalid_phi_response"), + }; + let challenge = derive_phi_challenge_v2(req.epoch, &node_arr); + verify_phi_response_v2(&challenge, &resp_arr, &node_arr) + } else { + let challenge = derive_phi_challenge(req.epoch, &node_id); + verify_phi_response(&challenge, &phi_response, &node_id) + }; + + if !challenge_ok { + return err("phi_challenge_mismatch"); } let root = match hex::decode(&req.merkle_proof.root) { @@ -52,16 +61,7 @@ pub async fn post_prove( arr.copy_from_slice(&v); arr } - _ => { - return axum::Json(ProveResponse { - valid: false, - reward_lamports: 0, - epoch_hash: String::new(), - next_challenge: String::new(), - tokens_count: 0, - reason: Some("merkle_proof_invalid".into()), - }); - } + _ => return err("merkle_proof_invalid"), }; let leaf = match hex::decode(&req.merkle_proof.leaf) { @@ -70,16 +70,7 @@ pub async fn post_prove( arr.copy_from_slice(&v); arr } - _ => { - return axum::Json(ProveResponse { - valid: false, - reward_lamports: 0, - epoch_hash: String::new(), - next_challenge: String::new(), - tokens_count: 0, - reason: Some("merkle_proof_invalid".into()), - }); - } + _ => return err("merkle_proof_invalid"), }; let siblings: Vec<[u8; 32]> = req @@ -99,36 +90,15 @@ pub async fn post_prove( .collect(); if siblings.len() != req.merkle_proof.siblings.len() { - return axum::Json(ProveResponse { - valid: false, - reward_lamports: 0, - epoch_hash: String::new(), - next_challenge: String::new(), - tokens_count: 0, - reason: Some("merkle_proof_invalid".into()), - }); + return err("merkle_proof_invalid"); } if !crate::depin::merkle::verify_merkle(&root, &leaf, &siblings, req.merkle_leaf_index) { - return axum::Json(ProveResponse { - valid: false, - reward_lamports: 0, - epoch_hash: String::new(), - next_challenge: String::new(), - tokens_count: 0, - reason: Some("merkle_proof_invalid".into()), - }); + return err("merkle_proof_invalid"); } - if !verify_ed25519_signature(&node_id, &phi_response, &req.peer_sample_sig) { - return axum::Json(ProveResponse { - valid: false, - reward_lamports: 0, - epoch_hash: String::new(), - next_challenge: String::new(), - tokens_count: 0, - reason: Some("peer_sample_sig_invalid".into()), - }); + if !verify_ed25519_signature(&node_id, &phi_response, &req.peer_sample_sig, req.version) { + return err("peer_sample_sig_invalid"); } let guard = state.read().await; @@ -136,13 +106,22 @@ pub async fn post_prove( let reward = epoch.block_reward; let epoch_hash = compute_epoch_hash(req.epoch, &node_id, &phi_response); - let next = derive_phi_challenge(req.epoch + 1, &node_id); + let next_challenge_hex = if req.version == 2 { + let node_arr: [u8; 32] = match node_id.as_slice().try_into() { + Ok(a) => a, + Err(_) => return err("invalid_node_id"), + }; + let next_matrix = derive_phi_challenge_v2(req.epoch + 1, &node_arr); + hex::encode(crate::depin::phi_challenge::pack_gf16_matrix(&next_matrix)) + } else { + hex::encode(derive_phi_challenge(req.epoch + 1, &node_id)) + }; axum::Json(ProveResponse { valid: true, reward_lamports: reward, epoch_hash: hex::encode(epoch_hash), - next_challenge: hex::encode(next), + next_challenge: next_challenge_hex, tokens_count: reward / 1000, reason: None, }) @@ -169,7 +148,7 @@ pub async fn get_epoch_challenge( }) } -fn verify_ed25519_signature(node_id: &[u8], phi_response: &[u8], sig_hex: &str) -> bool { +fn verify_ed25519_signature(node_id: &[u8], phi_response: &[u8], sig_hex: &str, version: u8) -> bool { let sig_bytes = match hex::decode(sig_hex) { Ok(v) => v, Err(_) => return false, @@ -178,8 +157,9 @@ fn verify_ed25519_signature(node_id: &[u8], phi_response: &[u8], sig_hex: &str) return false; } + let domain: &[u8] = if version == 2 { b"TRI_PROVE_V2" } else { b"TRI_PROVE_V1" }; let mut message = Vec::new(); - message.extend_from_slice(b"TRI_PROVE_V1"); + message.extend_from_slice(domain); message.extend_from_slice(node_id); message.extend_from_slice(phi_response); @@ -277,6 +257,7 @@ mod tests { }, merkle_leaf_index: 0, peer_sample_sig: hex::encode(sig.to_bytes()), + version: 1, } } @@ -374,6 +355,7 @@ mod tests { }, merkle_leaf_index: 2, peer_sample_sig: hex::encode(sig.to_bytes()), + version: 1, }; let resp = call_prove(&app, req).await; assert!(resp.valid, "expected valid proof with 4-leaf merkle tree, got reason: {:?}", resp.reason); @@ -403,4 +385,103 @@ mod tests { } siblings } + + use crate::depin::phi_challenge::{compute_phi_response_v2, derive_phi_challenge_v2}; + + fn make_valid_proof_request_v2(epoch: u64) -> (ProveRequest, [u8; 32]) { + let signing_key_bytes = [0xCC; 32]; + let signing_key = SigningKey::from_bytes(&signing_key_bytes); + let verifying_key = signing_key.verifying_key(); + let node_id = verifying_key.to_bytes(); + + let challenge = derive_phi_challenge_v2(epoch, &node_id); + let phi_response = compute_phi_response_v2(&challenge); + + let leaf = crate::depin::types::sha2_hash(&[&node_id, &phi_response]); + let leaves = vec![leaf]; + let root = merkle_root(&leaves); + + let mut message = Vec::new(); + message.extend_from_slice(b"TRI_PROVE_V2"); + message.extend_from_slice(&node_id); + message.extend_from_slice(&phi_response); + let sig = signing_key.sign(&message); + + let req = ProveRequest { + node_id: hex::encode(node_id), + epoch, + phi_response: hex::encode(phi_response), + merkle_proof: MerkleProof { + root: hex::encode(root), + leaf: hex::encode(leaf), + siblings: vec![], + }, + merkle_leaf_index: 0, + peer_sample_sig: hex::encode(sig.to_bytes()), + version: 2, + }; + (req, phi_response) + } + + #[tokio::test] + async fn test_v2_e2e_valid_proof() { + let app = build_test_app(); + let (req, _) = make_valid_proof_request_v2(0); + let resp = call_prove(&app, req).await; + assert!(resp.valid, "expected valid v2 proof, got reason: {:?}", resp.reason); + assert_eq!(resp.reward_lamports, 50_000_000); + assert_eq!(resp.tokens_count, 50_000); + assert!(resp.reason.is_none()); + assert_eq!(resp.next_challenge.len(), 256, "v2 next_challenge is 128 bytes = 256 hex chars"); + } + + #[tokio::test] + async fn test_v2_e2e_invalid_response_flipped_bit() { + let app = build_test_app(); + let (mut req, _) = make_valid_proof_request_v2(0); + let mut bytes = hex::decode(&req.phi_response).unwrap(); + bytes[0] ^= 0x01; + req.phi_response = hex::encode(&bytes); + let resp = call_prove(&app, req).await; + assert!(!resp.valid); + assert_eq!(resp.reason.as_deref(), Some("phi_challenge_mismatch")); + } + + #[tokio::test] + async fn test_v2_e2e_wrong_response_length() { + let app = build_test_app(); + let (mut req, _) = make_valid_proof_request_v2(0); + req.phi_response = hex::encode([0xAA; 4]); + let resp = call_prove(&app, req).await; + assert!(!resp.valid); + assert_eq!(resp.reason.as_deref(), Some("invalid_phi_response")); + } + + #[tokio::test] + async fn test_v2_e2e_unsupported_version() { + let app = build_test_app(); + let (mut req, _) = make_valid_proof_request_v2(0); + req.version = 99; + let resp = call_prove(&app, req).await; + assert!(!resp.valid); + assert_eq!(resp.reason.as_deref(), Some("unsupported_version")); + } + + #[test] + fn test_v2_kat_pinned_response() { + let mut node_id = [0u8; 32]; + for (i, b) in node_id.iter_mut().enumerate() { + *b = (i + 1) as u8; + } + let challenge = derive_phi_challenge_v2(42, &node_id); + let response = compute_phi_response_v2(&challenge); + let hex_resp = hex::encode(response); + assert_eq!(hex_resp.len(), 64); + let challenge_again = derive_phi_challenge_v2(42, &node_id); + let response_again = compute_phi_response_v2(&challenge_again); + assert_eq!(response, response_again, "v2 KAT must be deterministic"); + let other = derive_phi_challenge_v2(43, &node_id); + let other_resp = compute_phi_response_v2(&other); + assert_ne!(response, other_resp, "different epoch must yield different response"); + } } diff --git a/cli/tri/src/depin/types.rs b/cli/tri/src/depin/types.rs index af16215c1..d9a472a06 100644 --- a/cli/tri/src/depin/types.rs +++ b/cli/tri/src/depin/types.rs @@ -8,6 +8,12 @@ pub struct ProveRequest { pub merkle_proof: MerkleProof, pub merkle_leaf_index: usize, pub peer_sample_sig: String, + #[serde(default = "default_version")] + pub version: u8, +} + +fn default_version() -> u8 { + 1 } #[derive(Debug, Serialize, Deserialize)] diff --git a/contrib/solana/programs/tri-mining/src/lib.rs b/contrib/solana/programs/tri-mining/src/lib.rs index 9c4e833f9..5d9db04c6 100644 --- a/contrib/solana/programs/tri-mining/src/lib.rs +++ b/contrib/solana/programs/tri-mining/src/lib.rs @@ -47,6 +47,49 @@ pub mod tri_mining { Ok(()) } + + pub fn submit_proof_v2( + ctx: Context, + phi_response: [u8; 32], + merkle_root: [u8; 32], + signature: [u8; 64], + ) -> Result<()> { + let epoch = &mut ctx.accounts.mining_epoch; + let node_proof = &mut ctx.accounts.node_proof; + + let miner_key = ctx.accounts.miner.key(); + let mut node_id = [0u8; 32]; + node_id.copy_from_slice(miner_key.as_ref()); + + let challenge = derive_phi_challenge_v2(epoch.epoch_id, &node_id); + let expected = compute_phi_response_v2(&challenge); + + let mut diff: u8 = 0; + for i in 0..32 { + diff |= expected[i] ^ phi_response[i]; + } + require!(diff == 0, TriError::PhiChallengeMismatch); + + node_proof.miner = miner_key; + node_proof.epoch_id = epoch.epoch_id; + node_proof.phi_response = phi_response; + node_proof.merkle_root = merkle_root; + node_proof.signature = signature; + node_proof.version = 2; + node_proof.tokens_earned = epoch.block_reward / 1000; + node_proof.timestamp = Clock::get()?.unix_timestamp; + + epoch.total_proofs += 1; + epoch.total_tokens_minted += node_proof.tokens_earned; + + emit!(ProofSubmittedV2 { + miner: miner_key, + epoch_id: epoch.epoch_id, + tokens: node_proof.tokens_earned, + }); + + Ok(()) + } } fn compute_phi_challenge(epoch_id: u64, node_id: &[u8]) -> [u8; 16] { @@ -94,6 +137,75 @@ fn gf16_dot4(w: &[u8; 4], x: &[u8; 4]) -> Vec { vec![gf16_mul(w[0], x[0]), gf16_mul(w[1], x[1]), gf16_mul(w[2], x[2]), gf16_mul(w[3], x[3])] } +const CHAMPION_WEIGHTS: [[u8; 16]; 16] = [ + [0x4, 0xF, 0xA, 0x7, 0x2, 0x8, 0x6, 0x1, 0xA, 0x2, 0x4, 0xC, 0x0, 0x6, 0x1, 0x5], + [0xB, 0x7, 0x2, 0x4, 0x6, 0xA, 0x3, 0x7, 0xA, 0x3, 0xF, 0x9, 0x5, 0x1, 0xD, 0x1], + [0xC, 0x7, 0x3, 0xA, 0x5, 0x2, 0x1, 0xF, 0x4, 0x2, 0x9, 0x7, 0x2, 0x9, 0x0, 0xB], + [0xD, 0xE, 0x7, 0x9, 0xE, 0x2, 0x6, 0x1, 0xC, 0xF, 0x7, 0xE, 0x7, 0x6, 0x6, 0x1], + [0xB, 0x7, 0x3, 0x9, 0x2, 0x4, 0xE, 0x1, 0xF, 0x5, 0x9, 0x7, 0xD, 0xB, 0x9, 0x2], + [0x1, 0x5, 0x1, 0xB, 0x8, 0x2, 0x2, 0xB, 0x9, 0x9, 0x7, 0xB, 0x9, 0x9, 0x3, 0xB], + [0x2, 0x1, 0xA, 0x7, 0xD, 0x1, 0x2, 0xB, 0x3, 0x7, 0x4, 0xF, 0xC, 0x7, 0x5, 0xD], + [0xA, 0x8, 0xB, 0x1, 0xC, 0xA, 0x4, 0xC, 0xE, 0x5, 0x7, 0xF, 0x6, 0xA, 0xA, 0xA], + [0xC, 0x9, 0x7, 0x6, 0xF, 0x4, 0x5, 0x7, 0x1, 0x2, 0xD, 0x0, 0xF, 0xE, 0x6, 0x0], + [0x7, 0xE, 0xA, 0xE, 0x7, 0xB, 0x5, 0x7, 0x4, 0xC, 0xB, 0x3, 0x7, 0x4, 0xB, 0xE], + [0xB, 0x8, 0x4, 0x9, 0x0, 0xE, 0x0, 0x6, 0x9, 0x5, 0x1, 0xA, 0x6, 0x5, 0x5, 0x8], + [0x8, 0x2, 0xC, 0x4, 0x7, 0x6, 0x2, 0x2, 0xF, 0xA, 0xA, 0x1, 0x3, 0xD, 0x0, 0x6], + [0xA, 0x4, 0x6, 0xF, 0x9, 0xC, 0x4, 0xB, 0xB, 0xD, 0x6, 0x2, 0xA, 0x5, 0x9, 0x5], + [0x8, 0x6, 0xA, 0x7, 0x0, 0xC, 0x0, 0x8, 0x8, 0xF, 0x4, 0xE, 0x6, 0xA, 0x5, 0x5], + [0xB, 0x5, 0x1, 0x8, 0xD, 0x8, 0x2, 0x8, 0x0, 0xE, 0xD, 0x4, 0x1, 0x0, 0x7, 0xC], + [0x2, 0x3, 0xA, 0xE, 0x5, 0x5, 0xC, 0xB, 0x3, 0x8, 0x1, 0xD, 0xA, 0xA, 0x2, 0xF], +]; + +fn gf16_matmul_v2(a: &[[u8; 16]; 16], b: &[[u8; 16]; 16]) -> [[u8; 16]; 16] { + let mut c = [[0u8; 16]; 16]; + for i in 0..16 { + for j in 0..16 { + let mut acc = 0u8; + for k in 0..16 { + acc ^= gf16_mul(a[i][k] & 0xF, b[k][j] & 0xF); + } + c[i][j] = acc & 0xF; + } + } + c +} + +fn pack_gf16_matrix(m: &[[u8; 16]; 16]) -> [u8; 128] { + let mut out = [0u8; 128]; + for i in 0..16 { + for j in 0..8 { + out[i * 8 + j] = ((m[i][j * 2] & 0xF) << 4) | (m[i][j * 2 + 1] & 0xF); + } + } + out +} + +fn derive_phi_challenge_v2(epoch: u64, node_id: &[u8; 32]) -> [[u8; 16]; 16] { + use anchor_lang::solana_program::hash::hash; + let prefix = b"TRI_PHI_CHALLENGE_V2"; + let epoch_bytes = epoch.to_le_bytes(); + let mut matrix = [[0u8; 16]; 16]; + for i in 0u8..16 { + let mut input = Vec::with_capacity(prefix.len() + 8 + 32 + 1); + input.extend_from_slice(prefix); + input.extend_from_slice(&epoch_bytes); + input.extend_from_slice(node_id); + input.push(i); + let h = hash(&input).to_bytes(); + for j in 0..16 { + matrix[i as usize][j] = (h[j * 2] >> 4) & 0xF; + } + } + matrix +} + +fn compute_phi_response_v2(challenge: &[[u8; 16]; 16]) -> [u8; 32] { + use anchor_lang::solana_program::hash::hash; + let product = gf16_matmul_v2(&CHAMPION_WEIGHTS, challenge); + let packed = pack_gf16_matrix(&product); + hash(&packed).to_bytes() +} + #[derive(Accounts)] #[instruction(epoch_id: u64)] pub struct InitializeEpoch<'info> { @@ -128,6 +240,24 @@ pub struct SubmitProof<'info> { pub system_program: Program<'info, System>, } +#[derive(Accounts)] +#[instruction(phi_response: [u8; 32], merkle_root: [u8; 32], signature: [u8; 64])] +pub struct SubmitProofV2<'info> { + #[account(mut)] + pub mining_epoch: Account<'info, MiningEpoch>, + #[account( + init, + payer = miner, + space = 8 + 32 + 8 + 32 + 32 + 64 + 1 + 8 + 8, + seeds = [b"proof_v2", miner.key().as_ref(), mining_epoch.epoch_id.to_le_bytes().as_ref()], + bump + )] + pub node_proof: Account<'info, NodeProofV2>, + #[account(mut)] + pub miner: Signer<'info>, + pub system_program: Program<'info, System>, +} + #[account] pub struct MiningEpoch { pub epoch_id: u64, @@ -148,6 +278,18 @@ pub struct NodeProof { pub timestamp: i64, } +#[account] +pub struct NodeProofV2 { + pub miner: Pubkey, + pub epoch_id: u64, + pub phi_response: [u8; 32], + pub merkle_root: [u8; 32], + pub signature: [u8; 64], + pub version: u8, + pub tokens_earned: u64, + pub timestamp: i64, +} + #[error_code] pub enum TriError { #[msg("phi_challenge mismatch — response does not match challenge")] @@ -168,3 +310,76 @@ pub struct ProofSubmitted { pub epoch_id: u64, pub tokens: u64, } + +#[event] +pub struct ProofSubmittedV2 { + #[index] + pub miner: Pubkey, + #[index] + pub epoch_id: u64, + pub tokens: u64, +} + +#[cfg(test)] +mod tests_v2 { + use super::*; + + #[test] + fn test_onchain_v2_response_deterministic() { + let node = [0x42u8; 32]; + let c1 = derive_phi_challenge_v2(7, &node); + let c2 = derive_phi_challenge_v2(7, &node); + assert_eq!(c1, c2); + let r1 = compute_phi_response_v2(&c1); + let r2 = compute_phi_response_v2(&c2); + assert_eq!(r1, r2); + } + + #[test] + fn test_onchain_v2_wrong_epoch_differs() { + let node = [0x11u8; 32]; + let c0 = derive_phi_challenge_v2(0, &node); + let c1 = derive_phi_challenge_v2(1, &node); + let r0 = compute_phi_response_v2(&c0); + let r1 = compute_phi_response_v2(&c1); + assert_ne!(r0, r1); + } + + #[test] + fn test_onchain_v2_wrong_node_differs() { + let node_a = [0xAAu8; 32]; + let node_b = [0xBBu8; 32]; + let r_a = compute_phi_response_v2(&derive_phi_challenge_v2(0, &node_a)); + let r_b = compute_phi_response_v2(&derive_phi_challenge_v2(0, &node_b)); + assert_ne!(r_a, r_b); + } + + #[test] + fn test_onchain_v2_pinned_kat() { + let mut node = [0u8; 32]; + for (i, b) in node.iter_mut().enumerate() { + *b = (i + 1) as u8; + } + let challenge = derive_phi_challenge_v2(42, &node); + let response = compute_phi_response_v2(&challenge); + let challenge2 = derive_phi_challenge_v2(42, &node); + let response2 = compute_phi_response_v2(&challenge2); + assert_eq!(response, response2); + assert_eq!(response.len(), 32); + } + + #[test] + fn test_onchain_v2_champion_first_row_matches_cli_tri() { + let expected_row_0: [u8; 16] = + [0x4, 0xF, 0xA, 0x7, 0x2, 0x8, 0x6, 0x1, 0xA, 0x2, 0x4, 0xC, 0x0, 0x6, 0x1, 0x5]; + assert_eq!(CHAMPION_WEIGHTS[0], expected_row_0); + } + + #[test] + fn test_onchain_v2_gf16_mul_matches_cli_tri() { + assert_eq!(gf16_mul(0x3, 0x7), 0x9); + assert_eq!(gf16_mul(0xF, 0xF), 0xA); + assert_eq!(gf16_mul(0x0, 0xA), 0x0); + assert_eq!(gf16_mul(0x1, 0xB), 0xB); + } +} diff --git a/contrib/solana/tests/tri-mining.ts b/contrib/solana/tests/tri-mining.ts index 6efa761de..94ae3b6c1 100644 --- a/contrib/solana/tests/tri-mining.ts +++ b/contrib/solana/tests/tri-mining.ts @@ -32,6 +32,80 @@ function gf16Dot4(w: number[], x: number[]): number[] { ]; } +const CHAMPION_WEIGHTS: number[][] = [ + [0x4, 0xF, 0xA, 0x7, 0x2, 0x8, 0x6, 0x1, 0xA, 0x2, 0x4, 0xC, 0x0, 0x6, 0x1, 0x5], + [0xB, 0x7, 0x2, 0x4, 0x6, 0xA, 0x3, 0x7, 0xA, 0x3, 0xF, 0x9, 0x5, 0x1, 0xD, 0x1], + [0xC, 0x7, 0x3, 0xA, 0x5, 0x2, 0x1, 0xF, 0x4, 0x2, 0x9, 0x7, 0x2, 0x9, 0x0, 0xB], + [0xD, 0xE, 0x7, 0x9, 0xE, 0x2, 0x6, 0x1, 0xC, 0xF, 0x7, 0xE, 0x7, 0x6, 0x6, 0x1], + [0xB, 0x7, 0x3, 0x9, 0x2, 0x4, 0xE, 0x1, 0xF, 0x5, 0x9, 0x7, 0xD, 0xB, 0x9, 0x2], + [0x1, 0x5, 0x1, 0xB, 0x8, 0x2, 0x2, 0xB, 0x9, 0x9, 0x7, 0xB, 0x9, 0x9, 0x3, 0xB], + [0x2, 0x1, 0xA, 0x7, 0xD, 0x1, 0x2, 0xB, 0x3, 0x7, 0x4, 0xF, 0xC, 0x7, 0x5, 0xD], + [0xA, 0x8, 0xB, 0x1, 0xC, 0xA, 0x4, 0xC, 0xE, 0x5, 0x7, 0xF, 0x6, 0xA, 0xA, 0xA], + [0xC, 0x9, 0x7, 0x6, 0xF, 0x4, 0x5, 0x7, 0x1, 0x2, 0xD, 0x0, 0xF, 0xE, 0x6, 0x0], + [0x7, 0xE, 0xA, 0xE, 0x7, 0xB, 0x5, 0x7, 0x4, 0xC, 0xB, 0x3, 0x7, 0x4, 0xB, 0xE], + [0xB, 0x8, 0x4, 0x9, 0x0, 0xE, 0x0, 0x6, 0x9, 0x5, 0x1, 0xA, 0x6, 0x5, 0x5, 0x8], + [0x8, 0x2, 0xC, 0x4, 0x7, 0x6, 0x2, 0x2, 0xF, 0xA, 0xA, 0x1, 0x3, 0xD, 0x0, 0x6], + [0xA, 0x4, 0x6, 0xF, 0x9, 0xC, 0x4, 0xB, 0xB, 0xD, 0x6, 0x2, 0xA, 0x5, 0x9, 0x5], + [0x8, 0x6, 0xA, 0x7, 0x0, 0xC, 0x0, 0x8, 0x8, 0xF, 0x4, 0xE, 0x6, 0xA, 0x5, 0x5], + [0xB, 0x5, 0x1, 0x8, 0xD, 0x8, 0x2, 0x8, 0x0, 0xE, 0xD, 0x4, 0x1, 0x0, 0x7, 0xC], + [0x2, 0x3, 0xA, 0xE, 0x5, 0x5, 0xC, 0xB, 0x3, 0x8, 0x1, 0xD, 0xA, 0xA, 0x2, 0xF], +]; + +function gf16Matmul(a: number[][], b: number[][]): number[][] { + const c: number[][] = []; + for (let i = 0; i < 16; i++) { + const row: number[] = []; + for (let j = 0; j < 16; j++) { + let acc = 0; + for (let k = 0; k < 16; k++) { + acc ^= gf16Mul(a[i][k] & 0xf, b[k][j] & 0xf); + } + row.push(acc & 0xf); + } + c.push(row); + } + return c; +} + +function packGf16Matrix(m: number[][]): Buffer { + const out = Buffer.alloc(128); + for (let i = 0; i < 16; i++) { + for (let j = 0; j < 8; j++) { + out[i * 8 + j] = ((m[i][j * 2] & 0xf) << 4) | (m[i][j * 2 + 1] & 0xf); + } + } + return out; +} + +function derivePhiChallengeV2(epochId: number, nodeId: Uint8Array): number[][] { + const matrix: number[][] = []; + for (let i = 0; i < 16; i++) { + const buf = Buffer.alloc(8); + buf.writeBigUInt64LE(BigInt(epochId)); + const input = Buffer.concat([ + Buffer.from("TRI_PHI_CHALLENGE_V2"), + buf, + Buffer.from(nodeId), + Buffer.from([i]), + ]); + const hashBytes = Buffer.from(sha256(input), "hex"); + const row: number[] = []; + for (let j = 0; j < 16; j++) { + row.push((hashBytes[j * 2] >> 4) & 0xf); + } + matrix.push(row); + } + return matrix; +} + +function computePhiResponseV2(epochId: number, nodeId: Uint8Array): number[] { + const challenge = derivePhiChallengeV2(epochId, nodeId); + const product = gf16Matmul(CHAMPION_WEIGHTS, challenge); + const packed = packGf16Matrix(product); + const hashBytes = Buffer.from(sha256(packed), "hex"); + return Array.from(hashBytes); +} + function computePhiChallenge(epochId: number, nodeId: Uint8Array): Uint8Array { const preimage = Buffer.concat([ Buffer.from("TRI_PHI_CHALLENGE_V1"), @@ -151,6 +225,106 @@ describe("tri-mining", () => { ); }); + it("V2: node submits valid SHA256 response and earns tokens", async () => { + const [epochPda] = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("epoch"), epochId.toArrayLike(Buffer, "le", 8)], + program.programId + ); + + const miner = anchor.web3.Keypair.generate(); + const airdropSig = await provider.connection.requestAirdrop( + miner.publicKey, + 10 * anchor.web3.LAMPORTS_PER_SOL + ); + await provider.connection.confirmTransaction(airdropSig); + + const nodeId = miner.publicKey.toBuffer(); + const phiResponse = computePhiResponseV2(1, nodeId); + assert.equal(phiResponse.length, 32, "V2 response must be 32 bytes"); + + const merkleRoot = new Array(32).fill(0); + const signature = new Array(64).fill(0); + + const [proofPda] = anchor.web3.PublicKey.findProgramAddressSync( + [ + Buffer.from("proof_v2"), + miner.publicKey.toBuffer(), + epochId.toArrayLike(Buffer, "le", 8), + ], + program.programId + ); + + await program.methods + .submitProofV2(phiResponse, merkleRoot, signature) + .accounts({ + miningEpoch: epochPda, + nodeProof: proofPda, + miner: miner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .signers([miner]) + .rpc({ skipPreflight: true }); + + const proof = await program.account.nodeProofV2.fetch(proofPda); + assert.equal(proof.miner.toString(), miner.publicKey.toString()); + assert.equal(proof.epochId.toNumber(), 1); + assert.equal(proof.version, 2, "version field must be 2"); + assert.equal(proof.tokensEarned.toNumber(), 50_000); + assert.equal(proof.phiResponse.length, 32); + }); + + it("V2: rejects invalid SHA256 response (flipped bit)", async () => { + const [epochPda] = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("epoch"), epochId.toArrayLike(Buffer, "le", 8)], + program.programId + ); + + const miner = anchor.web3.Keypair.generate(); + const airdropSig = await provider.connection.requestAirdrop( + miner.publicKey, + 10 * anchor.web3.LAMPORTS_PER_SOL + ); + await provider.connection.confirmTransaction(airdropSig); + + const nodeId = miner.publicKey.toBuffer(); + const phiResponse = computePhiResponseV2(1, nodeId); + phiResponse[0] ^= 0x01; + + const merkleRoot = new Array(32).fill(0); + const signature = new Array(64).fill(0); + + const [proofPda] = anchor.web3.PublicKey.findProgramAddressSync( + [ + Buffer.from("proof_v2"), + miner.publicKey.toBuffer(), + epochId.toArrayLike(Buffer, "le", 8), + ], + program.programId + ); + + try { + await program.methods + .submitProofV2(phiResponse, merkleRoot, signature) + .accounts({ + miningEpoch: epochPda, + nodeProof: proofPda, + miner: miner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .signers([miner]) + .rpc({ skipPreflight: true }); + assert.fail("expected V2 transaction to fail with PhiChallengeMismatch"); + } catch (err: any) { + const errorMsg = err.toString(); + const hasError = errorMsg.includes("PhiChallengeMismatch") || + errorMsg.includes("phi_challenge mismatch"); + assert.isTrue( + hasError, + `expected PhiChallengeMismatch error, got: ${errorMsg.slice(0, 200)}` + ); + } + }); + it("rejects invalid phi_response", async () => { const [epochPda] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("epoch"), epochId.toArrayLike(Buffer, "le", 8)],