From 96dcbf0a35caf7e30306f268d4ee83217e295019 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 15 Jun 2026 21:39:04 +0200 Subject: [PATCH 01/35] HIP-25: implement Pure HACD Staking (types, actions, rewards, fee redirect) --- src/chain/execute/insert.rs | 19 +- src/mint/action/action.rs | 3 + src/mint/action/diamond_insc.rs | 18 +- src/mint/action/diamond_staking.rs | 59 ++++++ src/mint/action/mod.rs | 1 + src/mint/component/mod.rs | 1 + src/mint/component/staking.rs | 89 +++++++++ src/mint/operate/diamond.rs | 8 +- src/mint/operate/mod.rs | 1 + src/mint/operate/staking.rs | 277 +++++++++++++++++++++++++++++ src/mint/state/def.rs | 9 +- 11 files changed, 476 insertions(+), 9 deletions(-) create mode 100644 src/mint/action/diamond_staking.rs create mode 100644 src/mint/component/staking.rs create mode 100644 src/mint/operate/staking.rs diff --git a/src/chain/execute/insert.rs b/src/chain/execute/insert.rs index 4ce4688..b720917 100644 --- a/src/chain/execute/insert.rs +++ b/src/chain/execute/insert.rs @@ -123,7 +123,22 @@ pub fn do_check_insert( for tx in alltxs { if execn > 0 { // except coinbase tx exec_tx_actions(!not_fast_sync, cnf.chain_id, height, blkhash, &mut sub_state, store, tx.as_read())?; - alltxfee = alltxfee.add(&tx.fee_got())?; // fee_miner_received + let fee = tx.fee_got(); + // HIP-25: redirect 40% of HACD transfer tx fees to staking pool + if crate::mint::operate::tx_contains_diamond_transfer(tx.as_read()) { + let fee_zhu = fee.to_zhu_unsafe() as u64; + let (to_pool, to_miner_zhu) = crate::mint::operate::staking_redirect_fee_zhu(fee_zhu); + if to_pool > 0 { + let mut ms = crate::mint::state::MintState::wrap(&mut sub_state); + crate::mint::operate::staking_deposit_fee(&mut ms, to_pool); + } + if to_miner_zhu > 0 { + let miner_part = Amount::from_zhu(to_miner_zhu as i64)?; + alltxfee = alltxfee.add(&miner_part)?; + } + } else { + alltxfee = alltxfee.add(&fee)?; // fee_miner_received + } } // deduct tx fee after exec all actions tx.execute(height, &mut sub_state)?; // coinbase and other tx @@ -135,6 +150,8 @@ pub fn do_check_insert( let mut corestate = CoreState::wrap(&mut sub_state); operate::hac_add(&mut corestate, &miner, &alltxfee)?; } + // HIP-25: per-block staking rewards + cooldown finalization + crate::mint::operate::staking_on_block_close(&mut sub_state, height)?; // test Ok(sub_state) diff --git a/src/mint/action/action.rs b/src/mint/action/action.rs index 24fb2a6..86a03e3 100644 --- a/src/mint/action/action.rs +++ b/src/mint/action/action.rs @@ -22,6 +22,9 @@ pub const ACTION_KIND_ID_DIAMOND_MINT: u16 = 4; DiamondInscription // 32 DiamondInscriptionClear // 33 + DiamondStake // 34 HIP-25 + DiamondUnstake // 35 HIP-25 + } // reg action diff --git a/src/mint/action/diamond_insc.rs b/src/mint/action/diamond_insc.rs index a3dc185..b48a63c 100644 --- a/src/mint/action/diamond_insc.rs +++ b/src/mint/action/diamond_insc.rs @@ -63,10 +63,15 @@ fn diamond_inscription(this: &DiamondInscription, ctx: &dyn ExecContext, sta: &m return errf!("diamond inscription cost error need {} but got {}", ttcost.to_fin_string(), pcost.to_fin_string()) } - // change count + // change count + HIP-25 fee redirect (40% protocol fee → staking pool) + let pay_zhu = pcost.to_zhu_unsafe() as u64; + let (to_pool, to_burn_zhu) = staking_redirect_fee_zhu(pay_zhu); + if to_pool > 0 { + staking_deposit_fee(&mut state, to_pool); + } let mut ttcount = state.total_count(); ttcount.diamond_engraved += this.diamonds.count().uint() as u64; - ttcount.diamond_insc_burn_zhu += pcost.to_zhu_unsafe() as u64; + ttcount.diamond_insc_burn_zhu += to_burn_zhu; state.set_total_count(&ttcount); drop(state); @@ -133,9 +138,14 @@ fn diamond_inscription_clean(this: &DiamondInscriptionClear, ctx: &dyn ExecConte return errf!("diamond inscription cost error need {} but got {}", ttcost.to_fin_string(), pcost.to_fin_string()) } - // change count + // change count + HIP-25 fee redirect + let pay_zhu = pcost.to_zhu_unsafe() as u64; + let (to_pool, to_burn_zhu) = staking_redirect_fee_zhu(pay_zhu); + if to_pool > 0 { + staking_deposit_fee(&mut state, to_pool); + } let mut ttcount = state.total_count(); - ttcount.diamond_insc_burn_zhu += pcost.to_zhu_unsafe() as u64; + ttcount.diamond_insc_burn_zhu += to_burn_zhu; state.set_total_count(&ttcount); drop(state); diff --git a/src/mint/action/diamond_staking.rs b/src/mint/action/diamond_staking.rs new file mode 100644 index 0000000..9c890f3 --- /dev/null +++ b/src/mint/action/diamond_staking.rs @@ -0,0 +1,59 @@ + +/** + * HIP-25: Diamond Stake / Unstake actions + */ +ActionDefine!{ + DiamondStake : 34, ( + diamonds : DiamondNameListMax200 + ), + ACTLV_MAIN, + 21, + (self, ctx, state, store, gas), + true, + [], + { + gas += self.diamonds.count().uint() as i64 * DiamondName::width() as i64; + diamond_stake(self, ctx, state, store) + } +} + +ActionDefine!{ + DiamondUnstake : 35, ( + diamonds : DiamondNameListMax200 + ), + ACTLV_MAIN, + 21, + (self, ctx, state, store, gas), + true, + [], + { + gas += self.diamonds.count().uint() as i64 * DiamondName::width() as i64; + diamond_unstake(self, ctx, state, store) + } +} + +fn diamond_stake( + this: &DiamondStake, + ctx: &dyn ExecContext, + sta: &mut dyn State, + _sto: &dyn Store, +) -> Ret> { + let staker = ctx.main_address(); + let height = ctx.pending_height(); + let mut state = MintState::wrap(sta); + staking_apply_stake(&mut state, staker, &this.diamonds, height)?; + Ok(vec![]) +} + +fn diamond_unstake( + this: &DiamondUnstake, + ctx: &dyn ExecContext, + sta: &mut dyn State, + _sto: &dyn Store, +) -> Ret> { + let staker = ctx.main_address(); + let height = ctx.pending_height(); + let mut state = MintState::wrap(sta); + staking_apply_unstake(&mut state, staker, &this.diamonds, height)?; + Ok(vec![]) +} \ No newline at end of file diff --git a/src/mint/action/mod.rs b/src/mint/action/mod.rs index 12d3498..5aad158 100644 --- a/src/mint/action/mod.rs +++ b/src/mint/action/mod.rs @@ -25,6 +25,7 @@ include!("satoshi.rs"); include!("diamond.rs"); include!("diamond_mint.rs"); include!("diamond_insc.rs"); +include!("diamond_staking.rs"); include!("channel.rs"); include!("action.rs"); diff --git a/src/mint/component/mod.rs b/src/mint/component/mod.rs index 1fe867b..e9d39c5 100644 --- a/src/mint/component/mod.rs +++ b/src/mint/component/mod.rs @@ -22,6 +22,7 @@ include!("genesis.rs"); include!("total.rs"); include!("balance.rs"); include!("diamond.rs"); +include!("staking.rs"); include!("channel.rs"); include!("tx.rs"); include!("block.rs"); diff --git a/src/mint/component/staking.rs b/src/mint/component/staking.rs new file mode 100644 index 0000000..6ee86d6 --- /dev/null +++ b/src/mint/component/staking.rs @@ -0,0 +1,89 @@ + +/** + * HIP-25: Pure HACD Staking + * + * Constants, status values, and on-chain state types. + * See HIP-25 for the full protocol specification. + */ + +/// 40% of eligible inscription protocol fees and transfer fees → reward pool +pub const STAKING_FEE_SHARE_PERCENT: u64 = 40; + +/// ~3 days cooldown after unstake (1000 blocks ≈ 3.5 days per HIP-15) +pub const COOLDOWN_BLOCKS: u64 = 864; + +/// ~90 days / 3 months minimum stake before unstake is allowed +pub const MIN_STAKE_BLOCKS: u64 = 25714; + +/// HVM external action opcode (HIP-21) +pub const STAKE_HACD_VMKIND: u8 = 0x01; + +/// HVM external action opcode (HIP-21) +pub const UNSTAKE_HACD_VMKIND: u8 = 0x02; + +/// Diamond is locked and earning rewards +pub const DIAMOND_STATUS_STAKED: Uint1 = Uint1::from(4); + +/// Diamond is in post-unstake cooldown; HACD and rewards release at unlock_height +pub const DIAMOND_STATUS_STAKING_COOLDOWN: Uint1 = Uint1::from(5); + +pub fn diamond_status_allows_transfer(status: &Uint1) -> bool { + *status == DIAMOND_STATUS_NORMAL +} + +pub fn diamond_status_allows_inscription(status: &Uint1) -> bool { + *status == DIAMOND_STATUS_NORMAL +} + +pub fn diamond_status_is_staking_locked(status: &Uint1) -> bool { + *status == DIAMOND_STATUS_STAKED || *status == DIAMOND_STATUS_STAKING_COOLDOWN +} + +/** + * Global staking pool and reward index. + * Singleton key: &[2, 3] in MintState (see state/def.rs). + */ +StructFieldStruct!(GlobalStakingState, + total_staked_shares : Uint5 + global_reward_index : Uint8 + reward_pool_zhu : Uint8 + paused : Uint1 + unlock_queue_head : Uint5 + unlock_queue_tail : Uint5 +); + +impl GlobalStakingState { + pub fn is_paused(&self) -> bool { + self.paused.uint() != 0 + } +} + +/** + * Per-HACD staking metadata. + * Keyed by DiamondName while status is Staked or Cooldown. + * Removed when diamond returns to Normal. + */ +StructFieldStruct!(StakingRecord, + stake_height : BlockHeight + unlock_height : BlockHeight + reward_index : Uint8 + pending_reward : Amount +); + +impl StakingRecord { + pub fn is_active_stake(&self) -> bool { + self.unlock_height.is_zero() + } +} + +/** + * FIFO unlock queue entry. + * Appended on unstake; consumed at block close when unlock_height <= block height. + * Keyed by monotonic Uint5 entry id. + */ +StructFieldStruct!(StakingUnlockEntry, + unlock_height : BlockHeight + diamond : DiamondName + staker : Address + reward : Amount +); \ No newline at end of file diff --git a/src/mint/operate/diamond.rs b/src/mint/operate/diamond.rs index 35d0149..0976a11 100644 --- a/src/mint/operate/diamond.rs +++ b/src/mint/operate/diamond.rs @@ -18,9 +18,15 @@ pub fn check_diamond_status(state: &mut MintState, addr_from: &Address, hacd_nam let diaitem = must_have!( format!("diamond {}", hacd_name.readable()), state.diamond(hacd_name)); - if diaitem.status != DIAMOND_STATUS_NORMAL { + if diaitem.status == DIAMOND_STATUS_LENDING_TO_SYSTEM || diaitem.status == DIAMOND_STATUS_LENDING_TO_USER { return errf!("diamond {} has been mortgaged and cannot be transferred", hacd_name.readable()) } + if diamond_status_is_staking_locked(&diaitem.status) { + return errf!("diamond {} is staked or in unstake cooldown", hacd_name.readable()) + } + if diaitem.status != DIAMOND_STATUS_NORMAL { + return errf!("diamond {} status {} does not allow this operation", hacd_name.readable(), diaitem.status.uint()) + } if *addr_from != diaitem.address { return errf!("diamond {} not belong to address {}", hacd_name.readable(), addr_from.readable()) } diff --git a/src/mint/operate/mod.rs b/src/mint/operate/mod.rs index a003a5f..cf4ad25 100644 --- a/src/mint/operate/mod.rs +++ b/src/mint/operate/mod.rs @@ -20,4 +20,5 @@ use super::coinbase::*; include!("channel.rs"); include!("diamond.rs"); +include!("staking.rs"); diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs new file mode 100644 index 0000000..9456bd8 --- /dev/null +++ b/src/mint/operate/staking.rs @@ -0,0 +1,277 @@ + +use crate::interface::field::*; +use crate::interface::chain::*; +use crate::interface::protocol::*; + +use crate::sys::*; +use crate::core::field::*; +use crate::core::state::*; +use crate::protocol::operate::*; + +use super::super::state::*; +use super::super::component::*; + +fn staking_accrued_zhu(global_index: &Uint8, snapshot: &Uint8) -> u64 { + global_index.uint().saturating_sub(snapshot.uint()) +} + +fn staking_accrued_amount(global_index: &Uint8, snapshot: &Uint8) -> Ret { + let zhu = staking_accrued_zhu(global_index, snapshot) as i64; + if zhu <= 0 { + return Ok(Amount::default()); + } + Amount::from_zhu(zhu) +} + +/// Returns true if tx contains a HACD transfer action (kinds 5–8). +pub fn tx_contains_diamond_transfer(tx: &dyn TransactionRead) -> bool { + for act in tx.actions() { + let k = act.kind(); + if (5..=8).contains(&k) { + return true; + } + } + false +} + +pub fn staking_redirect_fee_zhu(fee_zhu: u64) -> (u64, u64) { + let to_pool = fee_zhu * STAKING_FEE_SHARE_PERCENT / 100; + let to_burn = fee_zhu - to_pool; + (to_pool, to_burn) +} + +pub fn staking_deposit_fee(state: &mut MintState, fee_zhu: u64) { + if fee_zhu == 0 { + return; + } + let mut global = state.staking_global(); + global.reward_pool_zhu = Uint8::from(global.reward_pool_zhu.uint() + fee_zhu); + state.set_staking_global(&global); +} + +pub fn staking_distribute_rewards(state: &mut MintState) -> Ret<()> { + let mut global = state.staking_global(); + let shares = global.total_staked_shares.uint(); + let pool = global.reward_pool_zhu.uint(); + if shares == 0 || pool == 0 { + return Ok(()); + } + let increment = pool / shares; + if increment > 0 { + global.global_reward_index = + Uint8::from(global.global_reward_index.uint() + increment); + } + // dust stays in pool when increment rounds to zero + let distributed = increment * shares; + if distributed >= pool { + global.reward_pool_zhu = Uint8::from(0); + } else { + global.reward_pool_zhu = Uint8::from(pool - distributed); + } + state.set_staking_global(&global); + Ok(()) +} + +fn staking_enqueue_unlock(state: &mut MintState, entry: &StakingUnlockEntry) -> Ret<()> { + let mut global = state.staking_global(); + let id = global.unlock_queue_tail.uint(); + let key = Uint5::from(id); + state.set_staking_unlock_entry(&key, entry); + global.unlock_queue_tail = Uint5::from(id + 1); + state.set_staking_global(&global); + Ok(()) +} + +fn staking_finalize_unlock( + mint_state: &mut MintState, + base_state: &mut dyn State, + entry: &StakingUnlockEntry, +) -> Ret<()> { + let dianame = &entry.diamond; + let mut diaitem = must_have!( + format!("diamond {}", dianame.readable()), + mint_state.diamond(dianame) + ); + if diaitem.status != DIAMOND_STATUS_STAKING_COOLDOWN { + return errf!( + "diamond {} unlock failed: expected cooldown status", + dianame.readable() + ); + } + diaitem.status = DIAMOND_STATUS_NORMAL; + mint_state.set_diamond(dianame, &diaitem); + mint_state.del_staking_record(dianame); + + if entry.reward.is_positive() { + let mut core_state = CoreState::wrap(base_state); + hac_add(&mut core_state, &entry.staker, &entry.reward)?; + } + Ok(()) +} + +pub fn staking_process_unlock_queue( + mint_state: &mut MintState, + base_state: &mut dyn State, + height: u64, +) -> Ret<()> { + let mut global = mint_state.staking_global(); + let mut head = global.unlock_queue_head.uint(); + let tail = global.unlock_queue_tail.uint(); + + while head < tail { + let key = Uint5::from(head); + let entry = match mint_state.staking_unlock_entry(&key) { + Some(e) => e, + None => { + head += 1; + continue; + } + }; + if entry.unlock_height.uint() > height { + break; + } + staking_finalize_unlock(mint_state, base_state, &entry)?; + mint_state.del_staking_unlock_entry(&key); + head += 1; + } + + global.unlock_queue_head = Uint5::from(head); + mint_state.set_staking_global(&global); + Ok(()) +} + +pub fn staking_on_block_close(base_state: &mut dyn State, height: u64) -> Ret<()> { + let mut mint_state = MintState::wrap(base_state); + staking_distribute_rewards(&mut mint_state)?; + staking_process_unlock_queue(&mut mint_state, base_state, height)?; + Ok(()) +} + +pub fn check_diamond_stakeable( + state: &MintState, + staker: &Address, + hacd_name: &DiamondName, +) -> Ret { + let diaitem = must_have!( + format!("diamond {}", hacd_name.readable()), + state.diamond(hacd_name) + ); + if !diamond_status_allows_transfer(&diaitem.status) { + return errf!( + "diamond {} cannot be staked while status is {}", + hacd_name.readable(), + diaitem.status.uint() + ); + } + if *staker != diaitem.address { + return errf!( + "diamond {} not belong to address {}", + hacd_name.readable(), + staker.readable() + ); + } + Ok(diaitem) +} + +pub fn staking_apply_stake( + state: &mut MintState, + staker: &Address, + diamonds: &DiamondNameListMax200, + height: u64, +) -> Ret<()> { + diamonds.check()?; + let mut global = state.staking_global(); + if global.is_paused() { + return errf!("HACD staking is paused"); + } + let reward_index = global.global_reward_index.clone(); + + for dianame in diamonds.list() { + let mut diaitem = check_diamond_stakeable(state, staker, &dianame)?; + diaitem.status = DIAMOND_STATUS_STAKED; + state.set_diamond(&dianame, &diaitem); + + let record = StakingRecord { + stake_height: BlockHeight::from(height), + unlock_height: BlockHeight::from(0), + reward_index: reward_index.clone(), + pending_reward: Amount::default(), + }; + state.set_staking_record(&dianame, &record); + global.total_staked_shares = + Uint5::from(global.total_staked_shares.uint() + 1); + } + + state.set_staking_global(&global); + Ok(()) +} + +pub fn staking_apply_unstake( + state: &mut MintState, + staker: &Address, + diamonds: &DiamondNameListMax200, + height: u64, +) -> Ret<()> { + diamonds.check()?; + let global = state.staking_global(); + let reward_index = global.global_reward_index.clone(); + + for dianame in diamonds.list() { + let mut diaitem = must_have!( + format!("diamond {}", dianame.readable()), + state.diamond(&dianame) + ); + if diaitem.status != DIAMOND_STATUS_STAKED { + return errf!("diamond {} is not staked", dianame.readable()); + } + if *staker != diaitem.address { + return errf!( + "diamond {} not belong to staker {}", + dianame.readable(), + staker.readable() + ); + } + + let record = must_have!( + format!("staking record for {}", dianame.readable()), + state.staking_record(&dianame) + ); + let stake_height = record.stake_height.uint(); + if height < stake_height + MIN_STAKE_BLOCKS { + return errf!( + "diamond {} must remain staked for at least {} blocks (~3 months)", + dianame.readable(), + MIN_STAKE_BLOCKS + ); + } + + let reward = staking_accrued_amount(&reward_index, &record.reward_index)?; + + diaitem.status = DIAMOND_STATUS_STAKING_COOLDOWN; + state.set_diamond(&dianame, &diaitem); + + let unlock_height = height + COOLDOWN_BLOCKS; + let cooldown_record = StakingRecord { + stake_height: record.stake_height.clone(), + unlock_height: BlockHeight::from(unlock_height), + reward_index: reward_index.clone(), + pending_reward: reward.clone(), + }; + state.set_staking_record(&dianame, &cooldown_record); + + let mut global = state.staking_global(); + global.total_staked_shares = + Uint5::from(global.total_staked_shares.uint().saturating_sub(1)); + state.set_staking_global(&global); + + let entry = StakingUnlockEntry { + unlock_height: BlockHeight::from(unlock_height), + diamond: dianame.clone(), + staker: staker.clone(), + reward, + }; + staking_enqueue_unlock(state, &entry)?; + } + + Ok(()) +} \ No newline at end of file diff --git a/src/mint/state/def.rs b/src/mint/state/def.rs index db4b220..3a21271 100644 --- a/src/mint/state/def.rs +++ b/src/mint/state/def.rs @@ -20,14 +20,17 @@ defineChainStateOperationInstance!{ State, MintState, ( - &[2, 1], total_count , TotalCount - &[2, 2], latest_diamond , DiamondSmelt + &[2, 1], total_count , TotalCount + &[2, 2], latest_diamond , DiamondSmelt + &[2, 3], staking_global , GlobalStakingState ) ( &[2, 21], diamond_ptr , DiamondNumber , DiamondName &[2, 22], diamond , DiamondName , DiamondSto &[2, 23], diamond_owned , Address , DiamondOwnedForm - &[2, 24], channel , ChannelId , ChannelSto + &[2, 24], channel , ChannelId , ChannelSto + &[2, 25], staking_record , DiamondName , StakingRecord + &[2, 26], staking_unlock_entry, Uint5 , StakingUnlockEntry ) } From 44b70f68c4af04e21836d18e17eac52d091d4055 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 15 Jun 2026 21:40:11 +0200 Subject: [PATCH 02/35] HIP-25: fix staking module imports and BlockHeight zero check --- src/mint/component/staking.rs | 2 +- src/mint/operate/staking.rs | 30 ++++++++++++++++++------------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/mint/component/staking.rs b/src/mint/component/staking.rs index 6ee86d6..4859d17 100644 --- a/src/mint/component/staking.rs +++ b/src/mint/component/staking.rs @@ -72,7 +72,7 @@ StructFieldStruct!(StakingRecord, impl StakingRecord { pub fn is_active_stake(&self) -> bool { - self.unlock_height.is_zero() + self.unlock_height.uint() == 0 } } diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs index 9456bd8..a7aa2a4 100644 --- a/src/mint/operate/staking.rs +++ b/src/mint/operate/staking.rs @@ -1,16 +1,4 @@ -use crate::interface::field::*; -use crate::interface::chain::*; -use crate::interface::protocol::*; - -use crate::sys::*; -use crate::core::field::*; -use crate::core::state::*; -use crate::protocol::operate::*; - -use super::super::state::*; -use super::super::component::*; - fn staking_accrued_zhu(global_index: &Uint8, snapshot: &Uint8) -> u64 { global_index.uint().saturating_sub(snapshot.uint()) } @@ -274,4 +262,22 @@ pub fn staking_apply_unstake( } Ok(()) +} + +#[cfg(test)] +mod staking_tests { + use super::*; + + #[test] + fn fee_redirect_splits_40_60() { + let (pool, burn) = staking_redirect_fee_zhu(1000); + assert_eq!(pool, 400); + assert_eq!(burn, 600); + } + + #[test] + fn min_stake_blocks_is_three_months_scale() { + assert!(MIN_STAKE_BLOCKS > 20000); + assert!(COOLDOWN_BLOCKS < 1000); + } } \ No newline at end of file From 74c48bff512ce3fcef008ccb3147638e8260dc3a Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 15 Jun 2026 21:40:37 +0200 Subject: [PATCH 03/35] HIP-25: fix mutable borrow in staking_on_block_close --- src/mint/operate/staking.rs | 81 ++++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs index a7aa2a4..6889ada 100644 --- a/src/mint/operate/staking.rs +++ b/src/mint/operate/staking.rs @@ -70,11 +70,7 @@ fn staking_enqueue_unlock(state: &mut MintState, entry: &StakingUnlockEntry) -> Ok(()) } -fn staking_finalize_unlock( - mint_state: &mut MintState, - base_state: &mut dyn State, - entry: &StakingUnlockEntry, -) -> Ret<()> { +fn staking_finalize_unlock(mint_state: &mut MintState, entry: &StakingUnlockEntry) -> Ret<()> { let dianame = &entry.diamond; let mut diaitem = must_have!( format!("diamond {}", dianame.readable()), @@ -90,48 +86,61 @@ fn staking_finalize_unlock( mint_state.set_diamond(dianame, &diaitem); mint_state.del_staking_record(dianame); - if entry.reward.is_positive() { - let mut core_state = CoreState::wrap(base_state); - hac_add(&mut core_state, &entry.staker, &entry.reward)?; - } Ok(()) } -pub fn staking_process_unlock_queue( - mint_state: &mut MintState, - base_state: &mut dyn State, - height: u64, -) -> Ret<()> { - let mut global = mint_state.staking_global(); - let mut head = global.unlock_queue_head.uint(); - let tail = global.unlock_queue_tail.uint(); - - while head < tail { - let key = Uint5::from(head); - let entry = match mint_state.staking_unlock_entry(&key) { - Some(e) => e, - None => { - head += 1; - continue; +pub fn staking_process_unlock_queue(base_state: &mut dyn State, height: u64) -> Ret<()> { + let mut pending: Vec<(Uint5, StakingUnlockEntry)> = Vec::new(); + + { + let mut mint_state = MintState::wrap(base_state); + let mut global = mint_state.staking_global(); + let mut head = global.unlock_queue_head.uint(); + let tail = global.unlock_queue_tail.uint(); + + while head < tail { + let key = Uint5::from(head); + let entry = match mint_state.staking_unlock_entry(&key) { + Some(e) => e, + None => { + head += 1; + continue; + } + }; + if entry.unlock_height.uint() > height { + break; } - }; - if entry.unlock_height.uint() > height { - break; + pending.push((key, entry)); + head += 1; + } + + global.unlock_queue_head = Uint5::from(head); + mint_state.set_staking_global(&global); + } + + for (key, entry) in pending { + let reward = entry.reward.clone(); + let staker = entry.staker.clone(); + { + let mut mint_state = MintState::wrap(base_state); + staking_finalize_unlock(&mut mint_state, &entry)?; + mint_state.del_staking_unlock_entry(&key); + } + if reward.is_positive() { + let mut core_state = CoreState::wrap(base_state); + hac_add(&mut core_state, &staker, &reward)?; } - staking_finalize_unlock(mint_state, base_state, &entry)?; - mint_state.del_staking_unlock_entry(&key); - head += 1; } - global.unlock_queue_head = Uint5::from(head); - mint_state.set_staking_global(&global); Ok(()) } pub fn staking_on_block_close(base_state: &mut dyn State, height: u64) -> Ret<()> { - let mut mint_state = MintState::wrap(base_state); - staking_distribute_rewards(&mut mint_state)?; - staking_process_unlock_queue(&mut mint_state, base_state, height)?; + { + let mut mint_state = MintState::wrap(base_state); + staking_distribute_rewards(&mut mint_state)?; + } + staking_process_unlock_queue(base_state, height)?; Ok(()) } From 02730ff9a1901cec9b52181c34ec92a7d8c6e2cf Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 15 Jun 2026 21:48:13 +0200 Subject: [PATCH 04/35] HIP-25: wire staking RPC, toolchain fixes, and public reward helper --- rust-toolchain.toml | 3 + src/core/field/amount.rs | 2 +- src/core/field/mod.rs | 1 - src/lib.rs | 2 +- src/mint/component/staking.rs | 13 ++++ src/mint/operate/staking.rs | 2 +- src/protocol/transaction/mod.rs | 2 +- src/server/rpc/mod.rs | 2 + src/server/rpc/routes.rs | 4 ++ src/server/rpc/staking.rs | 111 ++++++++++++++++++++++++++++++++ 10 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 rust-toolchain.toml create mode 100644 src/server/rpc/staking.rs diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..bb77af5 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.88.0" +components = ["rustfmt", "clippy"] \ No newline at end of file diff --git a/src/core/field/amount.rs b/src/core/field/amount.rs index d11be51..a02049a 100644 --- a/src/core/field/amount.rs +++ b/src/core/field/amount.rs @@ -1,4 +1,4 @@ - +use std::fmt; pub const AMOUNT_MIN_SIZE: usize = 2; diff --git a/src/core/field/mod.rs b/src/core/field/mod.rs index b7ffaa6..0e99b4c 100644 --- a/src/core/field/mod.rs +++ b/src/core/field/mod.rs @@ -1,4 +1,3 @@ -use std::fmt; use std::fmt::{Debug, Formatter}; use std::cmp::Ordering::{Less,Greater}; use std::cmp::{Ordering, PartialOrd, Ord}; diff --git a/src/lib.rs b/src/lib.rs index b40c992..aa28ab2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(not(test), no_main)] // #![no_std] // #[panic_handler] diff --git a/src/mint/component/staking.rs b/src/mint/component/staking.rs index 4859d17..b28bfaf 100644 --- a/src/mint/component/staking.rs +++ b/src/mint/component/staking.rs @@ -39,6 +39,19 @@ pub fn diamond_status_is_staking_locked(status: &Uint1) -> bool { *status == DIAMOND_STATUS_STAKED || *status == DIAMOND_STATUS_STAKING_COOLDOWN } +pub fn staking_status_label(status: &Uint1) -> &'static str { + if *status == DIAMOND_STATUS_STAKED { + return "staked"; + } + if *status == DIAMOND_STATUS_STAKING_COOLDOWN { + return "cooldown"; + } + if *status == DIAMOND_STATUS_NORMAL { + return "available"; + } + "unknown" +} + /** * Global staking pool and reward index. * Singleton key: &[2, 3] in MintState (see state/def.rs). diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs index 6889ada..8f7a47c 100644 --- a/src/mint/operate/staking.rs +++ b/src/mint/operate/staking.rs @@ -3,7 +3,7 @@ fn staking_accrued_zhu(global_index: &Uint8, snapshot: &Uint8) -> u64 { global_index.uint().saturating_sub(snapshot.uint()) } -fn staking_accrued_amount(global_index: &Uint8, snapshot: &Uint8) -> Ret { +pub fn staking_accrued_amount(global_index: &Uint8, snapshot: &Uint8) -> Ret { let zhu = staking_accrued_zhu(global_index, snapshot) as i64; if zhu <= 0 { return Ok(Amount::default()); diff --git a/src/protocol/transaction/mod.rs b/src/protocol/transaction/mod.rs index 1ae1614..bb9fa0e 100644 --- a/src/protocol/transaction/mod.rs +++ b/src/protocol/transaction/mod.rs @@ -1,4 +1,4 @@ -use std::fmt::*; +use std::fmt::{Debug, Display, Formatter, Result as FmtResult}; use std::collections::{ HashMap, HashSet }; use crate::x16rs; diff --git a/src/server/rpc/mod.rs b/src/server/rpc/mod.rs index 224a2fd..61c8b5e 100644 --- a/src/server/rpc/mod.rs +++ b/src/server/rpc/mod.rs @@ -67,5 +67,7 @@ include!("fee.rs"); include!("miner.rs"); include!("diamond_miner.rs"); +include!("staking.rs"); + diff --git a/src/server/rpc/routes.rs b/src/server/rpc/routes.rs index e36a8ce..0d9d98e 100644 --- a/src/server/rpc/routes.rs +++ b/src/server/rpc/routes.rs @@ -28,6 +28,10 @@ pub fn routes(mut ctx: ApiCtx) -> Router { .route(&query("diamond/engrave"), get(diamond_engrave)) .route(&query("diamond/inscription_protocol_cost"), get(diamond_inscription_protocol_cost)) + .route(&query("staking/status"), get(staking_status)) + .route(&query("staking/summary"), get(staking_summary)) + .route(&query("staking/global"), get(staking_global)) + .route(&query("fee/average"), get(fee_average)) .route(&query("miner/notice"), get(miner_notice)) diff --git a/src/server/rpc/staking.rs b/src/server/rpc/staking.rs new file mode 100644 index 0000000..42c751f --- /dev/null +++ b/src/server/rpc/staking.rs @@ -0,0 +1,111 @@ + +use crate::mint::component::*; +use crate::mint::operate::staking_accrued_amount; + +defineQueryObject!{ QStakingStatus, + diamond, String, s!(""), +} + +async fn staking_status(State(ctx): State, q: Query) -> impl IntoResponse { + ctx_mintstate!(ctx, mintstate); + q_unit!(q, unit); + let name = q.diamond.replace(" ", "").replace("\n", ""); + if !DiamondName::is_valid(name.as_bytes()) { + return api_error("diamond name error"); + } + let dian = DiamondName::cons(name.as_bytes().try_into().unwrap()); + let diaobj = mintstate.diamond(&dian); + if diaobj.is_none() { + return api_error("cannot find diamond"); + } + let diaobj = diaobj.unwrap(); + let global = mintstate.staking_global(); + let status = staking_status_label(&diaobj.status); + let mut stake_height = 0u64; + let mut unlock_height = 0u64; + let mut min_unstake_height = 0u64; + let mut accrued_reward = "0".to_string(); + if let Some(rec) = mintstate.staking_record(&dian) { + stake_height = rec.stake_height.uint(); + unlock_height = rec.unlock_height.uint(); + if stake_height > 0 { + min_unstake_height = stake_height + MIN_STAKE_BLOCKS; + } + if let Ok(amt) = staking_accrued_amount(&global.global_reward_index, &rec.reward_index) { + accrued_reward = amt.to_unit_string(&unit); + } + } + let data = jsondata!{ + "literal", dian.readable(), + "status", status, + "staker", diaobj.address.readable(), + "accrued_reward", accrued_reward, + "min_unstake_height", min_unstake_height, + "unlock_height", unlock_height, + "stake_height", stake_height, + }; + api_data(data) +} + +defineQueryObject!{ QStakingSummary, + address, String, s!(""), +} + +async fn staking_summary(State(ctx): State, q: Query) -> impl IntoResponse { + ctx_mintstate!(ctx, mintstate); + q_unit!(q, unit); + let ads = q.address.replace(" ", "").replace("\n", ""); + let adr = match Address::from_readable(&ads) { + Ok(a) => a, + Err(_) => return api_error("address format error"), + }; + let owned = mintstate.diamond_owned(&adr).unwrap_or_default(); + let names = owned.readable(); + let global = mintstate.staking_global(); + let mut staked_count = 0u64; + let mut cooldown_count = 0u64; + let mut total_accrued = Amount::default(); + let l = DiamondName::width(); + let bytes = names.as_bytes(); + for i in (0..bytes.len()).step_by(l) { + if i + l > bytes.len() { + break; + } + let dian = DiamondName::cons(bytes[i..i + l].try_into().unwrap()); + let Some(diaobj) = mintstate.diamond(&dian) else { + continue; + }; + if diaobj.status == DIAMOND_STATUS_STAKED { + staked_count += 1; + } else if diaobj.status == DIAMOND_STATUS_STAKING_COOLDOWN { + cooldown_count += 1; + } + if let Some(rec) = mintstate.staking_record(&dian) { + if let Ok(amt) = staking_accrued_amount(&global.global_reward_index, &rec.reward_index) { + total_accrued = total_accrued.add(&amt).unwrap_or(total_accrued); + } + } + } + let data = jsondata!{ + "staked_count", staked_count, + "cooldown_count", cooldown_count, + "total_accrued_reward", total_accrued.to_unit_string(&unit), + }; + api_data(data) +} + +defineQueryObject!{ QStakingGlobal, + __nnn_, Option, None, +} + +async fn staking_global(State(ctx): State, _q: Query) -> impl IntoResponse { + ctx_mintstate!(ctx, mintstate); + let global = mintstate.staking_global(); + let data = jsondata!{ + "total_staked_shares", global.total_staked_shares.uint(), + "reward_pool_pending_zhu", global.reward_pool_zhu.uint(), + "global_reward_index", global.global_reward_index.uint(), + "paused", global.is_paused(), + }; + api_data(data) +} \ No newline at end of file From 95a35af2d8ad8bcd8e00b5f098f2c88338c7174d Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 15 Jun 2026 22:03:19 +0200 Subject: [PATCH 05/35] HIP-25: integration tests, HVM bridge API, and testnet config --- Cargo.toml | 3 + src/mint/operate/staking.rs | 226 ++++++++++++++++++++++++++++++++++++ src/vm/mod.rs | 8 +- src/vm/staking_hvm.rs | 21 ++++ 4 files changed, 251 insertions(+), 7 deletions(-) create mode 100644 src/vm/staking_hvm.rs diff --git a/Cargo.toml b/Cargo.toml index 8cb00e5..6ff16c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,9 @@ spmc = "0.3.0" termsize = "0.1.9" reqwest = { version = "0.12.5", features = ["blocking"] } +[dev-dependencies] +tempfile = "3.10.1" + # rocksdb = { version = "0.22.0", default-features = false, features = ["lz4"] } # easy-http-request = "0.2.13" # leveldb-sys = "2.0.9" diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs index 8f7a47c..1e89a4b 100644 --- a/src/mint/operate/staking.rs +++ b/src/mint/operate/staking.rs @@ -170,6 +170,40 @@ pub fn check_diamond_stakeable( Ok(diaitem) } +/// Parse HVM / HIP-25 diamond list wire format: `Uint1 count` + `count × 6` literal bytes. +pub fn staking_parse_hvm_diamonds(raw: &[u8]) -> Ret { + if raw.is_empty() { + return errf!("diamond list empty"); + } + let mut list = DiamondNameListMax200::default(); + list.parse(raw, 0)?; + list.check()?; + Ok(list) +} + +/// Execute HIP-25 HVM external opcode (`0x01` stake, `0x02` unstake) against Mint state. +pub fn staking_exec_hvm_external( + opcode: u8, + payload: &[u8], + staker: &Address, + height: u64, + base_state: &mut dyn State, +) -> Ret<()> { + let diamonds = staking_parse_hvm_diamonds(payload)?; + let mut mint_state = MintState::wrap(base_state); + match opcode { + STAKE_HACD_VMKIND => staking_apply_stake(&mut mint_state, staker, &diamonds, height), + UNSTAKE_HACD_VMKIND => staking_apply_unstake(&mut mint_state, staker, &diamonds, height), + _ => errf!("unknown HIP-25 HVM opcode {}", opcode), + } +} + +pub fn staking_set_paused(state: &mut MintState, paused: bool) { + let mut global = state.staking_global(); + global.paused = Uint1::from(if paused { 1 } else { 0 }); + state.set_staking_global(&global); +} + pub fn staking_apply_stake( state: &mut MintState, staker: &Address, @@ -276,6 +310,54 @@ pub fn staking_apply_unstake( #[cfg(test)] mod staking_tests { use super::*; + use crate::core::state::ChainState; + use crate::mint::operate::hacd_move_one_diamond; + use tempfile::TempDir; + + fn test_state() -> (TempDir, ChainState) { + let dir = TempDir::new().unwrap(); + let state = ChainState::open(dir.path()); + (dir, state) + } + + fn test_staker() -> Address { + Address::from_readable("12vi7DEZjh6KrK5PVmmqSgvuJPCsZMmpfi").unwrap() + } + + fn test_other() -> Address { + Address::from_readable("1LsQLqkd8FQDh3R7ZhxC5fndNf92WfhM19").unwrap() + } + + fn seed_diamond(state: &mut ChainState, name: &str, owner: &Address) -> DiamondName { + let dian = DiamondName::cons(name.as_bytes().try_into().unwrap()); + let dia = DiamondSto { + status: DIAMOND_STATUS_NORMAL, + address: owner.clone(), + prev_engraved_height: BlockHeight::from(0), + inscripts: Inscripts::default(), + }; + let mut mint = MintState::wrap(state); + mint.set_diamond(&dian, &dia); + dian + } + + fn one_diamond_list(name: &str) -> DiamondNameListMax200 { + let mut list = DiamondNameListMax200::default(); + list.push(DiamondName::cons(name.as_bytes().try_into().unwrap())) + .unwrap(); + list + } + + fn hac_balance(state: &ChainState, addr: &Address) -> Amount { + let core = CoreStateDisk::wrap(state); + core.balance(addr) + .map(|b| b.hacash.clone()) + .unwrap_or_default() + } + + fn lit(name: &[u8; 6]) -> DiamondName { + DiamondName::cons(*name) + } #[test] fn fee_redirect_splits_40_60() { @@ -289,4 +371,148 @@ mod staking_tests { assert!(MIN_STAKE_BLOCKS > 20000); assert!(COOLDOWN_BLOCKS < 1000); } + + #[test] + fn stake_owned_hacd_sets_staked_status() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + let dian = seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &staker, &list, 1000).unwrap(); + let dia = mint.diamond(&dian).unwrap(); + assert_eq!(dia.status, DIAMOND_STATUS_STAKED); + assert_eq!(mint.staking_global().total_staked_shares.uint(), 1); + } + + #[test] + fn transfer_staked_hacd_rejected() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + let other = test_other(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &staker, &list, 1000).unwrap(); + let dian = lit(b"WTYUIA"); + let err = hacd_move_one_diamond(&mut mint, &staker, &other, &dian).unwrap_err(); + assert!(format!("{}", err).contains("staked")); + } + + #[test] + fn unstake_before_min_stake_age_rejected() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + let stake_h = 1000u64; + staking_apply_stake(&mut mint, &staker, &list, stake_h).unwrap(); + let too_early = stake_h + MIN_STAKE_BLOCKS - 1; + let err = staking_apply_unstake(&mut mint, &staker, &list, too_early).unwrap_err(); + assert!(format!("{}", err).contains("at least")); + } + + #[test] + fn unstake_cooldown_unlock_pays_reward() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let stake_h = 1000u64; + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &staker, &list, stake_h).unwrap(); + staking_deposit_fee(&mut mint, 1000); + staking_distribute_rewards(&mut mint).unwrap(); + let unstake_h = stake_h + MIN_STAKE_BLOCKS; + staking_apply_unstake(&mut mint, &staker, &list, unstake_h).unwrap(); + let unlock_h = unstake_h + COOLDOWN_BLOCKS; + staking_on_block_close(&mut state, unlock_h).unwrap(); + let mint = MintStateDisk::wrap(&state); + let dian = lit(b"WTYUIA"); + let dia = mint.diamond(&dian).unwrap(); + assert_eq!(dia.status, DIAMOND_STATUS_NORMAL); + assert!(mint.staking_record(&dian).is_none()); + assert!(hac_balance(&state, &staker).is_positive()); + } + + #[test] + fn two_stakers_split_rewards_proportionally() { + let (_dir, mut state) = test_state(); + let s1 = test_staker(); + let s2 = test_other(); + seed_diamond(&mut state, "WTYUIA", &s1); + seed_diamond(&mut state, "HXVMEK", &s2); + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &s1, &one_diamond_list("WTYUIA"), 100).unwrap(); + staking_apply_stake(&mut mint, &s2, &one_diamond_list("HXVMEK"), 100).unwrap(); + staking_deposit_fee(&mut mint, 1000); + staking_distribute_rewards(&mut mint).unwrap(); + let g = mint.staking_global(); + assert_eq!(g.global_reward_index.uint(), 500); + let r1 = mint.staking_record(&lit(b"WTYUIA")).unwrap(); + staking_apply_unstake( + &mut mint, + &s1, + &one_diamond_list("WTYUIA"), + 100 + MIN_STAKE_BLOCKS, + ) + .unwrap(); + let pending = r1.reward_index.uint(); + let accrued = g.global_reward_index.uint().saturating_sub(pending); + assert_eq!(accrued, 500); + } + + #[test] + fn pause_rejects_stake_allows_unstake() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + seed_diamond(&mut state, "HXVMEK", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &staker, &list, 1000).unwrap(); + staking_set_paused(&mut mint, true); + let err = + staking_apply_stake(&mut mint, &staker, &one_diamond_list("HXVMEK"), 2000).unwrap_err(); + assert!(format!("{}", err).contains("paused")); + staking_apply_unstake(&mut mint, &staker, &list, 1000 + MIN_STAKE_BLOCKS).unwrap(); + } + + #[test] + fn batch_over_200_rejected() { + let mut list = DiamondNameListMax200::default(); + let chars = b"WTYUIAHXVMEKBSZN"; + for i in 0..201usize { + let mut bytes = [b'W'; 6]; + for j in 0..6 { + bytes[j] = chars[(i + j) % chars.len()]; + } + list.push(DiamondName::cons(bytes)).unwrap(); + } + let err = list.check().unwrap_err(); + assert!(format!("{}", err).contains("200")); + } + + #[test] + fn hvm_opcode_stake_and_unstake_via_bridge() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let wire = one_diamond_list("WTYUIA").serialize(); + staking_exec_hvm_external(STAKE_HACD_VMKIND, &wire, &staker, 5000, &mut state).unwrap(); + let mint = MintStateDisk::wrap(&state); + let dian = lit(b"WTYUIA"); + assert_eq!(mint.diamond(&dian).unwrap().status, DIAMOND_STATUS_STAKED); + staking_exec_hvm_external( + UNSTAKE_HACD_VMKIND, + &wire, + &staker, + 5000 + MIN_STAKE_BLOCKS, + &mut state, + ) + .unwrap(); + let mint = MintStateDisk::wrap(&state); + assert_eq!(mint.diamond(&dian).unwrap().status, DIAMOND_STATUS_STAKING_COOLDOWN); + } } \ No newline at end of file diff --git a/src/vm/mod.rs b/src/vm/mod.rs index bdf4efa..85d19b6 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -36,13 +36,7 @@ - - - - - - - +include!("staking_hvm.rs"); /* diff --git a/src/vm/staking_hvm.rs b/src/vm/staking_hvm.rs new file mode 100644 index 0000000..878bf3a --- /dev/null +++ b/src/vm/staking_hvm.rs @@ -0,0 +1,21 @@ + +use crate::interface::chain::State; +use crate::core::field::Address; +use crate::mint::component::{STAKE_HACD_VMKIND, UNSTAKE_HACD_VMKIND}; +use crate::mint::operate::staking_exec_hvm_external; +use crate::sys::RetErr; + +/// Rust-side entry for HIP-25 HVM opcodes. Full HVM runtime (Go) calls the same Mint hooks. +pub fn exec_staking_hvm_opcode( + opcode: u8, + payload: &[u8], + staker: &Address, + height: u64, + state: &mut dyn State, +) -> RetErr { + if opcode != STAKE_HACD_VMKIND && opcode != UNSTAKE_HACD_VMKIND { + return errf!("unsupported staking HVM opcode {}", opcode); + } + staking_exec_hvm_external(opcode, payload, staker, height, state)?; + Ok(()) +} \ No newline at end of file From 70dbeefced20f45d946b900d9bf5699fc15ab01f Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 15 Jun 2026 22:03:30 +0200 Subject: [PATCH 06/35] HIP-25: add example fullnode config for testnet RPC --- hacash.config.ini.example | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 hacash.config.ini.example diff --git a/hacash.config.ini.example b/hacash.config.ini.example new file mode 100644 index 0000000..83274da --- /dev/null +++ b/hacash.config.ini.example @@ -0,0 +1,23 @@ +; HIP-25 local testnet / dev fullnode — copy to hacash.config.ini beside the binary +[default] +data_dir = hacash_hip25_testnet_data + +[server] +enable = true +listen = 8083 +recent_blocks = false +average_fee_purity = false + +[node] +listen = 3338 +not_find_nodes = true +boots = + +[mint] +chain_id = 1 + +[miner] +enable = false + +[diamondminer] +enable = false \ No newline at end of file From 2d7f6e5c8aabfa58ed72cc8a554dd841ce91ca1b Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 15 Jun 2026 22:14:59 +0200 Subject: [PATCH 07/35] fix(HIP-25): correct RPC accrual, fee redirect, activation gate --- hacash.config.ini.example | 2 + src/chain/execute/execute.rs | 3 +- src/chain/execute/insert.rs | 25 +++++--- src/config/mint.rs | 3 + src/mint/action/diamond_insc.rs | 16 +++-- src/mint/checker/initialize.rs | 9 ++- src/mint/component/staking.rs | 11 +++- src/mint/operate/staking.rs | 107 ++++++++++++++++++++++++++++++++ src/server/rpc/staking.rs | 7 ++- 9 files changed, 161 insertions(+), 22 deletions(-) diff --git a/hacash.config.ini.example b/hacash.config.ini.example index 83274da..4f0dd7b 100644 --- a/hacash.config.ini.example +++ b/hacash.config.ini.example @@ -15,6 +15,8 @@ boots = [mint] chain_id = 1 +; HIP-25 soft-fork: staking rules apply from this block height (1 = active from genesis on dev) +staking_activation_height = 1 [miner] enable = false diff --git a/src/chain/execute/execute.rs b/src/chain/execute/execute.rs index e808e33..289e20d 100644 --- a/src/chain/execute/execute.rs +++ b/src/chain/execute/execute.rs @@ -55,6 +55,7 @@ fn exec_tx_actions_withvm(is_fast_sync: bool, chain_id: u64, pending_height: u64, pending_hash: Hash, bst: &mut dyn State, sto: &dyn Store, tx: &dyn TransactionRead, ) -> RetErr { - errf!("cannot exec tx with vm") + // Native mint actions (e.g. HIP-25 stake/unstake 34/35) do not require the Go HVM runtime. + exec_tx_actions_normal(is_fast_sync, chain_id, pending_height, pending_hash, bst, sto, tx) } diff --git a/src/chain/execute/insert.rs b/src/chain/execute/insert.rs index b720917..5eb4541 100644 --- a/src/chain/execute/insert.rs +++ b/src/chain/execute/insert.rs @@ -124,17 +124,22 @@ pub fn do_check_insert( if execn > 0 { // except coinbase tx exec_tx_actions(!not_fast_sync, cnf.chain_id, height, blkhash, &mut sub_state, store, tx.as_read())?; let fee = tx.fee_got(); - // HIP-25: redirect 40% of HACD transfer tx fees to staking pool + // HIP-25: redirect 40% of total HACD transfer tx fees to staking pool if crate::mint::operate::tx_contains_diamond_transfer(tx.as_read()) { - let fee_zhu = fee.to_zhu_unsafe() as u64; - let (to_pool, to_miner_zhu) = crate::mint::operate::staking_redirect_fee_zhu(fee_zhu); - if to_pool > 0 { - let mut ms = crate::mint::state::MintState::wrap(&mut sub_state); - crate::mint::operate::staking_deposit_fee(&mut ms, to_pool); - } - if to_miner_zhu > 0 { - let miner_part = Amount::from_zhu(to_miner_zhu as i64)?; - alltxfee = alltxfee.add(&miner_part)?; + let mut ms = crate::mint::state::MintState::wrap(&mut sub_state); + if crate::mint::operate::staking_is_active_at_height(&ms, height) { + let (to_pool, miner_part) = crate::mint::operate::staking_split_transfer_tx_fee( + tx.fee(), + tx.burn_90(), + )?; + if to_pool > 0 { + crate::mint::operate::staking_deposit_fee(&mut ms, to_pool); + } + if miner_part.is_positive() { + alltxfee = alltxfee.add(&miner_part)?; + } + } else { + alltxfee = alltxfee.add(&fee)?; } } else { alltxfee = alltxfee.add(&fee)?; // fee_miner_received diff --git a/src/config/mint.rs b/src/config/mint.rs index f8bd2c6..938ab90 100644 --- a/src/config/mint.rs +++ b/src/config/mint.rs @@ -5,6 +5,8 @@ pub struct MintConf { pub difficulty_adjust_blocks: u64, // height pub each_block_target_time: u64, // secs pub _test_mul: u64, + /// HIP-25 soft-fork height; staking rules apply from this block onward. + pub staking_activation_height: u64, } @@ -21,6 +23,7 @@ impl MintConf { difficulty_adjust_blocks: ini_must_u64(&sec, "difficulty_adjust_blocks", 288), // 1 day each_block_target_time: ini_must_u64(&sec, "each_block_target_time", 300), // 5 mins _test_mul: ini_must_u64(&sec, "_test_mul", 1), // test + staking_activation_height: ini_must_u64(&sec, "staking_activation_height", 1), }; cnf diff --git a/src/mint/action/diamond_insc.rs b/src/mint/action/diamond_insc.rs index b48a63c..f72ad17 100644 --- a/src/mint/action/diamond_insc.rs +++ b/src/mint/action/diamond_insc.rs @@ -66,12 +66,16 @@ fn diamond_inscription(this: &DiamondInscription, ctx: &dyn ExecContext, sta: &m // change count + HIP-25 fee redirect (40% protocol fee → staking pool) let pay_zhu = pcost.to_zhu_unsafe() as u64; let (to_pool, to_burn_zhu) = staking_redirect_fee_zhu(pay_zhu); - if to_pool > 0 { + if to_pool > 0 && staking_is_active_at_height(&state, pdhei) { staking_deposit_fee(&mut state, to_pool); } let mut ttcount = state.total_count(); ttcount.diamond_engraved += this.diamonds.count().uint() as u64; - ttcount.diamond_insc_burn_zhu += to_burn_zhu; + ttcount.diamond_insc_burn_zhu += if staking_is_active_at_height(&state, pdhei) { + to_burn_zhu + } else { + pay_zhu + }; state.set_total_count(&ttcount); drop(state); @@ -141,11 +145,15 @@ fn diamond_inscription_clean(this: &DiamondInscriptionClear, ctx: &dyn ExecConte // change count + HIP-25 fee redirect let pay_zhu = pcost.to_zhu_unsafe() as u64; let (to_pool, to_burn_zhu) = staking_redirect_fee_zhu(pay_zhu); - if to_pool > 0 { + if to_pool > 0 && staking_is_active_at_height(&state, pdhei) { staking_deposit_fee(&mut state, to_pool); } let mut ttcount = state.total_count(); - ttcount.diamond_insc_burn_zhu += to_burn_zhu; + ttcount.diamond_insc_burn_zhu += if staking_is_active_at_height(&state, pdhei) { + to_burn_zhu + } else { + pay_zhu + }; state.set_total_count(&ttcount); drop(state); diff --git a/src/mint/checker/initialize.rs b/src/mint/checker/initialize.rs index 638ca5f..6d66dfa 100644 --- a/src/mint/checker/initialize.rs +++ b/src/mint/checker/initialize.rs @@ -1,6 +1,13 @@ fn impl_initialize(this: &BlockMintChecker, db: &mut dyn State) -> RetErr { - + + { + let mut mint_state = crate::mint::state::MintState::wrap(db); + let mut global = mint_state.staking_global(); + global.activation_height = BlockHeight::from(this.cnf.staking_activation_height); + mint_state.set_staking_global(&global); + } + let addr1 = Address::from_readable("12vi7DEZjh6KrK5PVmmqSgvuJPCsZMmpfi").unwrap(); let addr2 = Address::from_readable("1LsQLqkd8FQDh3R7ZhxC5fndNf92WfhM19").unwrap(); let addr3 = Address::from_readable("1NUgKsTgM6vQ5nxFHGz1C4METaYTPgiihh").unwrap(); diff --git a/src/mint/component/staking.rs b/src/mint/component/staking.rs index b28bfaf..3b6df35 100644 --- a/src/mint/component/staking.rs +++ b/src/mint/component/staking.rs @@ -41,13 +41,13 @@ pub fn diamond_status_is_staking_locked(status: &Uint1) -> bool { pub fn staking_status_label(status: &Uint1) -> &'static str { if *status == DIAMOND_STATUS_STAKED { - return "staked"; + return "Staked"; } if *status == DIAMOND_STATUS_STAKING_COOLDOWN { - return "cooldown"; + return "Cooldown"; } if *status == DIAMOND_STATUS_NORMAL { - return "available"; + return "Available"; } "unknown" } @@ -63,12 +63,17 @@ StructFieldStruct!(GlobalStakingState, paused : Uint1 unlock_queue_head : Uint5 unlock_queue_tail : Uint5 + activation_height : BlockHeight ); impl GlobalStakingState { pub fn is_paused(&self) -> bool { self.paused.uint() != 0 } + + pub fn is_active_at(&self, height: u64) -> bool { + height >= self.activation_height.uint() + } } /** diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs index 1e89a4b..ead9a7a 100644 --- a/src/mint/operate/staking.rs +++ b/src/mint/operate/staking.rs @@ -11,6 +11,22 @@ pub fn staking_accrued_amount(global_index: &Uint8, snapshot: &Uint8) -> Ret Ret { + if record.is_active_stake() { + staking_accrued_amount(global_index, &record.reward_index) + } else { + Ok(record.pending_reward.clone()) + } +} + +pub fn staking_is_active_at_height(state: &MintState, height: u64) -> bool { + state.staking_global().is_active_at(height) +} + /// Returns true if tx contains a HACD transfer action (kinds 5–8). pub fn tx_contains_diamond_transfer(tx: &dyn TransactionRead) -> bool { for act in tx.actions() { @@ -28,6 +44,21 @@ pub fn staking_redirect_fee_zhu(fee_zhu: u64) -> (u64, u64) { (to_pool, to_burn) } +/// HIP-25: 40% of total transfer fee → pool; remainder follows burn/miner split. +pub fn staking_split_transfer_tx_fee(total: &Amount, burn_90: bool) -> Ret<(u64, Amount)> { + let total_zhu = total.to_zhu_unsafe() as u64; + let (to_pool, remainder_zhu) = staking_redirect_fee_zhu(total_zhu); + let mut miner = if remainder_zhu > 0 { + Amount::from_zhu(remainder_zhu as i64)? + } else { + Amount::default() + }; + if burn_90 && miner.unit() > 1 { + miner.unit_sub(1); + } + Ok((to_pool, miner)) +} + pub fn staking_deposit_fee(state: &mut MintState, fee_zhu: u64) { if fee_zhu == 0 { return; @@ -136,6 +167,11 @@ pub fn staking_process_unlock_queue(base_state: &mut dyn State, height: u64) -> } pub fn staking_on_block_close(base_state: &mut dyn State, height: u64) -> Ret<()> { + let mint_state = MintState::wrap(base_state); + if !staking_is_active_at_height(&mint_state, height) { + return Ok(()); + } + drop(mint_state); { let mut mint_state = MintState::wrap(base_state); staking_distribute_rewards(&mut mint_state)?; @@ -212,6 +248,9 @@ pub fn staking_apply_stake( ) -> Ret<()> { diamonds.check()?; let mut global = state.staking_global(); + if !global.is_active_at(height) { + return errf!("HACD staking is not active at height {}", height); + } if global.is_paused() { return errf!("HACD staking is paused"); } @@ -494,6 +533,74 @@ mod staking_tests { assert!(format!("{}", err).contains("200")); } + #[test] + fn transfer_fee_redirect_uses_total_fee_not_fee_got() { + let total = Amount::from_zhu(1000).unwrap(); + let (pool, miner) = staking_split_transfer_tx_fee(&total, false).unwrap(); + assert_eq!(pool, 400); + assert_eq!(miner.to_zhu_unsafe() as u64, 600); + } + + #[test] + fn transfer_fee_redirect_applies_burn_90_on_remainder() { + let total = Amount::from_zhu(1000).unwrap(); + let (pool, miner) = staking_split_transfer_tx_fee(&total, true).unwrap(); + assert_eq!(pool, 400); + assert_eq!(miner.to_zhu_unsafe() as u64, 60); + } + + #[test] + fn cooldown_display_reward_uses_pending_not_live_index() { + let (_dir, mut state) = test_state(); + let s1 = test_staker(); + let s2 = test_other(); + seed_diamond(&mut state, "WTYUIA", &s1); + seed_diamond(&mut state, "HXVMEK", &s2); + let list1 = one_diamond_list("WTYUIA"); + let stake_h = 1000u64; + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &s1, &list1, stake_h).unwrap(); + staking_apply_stake(&mut mint, &s2, &one_diamond_list("HXVMEK"), stake_h).unwrap(); + staking_deposit_fee(&mut mint, 1000); + staking_distribute_rewards(&mut mint).unwrap(); + let pending_at_unstake = staking_accrued_amount( + &mint.staking_global().global_reward_index, + &mint.staking_record(&lit(b"WTYUIA")).unwrap().reward_index, + ) + .unwrap(); + let unstake_h = stake_h + MIN_STAKE_BLOCKS; + staking_apply_unstake(&mut mint, &s1, &list1, unstake_h).unwrap(); + staking_deposit_fee(&mut mint, 2000); + staking_distribute_rewards(&mut mint).unwrap(); + let rec = mint.staking_record(&lit(b"WTYUIA")).unwrap(); + let displayed = staking_display_accrued_reward( + &mint.staking_global().global_reward_index, + &rec, + ) + .unwrap(); + assert_eq!(displayed, pending_at_unstake); + let live = staking_accrued_amount( + &mint.staking_global().global_reward_index, + &rec.reward_index, + ) + .unwrap(); + assert!(live > displayed); + } + + #[test] + fn stake_before_activation_height_rejected() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + let mut global = mint.staking_global(); + global.activation_height = BlockHeight::from(5000); + mint.set_staking_global(&global); + let err = staking_apply_stake(&mut mint, &staker, &list, 1000).unwrap_err(); + assert!(format!("{}", err).contains("not active")); + } + #[test] fn hvm_opcode_stake_and_unstake_via_bridge() { let (_dir, mut state) = test_state(); diff --git a/src/server/rpc/staking.rs b/src/server/rpc/staking.rs index 42c751f..08c16c3 100644 --- a/src/server/rpc/staking.rs +++ b/src/server/rpc/staking.rs @@ -1,6 +1,6 @@ use crate::mint::component::*; -use crate::mint::operate::staking_accrued_amount; +use crate::mint::operate::staking_display_accrued_reward; defineQueryObject!{ QStakingStatus, diamond, String, s!(""), @@ -31,7 +31,7 @@ async fn staking_status(State(ctx): State, q: Query) -> if stake_height > 0 { min_unstake_height = stake_height + MIN_STAKE_BLOCKS; } - if let Ok(amt) = staking_accrued_amount(&global.global_reward_index, &rec.reward_index) { + if let Ok(amt) = staking_display_accrued_reward(&global.global_reward_index, &rec) { accrued_reward = amt.to_unit_string(&unit); } } @@ -81,7 +81,7 @@ async fn staking_summary(State(ctx): State, q: Query) - cooldown_count += 1; } if let Some(rec) = mintstate.staking_record(&dian) { - if let Ok(amt) = staking_accrued_amount(&global.global_reward_index, &rec.reward_index) { + if let Ok(amt) = staking_display_accrued_reward(&global.global_reward_index, &rec) { total_accrued = total_accrued.add(&amt).unwrap_or(total_accrued); } } @@ -105,6 +105,7 @@ async fn staking_global(State(ctx): State, _q: Query) -> "total_staked_shares", global.total_staked_shares.uint(), "reward_pool_pending_zhu", global.reward_pool_zhu.uint(), "global_reward_index", global.global_reward_index.uint(), + "activation_height", global.activation_height.uint(), "paused", global.is_paused(), }; api_data(data) From c20afb320979de8c88556061cd6843cabf331ae3 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 15 Jun 2026 22:22:44 +0200 Subject: [PATCH 08/35] feat(HIP-25): events, HVM script path, fee share 22% --- hacash.config.ini.example | 3 +- src/chain/execute/insert.rs | 2 +- src/mint/action/diamond_insc.rs | 2 +- src/mint/component/staking.rs | 41 ++++++++++- src/mint/operate/staking.rs | 122 ++++++++++++++++++++++++++++---- src/mint/state/def.rs | 1 + src/protocol/action/script.rs | 22 +++--- src/server/rpc/routes.rs | 1 + src/server/rpc/staking.rs | 55 ++++++++++++++ src/vm/staking_hvm.rs | 15 ++++ 10 files changed, 236 insertions(+), 28 deletions(-) diff --git a/hacash.config.ini.example b/hacash.config.ini.example index 4f0dd7b..a14d17a 100644 --- a/hacash.config.ini.example +++ b/hacash.config.ini.example @@ -15,7 +15,8 @@ boots = [mint] chain_id = 1 -; HIP-25 soft-fork: staking rules apply from this block height (1 = active from genesis on dev) +; HIP-25 soft-fork activation height (consensus parameter, written to chain at genesis init). +; Dev/testnet: 1 (active from first block). Mainnet: set announced height (30-day timelock). staking_activation_height = 1 [miner] diff --git a/src/chain/execute/insert.rs b/src/chain/execute/insert.rs index 5eb4541..6b61192 100644 --- a/src/chain/execute/insert.rs +++ b/src/chain/execute/insert.rs @@ -124,7 +124,7 @@ pub fn do_check_insert( if execn > 0 { // except coinbase tx exec_tx_actions(!not_fast_sync, cnf.chain_id, height, blkhash, &mut sub_state, store, tx.as_read())?; let fee = tx.fee_got(); - // HIP-25: redirect 40% of total HACD transfer tx fees to staking pool + // HIP-25: redirect 22% of total HACD transfer tx fees to staking pool if crate::mint::operate::tx_contains_diamond_transfer(tx.as_read()) { let mut ms = crate::mint::state::MintState::wrap(&mut sub_state); if crate::mint::operate::staking_is_active_at_height(&ms, height) { diff --git a/src/mint/action/diamond_insc.rs b/src/mint/action/diamond_insc.rs index f72ad17..6f77f90 100644 --- a/src/mint/action/diamond_insc.rs +++ b/src/mint/action/diamond_insc.rs @@ -63,7 +63,7 @@ fn diamond_inscription(this: &DiamondInscription, ctx: &dyn ExecContext, sta: &m return errf!("diamond inscription cost error need {} but got {}", ttcost.to_fin_string(), pcost.to_fin_string()) } - // change count + HIP-25 fee redirect (40% protocol fee → staking pool) + // change count + HIP-25 fee redirect (22% protocol fee → staking pool) let pay_zhu = pcost.to_zhu_unsafe() as u64; let (to_pool, to_burn_zhu) = staking_redirect_fee_zhu(pay_zhu); if to_pool > 0 && staking_is_active_at_height(&state, pdhei) { diff --git a/src/mint/component/staking.rs b/src/mint/component/staking.rs index 3b6df35..0b76b4b 100644 --- a/src/mint/component/staking.rs +++ b/src/mint/component/staking.rs @@ -6,8 +6,8 @@ * See HIP-25 for the full protocol specification. */ -/// 40% of eligible inscription protocol fees and transfer fees → reward pool -pub const STAKING_FEE_SHARE_PERCENT: u64 = 40; +/// 22% of eligible inscription protocol fees and transfer fees → reward pool +pub const STAKING_FEE_SHARE_PERCENT: u64 = 22; /// ~3 days cooldown after unstake (1000 blocks ≈ 3.5 days per HIP-15) pub const COOLDOWN_BLOCKS: u64 = 864; @@ -21,6 +21,12 @@ pub const STAKE_HACD_VMKIND: u8 = 0x01; /// HVM external action opcode (HIP-21) pub const UNSTAKE_HACD_VMKIND: u8 = 0x02; +/// On-chain staking event kinds (HIP-25 Events section) +pub const STAKING_EVENT_STAKED: Uint1 = Uint1::from(1); +pub const STAKING_EVENT_UNSTAKE_REQUESTED: Uint1 = Uint1::from(2); +pub const STAKING_EVENT_UNSTAKED: Uint1 = Uint1::from(3); +pub const STAKING_EVENT_REWARD_DISTRIBUTED: Uint1 = Uint1::from(4); + /// Diamond is locked and earning rewards pub const DIAMOND_STATUS_STAKED: Uint1 = Uint1::from(4); @@ -52,6 +58,22 @@ pub fn staking_status_label(status: &Uint1) -> &'static str { "unknown" } +pub fn staking_event_kind_label(kind: &Uint1) -> &'static str { + if *kind == STAKING_EVENT_STAKED { + return "Staked"; + } + if *kind == STAKING_EVENT_UNSTAKE_REQUESTED { + return "UnstakeRequested"; + } + if *kind == STAKING_EVENT_UNSTAKED { + return "Unstaked"; + } + if *kind == STAKING_EVENT_REWARD_DISTRIBUTED { + return "RewardDistributed"; + } + "unknown" +} + /** * Global staking pool and reward index. * Singleton key: &[2, 3] in MintState (see state/def.rs). @@ -64,6 +86,7 @@ StructFieldStruct!(GlobalStakingState, unlock_queue_head : Uint5 unlock_queue_tail : Uint5 activation_height : BlockHeight + event_log_tail : Uint5 ); impl GlobalStakingState { @@ -104,4 +127,18 @@ StructFieldStruct!(StakingUnlockEntry, diamond : DiamondName staker : Address reward : Amount +); + +/** + * Append-only HIP-25 event log entry. + * Keyed by monotonic Uint5 id in MintState (see state/def.rs). + */ +StructFieldStruct!(StakingEvent, + kind : Uint1 + height : BlockHeight + diamond : DiamondName + staker : Address + unlock_height : BlockHeight + reward : Amount + shares : Uint5 ); \ No newline at end of file diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs index ead9a7a..f83b690 100644 --- a/src/mint/operate/staking.rs +++ b/src/mint/operate/staking.rs @@ -44,7 +44,7 @@ pub fn staking_redirect_fee_zhu(fee_zhu: u64) -> (u64, u64) { (to_pool, to_burn) } -/// HIP-25: 40% of total transfer fee → pool; remainder follows burn/miner split. +/// HIP-25: 22% of total transfer fee → pool; remainder follows burn/miner split. pub fn staking_split_transfer_tx_fee(total: &Amount, burn_90: bool) -> Ret<(u64, Amount)> { let total_zhu = total.to_zhu_unsafe() as u64; let (to_pool, remainder_zhu) = staking_redirect_fee_zhu(total_zhu); @@ -59,6 +59,14 @@ pub fn staking_split_transfer_tx_fee(total: &Amount, burn_90: bool) -> Ret<(u64, Ok((to_pool, miner)) } +fn staking_push_event(state: &mut MintState, event: &StakingEvent) { + let mut global = state.staking_global(); + let id = global.event_log_tail.uint(); + state.set_staking_event(&Uint5::from(id), event); + global.event_log_tail = Uint5::from(id + 1); + state.set_staking_global(&global); +} + pub fn staking_deposit_fee(state: &mut MintState, fee_zhu: u64) { if fee_zhu == 0 { return; @@ -68,7 +76,7 @@ pub fn staking_deposit_fee(state: &mut MintState, fee_zhu: u64) { state.set_staking_global(&global); } -pub fn staking_distribute_rewards(state: &mut MintState) -> Ret<()> { +pub fn staking_distribute_rewards(state: &mut MintState, height: u64) -> Ret<()> { let mut global = state.staking_global(); let shares = global.total_staked_shares.uint(); let pool = global.reward_pool_zhu.uint(); @@ -88,6 +96,21 @@ pub fn staking_distribute_rewards(state: &mut MintState) -> Ret<()> { global.reward_pool_zhu = Uint8::from(pool - distributed); } state.set_staking_global(&global); + if distributed > 0 { + let reward = Amount::from_zhu(distributed as i64).unwrap_or_default(); + staking_push_event( + state, + &StakingEvent { + kind: STAKING_EVENT_REWARD_DISTRIBUTED, + height: BlockHeight::from(height), + diamond: DiamondName::default(), + staker: Address::default(), + unlock_height: BlockHeight::from(0), + reward, + shares: Uint5::from(shares), + }, + ); + } Ok(()) } @@ -117,6 +140,19 @@ fn staking_finalize_unlock(mint_state: &mut MintState, entry: &StakingUnlockEntr mint_state.set_diamond(dianame, &diaitem); mint_state.del_staking_record(dianame); + staking_push_event( + mint_state, + &StakingEvent { + kind: STAKING_EVENT_UNSTAKED, + height: entry.unlock_height.clone(), + diamond: entry.diamond.clone(), + staker: entry.staker.clone(), + unlock_height: entry.unlock_height.clone(), + reward: entry.reward.clone(), + shares: Uint5::from(0), + }, + ); + Ok(()) } @@ -174,7 +210,7 @@ pub fn staking_on_block_close(base_state: &mut dyn State, height: u64) -> Ret<() drop(mint_state); { let mut mint_state = MintState::wrap(base_state); - staking_distribute_rewards(&mut mint_state)?; + staking_distribute_rewards(&mut mint_state, height)?; } staking_process_unlock_queue(base_state, height)?; Ok(()) @@ -270,9 +306,24 @@ pub fn staking_apply_stake( state.set_staking_record(&dianame, &record); global.total_staked_shares = Uint5::from(global.total_staked_shares.uint() + 1); + + staking_push_event( + state, + &StakingEvent { + kind: STAKING_EVENT_STAKED, + height: BlockHeight::from(height), + diamond: dianame.clone(), + staker: staker.clone(), + unlock_height: BlockHeight::from(0), + reward: Amount::default(), + shares: global.total_staked_shares.clone(), + }, + ); } - state.set_staking_global(&global); + let mut final_global = state.staking_global(); + final_global.total_staked_shares = global.total_staked_shares; + state.set_staking_global(&final_global); Ok(()) } @@ -341,6 +392,19 @@ pub fn staking_apply_unstake( reward, }; staking_enqueue_unlock(state, &entry)?; + + staking_push_event( + state, + &StakingEvent { + kind: STAKING_EVENT_UNSTAKE_REQUESTED, + height: BlockHeight::from(height), + diamond: dianame.clone(), + staker: staker.clone(), + unlock_height: BlockHeight::from(unlock_height), + reward: entry.reward.clone(), + shares: Uint5::from(0), + }, + ); } Ok(()) @@ -399,10 +463,10 @@ mod staking_tests { } #[test] - fn fee_redirect_splits_40_60() { + fn fee_redirect_splits_22_78() { let (pool, burn) = staking_redirect_fee_zhu(1000); - assert_eq!(pool, 400); - assert_eq!(burn, 600); + assert_eq!(pool, 220); + assert_eq!(burn, 780); } #[test] @@ -462,7 +526,7 @@ mod staking_tests { let mut mint = MintState::wrap(&mut state); staking_apply_stake(&mut mint, &staker, &list, stake_h).unwrap(); staking_deposit_fee(&mut mint, 1000); - staking_distribute_rewards(&mut mint).unwrap(); + staking_distribute_rewards(&mut mint, stake_h).unwrap(); let unstake_h = stake_h + MIN_STAKE_BLOCKS; staking_apply_unstake(&mut mint, &staker, &list, unstake_h).unwrap(); let unlock_h = unstake_h + COOLDOWN_BLOCKS; @@ -486,7 +550,7 @@ mod staking_tests { staking_apply_stake(&mut mint, &s1, &one_diamond_list("WTYUIA"), 100).unwrap(); staking_apply_stake(&mut mint, &s2, &one_diamond_list("HXVMEK"), 100).unwrap(); staking_deposit_fee(&mut mint, 1000); - staking_distribute_rewards(&mut mint).unwrap(); + staking_distribute_rewards(&mut mint, 100).unwrap(); let g = mint.staking_global(); assert_eq!(g.global_reward_index.uint(), 500); let r1 = mint.staking_record(&lit(b"WTYUIA")).unwrap(); @@ -537,16 +601,16 @@ mod staking_tests { fn transfer_fee_redirect_uses_total_fee_not_fee_got() { let total = Amount::from_zhu(1000).unwrap(); let (pool, miner) = staking_split_transfer_tx_fee(&total, false).unwrap(); - assert_eq!(pool, 400); - assert_eq!(miner.to_zhu_unsafe() as u64, 600); + assert_eq!(pool, 220); + assert_eq!(miner.to_zhu_unsafe() as u64, 780); } #[test] fn transfer_fee_redirect_applies_burn_90_on_remainder() { let total = Amount::from_zhu(1000).unwrap(); let (pool, miner) = staking_split_transfer_tx_fee(&total, true).unwrap(); - assert_eq!(pool, 400); - assert_eq!(miner.to_zhu_unsafe() as u64, 60); + assert_eq!(pool, 220); + assert_eq!(miner.to_zhu_unsafe() as u64, 78); } #[test] @@ -562,7 +626,7 @@ mod staking_tests { staking_apply_stake(&mut mint, &s1, &list1, stake_h).unwrap(); staking_apply_stake(&mut mint, &s2, &one_diamond_list("HXVMEK"), stake_h).unwrap(); staking_deposit_fee(&mut mint, 1000); - staking_distribute_rewards(&mut mint).unwrap(); + staking_distribute_rewards(&mut mint, stake_h).unwrap(); let pending_at_unstake = staking_accrued_amount( &mint.staking_global().global_reward_index, &mint.staking_record(&lit(b"WTYUIA")).unwrap().reward_index, @@ -571,7 +635,7 @@ mod staking_tests { let unstake_h = stake_h + MIN_STAKE_BLOCKS; staking_apply_unstake(&mut mint, &s1, &list1, unstake_h).unwrap(); staking_deposit_fee(&mut mint, 2000); - staking_distribute_rewards(&mut mint).unwrap(); + staking_distribute_rewards(&mut mint, unstake_h).unwrap(); let rec = mint.staking_record(&lit(b"WTYUIA")).unwrap(); let displayed = staking_display_accrued_reward( &mint.staking_global().global_reward_index, @@ -601,6 +665,34 @@ mod staking_tests { assert!(format!("{}", err).contains("not active")); } + #[test] + fn stake_emits_staked_on_chain_event() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &staker, &list, 1000).unwrap(); + assert_eq!(mint.staking_global().event_log_tail.uint(), 1); + let ev = mint.staking_event(&Uint5::from(0)).unwrap(); + assert_eq!(ev.kind, STAKING_EVENT_STAKED); + assert_eq!(ev.diamond.readable(), "WTYUIA"); + assert_eq!(ev.staker, staker); + } + + #[test] + fn script_execute_stake_via_hvm_wire() { + use crate::vm::exec_staking_script; + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let mut wire = vec![STAKE_HACD_VMKIND]; + wire.extend(one_diamond_list("WTYUIA").serialize()); + exec_staking_script(&wire, &staker, 5000, &mut state).unwrap(); + let mint = MintStateDisk::wrap(&state); + assert_eq!(mint.diamond(&lit(b"WTYUIA")).unwrap().status, DIAMOND_STATUS_STAKED); + } + #[test] fn hvm_opcode_stake_and_unstake_via_bridge() { let (_dir, mut state) = test_state(); diff --git a/src/mint/state/def.rs b/src/mint/state/def.rs index 3a21271..cfee198 100644 --- a/src/mint/state/def.rs +++ b/src/mint/state/def.rs @@ -31,6 +31,7 @@ defineChainStateOperationInstance!{ &[2, 24], channel , ChannelId , ChannelSto &[2, 25], staking_record , DiamondName , StakingRecord &[2, 26], staking_unlock_entry, Uint5 , StakingUnlockEntry + &[2, 27], staking_event , Uint5 , StakingEvent ) } diff --git a/src/protocol/action/script.rs b/src/protocol/action/script.rs index 9bde636..e6a9467 100644 --- a/src/protocol/action/script.rs +++ b/src/protocol/action/script.rs @@ -16,16 +16,22 @@ (self, ctx, state, store, gas), // params true, // burn 90 [], // req sign - { - errf!("not support") - /* - let addr = Fixed21{ bytes: [0u8; 21] }; - let codes = [74u8,89]; - // ctx.vm()?.main_call(&addr, &codes) - Ok(vec![]) - */ + { + script_execute_staking(self, ctx, state) } } +fn script_execute_staking( + this: &ScriptExecute, + ctx: &dyn ExecContext, + sta: &mut dyn State, +) -> Ret> { + let staker = ctx.main_address(); + let height = ctx.pending_height(); + let codes = this.codes.as_ref(); + vm::exec_staking_script(codes, staker, height, sta)?; + Ok(vec![]) +} + diff --git a/src/server/rpc/routes.rs b/src/server/rpc/routes.rs index 0d9d98e..340a844 100644 --- a/src/server/rpc/routes.rs +++ b/src/server/rpc/routes.rs @@ -31,6 +31,7 @@ pub fn routes(mut ctx: ApiCtx) -> Router { .route(&query("staking/status"), get(staking_status)) .route(&query("staking/summary"), get(staking_summary)) .route(&query("staking/global"), get(staking_global)) + .route(&query("staking/events"), get(staking_events)) .route(&query("fee/average"), get(fee_average)) diff --git a/src/server/rpc/staking.rs b/src/server/rpc/staking.rs index 08c16c3..1e0e25c 100644 --- a/src/server/rpc/staking.rs +++ b/src/server/rpc/staking.rs @@ -106,7 +106,62 @@ async fn staking_global(State(ctx): State, _q: Query) -> "reward_pool_pending_zhu", global.reward_pool_zhu.uint(), "global_reward_index", global.global_reward_index.uint(), "activation_height", global.activation_height.uint(), + "event_count", global.event_log_tail.uint(), "paused", global.is_paused(), }; api_data(data) +} + +defineQueryObject!{ QStakingEvents, + from, String, s!("0"), + limit, String, s!("50"), +} + +async fn staking_events(State(ctx): State, q: Query) -> impl IntoResponse { + ctx_mintstate!(ctx, mintstate); + q_unit!(q, unit); + let from = q.from.parse::().unwrap_or(0); + let mut limit = q.limit.parse::().unwrap_or(50); + if limit == 0 { + limit = 50; + } + if limit > 200 { + limit = 200; + } + let global = mintstate.staking_global(); + let tail = global.event_log_tail.uint(); + let end = (from + limit).min(tail); + let mut items: Vec = Vec::new(); + for id in from..end { + let Some(ev) = mintstate.staking_event(&Uint5::from(id)) else { + continue; + }; + let literal = if ev.diamond.readable().trim().is_empty() { + "".to_string() + } else { + ev.diamond.readable() + }; + let staker = if ev.staker.readable().is_empty() { + "".to_string() + } else { + ev.staker.readable() + }; + items.push(json!({ + "id": id, + "kind": staking_event_kind_label(&ev.kind), + "height": ev.height.uint(), + "literal": literal, + "staker": staker, + "unlock_height": ev.unlock_height.uint(), + "reward": ev.reward.to_unit_string(&unit), + "shares": ev.shares.uint(), + })); + } + let data = jsondata!{ + "from", from, + "limit", limit, + "total", tail, + "events", items, + }; + api_data(data) } \ No newline at end of file diff --git a/src/vm/staking_hvm.rs b/src/vm/staking_hvm.rs index 878bf3a..67f23e5 100644 --- a/src/vm/staking_hvm.rs +++ b/src/vm/staking_hvm.rs @@ -5,6 +5,21 @@ use crate::mint::component::{STAKE_HACD_VMKIND, UNSTAKE_HACD_VMKIND}; use crate::mint::operate::staking_exec_hvm_external; use crate::sys::RetErr; +/// Execute HIP-25 staking bytecode from ScriptExecute `codes`: +/// wire `[opcode][Uint1 count][count×6 literals]` (HVM StakeHacd / UnstakeHacd format). +pub fn exec_staking_script( + codes: &[u8], + staker: &Address, + height: u64, + state: &mut dyn State, +) -> RetErr { + if codes.is_empty() { + return errf!("staking script empty"); + } + let opcode = codes[0]; + exec_staking_hvm_opcode(opcode, &codes[1..], staker, height, state) +} + /// Rust-side entry for HIP-25 HVM opcodes. Full HVM runtime (Go) calls the same Mint hooks. pub fn exec_staking_hvm_opcode( opcode: u8, From b1a14765142d157768ffdf9df917d5de85ed310c Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 15 Jun 2026 22:33:08 +0200 Subject: [PATCH 09/35] chore(HIP-25): reduce staking fee share to 13% for burn/miner balance --- src/chain/execute/insert.rs | 2 +- src/mint/action/diamond_insc.rs | 2 +- src/mint/component/staking.rs | 4 ++-- src/mint/operate/staking.rs | 16 ++++++++-------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/chain/execute/insert.rs b/src/chain/execute/insert.rs index 6b61192..f335939 100644 --- a/src/chain/execute/insert.rs +++ b/src/chain/execute/insert.rs @@ -124,7 +124,7 @@ pub fn do_check_insert( if execn > 0 { // except coinbase tx exec_tx_actions(!not_fast_sync, cnf.chain_id, height, blkhash, &mut sub_state, store, tx.as_read())?; let fee = tx.fee_got(); - // HIP-25: redirect 22% of total HACD transfer tx fees to staking pool + // HIP-25: redirect 13% of total HACD transfer tx fees to staking pool if crate::mint::operate::tx_contains_diamond_transfer(tx.as_read()) { let mut ms = crate::mint::state::MintState::wrap(&mut sub_state); if crate::mint::operate::staking_is_active_at_height(&ms, height) { diff --git a/src/mint/action/diamond_insc.rs b/src/mint/action/diamond_insc.rs index 6f77f90..01b01c8 100644 --- a/src/mint/action/diamond_insc.rs +++ b/src/mint/action/diamond_insc.rs @@ -63,7 +63,7 @@ fn diamond_inscription(this: &DiamondInscription, ctx: &dyn ExecContext, sta: &m return errf!("diamond inscription cost error need {} but got {}", ttcost.to_fin_string(), pcost.to_fin_string()) } - // change count + HIP-25 fee redirect (22% protocol fee → staking pool) + // change count + HIP-25 fee redirect (13% protocol fee → staking pool) let pay_zhu = pcost.to_zhu_unsafe() as u64; let (to_pool, to_burn_zhu) = staking_redirect_fee_zhu(pay_zhu); if to_pool > 0 && staking_is_active_at_height(&state, pdhei) { diff --git a/src/mint/component/staking.rs b/src/mint/component/staking.rs index 0b76b4b..a0bdf57 100644 --- a/src/mint/component/staking.rs +++ b/src/mint/component/staking.rs @@ -6,8 +6,8 @@ * See HIP-25 for the full protocol specification. */ -/// 22% of eligible inscription protocol fees and transfer fees → reward pool -pub const STAKING_FEE_SHARE_PERCENT: u64 = 22; +/// 13% of eligible inscription protocol fees and transfer fees → reward pool +pub const STAKING_FEE_SHARE_PERCENT: u64 = 13; /// ~3 days cooldown after unstake (1000 blocks ≈ 3.5 days per HIP-15) pub const COOLDOWN_BLOCKS: u64 = 864; diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs index f83b690..f0846a2 100644 --- a/src/mint/operate/staking.rs +++ b/src/mint/operate/staking.rs @@ -44,7 +44,7 @@ pub fn staking_redirect_fee_zhu(fee_zhu: u64) -> (u64, u64) { (to_pool, to_burn) } -/// HIP-25: 22% of total transfer fee → pool; remainder follows burn/miner split. +/// HIP-25: 13% of total transfer fee → pool; remainder follows burn/miner split. pub fn staking_split_transfer_tx_fee(total: &Amount, burn_90: bool) -> Ret<(u64, Amount)> { let total_zhu = total.to_zhu_unsafe() as u64; let (to_pool, remainder_zhu) = staking_redirect_fee_zhu(total_zhu); @@ -463,10 +463,10 @@ mod staking_tests { } #[test] - fn fee_redirect_splits_22_78() { + fn fee_redirect_splits_13_87() { let (pool, burn) = staking_redirect_fee_zhu(1000); - assert_eq!(pool, 220); - assert_eq!(burn, 780); + assert_eq!(pool, 130); + assert_eq!(burn, 870); } #[test] @@ -601,16 +601,16 @@ mod staking_tests { fn transfer_fee_redirect_uses_total_fee_not_fee_got() { let total = Amount::from_zhu(1000).unwrap(); let (pool, miner) = staking_split_transfer_tx_fee(&total, false).unwrap(); - assert_eq!(pool, 220); - assert_eq!(miner.to_zhu_unsafe() as u64, 780); + assert_eq!(pool, 130); + assert_eq!(miner.to_zhu_unsafe() as u64, 870); } #[test] fn transfer_fee_redirect_applies_burn_90_on_remainder() { let total = Amount::from_zhu(1000).unwrap(); let (pool, miner) = staking_split_transfer_tx_fee(&total, true).unwrap(); - assert_eq!(pool, 220); - assert_eq!(miner.to_zhu_unsafe() as u64, 78); + assert_eq!(pool, 130); + assert_eq!(miner.to_zhu_unsafe() as u64, 87); } #[test] From 2ff9268c02902cb9647664381b8560b220b42bd8 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 15 Jun 2026 22:45:38 +0200 Subject: [PATCH 10/35] feat(HIP-25): RPC stake/unstake tx build + smoke test script --- scripts/hip25_smoke.ps1 | 33 +++++++++++++++++++++++++++++++++ src/server/ctx/action.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 scripts/hip25_smoke.ps1 diff --git a/scripts/hip25_smoke.ps1 b/scripts/hip25_smoke.ps1 new file mode 100644 index 0000000..77d3e54 --- /dev/null +++ b/scripts/hip25_smoke.ps1 @@ -0,0 +1,33 @@ +# HIP-25 testnet smoke test — run while fullnode listens on 8083 +$Base = "http://127.0.0.1:8083" +$Rqid = "hip25smoke$(Get-Random)" + +function Get-HacQuery($path) { + $uri = "$Base/query/$path" + $(if ($path -match '\?') { "&" } else { "?" }) + "rqid=$Rqid" + Invoke-RestMethod -Uri $uri -TimeoutSec 10 +} + +Write-Host "=== HIP-25 smoke test ===" -ForegroundColor Cyan + +try { + $global = Get-HacQuery "staking/global" + if ($global.ret -ne 0) { throw "staking/global ret=$($global.ret)" } + Write-Host "[OK] staking/global" + Write-Host " activation_height=$($global.activation_height) shares=$($global.total_staked_shares) pool=$($global.reward_pool_pending_zhu) events=$($global.event_count)" + + $latest = Get-HacQuery "latest" + Write-Host "[OK] latest height=$($latest.height)" + + $events = Get-HacQuery "staking/events?from=0&limit=5" + Write-Host "[OK] staking/events total=$($events.total)" + + $addr = "12vi7DEZjh6KrK5PVmmqSgvuJPCsZMmpfi" + $bal = Get-HacQuery "balance?address=$addr" + Write-Host "[OK] balance $addr = $($bal.balance)" + + Write-Host "" + Write-Host "Smoke RPC checks passed." -ForegroundColor Green +} catch { + Write-Host "[FAIL] $_" -ForegroundColor Red + exit 1 +} \ No newline at end of file diff --git a/src/server/ctx/action.rs b/src/server/ctx/action.rs index d57a731..6c3085e 100644 --- a/src/server/ctx/action.rs +++ b/src/server/ctx/action.rs @@ -216,6 +216,14 @@ pub fn action_from_json(main_addr: &Address, jsonv: &serde_json::Value) -> Ret Date: Tue, 16 Jun 2026 00:01:48 +0200 Subject: [PATCH 11/35] feat(HIP-25): testnet seed, live stake E2E, wallet MVP UI - Add hip25_testnet_seed genesis HACD+HAC for dev testnet - Fix load_config subcommand clash (poworker/diaworker) - Add hacash.exe poworker and diaworker subcommands - scripts/hip25_live_stake.ps1: full on-chain stake E2E - Serve wallet MVP at GET /hip25/wallet (portfolio, badges, stake/unstake) --- hacash.config.ini.example | 4 + scripts/hip25_live_stake.ps1 | 201 ++++++++++++++++++ scripts/hip25_smoke.ps1 | 2 + src/config/config.rs | 12 +- src/config/mint.rs | 7 +- src/main.rs | 14 +- src/mint/checker/initialize.rs | 29 ++- src/mint/operate/staking.rs | 9 + src/server/http/start.rs | 1 + src/server/rpc/mod.rs | 1 + src/server/rpc/routes.rs | 4 +- src/server/rpc/wallet_ui.rs | 9 + wallet/hip25/index.html | 375 +++++++++++++++++++++++++++++++++ 13 files changed, 657 insertions(+), 11 deletions(-) create mode 100644 scripts/hip25_live_stake.ps1 create mode 100644 src/server/rpc/wallet_ui.rs create mode 100644 wallet/hip25/index.html diff --git a/hacash.config.ini.example b/hacash.config.ini.example index a14d17a..cd44d9c 100644 --- a/hacash.config.ini.example +++ b/hacash.config.ini.example @@ -5,6 +5,7 @@ data_dir = hacash_hip25_testnet_data [server] enable = true listen = 8083 +; HIP-25 wallet MVP UI (same origin as RPC): http://127.0.0.1:8083/hip25/wallet recent_blocks = false average_fee_purity = false @@ -18,6 +19,9 @@ chain_id = 1 ; HIP-25 soft-fork activation height (consensus parameter, written to chain at genesis init). ; Dev/testnet: 1 (active from first block). Mainnet: set announced height (30-day timelock). staking_activation_height = 1 +; Dev only: seed HACD WTYUIA + 10 HAC to password-derived account at genesis. +hip25_testnet_seed = false +hip25_testnet_seed_password = hip25test [miner] enable = false diff --git a/scripts/hip25_live_stake.ps1 b/scripts/hip25_live_stake.ps1 new file mode 100644 index 0000000..1fdee03 --- /dev/null +++ b/scripts/hip25_live_stake.ps1 @@ -0,0 +1,201 @@ +# HIP-25 live testnet: mine blocks, stake seeded HACD WTYUIA, verify RPC +param( + [string]$Base = "http://127.0.0.1:8083", + [string]$DataDir = "hacash_hip25_live_data", + [string]$SeedPassword = "hip25test", + [string]$SeedAddress = "1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2", + [string]$SeedPrikey = "95f8f5960f8d12471419d76716677cbe1764b628cf11213845b6d917a9f98657", + [int]$MineBlocksBeforeStake = 1, + [int]$MineBlocksAfterStake = 2 +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$BinDir = Join-Path $Root "target\debug" +$Exe = Join-Path $BinDir "hacash.exe" +Set-Location $BinDir + +function Invoke-HacGet($Path, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + return Invoke-RestMethod -Uri $uri -TimeoutSec 30 +} + +function Invoke-HacPost($Path, $Body, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + $bytes = if ($Body -is [byte[]]) { $Body } else { [System.Text.Encoding]::UTF8.GetBytes([string]$Body) } + $req = [System.Net.HttpWebRequest]::Create($uri) + $req.Method = "POST" + $req.ContentType = "application/octet-stream" + $req.ContentLength = $bytes.Length + $stream = $req.GetRequestStream() + $stream.Write($bytes, 0, $bytes.Length) + $stream.Close() + $resp = $req.GetResponse() + $reader = New-Object System.IO.StreamReader($resp.GetResponseStream()) + return ($reader.ReadToEnd() | ConvertFrom-Json) +} + +function Wait-RpcReady() { + for ($i = 0; $i -lt 30; $i++) { + try { + $null = Invoke-HacGet "query/latest" + return + } catch { + Start-Sleep -Seconds 1 + } + } + throw "RPC not ready on $Base" +} + +function Wait-Height($Target) { + for ($i = 0; $i -lt 180; $i++) { + $latest = Invoke-HacGet "query/latest" + if ($latest.height -ge $Target) { return $latest.height } + Start-Sleep -Seconds 2 + } + throw "Timeout waiting for height >= $Target" +} + +Write-Host "=== HIP-25 live stake E2E ===" -ForegroundColor Cyan + +# Fresh chain data +if (Test-Path $DataDir) { + Write-Host "Removing old data dir $DataDir ..." + Remove-Item -Recurse -Force $DataDir +} + +# Sync config beside binary (load_config uses exe directory, not cwd) +$cfg = @" +; HIP-25 live stake run +[default] +data_dir = $DataDir + +[server] +enable = true +listen = 8083 +recent_blocks = false +average_fee_purity = false + +[node] +listen = 3338 +not_find_nodes = true +boots = + +[mint] +chain_id = 1 +staking_activation_height = 1 +hip25_testnet_seed = true +hip25_testnet_seed_password = $SeedPassword + +[miner] +enable = true +reward = $SeedAddress +message = hip25testnet + +[diamondminer] +enable = false +"@ +Set-Content "hacash.config.ini" $cfg +@" +[default] +connect = 127.0.0.1:8083 +supervene = 4 +nonce_max = 4294967295 +notice_wait = 3 +"@ | Set-Content "poworker.config.ini" + +Get-Process hacash -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 1 + +Write-Host "Starting fullnode..." +$node = Start-Process -FilePath $Exe -WorkingDirectory $BinDir -PassThru +Wait-RpcReady +Write-Host "[OK] fullnode RPC ready" + +Write-Host "Starting poworker..." +$miner = Start-Process -FilePath $Exe -ArgumentList "poworker" -WorkingDirectory $BinDir -PassThru +Start-Sleep -Seconds 2 + +try { + Write-Host "Mining $MineBlocksBeforeStake block(s) (genesis init + seed HACD on block 1)..." + Wait-Height $MineBlocksBeforeStake | Out-Null + + # Submit stake before poworker mines block 2 (avoids mempool / miner_notice stall) + $ts = 1718496000 + $txJson = "{`"main_address`":`"$SeedAddress`",`"timestamp`":$ts,`"fee`":`"0:247`",`"actions`":[{`"kind`":34,`"diamonds`":`"WTYUIA`"}]}" + + $built = Invoke-HacPost "create/transaction" $txJson @{ action = "true"; signature = "true" } + if ($built.ret -ne 0) { throw "create/transaction: $($built.err)" } + Write-Host "[OK] built stake tx hash=$($built.hash)" + + $txBytes = [byte[]]::new($built.body.Length / 2) + for ($i = 0; $i -lt $txBytes.Length; $i++) { + $txBytes[$i] = [Convert]::ToByte($built.body.Substring($i * 2, 2), 16) + } + $signed = Invoke-HacPost "util/transaction/sign" $txBytes @{ prikey = $SeedPrikey; signature = "true"; action = "true" } + if ($signed.ret -ne 0) { throw "transaction/sign: $($signed.err)" } + Write-Host "[OK] signed tx" + + $signedBytes = [byte[]]::new($signed.body.Length / 2) + for ($i = 0; $i -lt $signedBytes.Length; $i++) { + $signedBytes[$i] = [Convert]::ToByte($signed.body.Substring($i * 2, 2), 16) + } + $submitted = Invoke-HacPost "submit/transaction" $signedBytes @{} + if ($submitted.ret -ne 0) { throw "submit/transaction: $($submitted.err)" } + Write-Host "[OK] submitted tx hash=$($submitted.hash)" + + $global = Invoke-HacGet "query/staking/global" + if ($global.ret -ne 0) { throw "staking/global failed" } + Write-Host "[OK] activation_height=$($global.activation_height) shares=$($global.total_staked_shares)" + + $bal = Invoke-HacGet "query/balance" @{ address = $SeedAddress; diamonds = "true" } + $entry = if ($bal.list) { $bal.list[0] } else { $null } + Write-Host "[OK] seed balance HAC=$($entry.hacash) diamonds=$($entry.diamonds)" + + Write-Host "Waiting for on-chain stake confirmation..." + $status = $null + $lastHeight = -1 + $stuck = 0 + for ($i = 0; $i -lt 120; $i++) { + try { $null = Invoke-HacGet "query/miner/pending?stuff=true" } catch {} + $latest = Invoke-HacGet "query/latest" + $status = Invoke-HacGet "query/staking/status" @{ diamond = "WTYUIA" } + if ($status.status -eq "Staked") { + Write-Host "[OK] confirmed at height=$($latest.height)" + break + } + if ($latest.height -eq $lastHeight) { + $stuck++ + if ($stuck -ge 8) { + Write-Host "[INFO] mining stalled at height=$lastHeight, restarting poworker..." + Get-Process hacash -ErrorAction SilentlyContinue | + Where-Object { $_.Id -ne $node.Id } | + Stop-Process -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + $miner = Start-Process -FilePath $Exe -ArgumentList "poworker" -WorkingDirectory $BinDir -PassThru + $stuck = 0 + } + } else { + $lastHeight = $latest.height + $stuck = 0 + } + Start-Sleep -Seconds 2 + } + if ($status.ret -ne 0) { throw "staking/status: $($status.err)" } + Write-Host "[OK] WTYUIA status=$($status.status) staker=$($status.staker) stake_height=$($status.stake_height)" + + if ($status.status -ne "Staked") { + throw "Expected status=Staked, got $($status.status)" + } + + $events = Invoke-HacGet "query/staking/events" @{ from = 0; limit = 10 } + Write-Host "[OK] staking events total=$($events.total)" + + Write-Host "" + Write-Host "Live stake E2E passed." -ForegroundColor Green +} finally { + if ($miner -and -not $miner.HasExited) { Stop-Process -Id $miner.Id -Force -ErrorAction SilentlyContinue } + if ($node -and -not $node.HasExited) { Stop-Process -Id $node.Id -Force -ErrorAction SilentlyContinue } +} \ No newline at end of file diff --git a/scripts/hip25_smoke.ps1 b/scripts/hip25_smoke.ps1 index 77d3e54..080bf7d 100644 --- a/scripts/hip25_smoke.ps1 +++ b/scripts/hip25_smoke.ps1 @@ -1,4 +1,6 @@ # HIP-25 testnet smoke test — run while fullnode listens on 8083 +# Note: hacash loads hacash.config.ini from target\debug\ (exe dir). Sync before run: +# Copy-Item ..\hacash.config.ini .\hacash.config.ini $Base = "http://127.0.0.1:8083" $Rqid = "hip25smoke$(Get-Random)" diff --git a/src/config/config.rs b/src/config/config.rs index 2d866e0..4a5e025 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -53,11 +53,17 @@ pub fn load_config(mut cnfilestr: String) -> IniObj { let mut execdir = std::env::current_exe().unwrap().parent().unwrap().to_path_buf(); let mut cnf_file = execdir.join(&cnfilestr); - // cmd args + // cmd args: optional explicit config path (not subcommands like "poworker") let args: Vec = env::args().collect(); if args.len() == 2 { - cnfilestr = args[1].clone(); - cnf_file = PathBuf::from(&cnfilestr); + let arg1 = &args[1]; + let is_config_path = arg1.ends_with(".ini") + || arg1.contains('/') + || arg1.contains('\\'); + if is_config_path { + cnfilestr = arg1.clone(); + cnf_file = PathBuf::from(&cnfilestr); + } } // check exists diff --git a/src/config/mint.rs b/src/config/mint.rs index 938ab90..b30647b 100644 --- a/src/config/mint.rs +++ b/src/config/mint.rs @@ -1,5 +1,5 @@ -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct MintConf { pub chain_id: u64, // sub chain id pub difficulty_adjust_blocks: u64, // height @@ -7,6 +7,9 @@ pub struct MintConf { pub _test_mul: u64, /// HIP-25 soft-fork height; staking rules apply from this block onward. pub staking_activation_height: u64, + /// Dev/testnet: seed one HACD + HAC to a password-derived account at genesis. + pub hip25_testnet_seed: bool, + pub hip25_testnet_seed_password: String, } @@ -24,6 +27,8 @@ impl MintConf { each_block_target_time: ini_must_u64(&sec, "each_block_target_time", 300), // 5 mins _test_mul: ini_must_u64(&sec, "_test_mul", 1), // test staking_activation_height: ini_must_u64(&sec, "staking_activation_height", 1), + hip25_testnet_seed: ini_must_bool(&sec, "hip25_testnet_seed", false), + hip25_testnet_seed_password: ini_must(&sec, "hip25_testnet_seed_password", "hip25test"), }; cnf diff --git a/src/main.rs b/src/main.rs index a4b792a..e607e45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,11 +30,15 @@ use crate::run::*; fn main() { - - // poworker(); // HAC PoW Miner Worker - // diaworker(); // Diamond Miner Worker - fullnode(); // Hacash Full Node - + let args: Vec = std::env::args().collect(); + if args.len() >= 2 { + match args[1].as_str() { + "poworker" => return poworker(), + "diaworker" => return diaworker(), + _ => (), + } + } + fullnode(); } diff --git a/src/mint/checker/initialize.rs b/src/mint/checker/initialize.rs index 6d66dfa..e972377 100644 --- a/src/mint/checker/initialize.rs +++ b/src/mint/checker/initialize.rs @@ -1,13 +1,40 @@ +use crate::core::account::Account; +use crate::mint::operate::diamond_owned_push_one; +use crate::mint::state::MintState; fn impl_initialize(this: &BlockMintChecker, db: &mut dyn State) -> RetErr { { - let mut mint_state = crate::mint::state::MintState::wrap(db); + let mut mint_state = MintState::wrap(db); let mut global = mint_state.staking_global(); global.activation_height = BlockHeight::from(this.cnf.staking_activation_height); mint_state.set_staking_global(&global); } + if this.cnf.hip25_testnet_seed { + let acc = Account::create_by_password(&this.cnf.hip25_testnet_seed_password) + .map_err(|e| e.to_string())?; + let owner = Address::cons(*acc.address()); + let dianame = DiamondName::cons(*b"WTYUIA"); + let dia = DiamondSto { + status: DIAMOND_STATUS_NORMAL, + address: owner.clone(), + prev_engraved_height: BlockHeight::from(0), + inscripts: Inscripts::default(), + }; + let fee_hac = Amount::new_small(11, 244); + let mut mint_state = MintState::wrap(db); + mint_state.set_diamond(&dianame, &dia); + diamond_owned_push_one(&mut mint_state, &owner, &dianame); + let mut core = CoreState::wrap(db); + core.set_balance(&owner, &Balance::hacash(fee_hac)); + println!( + "[HIP-25 testnet seed] HACD WTYUIA + 11 HAC -> {} (password: {})", + owner.readable(), + &this.cnf.hip25_testnet_seed_password + ); + } + let addr1 = Address::from_readable("12vi7DEZjh6KrK5PVmmqSgvuJPCsZMmpfi").unwrap(); let addr2 = Address::from_readable("1LsQLqkd8FQDh3R7ZhxC5fndNf92WfhM19").unwrap(); let addr3 = Address::from_readable("1NUgKsTgM6vQ5nxFHGz1C4METaYTPgiihh").unwrap(); diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs index f0846a2..a338a7d 100644 --- a/src/mint/operate/staking.rs +++ b/src/mint/operate/staking.rs @@ -462,6 +462,15 @@ mod staking_tests { DiamondName::cons(*name) } + #[test] + fn hip25_testnet_seed_password_address() { + use crate::core::account::Account; + let acc = Account::create_by_password("hip25test").unwrap(); + eprintln!("HIP25_TESTNET_ADDRESS={}", acc.readable()); + let prikey = hex::encode(acc.secret_key().serialize()); + eprintln!("HIP25_TESTNET_PRIKEY={}", prikey); + } + #[test] fn fee_redirect_splits_13_87() { let (pool, burn) = staking_redirect_fee_zhu(1000); diff --git a/src/server/http/start.rs b/src/server/http/start.rs index a6720f5..5f3c6d5 100644 --- a/src/server/http/start.rs +++ b/src/server/http/start.rs @@ -31,6 +31,7 @@ async fn server_listen(mut ser: RPCServer) { ser.engine.clone(), ser.hcshnd.clone(), )); + println!("[RPC Server] HIP-25 wallet UI: http://{addr}/hip25/wallet"); if let Err(e) = axum::serve(listener, app).await { println!("{e}"); } diff --git a/src/server/rpc/mod.rs b/src/server/rpc/mod.rs index 61c8b5e..772f44a 100644 --- a/src/server/rpc/mod.rs +++ b/src/server/rpc/mod.rs @@ -68,6 +68,7 @@ include!("miner.rs"); include!("diamond_miner.rs"); include!("staking.rs"); +include!("wallet_ui.rs"); diff --git a/src/server/rpc/routes.rs b/src/server/rpc/routes.rs index 340a844..6b40788 100644 --- a/src/server/rpc/routes.rs +++ b/src/server/rpc/routes.rs @@ -4,7 +4,9 @@ pub fn routes(mut ctx: ApiCtx) -> Router { use ctx::*; - let lrt = Router::new().route("/", get(console)) + let lrt = Router::new() + .route("/hip25/wallet", get(hip25_wallet_page)) + .route("/", get(console)) // query .route(&query("latest"), get(latest)) diff --git a/src/server/rpc/wallet_ui.rs b/src/server/rpc/wallet_ui.rs new file mode 100644 index 0000000..654fb25 --- /dev/null +++ b/src/server/rpc/wallet_ui.rs @@ -0,0 +1,9 @@ + +const HIP25_WALLET_HTML: &str = include_str!("../../../wallet/hip25/index.html"); + +async fn hip25_wallet_page() -> impl IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], + HIP25_WALLET_HTML, + ) +} \ No newline at end of file diff --git a/wallet/hip25/index.html b/wallet/hip25/index.html new file mode 100644 index 0000000..cef449c --- /dev/null +++ b/wallet/hip25/index.html @@ -0,0 +1,375 @@ + + + + + + HIP-25 HACD Staking Wallet + + + +
+

HIP-25 HACD Staking

+ Wallet MVP +
+
+
+

Connection

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+

Summary

+
+
HAC balance
+
Staked HACD
+
In cooldown
+
Accrued rewards
+
Chain height
+
Pool (zhu)
+
+
+ +
+

Your HACD

+
+ + + + +
+
+
Load your wallet to see HACD and staking badges.
+
+
+ +
+

Activity log

+
+
+
+ + + \ No newline at end of file From 88d844fa7e2355e4b76de403b3524ca4caaa8908 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Tue, 16 Jun 2026 00:15:40 +0200 Subject: [PATCH 12/35] HIP-25: multi-HACD testnet seed, WASM hacd_stake/unstake, wallet + boot docs - Seed 5 HACD (WTYUIA,HXVMEK,VMEKBS,UIASHX,MEKUIA) for wallet label demos - Fix and extend WASM SDK transfer.rs with hacd_stake/hacd_unstake (kinds 34/35) - Add sdk_lib.rs + Cargo feature sdk for wasm32 builds - Wallet: testnet preset, RPC/WASM tx modes, badge UI unchanged - docs/hip25_testnet_boot.md + scripts/hip25_wallet_e2e.ps1 (verified E2E) --- Cargo.toml | 15 +- docs/hip25_testnet_boot.md | 109 +++++++++ hacash.config.ini.example | 3 +- scripts/hip25_live_stake.ps1 | 3 + scripts/hip25_wallet_e2e.ps1 | 168 +++++++++++++ src/mint/checker/initialize.rs | 25 +- src/sdk/mod.rs | 2 +- src/sdk/web/mod.rs | 11 +- src/sdk/web/transfer.rs | 431 +++++++++++++++++++-------------- src/sdk_lib.rs | 20 ++ wallet/hip25/index.html | 91 ++++++- 11 files changed, 668 insertions(+), 210 deletions(-) create mode 100644 docs/hip25_testnet_boot.md create mode 100644 scripts/hip25_wallet_e2e.ps1 create mode 100644 src/sdk_lib.rs diff --git a/Cargo.toml b/Cargo.toml index 6ff16c4..1cf2a75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,16 +3,21 @@ name = "hacash" version = "0.1.0" edition = "2021" +[features] +default = [] +sdk = ["wasm-bindgen"] + [package.metadata.wasm-pack.profile.release] wasm-opt = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -# [lib] -# name = "hacash_sdk" -# # version = "0.1.0" -# crate-type = ["staticlib", "cdylib"] +[lib] +name = "hacash_sdk" +path = "src/sdk_lib.rs" +crate-type = ["cdylib"] +required-features = ["sdk"] [build-dependencies] @@ -48,6 +53,7 @@ axum = "0.7.5" spmc = "0.3.0" termsize = "0.1.9" reqwest = { version = "0.12.5", features = ["blocking"] } +wasm-bindgen = { version = "0.2.87", optional = true } [dev-dependencies] tempfile = "3.10.1" @@ -57,7 +63,6 @@ tempfile = "3.10.1" # leveldb-sys = "2.0.9" # rusty-leveldb = "3.0.0" # leveldb = "0.8.6" -# wasm-bindgen = "0.2.87" diff --git a/docs/hip25_testnet_boot.md b/docs/hip25_testnet_boot.md new file mode 100644 index 0000000..8f74a61 --- /dev/null +++ b/docs/hip25_testnet_boot.md @@ -0,0 +1,109 @@ +# HIP-25 Public Testnet — Boot Instructions + +Local dev/testnet for **Pure HACD Staking** (HIP-25). Not a public internet testnet; run on your machine and open the wallet in a browser. + +## Prerequisites + +- Windows or Linux +- Rust toolchain (`cargo`, `rustc`) +- Built `hacash` binary: `cargo build` from repo root + +Config is loaded from the **directory containing `hacash.exe`**, typically `target/debug/`. + +## Quick start + +```powershell +cd target\debug +copy ..\..\hacash.config.ini.example hacash.config.ini +# Edit hacash.config.ini: hip25_testnet_seed = true, miner enable = true (see example) +.\hacash.exe +``` + +In a second terminal (same `target\debug` folder): + +```powershell +.\hacash.exe poworker +``` + +`poworker.config.ini` must contain `connect = 127.0.0.1:8083`. + +## Seeded test account (dev only) + +When `hip25_testnet_seed = true` in `[mint]`, block **1** seeds: + +| Field | Value | +|-------|-------| +| Password | `hip25test` | +| Address | `1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2` | +| Private key (hex) | `95f8f5960f8d12471419d76716677cbe1764b628cf11213845b6d917a9f98657` | +| HAC | `11` (11:244) | +| HACD | `WTYUIA`, `HXVMEK`, `VMEKBS`, `UIASHX`, `MEKUIA` | + +**Never use this key on mainnet.** + +## Wallet UI + +Open: **http://127.0.0.1:8083/hip25/wallet** + +1. Click **Fill HIP-25 testnet seed** +2. **Load portfolio** — five HACD with badges (`Available` / `Staked` / `Cooldown`) +3. Select diamonds → **Stake selected** or **Unstake selected** + +Tx modes: + +- **RPC** — `create/transaction` + `util/transaction/sign` + `submit/transaction` (works out of the box) +- **WASM SDK** — `hacd_stake` / `hacd_unstake` (requires built `pkg/hacash_sdk.js`; see below) + +## WASM SDK (optional) + +Build (Linux/macOS or Windows with `wasm32-unknown-unknown`): + +```bash +rustup target add wasm32-unknown-unknown +cargo build --release --features sdk --target wasm32-unknown-unknown --lib +# Or: wasm-pack build --target web --features sdk +``` + +Exported functions: + +- `hacd_stake(chain_id, password, "WTYUIA,HXVMEK", fee, timestamp)` → signed tx JSON +- `hacd_unstake(chain_id, password, diamonds, fee, timestamp)` → signed tx JSON + +Submit `tx_body` hex via `POST /submit/transaction`. + +## Automated E2E + +From repo root: + +```powershell +.\scripts\hip25_smoke.ps1 +.\scripts\hip25_live_stake.ps1 +.\scripts\hip25_wallet_e2e.ps1 +``` + +## Staking RPC + +| Endpoint | Purpose | +|----------|---------| +| `GET /query/staking/status?diamond=WTYUIA` | Per-HACD status label | +| `GET /query/staking/summary?address=…` | Portfolio counts | +| `GET /query/staking/global` | Pool / activation height | +| `GET /query/staking/events?from=0&limit=20` | Stake/unstake events | + +Actions: **34** stake, **35** unstake. + +## Consensus parameters (v1) + +- Fee share to staking pool: **13%** +- `MIN_STAKE_BLOCKS = 25714` +- `COOLDOWN_BLOCKS = 864` +- `staking_activation_height` in `[mint]` (testnet: `1`) + +## One fullnode per data dir + +LevelDB locks the data directory. Stop other `hacash.exe` instances before starting a fresh testnet. + +## Reference + +- HIP: `hacash-hip25/HIP/HIP-25_Pure_HACD_Staking.md` +- Branch: `hip-25-staking` on https://github.com/Moskyera/rust \ No newline at end of file diff --git a/hacash.config.ini.example b/hacash.config.ini.example index cd44d9c..cd4a5d8 100644 --- a/hacash.config.ini.example +++ b/hacash.config.ini.example @@ -19,7 +19,8 @@ chain_id = 1 ; HIP-25 soft-fork activation height (consensus parameter, written to chain at genesis init). ; Dev/testnet: 1 (active from first block). Mainnet: set announced height (30-day timelock). staking_activation_height = 1 -; Dev only: seed HACD WTYUIA + 10 HAC to password-derived account at genesis. +; Dev only: seed 5 HACD + 11 HAC to password-derived account at block 1 init. +; HACD: WTYUIA,HXVMEK,VMEKBS,UIASHX,MEKUIA — password hip25test → 1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2 hip25_testnet_seed = false hip25_testnet_seed_password = hip25test diff --git a/scripts/hip25_live_stake.ps1 b/scripts/hip25_live_stake.ps1 index 1fdee03..a2839ee 100644 --- a/scripts/hip25_live_stake.ps1 +++ b/scripts/hip25_live_stake.ps1 @@ -153,6 +153,9 @@ try { $bal = Invoke-HacGet "query/balance" @{ address = $SeedAddress; diamonds = "true" } $entry = if ($bal.list) { $bal.list[0] } else { $null } Write-Host "[OK] seed balance HAC=$($entry.hacash) diamonds=$($entry.diamonds)" + if ($entry.diamonds.Length -lt 30) { + throw "Expected 5 seeded HACD (30 chars), got: $($entry.diamonds)" + } Write-Host "Waiting for on-chain stake confirmation..." $status = $null diff --git a/scripts/hip25_wallet_e2e.ps1 b/scripts/hip25_wallet_e2e.ps1 new file mode 100644 index 0000000..0739415 --- /dev/null +++ b/scripts/hip25_wallet_e2e.ps1 @@ -0,0 +1,168 @@ +# HIP-25 wallet E2E: verify seeded HACD list + stake one diamond + badge labels +param( + [string]$Base = "http://127.0.0.1:8083", + [string]$DataDir = "hacash_hip25_wallet_data", + [string]$SeedPassword = "hip25test", + [string]$SeedAddress = "1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2", + [string]$SeedPrikey = "95f8f5960f8d12471419d76716677cbe1764b628cf11213845b6d917a9f98657", + [string]$StakeDiamond = "HXVMEK" +) + +$ErrorActionPreference = "Stop" +$ExpectedDiamonds = @("WTYUIA", "HXVMEK", "VMEKBS", "UIASHX", "MEKUIA") +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$BinDir = Join-Path $Root "target\debug" +$Exe = Join-Path $BinDir "hacash.exe" +Set-Location $BinDir + +function Invoke-HacGet($Path, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + return Invoke-RestMethod -Uri $uri -TimeoutSec 30 +} + +function Invoke-HacPost($Path, $Body, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + $bytes = if ($Body -is [byte[]]) { $Body } else { [System.Text.Encoding]::UTF8.GetBytes([string]$Body) } + $req = [System.Net.HttpWebRequest]::Create($uri) + $req.Method = "POST" + $req.ContentType = "application/octet-stream" + $req.ContentLength = $bytes.Length + $stream = $req.GetRequestStream() + $stream.Write($bytes, 0, $bytes.Length) + $stream.Close() + $resp = $req.GetResponse() + $reader = New-Object System.IO.StreamReader($resp.GetResponseStream()) + return ($reader.ReadToEnd() | ConvertFrom-Json) +} + +function Wait-RpcReady() { + for ($i = 0; $i -lt 30; $i++) { + try { $null = Invoke-HacGet "query/latest"; return } catch { Start-Sleep -Seconds 1 } + } + throw "RPC not ready" +} + +function Wait-Height($Target) { + for ($i = 0; $i -lt 120; $i++) { + $latest = Invoke-HacGet "query/latest" + if ($latest.height -ge $Target) { return $latest.height } + Start-Sleep -Seconds 2 + } + throw "Timeout height >= $Target" +} + +Write-Host "=== HIP-25 wallet E2E (5 HACD + labels) ===" -ForegroundColor Cyan + +if (Test-Path $DataDir) { Remove-Item -Recurse -Force $DataDir } + +$cfg = @" +[default] +data_dir = $DataDir +[server] +enable = true +listen = 8083 +recent_blocks = false +average_fee_purity = false +[node] +listen = 3338 +not_find_nodes = true +boots = +[mint] +chain_id = 1 +staking_activation_height = 1 +hip25_testnet_seed = true +hip25_testnet_seed_password = $SeedPassword +[miner] +enable = true +reward = $SeedAddress +message = hip25wallet +[diamondminer] +enable = false +"@ +Set-Content "hacash.config.ini" $cfg +@" +[default] +connect = 127.0.0.1:8083 +supervene = 4 +nonce_max = 4294967295 +notice_wait = 3 +"@ | Set-Content "poworker.config.ini" + +Get-Process hacash -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 1 + +$node = Start-Process -FilePath $Exe -WorkingDirectory $BinDir -PassThru +Wait-RpcReady +$miner = Start-Process -FilePath $Exe -ArgumentList "poworker" -WorkingDirectory $BinDir -PassThru +Start-Sleep -Seconds 2 + +try { + Wait-Height 1 | Out-Null + + $bal = Invoke-HacGet "query/balance" @{ address = $SeedAddress; diamonds = "true" } + $entry = $bal.list[0] + $raw = $entry.diamonds -replace "\s", "" + $found = @() + for ($i = 0; $i -lt $raw.Length; $i += 6) { $found += $raw.Substring($i, 6) } + $found = $found | Sort-Object + $expected = $ExpectedDiamonds | Sort-Object + if (($found -join ",") -ne ($expected -join ",")) { + throw "Expected diamonds $($expected -join ',') but got $($found -join ',')" + } + Write-Host "[OK] balance lists 5 seeded HACD" -ForegroundColor Green + + foreach ($d in $ExpectedDiamonds) { + $st = Invoke-HacGet "query/staking/status" @{ diamond = $d } + if ($st.status -ne "Available") { throw "$d expected Available, got $($st.status)" } + } + Write-Host "[OK] all 5 badges = Available before stake" -ForegroundColor Green + + $ts = 1718496000 + $txJson = "{`"main_address`":`"$SeedAddress`",`"timestamp`":$ts,`"fee`":`"0:247`",`"actions`":[{`"kind`":34,`"diamonds`":`"$StakeDiamond`"}]}" + $built = Invoke-HacPost "create/transaction" $txJson @{ action = "true"; signature = "true" } + if ($built.ret -ne 0) { throw "create/transaction: $($built.err)" } + $txBody = [string]$built.body + $txBytes = [byte[]]::new($txBody.Length / 2) + for ($i = 0; $i -lt $txBytes.Length; $i++) { + $txBytes[$i] = [Convert]::ToByte($txBody.Substring($i * 2, 2), 16) + } + $signed = Invoke-HacPost "util/transaction/sign" $txBytes @{ prikey = $SeedPrikey; signature = "true"; action = "true" } + if ($signed.ret -ne 0) { throw "transaction/sign: $($signed.err)" } + $signedBody = [string]$signed.body + $signedBytes = [byte[]]::new($signedBody.Length / 2) + for ($i = 0; $i -lt $signedBytes.Length; $i++) { + $signedBytes[$i] = [Convert]::ToByte($signedBody.Substring($i * 2, 2), 16) + } + $sub = Invoke-HacPost "submit/transaction" $signedBytes @{} + if ($sub.ret -ne 0) { throw "submit/transaction: $($sub.err)" } + Write-Host "[OK] staked $StakeDiamond tx=$($sub.hash)" -ForegroundColor Green + + $staked = $false + for ($i = 0; $i -lt 90; $i++) { + $st = Invoke-HacGet "query/staking/status" @{ diamond = $StakeDiamond } + if ($st.status -eq "Staked") { $staked = $true; break } + Start-Sleep -Seconds 2 + } + if (-not $staked) { throw "$StakeDiamond not Staked after submit" } + + $summary = Invoke-HacGet "query/staking/summary" @{ address = $SeedAddress } + if ($summary.staked_count -lt 1) { throw "summary staked_count < 1" } + Write-Host "[OK] $StakeDiamond Staked; summary staked=$($summary.staked_count)" -ForegroundColor Green + + $other = $ExpectedDiamonds | Where-Object { $_ -ne $StakeDiamond } | Select-Object -First 1 + $st2 = Invoke-HacGet "query/staking/status" @{ diamond = $other } + if ($st2.status -ne "Available") { throw "$other should stay Available" } + Write-Host "[OK] $other still Available (label isolation)" -ForegroundColor Green + + $wallet = Invoke-WebRequest -Uri "$Base/hip25/wallet" -UseBasicParsing + if ($wallet.StatusCode -ne 200) { throw "wallet page failed" } + Write-Host "[OK] GET /hip25/wallet" -ForegroundColor Green + + Write-Host "" + Write-Host "Wallet E2E passed. Open $Base/hip25/wallet" -ForegroundColor Green +} finally { + if ($miner -and -not $miner.HasExited) { Stop-Process -Id $miner.Id -Force -ErrorAction SilentlyContinue } + if ($node -and -not $node.HasExited) { Stop-Process -Id $node.Id -Force -ErrorAction SilentlyContinue } +} \ No newline at end of file diff --git a/src/mint/checker/initialize.rs b/src/mint/checker/initialize.rs index e972377..05a0a70 100644 --- a/src/mint/checker/initialize.rs +++ b/src/mint/checker/initialize.rs @@ -15,21 +15,26 @@ fn impl_initialize(this: &BlockMintChecker, db: &mut dyn State) -> RetErr { let acc = Account::create_by_password(&this.cnf.hip25_testnet_seed_password) .map_err(|e| e.to_string())?; let owner = Address::cons(*acc.address()); - let dianame = DiamondName::cons(*b"WTYUIA"); - let dia = DiamondSto { - status: DIAMOND_STATUS_NORMAL, - address: owner.clone(), - prev_engraved_height: BlockHeight::from(0), - inscripts: Inscripts::default(), - }; + let seed_diamonds: [&[u8; 6]; 5] = [ + b"WTYUIA", b"HXVMEK", b"VMEKBS", b"UIASHX", b"MEKUIA", + ]; let fee_hac = Amount::new_small(11, 244); let mut mint_state = MintState::wrap(db); - mint_state.set_diamond(&dianame, &dia); - diamond_owned_push_one(&mut mint_state, &owner, &dianame); + for name in seed_diamonds { + let dianame = DiamondName::cons(*name); + let dia = DiamondSto { + status: DIAMOND_STATUS_NORMAL, + address: owner.clone(), + prev_engraved_height: BlockHeight::from(0), + inscripts: Inscripts::default(), + }; + mint_state.set_diamond(&dianame, &dia); + diamond_owned_push_one(&mut mint_state, &owner, &dianame); + } let mut core = CoreState::wrap(db); core.set_balance(&owner, &Balance::hacash(fee_hac)); println!( - "[HIP-25 testnet seed] HACD WTYUIA + 11 HAC -> {} (password: {})", + "[HIP-25 testnet seed] 5 HACD (WTYUIA,HXVMEK,VMEKBS,UIASHX,MEKUIA) + 11 HAC -> {} (password: {})", owner.readable(), &this.cnf.hip25_testnet_seed_password ); diff --git a/src/sdk/mod.rs b/src/sdk/mod.rs index 18705a0..591c432 100644 --- a/src/sdk/mod.rs +++ b/src/sdk/mod.rs @@ -1,3 +1,3 @@ -// pub mod web; +pub mod web; diff --git a/src/sdk/web/mod.rs b/src/sdk/web/mod.rs index f20413d..01c53ce 100644 --- a/src/sdk/web/mod.rs +++ b/src/sdk/web/mod.rs @@ -1,7 +1,7 @@ // We need the trait in scope to use Utc::timestamp(). -use chrono::{TimeZone, Utc, Duration}; +use chrono::{TimeZone, Utc}; use wasm_bindgen::prelude::*; @@ -9,9 +9,9 @@ use crate::core::field_bnk; use crate::core::field_bnk::*; use crate::core::interface::field::*; use crate::core::interface::transaction::*; -use crate::core::protocol::action; -use crate::core::protocol::action::*; -use crate::core::protocol::transaction; +use crate::protocol::action; +use crate::protocol::action::*; +use crate::protocol::transaction; /******** sdk ********/ @@ -30,5 +30,4 @@ macro_rules! or_return { include!{"amount.rs"} include!{"account.rs"} include!{"sign.rs"} -include!{"transfer.rs"} - +include!{"transfer.rs"} \ No newline at end of file diff --git a/src/sdk/web/transfer.rs b/src/sdk/web/transfer.rs index 1a774ac..b61c8a3 100644 --- a/src/sdk/web/transfer.rs +++ b/src/sdk/web/transfer.rs @@ -1,28 +1,86 @@ -static mut API_RETURN_JSON: bool = true; +use chrono::Utc; - - - -/* -#[no_mangle] -pub extern fn trs_test(x: i32) -> i32 { - let mut bts = vec![1,0,5,1,1,1,1,1,1,1]; - bts[1] = x; - if x > 100 { - panic!("error more 100") +use crate::core::account::Account; +use crate::core::field::diamond::DiamondNameListMax200; +use crate::mint::action::{DiamondFromToTransfer, DiamondSingleTransfer, DiamondStake, DiamondUnstake}; +use crate::protocol::action::{ + HacFromToTransfer, HacToTransfer, SatoshiFromToTransfer, SatoshiToTransfer, SubChainID, +}; +use crate::protocol::transaction::TransactionType2; + +fn if_add_chain_id(chain_id: u64, tx: &mut TransactionType2) { + if chain_id > 0 { + let mut act = SubChainID::new(); + act.chain_id = Uint8::from(chain_id); + let _ = tx.push_action(Box::new(act)); } - let mut res = 0; - for v in bts { - res += v; +} + +fn get_time_set(timestamp: i64) -> i64 { + let mut time_set = timestamp; + if time_set <= 0 { + time_set = Utc::now().timestamp(); } - res + 10 + time_set } -*/ +fn parse_diamond_list(diamond_name_list: String) -> Result { + DiamondNameListMax200::from_readable(&diamond_name_list).map_err(|e| e.to_string()) +} +fn stake_tx_json( + tx: &TransactionType2, + dlist: &DiamondNameListMax200, + fee: &Amount, + acc: &Account, + time_set: i64, + action_label: &str, +) -> String { + let ok = format!( + r##""tx_hash":"{}","tx_body":"{}","action":"{}","diamond_count":{},"diamonds":"{}","fee":"{}","main_address":"{}","timestamp":{}"##, + tx.hash().hex(), + hex::encode(tx.serialize()), + action_label, + dlist.count().uint(), + dlist.readable(), + fee.to_fin_string(), + acc.readable(), + time_set + ); + format!("{{{}}}", ok) +} -#[no_mangle] -pub extern fn trs_test(x: i32) -> usize { +fn build_signed_stake_tx( + chain_id: u64, + from_pass: String, + diamond_name_list: String, + fee: String, + timestamp: i64, + stake: bool, +) -> String { + let time_set = get_time_set(timestamp); + let dlist = or_return! { "Diamond Name parse", parse_diamond_list(diamond_name_list) }; + let fee = or_return! { "Fee parse", Amount::from_string_unsafe(&fee) }; + let acc = or_return! { "From Account", Account::create_by(&from_pass) }; + let mut tx = TransactionType2::build(*acc.address(), fee.clone()); + tx.timestamp = Timestamp::from(time_set as u64); + if_add_chain_id(chain_id, &mut tx); + if stake { + let mut act = DiamondStake::new(); + act.diamonds = dlist.clone(); + let _ = tx.push_action(Box::new(act)); + } else { + let mut act = DiamondUnstake::new(); + act.diamonds = dlist.clone(); + let _ = tx.push_action(Box::new(act)); + } + let _ = tx.fill_sign(&acc); + let label = if stake { "stake" } else { "unstake" }; + stake_tx_json(&tx, &dlist, &fee, &acc, time_set, label) +} + +#[wasm_bindgen] +pub fn trs_test(x: i32) -> usize { let mut bt = field_bnk::Fixed4::default(); let data = vec![x as u8 + 1, x as u8 + 2, x as u8 + 3, x as u8 + 4]; let mut res = bt.parse(&data, 0).unwrap(); @@ -33,211 +91,222 @@ pub extern fn trs_test(x: i32) -> usize { let vvs = bt.hex().into_bytes(); res += vvs[2] as usize; res - - // x as usize + data[0] as usize - // x as usize + 1 } -use crate::core::account::Account; - -#[no_mangle] -pub extern fn create_acc_random() -> usize { +#[wasm_bindgen] +pub fn create_acc_random() -> usize { let acc = Account::create_by_password(&"123456".to_string()); - if let Err(e) = acc { - return 0 - } + if let Err(_) = acc { + return 0; + } let accstr = acc.unwrap().readable().clone(); let bts = accstr.as_bytes(); - bts[1] as usize - -} - - -////////////////////////////// -#[no_mangle] -pub extern fn set_api_return_json() { -} - - - - - - -fn if_add_chain_id(chain_id: u64, tx: &mut impl Transaction) { - // act - if chain_id > 0 { - let mut act = action::new_CheckChainID(); - act.chain_id = Uint8::from(chain_id); - tx.append_action(Box::new(act)); - } -} - -fn get_time_set(timestamp: i64) -> i64 { - let mut time_set = timestamp; - if time_set <= 0 { - time_set = Utc::now().timestamp(); - } - time_set } +#[wasm_bindgen] +pub fn set_api_return_json() {} #[wasm_bindgen] -pub fn general_transfer(chain_id: u64, from_pass: String, to_addr: String, amountex: String, fee: String, timestamp: i64) -> String { - let amount = amountex.clone().to_uppercase().replace(" ",""); - // HACD - let res1 = DiamondListMax200::parse_from_list(amount.clone()); - if let Ok(diamonds) = res1 { - return hacd_transfer(chain_id, from_pass.clone(), from_pass.clone(), to_addr, amount, fee, timestamp); - } - // SAT - let res2 = amount.find("SAT"); // SAT, SATS, SATOSHI, SATOSHIS - if let Some(_) = res2 { - let v = amount.replace("S","").replace("AT","").replace("OHI",""); +pub fn general_transfer( + chain_id: u64, + from_pass: String, + to_addr: String, + amountex: String, + fee: String, + timestamp: i64, +) -> String { + let amount = amountex.clone().to_uppercase().replace(" ", ""); + if DiamondNameListMax200::from_readable(&amount).is_ok() { + return hacd_transfer( + chain_id, + from_pass.clone(), + from_pass.clone(), + to_addr, + amount, + fee, + timestamp, + ); + } + let res2 = amount.find("SAT"); + if res2.is_some() { + let v = amount.replace("S", "").replace("AT", "").replace("OHI", ""); if let Ok(sat) = v.parse::() { - return sat_transfer(chain_id, from_pass.clone(), from_pass.clone(), to_addr, sat, fee, timestamp); + return sat_transfer( + chain_id, + from_pass.clone(), + from_pass.clone(), + to_addr, + sat, + fee, + timestamp, + ); } } - // HAC - let res3 = Amount::from_string_unsafe(&amount); - if let Ok(hac) = res3 { + if Amount::from_string_unsafe(&amount).is_ok() { return hac_transfer(chain_id, from_pass.clone(), to_addr, amount, fee, timestamp); } - - // AMOUNT ERROR - or_return!{"Amount format", Err(amount)}; - - return "[ERROR]".to_string() + or_return! { "Amount format", Err(amount) }; + "[ERROR]".to_string() } - - - #[wasm_bindgen] -pub fn hac_transfer(chain_id: u64, from_pass: String, to_addr: String, amount: String, fee: String, timestamp: i64) -> String { +pub fn hac_transfer( + chain_id: u64, + from_pass: String, + to_addr: String, + amount: String, + fee: String, + timestamp: i64, +) -> String { let time_set = get_time_set(timestamp); - // amount - let amt = or_return!{ "Amount parse", Amount::from_string_unsafe(&amount) }; - let fee = or_return!{ "Fee parse", Amount::from_string_unsafe(&fee) }; - let acc = or_return!{ "From Account", Account::create_by(&from_pass) }; - let toaddr = or_return!{ "To Address", Address::from_readable(&to_addr) }; - // tx - let mut tx = transaction::new_type_2(acc.address(), &fee, time_set); - // chain id + let amt = or_return! { "Amount parse", Amount::from_string_unsafe(&amount) }; + let fee = or_return! { "Fee parse", Amount::from_string_unsafe(&fee) }; + let acc = or_return! { "From Account", Account::create_by(&from_pass) }; + let toaddr = or_return! { "To Address", Address::from_readable(&to_addr) }; + let mut tx = TransactionType2::build(*acc.address(), fee.clone()); + tx.timestamp = Timestamp::from(time_set as u64); if_add_chain_id(chain_id, &mut tx); - // actions - let act = action_create!{ HacTransfer, - to_address: toaddr.clone(), - amount: amt.clone() - }; - tx.append_action(Box::new(act)); - // sign - tx.fill_sign(&acc); - - // ok - // format!("{},{},{},{}", hex::encode(2u64.to_be_bytes()), hex::encode(Uint1::from_uint(2)), tx.hash().hex(), hex::encode(tx.serialize())) - // format!("{},{},{},{},{},{},{},{}", tx.hash().hex(), hex::encode(tx.serialize()), chain_id, acc.readable(), toaddr.readable(), amt.to_fin_string(), fee.to_fin_string(), time_set) - // format!("{},{},{},{},{}", tx.hash().hex(), hex::encode(tx.serialize()), acc.readable(), acc.readable(), time_set) - - let ok = format!(r##""tx_hash":"{}","tx_body":"{}","amount":"{}","fee":"{}","payment_address":"{}","fee_address":"{}","collection_address":"{}","timestamp":{}"##, - tx.hash().hex(), hex::encode(tx.serialize()), amt.to_fin_string(), fee.to_fin_string(), acc.readable(), acc.readable(), toaddr.readable(), time_set); + let mut act = HacToTransfer::new(); + act.to = AddrOrPtr::from_addr(toaddr.clone()); + act.hacash = amt.clone(); + let _ = tx.push_action(Box::new(act)); + let _ = tx.fill_sign(&acc); + let ok = format!( + r##""tx_hash":"{}","tx_body":"{}","amount":"{}","fee":"{}","payment_address":"{}","fee_address":"{}","collection_address":"{}","timestamp":{}"##, + tx.hash().hex(), + hex::encode(tx.serialize()), + amt.to_fin_string(), + fee.to_fin_string(), + acc.readable(), + acc.readable(), + toaddr.readable(), + time_set + ); format!("{{{}}}", ok) } - #[wasm_bindgen] -pub fn sat_transfer(chain_id: u64, from_pass: String, fee_pass: String, to_addr: String, satoshi: u64, fee: String, timestamp: i64) -> String { +pub fn sat_transfer( + chain_id: u64, + from_pass: String, + fee_pass: String, + to_addr: String, + satoshi: u64, + fee: String, + timestamp: i64, +) -> String { let time_set = get_time_set(timestamp); - // amount - let sat = Satoshi::from_uint(satoshi); - let fee = or_return!{ "Fee parse", Amount::from_string_unsafe(&fee) }; - let acc = or_return!{ "From Account", Account::create_by(&from_pass) }; - let feeacc = or_return!{ "Fee Account", Account::create_by(&fee_pass) }; - let toaddr = or_return!{ "To Address", Address::from_readable(&to_addr) }; - // tx + let sat = Satoshi::from(satoshi); + let fee = or_return! { "Fee parse", Amount::from_string_unsafe(&fee) }; + let acc = or_return! { "From Account", Account::create_by(&from_pass) }; + let feeacc = or_return! { "Fee Account", Account::create_by(&fee_pass) }; + let toaddr = or_return! { "To Address", Address::from_readable(&to_addr) }; let is_main_single = feeacc.address() == acc.address(); - let mut tx = transaction::new_type_2(feeacc.address(), &fee, time_set); - // chain id + let mut tx = TransactionType2::build(*feeacc.address(), fee.clone()); + tx.timestamp = Timestamp::from(time_set as u64); if_add_chain_id(chain_id, &mut tx); - // actions if is_main_single { - let act = action_create!{ SatTransfer, - to_address: toaddr.clone(), - satoshi: sat.clone() - }; - tx.append_action(Box::new(act)); - }else{ - let act = action_create!{ FromToSatTransfer, - from_address: acc.address().clone(), - to_address: toaddr.clone(), - satoshi: sat.clone() - }; - tx.append_action(Box::new(act)); + let mut act = SatoshiToTransfer::new(); + act.to = AddrOrPtr::from_addr(toaddr.clone()); + act.satoshi = sat.clone(); + let _ = tx.push_action(Box::new(act)); + } else { + let mut act = SatoshiFromToTransfer::new(); + act.from = AddrOrPtr::from_addr(acc.address().clone()); + act.to = AddrOrPtr::from_addr(toaddr.clone()); + act.satoshi = sat.clone(); + let _ = tx.push_action(Box::new(act)); } - // sign - tx.fill_sign(&acc); + let _ = tx.fill_sign(&acc); if !is_main_single { - tx.fill_sign(&feeacc); + let _ = tx.fill_sign(&feeacc); } - - // ok - // format!("{},{},{},{}", hex::encode(2u64.to_be_bytes()), hex::encode(Uint1::from_uint(2)), tx.hash().hex(), hex::encode(tx.serialize())) - // format!("{},{},{},{},{},{},{},{}", tx.hash().hex(), hex::encode(tx.serialize()), chain_id, acc.readable(), toaddr.readable(), amt.to_fin_string(), fee.to_fin_string(), time_set) - // format!("{},{},{},{},{}", tx.hash().hex(), hex::encode(tx.serialize()), acc.readable(), feeacc.readable(), time_set) - - let ok = format!(r##""tx_hash":"{}","tx_body":"{}","amount":"{} SAT","fee":"{}","payment_address":"{}","fee_address":"{}","collection_address":"{}","timestamp":{}"##, - tx.hash().hex(), hex::encode(tx.serialize()), sat.to_u64(), fee.to_fin_string(), acc.readable(), feeacc.readable(), toaddr.readable(), time_set); + let ok = format!( + r##""tx_hash":"{}","tx_body":"{}","amount":"{} SAT","fee":"{}","payment_address":"{}","fee_address":"{}","collection_address":"{}","timestamp":{}"##, + tx.hash().hex(), + hex::encode(tx.serialize()), + sat.to_u64(), + fee.to_fin_string(), + acc.readable(), + feeacc.readable(), + toaddr.readable(), + time_set + ); format!("{{{}}}", ok) - } - #[wasm_bindgen] -pub fn hacd_transfer(chain_id: u64, from_pass: String, fee_pass: String, to_addr: String, diamond_name_list: String, fee: String, timestamp: i64) -> String { +pub fn hacd_transfer( + chain_id: u64, + from_pass: String, + fee_pass: String, + to_addr: String, + diamond_name_list: String, + fee: String, + timestamp: i64, +) -> String { let time_set = get_time_set(timestamp); - // data - - let dlist = or_return!{ "Diamond Name parse", DiamondListMax200::parse_from_list(diamond_name_list) }; - let fee = or_return!{ "Fee parse", Amount::from_string_unsafe(&fee) }; - let acc = or_return!{ "From Account", Account::create_by(&from_pass) }; - let feeacc = or_return!{ "Fee Account", Account::create_by(&fee_pass) }; - let toaddr = or_return!{ "To Address", Address::from_readable(&to_addr) }; - // tx + let dlist = or_return! { "Diamond Name parse", parse_diamond_list(diamond_name_list) }; + let fee = or_return! { "Fee parse", Amount::from_string_unsafe(&fee) }; + let acc = or_return! { "From Account", Account::create_by(&from_pass) }; + let feeacc = or_return! { "Fee Account", Account::create_by(&fee_pass) }; + let toaddr = or_return! { "To Address", Address::from_readable(&to_addr) }; let is_main_single = feeacc.address() == acc.address(); - let mut tx = transaction::new_type_2(feeacc.address(), &fee, time_set); - // chain id + let mut tx = TransactionType2::build(*feeacc.address(), fee.clone()); + tx.timestamp = Timestamp::from(time_set as u64); if_add_chain_id(chain_id, &mut tx); - // actions - if is_main_single && dlist.len() == 1 { - let act = action_create!{ HacdTransfer, - diamond: dlist[0], - to_address: toaddr.clone() - }; - tx.append_action(Box::new(act)); - }else{ - let act = action_create!{ HacdTransferMultiple, - from_address: acc.address().clone(), - to_address: toaddr.clone(), - diamond_list: dlist.clone() - }; - tx.append_action(Box::new(act)); + if is_main_single && dlist.count().uint() == 1 { + let mut act = DiamondSingleTransfer::new(); + act.diamond = dlist.lists[0].clone(); + act.to = AddrOrPtr::from_addr(toaddr.clone()); + let _ = tx.push_action(Box::new(act)); + } else { + let mut act = DiamondFromToTransfer::new(); + act.from = AddrOrPtr::from_addr(acc.address().clone()); + act.to = AddrOrPtr::from_addr(toaddr.clone()); + act.diamonds = dlist.clone(); + let _ = tx.push_action(Box::new(act)); } - // sign - tx.fill_sign(&acc); + let _ = tx.fill_sign(&acc); if !is_main_single { - tx.fill_sign(&feeacc); + let _ = tx.fill_sign(&feeacc); } - - // ok - // format!("{},{},{},{}", hex::encode(2u64.to_be_bytes()), hex::encode(Uint1::from_uint(2)), tx.hash().hex(), hex::encode(tx.serialize())) - // format!("{},{},{},{},{},{},{},{}", tx.hash().hex(), hex::encode(tx.serialize()), chain_id, acc.readable(), toaddr.readable(), amt.to_fin_string(), fee.to_fin_string(), time_set) - // format!("{},{},{},{},{}", tx.hash().hex(), hex::encode(tx.serialize()), acc.readable(), feeacc.readable(), time_set) - - let ok = format!(r##""tx_hash":"{}","tx_body":"{}","diamond_count":{},"diamonds":"{}","fee":"{}","payment_address":"{}","fee_address":"{}","collection_address":"{}","timestamp":{}"##, - tx.hash().hex(), hex::encode(tx.serialize()), dlist.len(), dlist.to_string(), fee.to_fin_string(), acc.readable(), feeacc.readable(), toaddr.readable(), time_set); + let ok = format!( + r##""tx_hash":"{}","tx_body":"{}","diamond_count":{},"diamonds":"{}","fee":"{}","payment_address":"{}","fee_address":"{}","collection_address":"{}","timestamp":{}"##, + tx.hash().hex(), + hex::encode(tx.serialize()), + dlist.count().uint(), + dlist.readable(), + fee.to_fin_string(), + acc.readable(), + feeacc.readable(), + toaddr.readable(), + time_set + ); format!("{{{}}}", ok) - } +/// HIP-25: stake owned HACD (action kind 34). +#[wasm_bindgen] +pub fn hacd_stake( + chain_id: u64, + from_pass: String, + diamond_name_list: String, + fee: String, + timestamp: i64, +) -> String { + build_signed_stake_tx(chain_id, from_pass, diamond_name_list, fee, timestamp, true) +} - +/// HIP-25: unstake HACD after min stake period (action kind 35). +#[wasm_bindgen] +pub fn hacd_unstake( + chain_id: u64, + from_pass: String, + diamond_name_list: String, + fee: String, + timestamp: i64, +) -> String { + build_signed_stake_tx(chain_id, from_pass, diamond_name_list, fee, timestamp, false) +} \ No newline at end of file diff --git a/src/sdk_lib.rs b/src/sdk_lib.rs new file mode 100644 index 0000000..09467a7 --- /dev/null +++ b/src/sdk_lib.rs @@ -0,0 +1,20 @@ +//! Minimal library entry for the browser WASM SDK (hacash_sdk). + +#![cfg(target_arch = "wasm32")] + +pub mod x16rs; + +#[macro_use] +pub mod sys; +#[macro_use] +pub mod base; +pub mod interface; +#[macro_use] +pub mod core; +#[macro_use] +pub mod protocol; +pub mod mint; +#[macro_use] +pub mod vm; + +pub mod sdk; \ No newline at end of file diff --git a/wallet/hip25/index.html b/wallet/hip25/index.html index cef449c..56d54c2 100644 --- a/wallet/hip25/index.html +++ b/wallet/hip25/index.html @@ -157,14 +157,27 @@

Connection

- - + + +
+
+ + +
+
+ +
+
+
WASM SDK: checking…
@@ -198,8 +211,16 @@

Activity log

+ \ No newline at end of file From 1923b2c0ef96d157913c5fe1e16ec70dbff173c2 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Tue, 16 Jun 2026 00:43:13 +0200 Subject: [PATCH 13/35] HIP-25: unstake demo periods + hip25_unstake_demo.ps1 (verified E2E) --- hacash.config.ini.example | 2 + scripts/START_WALLET.bat | 87 +++++++++++++++++++ scripts/hip25_unstake_demo.ps1 | 154 +++++++++++++++++++++++++++++++++ scripts/start_hip25_wallet.ps1 | 83 ++++++++++++++++++ src/config/mint.rs | 3 + src/mint/checker/initialize.rs | 7 ++ src/mint/component/staking.rs | 12 +++ src/mint/operate/staking.rs | 11 ++- src/server/rpc/staking.rs | 2 +- 9 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 scripts/START_WALLET.bat create mode 100644 scripts/hip25_unstake_demo.ps1 create mode 100644 scripts/start_hip25_wallet.ps1 diff --git a/hacash.config.ini.example b/hacash.config.ini.example index cd4a5d8..50a4615 100644 --- a/hacash.config.ini.example +++ b/hacash.config.ini.example @@ -23,6 +23,8 @@ staking_activation_height = 1 ; HACD: WTYUIA,HXVMEK,VMEKBS,UIASHX,MEKUIA — password hip25test → 1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2 hip25_testnet_seed = false hip25_testnet_seed_password = hip25test +; Unstake demo only: min_stake=5, cooldown=3 blocks (NEVER enable on mainnet) +hip25_testnet_demo_periods = false [miner] enable = false diff --git a/scripts/START_WALLET.bat b/scripts/START_WALLET.bat new file mode 100644 index 0000000..bc9e74e --- /dev/null +++ b/scripts/START_WALLET.bat @@ -0,0 +1,87 @@ +@echo off +title HIP-25 Wallet Launcher +cd /d "%~dp0..\target\debug" + +if not exist hacash.exe ( + echo. + echo hacash.exe not found. Build first: + echo cd C:\Users\KQHEX\Documents\hacash-rust + echo cargo build + echo. + pause + exit /b 1 +) + +taskkill /IM hacash.exe /F >nul 2>&1 +timeout /t 2 /nobreak >nul + +( +echo [default] +echo data_dir = hacash_hip25_demo +echo [server] +echo enable = true +echo listen = 8083 +echo recent_blocks = false +echo average_fee_purity = false +echo [node] +echo listen = 3338 +echo not_find_nodes = true +echo boots = +echo [mint] +echo chain_id = 1 +echo staking_activation_height = 1 +echo hip25_testnet_seed = true +echo hip25_testnet_seed_password = hip25test +echo [miner] +echo enable = true +echo reward = 1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2 +echo message = hip25wallet +echo [diamondminer] +echo enable = false +) > hacash.config.ini + +( +echo [default] +echo connect = 127.0.0.1:8083 +echo supervene = 4 +echo nonce_max = 4294967295 +echo notice_wait = 3 +) > poworker.config.ini + +echo. +echo [1/3] Starting HIP25-FULLNODE... +start "HIP25-FULLNODE" cmd /k "cd /d %cd% && title HIP25-FULLNODE && hacash.exe" + +echo [2/3] Waiting for RPC (max 60s)... +set /a tries=0 +:waitrpc +set /a tries+=1 +if %tries% gtr 60 goto rpcfail +timeout /t 1 /nobreak >nul +powershell -NoProfile -Command "try { Invoke-RestMethod 'http://127.0.0.1:8083/query/latest' -TimeoutSec 2 | Out-Null; exit 0 } catch { exit 1 }" >nul 2>&1 +if errorlevel 1 goto waitrpc +echo RPC ready. + +echo [3/3] Starting HIP25-POWORKER... +start "HIP25-POWORKER" cmd /k "cd /d %cd% && title HIP25-POWORKER && hacash.exe poworker" + +timeout /t 2 /nobreak >nul +start http://127.0.0.1:8083/hip25/wallet + +echo. +echo ======================================== +echo WALLET: http://127.0.0.1:8083/hip25/wallet +echo ======================================== +echo. +echo KEEP OPEN: HIP25-FULLNODE + HIP25-POWORKER +echo In wallet: Fill testnet seed -^> Load portfolio +echo. +pause +exit /b 0 + +:rpcfail +echo. +echo RPC did not start. Check HIP25-FULLNODE window for errors. +echo. +pause +exit /b 1 \ No newline at end of file diff --git a/scripts/hip25_unstake_demo.ps1 b/scripts/hip25_unstake_demo.ps1 new file mode 100644 index 0000000..703c0b4 --- /dev/null +++ b/scripts/hip25_unstake_demo.ps1 @@ -0,0 +1,154 @@ +# HIP-25 unstake demo: short periods (min_stake=5, cooldown=3) on fresh testnet chain +param( + [string]$Base = "http://127.0.0.1:8083", + [string]$DataDir = "hacash_hip25_unstake_demo", + [string]$SeedAddress = "1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2", + [string]$SeedPrikey = "95f8f5960f8d12471419d76716677cbe1764b628cf11213845b6d917a9f98657", + [string]$Diamond = "MEKUIA" +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$BinDir = Join-Path $Root "target\debug" +$Exe = Join-Path $BinDir "hacash.exe" +Set-Location $BinDir + +function Invoke-HacGet($Path, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + return Invoke-RestMethod -Uri $uri -TimeoutSec 30 +} + +function Invoke-HacPost($Path, $Body, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + $bytes = if ($Body -is [byte[]]) { $Body } else { [System.Text.Encoding]::UTF8.GetBytes([string]$Body) } + $req = [System.Net.HttpWebRequest]::Create($uri) + $req.Method = "POST" + $req.ContentType = "application/octet-stream" + $req.ContentLength = $bytes.Length + $stream = $req.GetRequestStream() + $stream.Write($bytes, 0, $bytes.Length) + $stream.Close() + $resp = $req.GetResponse() + $reader = New-Object System.IO.StreamReader($resp.GetResponseStream()) + return ($reader.ReadToEnd() | ConvertFrom-Json) +} + +function Submit-StakeTx($kind, $diamonds) { + $ts = 1718496000 + $txJson = "{`"main_address`":`"$SeedAddress`",`"timestamp`":$ts,`"fee`":`"0:247`",`"actions`":[{`"kind`":$kind,`"diamonds`":`"$diamonds`"}]}" + $built = Invoke-HacPost "create/transaction" $txJson @{ action = "true"; signature = "true" } + if ($built.ret -ne 0) { throw "create: $($built.err)" } + $txBytes = [byte[]]::new($built.body.Length / 2) + for ($i = 0; $i -lt $txBytes.Length; $i++) { $txBytes[$i] = [Convert]::ToByte($built.body.Substring($i * 2, 2), 16) } + $signed = Invoke-HacPost "util/transaction/sign" $txBytes @{ prikey = $SeedPrikey; signature = "true"; action = "true" } + if ($signed.ret -ne 0) { throw "sign: $($signed.err)" } + $signedBytes = [byte[]]::new($signed.body.Length / 2) + for ($i = 0; $i -lt $signedBytes.Length; $i++) { $signedBytes[$i] = [Convert]::ToByte($signed.body.Substring($i * 2, 2), 16) } + $sub = Invoke-HacPost "submit/transaction" $signedBytes @{} + if ($sub.ret -ne 0) { throw "submit: $($sub.err)" } + return $sub.hash +} + +function Wait-Status($diamond, $want, $timeout = 120) { + for ($i = 0; $i -lt $timeout; $i++) { + $st = Invoke-HacGet "query/staking/status" @{ diamond = $diamond } + if ($st.status -eq $want) { return $st } + Start-Sleep -Seconds 2 + } + throw "Timeout: $diamond not $want" +} + +Write-Host "=== HIP-25 UNSTAKE DEMO ===" -ForegroundColor Cyan + +if (Test-Path $DataDir) { Remove-Item -Recurse -Force $DataDir } + +$cfg = @" +[default] +data_dir = $DataDir +[server] +enable = true +listen = 8083 +[node] +listen = 3338 +not_find_nodes = true +boots = +[mint] +chain_id = 1 +staking_activation_height = 1 +hip25_testnet_seed = true +hip25_testnet_seed_password = hip25test +hip25_testnet_demo_periods = true +[miner] +enable = true +reward = $SeedAddress +[diamondminer] +enable = false +"@ +Set-Content "hacash.config.ini" $cfg +@" +[default] +connect = 127.0.0.1:8083 +supervene = 4 +nonce_max = 4294967295 +notice_wait = 3 +"@ | Set-Content "poworker.config.ini" + +Get-Process hacash -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 2 + +$node = Start-Process -FilePath $Exe -WorkingDirectory $BinDir -PassThru +for ($i = 0; $i -lt 30; $i++) { + try { $null = Invoke-HacGet "query/latest"; break } catch { Start-Sleep -Seconds 1 } +} +$miner = Start-Process -FilePath $Exe -ArgumentList "poworker" -WorkingDirectory $BinDir -PassThru +Start-Sleep -Seconds 2 + +try { + for ($i = 0; $i -lt 60; $i++) { + if ((Invoke-HacGet "query/latest").height -ge 1) { break } + Start-Sleep -Seconds 2 + } + + Write-Host "[1] Stake $Diamond..." -ForegroundColor Yellow + $h1 = Submit-StakeTx 34 $Diamond + Write-Host " tx $h1" + $st = Wait-Status $Diamond "Staked" + Write-Host " badge: Staked at height $($st.stake_height) min_unstake=$($st.min_unstake_height)" -ForegroundColor Green + + Write-Host "[2] Wait until min unstake height..." -ForegroundColor Yellow + for ($i = 0; $i -lt 90; $i++) { + $h = (Invoke-HacGet "query/latest").height + $st = Invoke-HacGet "query/staking/status" @{ diamond = $Diamond } + if ($h -ge $st.min_unstake_height) { Write-Host " height=$h >= min_unstake=$($st.min_unstake_height)" -ForegroundColor Green; break } + Start-Sleep -Seconds 2 + } + + Write-Host "[3] Unstake $Diamond (kind 35)..." -ForegroundColor Yellow + $h2 = Submit-StakeTx 35 $Diamond + Write-Host " tx $h2" + $st2 = Wait-Status $Diamond "Cooldown" + Write-Host " badge: Cooldown unlock_height=$($st2.unlock_height)" -ForegroundColor Magenta + + Write-Host "[4] Wait cooldown (3 blocks)..." -ForegroundColor Yellow + for ($i = 0; $i -lt 90; $i++) { + $h = (Invoke-HacGet "query/latest").height + if ($h -ge $st2.unlock_height) { break } + Start-Sleep -Seconds 2 + } + $st3 = Wait-Status $Diamond "Available" + Write-Host " badge: Available (unstake complete)" -ForegroundColor Green + + $events = Invoke-HacGet "query/staking/events" @{ from = 0; limit = 20 } + Write-Host "[5] Events: $($events.total) total" -ForegroundColor Green + foreach ($ev in $events.events) { + if ($ev.literal -eq $Diamond) { Write-Host " $($ev.kind) h=$($ev.height)" } + } + + Write-Host "" + Write-Host "UNSTAKE DEMO PASSED. Wallet: $Base/hip25/wallet" -ForegroundColor Green +} finally { + if ($miner -and -not $miner.HasExited) { Stop-Process -Id $miner.Id -Force -EA SilentlyContinue } + if ($node -and -not $node.HasExited) { Stop-Process -Id $node.Id -Force -EA SilentlyContinue } +} \ No newline at end of file diff --git a/scripts/start_hip25_wallet.ps1 b/scripts/start_hip25_wallet.ps1 new file mode 100644 index 0000000..a383550 --- /dev/null +++ b/scripts/start_hip25_wallet.ps1 @@ -0,0 +1,83 @@ +# Start HIP-25 testnet + open wallet. +# Opens TWO cmd windows (HIP25-FULLNODE + HIP25-POWORKER). Do not close them. +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$BinDir = Join-Path $Root "target\debug" +$Exe = Join-Path $BinDir "hacash.exe" +$SeedAddress = "1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2" +$DataDir = "hacash_hip25_demo" +$WalletUrl = "http://127.0.0.1:8083/hip25/wallet" + +if (-not (Test-Path $Exe)) { + Write-Host "Build first: cd $Root ; cargo build" -ForegroundColor Red + exit 1 +} + +Set-Location $BinDir +Get-Process hacash -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 2 + +@" +[default] +data_dir = $DataDir +[server] +enable = true +listen = 8083 +recent_blocks = false +average_fee_purity = false +[node] +listen = 3338 +not_find_nodes = true +boots = +[mint] +chain_id = 1 +staking_activation_height = 1 +hip25_testnet_seed = true +hip25_testnet_seed_password = hip25test +[miner] +enable = true +reward = $SeedAddress +message = hip25wallet +[diamondminer] +enable = false +"@ | Set-Content "hacash.config.ini" + +@" +[default] +connect = 127.0.0.1:8083 +supervene = 4 +nonce_max = 4294967295 +notice_wait = 3 +"@ | Set-Content "poworker.config.ini" + +Write-Host "Starting fullnode (cmd window: HIP25-FULLNODE)..." -ForegroundColor Cyan +Start-Process cmd.exe -ArgumentList "/k", "cd /d $BinDir && title HIP25-FULLNODE && hacash.exe" + +Write-Host "Waiting for RPC..." -ForegroundColor Cyan +$ready = $false +for ($i = 0; $i -lt 40; $i++) { + try { + $null = Invoke-RestMethod "http://127.0.0.1:8083/query/latest" -TimeoutSec 2 + $ready = $true + break + } catch { + Start-Sleep -Seconds 1 + } +} + +if ($ready) { + Write-Host "RPC ready." -ForegroundColor Green +} else { + Write-Host "RPC not ready. Wait 10s then open $WalletUrl" -ForegroundColor Yellow +} + +Write-Host "Starting poworker (cmd window: HIP25-POWORKER)..." -ForegroundColor Cyan +Start-Process cmd.exe -ArgumentList "/k", "cd /d $BinDir && title HIP25-POWORKER && hacash.exe poworker" + +Start-Sleep -Seconds 2 +Start-Process $WalletUrl + +Write-Host "" +Write-Host "Wallet: $WalletUrl" -ForegroundColor Yellow +Write-Host "Keep open: HIP25-FULLNODE and HIP25-POWORKER cmd windows." -ForegroundColor White +Write-Host "In wallet: Fill testnet seed -> Load portfolio." -ForegroundColor White \ No newline at end of file diff --git a/src/config/mint.rs b/src/config/mint.rs index b30647b..2503762 100644 --- a/src/config/mint.rs +++ b/src/config/mint.rs @@ -10,6 +10,8 @@ pub struct MintConf { /// Dev/testnet: seed one HACD + HAC to a password-derived account at genesis. pub hip25_testnet_seed: bool, pub hip25_testnet_seed_password: String, + /// Dev only: min_stake=5 blocks, cooldown=3 blocks (requires hip25_testnet_seed). + pub hip25_testnet_demo_periods: bool, } @@ -29,6 +31,7 @@ impl MintConf { staking_activation_height: ini_must_u64(&sec, "staking_activation_height", 1), hip25_testnet_seed: ini_must_bool(&sec, "hip25_testnet_seed", false), hip25_testnet_seed_password: ini_must(&sec, "hip25_testnet_seed_password", "hip25test"), + hip25_testnet_demo_periods: ini_must_bool(&sec, "hip25_testnet_demo_periods", false), }; cnf diff --git a/src/mint/checker/initialize.rs b/src/mint/checker/initialize.rs index 05a0a70..7d2fb69 100644 --- a/src/mint/checker/initialize.rs +++ b/src/mint/checker/initialize.rs @@ -8,6 +8,13 @@ fn impl_initialize(this: &BlockMintChecker, db: &mut dyn State) -> RetErr { let mut mint_state = MintState::wrap(db); let mut global = mint_state.staking_global(); global.activation_height = BlockHeight::from(this.cnf.staking_activation_height); + if this.cnf.hip25_testnet_seed && this.cnf.hip25_testnet_demo_periods { + global.demo_min_stake_blocks = Uint5::from(5); + global.demo_cooldown_blocks = Uint5::from(3); + println!( + "[HIP-25 testnet demo] short periods: min_stake=5 blocks, cooldown=3 blocks" + ); + } mint_state.set_staking_global(&global); } diff --git a/src/mint/component/staking.rs b/src/mint/component/staking.rs index a0bdf57..9ad7691 100644 --- a/src/mint/component/staking.rs +++ b/src/mint/component/staking.rs @@ -87,6 +87,8 @@ StructFieldStruct!(GlobalStakingState, unlock_queue_tail : Uint5 activation_height : BlockHeight event_log_tail : Uint5 + demo_min_stake_blocks : Uint5 + demo_cooldown_blocks : Uint5 ); impl GlobalStakingState { @@ -97,6 +99,16 @@ impl GlobalStakingState { pub fn is_active_at(&self, height: u64) -> bool { height >= self.activation_height.uint() } + + pub fn effective_min_stake_blocks(&self) -> u64 { + let v = self.demo_min_stake_blocks.uint(); + if v > 0 { v } else { MIN_STAKE_BLOCKS } + } + + pub fn effective_cooldown_blocks(&self) -> u64 { + let v = self.demo_cooldown_blocks.uint(); + if v > 0 { v } else { COOLDOWN_BLOCKS } + } } /** diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs index a338a7d..6f34486 100644 --- a/src/mint/operate/staking.rs +++ b/src/mint/operate/staking.rs @@ -358,11 +358,14 @@ pub fn staking_apply_unstake( state.staking_record(&dianame) ); let stake_height = record.stake_height.uint(); - if height < stake_height + MIN_STAKE_BLOCKS { + let global_snap = state.staking_global(); + let min_stake = global_snap.effective_min_stake_blocks(); + let cooldown = global_snap.effective_cooldown_blocks(); + if height < stake_height + min_stake { return errf!( - "diamond {} must remain staked for at least {} blocks (~3 months)", + "diamond {} must remain staked for at least {} blocks", dianame.readable(), - MIN_STAKE_BLOCKS + min_stake ); } @@ -371,7 +374,7 @@ pub fn staking_apply_unstake( diaitem.status = DIAMOND_STATUS_STAKING_COOLDOWN; state.set_diamond(&dianame, &diaitem); - let unlock_height = height + COOLDOWN_BLOCKS; + let unlock_height = height + cooldown; let cooldown_record = StakingRecord { stake_height: record.stake_height.clone(), unlock_height: BlockHeight::from(unlock_height), diff --git a/src/server/rpc/staking.rs b/src/server/rpc/staking.rs index 1e0e25c..b68db45 100644 --- a/src/server/rpc/staking.rs +++ b/src/server/rpc/staking.rs @@ -29,7 +29,7 @@ async fn staking_status(State(ctx): State, q: Query) -> stake_height = rec.stake_height.uint(); unlock_height = rec.unlock_height.uint(); if stake_height > 0 { - min_unstake_height = stake_height + MIN_STAKE_BLOCKS; + min_unstake_height = stake_height + global.effective_min_stake_blocks(); } if let Ok(amt) = staking_display_accrued_reward(&global.global_reward_index, &rec) { accrued_reward = amt.to_unit_string(&unit); From d0de899bde1784dd0d842e96d4c756c5f0c87e89 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Tue, 16 Jun 2026 00:48:32 +0200 Subject: [PATCH 14/35] =?UTF-8?q?HIP-25:=20mainnet=20hardening=20=E2=80=94?= =?UTF-8?q?=20config=20guards,=20WASM=20wallet,=20RPC=20prikey=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hacash.config.ini.example | 1 + hacash_mainnet_hip25.config.ini.example | 30 ++++++++ scripts/build_wallet_sdk.ps1 | 26 +++++++ src/config/mint.rs | 36 ++++++++-- src/mint/operate/staking.rs | 40 ++++++++++- src/server/rpc/routes.rs | 2 + src/server/rpc/transaction.rs | 7 ++ src/server/rpc/wallet_ui.rs | 40 ++++++++++- wallet/hip25/index.html | 95 +++++++++---------------- 9 files changed, 207 insertions(+), 70 deletions(-) create mode 100644 hacash_mainnet_hip25.config.ini.example create mode 100644 scripts/build_wallet_sdk.ps1 diff --git a/hacash.config.ini.example b/hacash.config.ini.example index 50a4615..36cac17 100644 --- a/hacash.config.ini.example +++ b/hacash.config.ini.example @@ -24,6 +24,7 @@ staking_activation_height = 1 hip25_testnet_seed = false hip25_testnet_seed_password = hip25test ; Unstake demo only: min_stake=5, cooldown=3 blocks (NEVER enable on mainnet) +; Requires chain_id = 1 (HIP25_DEV_CHAIN_ID) — node refuses to start otherwise. hip25_testnet_demo_periods = false [miner] diff --git a/hacash_mainnet_hip25.config.ini.example b/hacash_mainnet_hip25.config.ini.example new file mode 100644 index 0000000..b303403 --- /dev/null +++ b/hacash_mainnet_hip25.config.ini.example @@ -0,0 +1,30 @@ +; HIP-25 mainnet fullnode template — copy beside hacash binary as hacash.config.ini +; Dev/testnet flags MUST stay false; node panics at startup if enabled with chain_id != 1. +[default] +data_dir = hacash_mainnet_data + +[server] +enable = true +listen = 8081 +recent_blocks = false +average_fee_purity = false + +[node] +listen = 3333 +not_find_nodes = false +boots = + +[mint] +; Hacash L1 mainnet sub-chain id (0). HIP25_DEV_CHAIN_ID = 1 is local testnet only. +chain_id = 0 +; Announce activation height ≥30 days before fork (HIP-25 timelock). Replace 0 with agreed height. +staking_activation_height = 0 +hip25_testnet_seed = false +hip25_testnet_seed_password = hip25test +hip25_testnet_demo_periods = false + +[miner] +enable = false + +[diamondminer] +enable = false \ No newline at end of file diff --git a/scripts/build_wallet_sdk.ps1 b/scripts/build_wallet_sdk.ps1 new file mode 100644 index 0000000..34f0448 --- /dev/null +++ b/scripts/build_wallet_sdk.ps1 @@ -0,0 +1,26 @@ +# Build HIP-25 WASM SDK and copy beside hacash.exe for /pkg/* routes +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$BinDir = Join-Path $Root "target\debug" +$PkgDir = Join-Path $BinDir "pkg" +$WalletPkg = Join-Path $Root "wallet\hip25\pkg" + +Write-Host "Building hacash_sdk (wasm32)..." -ForegroundColor Cyan +Set-Location $Root +rustup target add wasm32-unknown-unknown 2>$null +cargo build --features sdk --target wasm32-unknown-unknown --release --lib + +$WasmSrc = Join-Path $Root "target\wasm32-unknown-unknown\release\hacash_sdk.wasm" +if (-not (Test-Path $WasmSrc)) { throw "WASM build failed: $WasmSrc" } + +New-Item -ItemType Directory -Force -Path $PkgDir | Out-Null +New-Item -ItemType Directory -Force -Path $WalletPkg | Out-Null + +wasm-bindgen $WasmSrc --out-dir $PkgDir --target web --no-typescript + +Copy-Item (Join-Path $PkgDir "hacash_sdk.js") (Join-Path $WalletPkg "hacash_sdk.js") -Force +Copy-Item (Join-Path $PkgDir "hacash_sdk_bg.wasm") (Join-Path $WalletPkg "hacash_sdk_bg.wasm") -Force + +Write-Host "OK: pkg copied to" $PkgDir -ForegroundColor Green +Write-Host " and" $WalletPkg -ForegroundColor Green +Write-Host "Restart fullnode, then open /hip25/wallet" -ForegroundColor Yellow \ No newline at end of file diff --git a/src/config/mint.rs b/src/config/mint.rs index 2503762..7793030 100644 --- a/src/config/mint.rs +++ b/src/config/mint.rs @@ -1,4 +1,7 @@ +/// Local dev / HIP-25 testnet only. Mainnet must use a different chain_id. +pub const HIP25_DEV_CHAIN_ID: u64 = 1; + #[derive(Clone)] pub struct MintConf { pub chain_id: u64, // sub chain id @@ -14,16 +17,13 @@ pub struct MintConf { pub hip25_testnet_demo_periods: bool, } - - impl MintConf { - pub fn new(ini: &IniObj) -> MintConf { let sec = ini_section(ini, "mint"); - let mut cnf = MintConf { + let cnf = MintConf { chain_id: ini_must_u64(&sec, "chain_id", 0), difficulty_adjust_blocks: ini_must_u64(&sec, "difficulty_adjust_blocks", 288), // 1 day each_block_target_time: ini_must_u64(&sec, "each_block_target_time", 300), // 5 mins @@ -34,8 +34,32 @@ impl MintConf { hip25_testnet_demo_periods: ini_must_bool(&sec, "hip25_testnet_demo_periods", false), }; + if let Err(e) = cnf.validate_hip25_dev_flags() { + panic!("[Config Error] {}", e); + } + cnf } - -} + /// Reject dev-only HIP-25 flags on non-dev chain_id (mainnet safety). + pub fn validate_hip25_dev_flags(&self) -> Result<(), String> { + if self.hip25_testnet_demo_periods && !self.hip25_testnet_seed { + return Err( + "hip25_testnet_demo_periods requires hip25_testnet_seed = true".to_string(), + ); + } + if self.hip25_testnet_seed && self.chain_id != HIP25_DEV_CHAIN_ID { + return Err(format!( + "hip25_testnet_seed is only allowed on dev chain_id {} (configured chain_id={})", + HIP25_DEV_CHAIN_ID, self.chain_id + )); + } + if self.hip25_testnet_demo_periods && self.chain_id != HIP25_DEV_CHAIN_ID { + return Err(format!( + "hip25_testnet_demo_periods is only allowed on dev chain_id {} (configured chain_id={})", + HIP25_DEV_CHAIN_ID, self.chain_id + )); + } + Ok(()) + } +} \ No newline at end of file diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs index 6f34486..7634994 100644 --- a/src/mint/operate/staking.rs +++ b/src/mint/operate/staking.rs @@ -170,8 +170,12 @@ pub fn staking_process_unlock_queue(base_state: &mut dyn State, height: u64) -> let entry = match mint_state.staking_unlock_entry(&key) { Some(e) => e, None => { - head += 1; - continue; + return errf!( + "staking unlock queue corrupted: missing entry {} (head {} tail {})", + head, + head, + tail + ); } }; if entry.unlock_height.uint() > height { @@ -726,4 +730,36 @@ mod staking_tests { let mint = MintStateDisk::wrap(&state); assert_eq!(mint.diamond(&dian).unwrap().status, DIAMOND_STATUS_STAKING_COOLDOWN); } + + #[test] + fn unlock_queue_missing_entry_fails_hard() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let stake_h = 1000u64; + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &staker, &list, stake_h).unwrap(); + staking_apply_unstake(&mut mint, &staker, &list, stake_h + MIN_STAKE_BLOCKS).unwrap(); + mint.del_staking_unlock_entry(&Uint5::from(0)); + let err = staking_process_unlock_queue(&mut state, stake_h + MIN_STAKE_BLOCKS + COOLDOWN_BLOCKS) + .unwrap_err(); + assert!(format!("{}", err).contains("unlock queue corrupted")); + } + + #[test] + fn hip25_dev_flags_rejected_on_mainnet_chain_id() { + use crate::config::{HIP25_DEV_CHAIN_ID, MintConf}; + let mut cnf = MintConf { + chain_id: HIP25_DEV_CHAIN_ID + 99, + difficulty_adjust_blocks: 288, + each_block_target_time: 300, + _test_mul: 1, + staking_activation_height: 1, + hip25_testnet_seed: true, + hip25_testnet_seed_password: "hip25test".to_string(), + hip25_testnet_demo_periods: false, + }; + assert!(cnf.validate_hip25_dev_flags().is_err()); + } } \ No newline at end of file diff --git a/src/server/rpc/routes.rs b/src/server/rpc/routes.rs index 6b40788..a57e2bb 100644 --- a/src/server/rpc/routes.rs +++ b/src/server/rpc/routes.rs @@ -6,6 +6,8 @@ pub fn routes(mut ctx: ApiCtx) -> Router { let lrt = Router::new() .route("/hip25/wallet", get(hip25_wallet_page)) + .route("/pkg/hacash_sdk.js", get(hip25_sdk_js)) + .route("/pkg/hacash_sdk_bg.wasm", get(hip25_sdk_wasm)) .route("/", get(console)) // query diff --git a/src/server/rpc/transaction.rs b/src/server/rpc/transaction.rs index 176ebf5..92f1829 100644 --- a/src/server/rpc/transaction.rs +++ b/src/server/rpc/transaction.rs @@ -26,6 +26,13 @@ async fn transaction_sign(State(ctx): State, q: Query, body: Byte q_must!(q, signature, false); q_must!(q, description, false); + let chain_id = ctx.engine.config().chain_id; + if prikey.len() == 64 && chain_id != crate::config::HIP25_DEV_CHAIN_ID { + return api_error( + "server-side prikey signing is disabled on mainnet; use client-side WASM signing", + ); + } + let lasthei = ctx.engine.latest_block().objc().height().uint(); let txdts = q_body_data_may_hex!(q, body); diff --git a/src/server/rpc/wallet_ui.rs b/src/server/rpc/wallet_ui.rs index 654fb25..a020f3f 100644 --- a/src/server/rpc/wallet_ui.rs +++ b/src/server/rpc/wallet_ui.rs @@ -1,9 +1,47 @@ const HIP25_WALLET_HTML: &str = include_str!("../../../wallet/hip25/index.html"); +fn hip25_pkg_dir() -> Option { + let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf(); + let candidates = [ + exe_dir.join("pkg"), + exe_dir.join("wallet").join("hip25").join("pkg"), + ]; + for p in candidates { + if p.is_dir() { + return Some(p); + } + } + None +} + +fn serve_pkg_file(name: &str, content_type: &'static str) -> Response { + use axum::http::StatusCode; + let Some(dir) = hip25_pkg_dir() else { + return ( + StatusCode::NOT_FOUND, + "HIP-25 WASM SDK not built; run scripts/build_wallet_sdk.ps1", + ) + .into_response(); + }; + let path = dir.join(name); + match std::fs::read(&path) { + Ok(bytes) => ([(header::CONTENT_TYPE, content_type)], bytes).into_response(), + Err(_) => (StatusCode::NOT_FOUND, format!("missing {}", name)).into_response(), + } +} + async fn hip25_wallet_page() -> impl IntoResponse { ( - [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], HIP25_WALLET_HTML, ) +} + +async fn hip25_sdk_js() -> impl IntoResponse { + serve_pkg_file("hacash_sdk.js", "application/javascript") +} + +async fn hip25_sdk_wasm() -> impl IntoResponse { + serve_pkg_file("hacash_sdk_bg.wasm", "application/wasm") } \ No newline at end of file diff --git a/wallet/hip25/index.html b/wallet/hip25/index.html index 56d54c2..8462a64 100644 --- a/wallet/hip25/index.html +++ b/wallet/hip25/index.html @@ -157,20 +157,13 @@

Connection

- - + +
- +
-
- - -
@@ -213,12 +206,14 @@

Activity log

- \ No newline at end of file From c8e460c38ec53d6e11d55b47def335de390ba5f1 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Tue, 16 Jun 2026 01:02:53 +0200 Subject: [PATCH 15/35] HIP-25 demo: WASM SDK build + wallet create/sign/submit flow --- Cargo.toml | 14 +++--- build.rs | 5 ++ scripts/build_wallet_sdk.ps1 | 3 +- src/core/db/level/mod.rs | 13 ++--- src/core/db/level/native_level.rs | 10 ++++ src/core/db/level/wasm_stub.rs | 82 +++++++++++++++++++++++++++++++ src/interface/chain/mod.rs | 1 + src/mint/action/action.rs | 7 +++ src/mint/action/mod.rs | 1 + src/mint/mod.rs | 3 ++ src/mint/operate/mod.rs | 2 + src/sdk/web/mod.rs | 8 +-- src/sdk/web/transfer.rs | 29 ++++++++--- src/sdk_lib.rs | 6 +++ src/x16rs/x16rs.rs | 23 +++++---- wallet/hip25/index.html | 35 +++++++++---- 16 files changed, 192 insertions(+), 50 deletions(-) create mode 100644 src/core/db/level/native_level.rs create mode 100644 src/core/db/level/wasm_stub.rs diff --git a/Cargo.toml b/Cargo.toml index 1cf2a75..48fa1ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,6 @@ cc = "1.0" [dependencies] -libc = "0.2.4" chrono = "0.4.38" lazy_static = "1.4.0" concat-idents = "1.1.5" @@ -41,19 +40,22 @@ sha3 = "0.10.1" sha2 = "0.10.2" regex = "1.10.0" ini = "1.3.0" -leveldb-sys = "2.0.9" dyn-clone = "1.0.17" -http_req = "0.10.2" -tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "sync", "time", "io-util", "net", "macros"] } -ctrlc = "3.4.4" serde = "1.0.199" serde_json = "1.0.116" bytes = "1.6.0" +wasm-bindgen = { version = "0.2.100", optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +libc = "0.2.4" +leveldb-sys = "2.0.9" +http_req = "0.10.2" +tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "sync", "time", "io-util", "net", "macros"] } +ctrlc = "3.4.4" axum = "0.7.5" spmc = "0.3.0" termsize = "0.1.9" reqwest = { version = "0.12.5", features = ["blocking"] } -wasm-bindgen = { version = "0.2.87", optional = true } [dev-dependencies] tempfile = "3.10.1" diff --git a/build.rs b/build.rs index 5531f51..6a624c8 100644 --- a/build.rs +++ b/build.rs @@ -25,6 +25,11 @@ cp target/x86_64-apple-darwin/release/hacash ./hacash_macos fn main() { + // Browser WASM SDK (hacd_stake / hacd_unstake) does not need native x16rs C code. + let target = std::env::var("TARGET").unwrap_or_default(); + if target == "wasm32-unknown-unknown" { + return; + } cc::Build::new() .file("src/x16rs/x16rs.c") .compile("x16rs"); diff --git a/scripts/build_wallet_sdk.ps1 b/scripts/build_wallet_sdk.ps1 index 34f0448..1e97374 100644 --- a/scripts/build_wallet_sdk.ps1 +++ b/scripts/build_wallet_sdk.ps1 @@ -16,7 +16,8 @@ if (-not (Test-Path $WasmSrc)) { throw "WASM build failed: $WasmSrc" } New-Item -ItemType Directory -Force -Path $PkgDir | Out-Null New-Item -ItemType Directory -Force -Path $WalletPkg | Out-Null -wasm-bindgen $WasmSrc --out-dir $PkgDir --target web --no-typescript +# no-modules: exposes global wasm_bindgen for /hip25/wallet classic script tag +wasm-bindgen $WasmSrc --out-dir $PkgDir --target no-modules --no-typescript Copy-Item (Join-Path $PkgDir "hacash_sdk.js") (Join-Path $WalletPkg "hacash_sdk.js") -Force Copy-Item (Join-Path $PkgDir "hacash_sdk_bg.wasm") (Join-Path $WalletPkg "hacash_sdk_bg.wasm") -Force diff --git a/src/core/db/level/mod.rs b/src/core/db/level/mod.rs index 85987bc..ec01477 100644 --- a/src/core/db/level/mod.rs +++ b/src/core/db/level/mod.rs @@ -1,10 +1,5 @@ -use std::ptr; -use std::ffi::{ c_void, c_char, CString }; -use leveldb_sys::*; -use libc::size_t; - -include!("error.rs"); -include!("bytes.rs"); -include!("batch.rs"); -include!("db.rs"); +#[cfg(target_arch = "wasm32")] +include!("wasm_stub.rs"); +#[cfg(not(target_arch = "wasm32"))] +include!("native_level.rs"); \ No newline at end of file diff --git a/src/core/db/level/native_level.rs b/src/core/db/level/native_level.rs new file mode 100644 index 0000000..721d110 --- /dev/null +++ b/src/core/db/level/native_level.rs @@ -0,0 +1,10 @@ +use std::ffi::{c_char, c_void, CString}; +use std::ptr; + +use leveldb_sys::*; +use libc::size_t; + +include!("error.rs"); +include!("bytes.rs"); +include!("batch.rs"); +include!("db.rs"); \ No newline at end of file diff --git a/src/core/db/level/wasm_stub.rs b/src/core/db/level/wasm_stub.rs new file mode 100644 index 0000000..f165624 --- /dev/null +++ b/src/core/db/level/wasm_stub.rs @@ -0,0 +1,82 @@ +// In-memory LevelDB stubs for browser WASM SDK (signing only; no disk I/O). + +pub struct RawBytes(Vec); + +impl std::ops::Deref for RawBytes { + type Target = [u8]; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for Vec { + fn from(bytes: RawBytes) -> Self { + bytes.0 + } +} + +pub struct Writebatch { + ops: Vec<(Vec, Option>)>, +} + +impl Writebatch { + pub fn new() -> Writebatch { + Writebatch { ops: Vec::new() } + } + + pub fn put(&mut self, k: &[u8], value: &[u8]) { + self.ops.push((k.to_vec(), Some(value.to_vec()))); + } + + pub fn delete(&mut self, k: &[u8]) { + self.ops.push((k.to_vec(), None)); + } +} + +pub struct LevelDB { + data: std::sync::Mutex, Vec>>, +} + +impl LevelDB { + pub fn open(_dir: &std::path::Path) -> LevelDB { + LevelDB { + data: std::sync::Mutex::new(std::collections::HashMap::new()), + } + } + + pub fn get_at(&self, k: &[u8]) -> Option { + let guard = self.data.lock().ok()?; + guard.get(k).map(|v| RawBytes(v.clone())) + } + + pub fn get(&self, k: &[u8]) -> Option> { + self.get_at(k).map(|v| v.0) + } + + pub fn put(&self, k: &[u8], value: &[u8]) { + if let Ok(mut guard) = self.data.lock() { + guard.insert(k.to_vec(), value.to_vec()); + } + } + + pub fn rm(&self, k: &[u8]) { + if let Ok(mut guard) = self.data.lock() { + guard.remove(k); + } + } + + pub fn write(&self, batch: &Writebatch) { + if let Ok(mut guard) = self.data.lock() { + for (k, v) in &batch.ops { + match v { + Some(val) => { + guard.insert(k.clone(), val.clone()); + } + None => { + guard.remove(k); + } + } + } + } + } +} \ No newline at end of file diff --git a/src/interface/chain/mod.rs b/src/interface/chain/mod.rs index 88aa4f4..98a69c5 100644 --- a/src/interface/chain/mod.rs +++ b/src/interface/chain/mod.rs @@ -5,6 +5,7 @@ use crate::config::*; use crate::base::field::*; use crate::core::field::*; use crate::core::db::*; +#[cfg(not(target_arch = "wasm32"))] use crate::chain::roller::*; diff --git a/src/mint/action/action.rs b/src/mint/action/action.rs index 86a03e3..f273cf7 100644 --- a/src/mint/action/action.rs +++ b/src/mint/action/action.rs @@ -4,6 +4,7 @@ pub const ACTION_KIND_ID_DIAMOND_MINT: u16 = 4; /** * reg actions */ +#[cfg(not(target_arch = "wasm32"))] pubFnRegExtendActionCreates!{ ChannelOpen // 2 @@ -27,6 +28,12 @@ pub const ACTION_KIND_ID_DIAMOND_MINT: u16 = 4; } +#[cfg(target_arch = "wasm32")] +pubFnRegExtendActionCreates!{ + DiamondStake // 34 HIP-25 + DiamondUnstake // 35 HIP-25 +} + // reg action pub fn init_reg() { unsafe { diff --git a/src/mint/action/mod.rs b/src/mint/action/mod.rs index 5aad158..17ac8bf 100644 --- a/src/mint/action/mod.rs +++ b/src/mint/action/mod.rs @@ -26,6 +26,7 @@ include!("diamond.rs"); include!("diamond_mint.rs"); include!("diamond_insc.rs"); include!("diamond_staking.rs"); +#[cfg(not(target_arch = "wasm32"))] include!("channel.rs"); include!("action.rs"); diff --git a/src/mint/mod.rs b/src/mint/mod.rs index 25a2b9f..b8cd603 100644 --- a/src/mint/mod.rs +++ b/src/mint/mod.rs @@ -1,9 +1,12 @@ pub mod state; pub mod component; +#[cfg(not(target_arch = "wasm32"))] pub mod coinbase; +#[cfg(not(target_arch = "wasm32"))] pub mod difficulty; pub mod operate; pub mod action; +#[cfg(not(target_arch = "wasm32"))] pub mod checker; diff --git a/src/mint/operate/mod.rs b/src/mint/operate/mod.rs index cf4ad25..92aad73 100644 --- a/src/mint/operate/mod.rs +++ b/src/mint/operate/mod.rs @@ -13,11 +13,13 @@ use crate::protocol::operate::*; use super::state::*; use super::component::*; +#[cfg(not(target_arch = "wasm32"))] use super::coinbase::*; +#[cfg(not(target_arch = "wasm32"))] include!("channel.rs"); include!("diamond.rs"); include!("staking.rs"); diff --git a/src/sdk/web/mod.rs b/src/sdk/web/mod.rs index 01c53ce..eefc0e6 100644 --- a/src/sdk/web/mod.rs +++ b/src/sdk/web/mod.rs @@ -5,10 +5,10 @@ use chrono::{TimeZone, Utc}; use wasm_bindgen::prelude::*; -use crate::core::field_bnk; -use crate::core::field_bnk::*; -use crate::core::interface::field::*; -use crate::core::interface::transaction::*; +use crate::base::field::*; +use crate::core::field::*; +use crate::interface::field::*; +use crate::interface::protocol::{Transaction, TransactionRead}; use crate::protocol::action; use crate::protocol::action::*; use crate::protocol::transaction; diff --git a/src/sdk/web/transfer.rs b/src/sdk/web/transfer.rs index b61c8a3..b71a6f1 100644 --- a/src/sdk/web/transfer.rs +++ b/src/sdk/web/transfer.rs @@ -1,11 +1,18 @@ -use chrono::Utc; +use std::sync::Once; use crate::core::account::Account; -use crate::core::field::diamond::DiamondNameListMax200; -use crate::mint::action::{DiamondFromToTransfer, DiamondSingleTransfer, DiamondStake, DiamondUnstake}; -use crate::protocol::action::{ - HacFromToTransfer, HacToTransfer, SatoshiFromToTransfer, SatoshiToTransfer, SubChainID, -}; +use crate::core::field::DiamondNameListMax200; +use crate::interface::field::Field; +use crate::mint::action::{DiamondStake, DiamondUnstake}; + +static SDK_INIT: Once = Once::new(); + +fn ensure_sdk_init() { + SDK_INIT.call_once(|| { + crate::mint::action::init_reg(); + }); +} +use crate::protocol::action::{HacToTransfer, SubChainID}; use crate::protocol::transaction::TransactionType2; fn if_add_chain_id(chain_id: u64, tx: &mut TransactionType2) { @@ -58,11 +65,13 @@ fn build_signed_stake_tx( timestamp: i64, stake: bool, ) -> String { + ensure_sdk_init(); let time_set = get_time_set(timestamp); let dlist = or_return! { "Diamond Name parse", parse_diamond_list(diamond_name_list) }; let fee = or_return! { "Fee parse", Amount::from_string_unsafe(&fee) }; let acc = or_return! { "From Account", Account::create_by(&from_pass) }; - let mut tx = TransactionType2::build(*acc.address(), fee.clone()); + let addr = or_return! { "From Address", Address::from_readable(acc.readable()) }; + let mut tx = TransactionType2::build(addr, fee.clone()); tx.timestamp = Timestamp::from(time_set as u64); if_add_chain_id(chain_id, &mut tx); if stake { @@ -81,7 +90,7 @@ fn build_signed_stake_tx( #[wasm_bindgen] pub fn trs_test(x: i32) -> usize { - let mut bt = field_bnk::Fixed4::default(); + let mut bt = Fixed4::default(); let data = vec![x as u8 + 1, x as u8 + 2, x as u8 + 3, x as u8 + 4]; let mut res = bt.parse(&data, 0).unwrap(); let vals = bt.serialize(); @@ -107,6 +116,7 @@ pub fn create_acc_random() -> usize { #[wasm_bindgen] pub fn set_api_return_json() {} +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] pub fn general_transfer( chain_id: u64, @@ -150,6 +160,7 @@ pub fn general_transfer( "[ERROR]".to_string() } +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] pub fn hac_transfer( chain_id: u64, @@ -186,6 +197,7 @@ pub fn hac_transfer( format!("{{{}}}", ok) } +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] pub fn sat_transfer( chain_id: u64, @@ -236,6 +248,7 @@ pub fn sat_transfer( format!("{{{}}}", ok) } +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] pub fn hacd_transfer( chain_id: u64, diff --git a/src/sdk_lib.rs b/src/sdk_lib.rs index 09467a7..2a67d6e 100644 --- a/src/sdk_lib.rs +++ b/src/sdk_lib.rs @@ -2,6 +2,11 @@ #![cfg(target_arch = "wasm32")] +#[macro_use] +extern crate ini; +#[macro_use] +extern crate lazy_static; + pub mod x16rs; #[macro_use] @@ -9,6 +14,7 @@ pub mod sys; #[macro_use] pub mod base; pub mod interface; +pub mod config; #[macro_use] pub mod core; #[macro_use] diff --git a/src/x16rs/x16rs.rs b/src/x16rs/x16rs.rs index df53ef9..20c0e6c 100644 --- a/src/x16rs/x16rs.rs +++ b/src/x16rs/x16rs.rs @@ -1,23 +1,22 @@ - +#[cfg(not(target_arch = "wasm32"))] #[link(name = "x16rs", kind = "static")] extern "C" { fn c_x16rs_hash(a: i32, b: *const u8, c: *const u8) -> (); } - +#[cfg(not(target_arch = "wasm32"))] pub fn x16rs_hash(loopnum: i32, indata: &[u8; 32]) -> [u8; 32] { - - let outdata = [0u8; 32]; + let mut outdata = [0u8; 32]; unsafe { - // input hash let input: *const u8 = indata.as_ptr(); - - // output hash - let output: *const u8 = outdata.as_ptr(); - - // do call + let output: *mut u8 = outdata.as_mut_ptr(); c_x16rs_hash(loopnum, input, output); - // println!("{:?}", outdata); } - return outdata; + outdata +} + +/// WASM SDK stub — stake/unstake signing uses sha3/sha2 only, not x16rs PoW hash. +#[cfg(target_arch = "wasm32")] +pub fn x16rs_hash(_loopnum: i32, indata: &[u8; 32]) -> [u8; 32] { + *indata } diff --git a/wallet/hip25/index.html b/wallet/hip25/index.html index 8462a64..380879f 100644 --- a/wallet/hip25/index.html +++ b/wallet/hip25/index.html @@ -211,7 +211,7 @@

Activity log

}; function txTimestamp() { - return BigInt(Math.floor(Date.now() / 1000)); + return Math.floor(Date.now() / 1000); } const $ = (id) => document.getElementById(id); @@ -369,7 +369,7 @@

Activity log

const wasmUrl = `${base()}/pkg/hacash_sdk_bg.wasm`; await wasm_bindgen(wasmUrl); state.wasm = wasm_bindgen; - el.textContent = "WASM SDK: ready — password stays in browser (hacd_stake / hacd_unstake)"; + el.textContent = "WASM SDK: ready — local sign only (password never sent to RPC)"; el.className = "meta ok"; updateActionButtons(); } catch (e) { @@ -383,18 +383,33 @@

Activity log

const pass = $("prikey").value.trim(); if (!pass) throw new Error("Password required for local signing"); const fee = $("fee").value.trim(); - const chainId = BigInt($("chainId").value.trim() || "1"); + const addr = $("address").value.trim(); const sel = [...state.selected]; if (!sel.length) throw new Error("Select at least one HACD"); const diamonds = sel.join(","); - const stake = kind === 34; - const fn = stake ? state.wasm.hacd_stake : state.wasm.hacd_unstake; const ts = txTimestamp(); - log(`[WASM] ${stake ? "hacd_stake" : "hacd_unstake"} for ${diamonds} (ts=${ts})…`); - const raw = fn(chainId, pass, diamonds, fee, ts); - const tx = parseSdkJson(raw); - log(`Signed locally ${tx.tx_hash}`); - const submitted = await apiPost("submit/transaction", hexToBytes(tx.tx_body)); + const txJson = JSON.stringify({ + main_address: addr, + timestamp: ts, + fee, + actions: [{ kind, diamonds }], + }); + log(`[RPC] create/transaction kind=${kind} for ${diamonds}…`); + const built = await apiPost("create/transaction", txJson, { action: "true", signature: "true" }); + const txBytes = hexToBytes(built.body); + const hashHex = built.hash_with_fee || built.hash; + log(`[WASM] sign hash ${hashHex.slice(0, 16)}…`); + const sigRaw = state.wasm.sign(pass, hashHex); + const sig = parseSdkJson(sigRaw); + log(`[RPC] attach signature for ${sig.address}…`); + const signed = await apiPost("util/transaction/sign", txBytes, { + pubkey: sig.pubkey, + sigdts: sig.sigdts, + signature: "true", + action: "true", + }); + const signedBytes = hexToBytes(signed.body); + const submitted = await apiPost("submit/transaction", signedBytes); log(`Submitted ${submitted.hash}`, "ok"); setTimeout(loadPortfolio, 3000); } From 4878f9c04fd93281a70b79f8ef49e517e2df3546 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Tue, 16 Jun 2026 01:09:04 +0200 Subject: [PATCH 16/35] =?UTF-8?q?fix(WASM):=20hacd=5Fstake=20=E2=80=94=20u?= =?UTF-8?q?se=20Utc::now=20in=20curtimes=20on=20wasm32=20(SystemTime=20uns?= =?UTF-8?q?upported)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 3 +++ scripts/test_hacd_stake.mjs | 50 +++++++++++++++++++++++++++++++++++++ src/sdk/web/transfer.rs | 13 +++++++--- src/sdk_lib.rs | 8 +++++- src/sys/time.rs | 15 +++++++++-- wallet/hip25/index.html | 35 ++++++++------------------ 6 files changed, 93 insertions(+), 31 deletions(-) create mode 100644 scripts/test_hacd_stake.mjs diff --git a/Cargo.toml b/Cargo.toml index 48fa1ee..0b7b82d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,9 @@ serde_json = "1.0.116" bytes = "1.6.0" wasm-bindgen = { version = "0.2.100", optional = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +console_error_panic_hook = "0.1" + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] libc = "0.2.4" leveldb-sys = "2.0.9" diff --git a/scripts/test_hacd_stake.mjs b/scripts/test_hacd_stake.mjs new file mode 100644 index 0000000..7b4c1b2 --- /dev/null +++ b/scripts/test_hacd_stake.mjs @@ -0,0 +1,50 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import vm from "vm"; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const pkgDir = path.join(root, "target", "debug", "pkg"); +const js = fs.readFileSync(path.join(pkgDir, "hacash_sdk.js"), "utf8"); +const wasm = fs.readFileSync(path.join(pkgDir, "hacash_sdk_bg.wasm")); + +const ctx = vm.createContext({ + console, + WebAssembly, + TextDecoder, + TextEncoder, + performance: globalThis.performance, + Date, + Math, + Reflect, + ArrayBuffer, + Uint8Array, + Int32Array, + Float64Array, + Promise, + URL, + location: { href: "http://localhost/" }, + document: { currentScript: { src: "http://localhost/hacash_sdk.js" } }, +}); +vm.runInContext(js.replace("let wasm_bindgen", "var wasm_bindgen"), ctx); +await ctx.wasm_bindgen({ module_or_path: wasm }); + +const raw = ctx.wasm_bindgen.hacd_stake(1n, "hip25test", "WTYUIA", "0:247", 1718496000n); +if (raw.startsWith("[ERROR]")) throw new Error(raw); +const tx = JSON.parse(`{${raw}}`); +console.log("hacd_stake OK", tx.tx_hash); + +const base = process.env.HACASH_RPC || "http://127.0.0.1:8083"; +try { + const body = Buffer.from(tx.tx_body, "hex"); + const res = await fetch(`${base}/submit/transaction`, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body, + }); + const out = await res.json(); + if (out.ret !== 0) throw new Error(JSON.stringify(out)); + console.log("submit OK", out.hash); +} catch (e) { + console.log("(submit skipped — node not running)", e.message); +} \ No newline at end of file diff --git a/src/sdk/web/transfer.rs b/src/sdk/web/transfer.rs index b71a6f1..fa4cd62 100644 --- a/src/sdk/web/transfer.rs +++ b/src/sdk/web/transfer.rs @@ -77,13 +77,20 @@ fn build_signed_stake_tx( if stake { let mut act = DiamondStake::new(); act.diamonds = dlist.clone(); - let _ = tx.push_action(Box::new(act)); + if let Err(e) = tx.push_action(Box::new(act)) { + return format!("[ERROR] push stake action: {}", e); + } } else { let mut act = DiamondUnstake::new(); act.diamonds = dlist.clone(); - let _ = tx.push_action(Box::new(act)); + if let Err(e) = tx.push_action(Box::new(act)) { + return format!("[ERROR] push unstake action: {}", e); + } + } + use crate::interface::protocol::Transaction; + if let Err(e) = tx.fill_sign(&acc) { + return format!("[ERROR] fill_sign: {}", e); } - let _ = tx.fill_sign(&acc); let label = if stake { "stake" } else { "unstake" }; stake_tx_json(&tx, &dlist, &fee, &acc, time_set, label) } diff --git a/src/sdk_lib.rs b/src/sdk_lib.rs index 2a67d6e..cd7f990 100644 --- a/src/sdk_lib.rs +++ b/src/sdk_lib.rs @@ -23,4 +23,10 @@ pub mod mint; #[macro_use] pub mod vm; -pub mod sdk; \ No newline at end of file +pub mod sdk; + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn wasm_panic_hook() { + console_error_panic_hook::set_once(); +} \ No newline at end of file diff --git a/src/sys/time.rs b/src/sys/time.rs index 1a3c285..7ab00e6 100644 --- a/src/sys/time.rs +++ b/src/sys/time.rs @@ -1,9 +1,20 @@ -use chrono::{DateTime, Local, TimeZone}; +use chrono::{DateTime, Local, TimeZone, Utc}; pub const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S"; pub fn curtimes() -> u64 { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as u64 + // std::time::SystemTime is unavailable on wasm32-unknown-unknown (browser WASM SDK). + #[cfg(target_arch = "wasm32")] + { + return Utc::now().timestamp() as u64; + } + #[cfg(not(target_arch = "wasm32"))] + { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as u64 + } } diff --git a/wallet/hip25/index.html b/wallet/hip25/index.html index 380879f..6d6b507 100644 --- a/wallet/hip25/index.html +++ b/wallet/hip25/index.html @@ -369,7 +369,7 @@

Activity log

const wasmUrl = `${base()}/pkg/hacash_sdk_bg.wasm`; await wasm_bindgen(wasmUrl); state.wasm = wasm_bindgen; - el.textContent = "WASM SDK: ready — local sign only (password never sent to RPC)"; + el.textContent = "WASM SDK: ready — hacd_stake / hacd_unstake (password stays in browser)"; el.className = "meta ok"; updateActionButtons(); } catch (e) { @@ -383,33 +383,18 @@

Activity log

const pass = $("prikey").value.trim(); if (!pass) throw new Error("Password required for local signing"); const fee = $("fee").value.trim(); - const addr = $("address").value.trim(); + const chainId = BigInt($("chainId").value.trim() || "1"); const sel = [...state.selected]; if (!sel.length) throw new Error("Select at least one HACD"); const diamonds = sel.join(","); - const ts = txTimestamp(); - const txJson = JSON.stringify({ - main_address: addr, - timestamp: ts, - fee, - actions: [{ kind, diamonds }], - }); - log(`[RPC] create/transaction kind=${kind} for ${diamonds}…`); - const built = await apiPost("create/transaction", txJson, { action: "true", signature: "true" }); - const txBytes = hexToBytes(built.body); - const hashHex = built.hash_with_fee || built.hash; - log(`[WASM] sign hash ${hashHex.slice(0, 16)}…`); - const sigRaw = state.wasm.sign(pass, hashHex); - const sig = parseSdkJson(sigRaw); - log(`[RPC] attach signature for ${sig.address}…`); - const signed = await apiPost("util/transaction/sign", txBytes, { - pubkey: sig.pubkey, - sigdts: sig.sigdts, - signature: "true", - action: "true", - }); - const signedBytes = hexToBytes(signed.body); - const submitted = await apiPost("submit/transaction", signedBytes); + const stake = kind === 34; + const fn = stake ? state.wasm.hacd_stake : state.wasm.hacd_unstake; + const ts = BigInt(txTimestamp()); + log(`[WASM] ${stake ? "hacd_stake" : "hacd_unstake"} for ${diamonds} (ts=${ts})…`); + const raw = fn(chainId, pass, diamonds, fee, ts); + const tx = parseSdkJson(raw); + log(`Signed locally ${tx.tx_hash}`); + const submitted = await apiPost("submit/transaction", hexToBytes(tx.tx_body)); log(`Submitted ${submitted.hash}`, "ok"); setTimeout(loadPortfolio, 3000); } From 753a9d397265ff60aa014d4b47249d75615de514 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Tue, 16 Jun 2026 01:21:12 +0200 Subject: [PATCH 17/35] fix(wallet): auto-retry HACD load until block 1 seeds testnet --- scripts/START_WALLET.bat | 22 ++++++++++- scripts/start_hip25_wallet.ps1 | 44 +++++++++++++++++----- wallet/hip25/index.html | 68 ++++++++++++++++++++++++++++++---- 3 files changed, 114 insertions(+), 20 deletions(-) diff --git a/scripts/START_WALLET.bat b/scripts/START_WALLET.bat index bc9e74e..58347a9 100644 --- a/scripts/START_WALLET.bat +++ b/scripts/START_WALLET.bat @@ -15,6 +15,11 @@ if not exist hacash.exe ( taskkill /IM hacash.exe /F >nul 2>&1 timeout /t 2 /nobreak >nul +if exist hacash_hip25_demo ( + echo Removing old chain data ^(fresh testnet seed^)... + rmdir /s /q hacash_hip25_demo +) + ( echo [default] echo data_dir = hacash_hip25_demo @@ -32,6 +37,7 @@ echo chain_id = 1 echo staking_activation_height = 1 echo hip25_testnet_seed = true echo hip25_testnet_seed_password = hip25test +echo hip25_testnet_demo_periods = true echo [miner] echo enable = true echo reward = 1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2 @@ -62,10 +68,22 @@ powershell -NoProfile -Command "try { Invoke-RestMethod 'http://127.0.0.1:8083/q if errorlevel 1 goto waitrpc echo RPC ready. -echo [3/3] Starting HIP25-POWORKER... +echo [3/4] Starting HIP25-POWORKER... start "HIP25-POWORKER" cmd /k "cd /d %cd% && title HIP25-POWORKER && hacash.exe poworker" -timeout /t 2 /nobreak >nul +echo [4/4] Waiting for block 1 ^(HACD seed, max 90s^)... +set /a tries=0 +:waitblock +set /a tries+=1 +if %tries% gtr 90 goto blockwarn +timeout /t 1 /nobreak >nul +powershell -NoProfile -Command "try { $r = Invoke-RestMethod 'http://127.0.0.1:8083/query/latest' -TimeoutSec 2; if ([int]$r.height -ge 1) { exit 0 } else { exit 1 } } catch { exit 1 }" >nul 2>&1 +if errorlevel 1 goto waitblock +echo Block 1 ready — 5 HACD seeded. +goto openwallet +:blockwarn +echo Block 1 not mined yet — wallet will auto-retry. +:openwallet start http://127.0.0.1:8083/hip25/wallet echo. diff --git a/scripts/start_hip25_wallet.ps1 b/scripts/start_hip25_wallet.ps1 index a383550..d6545a9 100644 --- a/scripts/start_hip25_wallet.ps1 +++ b/scripts/start_hip25_wallet.ps1 @@ -17,6 +17,11 @@ Set-Location $BinDir Get-Process hacash -ErrorAction SilentlyContinue | Stop-Process -Force Start-Sleep -Seconds 2 +if (Test-Path $DataDir) { + Write-Host "Removing old chain data (fresh testnet seed)..." -ForegroundColor Yellow + Remove-Item -Recurse -Force $DataDir +} + @" [default] data_dir = $DataDir @@ -34,6 +39,7 @@ chain_id = 1 staking_activation_height = 1 hip25_testnet_seed = true hip25_testnet_seed_password = hip25test +hip25_testnet_demo_periods = true [miner] enable = true reward = $SeedAddress @@ -50,12 +56,12 @@ nonce_max = 4294967295 notice_wait = 3 "@ | Set-Content "poworker.config.ini" -Write-Host "Starting fullnode (cmd window: HIP25-FULLNODE)..." -ForegroundColor Cyan +Write-Host "[1/4] Starting fullnode (cmd window: HIP25-FULLNODE)..." -ForegroundColor Cyan Start-Process cmd.exe -ArgumentList "/k", "cd /d $BinDir && title HIP25-FULLNODE && hacash.exe" -Write-Host "Waiting for RPC..." -ForegroundColor Cyan +Write-Host "[2/4] Waiting for RPC (max 60s)..." -ForegroundColor Cyan $ready = $false -for ($i = 0; $i -lt 40; $i++) { +for ($i = 0; $i -lt 60; $i++) { try { $null = Invoke-RestMethod "http://127.0.0.1:8083/query/latest" -TimeoutSec 2 $ready = $true @@ -65,19 +71,37 @@ for ($i = 0; $i -lt 40; $i++) { } } -if ($ready) { - Write-Host "RPC ready." -ForegroundColor Green -} else { - Write-Host "RPC not ready. Wait 10s then open $WalletUrl" -ForegroundColor Yellow +if (-not $ready) { + Write-Host "RPC not ready. Check HIP25-FULLNODE window." -ForegroundColor Red + exit 1 } +Write-Host " RPC ready." -ForegroundColor Green -Write-Host "Starting poworker (cmd window: HIP25-POWORKER)..." -ForegroundColor Cyan +Write-Host "[3/4] Starting poworker (cmd window: HIP25-POWORKER)..." -ForegroundColor Cyan Start-Process cmd.exe -ArgumentList "/k", "cd /d $BinDir && title HIP25-POWORKER && hacash.exe poworker" -Start-Sleep -Seconds 2 +Write-Host "[4/4] Waiting for block 1 (HACD seed, max 90s)..." -ForegroundColor Cyan +$mined = $false +for ($i = 0; $i -lt 90; $i++) { + Start-Sleep -Seconds 1 + try { + $r = Invoke-RestMethod "http://127.0.0.1:8083/query/latest" -TimeoutSec 2 + if ([int]$r.height -ge 1) { + $mined = $true + break + } + } catch {} +} + +if ($mined) { + Write-Host " Block 1 ready — 5 HACD seeded." -ForegroundColor Green +} else { + Write-Host " Block 1 not mined yet — wallet will auto-retry." -ForegroundColor Yellow +} + Start-Process $WalletUrl Write-Host "" Write-Host "Wallet: $WalletUrl" -ForegroundColor Yellow Write-Host "Keep open: HIP25-FULLNODE and HIP25-POWORKER cmd windows." -ForegroundColor White -Write-Host "In wallet: Fill testnet seed -> Load portfolio." -ForegroundColor White \ No newline at end of file +Write-Host "Testnet: address $SeedAddress, password hip25test" -ForegroundColor White \ No newline at end of file diff --git a/wallet/hip25/index.html b/wallet/hip25/index.html index 6d6b507..af090e1 100644 --- a/wallet/hip25/index.html +++ b/wallet/hip25/index.html @@ -120,6 +120,7 @@ .badge-Staked { background: #2e2640; color: var(--purple); } .badge-Cooldown { background: #3d3020; color: var(--amber); } .badge-unknown { background: #333; color: var(--muted); } + .badge-Loading { background: #243044; color: var(--muted); } .literal { font-family: ui-monospace, monospace; font-weight: 600; letter-spacing: 0.05em; } .meta { font-size: 0.75rem; color: var(--muted); } #log { @@ -216,6 +217,21 @@

Activity log

const $ = (id) => document.getElementById(id); const state = { diamonds: [], height: 0, selected: new Set(), wasm: null }; + let loadRetryTimer = null; + + function schedulePortfolioRetry() { + if (loadRetryTimer) return; + loadRetryTimer = setTimeout(() => { + loadRetryTimer = null; + loadPortfolio({ retry: true }).catch((e) => log(e.message, "err")); + }, 2000); + } + + function clearPortfolioRetry() { + if (!loadRetryTimer) return; + clearTimeout(loadRetryTimer); + loadRetryTimer = null; + } function base() { let b = $("rpc").value.trim().replace(/\/$/, ""); @@ -234,7 +250,12 @@

Activity log

async function apiGet(path, params = {}) { const qs = new URLSearchParams(params).toString(); const url = `${base()}/${path}${qs ? "?" + qs : ""}`; - const r = await fetch(url); + let r; + try { + r = await fetch(url); + } catch (e) { + throw new Error(`RPC unreachable at ${base()} — run START_WALLET.bat and keep HIP25-FULLNODE open`); + } const j = await r.json(); if (j.ret !== 0) throw new Error(j.err || JSON.stringify(j)); return j; @@ -308,10 +329,10 @@

Activity log

$("btnUnstake").disabled = !wasmOk || !canUnstake; } - async function loadPortfolio() { + async function loadPortfolio(opts = {}) { const addr = $("address").value.trim(); if (!addr) throw new Error("Address required"); - log("Loading portfolio…"); + if (!opts.retry) log("Loading portfolio…"); const latest = await apiGet("query/latest"); state.height = latest.height; $("sHeight").textContent = latest.height; @@ -329,15 +350,39 @@

Activity log

$("sAccrued").textContent = summary.total_accrued_reward || "0"; const names = splitDiamonds(entry.diamonds || ""); - state.diamonds = []; + if (!names.length && state.height < 1) { + log("Waiting for block 1 (testnet seed loads with first block)…"); + schedulePortfolioRetry(); + return; + } + clearPortfolioRetry(); + if (!names.length) { + state.diamonds = []; + state.selected.clear(); + renderDiamonds(); + log(`No HACD on ${addr} — use Fill testnet (1Do17Buq...)`, "err"); + return; + } state.selected.clear(); + state.diamonds = names.map((literal) => ({ + literal, + status: "Loading", + accrued_reward: "0", + min_unstake_height: 0, + })); + renderDiamonds(); await Promise.all( - names.map(async (literal) => { + names.map(async (literal, idx) => { try { const st = await apiGet("query/staking/status", { diamond: literal }); - state.diamonds.push(st); + state.diamonds[idx] = { + literal: st.literal || literal, + status: st.status || "unknown", + accrued_reward: st.accrued_reward || "0", + min_unstake_height: st.min_unstake_height || 0, + }; } catch (e) { - state.diamonds.push({ literal, status: "unknown", accrued_reward: "0", min_unstake_height: 0 }); + state.diamonds[idx] = { literal, status: "unknown", accrued_reward: "0", min_unstake_height: 0 }; } }) ); @@ -400,12 +445,17 @@

Activity log

} $("rpc").value = window.location.origin || "http://127.0.0.1:8083"; + $("address").value = TESTNET.address; + $("prikey").value = TESTNET.password; $("btnTestnet").onclick = () => { $("address").value = TESTNET.address; $("prikey").value = TESTNET.password; $("fee").value = TESTNET.fee; + $("chainId").value = "1"; + $("rpc").value = window.location.origin || "http://127.0.0.1:8083"; log("Filled HIP-25 testnet seed account (password: hip25test)"); + loadPortfolio().catch((e) => log(e.message, "err")); }; $("btnLoad").onclick = () => loadPortfolio().catch((e) => log(e.message, "err")); @@ -421,7 +471,9 @@

Activity log

}; $("btnSelNone").onclick = () => { state.selected.clear(); renderDiamonds(); }; - initWasmSdk(); + initWasmSdk().then(() => { + setTimeout(() => loadPortfolio().catch((e) => log(e.message, "err")), 1500); + }); \ No newline at end of file From eadb8395d520ec7bd7e790208d26c15d536786b0 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Tue, 16 Jun 2026 01:25:31 +0200 Subject: [PATCH 18/35] fix(wallet): parse WASM stake JSON without double-wrapping braces --- scripts/test_hacd_stake.mjs | 2 +- wallet/hip25/index.html | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/test_hacd_stake.mjs b/scripts/test_hacd_stake.mjs index 7b4c1b2..79622aa 100644 --- a/scripts/test_hacd_stake.mjs +++ b/scripts/test_hacd_stake.mjs @@ -31,7 +31,7 @@ await ctx.wasm_bindgen({ module_or_path: wasm }); const raw = ctx.wasm_bindgen.hacd_stake(1n, "hip25test", "WTYUIA", "0:247", 1718496000n); if (raw.startsWith("[ERROR]")) throw new Error(raw); -const tx = JSON.parse(`{${raw}}`); +const tx = JSON.parse(raw.trim().startsWith("{") ? raw.trim() : `{${raw.trim()}}`); console.log("hacd_stake OK", tx.tx_hash); const base = process.env.HACASH_RPC || "http://127.0.0.1:8083"; diff --git a/wallet/hip25/index.html b/wallet/hip25/index.html index af090e1..218618c 100644 --- a/wallet/hip25/index.html +++ b/wallet/hip25/index.html @@ -393,7 +393,10 @@

Activity log

function parseSdkJson(raw) { if (!raw || raw.startsWith("[ERROR]")) throw new Error(raw || "SDK error"); - return JSON.parse(`{${raw}}`); + const trimmed = raw.trim(); + // hacd_stake / hacd_unstake already return a full JSON object + if (trimmed.startsWith("{")) return JSON.parse(trimmed); + return JSON.parse(`{${trimmed}}`); } function loadSdkScript() { From 2b805357f9535c15e819f40859f1da55f5880aad Mon Sep 17 00:00:00 2001 From: Moskyera Date: Tue, 16 Jun 2026 01:41:06 +0200 Subject: [PATCH 19/35] security(HIP-25): remediate all 3 audit findings for mainnet readiness --- hacash_mainnet_hip25.config.ini.example | 1 + scripts/START_WALLET.bat | 1 + scripts/start_hip25_wallet.ps1 | 1 + src/chain/engine/init.rs | 6 +- src/chain/engine/read.rs | 3 +- src/config/server.rs | 4 +- src/config/util.rs | 2 +- src/core/field/diamond.rs | 8 ++ src/mint/action/diamond_staking.rs | 2 +- src/mint/checker/initialize.rs | 5 +- src/mint/component/staking.rs | 22 ++- src/mint/operate/staking.rs | 98 +++++++++++-- src/protocol/action/script.rs | 2 +- src/sdk/web/account.rs | 4 +- src/server/ctx/ctx.rs | 6 +- src/server/http/start.rs | 31 +++- src/server/mod.rs | 1 + src/server/rpc/create_account.rs | 4 + src/server/rpc/create_transfer.rs | 4 + src/server/rpc/fee.rs | 4 + src/server/rpc/latest.rs | 3 + src/server/rpc/routes.rs | 14 +- src/server/rpc/staking.rs | 33 ++++- src/server/rpc/transaction.rs | 6 +- src/server/rpc/wallet_ui.rs | 9 +- src/server/security.rs | 183 ++++++++++++++++++++++++ src/vm/staking_hvm.rs | 6 +- wallet/hip25/index.html | 28 +++- 28 files changed, 430 insertions(+), 61 deletions(-) create mode 100644 src/server/security.rs diff --git a/hacash_mainnet_hip25.config.ini.example b/hacash_mainnet_hip25.config.ini.example index b303403..5820f91 100644 --- a/hacash_mainnet_hip25.config.ini.example +++ b/hacash_mainnet_hip25.config.ini.example @@ -6,6 +6,7 @@ data_dir = hacash_mainnet_data [server] enable = true listen = 8081 +listen_host = 127.0.0.1 recent_blocks = false average_fee_purity = false diff --git a/scripts/START_WALLET.bat b/scripts/START_WALLET.bat index 58347a9..6d7d546 100644 --- a/scripts/START_WALLET.bat +++ b/scripts/START_WALLET.bat @@ -26,6 +26,7 @@ echo data_dir = hacash_hip25_demo echo [server] echo enable = true echo listen = 8083 +echo listen_host = 127.0.0.1 echo recent_blocks = false echo average_fee_purity = false echo [node] diff --git a/scripts/start_hip25_wallet.ps1 b/scripts/start_hip25_wallet.ps1 index d6545a9..700f78a 100644 --- a/scripts/start_hip25_wallet.ps1 +++ b/scripts/start_hip25_wallet.ps1 @@ -28,6 +28,7 @@ data_dir = $DataDir [server] enable = true listen = 8083 +listen_host = 127.0.0.1 recent_blocks = false average_fee_purity = false [node] diff --git a/src/chain/engine/init.rs b/src/chain/engine/init.rs index 5de3f59..c5a2542 100644 --- a/src/chain/engine/init.rs +++ b/src/chain/engine/init.rs @@ -66,7 +66,11 @@ fn _do_rebuild(this: &mut BlockEngine) { // try insert let ier = this.insert_unsafe(resblk); if let Err(e) = ier { - panic!("[State Panic] rebuild block state error: {}", e); + eprintln!( + "[State Fatal] rebuild block state error at height {}: {}", + next_height, e + ); + std::process::exit(1); } // next std::io::stdout().flush().unwrap(); diff --git a/src/chain/engine/read.rs b/src/chain/engine/read.rs index 5379bf8..4261472 100644 --- a/src/chain/engine/read.rs +++ b/src/chain/engine/read.rs @@ -6,7 +6,8 @@ impl EngineRead for BlockEngine { } fn state(&self) -> Arc { - self.klctx.lock().unwrap().state.upgrade().unwrap() + self.get_latest_state() + .expect("chain state unavailable during rebuild; retry shortly") } fn store(&self) -> Arc { diff --git a/src/config/server.rs b/src/config/server.rs index 92bf9e0..0c81c7b 100644 --- a/src/config/server.rs +++ b/src/config/server.rs @@ -1,8 +1,9 @@ -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct ServerConf { pub enable: bool, pub listen: u16, + pub listen_host: String, pub multi_thread: bool, } @@ -15,6 +16,7 @@ impl ServerConf { let mut cnf = ServerConf{ enable: ini_must_bool(&sec, "enable", false), listen: ini_must_u64(&sec, "listen", 8083) as u16, + listen_host: ini_must(&sec, "listen_host", "127.0.0.1"), multi_thread: ini_must_bool(&sec, "multi_thread", false), }; diff --git a/src/config/util.rs b/src/config/util.rs index c54134a..779662f 100644 --- a/src/config/util.rs +++ b/src/config/util.rs @@ -73,7 +73,7 @@ pub fn ini_must_address(sec: &HashMap>, key: &str) -> Add pub fn ini_must_account(sec: &HashMap>, key: &str) -> Account { let pass = ini_must(sec, key, "123456"); let Ok(acc) = Account::create_by(&pass) else { - panic!("[Config Error] account password {} error.", &pass) + panic!("[Config Error] account key '{}' invalid password or prikey format.", key) }; acc } diff --git a/src/core/field/diamond.rs b/src/core/field/diamond.rs index 1d7dde3..e9d3f98 100644 --- a/src/core/field/diamond.rs +++ b/src/core/field/diamond.rs @@ -79,6 +79,14 @@ impl DiamondNameListMax200 { return errf!("diamond name {} is not valid", v.readable()) } } + // reject duplicates + for (i, a) in self.lists.iter().enumerate() { + for b in self.lists.iter().skip(i + 1) { + if a.as_ref() == b.as_ref() { + return errf!("duplicate diamond {} in list", a.readable()) + } + } + } // success Ok(reallen as u8) } diff --git a/src/mint/action/diamond_staking.rs b/src/mint/action/diamond_staking.rs index 9c890f3..da6ae34 100644 --- a/src/mint/action/diamond_staking.rs +++ b/src/mint/action/diamond_staking.rs @@ -54,6 +54,6 @@ fn diamond_unstake( let staker = ctx.main_address(); let height = ctx.pending_height(); let mut state = MintState::wrap(sta); - staking_apply_unstake(&mut state, staker, &this.diamonds, height)?; + staking_apply_unstake(&mut state, staker, &this.diamonds, height, ctx.chain_id())?; Ok(vec![]) } \ No newline at end of file diff --git a/src/mint/checker/initialize.rs b/src/mint/checker/initialize.rs index 7d2fb69..deaa012 100644 --- a/src/mint/checker/initialize.rs +++ b/src/mint/checker/initialize.rs @@ -41,9 +41,8 @@ fn impl_initialize(this: &BlockMintChecker, db: &mut dyn State) -> RetErr { let mut core = CoreState::wrap(db); core.set_balance(&owner, &Balance::hacash(fee_hac)); println!( - "[HIP-25 testnet seed] 5 HACD (WTYUIA,HXVMEK,VMEKBS,UIASHX,MEKUIA) + 11 HAC -> {} (password: {})", - owner.readable(), - &this.cnf.hip25_testnet_seed_password + "[HIP-25 testnet seed] 5 HACD (WTYUIA,HXVMEK,VMEKBS,UIASHX,MEKUIA) + 11 HAC -> {} (see docs for dev password)", + owner.readable() ); } diff --git a/src/mint/component/staking.rs b/src/mint/component/staking.rs index 9ad7691..7f7f393 100644 --- a/src/mint/component/staking.rs +++ b/src/mint/component/staking.rs @@ -100,14 +100,24 @@ impl GlobalStakingState { height >= self.activation_height.uint() } - pub fn effective_min_stake_blocks(&self) -> u64 { - let v = self.demo_min_stake_blocks.uint(); - if v > 0 { v } else { MIN_STAKE_BLOCKS } + pub fn effective_min_stake_blocks(&self, chain_id: u64) -> u64 { + if chain_id == crate::config::HIP25_DEV_CHAIN_ID { + let v = self.demo_min_stake_blocks.uint(); + if v > 0 { + return v; + } + } + MIN_STAKE_BLOCKS } - pub fn effective_cooldown_blocks(&self) -> u64 { - let v = self.demo_cooldown_blocks.uint(); - if v > 0 { v } else { COOLDOWN_BLOCKS } + pub fn effective_cooldown_blocks(&self, chain_id: u64) -> u64 { + if chain_id == crate::config::HIP25_DEV_CHAIN_ID { + let v = self.demo_cooldown_blocks.uint(); + if v > 0 { + return v; + } + } + COOLDOWN_BLOCKS } } diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs index 7634994..f267acb 100644 --- a/src/mint/operate/staking.rs +++ b/src/mint/operate/staking.rs @@ -192,15 +192,15 @@ pub fn staking_process_unlock_queue(base_state: &mut dyn State, height: u64) -> for (key, entry) in pending { let reward = entry.reward.clone(); let staker = entry.staker.clone(); + if reward.is_positive() { + let mut core_state = CoreState::wrap(base_state); + hac_add(&mut core_state, &staker, &reward)?; + } { let mut mint_state = MintState::wrap(base_state); staking_finalize_unlock(&mut mint_state, &entry)?; mint_state.del_staking_unlock_entry(&key); } - if reward.is_positive() { - let mut core_state = CoreState::wrap(base_state); - hac_add(&mut core_state, &staker, &reward)?; - } } Ok(()) @@ -263,13 +263,16 @@ pub fn staking_exec_hvm_external( payload: &[u8], staker: &Address, height: u64, + chain_id: u64, base_state: &mut dyn State, ) -> Ret<()> { let diamonds = staking_parse_hvm_diamonds(payload)?; let mut mint_state = MintState::wrap(base_state); match opcode { STAKE_HACD_VMKIND => staking_apply_stake(&mut mint_state, staker, &diamonds, height), - UNSTAKE_HACD_VMKIND => staking_apply_unstake(&mut mint_state, staker, &diamonds, height), + UNSTAKE_HACD_VMKIND => { + staking_apply_unstake(&mut mint_state, staker, &diamonds, height, chain_id) + } _ => errf!("unknown HIP-25 HVM opcode {}", opcode), } } @@ -336,6 +339,7 @@ pub fn staking_apply_unstake( staker: &Address, diamonds: &DiamondNameListMax200, height: u64, + chain_id: u64, ) -> Ret<()> { diamonds.check()?; let global = state.staking_global(); @@ -363,8 +367,8 @@ pub fn staking_apply_unstake( ); let stake_height = record.stake_height.uint(); let global_snap = state.staking_global(); - let min_stake = global_snap.effective_min_stake_blocks(); - let cooldown = global_snap.effective_cooldown_blocks(); + let min_stake = global_snap.effective_min_stake_blocks(chain_id); + let cooldown = global_snap.effective_cooldown_blocks(chain_id); if height < stake_height + min_stake { return errf!( "diamond {} must remain staked for at least {} blocks", @@ -528,7 +532,7 @@ mod staking_tests { let stake_h = 1000u64; staking_apply_stake(&mut mint, &staker, &list, stake_h).unwrap(); let too_early = stake_h + MIN_STAKE_BLOCKS - 1; - let err = staking_apply_unstake(&mut mint, &staker, &list, too_early).unwrap_err(); + let err = staking_apply_unstake(&mut mint, &staker, &list, too_early, 0).unwrap_err(); assert!(format!("{}", err).contains("at least")); } @@ -544,7 +548,7 @@ mod staking_tests { staking_deposit_fee(&mut mint, 1000); staking_distribute_rewards(&mut mint, stake_h).unwrap(); let unstake_h = stake_h + MIN_STAKE_BLOCKS; - staking_apply_unstake(&mut mint, &staker, &list, unstake_h).unwrap(); + staking_apply_unstake(&mut mint, &staker, &list, unstake_h, 0).unwrap(); let unlock_h = unstake_h + COOLDOWN_BLOCKS; staking_on_block_close(&mut state, unlock_h).unwrap(); let mint = MintStateDisk::wrap(&state); @@ -575,6 +579,7 @@ mod staking_tests { &s1, &one_diamond_list("WTYUIA"), 100 + MIN_STAKE_BLOCKS, + 0, ) .unwrap(); let pending = r1.reward_index.uint(); @@ -595,7 +600,7 @@ mod staking_tests { let err = staking_apply_stake(&mut mint, &staker, &one_diamond_list("HXVMEK"), 2000).unwrap_err(); assert!(format!("{}", err).contains("paused")); - staking_apply_unstake(&mut mint, &staker, &list, 1000 + MIN_STAKE_BLOCKS).unwrap(); + staking_apply_unstake(&mut mint, &staker, &list, 1000 + MIN_STAKE_BLOCKS, 0).unwrap(); } #[test] @@ -649,7 +654,7 @@ mod staking_tests { ) .unwrap(); let unstake_h = stake_h + MIN_STAKE_BLOCKS; - staking_apply_unstake(&mut mint, &s1, &list1, unstake_h).unwrap(); + staking_apply_unstake(&mut mint, &s1, &list1, unstake_h, 0).unwrap(); staking_deposit_fee(&mut mint, 2000); staking_distribute_rewards(&mut mint, unstake_h).unwrap(); let rec = mint.staking_record(&lit(b"WTYUIA")).unwrap(); @@ -704,7 +709,7 @@ mod staking_tests { seed_diamond(&mut state, "WTYUIA", &staker); let mut wire = vec![STAKE_HACD_VMKIND]; wire.extend(one_diamond_list("WTYUIA").serialize()); - exec_staking_script(&wire, &staker, 5000, &mut state).unwrap(); + exec_staking_script(&wire, &staker, 5000, 0, &mut state).unwrap(); let mint = MintStateDisk::wrap(&state); assert_eq!(mint.diamond(&lit(b"WTYUIA")).unwrap().status, DIAMOND_STATUS_STAKED); } @@ -715,7 +720,7 @@ mod staking_tests { let staker = test_staker(); seed_diamond(&mut state, "WTYUIA", &staker); let wire = one_diamond_list("WTYUIA").serialize(); - staking_exec_hvm_external(STAKE_HACD_VMKIND, &wire, &staker, 5000, &mut state).unwrap(); + staking_exec_hvm_external(STAKE_HACD_VMKIND, &wire, &staker, 5000, 0, &mut state).unwrap(); let mint = MintStateDisk::wrap(&state); let dian = lit(b"WTYUIA"); assert_eq!(mint.diamond(&dian).unwrap().status, DIAMOND_STATUS_STAKED); @@ -724,6 +729,7 @@ mod staking_tests { &wire, &staker, 5000 + MIN_STAKE_BLOCKS, + 0, &mut state, ) .unwrap(); @@ -740,13 +746,77 @@ mod staking_tests { let stake_h = 1000u64; let mut mint = MintState::wrap(&mut state); staking_apply_stake(&mut mint, &staker, &list, stake_h).unwrap(); - staking_apply_unstake(&mut mint, &staker, &list, stake_h + MIN_STAKE_BLOCKS).unwrap(); + staking_apply_unstake(&mut mint, &staker, &list, stake_h + MIN_STAKE_BLOCKS, 0).unwrap(); mint.del_staking_unlock_entry(&Uint5::from(0)); let err = staking_process_unlock_queue(&mut state, stake_h + MIN_STAKE_BLOCKS + COOLDOWN_BLOCKS) .unwrap_err(); assert!(format!("{}", err).contains("unlock queue corrupted")); } + #[test] + fn non_owner_stake_rejected() { + let (_dir, mut state) = test_state(); + let owner = test_staker(); + let other = test_other(); + seed_diamond(&mut state, "WTYUIA", &owner); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + let err = staking_apply_stake(&mut mint, &other, &list, 1000).unwrap_err(); + assert!(format!("{}", err).contains("not belong")); + } + + #[test] + fn non_owner_unstake_rejected() { + let (_dir, mut state) = test_state(); + let owner = test_staker(); + let other = test_other(); + seed_diamond(&mut state, "WTYUIA", &owner); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &owner, &list, 1000).unwrap(); + let err = + staking_apply_unstake(&mut mint, &other, &list, 1000 + MIN_STAKE_BLOCKS, 0).unwrap_err(); + assert!(format!("{}", err).contains("not belong")); + } + + #[test] + fn stake_during_cooldown_rejected() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + let stake_h = 1000u64; + staking_apply_stake(&mut mint, &staker, &list, stake_h).unwrap(); + staking_apply_unstake(&mut mint, &staker, &list, stake_h + MIN_STAKE_BLOCKS, 0).unwrap(); + let err = staking_apply_stake(&mut mint, &staker, &list, stake_h + MIN_STAKE_BLOCKS + 1) + .unwrap_err(); + assert!(format!("{}", err).contains("cannot be staked")); + } + + #[test] + fn duplicate_diamond_in_batch_rejected() { + let mut list = DiamondNameListMax200::default(); + list.push(lit(b"WTYUIA")).unwrap(); + list.push(lit(b"WTYUIA")).unwrap(); + let err = list.check().unwrap_err(); + assert!(format!("{}", err).contains("duplicate")); + } + + #[test] + fn demo_periods_ignored_on_mainnet_chain_id() { + let (_dir, mut state) = test_state(); + let mut mint = MintState::wrap(&mut state); + let mut global = mint.staking_global(); + global.demo_min_stake_blocks = Uint5::from(5); + global.demo_cooldown_blocks = Uint5::from(3); + mint.set_staking_global(&global); + let g = mint.staking_global(); + assert_eq!(g.effective_min_stake_blocks(0), MIN_STAKE_BLOCKS); + assert_eq!(g.effective_cooldown_blocks(0), COOLDOWN_BLOCKS); + assert_eq!(g.effective_min_stake_blocks(crate::config::HIP25_DEV_CHAIN_ID), 5); + } + #[test] fn hip25_dev_flags_rejected_on_mainnet_chain_id() { use crate::config::{HIP25_DEV_CHAIN_ID, MintConf}; diff --git a/src/protocol/action/script.rs b/src/protocol/action/script.rs index e6a9467..cc453e1 100644 --- a/src/protocol/action/script.rs +++ b/src/protocol/action/script.rs @@ -29,7 +29,7 @@ fn script_execute_staking( let staker = ctx.main_address(); let height = ctx.pending_height(); let codes = this.codes.as_ref(); - vm::exec_staking_script(codes, staker, height, sta)?; + vm::exec_staking_script(codes, staker, height, ctx.chain_id(), sta)?; Ok(vec![]) } diff --git a/src/sdk/web/account.rs b/src/sdk/web/account.rs index 3ef58f2..32d1c67 100644 --- a/src/sdk/web/account.rs +++ b/src/sdk/web/account.rs @@ -4,9 +4,7 @@ pub fn create_account_by(s: String) -> String { let acc = or_return!{ "create account", Account::create_by(&s) }; // ok let accstr = acc.readable(); - let acckey = hex::encode(acc.secret_key().serialize()); let accpub = hex::encode(acc.public_key().serialize_compressed()); - // format!("{},{},{}", acckey, accpub, accstr) - let ok = format!(r##""private_key":"{}","public_key":"{}","address":"{}""##, acckey, accpub, accstr); + let ok = format!(r##""public_key":"{}","address":"{}""##, accpub, accstr); format!("{{{}}}", ok) } diff --git a/src/server/ctx/ctx.rs b/src/server/ctx/ctx.rs index e7cdae6..bb11dab 100644 --- a/src/server/ctx/ctx.rs +++ b/src/server/ctx/ctx.rs @@ -11,17 +11,21 @@ pub struct ApiCtx { pub hcshnd: ChainNode, pub blocks: BlockCaches, pub miner_worker_notice_count: Arc>, + pub listen_host: String, + pub rate_limiter: Arc, blocks_max: usize, // 4 } impl ApiCtx { - pub fn new(eng: ChainEngine, nd: ChainNode) -> ApiCtx { + pub fn new(eng: ChainEngine, nd: ChainNode, listen_host: String) -> ApiCtx { ApiCtx{ engine: eng, hcshnd: nd, blocks: Arc::default(), miner_worker_notice_count: Arc::default(), + listen_host, + rate_limiter: Arc::new(crate::server::security::RateLimiter::new(30, 60)), blocks_max: 4, } } diff --git a/src/server/http/start.rs b/src/server/http/start.rs index 5f3c6d5..4421644 100644 --- a/src/server/http/start.rs +++ b/src/server/http/start.rs @@ -17,22 +17,39 @@ impl RPCServer { async fn server_listen(mut ser: RPCServer) { + use axum::extract::DefaultBodyLimit; + use std::net::IpAddr; + use axum::Extension; + use crate::server::security::{MiddlewareCtx, RPC_BODY_LIMIT_BYTES, security_middleware}; + let port = ser.cnf.listen; - let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let host = ser.cnf.listen_host.clone(); + let ip: IpAddr = host.parse().unwrap_or_else(|_| "127.0.0.1".parse().unwrap()); + let addr = SocketAddr::from((ip, port)); let listener = TcpListener::bind(addr).await; if let Err(ref e) = listener { - println!("\n[Error] RPC Server bind port {} error: {}\n", port, e); + println!("\n[Error] RPC Server bind {}:{} error: {}\n", host, port, e); return } let listener = listener.unwrap(); println!("[RPC Server] Listening on http://{addr}"); - // - let app = rpc::routes(ApiCtx::new( + // + let ctx = ApiCtx::new( ser.engine.clone(), ser.hcshnd.clone(), - )); + host.clone(), + ); + let mw = MiddlewareCtx { + listen_host: host, + rate_limiter: ctx.rate_limiter.clone(), + }; + let app = rpc::routes(ctx) + .layer(DefaultBodyLimit::max(RPC_BODY_LIMIT_BYTES)) + .layer(Extension(mw)) + .layer(axum::middleware::from_fn(security_middleware)); println!("[RPC Server] HIP-25 wallet UI: http://{addr}/hip25/wallet"); - if let Err(e) = axum::serve(listener, app).await { + let make_svc = app.into_make_service_with_connect_info::(); + if let Err(e) = axum::serve(listener, make_svc).await { println!("{e}"); } -} +} \ No newline at end of file diff --git a/src/server/mod.rs b/src/server/mod.rs index f12e0e3..595a286 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -12,6 +12,7 @@ include!("util.rs"); pub mod ctx; mod extend; mod unstable; +pub mod security; mod rpc; pub mod http; diff --git a/src/server/rpc/create_account.rs b/src/server/rpc/create_account.rs index 007d573..9d1d2e2 100644 --- a/src/server/rpc/create_account.rs +++ b/src/server/rpc/create_account.rs @@ -5,6 +5,10 @@ defineQueryObject!{ Q8936, } async fn account(State(ctx): State, q: Query) -> impl IntoResponse { + let chain_id = ctx.engine.config().chain_id; + if let Some(msg) = crate::server::security::reject_create_account_on_mainnet(chain_id) { + return api_error(msg); + } q_must!(q, quantity, 1); if quantity == 0 { return api_error("quantity error") diff --git a/src/server/rpc/create_transfer.rs b/src/server/rpc/create_transfer.rs index 89bbff0..c9d0528 100644 --- a/src/server/rpc/create_transfer.rs +++ b/src/server/rpc/create_transfer.rs @@ -12,6 +12,10 @@ defineQueryObject!{ Q9374, } async fn create_coin_transfer(State(ctx): State, q: Query) -> impl IntoResponse { + let chain_id = ctx.engine.config().chain_id; + if let Some(msg) = crate::server::security::reject_server_secret_signing(chain_id) { + return api_error(msg); + } ctx_state!(ctx, state); q_must!(q, from_prikey, s!("")); q_must!(q, timestamp, 0); diff --git a/src/server/rpc/fee.rs b/src/server/rpc/fee.rs index 9be650e..339fd79 100644 --- a/src/server/rpc/fee.rs +++ b/src/server/rpc/fee.rs @@ -37,6 +37,10 @@ defineQueryObject!{ Q5396, } async fn raise_fee(State(ctx): State, q: Query, body: Bytes) -> impl IntoResponse { + let chain_id = ctx.engine.config().chain_id; + if let Some(msg) = crate::server::security::reject_server_secret_signing(chain_id) { + return api_error(msg); + } // ctx_store!(ctx, store); q_must!(q, hash, s!("")); let fee = q_data_amt!(q, fee); diff --git a/src/server/rpc/latest.rs b/src/server/rpc/latest.rs index 0adfe11..a617564 100644 --- a/src/server/rpc/latest.rs +++ b/src/server/rpc/latest.rs @@ -10,9 +10,12 @@ async fn latest(State(ctx): State, q: Query) -> impl IntoResponse let lasthei = ctx.engine.latest_block().objc().height().uint(); let lastdia = mintstate.latest_diamond(); // return data + let chain_id = ctx.engine.config().chain_id; let mut data = jsondata!{ "height", lasthei, "diamond", lastdia.number.uint(), + "chain_id", chain_id, + "hip25_dev", chain_id == crate::config::HIP25_DEV_CHAIN_ID, }; api_data(data) } diff --git a/src/server/rpc/routes.rs b/src/server/rpc/routes.rs index a57e2bb..1e20f8f 100644 --- a/src/server/rpc/routes.rs +++ b/src/server/rpc/routes.rs @@ -46,7 +46,7 @@ pub fn routes(mut ctx: ApiCtx) -> Router { // create .route(&create("account"), get(account)) .route(&create("transaction"), post(transaction_build)) - .route(&create("coin/transfer"), get(create_coin_transfer)) + .route(&create("coin/transfer"), post(create_coin_transfer)) // submit .route(&submit("transaction"), post(submit_transaction)) @@ -64,11 +64,13 @@ pub fn routes(mut ctx: ApiCtx) -> Router { ; - // merge unstable & extend - Router::new().merge(lrt) - .merge(unstable::routes()) - .merge(extend::routes()) - .with_state(ctx) + // merge extend (unstable test routes disabled in release builds) + let mut router = Router::new().merge(lrt).merge(extend::routes()); + #[cfg(debug_assertions)] + { + router = router.merge(unstable::routes()); + } + router.with_state(ctx) } diff --git a/src/server/rpc/staking.rs b/src/server/rpc/staking.rs index b68db45..037ad56 100644 --- a/src/server/rpc/staking.rs +++ b/src/server/rpc/staking.rs @@ -29,7 +29,8 @@ async fn staking_status(State(ctx): State, q: Query) -> stake_height = rec.stake_height.uint(); unlock_height = rec.unlock_height.uint(); if stake_height > 0 { - min_unstake_height = stake_height + global.effective_min_stake_blocks(); + let chain_id = ctx.engine.config().chain_id; + min_unstake_height = stake_height + global.effective_min_stake_blocks(chain_id); } if let Ok(amt) = staking_display_accrued_reward(&global.global_reward_index, &rec) { accrued_reward = amt.to_unit_string(&unit); @@ -49,6 +50,8 @@ async fn staking_status(State(ctx): State, q: Query) -> defineQueryObject!{ QStakingSummary, address, String, s!(""), + offset, String, s!("0"), + limit, String, s!("200"), } async fn staking_summary(State(ctx): State, q: Query) -> impl IntoResponse { @@ -62,15 +65,34 @@ async fn staking_summary(State(ctx): State, q: Query) - let owned = mintstate.diamond_owned(&adr).unwrap_or_default(); let names = owned.readable(); let global = mintstate.staking_global(); + let mut offset = q.offset.parse::().unwrap_or(0); + let mut limit = q.limit.parse::().unwrap_or(200); + if limit == 0 { + limit = 200; + } + if limit > crate::server::security::STAKING_SUMMARY_MAX_DIAMONDS { + limit = crate::server::security::STAKING_SUMMARY_MAX_DIAMONDS; + } + let l = DiamondName::width(); + let bytes = names.as_bytes(); + let total_owned = bytes.len() / l; let mut staked_count = 0u64; let mut cooldown_count = 0u64; let mut total_accrued = Amount::default(); - let l = DiamondName::width(); - let bytes = names.as_bytes(); + let mut processed = 0usize; + let mut skipped = 0usize; for i in (0..bytes.len()).step_by(l) { if i + l > bytes.len() { break; } + if skipped < offset { + skipped += 1; + continue; + } + if processed >= limit { + break; + } + processed += 1; let dian = DiamondName::cons(bytes[i..i + l].try_into().unwrap()); let Some(diaobj) = mintstate.diamond(&dian) else { continue; @@ -86,10 +108,15 @@ async fn staking_summary(State(ctx): State, q: Query) - } } } + let truncated = offset + processed < total_owned; let data = jsondata!{ "staked_count", staked_count, "cooldown_count", cooldown_count, "total_accrued_reward", total_accrued.to_unit_string(&unit), + "total_owned", total_owned as u64, + "offset", offset as u64, + "limit", limit as u64, + "truncated", truncated, }; api_data(data) } diff --git a/src/server/rpc/transaction.rs b/src/server/rpc/transaction.rs index 92f1829..d5a4c52 100644 --- a/src/server/rpc/transaction.rs +++ b/src/server/rpc/transaction.rs @@ -27,10 +27,8 @@ async fn transaction_sign(State(ctx): State, q: Query, body: Byte q_must!(q, description, false); let chain_id = ctx.engine.config().chain_id; - if prikey.len() == 64 && chain_id != crate::config::HIP25_DEV_CHAIN_ID { - return api_error( - "server-side prikey signing is disabled on mainnet; use client-side WASM signing", - ); + if let Some(msg) = crate::server::security::reject_server_secret_signing(chain_id) { + return api_error(msg); } let lasthei = ctx.engine.latest_block().objc().height().uint(); diff --git a/src/server/rpc/wallet_ui.rs b/src/server/rpc/wallet_ui.rs index a020f3f..54ac5de 100644 --- a/src/server/rpc/wallet_ui.rs +++ b/src/server/rpc/wallet_ui.rs @@ -26,7 +26,14 @@ fn serve_pkg_file(name: &str, content_type: &'static str) -> Response { }; let path = dir.join(name); match std::fs::read(&path) { - Ok(bytes) => ([(header::CONTENT_TYPE, content_type)], bytes).into_response(), + Ok(bytes) => ( + [ + (header::CONTENT_TYPE, content_type), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + bytes, + ) + .into_response(), Err(_) => (StatusCode::NOT_FOUND, format!("missing {}", name)).into_response(), } } diff --git a/src/server/security.rs b/src/server/security.rs new file mode 100644 index 0000000..b986730 --- /dev/null +++ b/src/server/security.rs @@ -0,0 +1,183 @@ +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use axum::{ + extract::{ConnectInfo, Request}, + http::{header, HeaderValue, Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; + +use crate::config::HIP25_DEV_CHAIN_ID; + +use super::ctx::api_error; + +/// Max RPC POST body (signed tx, hex payloads). +pub const RPC_BODY_LIMIT_BYTES: usize = 256 * 1024; + +/// Max diamonds processed per staking/summary request. +pub const STAKING_SUMMARY_MAX_DIAMONDS: usize = 200; + +pub fn is_mainnet(chain_id: u64) -> bool { + chain_id != HIP25_DEV_CHAIN_ID +} + +pub fn server_signing_disabled_msg() -> &'static str { + "server-side secret signing is disabled on mainnet; use client-side WASM signing" +} + +pub fn reject_server_secret_signing(chain_id: u64) -> Option<&'static str> { + if is_mainnet(chain_id) { + Some(server_signing_disabled_msg()) + } else { + None + } +} + +pub fn reject_create_account_on_mainnet(chain_id: u64) -> Option<&'static str> { + if is_mainnet(chain_id) { + Some("create/account is disabled on mainnet RPC") + } else { + None + } +} + +/// Sliding-window per-IP rate limiter for expensive RPC routes. +pub struct RateLimiter { + inner: Mutex>, + max_per_window: u32, + window: Duration, +} + +impl RateLimiter { + pub fn new(max_per_window: u32, window_secs: u64) -> Self { + Self { + inner: Mutex::new(HashMap::new()), + max_per_window, + window: Duration::from_secs(window_secs), + } + } + + pub fn allow(&self, key: &str) -> bool { + let mut map = self.inner.lock().unwrap(); + let now = Instant::now(); + let entry = map.entry(key.to_string()).or_insert((0, now)); + if now.duration_since(entry.1) > self.window { + *entry = (0, now); + } + if entry.0 >= self.max_per_window { + return false; + } + entry.0 += 1; + true + } +} + +fn client_ip(request: &Request) -> String { + request + .extensions() + .get::>() + .map(|c| c.0.ip().to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn is_mutating_path(path: &str) -> bool { + path.starts_with("/submit/") + || path.starts_with("/operate/") + || path == "/util/transaction/sign" + || path == "/create/coin/transfer" + || path == "/create/transaction" +} + +fn origin_allowed(origin: &str, listen_host: &str) -> bool { + let o = origin.trim(); + if o.is_empty() { + return true; + } + if o.starts_with("http://127.0.0.1:") + || o.starts_with("http://localhost:") + || o.starts_with("https://127.0.0.1:") + || o.starts_with("https://localhost:") + { + return true; + } + if listen_host == "127.0.0.1" || listen_host == "localhost" { + return false; + } + format!("http://{listen_host}").starts_with(o) + || format!("https://{listen_host}").starts_with(o) +} + +#[derive(Clone)] +pub struct MiddlewareCtx { + pub listen_host: String, + pub rate_limiter: Arc, +} + +pub async fn security_middleware( + axum::Extension(mw): axum::Extension, + request: Request, + next: Next, +) -> Response { + let path = request.uri().path().to_string(); + let method = request.method().clone(); + + if is_mutating_path(&path) { + if let Some(origin) = request.headers().get(header::ORIGIN).and_then(|v| v.to_str().ok()) { + if !origin_allowed(origin, &mw.listen_host) { + return ( + StatusCode::FORBIDDEN, + api_error("cross-origin mutation blocked; use the local wallet UI"), + ) + .into_response(); + } + } + } + + let ip = client_ip(&request); + let rate_key = if path.starts_with("/submit/transaction") { + format!("submit:{ip}") + } else if path.starts_with("/util/transaction/") { + format!("util:{ip}") + } else { + String::new() + }; + + if !rate_key.is_empty() && !mw.rate_limiter.allow(&rate_key) { + return ( + StatusCode::TOO_MANY_REQUESTS, + api_error("rate limit exceeded, retry later"), + ) + .into_response(); + } + + if method == Method::GET && path == "/create/coin/transfer" { + return ( + StatusCode::METHOD_NOT_ALLOWED, + api_error( + "create/coin/transfer requires POST; never put prikey/password in URL query strings", + ), + ) + .into_response(); + } + + let mut response = next.run(request).await; + let headers = response.headers_mut(); + if path.starts_with("/hip25/wallet") { + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static( + "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'none'; object-src 'none'; base-uri 'self'", + ), + ); + } + if path.starts_with("/pkg/") { + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + } + response +} \ No newline at end of file diff --git a/src/vm/staking_hvm.rs b/src/vm/staking_hvm.rs index 67f23e5..7ffbb73 100644 --- a/src/vm/staking_hvm.rs +++ b/src/vm/staking_hvm.rs @@ -11,13 +11,14 @@ pub fn exec_staking_script( codes: &[u8], staker: &Address, height: u64, + chain_id: u64, state: &mut dyn State, ) -> RetErr { if codes.is_empty() { return errf!("staking script empty"); } let opcode = codes[0]; - exec_staking_hvm_opcode(opcode, &codes[1..], staker, height, state) + exec_staking_hvm_opcode(opcode, &codes[1..], staker, height, chain_id, state) } /// Rust-side entry for HIP-25 HVM opcodes. Full HVM runtime (Go) calls the same Mint hooks. @@ -26,11 +27,12 @@ pub fn exec_staking_hvm_opcode( payload: &[u8], staker: &Address, height: u64, + chain_id: u64, state: &mut dyn State, ) -> RetErr { if opcode != STAKE_HACD_VMKIND && opcode != UNSTAKE_HACD_VMKIND { return errf!("unsupported staking HVM opcode {}", opcode); } - staking_exec_hvm_external(opcode, payload, staker, height, state)?; + staking_exec_hvm_external(opcode, payload, staker, height, chain_id, state)?; Ok(()) } \ No newline at end of file diff --git a/wallet/hip25/index.html b/wallet/hip25/index.html index 218618c..31b20dd 100644 --- a/wallet/hip25/index.html +++ b/wallet/hip25/index.html @@ -336,6 +336,15 @@

Activity log

const latest = await apiGet("query/latest"); state.height = latest.height; $("sHeight").textContent = latest.height; + if (latest.chain_id !== undefined) { + const nodeChain = String(latest.chain_id); + const cur = $("chainId").value.trim(); + if (!cur || cur !== nodeChain) { + $("chainId").value = nodeChain; + if (cur && cur !== nodeChain) log(`Chain ID synced to node (${nodeChain})`, "err"); + } + $("chainId").readOnly = !latest.hip25_dev; + } const global = await apiGet("query/staking/global"); $("sPool").textContent = global.reward_pool_pending_zhu ?? "—"; @@ -448,8 +457,6 @@

Activity log

} $("rpc").value = window.location.origin || "http://127.0.0.1:8083"; - $("address").value = TESTNET.address; - $("prikey").value = TESTNET.password; $("btnTestnet").onclick = () => { $("address").value = TESTNET.address; @@ -474,9 +481,20 @@

Activity log

}; $("btnSelNone").onclick = () => { state.selected.clear(); renderDiamonds(); }; - initWasmSdk().then(() => { - setTimeout(() => loadPortfolio().catch((e) => log(e.message, "err")), 1500); - }); + async function bootstrap() { + await initWasmSdk(); + try { + const latest = await apiGet("query/latest"); + if (latest.chain_id !== undefined) $("chainId").value = String(latest.chain_id); + if (latest.hip25_dev && !$("address").value.trim()) { + $("address").value = TESTNET.address; + setTimeout(() => loadPortfolio().catch((e) => log(e.message, "err")), 500); + } + } catch (e) { + log(e.message, "err"); + } + } + bootstrap(); \ No newline at end of file From fff56c0f3525cddbbe2643fdb70daaec635b5ee7 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Tue, 16 Jun 2026 01:51:46 +0200 Subject: [PATCH 20/35] security(Audit #3): harden API surface to PASS grade - try_state in ctx macros returns 503 instead of panic - Rate-limit all POST submit/operate/create/util routes - Fix origin_allowed, reject query secrets, allow_public_rpc guard - JSON body signing on transfer/fee/sign; cap block/datas limit --- hacash_mainnet_hip25.config.ini.example | 1 + scripts/START_WALLET.bat | 1 + src/chain/engine/read.rs | 6 +- src/config/server.rs | 2 + src/interface/chain/engine.rs | 1 + src/server/ctx/ctx.rs | 2 +- src/server/ctx/param.rs | 8 +- src/server/ctx/util.rs | 24 ++-- src/server/http/start.rs | 28 +++-- src/server/rpc/block.rs | 3 + src/server/rpc/create_transfer.rs | 26 +++- src/server/rpc/fee.rs | 18 ++- src/server/rpc/hashrate.rs | 4 +- src/server/rpc/miner.rs | 2 +- src/server/rpc/transaction.rs | 51 ++++++-- src/server/security.rs | 155 ++++++++++++++++++------ wallet/hip25/index.html | 12 +- 17 files changed, 270 insertions(+), 74 deletions(-) diff --git a/hacash_mainnet_hip25.config.ini.example b/hacash_mainnet_hip25.config.ini.example index 5820f91..1ada3c1 100644 --- a/hacash_mainnet_hip25.config.ini.example +++ b/hacash_mainnet_hip25.config.ini.example @@ -7,6 +7,7 @@ data_dir = hacash_mainnet_data enable = true listen = 8081 listen_host = 127.0.0.1 +allow_public_rpc = false recent_blocks = false average_fee_purity = false diff --git a/scripts/START_WALLET.bat b/scripts/START_WALLET.bat index 6d7d546..e279059 100644 --- a/scripts/START_WALLET.bat +++ b/scripts/START_WALLET.bat @@ -27,6 +27,7 @@ echo [server] echo enable = true echo listen = 8083 echo listen_host = 127.0.0.1 +echo allow_public_rpc = false echo recent_blocks = false echo average_fee_purity = false echo [node] diff --git a/src/chain/engine/read.rs b/src/chain/engine/read.rs index 4261472..100696d 100644 --- a/src/chain/engine/read.rs +++ b/src/chain/engine/read.rs @@ -5,8 +5,12 @@ impl EngineRead for BlockEngine { &self.cnf } + fn try_state(&self) -> Option> { + self.get_latest_state().map(|s| s as Arc) + } + fn state(&self) -> Arc { - self.get_latest_state() + self.try_state() .expect("chain state unavailable during rebuild; retry shortly") } diff --git a/src/config/server.rs b/src/config/server.rs index 0c81c7b..dc1c9a2 100644 --- a/src/config/server.rs +++ b/src/config/server.rs @@ -4,6 +4,7 @@ pub struct ServerConf { pub enable: bool, pub listen: u16, pub listen_host: String, + pub allow_public_rpc: bool, pub multi_thread: bool, } @@ -17,6 +18,7 @@ impl ServerConf { enable: ini_must_bool(&sec, "enable", false), listen: ini_must_u64(&sec, "listen", 8083) as u16, listen_host: ini_must(&sec, "listen_host", "127.0.0.1"), + allow_public_rpc: ini_must_bool(&sec, "allow_public_rpc", false), multi_thread: ini_must_bool(&sec, "multi_thread", false), }; diff --git a/src/interface/chain/engine.rs b/src/interface/chain/engine.rs index b964c39..cb61410 100644 --- a/src/interface/chain/engine.rs +++ b/src/interface/chain/engine.rs @@ -10,6 +10,7 @@ pub trait EngineRead: Send + Sync { fn config(&self) -> &EngineConf { panic_never_call_this!() } fn state(&self) -> Arc { panic_never_call_this!() } + fn try_state(&self) -> Option> { None } fn store(&self) -> Arc { panic_never_call_this!() } // fn confirm_state(&self) -> (Arc, Arc) { panic_never_call_this!() } diff --git a/src/server/ctx/ctx.rs b/src/server/ctx/ctx.rs index bb11dab..8faa199 100644 --- a/src/server/ctx/ctx.rs +++ b/src/server/ctx/ctx.rs @@ -25,7 +25,7 @@ impl ApiCtx { blocks: Arc::default(), miner_worker_notice_count: Arc::default(), listen_host, - rate_limiter: Arc::new(crate::server::security::RateLimiter::new(30, 60)), + rate_limiter: Arc::new(crate::server::security::RateLimiter::new(60, 60)), blocks_max: 4, } } diff --git a/src/server/ctx/param.rs b/src/server/ctx/param.rs index e85bbf8..1b8fef5 100644 --- a/src/server/ctx/param.rs +++ b/src/server/ctx/param.rs @@ -3,7 +3,9 @@ #[macro_export] macro_rules! ctx_state{ ($ctx:expr, $state:ident) => ( - let _s1_db = $ctx.engine.state(); + let Some(_s1_db) = $ctx.engine.try_state() else { + return api_state_unavailable(); + }; let $state = CoreStateDisk::wrap(_s1_db.as_ref()); ) } @@ -19,7 +21,9 @@ macro_rules! ctx_store{ #[macro_export] macro_rules! ctx_mintstate{ ($ctx:expr, $mintstate:ident) => ( - let _s3_db = $ctx.engine.state(); + let Some(_s3_db) = $ctx.engine.try_state() else { + return api_state_unavailable(); + }; let $mintstate = MintStateDisk::wrap(_s3_db.as_ref()); ) } diff --git a/src/server/ctx/util.rs b/src/server/ctx/util.rs index 7ada1b6..8dba0d5 100644 --- a/src/server/ctx/util.rs +++ b/src/server/ctx/util.rs @@ -42,24 +42,32 @@ pub fn json_headers() -> HeaderMap { headers } -pub fn api_error(errmsg: &str) -> (HeaderMap, String) { - (json_headers(), json!({"ret":1,"err":errmsg}).to_string()) +pub fn api_error(errmsg: &str) -> Response { + (json_headers(), json!({"ret":1,"err":errmsg}).to_string()).into_response() } -pub fn api_ok() -> (HeaderMap, String){ - (json_headers(), json!({"ret":0,"ok":true}).to_string()) +pub fn api_state_unavailable() -> Response { + ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + (json_headers(), json!({"ret":1,"err":"state temporarily unavailable, retry shortly"}).to_string()), + ) + .into_response() +} + +pub fn api_ok() -> Response { + (json_headers(), json!({"ret":0,"ok":true}).to_string()).into_response() } -pub fn api_data_list(jsdts: Vec) -> (HeaderMap, String){ +pub fn api_data_list(jsdts: Vec) -> Response { let list = jsdts.iter().map(|a|a.to_string()).collect::>().join(","); - (json_headers(), format!(r#"{{"ret":0,"list":[{}]}}"#, list)) + (json_headers(), format!(r#"{{"ret":0,"list":[{}]}}"#, list)).into_response() } -pub fn api_data(jsdts: HashMap<&'static str, Value>) -> (HeaderMap, String){ +pub fn api_data(jsdts: HashMap<&'static str, Value>) -> Response { let resjson = jsdts.iter().map(|(k,v)| format!(r#""{}":{}"#, k, v.to_string()) ).collect::>().join(","); - (json_headers(), format!(r#"{{"ret":0,{}}}"#, resjson)) + (json_headers(), format!(r#"{{"ret":0,{}}}"#, resjson)).into_response() } diff --git a/src/server/http/start.rs b/src/server/http/start.rs index 4421644..fb8e71d 100644 --- a/src/server/http/start.rs +++ b/src/server/http/start.rs @@ -18,12 +18,20 @@ impl RPCServer { async fn server_listen(mut ser: RPCServer) { use axum::extract::DefaultBodyLimit; - use std::net::IpAddr; use axum::Extension; - use crate::server::security::{MiddlewareCtx, RPC_BODY_LIMIT_BYTES, security_middleware}; + use std::net::IpAddr; + use crate::server::security::{ + MiddlewareCtx, RPC_BODY_LIMIT_BYTES, resolve_listen_endpoint, security_middleware, + }; let port = ser.cnf.listen; - let host = ser.cnf.listen_host.clone(); + let host = match resolve_listen_endpoint(&ser.cnf.listen_host, port, ser.cnf.allow_public_rpc) { + Ok(h) => h, + Err(e) => { + println!("\n[Error] RPC Server config: {}\n", e); + return; + } + }; let ip: IpAddr = host.parse().unwrap_or_else(|_| "127.0.0.1".parse().unwrap()); let addr = SocketAddr::from((ip, port)); let listener = TcpListener::bind(addr).await; @@ -33,16 +41,20 @@ async fn server_listen(mut ser: RPCServer) { } let listener = listener.unwrap(); println!("[RPC Server] Listening on http://{addr}"); + if crate::server::security::is_public_bind_host(&host) { + println!("[RPC Server] WARNING: public bind enabled (allow_public_rpc=true)"); + } // + let mw = MiddlewareCtx { + listen_host: host.clone(), + listen_port: port, + rate_limiter: Arc::new(crate::server::security::RateLimiter::new(60, 60)), + }; let ctx = ApiCtx::new( ser.engine.clone(), ser.hcshnd.clone(), - host.clone(), + host, ); - let mw = MiddlewareCtx { - listen_host: host, - rate_limiter: ctx.rate_limiter.clone(), - }; let app = rpc::routes(ctx) .layer(DefaultBodyLimit::max(RPC_BODY_LIMIT_BYTES)) .layer(Extension(mw)) diff --git a/src/server/rpc/block.rs b/src/server/rpc/block.rs index 457df70..f222c7e 100644 --- a/src/server/rpc/block.rs +++ b/src/server/rpc/block.rs @@ -182,6 +182,9 @@ async fn block_datas(State(ctx): State, q: Query) -> impl IntoRes q_must!(q, base64body, false); q_must!(q, start_height, 0); q_must!(q, limit, u64::MAX); + if limit > crate::server::security::BLOCK_DATAS_MAX_LIMIT { + limit = crate::server::security::BLOCK_DATAS_MAX_LIMIT; + } q_must!(q, max_size, MB); // 1mb q_must!(q, confirm, false); if max_size > 10*MB { diff --git a/src/server/rpc/create_transfer.rs b/src/server/rpc/create_transfer.rs index c9d0528..06fe846 100644 --- a/src/server/rpc/create_transfer.rs +++ b/src/server/rpc/create_transfer.rs @@ -11,11 +11,35 @@ defineQueryObject!{ Q9374, diamonds, Option, None, } -async fn create_coin_transfer(State(ctx): State, q: Query) -> impl IntoResponse { +fn merge_coin_transfer_json(q: &mut Q9374, body: &[u8]) { + if body.is_empty() { + return; + } + let Ok(v) = serde_json::from_slice::(body) else { + return; + }; + if let Some(s) = v.get("main_prikey").and_then(|x| x.as_str()) { + if !s.is_empty() { + q.main_prikey = s.to_string(); + } + } + if let Some(s) = v.get("from_prikey").and_then(|x| x.as_str()) { + if !s.is_empty() { + q.from_prikey = Some(s.to_string()); + } + } +} + +async fn create_coin_transfer( + State(ctx): State, + Query(mut q): Query, + body: Bytes, +) -> impl IntoResponse { let chain_id = ctx.engine.config().chain_id; if let Some(msg) = crate::server::security::reject_server_secret_signing(chain_id) { return api_error(msg); } + merge_coin_transfer_json(&mut q, &body); ctx_state!(ctx, state); q_must!(q, from_prikey, s!("")); q_must!(q, timestamp, 0); diff --git a/src/server/rpc/fee.rs b/src/server/rpc/fee.rs index 339fd79..36a93bc 100644 --- a/src/server/rpc/fee.rs +++ b/src/server/rpc/fee.rs @@ -36,11 +36,27 @@ defineQueryObject!{ Q5396, hash, Option, None, // find by tx hash } -async fn raise_fee(State(ctx): State, q: Query, body: Bytes) -> impl IntoResponse { +fn merge_raise_fee_json(q: &mut Q5396, body: &[u8]) { + if body.is_empty() { + return; + } + let Ok(v) = serde_json::from_slice::(body) else { + return; + }; + if let Some(s) = v.get("fee_prikey").and_then(|x| x.as_str()) { + if !s.is_empty() { + q.fee_prikey = s.to_string(); + } + } +} + +async fn raise_fee(State(ctx): State, Query(mut q): Query, body: Bytes) -> impl IntoResponse { let chain_id = ctx.engine.config().chain_id; if let Some(msg) = crate::server::security::reject_server_secret_signing(chain_id) { return api_error(msg); } + let body_copy = body.to_vec(); + merge_raise_fee_json(&mut q, &body_copy); // ctx_store!(ctx, store); q_must!(q, hash, s!("")); let fee = q_data_amt!(q, fee); diff --git a/src/server/rpc/hashrate.rs b/src/server/rpc/hashrate.rs index 8004d41..a29bbe6 100644 --- a/src/server/rpc/hashrate.rs +++ b/src/server/rpc/hashrate.rs @@ -5,7 +5,6 @@ use crate::mint::difficulty::*; fn query_hashrate(ctx: &ApiCtx) -> JsonObject { ctx_store!(ctx, store); - ctx_state!(ctx, state); let mtckr = ctx.engine.mint_checker(); let mtcnf = mtckr.config(); @@ -61,6 +60,9 @@ defineQueryObject!{ Q5295, } async fn hashrate(State(ctx): State, q: Query) -> impl IntoResponse { + let Some(_s1_db) = ctx.engine.try_state() else { + return api_state_unavailable(); + }; let data = query_hashrate(&ctx); diff --git a/src/server/rpc/miner.rs b/src/server/rpc/miner.rs index ab436c9..3f36f4c 100644 --- a/src/server/rpc/miner.rs +++ b/src/server/rpc/miner.rs @@ -35,7 +35,7 @@ fn update_miner_pending_block(block: BlockV1, cbtx: TransactionCoinbase) { } -fn get_miner_pending_block_stuff(is_detail: bool, is_transaction: bool, is_stuff: bool, is_base64: bool) -> (HeaderMap, String) { +fn get_miner_pending_block_stuff(is_detail: bool, is_transaction: bool, is_stuff: bool, is_base64: bool) -> Response { let mut stuff = MINER_PENDING_BLOCK.lock().unwrap(); if stuff.len() == 0 { panic!("get miner pending block stuff error: block not init!"); diff --git a/src/server/rpc/transaction.rs b/src/server/rpc/transaction.rs index d5a4c52..9bf6417 100644 --- a/src/server/rpc/transaction.rs +++ b/src/server/rpc/transaction.rs @@ -16,7 +16,44 @@ defineQueryObject!{ Q8375, } -async fn transaction_sign(State(ctx): State, q: Query, body: Bytes) -> impl IntoResponse { +fn parse_sign_request(q: &mut Q8375, body: &Bytes) -> Result, String> { + if !body.is_empty() { + if let Ok(v) = serde_json::from_slice::(body) { + if let Some(pk) = v.get("prikey").and_then(|x| x.as_str()) { + if !pk.is_empty() { + q.prikey = Some(pk.to_string()); + } + } + if let Some(pk) = v.get("pubkey").and_then(|x| x.as_str()) { + if !pk.is_empty() { + q.pubkey = Some(pk.to_string()); + } + } + if let Some(sig) = v.get("sigdts").and_then(|x| x.as_str()) { + if !sig.is_empty() { + q.sigdts = Some(sig.to_string()); + } + } + if let Some(tx) = v.get("tx_body").and_then(|x| x.as_str()) { + let raw = hex::decode(tx).map_err(|_| "tx_body hex error".to_string())?; + return Ok(raw); + } + } + } + let hexbody = q.hexbody.unwrap_or(false); + let bddt = body.to_vec(); + let raw = match hexbody { + false => bddt, + true => hex::decode(&bddt).map_err(|_| "hex format error".to_string())?, + }; + Ok(raw) +} + +async fn transaction_sign(State(ctx): State, Query(mut q): Query, body: Bytes) -> impl IntoResponse { + let chain_id = ctx.engine.config().chain_id; + if let Some(msg) = crate::server::security::reject_server_secret_signing(chain_id) { + return api_error(msg); + } ctx_store!(ctx, store); ctx_state!(ctx, state); q_unit!(q, unit); @@ -26,14 +63,14 @@ async fn transaction_sign(State(ctx): State, q: Query, body: Byte q_must!(q, signature, false); q_must!(q, description, false); - let chain_id = ctx.engine.config().chain_id; - if let Some(msg) = crate::server::security::reject_server_secret_signing(chain_id) { - return api_error(msg); - } - let lasthei = ctx.engine.latest_block().objc().height().uint(); - let txdts = q_body_data_may_hex!(q, body); + let txdts = match parse_sign_request(&mut q, &body) { + Ok(v) => v, + Err(e) => return api_error(&e), + }; + q_must!(q, prikey, s!("")); + q_must!(q, pubkey, s!("")); let Ok((mut tx, _)) = transaction::create(&txdts) else { return api_error("transaction body error") }; diff --git a/src/server/security.rs b/src/server/security.rs index b986730..dbd1b58 100644 --- a/src/server/security.rs +++ b/src/server/security.rs @@ -20,10 +20,31 @@ pub const RPC_BODY_LIMIT_BYTES: usize = 256 * 1024; /// Max diamonds processed per staking/summary request. pub const STAKING_SUMMARY_MAX_DIAMONDS: usize = 200; +/// Max blocks per block/datas request. +pub const BLOCK_DATAS_MAX_LIMIT: u64 = 500; + +const SECRET_QUERY_MARKERS: &[&str] = &[ + "prikey=", + "main_prikey=", + "from_prikey=", + "fee_prikey=", + "password=", +]; + +const RATE_LIMIT_MAX_KEYS: usize = 4096; + pub fn is_mainnet(chain_id: u64) -> bool { chain_id != HIP25_DEV_CHAIN_ID } +pub fn is_loopback_host(host: &str) -> bool { + matches!(host, "127.0.0.1" | "localhost" | "::1" | "[::1]") +} + +pub fn is_public_bind_host(host: &str) -> bool { + host == "0.0.0.0" || host == "::" || host == "[::]" +} + pub fn server_signing_disabled_msg() -> &'static str { "server-side secret signing is disabled on mainnet; use client-side WASM signing" } @@ -44,6 +65,20 @@ pub fn reject_create_account_on_mainnet(chain_id: u64) -> Option<&'static str> { } } +pub fn query_string_has_secret_keys(query: &str) -> bool { + if query.is_empty() { + return false; + } + let q = query.to_ascii_lowercase(); + SECRET_QUERY_MARKERS.iter().any(|m| q.contains(m)) +} + +pub fn path_accepts_signing_secrets(path: &str) -> bool { + path == "/util/transaction/sign" + || path == "/create/coin/transfer" + || path == "/operate/fee/raise" +} + /// Sliding-window per-IP rate limiter for expensive RPC routes. pub struct RateLimiter { inner: Mutex>, @@ -60,9 +95,20 @@ impl RateLimiter { } } + fn prune_stale(map: &mut HashMap, now: Instant, window: Duration) { + if map.len() <= RATE_LIMIT_MAX_KEYS { + return; + } + map.retain(|_, (_, start)| now.duration_since(*start) <= window); + if map.len() > RATE_LIMIT_MAX_KEYS { + map.clear(); + } + } + pub fn allow(&self, key: &str) -> bool { let mut map = self.inner.lock().unwrap(); let now = Instant::now(); + Self::prune_stale(&mut map, now, self.window); let entry = map.entry(key.to_string()).or_insert((0, now)); if now.duration_since(entry.1) > self.window { *entry = (0, now); @@ -86,33 +132,43 @@ fn client_ip(request: &Request) -> String { fn is_mutating_path(path: &str) -> bool { path.starts_with("/submit/") || path.starts_with("/operate/") - || path == "/util/transaction/sign" - || path == "/create/coin/transfer" - || path == "/create/transaction" + || path.starts_with("/create/") + || path.starts_with("/util/") } -fn origin_allowed(origin: &str, listen_host: &str) -> bool { +fn is_rate_limited_post(path: &str, method: &Method) -> bool { + *method == Method::POST + && (path.starts_with("/submit/") + || path.starts_with("/operate/") + || path.starts_with("/create/") + || path.starts_with("/util/")) +} + +pub fn origin_allowed(origin: &str, listen_host: &str, listen_port: u16) -> bool { let o = origin.trim(); - if o.is_empty() { - return true; - } - if o.starts_with("http://127.0.0.1:") - || o.starts_with("http://localhost:") - || o.starts_with("https://127.0.0.1:") - || o.starts_with("https://localhost:") - { - return true; + if is_loopback_host(listen_host) { + if o.is_empty() { + return true; + } + return o.starts_with(&format!("http://127.0.0.1:{listen_port}")) + || o.starts_with(&format!("http://localhost:{listen_port}")) + || o.starts_with(&format!("https://127.0.0.1:{listen_port}")) + || o.starts_with(&format!("https://localhost:{listen_port}")); } - if listen_host == "127.0.0.1" || listen_host == "localhost" { + if o.is_empty() { return false; } - format!("http://{listen_host}").starts_with(o) - || format!("https://{listen_host}").starts_with(o) + let host = listen_host.trim(); + o.starts_with(&format!("http://{host}:{listen_port}")) + || o.starts_with(&format!("https://{host}:{listen_port}")) + || o == format!("http://{host}") + || o == format!("https://{host}") } #[derive(Clone)] pub struct MiddlewareCtx { pub listen_host: String, + pub listen_port: u16, pub rate_limiter: Arc, } @@ -123,34 +179,45 @@ pub async fn security_middleware( ) -> Response { let path = request.uri().path().to_string(); let method = request.method().clone(); + let query = request.uri().query().unwrap_or("").to_string(); + + if query_string_has_secret_keys(&query) + && (path_accepts_signing_secrets(&path) || path.starts_with("/util/")) + { + return ( + StatusCode::BAD_REQUEST, + api_error( + "secrets must be sent in POST JSON body, never in URL query strings", + ), + ) + .into_response(); + } if is_mutating_path(&path) { - if let Some(origin) = request.headers().get(header::ORIGIN).and_then(|v| v.to_str().ok()) { - if !origin_allowed(origin, &mw.listen_host) { - return ( - StatusCode::FORBIDDEN, - api_error("cross-origin mutation blocked; use the local wallet UI"), - ) - .into_response(); - } + let origin_hdr = request + .headers() + .get(header::ORIGIN) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if !origin_allowed(origin_hdr, &mw.listen_host, mw.listen_port) { + return ( + StatusCode::FORBIDDEN, + api_error("cross-origin mutation blocked; use the local wallet UI"), + ) + .into_response(); } } - let ip = client_ip(&request); - let rate_key = if path.starts_with("/submit/transaction") { - format!("submit:{ip}") - } else if path.starts_with("/util/transaction/") { - format!("util:{ip}") - } else { - String::new() - }; - - if !rate_key.is_empty() && !mw.rate_limiter.allow(&rate_key) { - return ( - StatusCode::TOO_MANY_REQUESTS, - api_error("rate limit exceeded, retry later"), - ) - .into_response(); + if is_rate_limited_post(&path, &method) { + let ip = client_ip(&request); + let rate_key = format!("post:{ip}:{}", path); + if !mw.rate_limiter.allow(&rate_key) { + return ( + StatusCode::TOO_MANY_REQUESTS, + api_error("rate limit exceeded, retry later"), + ) + .into_response(); + } } if method == Method::GET && path == "/create/coin/transfer" { @@ -180,4 +247,14 @@ pub async fn security_middleware( ); } response +} + +pub fn resolve_listen_endpoint(host: &str, port: u16, allow_public_rpc: bool) -> Result { + let h = host.trim(); + if is_public_bind_host(h) && !allow_public_rpc { + return Err(format!( + "listen_host={h} requires allow_public_rpc=true in [server] (default is loopback-only)" + )); + } + Ok(h.to_string()) } \ No newline at end of file diff --git a/wallet/hip25/index.html b/wallet/hip25/index.html index 31b20dd..785887c 100644 --- a/wallet/hip25/index.html +++ b/wallet/hip25/index.html @@ -464,7 +464,7 @@

Activity log

$("fee").value = TESTNET.fee; $("chainId").value = "1"; $("rpc").value = window.location.origin || "http://127.0.0.1:8083"; - log("Filled HIP-25 testnet seed account (password: hip25test)"); + log("Filled HIP-25 testnet seed account"); loadPortfolio().catch((e) => log(e.message, "err")); }; @@ -486,9 +486,13 @@

Activity log

try { const latest = await apiGet("query/latest"); if (latest.chain_id !== undefined) $("chainId").value = String(latest.chain_id); - if (latest.hip25_dev && !$("address").value.trim()) { - $("address").value = TESTNET.address; - setTimeout(() => loadPortfolio().catch((e) => log(e.message, "err")), 500); + if (latest.hip25_dev) { + if (!$("address").value.trim()) { + $("address").value = TESTNET.address; + setTimeout(() => loadPortfolio().catch((e) => log(e.message, "err")), 500); + } + } else { + $("btnTestnet").style.display = "none"; } } catch (e) { log(e.message, "err"); From a798094f5e57e9e58a4ee0297115ab9aa45bce4f Mon Sep 17 00:00:00 2001 From: Moskyera Date: Tue, 16 Jun 2026 01:58:19 +0200 Subject: [PATCH 21/35] security(audits 4-5): remediate all PARTIAL findings to PASS Audit #4: P2P sig+execute gate, tx timestamp in try_execute_tx, cumulative miner packing, sync pool insert, rebuild-safe pool sweep, 256KB P2P tx cap Audit #5: SDK loads from wallet origin only, SHA-256 integrity on /pkg/, DOM textContent, mainnet chain ID pin, stripped WASM exports, password clear Also: fix get_id_range filter, miner GET rate limit, remove dead ApiCtx rate_limiter --- scripts/build_wallet_sdk.ps1 | 18 ++++++- src/chain/engine/read.rs | 33 +++++++++---- src/interface/chain/engine.rs | 6 +++ src/node/handler/handler.rs | 6 ++- src/node/handler/msg.rs | 2 + src/node/handler/txblock.rs | 40 ++++++++-------- src/node/node/hnode.rs | 11 +++-- src/sdk/web/account.rs | 1 + src/sdk/web/sign.rs | 1 + src/sdk/web/transfer.rs | 5 +- src/server/ctx/ctx.rs | 2 - src/server/ctx/util.rs | 2 +- src/server/rpc/miner.rs | 20 +++++--- src/server/rpc/submit_transaction.rs | 2 +- src/server/rpc/wallet_ui.rs | 46 ++++++++++++++---- src/server/security.rs | 9 +++- wallet/hip25/index.html | 72 ++++++++++++++++++++-------- 17 files changed, 200 insertions(+), 76 deletions(-) diff --git a/scripts/build_wallet_sdk.ps1 b/scripts/build_wallet_sdk.ps1 index 1e97374..4564a07 100644 --- a/scripts/build_wallet_sdk.ps1 +++ b/scripts/build_wallet_sdk.ps1 @@ -7,7 +7,7 @@ $WalletPkg = Join-Path $Root "wallet\hip25\pkg" Write-Host "Building hacash_sdk (wasm32)..." -ForegroundColor Cyan Set-Location $Root -rustup target add wasm32-unknown-unknown 2>$null +rustup target add wasm32-unknown-unknown 2>&1 | Out-Null cargo build --features sdk --target wasm32-unknown-unknown --release --lib $WasmSrc = Join-Path $Root "target\wasm32-unknown-unknown\release\hacash_sdk.wasm" @@ -22,6 +22,22 @@ wasm-bindgen $WasmSrc --out-dir $PkgDir --target no-modules --no-typescript Copy-Item (Join-Path $PkgDir "hacash_sdk.js") (Join-Path $WalletPkg "hacash_sdk.js") -Force Copy-Item (Join-Path $PkgDir "hacash_sdk_bg.wasm") (Join-Path $WalletPkg "hacash_sdk_bg.wasm") -Force +function Get-Sha256Hex([string]$Path) { + $hash = Get-FileHash -Path $Path -Algorithm SHA256 + return $hash.Hash.ToLowerInvariant() +} + +$jsHash = Get-Sha256Hex (Join-Path $PkgDir "hacash_sdk.js") +$wasmHash = Get-Sha256Hex (Join-Path $PkgDir "hacash_sdk_bg.wasm") +$manifest = @{ + "hacash_sdk.js" = $jsHash + "hacash_sdk_bg.wasm" = $wasmHash +} | ConvertTo-Json -Compress + +$manifest | Set-Content -Encoding UTF8 (Join-Path $PkgDir "integrity.json") +$manifest | Set-Content -Encoding UTF8 (Join-Path $WalletPkg "integrity.json") + Write-Host "OK: pkg copied to" $PkgDir -ForegroundColor Green Write-Host " and" $WalletPkg -ForegroundColor Green +Write-Host "integrity.json written (SHA-256 verified on serve)" -ForegroundColor Green Write-Host "Restart fullnode, then open /hip25/wallet" -ForegroundColor Yellow \ No newline at end of file diff --git a/src/chain/engine/read.rs b/src/chain/engine/read.rs index 100696d..8869814 100644 --- a/src/chain/engine/read.rs +++ b/src/chain/engine/read.rs @@ -38,17 +38,34 @@ impl EngineRead for BlockEngine { } fn try_execute_tx(&self, tx: &dyn TransactionRead) -> RetErr { + self.try_execute_txs_cumulative(&[tx]) + } + + fn try_execute_txs_cumulative(&self, txs: &[&dyn TransactionRead]) -> RetErr { let sta = self.get_latest_state(); - if let None = sta { - return errf!("block engine not yet") + if sta.is_none() { + return errf!("block engine not yet"); } let mut sub_state = fork_sub_state(sta.unwrap()); - let height = self.get_latest_height().uint() + 1; // next height - let blkhash = Hash::cons([0u8; 32]); // empty hash - // exec - exec_tx_actions(false, self.cnf.chain_id, height, blkhash, &mut sub_state, self.store.as_ref(), tx)?; - tx.execute(height, &mut sub_state) - } + let height = self.get_latest_height().uint() + 1; + let chain_id = self.cnf.chain_id; + let store = self.store.as_ref(); + let cur_time = curtimes(); + let blkhash = Hash::cons([0u8; 32]); + for tx in txs { + let tx = *tx; + if tx.timestamp().to_u64() > cur_time { + return errf!( + "tx timestamp {} cannot more than now {}", + tx.timestamp(), + cur_time + ); + } + exec_tx_actions(false, chain_id, height, blkhash, &mut sub_state, store, tx)?; + tx.execute(height, &mut sub_state)?; + } + Ok(()) + } fn recent_blocks(&self) -> Vec> { let vs = self.rctblks.lock().unwrap(); diff --git a/src/interface/chain/engine.rs b/src/interface/chain/engine.rs index cb61410..5ec1426 100644 --- a/src/interface/chain/engine.rs +++ b/src/interface/chain/engine.rs @@ -21,6 +21,12 @@ pub trait EngineRead: Send + Sync { fn average_fee_purity(&self) -> u64 { 0 } // 1w zhu(shuo) / 200byte(1trs) fn try_execute_tx(&self, _: &dyn TransactionRead) -> RetErr { panic_never_call_this!() } + fn try_execute_txs_cumulative(&self, txs: &[&dyn TransactionRead]) -> RetErr { + for tx in txs { + self.try_execute_tx(*tx)?; + } + Ok(()) + } // realtime average fee purity // fn avgfee(&self) -> u32 { 0 } } diff --git a/src/node/handler/handler.rs b/src/node/handler/handler.rs index d6ad2fb..d5da441 100644 --- a/src/node/handler/handler.rs +++ b/src/node/handler/handler.rs @@ -71,7 +71,11 @@ impl MsgHandler { // println!("on_message peer={} ty={} len={}", peer.nick(), ty, body.len()); match ty { - MSG_TX_SUBMIT => { self.blktx.send(BlockTxArrive::Tx(Some(peer.clone()), body)).await; }, + MSG_TX_SUBMIT => { + if body.len() <= TX_SUBMIT_MAX_BYTES { + self.blktx.send(BlockTxArrive::Tx(Some(peer.clone()), body)).await; + } + }, MSG_BLOCK_DISCOVER => { self.blktx.send(BlockTxArrive::Block(Some(peer.clone()), body)).await; }, MSG_BLOCK_HASH => { self.receive_hashs(peer, body).await; }, MSG_REQ_BLOCK_HASH => { self.send_hashs(peer, body).await; }, diff --git a/src/node/handler/msg.rs b/src/node/handler/msg.rs index 2a5efed..d8062f0 100644 --- a/src/node/handler/msg.rs +++ b/src/node/handler/msg.rs @@ -11,6 +11,8 @@ pub const MSG_REQ_BLOCK: u16 = 5; pub const MSG_BLOCK: u16 = 6; pub const MSG_TX_SUBMIT: u16 = 7; // new tx arrived +/// Max serialized tx size accepted from P2P (aligned with RPC body limit). +pub const TX_SUBMIT_MAX_BYTES: usize = 256 * 1024; pub const MSG_BLOCK_DISCOVER: u16 = 8; // new block arrived // msg stuff diff --git a/src/node/handler/txblock.rs b/src/node/handler/txblock.rs index 434f528..e03af7d 100644 --- a/src/node/handler/txblock.rs +++ b/src/node/handler/txblock.rs @@ -1,31 +1,31 @@ async fn handle_new_tx(this: Arc, peer: Option>, body: Vec) { - // println!("1111111 handle_txblock_arrive Tx, peer={} len={}", peer.nick(), body.clone().len()); + if body.len() > TX_SUBMIT_MAX_BYTES { + return; + } let engcnf = this.engine.config(); - // parse let txpkg = transaction::create_pkg(BytesW4::from_vec(body)); - if let Err(e) = txpkg { - return // parse tx error + if let Err(_) = txpkg { + return; } let txpkg = txpkg.unwrap(); - // tx hash with fee + let txread = txpkg.objc().as_ref().as_read(); + if txread.verify_signature().is_err() { + return; + } + if this.engine.try_execute_tx(txread).is_err() { + return; + } let hxfe = txpkg.objc().hash_with_fee(); let (already, knowkey) = check_know(&this.knows, &hxfe, peer.clone()); if already { - return // alreay know it + return; } - // println!("p2p recv new tx: {}, {}", txpkg.objc().hash().half(), hxfe.nonce()); let txdatas = txpkg.body().clone().into_vec(); if engcnf.is_open_miner() { - // try execute tx - if let Err(..) = this.engine.try_execute_tx(txpkg.objc().as_ref().as_read()) { - return // tx execute fail - } - // add to pool - this.txpool.insert(txpkg); + let _ = this.txpool.insert(txpkg); } - // broadcast let p2p = this.p2pmng.lock().unwrap(); let p2p = p2p.as_ref().unwrap(); p2p.broadcast_message(0/*not delay*/, knowkey, MSG_TX_SUBMIT, txdatas); @@ -140,9 +140,10 @@ fn drain_all_block_txs(eng: Arc, txpool: Arc, txs: V // clean_ fn clean_invalid_normal_txs(eng: Arc, txpool: Arc, blkhei: u64) { - // already minted hacd number - let sta = eng.state(); - let ldn = MintStateDisk::wrap(sta.as_ref()).latest_diamond().number.uint(); + let Some(sta) = eng.try_state() else { + return; + }; + let _ldn = MintStateDisk::wrap(sta.as_ref()).latest_diamond().number.uint(); txpool.drain_filter_at(&|a: &Box| { match eng.try_execute_tx( a.objc().as_read() ) { Err(..) => true, // delete @@ -154,8 +155,9 @@ fn clean_invalid_normal_txs(eng: Arc, txpool: Arc, b // clean_ fn clean_invalid_diamond_mint_txs(eng: Arc, txpool: Arc, blkhei: u64) { - // already minted hacd number - let sta = eng.state(); + let Some(sta) = eng.try_state() else { + return; + }; let curdn = MintStateDisk::wrap(sta.as_ref()).latest_diamond().number.uint(); txpool.drain_filter_at(&|a: &Box| { let tx = a.objc().as_read(); diff --git a/src/node/node/hnode.rs b/src/node/node/hnode.rs index 45ec9d1..c2c598e 100644 --- a/src/node/node/hnode.rs +++ b/src/node/node/hnode.rs @@ -3,12 +3,15 @@ impl HNode for HacashNode { fn submit_transaction(&self, txpkg: &Box, in_async: bool) -> RetErr { - // check signature let txread = txpkg.objc().as_ref().as_read(); txread.verify_signature()?; - // try execute tx self.engine.try_execute_tx(txread)?; - // add to pool + if self.engine.config().is_open_miner() { + let txbody = txpkg.body().clone().into_vec(); + if let Ok(pkg) = crate::protocol::transaction::create_pkg(BytesW4::from_vec(txbody)) { + let _ = self.txpool.insert(pkg); + } + } let msghdl = self.msghdl.clone(); let txbody = txpkg.body().clone().into_vec(); let runobj = async move { @@ -16,7 +19,7 @@ impl HNode for HacashNode { }; if in_async { tokio::spawn(runobj); - }else{ + } else { new_current_thread_tokio_rt().block_on(runobj); } Ok(()) diff --git a/src/sdk/web/account.rs b/src/sdk/web/account.rs index 32d1c67..760fae3 100644 --- a/src/sdk/web/account.rs +++ b/src/sdk/web/account.rs @@ -1,4 +1,5 @@ +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] pub fn create_account_by(s: String) -> String { let acc = or_return!{ "create account", Account::create_by(&s) }; diff --git a/src/sdk/web/sign.rs b/src/sdk/web/sign.rs index 3bb085d..e25404a 100644 --- a/src/sdk/web/sign.rs +++ b/src/sdk/web/sign.rs @@ -1,4 +1,5 @@ +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] pub fn sign(acckey: String, msg: String) -> String { let acc = or_return!{ "create account", Account::create_by(&acckey) }; diff --git a/src/sdk/web/transfer.rs b/src/sdk/web/transfer.rs index fa4cd62..8ed82e9 100644 --- a/src/sdk/web/transfer.rs +++ b/src/sdk/web/transfer.rs @@ -59,7 +59,7 @@ fn stake_tx_json( fn build_signed_stake_tx( chain_id: u64, - from_pass: String, + mut from_pass: String, diamond_name_list: String, fee: String, timestamp: i64, @@ -70,6 +70,7 @@ fn build_signed_stake_tx( let dlist = or_return! { "Diamond Name parse", parse_diamond_list(diamond_name_list) }; let fee = or_return! { "Fee parse", Amount::from_string_unsafe(&fee) }; let acc = or_return! { "From Account", Account::create_by(&from_pass) }; + from_pass.clear(); let addr = or_return! { "From Address", Address::from_readable(acc.readable()) }; let mut tx = TransactionType2::build(addr, fee.clone()); tx.timestamp = Timestamp::from(time_set as u64); @@ -95,6 +96,7 @@ fn build_signed_stake_tx( stake_tx_json(&tx, &dlist, &fee, &acc, time_set, label) } +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] pub fn trs_test(x: i32) -> usize { let mut bt = Fixed4::default(); @@ -109,6 +111,7 @@ pub fn trs_test(x: i32) -> usize { res } +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] pub fn create_acc_random() -> usize { let acc = Account::create_by_password(&"123456".to_string()); diff --git a/src/server/ctx/ctx.rs b/src/server/ctx/ctx.rs index 8faa199..61378e2 100644 --- a/src/server/ctx/ctx.rs +++ b/src/server/ctx/ctx.rs @@ -12,7 +12,6 @@ pub struct ApiCtx { pub blocks: BlockCaches, pub miner_worker_notice_count: Arc>, pub listen_host: String, - pub rate_limiter: Arc, blocks_max: usize, // 4 } @@ -25,7 +24,6 @@ impl ApiCtx { blocks: Arc::default(), miner_worker_notice_count: Arc::default(), listen_host, - rate_limiter: Arc::new(crate::server::security::RateLimiter::new(60, 60)), blocks_max: 4, } } diff --git a/src/server/ctx/util.rs b/src/server/ctx/util.rs index 8dba0d5..761e480 100644 --- a/src/server/ctx/util.rs +++ b/src/server/ctx/util.rs @@ -115,7 +115,7 @@ pub fn get_id_range(max: i64, page: i64, limit: i64, instart: i64, decs: bool) - rng = (end+1..start+1).rev().collect(); } // ok - rng.retain(|&x| x>=1 || x<=max); + rng.retain(|&x| x >= 1 && x <= max); rng } diff --git a/src/server/rpc/miner.rs b/src/server/rpc/miner.rs index 3f36f4c..64a8058 100644 --- a/src/server/rpc/miner.rs +++ b/src/server/rpc/miner.rs @@ -38,7 +38,7 @@ fn update_miner_pending_block(block: BlockV1, cbtx: TransactionCoinbase) { fn get_miner_pending_block_stuff(is_detail: bool, is_transaction: bool, is_stuff: bool, is_base64: bool) -> Response { let mut stuff = MINER_PENDING_BLOCK.lock().unwrap(); if stuff.len() == 0 { - panic!("get miner pending block stuff error: block not init!"); + return api_error("miner pending block not initialized; enable miner and wait for first template"); }; let stuff = &mut stuff[0]; @@ -198,14 +198,20 @@ fn append_valid_tx_pick_from_txpool(nexthei: u64, trslen: &mut usize, trshxs: &m macro_rules! check_pick_one_tx { ($a: expr) => { let txr = $a.objc().as_ref().as_read(); - if let Err(..) = txr.verify_signature() { - return true // sign fail, ignore, next + if txr.verify_signature().is_err() { + return true; } - if let Err(..) = engine.try_execute_tx(txr) { - return true // execute fail, ignore, next + let mut sim: Vec<&dyn TransactionRead> = trs + .list() + .iter() + .skip(1) + .map(|t| t.as_ref().as_read()) + .collect(); + sim.push(txr); + if engine.try_execute_txs_cumulative(&sim).is_err() { + return true; } - } - + }; } // pick one diamond mint tx diff --git a/src/server/rpc/submit_transaction.rs b/src/server/rpc/submit_transaction.rs index 02e54cd..f0692db 100644 --- a/src/server/rpc/submit_transaction.rs +++ b/src/server/rpc/submit_transaction.rs @@ -14,7 +14,7 @@ async fn submit_transaction(State(ctx): State, q: Query, body: By } let txpkg = txpkg.unwrap(); // try submit - let is_async = true; + let is_async = false; if let Err(e) = ctx.hcshnd.submit_transaction(&txpkg, is_async) { return api_error(&e) } diff --git a/src/server/rpc/wallet_ui.rs b/src/server/rpc/wallet_ui.rs index 54ac5de..4aab6fc 100644 --- a/src/server/rpc/wallet_ui.rs +++ b/src/server/rpc/wallet_ui.rs @@ -15,6 +15,19 @@ fn hip25_pkg_dir() -> Option { None } +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(bytes); + hex::encode(digest) +} + +fn integrity_expected(dir: &std::path::Path, name: &str) -> Option { + let manifest = dir.join("integrity.json"); + let raw = std::fs::read_to_string(&manifest).ok()?; + let v: serde_json::Value = serde_json::from_str(&raw).ok()?; + v.get(name)?.as_str().map(|s| s.to_ascii_lowercase()) +} + fn serve_pkg_file(name: &str, content_type: &'static str) -> Response { use axum::http::StatusCode; let Some(dir) = hip25_pkg_dir() else { @@ -25,17 +38,30 @@ fn serve_pkg_file(name: &str, content_type: &'static str) -> Response { .into_response(); }; let path = dir.join(name); - match std::fs::read(&path) { - Ok(bytes) => ( - [ - (header::CONTENT_TYPE, content_type), - (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), - ], - bytes, - ) - .into_response(), - Err(_) => (StatusCode::NOT_FOUND, format!("missing {}", name)).into_response(), + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(_) => return (StatusCode::NOT_FOUND, format!("missing {}", name)).into_response(), + }; + if let Some(expected) = integrity_expected(&dir, name) { + let actual = sha256_hex(&bytes); + if actual != expected { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!( + "HIP-25 SDK integrity check failed for {name}; rebuild with scripts/build_wallet_sdk.ps1" + ), + ) + .into_response(); + } } + ( + [ + (header::CONTENT_TYPE, content_type), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + bytes, + ) + .into_response() } async fn hip25_wallet_page() -> impl IntoResponse { diff --git a/src/server/security.rs b/src/server/security.rs index dbd1b58..fcbd1ef 100644 --- a/src/server/security.rs +++ b/src/server/security.rs @@ -144,6 +144,10 @@ fn is_rate_limited_post(path: &str, method: &Method) -> bool { || path.starts_with("/util/")) } +fn is_rate_limited_get(path: &str, method: &Method) -> bool { + *method == Method::GET && path.starts_with("/submit/miner/") +} + pub fn origin_allowed(origin: &str, listen_host: &str, listen_port: u16) -> bool { let o = origin.trim(); if is_loopback_host(listen_host) { @@ -208,9 +212,10 @@ pub async fn security_middleware( } } - if is_rate_limited_post(&path, &method) { + if is_rate_limited_post(&path, &method) || is_rate_limited_get(&path, &method) { let ip = client_ip(&request); - let rate_key = format!("post:{ip}:{}", path); + let verb = if method == Method::POST { "post" } else { "get" }; + let rate_key = format!("{verb}:{ip}:{}", path); if !mw.rate_limiter.allow(&rate_key) { return ( StatusCode::TOO_MANY_REQUESTS, diff --git a/wallet/hip25/index.html b/wallet/hip25/index.html index 785887c..c1b2106 100644 --- a/wallet/hip25/index.html +++ b/wallet/hip25/index.html @@ -139,7 +139,7 @@

HIP-25 HACD Staking

- Wallet MVP + Wallet MVP
@@ -205,9 +205,9 @@

Activity log

+ + + + + + - \ No newline at end of file + diff --git a/wallet/hip25/js/api.js b/wallet/hip25/js/api.js new file mode 100644 index 0000000..e24d5fe --- /dev/null +++ b/wallet/hip25/js/api.js @@ -0,0 +1,68 @@ +/* RPC client */ +(function (W) { + W.apiBase = () => { + let b = W.$("rpc").value.trim().replace(/\/$/, ""); + if (!b) b = W.sdkOrigin(); + return b; + }; + + W.explainSubmitError = (msg) => { + if (msg.includes("loan amount must be")) { + return `${msg} — re-select HACD or use Calculate loan to sync loan amount with bid-burn sum`; + } + if (msg.includes("do hac_sub error") && msg.includes("not enough")) { + const portfolio = W.$("address").value.trim() || "portfolio address"; + const signer = W.state.signAddress || "signing address"; + return `${msg} — ensure password matches ${portfolio} (signs as ${signer}) and HAC balance covers origination burn + tx fee`; + } + if (msg.includes("mortgage owner contract index full")) { + const max = W.state.mortgageGlobal?.owner_index_max ?? 64; + return `${msg} — maximum ${max} active mortgage contracts per address`; + } + return msg; + }; + + W.apiGet = async (path, params = {}) => { + const qs = new URLSearchParams(params).toString(); + const url = `${W.apiBase()}/${path}${qs ? "?" + qs : ""}`; + let r; + try { + r = await fetch(url); + } catch (e) { + throw new Error(`RPC unreachable at ${W.apiBase()} — run START_WALLET.bat and keep HIP25-FULLNODE open`); + } + const text = await r.text(); + let j; + try { + j = JSON.parse(text); + } catch { + throw new Error(text.trim() || `RPC error ${r.status}`); + } + if (j.ret !== 0) throw new Error(W.explainSubmitError(j.err || JSON.stringify(j))); + return j; + }; + + W.apiPost = async (path, body, params = {}) => { + const qs = new URLSearchParams(params).toString(); + const url = `${W.apiBase()}/${path}${qs ? "?" + qs : ""}`; + const bytes = body instanceof Uint8Array ? body : new TextEncoder().encode(body); + const r = await fetch(url, { method: "POST", body: bytes }); + const text = await r.text(); + let j; + try { + j = JSON.parse(text); + } catch { + throw new Error(text.trim() || `RPC error ${r.status}`); + } + if (j.ret !== 0) throw new Error(W.explainSubmitError(j.err || JSON.stringify(j))); + return j; + }; + + W.warnRpcOriginMismatch = () => { + const api = W.apiBase(); + const sdk = W.sdkOrigin(); + if (api !== sdk) { + W.log(`RPC URL (${api}) differs from wallet origin (${sdk}); WASM SDK always loads from wallet origin`, "err"); + } + }; +})(window.Hip25Wallet); \ No newline at end of file diff --git a/wallet/hip25/js/app.js b/wallet/hip25/js/app.js new file mode 100644 index 0000000..9a9403b --- /dev/null +++ b/wallet/hip25/js/app.js @@ -0,0 +1,97 @@ +/* Event bindings and bootstrap */ +(function (W) { + W.bootstrap = async () => { + await W.initWasmSdk(); + try { + const latest = await W.apiGet("query/latest"); + if (latest.chain_id !== undefined) W.$("chainId").value = String(latest.chain_id); + W.state.hip25Dev = !!latest.hip25_dev; + if (latest.hip25_dev) { + W.$("networkTag").textContent = "HIP-25 testnet"; + if (!W.$("address").value.trim()) { + W.$("address").value = W.TESTNET.address; + W.$("fee").value = W.TESTNET.fee; + W.$("chainId").value = "1"; + } + if (!W.$("lendId").value.trim()) W.$("lendId").value = W.randomLendIdHex(); + setTimeout(() => W.loadPortfolio().catch((e) => W.log(e.message, "err")), 800); + } else { + W.$("btnTestnet").style.display = "none"; + W.$("chainId").value = W.MAINNET_CHAIN_ID; + W.$("chainId").readOnly = true; + W.$("networkTag").textContent = `Mainnet chain ${W.MAINNET_CHAIN_ID}`; + } + } catch (e) { + W.log(e.message, "err"); + } + }; + + W.$("rpc").value = W.sdkOrigin() || "http://127.0.0.1:8083"; + W.$("rpc").addEventListener("change", W.warnRpcOriginMismatch); + W.$("rpc").addEventListener("blur", W.warnRpcOriginMismatch); + + W.$("btnTestnet").onclick = () => { + if (!W.state.hip25Dev) { + W.log("Testnet seed fill is disabled on mainnet nodes", "err"); + return; + } + W.$("address").value = W.TESTNET.address; + W.$("fee").value = W.TESTNET.fee; + W.$("chainId").value = "1"; + W.$("rpc").value = W.sdkOrigin(); + if (!W.$("prikey").value.trim()) W.$("prikey").value = W.TESTNET.password; + W.resolveSigningAddress().then(() => W.loadPortfolio()).catch((e) => W.log(e.message, "err")); + W.log("Filled testnet seed — loading portfolio…"); + }; + + W.$("prikey").addEventListener("input", () => W.resolveSigningAddress()); + W.$("address").addEventListener("input", W.updateAddressMismatchWarning); + + W.$("btnLoad").onclick = () => W.loadPortfolio().catch((e) => W.log(e.message, "err")); + W.$("btnRefresh").onclick = () => W.loadPortfolio().catch((e) => W.log(e.message, "err")); + W.$("btnStake").onclick = () => W.submitStakeAction(34).catch((e) => W.log(e.message, "err")); + W.$("btnUnstake").onclick = () => W.submitStakeAction(35).catch((e) => W.log(e.message, "err")); + + W.$("btnSelAll").onclick = () => { + W.state.diamonds.forEach((d) => { + if (W.diamondSelectable(d)) W.state.selected.add(d.literal); + }); + W.renderDiamonds(); + W.scheduleLoanRefresh(); + }; + W.$("btnSelNone").onclick = () => { + W.state.selected.clear(); + W.renderDiamonds(); + W.$("mortgageCostHint").textContent = ""; + }; + + W.$("btnNewLendId").onclick = () => { + W.$("lendId").value = W.randomLendIdHex(); + W.updateActionButtons(); + }; + + W.$("btnCalcLoan").onclick = () => + W.prepareMortgageOpen({ quiet: false }).catch((e) => W.log(e.message, "err")); + + W.$("btnMortgageQuote").onclick = async () => { + const id = W.$("lendId").value.trim(); + if (!id) return W.log("Lending ID required", "err"); + try { + const q = await W.apiGet("query/mortgage/contract", { + id, + redeemer: W.$("address").value.trim(), + height: W.state.height ? String(W.state.height) : "0", + }); + W.$("ransomAmount").value = q.min_ransom || ""; + W.$("mortgageMeta").textContent = `Redemption phase: ${q.redeem_phase || "—"} · min ransom: ${q.min_ransom || "—"}`; + W.log(`Quote: ${q.min_ransom} (${q.redeem_phase})`, "ok"); + } catch (e) { + W.log(e.message, "err"); + } + }; + + W.$("btnMortgageOpen").onclick = () => W.submitMortgage(15).catch((e) => W.log(e.message, "err")); + W.$("btnMortgageRedeem").onclick = () => W.submitMortgage(16).catch((e) => W.log(e.message, "err")); + + W.bootstrap(); +})(window.Hip25Wallet); \ No newline at end of file diff --git a/wallet/hip25/js/core.js b/wallet/hip25/js/core.js new file mode 100644 index 0000000..3ff22f6 --- /dev/null +++ b/wallet/hip25/js/core.js @@ -0,0 +1,71 @@ +/* HIP-25 wallet shared state and utilities */ +(function (W) { + W.MAINNET_CHAIN_ID = "0"; + W.TESTNET = { + address: "1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2", + password: "hip25test", + fee: "0:247", + }; + + W.state = { + diamonds: [], + mortgageContracts: [], + mortgageGlobal: null, + height: 0, + selected: new Set(), + wasm: null, + hip25Dev: true, + signAddress: "", + }; + + W.$ = (id) => document.getElementById(id); + + W.txTimestamp = () => Math.floor(Date.now() / 1000); + + W.sdkOrigin = () => window.location.origin; + + W.log = (msg, cls = "") => { + const el = W.$("log"); + const line = document.createElement("div"); + if (cls) line.className = cls; + line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; + el.prepend(line); + }; + + W.parseAccountJson = (raw) => { + if (!raw || raw.startsWith("[ERROR]")) throw new Error(raw || "account error"); + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) throw new Error("invalid account JSON"); + return JSON.parse(trimmed); + }; + + W.parseSdkJson = (raw) => { + if (!raw || raw.startsWith("[ERROR]")) throw new Error(raw || "SDK error"); + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) throw new Error("SDK returned invalid JSON"); + return JSON.parse(trimmed); + }; + + W.hexToBytes = (hex) => { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16); + return out; + }; + + W.splitDiamonds = (str) => { + if (!str) return []; + const w = 6; + const s = str.replace(/\s/g, ""); + const out = []; + for (let i = 0; i < s.length; i += w) out.push(s.slice(i, i + w)); + return out.filter((x) => x.length === w); + }; + + W.randomLendIdHex = () => { + const bytes = new Uint8Array(14); + bytes[0] = 0x4d; + crypto.getRandomValues(bytes.subarray(1, 13)); + bytes[13] = 0x7a; + return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); + }; +})(window.Hip25Wallet = window.Hip25Wallet || {}); \ No newline at end of file diff --git a/wallet/hip25/js/mortgage.js b/wallet/hip25/js/mortgage.js new file mode 100644 index 0000000..5442dc0 --- /dev/null +++ b/wallet/hip25/js/mortgage.js @@ -0,0 +1,142 @@ +/* HIP-2 mortgage: loan prep, quotes, submit */ +(function (W) { + let loanRefreshTimer = null; + + W.updateMortgageCostHint = (principal, selCount) => { + const hint = W.$("mortgageCostHint"); + if (!principal?.loan) { + hint.textContent = ""; + return; + } + const fee = W.$("fee").value.trim() || "0"; + const bps = principal.origination_fee_bps ?? W.state.mortgageGlobal?.origination_fee_bps ?? 100; + hint.textContent = `${principal.hacd_count || selCount} HACD → loan ${principal.loan} · origination burn (${bps / 100}%) ${principal.origination_burn || "—"} · tx fee ${fee}`; + }; + + W.prepareMortgageOpen = async (opts = {}) => { + const sel = W.selectedAvailableDiamonds(); + if (!sel.length) { + W.$("mortgageCostHint").textContent = ""; + if (!opts.quiet) throw new Error("Select Available HACD first"); + return null; + } + + if (opts.preflight) { + const signer = await W.resolveSigningAddress(); + if (!signer) throw new Error("Enter signing password"); + const portfolio = W.$("address").value.trim(); + if (portfolio && signer !== portfolio) { + throw new Error(`Password signs as ${signer} but portfolio is ${portfolio}. Addresses must match.`); + } + const max = W.state.mortgageGlobal?.owner_index_max ?? 64; + const active = W.state.mortgageContracts?.length ?? 0; + if (active >= max) { + throw new Error(`Mortgage index full (${active}/${max} active contracts for this address)`); + } + } + + const principal = await W.apiGet("query/mortgage/principal", { diamonds: sel.join(",") }); + W.$("loanAmount").value = principal.loan || ""; + W.updateMortgageCostHint(principal, sel.length); + + if (opts.preflight) { + const signer = W.state.signAddress; + const bal = await W.apiGet("query/balance", { address: signer }); + const hac = bal.list?.[0]?.hacash || "0"; + W.log(`Signer ${signer} balance ${hac} · need origination ${principal.origination_burn} + fee ${W.$("fee").value.trim()}`, "ok"); + } else if (!opts.quiet) { + W.log(`Loan updated: ${principal.loan} for ${sel.join(",")}`, "ok"); + } + + return { sel, principal }; + }; + + W.scheduleLoanRefresh = () => { + if (loanRefreshTimer) clearTimeout(loanRefreshTimer); + loanRefreshTimer = setTimeout(() => { + loanRefreshTimer = null; + W.prepareMortgageOpen({ quiet: true }).catch((e) => W.log(e.message, "err")); + }, 200); + }; + + W.renderMortgageContracts = () => { + const el = W.$("mortgageContracts"); + const list = W.state.mortgageContracts || []; + const max = W.state.mortgageGlobal?.owner_index_max ?? 64; + if (!list.length) { + el.textContent = `No active mortgage contracts for this address (limit ${max}).`; + return; + } + const header = list.length >= max ? `
Index full: ${list.length}/${max} active contracts
` : ""; + el.innerHTML = + header + + list + .map((c) => { + const id = c.lending_id || ""; + const dias = (c.diamonds || []).join(", "); + return `
+ ${id.slice(0, 12)}… · loan ${c.loan_principal || "—"} · phase ${c.redeem_phase || "—"} · ransom ${c.min_ransom || "—"}
+ HACD: ${dias || "—"} + +
`; + }) + .join(""); + el.querySelectorAll("button[data-lend]").forEach((btn) => { + btn.onclick = () => { + W.$("lendId").value = btn.getAttribute("data-lend") || ""; + W.$("ransomAmount").value = btn.getAttribute("data-ransom") || ""; + W.updateActionButtons(); + W.log(`Selected contract ${W.$("lendId").value.slice(0, 12)}… for redeem`); + }; + }); + }; + + W.loadMortgagePortfolio = async (addr) => { + try { + const p = await W.apiGet("query/mortgage/portfolio", { address: addr }); + W.state.mortgageContracts = p.contracts || []; + if (p.owner_index_max) { + W.state.mortgageGlobal = { ...(W.state.mortgageGlobal || {}), owner_index_max: p.owner_index_max }; + } + W.renderMortgageContracts(); + return W.state.mortgageContracts; + } catch (e) { + W.state.mortgageContracts = []; + W.$("mortgageContracts").textContent = `Mortgage portfolio: ${e.message}`; + return []; + } + }; + + W.submitMortgage = async (kind) => { + if (!W.state.wasm) throw new Error("WASM SDK required"); + const pass = W.$("prikey").value.trim(); + if (!pass) throw new Error("Password required"); + const fee = W.$("fee").value.trim(); + const chainId = BigInt(W.$("chainId").value.trim() || W.MAINNET_CHAIN_ID); + const lendId = W.$("lendId").value.trim(); + const ts = BigInt(W.txTimestamp()); + let raw; + if (kind === 15) { + const prepared = await W.prepareMortgageOpen({ preflight: true }); + if (!prepared) throw new Error("Mortgage preparation failed"); + const sel = prepared.sel; + const loan = W.$("loanAmount").value.trim(); + const bp = parseInt(W.$("borrowPeriod").value, 10); + if (!lendId || !loan) throw new Error("Lending ID and loan amount required"); + if (loan !== prepared.principal.loan) { + throw new Error(`Loan must be ${prepared.principal.loan} for selected HACD`); + } + raw = W.state.wasm.hacd_mortgage_open(chainId, pass, lendId, sel.join(","), loan, bp, fee, ts); + } else { + const ransom = W.$("ransomAmount").value.trim(); + if (!lendId || !ransom) throw new Error("Lending ID and ransom required"); + raw = W.state.wasm.hacd_mortgage_redeem(chainId, pass, lendId, ransom, fee, ts); + } + const tx = W.parseSdkJson(raw); + W.log(`Signed ${tx.action} ${tx.tx_hash}`); + const submitted = await W.apiPost("submit/transaction", W.hexToBytes(tx.tx_body)); + W.$("prikey").value = ""; + W.log(`Submitted ${submitted.hash}`, "ok"); + setTimeout(W.loadPortfolio, 3000); + }; +})(window.Hip25Wallet); \ No newline at end of file diff --git a/wallet/hip25/js/portfolio.js b/wallet/hip25/js/portfolio.js new file mode 100644 index 0000000..ff7e11a --- /dev/null +++ b/wallet/hip25/js/portfolio.js @@ -0,0 +1,233 @@ +/* Portfolio load and HACD list */ +(function (W) { + let loadRetryTimer = null; + + W.schedulePortfolioRetry = () => { + if (loadRetryTimer) return; + loadRetryTimer = setTimeout(() => { + loadRetryTimer = null; + W.loadPortfolio({ retry: true }).catch((e) => W.log(e.message, "err")); + }, 2000); + }; + + W.clearPortfolioRetry = () => { + if (!loadRetryTimer) return; + clearTimeout(loadRetryTimer); + loadRetryTimer = null; + }; + + W.selectedAvailableDiamonds = () => { + const byName = Object.fromEntries(W.state.diamonds.map((d) => [d.literal, d])); + return [...W.state.selected].filter((n) => byName[n]?.status === "Available"); + }; + + W.diamondSelectable = (d) => { + if (d.status === "Available") return true; + if (d.status === "Staked" && W.state.height >= (d.min_unstake_height || 0)) return true; + if (d.status === "Mortgaged") return true; + return false; + }; + + W.restoreSelection = (prevSelected) => { + W.state.selected.clear(); + if (!prevSelected.size) return; + const byName = Object.fromEntries(W.state.diamonds.map((d) => [d.literal, d])); + for (const lit of prevSelected) { + const d = byName[lit]; + if (d && W.diamondSelectable(d)) W.state.selected.add(lit); + } + }; + + W.updateActionButtons = () => { + const sel = [...W.state.selected]; + const byName = Object.fromEntries(W.state.diamonds.map((d) => [d.literal, d])); + const canStake = sel.some((n) => byName[n]?.status === "Available"); + const canUnstake = sel.some( + (n) => byName[n]?.status === "Staked" && W.state.height >= (byName[n].min_unstake_height || 0) + ); + const canMortgageOpen = sel.some((n) => byName[n]?.status === "Available"); + const wasmOk = !!W.state.wasm; + W.$("btnStake").disabled = !wasmOk || !canStake; + W.$("btnUnstake").disabled = !wasmOk || !canUnstake; + W.$("btnMortgageOpen").disabled = !wasmOk || !canMortgageOpen; + W.$("btnMortgageRedeem").disabled = !wasmOk || !W.$("lendId").value.trim(); + }; + + W.renderDiamonds = () => { + const box = W.$("diamonds"); + box.innerHTML = ""; + if (!W.state.diamonds.length) { + box.innerHTML = '
No HACD found for this address.
'; + return; + } + for (const d of W.state.diamonds) { + const row = document.createElement("div"); + row.className = "diamond-row" + (W.state.selected.has(d.literal) ? " selected" : ""); + const canStake = d.status === "Available"; + const canUnstake = d.status === "Staked" && W.state.height >= d.min_unstake_height; + const isMortgaged = d.status === "Mortgaged"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = W.state.selected.has(d.literal); + cb.disabled = !W.diamondSelectable(d); + cb.onchange = () => { + if (cb.checked) { + W.state.selected.add(d.literal); + if (isMortgaged && d.lending_id) { + W.$("lendId").value = d.lending_id; + W.$("btnMortgageQuote").click(); + } + } else W.state.selected.delete(d.literal); + W.updateActionButtons(); + row.classList.toggle("selected", cb.checked); + W.scheduleLoanRefresh(); + }; + const info = document.createElement("div"); + const lit = document.createElement("div"); + lit.className = "literal"; + lit.textContent = d.literal; + const meta = document.createElement("div"); + meta.className = "meta"; + let metaTxt = `reward ${d.accrued_reward || "0"} · min unstake block ${d.min_unstake_height ?? "—"}`; + if (d.lending_id) metaTxt += ` · mortgage ${d.lending_id.slice(0, 8)}…`; + meta.textContent = metaTxt; + info.append(lit, meta); + const badge = document.createElement("span"); + badge.className = `badge badge-${d.status}`; + badge.textContent = d.status; + row.append(cb, info, badge, document.createElement("span")); + box.appendChild(row); + } + W.updateActionButtons(); + }; + + W.loadPortfolio = async (opts = {}) => { + const addr = W.$("address").value.trim(); + if (!addr) throw new Error("Address required"); + const prevSelected = new Set(W.state.selected); + if (!opts.retry) W.log("Loading portfolio…"); + + const latest = await W.apiGet("query/latest"); + W.state.height = latest.height; + W.$("sHeight").textContent = latest.height; + W.state.hip25Dev = !!latest.hip25_dev; + if (latest.chain_id !== undefined) { + const nodeChain = String(latest.chain_id); + const cur = W.$("chainId").value.trim(); + if (!cur || cur !== nodeChain) { + W.$("chainId").value = nodeChain; + if (cur && cur !== nodeChain) W.log(`Chain ID synced to node (${nodeChain})`, "err"); + } + W.$("chainId").readOnly = !latest.hip25_dev; + W.$("networkTag").textContent = latest.hip25_dev ? "HIP-25 testnet" : `Mainnet chain ${W.MAINNET_CHAIN_ID}`; + } + + const global = await W.apiGet("query/staking/global"); + W.$("sPool").textContent = global.reward_pool_pending_zhu ?? "—"; + try { + const mg = await W.apiGet("query/mortgage/global"); + W.state.mortgageGlobal = mg; + W.$("sMortgageIoU").textContent = mg.outstanding_ioo_zhu ?? "—"; + W.$("sMortgageApr").textContent = mg.apr_bps ? `${(mg.apr_bps / 100).toFixed(1)}%` : "—"; + const max = mg.owner_index_max ?? 64; + W.$("mortgageIndexHint").textContent = `Up to ${max} active mortgage contracts per address (indexed in portfolio RPC).`; + } catch (_) { + W.state.mortgageGlobal = null; + W.$("sMortgageIoU").textContent = "—"; + W.$("sMortgageApr").textContent = "—"; + } + + const bal = await W.apiGet("query/balance", { address: addr, diamonds: "true" }); + const entry = bal.list?.[0] || {}; + W.$("sHac").textContent = entry.hacash || "0"; + + const summary = await W.apiGet("query/staking/summary", { address: addr }); + W.$("sStaked").textContent = summary.staked_count ?? 0; + W.$("sCooldown").textContent = summary.cooldown_count ?? 0; + W.$("sAccrued").textContent = summary.total_accrued_reward || "0"; + + const contracts = await W.loadMortgagePortfolio(addr); + const mortgagedMap = {}; + for (const c of contracts) { + for (const d of c.diamonds || []) mortgagedMap[d] = c.lending_id; + } + const names = [...new Set([...W.splitDiamonds(entry.diamonds || ""), ...Object.keys(mortgagedMap)])]; + if (!names.length && W.state.height < 1) { + W.log("Waiting for block 1 (testnet seed loads with first block)…"); + W.schedulePortfolioRetry(); + return; + } + W.clearPortfolioRetry(); + if (!names.length) { + W.state.diamonds = []; + W.state.selected.clear(); + W.renderDiamonds(); + W.log(`No HACD on ${addr} — click "Fill HIP-25 testnet seed" then Load portfolio`, "err"); + return; + } + + W.state.diamonds = names.map((literal) => ({ + literal, + status: "Loading", + accrued_reward: "0", + min_unstake_height: 0, + lending_id: mortgagedMap[literal] || "", + })); + W.renderDiamonds(); + await Promise.all( + names.map(async (literal, idx) => { + try { + const st = await W.apiGet("query/staking/status", { diamond: literal }); + W.state.diamonds[idx] = { + literal: st.literal || literal, + status: st.status || "unknown", + accrued_reward: st.accrued_reward || "0", + min_unstake_height: st.min_unstake_height || 0, + lending_id: mortgagedMap[literal] || "", + }; + } catch (e) { + W.state.diamonds[idx] = { + literal, + status: mortgagedMap[literal] ? "Mortgaged" : "unknown", + accrued_reward: "0", + min_unstake_height: 0, + lending_id: mortgagedMap[literal] || "", + }; + } + }) + ); + W.state.diamonds.sort((a, b) => a.literal.localeCompare(b.literal)); + W.restoreSelection(prevSelected); + W.renderDiamonds(); + W.log(`Loaded ${names.length} HACD`, "ok"); + if (W.selectedAvailableDiamonds().length) { + W.prepareMortgageOpen({ quiet: true }).catch((e) => W.log(e.message, "err")); + } + }; + + W.submitStakeAction = async (kind) => { + if (!W.state.wasm) throw new Error("WASM SDK required — build with scripts/build_wallet_sdk.ps1"); + const pass = W.$("prikey").value.trim(); + if (!pass) throw new Error("Password required for local signing"); + const fee = W.$("fee").value.trim(); + const chainIdStr = W.$("chainId").value.trim(); + if (!W.state.hip25Dev && chainIdStr !== W.MAINNET_CHAIN_ID) { + throw new Error(`Mainnet wallet requires chain ID ${W.MAINNET_CHAIN_ID}`); + } + const chainId = BigInt(chainIdStr || W.MAINNET_CHAIN_ID); + const sel = [...W.state.selected]; + if (!sel.length) throw new Error("Select at least one HACD"); + const diamonds = sel.join(","); + const stake = kind === 34; + const fn = stake ? W.state.wasm.hacd_stake : W.state.wasm.hacd_unstake; + const ts = BigInt(W.txTimestamp()); + W.log(`[WASM] ${stake ? "hacd_stake" : "hacd_unstake"} for ${diamonds} (ts=${ts})…`); + const raw = fn(chainId, pass, diamonds, fee, ts); + const tx = W.parseSdkJson(raw); + W.log(`Signed locally ${tx.tx_hash}`); + const submitted = await W.apiPost("submit/transaction", W.hexToBytes(tx.tx_body)); + W.$("prikey").value = ""; + W.log(`Submitted ${submitted.hash}`, "ok"); + setTimeout(W.loadPortfolio, 3000); + }; +})(window.Hip25Wallet); \ No newline at end of file diff --git a/wallet/hip25/js/signing.js b/wallet/hip25/js/signing.js new file mode 100644 index 0000000..b8f8def --- /dev/null +++ b/wallet/hip25/js/signing.js @@ -0,0 +1,75 @@ +/* Local WASM signing address resolution */ +(function (W) { + W.resolveSigningAddress = async () => { + const pass = W.$("prikey").value.trim(); + const el = W.$("signAddress"); + const warn = W.$("addressWarn"); + if (!pass) { + W.state.signAddress = ""; + el.value = ""; + warn.textContent = ""; + return ""; + } + if (!W.state.wasm?.create_account_by) { + el.value = "(rebuild WASM: scripts/build_wallet_sdk.ps1)"; + return ""; + } + try { + const acc = W.parseAccountJson(W.state.wasm.create_account_by(pass)); + W.state.signAddress = acc.address || ""; + el.value = W.state.signAddress; + W.updateAddressMismatchWarning(); + return W.state.signAddress; + } catch (e) { + W.state.signAddress = ""; + el.value = ""; + warn.textContent = e.message; + warn.className = "meta err"; + return ""; + } + }; + + W.updateAddressMismatchWarning = () => { + const warn = W.$("addressWarn"); + const portfolio = W.$("address").value.trim(); + const signer = W.state.signAddress; + if (!portfolio || !signer) { + warn.textContent = ""; + return; + } + if (portfolio !== signer) { + warn.textContent = `Mismatch: portfolio is ${portfolio} but password signs as ${signer}. Transactions will fail until they match.`; + warn.className = "meta err"; + return; + } + warn.textContent = "Portfolio address matches signing address."; + warn.className = "meta ok"; + }; + + W.loadSdkScript = () => + new Promise((resolve, reject) => { + if (typeof wasm_bindgen === "function") return resolve(); + const s = document.createElement("script"); + s.src = `${W.sdkOrigin()}/pkg/hacash_sdk.js`; + s.onload = () => resolve(); + s.onerror = () => reject(new Error("failed to load /pkg/hacash_sdk.js")); + document.head.appendChild(s); + }); + + W.initWasmSdk = async () => { + const el = W.$("sdkStatus"); + try { + await W.loadSdkScript(); + const wasmUrl = `${W.sdkOrigin()}/pkg/hacash_sdk_bg.wasm`; + await wasm_bindgen(wasmUrl); + W.state.wasm = wasm_bindgen; + el.textContent = "WASM SDK: ready — stake / unstake / mortgage (password stays in browser)"; + el.className = "meta ok"; + W.updateActionButtons(); + await W.resolveSigningAddress(); + } catch (e) { + el.textContent = `WASM SDK: ${e.message}`; + el.className = "meta err"; + } + }; +})(window.Hip25Wallet); \ No newline at end of file From eeee5da1af4309f141f2a625feb7c5ce3aed76be Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 18 Jun 2026 15:45:07 +0200 Subject: [PATCH 33/35] HIP-25 v3: supply-neutral staking from HACD mint miner share - Redirect DiamondMint fee_got (10% bid) to staking pool when active - Remove inscription protocol fee redirect; full protocol cost burns - RPC economics_version v3, fee_sources hacd_mint_miner_share - Docs: HIP25_ECONOMICS_V3.md; idle pool sweep counts hacd_bid_burn_zhu --- docs/HIP25_COMMUNITY_REVIEW.md | 2 +- docs/HIP25_ECONOMICS_V2.md | 4 + docs/HIP25_ECONOMICS_V3.md | 37 ++++ docs/hip25_testnet_boot.md | 2 +- src/chain/execute/insert.rs | 17 +- src/chain/execute/mod.rs | 1 + src/mint/action/diamond_insc.rs | 23 +-- src/mint/component/staking.rs | 8 +- src/mint/operate/staking.rs | 326 +++++++++++++++++--------------- src/server/rpc/staking.rs | 3 +- 10 files changed, 242 insertions(+), 181 deletions(-) create mode 100644 docs/HIP25_ECONOMICS_V3.md diff --git a/docs/HIP25_COMMUNITY_REVIEW.md b/docs/HIP25_COMMUNITY_REVIEW.md index 8f80edc..5501255 100644 --- a/docs/HIP25_COMMUNITY_REVIEW.md +++ b/docs/HIP25_COMMUNITY_REVIEW.md @@ -54,7 +54,7 @@ Requires **synced mainnet** `data_dir` — does **not** delete chain data. Reviewers / voters should confirm: -- [ ] **Consensus:** stake/unstake rules match HIP-25 v2 spec (min stake, cooldown, **10% inscription protocol fee only**, idle pool burn, ownership). +- [ ] **Consensus:** stake/unstake rules match HIP-25 **v3** spec (min stake, cooldown, **HACD mint miner-share redirect**, full inscription burn, idle pool burn, ownership). - [ ] **Mainnet safety:** dev flags (`hip25_testnet_seed`) panic at startup when `chain_id=0`. - [ ] **Wallet:** secrets never sent to RPC; only signed `tx_body` submitted. - [ ] **RPC:** loopback-only by default; public bind requires explicit `allow_public_rpc=true`. diff --git a/docs/HIP25_ECONOMICS_V2.md b/docs/HIP25_ECONOMICS_V2.md index b946ae5..af190c0 100644 --- a/docs/HIP25_ECONOMICS_V2.md +++ b/docs/HIP25_ECONOMICS_V2.md @@ -1,3 +1,7 @@ +# HIP-25 v2 Economics (superseded by v3) + +> **Note:** Mainnet/community target is **v3** (`docs/HIP25_ECONOMICS_V3.md`) — inscription fee redirect replaced by HACD mint miner-share redirect for supply-neutral staking. + # HIP-25 v2 Economics (response to HIP-11 / jojoin review) **Context:** [hacash/rust#13](https://github.com/hacash/rust/pull/13#issuecomment-4714088187) — any redistribution of HAC that would otherwise burn requires long-term community consideration (HIP-11). HIP-2 (mortgage + repay principal+interest) is the reference model for HAC liquidity from HACD. diff --git a/docs/HIP25_ECONOMICS_V3.md b/docs/HIP25_ECONOMICS_V3.md new file mode 100644 index 0000000..ae38152 --- /dev/null +++ b/docs/HIP25_ECONOMICS_V3.md @@ -0,0 +1,37 @@ +# HIP-25 v3 Economics (supply-neutral staking) + +**Context:** Community feedback — staking yield should not reduce HAC burn vs baseline. Redirect the existing HACD mint miner-share instead of inscription protocol fees. + +## v3 rule + +| Item | v2 | **v3** | +|------|-----|--------| +| Staking pool funding | 10% of inscription protocol fees (less burn) | **10% of HACD mint bid fee** (`fee_got` on action 4) | +| Inscription fees | 10% → pool, 90% burn when staking active | **100% burn** (unchanged from pre-HIP-25) | +| Miner on DiamondMint blocks | Receives bid 10% via `fee_got` | **0%** when staking active — share goes to pool | +| Total issuance vs pre-HIP-25 | Higher circulating HAC (less inscription burn) | **Unchanged** — reallocation only | +| Idle pool (no stakers) | Burn after 1008 blocks | Same | + +## Mechanism + +1. `DiamondMint` txs use `burn_90`: ~90% of bid fee burns, ~10% is `fee_got`. +2. On block close (`insert.rs`), when staking is active, `fee_got` from **DiamondMint-only** txs deposits into `reward_pool_zhu` instead of the block miner. +3. `staking_on_block_close` distributes pool to stakers by shares; idle pool sweeps to burn (`hacd_bid_burn_zhu` counter). +4. Inscription actions (32/33) no longer call `staking_deposit_fee`. + +## RPC + +`GET /query/staking/global` returns: + +- `economics_version`: `"v3"` +- `fee_sources`: `"hacd_mint_miner_share"` +- `fee_share_percent`: `10` + +## Comparison + +| | HIP-2 mortgage | HIP-25 v3 staking | +|--|----------------|-------------------| +| HAC source | Coinbase IOU (bid collateral) | HACD mint miner-share redirect | +| Supply impact | IOU issuance model | Zero-sum vs current miner payout | + +See `HIP25_ECONOMICS_V2.md` for superseded v2 inscription-fee model. \ No newline at end of file diff --git a/docs/hip25_testnet_boot.md b/docs/hip25_testnet_boot.md index 2f28a73..20cd0ec 100644 --- a/docs/hip25_testnet_boot.md +++ b/docs/hip25_testnet_boot.md @@ -112,7 +112,7 @@ Actions: **15** mortgage open, **16** mortgage redeem. ## Consensus parameters (v1) -- Fee share to staking pool: **10%** (inscription protocol fees only; v2 economics) +- Staking economics: **v3** — **10% of HACD mint bid fee** (miner share) → stakers; inscription fees **fully burn** - `MIN_STAKE_BLOCKS = 25714` - `COOLDOWN_BLOCKS = 864` - `staking_activation_height` in `[mint]` (testnet: `1`) diff --git a/src/chain/execute/insert.rs b/src/chain/execute/insert.rs index 31bd882..3f433e1 100644 --- a/src/chain/execute/insert.rs +++ b/src/chain/execute/insert.rs @@ -110,7 +110,7 @@ pub fn do_check_insert( // ready exec let coinbase_tx = &*alltxs[0]; - let mut alltxfee = Amount::default(); + let mut miner_txfee = Amount::default(); // check state let mut sub_state = fork_sub_state(prev_state.clone()); // if init genesis status @@ -124,17 +124,26 @@ pub fn do_check_insert( if execn > 0 { // except coinbase tx exec_tx_actions(!not_fast_sync, cnf.chain_id, height, blkhash, &mut sub_state, store, tx.as_read())?; let fee = tx.fee_got(); - alltxfee = alltxfee.add(&fee)?; // fee_miner_received (HIP-25 v2: no transfer-fee redirect) + if fee.is_positive() { + let mut mint_state = MintState::wrap(&mut sub_state); + let redirect = crate::mint::operate::staking_is_active_at_height(&mint_state, height) + && crate::mint::operate::staking_tx_qualifies_for_mint_fee_redirect(tx.as_read()); + if redirect { + crate::mint::operate::staking_deposit_mint_miner_share(&mut mint_state, &fee); + } else { + miner_txfee = miner_txfee.add(&fee)?; + } + } } // deduct tx fee after exec all actions tx.execute(height, &mut sub_state)?; // coinbase and other tx execn += 1; } // add miner got fee - if alltxfee.is_positive() { // amt > 0 + if miner_txfee.is_positive() { let miner = coinbase_tx.address().unwrap(); let mut corestate = CoreState::wrap(&mut sub_state); - operate::hac_add(&mut corestate, &miner, &alltxfee)?; + operate::hac_add(&mut corestate, &miner, &miner_txfee)?; } // HIP-25: per-block staking rewards + cooldown finalization crate::mint::operate::staking_on_block_close(&mut sub_state, height)?; diff --git a/src/chain/execute/mod.rs b/src/chain/execute/mod.rs index aeb132e..b6c7520 100644 --- a/src/chain/execute/mod.rs +++ b/src/chain/execute/mod.rs @@ -22,6 +22,7 @@ use crate::core::state::*; use crate::protocol::{self, *}; use crate::protocol::transaction::*; use crate::mint::checker::*; +use crate::mint::state::MintState; use super::roller; use super::roller::*; diff --git a/src/mint/action/diamond_insc.rs b/src/mint/action/diamond_insc.rs index b4e0ab1..3ae9a3a 100644 --- a/src/mint/action/diamond_insc.rs +++ b/src/mint/action/diamond_insc.rs @@ -63,19 +63,11 @@ fn diamond_inscription(this: &DiamondInscription, ctx: &dyn ExecContext, sta: &m return errf!("diamond inscription cost error need {} but got {}", ttcost.to_fin_string(), pcost.to_fin_string()) } - // change count + HIP-25 v2 fee redirect (10% inscription protocol fee → staking pool) + // HIP-25 v3: full protocol cost burns (no inscription fee redirect). let pay_zhu = pcost.to_zhu_unsafe() as u64; - let (to_pool, to_burn_zhu) = staking_redirect_fee_zhu(pay_zhu); - if to_pool > 0 && staking_is_active_at_height(&state, pdhei) { - staking_deposit_fee(&mut state, to_pool); - } let mut ttcount = state.total_count(); ttcount.diamond_engraved += this.diamonds.count().uint() as u64; - ttcount.diamond_insc_burn_zhu += if staking_is_active_at_height(&state, pdhei) { - to_burn_zhu - } else { - pay_zhu - }; + ttcount.diamond_insc_burn_zhu += pay_zhu; state.set_total_count(&ttcount); drop(state); @@ -142,18 +134,9 @@ fn diamond_inscription_clean(this: &DiamondInscriptionClear, ctx: &dyn ExecConte return errf!("diamond inscription cost error need {} but got {}", ttcost.to_fin_string(), pcost.to_fin_string()) } - // change count + HIP-25 fee redirect let pay_zhu = pcost.to_zhu_unsafe() as u64; - let (to_pool, to_burn_zhu) = staking_redirect_fee_zhu(pay_zhu); - if to_pool > 0 && staking_is_active_at_height(&state, pdhei) { - staking_deposit_fee(&mut state, to_pool); - } let mut ttcount = state.total_count(); - ttcount.diamond_insc_burn_zhu += if staking_is_active_at_height(&state, pdhei) { - to_burn_zhu - } else { - pay_zhu - }; + ttcount.diamond_insc_burn_zhu += pay_zhu; state.set_total_count(&ttcount); drop(state); diff --git a/src/mint/component/staking.rs b/src/mint/component/staking.rs index ce7e0cf..e848f46 100644 --- a/src/mint/component/staking.rs +++ b/src/mint/component/staking.rs @@ -6,9 +6,15 @@ * See HIP-25 for the full protocol specification. */ -/// 10% of HIP-15 inscription protocol fees only → reward pool (v2 economics; no transfer-fee redirect). +/// HIP-25 v3: HACD mint bid fee miner share (`fee_got` on DiamondMint) → reward pool when staking active. pub const STAKING_FEE_SHARE_PERCENT: u64 = 10; +/// On-chain economics label returned by staking global RPC. +pub const STAKING_ECONOMICS_VERSION: &str = "v3"; + +/// v3 fee source identifier for RPC (`fee_sources` field). +pub const STAKING_FEE_SOURCES: &str = "hacd_mint_miner_share"; + /// Consecutive blocks with zero stakers and a non-empty pool before undistributed fees are burned. pub const STAKING_POOL_SWEEP_BLOCKS: u64 = 1008; diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs index 8e35118..5fd72d5 100644 --- a/src/mint/operate/staking.rs +++ b/src/mint/operate/staking.rs @@ -1,8 +1,9 @@ + use crate::mint::action::ACTION_KIND_ID_DIAMOND_MINT; -fn staking_accrued_zhu(global_index: &Uint8, snapshot: &Uint8) -> u64 { - global_index.uint().saturating_sub(snapshot.uint()) -} - +fn staking_accrued_zhu(global_index: &Uint8, snapshot: &Uint8) -> u64 { + global_index.uint().saturating_sub(snapshot.uint()) +} + pub fn staking_accrued_amount(global_index: &Uint8, snapshot: &Uint8) -> Ret { let zhu = staking_accrued_zhu(global_index, snapshot) as i64; if zhu <= 0 { @@ -26,12 +27,32 @@ pub fn staking_display_accrued_reward( pub fn staking_is_active_at_height(state: &MintState, height: u64) -> bool { state.staking_global().is_active_at(height) } + +/// HIP-25 v3: miner-visible share of a burn_90 tx fee (10% — matches `Transaction::fee_got`). +pub fn staking_mint_miner_share_zhu(fee_zhu: u64) -> u64 { + fee_zhu * STAKING_FEE_SHARE_PERCENT / 100 +} -/// HIP-25 v2: split HIP-15 inscription protocol fee between staking pool and burn. -pub fn staking_redirect_fee_zhu(fee_zhu: u64) -> (u64, u64) { - let to_pool = fee_zhu * STAKING_FEE_SHARE_PERCENT / 100; - let to_burn = fee_zhu - to_pool; - (to_pool, to_burn) +/// True when tx fee miner share should fund the staking pool (DiamondMint bid only). +pub fn staking_tx_qualifies_for_mint_fee_redirect(tx: &dyn TransactionRead) -> bool { + if !tx.burn_90() { + return false; + } + for act in tx.actions() { + if act.kind() == ACTION_KIND_ID_DIAMOND_MINT { + return true; + } + } + false +} + +/// Deposit HACD mint miner-share into the staking reward pool (v3). +pub fn staking_deposit_mint_miner_share(state: &mut MintState, fee: &Amount) { + if !fee.is_positive() { + return; + } + let zhu = fee.to_zhu_unsafe().max(0.0) as u64; + staking_deposit_fee(state, zhu); } fn staking_push_event(state: &mut MintState, event: &StakingEvent) { @@ -75,7 +96,7 @@ pub fn staking_sweep_idle_pool(state: &mut MintState, height: u64) -> Ret<()> { Uint8::from(global.cumulative_pool_burned_zhu.uint() + pool); state.set_staking_global(&global); let mut ttcount = state.total_count(); - ttcount.diamond_insc_burn_zhu = Uint8::from(ttcount.diamond_insc_burn_zhu.uint() + pool); + ttcount.hacd_bid_burn_zhu = Uint8::from(ttcount.hacd_bid_burn_zhu.uint() + pool); state.set_total_count(&ttcount); staking_push_event( state, @@ -91,7 +112,7 @@ pub fn staking_sweep_idle_pool(state: &mut MintState, height: u64) -> Ret<()> { ); Ok(()) } - + pub fn staking_distribute_rewards(state: &mut MintState, height: u64) -> Ret<()> { let mut global = state.staking_global(); let shares = global.total_staked_shares.uint(); @@ -129,31 +150,31 @@ pub fn staking_distribute_rewards(state: &mut MintState, height: u64) -> Ret<()> } Ok(()) } - -fn staking_enqueue_unlock(state: &mut MintState, entry: &StakingUnlockEntry) -> Ret<()> { - let mut global = state.staking_global(); - let id = global.unlock_queue_tail.uint(); - let key = Uint5::from(id); - state.set_staking_unlock_entry(&key, entry); - global.unlock_queue_tail = Uint5::from(id + 1); - state.set_staking_global(&global); - Ok(()) -} - -fn staking_finalize_unlock(mint_state: &mut MintState, entry: &StakingUnlockEntry) -> Ret<()> { - let dianame = &entry.diamond; - let mut diaitem = must_have!( - format!("diamond {}", dianame.readable()), - mint_state.diamond(dianame) - ); - if diaitem.status != DIAMOND_STATUS_STAKING_COOLDOWN { - return errf!( - "diamond {} unlock failed: expected cooldown status", - dianame.readable() - ); - } - diaitem.status = DIAMOND_STATUS_NORMAL; - mint_state.set_diamond(dianame, &diaitem); + +fn staking_enqueue_unlock(state: &mut MintState, entry: &StakingUnlockEntry) -> Ret<()> { + let mut global = state.staking_global(); + let id = global.unlock_queue_tail.uint(); + let key = Uint5::from(id); + state.set_staking_unlock_entry(&key, entry); + global.unlock_queue_tail = Uint5::from(id + 1); + state.set_staking_global(&global); + Ok(()) +} + +fn staking_finalize_unlock(mint_state: &mut MintState, entry: &StakingUnlockEntry) -> Ret<()> { + let dianame = &entry.diamond; + let mut diaitem = must_have!( + format!("diamond {}", dianame.readable()), + mint_state.diamond(dianame) + ); + if diaitem.status != DIAMOND_STATUS_STAKING_COOLDOWN { + return errf!( + "diamond {} unlock failed: expected cooldown status", + dianame.readable() + ); + } + diaitem.status = DIAMOND_STATUS_NORMAL; + mint_state.set_diamond(dianame, &diaitem); mint_state.del_staking_record(dianame); staking_push_event( @@ -171,18 +192,18 @@ fn staking_finalize_unlock(mint_state: &mut MintState, entry: &StakingUnlockEntr Ok(()) } - -pub fn staking_process_unlock_queue(base_state: &mut dyn State, height: u64) -> Ret<()> { - let mut pending: Vec<(Uint5, StakingUnlockEntry)> = Vec::new(); - - { - let mut mint_state = MintState::wrap(base_state); - let mut global = mint_state.staking_global(); - let mut head = global.unlock_queue_head.uint(); - let tail = global.unlock_queue_tail.uint(); - - while head < tail { - let key = Uint5::from(head); + +pub fn staking_process_unlock_queue(base_state: &mut dyn State, height: u64) -> Ret<()> { + let mut pending: Vec<(Uint5, StakingUnlockEntry)> = Vec::new(); + + { + let mut mint_state = MintState::wrap(base_state); + let mut global = mint_state.staking_global(); + let mut head = global.unlock_queue_head.uint(); + let tail = global.unlock_queue_tail.uint(); + + while head < tail { + let key = Uint5::from(head); let entry = match mint_state.staking_unlock_entry(&key) { Some(e) => e, None => { @@ -194,17 +215,17 @@ pub fn staking_process_unlock_queue(base_state: &mut dyn State, height: u64) -> ); } }; - if entry.unlock_height.uint() > height { - break; - } - pending.push((key, entry)); - head += 1; - } - - global.unlock_queue_head = Uint5::from(head); - mint_state.set_staking_global(&global); - } - + if entry.unlock_height.uint() > height { + break; + } + pending.push((key, entry)); + head += 1; + } + + global.unlock_queue_head = Uint5::from(head); + mint_state.set_staking_global(&global); + } + for (key, entry) in pending { let reward = entry.reward.clone(); let staker = entry.staker.clone(); @@ -224,10 +245,10 @@ pub fn staking_process_unlock_queue(base_state: &mut dyn State, height: u64) -> mint_state.del_staking_unlock_entry(&key); } } - - Ok(()) -} - + + Ok(()) +} + pub fn staking_on_block_close(base_state: &mut dyn State, height: u64) -> Ret<()> { let mint_state = MintState::wrap(base_state); if !staking_is_active_at_height(&mint_state, height) { @@ -242,33 +263,33 @@ pub fn staking_on_block_close(base_state: &mut dyn State, height: u64) -> Ret<() staking_process_unlock_queue(base_state, height)?; Ok(()) } - -pub fn check_diamond_stakeable( - state: &MintState, - staker: &Address, - hacd_name: &DiamondName, -) -> Ret { - let diaitem = must_have!( - format!("diamond {}", hacd_name.readable()), - state.diamond(hacd_name) - ); - if !diamond_status_allows_transfer(&diaitem.status) { - return errf!( - "diamond {} cannot be staked while status is {}", - hacd_name.readable(), - diaitem.status.uint() - ); - } - if *staker != diaitem.address { - return errf!( - "diamond {} not belong to address {}", - hacd_name.readable(), - staker.readable() - ); - } - Ok(diaitem) -} - + +pub fn check_diamond_stakeable( + state: &MintState, + staker: &Address, + hacd_name: &DiamondName, +) -> Ret { + let diaitem = must_have!( + format!("diamond {}", hacd_name.readable()), + state.diamond(hacd_name) + ); + if !diamond_status_allows_transfer(&diaitem.status) { + return errf!( + "diamond {} cannot be staked while status is {}", + hacd_name.readable(), + diaitem.status.uint() + ); + } + if *staker != diaitem.address { + return errf!( + "diamond {} not belong to address {}", + hacd_name.readable(), + staker.readable() + ); + } + Ok(diaitem) +} + /// Parse HVM / HIP-25 diamond list wire format: `Uint1 count` + `count × 6` literal bytes. pub fn staking_parse_hvm_diamonds(raw: &[u8]) -> Ret { if raw.is_empty() { @@ -320,20 +341,20 @@ pub fn staking_apply_stake( if global.is_paused() { return errf!("HACD staking is paused"); } - let reward_index = global.global_reward_index.clone(); - - for dianame in diamonds.list() { - let mut diaitem = check_diamond_stakeable(state, staker, &dianame)?; - diaitem.status = DIAMOND_STATUS_STAKED; - state.set_diamond(&dianame, &diaitem); - - let record = StakingRecord { - stake_height: BlockHeight::from(height), - unlock_height: BlockHeight::from(0), - reward_index: reward_index.clone(), - pending_reward: Amount::default(), - }; - state.set_staking_record(&dianame, &record); + let reward_index = global.global_reward_index.clone(); + + for dianame in diamonds.list() { + let mut diaitem = check_diamond_stakeable(state, staker, &dianame)?; + diaitem.status = DIAMOND_STATUS_STAKED; + state.set_diamond(&dianame, &diaitem); + + let record = StakingRecord { + stake_height: BlockHeight::from(height), + unlock_height: BlockHeight::from(0), + reward_index: reward_index.clone(), + pending_reward: Amount::default(), + }; + state.set_staking_record(&dianame, &record); global.total_staked_shares = Uint5::from(global.total_staked_shares.uint() + 1); @@ -364,30 +385,30 @@ pub fn staking_apply_unstake( height: u64, chain_id: u64, ) -> Ret<()> { - diamonds.check()?; - let global = state.staking_global(); - let reward_index = global.global_reward_index.clone(); - - for dianame in diamonds.list() { - let mut diaitem = must_have!( - format!("diamond {}", dianame.readable()), - state.diamond(&dianame) - ); - if diaitem.status != DIAMOND_STATUS_STAKED { - return errf!("diamond {} is not staked", dianame.readable()); - } - if *staker != diaitem.address { - return errf!( - "diamond {} not belong to staker {}", - dianame.readable(), - staker.readable() - ); - } - - let record = must_have!( - format!("staking record for {}", dianame.readable()), - state.staking_record(&dianame) - ); + diamonds.check()?; + let global = state.staking_global(); + let reward_index = global.global_reward_index.clone(); + + for dianame in diamonds.list() { + let mut diaitem = must_have!( + format!("diamond {}", dianame.readable()), + state.diamond(&dianame) + ); + if diaitem.status != DIAMOND_STATUS_STAKED { + return errf!("diamond {} is not staked", dianame.readable()); + } + if *staker != diaitem.address { + return errf!( + "diamond {} not belong to staker {}", + dianame.readable(), + staker.readable() + ); + } + + let record = must_have!( + format!("staking record for {}", dianame.readable()), + state.staking_record(&dianame) + ); let stake_height = record.stake_height.uint(); let global_snap = state.staking_global(); let min_stake = global_snap.effective_min_stake_blocks(chain_id); @@ -406,25 +427,25 @@ pub fn staking_apply_unstake( state.set_diamond(&dianame, &diaitem); let unlock_height = height + cooldown; - let cooldown_record = StakingRecord { - stake_height: record.stake_height.clone(), - unlock_height: BlockHeight::from(unlock_height), - reward_index: reward_index.clone(), - pending_reward: reward.clone(), - }; - state.set_staking_record(&dianame, &cooldown_record); - - let mut global = state.staking_global(); - global.total_staked_shares = - Uint5::from(global.total_staked_shares.uint().saturating_sub(1)); - state.set_staking_global(&global); - - let entry = StakingUnlockEntry { - unlock_height: BlockHeight::from(unlock_height), - diamond: dianame.clone(), - staker: staker.clone(), - reward, - }; + let cooldown_record = StakingRecord { + stake_height: record.stake_height.clone(), + unlock_height: BlockHeight::from(unlock_height), + reward_index: reward_index.clone(), + pending_reward: reward.clone(), + }; + state.set_staking_record(&dianame, &cooldown_record); + + let mut global = state.staking_global(); + global.total_staked_shares = + Uint5::from(global.total_staked_shares.uint().saturating_sub(1)); + state.set_staking_global(&global); + + let entry = StakingUnlockEntry { + unlock_height: BlockHeight::from(unlock_height), + diamond: dianame.clone(), + staker: staker.clone(), + reward, + }; staking_enqueue_unlock(state, &entry)?; staking_push_event( @@ -443,7 +464,7 @@ pub fn staking_apply_unstake( Ok(()) } - + #[cfg(test)] mod staking_tests { use super::*; @@ -506,10 +527,9 @@ mod staking_tests { } #[test] - fn fee_redirect_splits_10_90_inscription_protocol_only() { - let (pool, burn) = staking_redirect_fee_zhu(1000); - assert_eq!(pool, 100); - assert_eq!(burn, 900); + fn mint_miner_share_is_ten_percent_of_bid_fee() { + assert_eq!(staking_mint_miner_share_zhu(2300), 230); + assert_eq!(staking_mint_miner_share_zhu(1000), 100); } #[test] @@ -651,7 +671,7 @@ mod staking_tests { } assert_eq!(mint.staking_global().reward_pool_zhu.uint(), 0); assert_eq!(mint.staking_global().cumulative_pool_burned_zhu.uint(), 5000); - assert_eq!(mint.total_count().diamond_insc_burn_zhu.uint(), 5000); + assert_eq!(mint.total_count().hacd_bid_burn_zhu.uint(), 5000); } #[test] diff --git a/src/server/rpc/staking.rs b/src/server/rpc/staking.rs index ed69d33..7fb27f2 100644 --- a/src/server/rpc/staking.rs +++ b/src/server/rpc/staking.rs @@ -135,8 +135,9 @@ async fn staking_global(State(ctx): State, _q: Query) -> "activation_height", global.activation_height.uint(), "event_count", global.event_log_tail.uint(), "paused", global.is_paused(), + "economics_version", STAKING_ECONOMICS_VERSION, "fee_share_percent", STAKING_FEE_SHARE_PERCENT, - "fee_sources", "inscription_protocol_only", + "fee_sources", STAKING_FEE_SOURCES, "cumulative_deposit_zhu", global.cumulative_deposit_zhu.uint(), "cumulative_paid_zhu", global.cumulative_paid_zhu.uint(), "cumulative_pool_burned_zhu", global.cumulative_pool_burned_zhu.uint(), From 844652399f6210c8eb2be2297faced13b7576ad8 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Sun, 21 Jun 2026 00:40:09 +0200 Subject: [PATCH 34/35] =?UTF-8?q?docs:=20HIP-25=20reference=20repositionin?= =?UTF-8?q?g=20=E2=80=94=20spec,=20roadmap,=20v3=20PR=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe fork as reference implementation for fullnodedev port, not legacy rust mainnet merge. Add formal HIP25_SPEC, roadmap, maintainer outreach templates, and community post. --- README.md | 12 ++- docs/HIP25_COMMUNITY_POST.md | 47 ++++++++++ docs/HIP25_COMMUNITY_REVIEW.md | 19 ++-- docs/HIP25_ROADMAP.md | 59 ++++++++++++ docs/HIP25_SPEC.md | 162 +++++++++++++++++++++++++++++++++ docs/JOJOIN_OUTREACH.md | 54 +++++++++++ docs/UPSTREAM_PR.md | 93 +++++++++---------- 7 files changed, 387 insertions(+), 59 deletions(-) create mode 100644 docs/HIP25_COMMUNITY_POST.md create mode 100644 docs/HIP25_ROADMAP.md create mode 100644 docs/HIP25_SPEC.md create mode 100644 docs/JOJOIN_OUTREACH.md diff --git a/README.md b/README.md index 54f3609..c90ae6f 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,11 @@ Each layer of the architecture has independent functions and responsibilities fo 7. [Miner] Miner - block construction and mining, diamond mining, transaction memory pool, mining pool server, mining pool worker, etc. -### HIP-25 HACD staking (community review) +### HIP-25 HACD staking (reference implementation) -**Branch:** [`hip-25-staking`](https://github.com/Moskyera/rust/tree/hip-25-staking) · **Release:** [`v0.1.0-hip25-mainnet`](https://github.com/Moskyera/rust/releases/tag/v0.1.0-hip25-mainnet) · **Audits:** 5/5 PASS @ `a798094` +**Branch:** [`hip-25-staking`](https://github.com/Moskyera/rust/tree/hip-25-staking) · **Tag:** `v0.1.0-hip25-reference` · **Audits:** 5/5 PASS @ `a798094` + +> **Note:** Official Hacash development is on [fullnodedev](https://github.com/hacash/fullnodedev) → [fullnode](https://github.com/hacash/fullnode/releases). This fork is a **tested reference** for HIP-25 spec review and a future port — not the canonical mainnet merge target for legacy [hacash/rust](https://github.com/hacash/rust). | Mode | Launcher | Port | Notes | |------|----------|------|-------| @@ -39,9 +41,9 @@ Each layer of the architecture has independent functions and responsibilities fo powershell -ExecutionPolicy Bypass -File scripts\BUILD_MAINNET_RELEASE.ps1 ``` -**Community review / voting:** [docs/HIP25_COMMUNITY_REVIEW.md](docs/HIP25_COMMUNITY_REVIEW.md) -**Upstream PR (copy-paste body):** [docs/UPSTREAM_PR.md](docs/UPSTREAM_PR.md) -**Open PR to official repo:** https://github.com/hacash/rust/compare/main...Moskyera:rust:hip-25-staking?expand=1 +**Spec:** [docs/HIP25_SPEC.md](docs/HIP25_SPEC.md) · **Roadmap:** [docs/HIP25_ROADMAP.md](docs/HIP25_ROADMAP.md) +**Community review:** [docs/HIP25_COMMUNITY_REVIEW.md](docs/HIP25_COMMUNITY_REVIEW.md) +**Discussion PR:** https://github.com/hacash/rust/pull/13 Config template: `hacash_mainnet_hip25.config.ini.example` (`chain_id=0`, loopback RPC, client WASM signing only). diff --git a/docs/HIP25_COMMUNITY_POST.md b/docs/HIP25_COMMUNITY_POST.md new file mode 100644 index 0000000..68e2ea1 --- /dev/null +++ b/docs/HIP25_COMMUNITY_POST.md @@ -0,0 +1,47 @@ +# Community post (English) — copy-paste + +**Suggested channels:** HacashTalk, Discord, X thread, PR #13 + +--- + +## Post + +**HIP-25 HACD Staking — reference implementation ready, seeking mainline path** + +We've completed a full **HIP-25** prototype: on-chain stake/unstake (actions 34/35), supply-neutral **v3 economics**, WASM wallet, 24 tests, and 5 security audits (all PASS). + +### What it does + +- Lock HACD to earn a share of HAC from the **existing 10% DiamondMint miner fee** — not new coinbase minting. +- Inscription fees stay **100% burn** (same as today). +- Min stake ~90 days, cooldown ~3 days — reduces mercenary staking around mint events. +- Cannot stake mortgaged diamonds (HIP-2 compatible). + +### v3 economics (why it changed) + +Early feedback (including @jojoin on PR #13) noted that redirecting burn-bound HAC needs HIP-11-level care. **v3** fixes this: we only redirect the miner share that already goes to the block miner today. Total HAC issuance vs baseline is unchanged — reallocation, not inflation. + +### Important: this is a reference impl, not legacy rust merge + +Official Hacash development has moved to **fullnodedev → fullnode** ([developer guide](https://www.hacash.org/development)). The legacy `hacash/rust` repo is inactive. Our code is a **tested reference** for community review and a future port — not a claim that PR #13 alone activates mainnet. + +### Links + +- **Spec:** https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_SPEC.md +- **Branch:** https://github.com/Moskyera/rust/tree/hip-25-staking +- **Discussion PR:** https://github.com/hacash/rust/pull/13 +- **Economics v3:** https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_ECONOMICS_V3.md +- **Try testnet wallet:** clone fork → `scripts\START_WALLET.bat` → http://127.0.0.1:8083/hip25/wallet + +### What we need from the community + +1. Review the spec and economics (especially vs HIP-2 mortgage and dynamic-staking proposals). +2. Feedback on min stake / cooldown durations. +3. Support for formal HIP submission to hacash/paper. +4. Maintainer direction on **fullnodedev port** timing (before/after Istanbul @ 765432). + +We will **not** announce a mainnet fork height without ≥30 days notice and maintainer agreement. + +Thank you to everyone who reviewed PR #13 — the discussion is moving the design in the right direction. + +— Moskyera / HIP-25 reference implementation \ No newline at end of file diff --git a/docs/HIP25_COMMUNITY_REVIEW.md b/docs/HIP25_COMMUNITY_REVIEW.md index 5501255..e405b09 100644 --- a/docs/HIP25_COMMUNITY_REVIEW.md +++ b/docs/HIP25_COMMUNITY_REVIEW.md @@ -1,10 +1,13 @@ # HIP-25 — Community Review & Voting Guide -**Proposal:** Merge HIP-25 HACD staking (actions 34/35) + local WASM wallet into Hacash Rust fullnode. +**Proposal:** HIP-25 HACD staking (actions 34/35) — **reference implementation** for spec review and future **fullnodedev** port. **Fork:** https://github.com/Moskyera/rust/tree/hip-25-staking -**Release tag:** `v0.1.0-hip25-mainnet` +**Reference tag:** `v0.1.0-hip25-reference` +**Formal spec:** [HIP25_SPEC.md](./HIP25_SPEC.md) · **Roadmap:** [HIP25_ROADMAP.md](./HIP25_ROADMAP.md) **Audit status:** 5 independent security audits — **PASS** (local mainnet wallet model) +> Canonical mainnet path is [hacash/fullnodedev](https://github.com/hacash/fullnodedev) → [hacash/fullnode](https://github.com/hacash/fullnode/releases), not legacy `hacash/rust`. PR #13 is for **discussion**, not assumed merge. + --- ## What to review @@ -63,14 +66,14 @@ Reviewers / voters should confirm: --- -## Upstream merge - -Open PR to official repo using prepared body: +## Discussion PR & mainline path -**→ [docs/UPSTREAM_PR.md](./UPSTREAM_PR.md)** (copy-paste PR description) +**Open discussion PR:** https://github.com/hacash/rust/pull/13 +**PR body (v3 + reference status):** [docs/UPSTREAM_PR.md](./UPSTREAM_PR.md) +**Maintainer outreach:** [docs/JOJOIN_OUTREACH.md](./JOJOIN_OUTREACH.md) +**Community post:** [docs/HIP25_COMMUNITY_POST.md](./HIP25_COMMUNITY_POST.md) -**One-click compare:** -https://github.com/hacash/rust/compare/main...Moskyera:rust:hip-25-staking?expand=1 +Next step for mainnet: port to **fullnodedev** after maintainer alignment (see roadmap). --- diff --git a/docs/HIP25_ROADMAP.md b/docs/HIP25_ROADMAP.md new file mode 100644 index 0000000..d04728f --- /dev/null +++ b/docs/HIP25_ROADMAP.md @@ -0,0 +1,59 @@ +# HIP-25 Roadmap — Reference → Mainline + +## Current position + +| Item | Status | +|------|--------| +| Reference impl (Moskyera/rust `hip-25-staking`) | ✅ `eeee5da` — v3 economics, 24 tests, 5 audits | +| Discussion PR (hacash/rust #13) | ✅ Open — community feedback | +| Formal HIP doc | ✅ `docs/HIP25_SPEC.md` (submit to hacash/paper) | +| fullnodedev port | ⏳ Awaiting maintainer alignment | +| Mainnet activation height | ⏳ Not set | + +## What changed (June 2026) + +We no longer treat merge into **legacy** `hacash/rust` as the mainnet path. Official development moved to **fullnodedev → fullnode** ([hacash.org/development](https://www.hacash.org/development)). Our work remains a **reference implementation** and specification baseline. + +## Phase 1 — Stabilize reference (done / this repo) + +- [x] HIP-25 v3 supply-neutral economics +- [x] WASM wallet + mainnet RPC hardening +- [x] HIP-2 v2.1 mortgage (actions 15/16) on same branch +- [x] Formal spec: `HIP25_SPEC.md` +- [x] Tag: `v0.1.0-hip25-reference` + +## Phase 2 — Governance (next) + +1. Submit `HIP25_SPEC.md` to [hacash/paper](https://github.com/hacash/paper) (HIP table entry). +2. Maintainer outreach (jojoin) — see `JOJOIN_OUTREACH.md`. +3. Community post — see `HIP25_COMMUNITY_POST.md`. +4. Keep PR #13 open as **discussion + audit record**; description reflects v3 + reference status. + +## Phase 3 — fullnodedev port (after maintainer yes) + +Port order (estimated): + +1. **Protocol** — action kinds 34/35, diamond status 4/5, state types (`mint/`, `protocol/`) +2. **Consensus** — `staking_apply_stake/unstake`, block-close distribution, v3 fee redirect in chain insert +3. **RPC** — `/query/staking/*` in `server/` +4. **Tests** — port `staking_tests` to fullnodedev testkit +5. **Wallet** — integrate with official WASM SDK or hacash/wallet + +**Do not start Phase 3 without explicit maintainer direction** — Istanbul upgrade (height 765432) is the current priority. + +## Phase 4 — Mainnet activation + +- Community ≥30 day notice +- Agreed `staking_activation_height` +- Release via hacash/fullnode +- Optional: separate height from Istanbul or bundled — maintainer decision + +## Assets preserved + +| Asset | Location | +|-------|----------| +| Spec | `docs/HIP25_SPEC.md` | +| Economics v3 | `docs/HIP25_ECONOMICS_V3.md` | +| Community review | `docs/HIP25_COMMUNITY_REVIEW.md` | +| Code | https://github.com/Moskyera/rust/tree/hip-25-staking | +| Tag | `v0.1.0-hip25-reference` | \ No newline at end of file diff --git a/docs/HIP25_SPEC.md b/docs/HIP25_SPEC.md new file mode 100644 index 0000000..13d5192 --- /dev/null +++ b/docs/HIP25_SPEC.md @@ -0,0 +1,162 @@ +# HIP-25: HACD Staking (Pure Lock-and-Earn) + +**Status:** Reference implementation complete · Formal HIP submission draft +**Authors:** Moskyera community implementation +**Reference code:** https://github.com/Moskyera/rust/tree/hip-25-staking @ `eeee5da` +**Economics version:** v3 (supply-neutral) +**Intended upstream:** [hacash/fullnodedev](https://github.com/hacash/fullnodedev) per [HIP-12](https://github.com/hacash/doc/blob/main/HIP/development/HIP-12_Hacash_development_workflow_and_code_permission.pdf) + +--- + +## Abstract + +HIP-25 introduces on-chain **HACD staking**: holders lock diamonds to earn a pro-rata share of HAC redirected from the existing **10% DiamondMint miner fee** (`fee_got`). No new HAC is minted. Inscription protocol fees remain **100% burn** (unchanged from pre-HIP-25). Staking complements — does not replace — [HIP-2](https://hacashtalk.com/t/diamond-mortgage-loan-proposal/117) mortgage lending. + +--- + +## Motivation + +1. **Store-of-value utility** for HACD without coinbase IOU issuance (contrast HIP-2). +2. **Supply-neutral yield** — redirect existing miner-share, not burn-bound inscription fees (v3 addresses HIP-11 concerns raised in [PR #13](https://github.com/hacash/rust/pull/13)). +3. **Commitment** — minimum stake period reduces mercenary stake/unstake around mint events. +4. **Mutual exclusion** with mortgaged diamonds (status 2/3) and inscription on locked diamonds. + +--- + +## On-chain actions + +| Kind | Name | Payload | +|------|------|---------| +| 34 | `DiamondStake` | `DiamondNameListMax200` | +| 35 | `DiamondUnstake` | `DiamondNameListMax200` | + +Both require main-address signature. Up to 200 diamonds per action. + +--- + +## Diamond status + +| Status | Value | Meaning | +|--------|-------|---------| +| Normal | 1 | Transfer / inscribe allowed | +| Staked | 4 | Locked, earning rewards | +| Staking cooldown | 5 | Unstake requested; rewards fixed; unlock at `unlock_height` | + +Staked and cooldown diamonds cannot transfer or receive inscriptions. + +--- + +## Timing parameters + +| Parameter | Blocks | ~Duration | +|-----------|--------|-----------| +| `MIN_STAKE_BLOCKS` | 25,714 | ~90 days | +| `COOLDOWN_BLOCKS` | 864 | ~3 days | +| `STAKING_POOL_SWEEP_BLOCKS` | 1,008 | idle pool burn delay | + +Unstake allowed only after `stake_height + MIN_STAKE_BLOCKS`. +HACD returns to liquid balance at `unstake_height + COOLDOWN_BLOCKS`. + +Testnet may use compressed periods via `hip25_testnet_demo_periods` (mainnet: **panic** if enabled with `chain_id=0`). + +--- + +## Economics v3 (supply-neutral) + +### Fee redirect + +When staking is active at block height `H ≥ activation_height`: + +1. `DiamondMint` (kind 4) transactions use `burn_90` fee split. +2. The **10% miner share** (`fee_got`) deposits into `reward_pool_zhu` instead of the block miner. +3. Inscription actions (32/33) do **not** fund the pool — **100% burn** as today. +4. HAC transfer fees follow existing burn/miner rules (no staking redirect). + +### Reward distribution + +- Global `reward_index` increases per staked share each block close. +- Stakers claim accrued HAC on unstake (fixed `pending_reward` during cooldown). +- If `total_staked_shares == 0` for `STAKING_POOL_SWEEP_BLOCKS` consecutive blocks, undistributed pool burns (`hacd_bid_burn_zhu` counter). + +### Comparison + +| Model | HAC source | Supply vs baseline | +|-------|------------|-------------------| +| HIP-2 mortgage | Coinbase IOU | Issuance on loan; burn on repay | +| HIP-25 v2 (superseded) | Inscription protocol fees | More circulating HAC | +| **HIP-25 v3** | DiamondMint miner 10% | **Unchanged total issuance** | + +See `HIP25_ECONOMICS_V3.md` for implementation notes. + +--- + +## Global state + +`StakingGlobal` (consensus): + +- `activation_height` +- `reward_pool_zhu`, `reward_index`, `total_staked_shares` +- `cumulative_deposit_zhu`, `cumulative_paid_zhu`, `cumulative_pool_burned_zhu` +- `idle_pool_blocks`, `event_log_tail` + +Per-diamond `StakingRecord` and per-address stake index as in reference impl. + +--- + +## Activation + +- Config: `staking_activation_height` in `[mint]` (0 = disabled). +- Requires **≥30 days** community notice before mainnet height is set. +- Nodes must upgrade before activation height; no effect until then. + +--- + +## RPC (reference) + +`GET /query/staking/global`: + +```json +{ + "economics_version": "v3", + "fee_sources": "hacd_mint_miner_share", + "fee_share_percent": 10 +} +``` + +--- + +## Security (reference impl) + +- 24 staking unit tests (`cargo test staking_tests`) +- 5 independent audits PASS @ `a798094` +- Mainnet: loopback RPC default, no server-side `prikey` on `chain_id=0` + +--- + +## Relationship to other HIPs + +| HIP | Relationship | +|-----|--------------| +| HIP-1 | Bid fee destruction unchanged (90% burn on mint) | +| HIP-2 | Complementary; mutual exclusion per diamond | +| HIP-11 | v3 avoids redirecting burn-bound HAC | +| HIP-15 | Inscription burn unchanged under v3 | +| HIP-12 | Port target: `fullnodedev`, not legacy `hacash/rust` | + +--- + +## Reference implementation disclaimer + +The working prototype lives on **legacy** [hacash/rust](https://github.com/hacash/rust) architecture (fork: [Moskyera/rust](https://github.com/Moskyera/rust)). Canonical mainnet integration requires porting to [hacash/fullnodedev](https://github.com/hacash/fullnodedev) and release via [hacash/fullnode](https://github.com/hacash/fullnode/releases). + +**Open PR (discussion):** https://github.com/hacash/rust/pull/13 + +--- + +## Changelog + +| Version | Change | +|---------|--------| +| v1 | Initial stake/unstake + global index | +| v2 | Inscription fee redirect only; idle pool burn | +| v3 | Supply-neutral: DiamondMint miner-share redirect; inscription 100% burn | \ No newline at end of file diff --git a/docs/JOJOIN_OUTREACH.md b/docs/JOJOIN_OUTREACH.md new file mode 100644 index 0000000..4a24dc4 --- /dev/null +++ b/docs/JOJOIN_OUTREACH.md @@ -0,0 +1,54 @@ +# Maintainer outreach — copy-paste for Discord / GitHub + +**Discord dev channel:** https://discord.com/channels/757976908653920299/802807729584209920 +**GitHub PR:** https://github.com/hacash/rust/pull/13 + +--- + +## Short version (Discord) + +``` +@jojoin — HIP-25 update from the Moskyera reference implementation. + +We completed a working HACD staking prototype (actions 34/35) on the legacy rust fork, with v3 supply-neutral economics: redirect the existing DiamondMint 10% miner share to stakers — no new HAC minting, inscription fees stay 100% burn. 24 tests, 5 audits PASS. + +We understand canonical mainnet work is fullnodedev → fullnode, not hacash/rust. We are NOT asking for a rust repo merge. + +Questions: +1. Is HIP-25 staking on the roadmap for fullnodedev? +2. Should activation be a separate fork height or after Istanbul (765432)? +3. Would you prefer we port to fullnodedev ourselves (PR) or wait for core team? + +Spec draft: https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_SPEC.md +Reference branch: https://github.com/Moskyera/rust/tree/hip-25-staking + +Happy to adjust economics/parameters from your HIP-11 / HIP-2 guidance. Thanks for the review on PR #13. +``` + +--- + +## Long version (GitHub comment on PR #13) + +```markdown +## Reference implementation status (not a merge request for legacy rust) + +Thanks again for the HIP-11 / HIP-2 economics note. Following that feedback we moved to **v3 supply-neutral economics** (`eeee5da`): + +- **Funding:** 10% of DiamondMint `fee_got` (miner share) → staking pool when active +- **Inscription fees:** 100% burn (unchanged from pre-HIP-25) +- **Supply:** zero-sum vs current miner payout — no extra circulating HAC from inscription redirect + +We now treat this PR as a **reference implementation + community review**, not a request to merge into legacy `hacash/rust`. Per [hacash.org/development](https://www.hacash.org/development), canonical mainnet path is **fullnodedev → fullnode**. + +**Formal spec draft:** [HIP25_SPEC.md](https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_SPEC.md) + +**Questions for maintainers:** + +1. Should HIP-25 be ported to `fullnodedev`? We can open a PR there if useful. +2. Preferred activation: separate `staking_activation_height` or post-Istanbul (765432)? +3. Any parameter changes required before port (min stake 25714 blocks, cooldown 864, idle sweep 1008)? + +We will not set a mainnet activation height without community consensus and maintainer agreement. + +Tag: `v0.1.0-hip25-reference` on the fork for a frozen reference point. +``` \ No newline at end of file diff --git a/docs/UPSTREAM_PR.md b/docs/UPSTREAM_PR.md index dfa94f4..2835dbf 100644 --- a/docs/UPSTREAM_PR.md +++ b/docs/UPSTREAM_PR.md @@ -1,15 +1,17 @@ -# Pull Request — HIP-25 HACD Staking (upstream `hacash/rust`) +# Pull Request — HIP-25 Reference Implementation -**Use this as the PR title and description** when opening: +**Discussion PR (legacy repo):** https://github.com/hacash/rust/pull/13 -https://github.com/hacash/rust/compare/main...Moskyera:rust:hip-25-staking?expand=1 +**Canonical mainnet path:** [hacash/fullnodedev](https://github.com/hacash/fullnodedev) → [hacash/fullnode](https://github.com/hacash/fullnode/releases) per [HIP-12](https://github.com/hacash/doc/blob/main/HIP/development/HIP-12_Hacash_development_workflow_and_code_permission.pdf). + +This document is the PR body for **community review**. It is **not** a request to merge into inactive `hacash/rust` as the mainnet delivery vehicle. --- ## PR Title ``` -HIP-25: HACD staking (kinds 34/35) + HIP-2 v2 mortgage (kinds 15/16), WASM wallet, mainnet security hardening +HIP-25 reference: HACD staking (34/35), v3 supply-neutral economics, WASM wallet ``` --- @@ -18,46 +20,52 @@ HIP-25: HACD staking (kinds 34/35) + HIP-2 v2 mortgage (kinds 15/16), WASM walle ### Summary -This PR adds **HIP-25 HACD staking** and **HIP-2 v2.1 system mortgage** to the Hacash Rust fullnode: +**Reference implementation** of HIP-25 HACD staking on legacy Hacash Rust architecture ([Moskyera/rust](https://github.com/Moskyera/rust) fork). Intended for spec validation, audits, and future port to **fullnodedev**. **HIP-25 (actions 34/35)** -- Global reward index pool, **10% inscription protocol fee redirect** (v2), idle pool burn, min stake / cooldown +- Global reward index pool, **v3 supply-neutral economics** +- **10% DiamondMint miner share** (`fee_got`) → staking pool when active +- Inscription fees: **100% burn** (unchanged from pre-HIP-25) +- Idle pool burn after 1008 blocks with zero stakers +- Min stake ~90d (`25714` blocks), cooldown ~3d (`864` blocks) - 24 staking unit tests -**HIP-2 v2.1 (actions 15/16)** -- HAC IOU mortgage against bid-burn collateral: **1% origination burn**, **3% flat APR**, **3-period grace**, **103% auction floor** -- Global `GlobalMortgageState`, per-contract `diamond_syslend` map, supply + mortgage RPC -- 15 unit + 9 E2E mortgage tests (full tx pipeline, Go wire compat) -- WASM: `hacd_mortgage_open` / `hacd_mortgage_redeem`; wallet UI mortgage panel +**HIP-2 v2.1 (actions 15/16)** — on same branch, complementary +- HAC IOU mortgage: 1% origination burn, 3% flat APR, mutual exclusion with staking +- 15 unit + 9 E2E mortgage tests **Shared** -- **Local WASM wallet** at `/hip25/wallet` (client-side signing only on mainnet) -- **Security hardening** for mainnet RPC (loopback default, rate limits, origin checks, no server-side secrets on `chain_id=0`) -- Staking ↔ mortgage mutual exclusion per diamond +- Local WASM wallet at `/hip25/wallet` (client-side signing on mainnet) +- Mainnet RPC hardening (loopback default, rate limits, no server `prikey` on `chain_id=0`) -### Audit status +### Economics v3 (current) + +| Source | v2 (superseded) | **v3** | +|--------|-----------------|--------| +| Staking pool | 10% inscription protocol fees | **10% DiamondMint miner share** | +| Inscription | Partial redirect to pool | **100% burn** | +| Supply vs baseline | Higher circulating HAC | **Unchanged** (reallocation only) | -**HIP-25** — 5 audits @ `a798094`: all **PASS** (see `docs/HIP25_COMMUNITY_REVIEW.md`) +Details: `docs/HIP25_ECONOMICS_V3.md` · Formal spec: `docs/HIP25_SPEC.md` -**HIP-2 v2.1** — 5 audits on branch `hip-25-staking`: all **PASS** (see `docs/HIP2_SECURITY_AUDITS.md`) +### Audit status -| Audit | HIP-25 | HIP-2 | -|-------|--------|-------| +| Audit | HIP-25 @ `a798094` | HIP-2 | +|-------|-------------------|-------| | Auth & secrets | PASS | PASS | | Economics | PASS | PASS | | API & DoS | PASS | PASS | | Tx pipeline | PASS | PASS | | WASM wallet | PASS | PASS | -**48** consensus tests pass (`staking_tests` + `mortgage_`). CI: `.github/workflows/hip25-ci.yml`. +**48** consensus tests pass. CI: `.github/workflows/hip25-ci.yml`. -### Mainnet safety controls +### Mainnet safety - `chain_id=0` blocks server-side `prikey` signing RPCs -- `hip25_testnet_seed` / `demo_periods` **panic** if enabled with mainnet `chain_id` +- `hip25_testnet_seed` / demo flags **panic** on mainnet `chain_id` - Default `listen_host=127.0.0.1`, `allow_public_rpc=false` - P2P tx ingress: signature + `try_execute_tx` before relay -- Query-string secret rejection on signing routes ### How to test @@ -74,46 +82,39 @@ scripts\START_MAINNET_WALLET.bat ### Breaking changes -None for existing mainnet nodes until `staking_activation_height` is reached. +None until `staking_activation_height` is reached on a network running this consensus. ### Configuration -Copy `hacash_mainnet_hip25.config.ini.example` → `hacash.config.ini`: +`hacash_mainnet_hip25.config.ini.example`: ```ini [mint] chain_id = 0 -staking_activation_height = +staking_activation_height = 0 hip25_testnet_seed = false hip25_testnet_demo_periods = false -mortgage_activation_height = 0 -mortgage_max_outstanding_zhu = 0 -hip2_testnet_demo_periods = false ``` -### Documentation +Activation height set only after community + maintainer consensus (≥30 day notice). -- HIP-25 community review: `docs/HIP25_COMMUNITY_REVIEW.md` -- HIP-2 community review: `docs/HIP2_COMMUNITY_REVIEW.md` -- HIP-2 spec / economics: `docs/HIP2_MORTGAGE_V2.md` -- HIP-2 security audits (5× PASS): `docs/HIP2_SECURITY_AUDITS.md` +### Documentation -### Checklist +- **HIP-25 spec:** `docs/HIP25_SPEC.md` +- **Roadmap:** `docs/HIP25_ROADMAP.md` +- Community review: `docs/HIP25_COMMUNITY_REVIEW.md` +- Economics v3: `docs/HIP25_ECONOMICS_V3.md` -- [x] HIP-25 staking consensus + tests (24) -- [x] HIP-2 mortgage consensus + unit/E2E tests (24) -- [x] Mainnet config guards (HIP-25 + HIP-2 dev flags) -- [x] WASM wallet (stake / unstake / mortgage) -- [x] RPC security middleware + mortgage/supply queries -- [x] 10 security audits PASS (5 HIP-25 + 5 HIP-2) -- [ ] **Maintainer:** set agreed `staking_activation_height` before merge to mainnet release branch -- [ ] **Governance:** set `mortgage_activation_height` + IOU cap after separate ≥30 day notice +### Request for maintainers -### Request for community +1. Should HIP-25 be ported to **fullnodedev**? +2. Separate fork height or post-Istanbul (765432)? +3. Parameter changes before port? -Please review staking economics (`src/mint/operate/staking.rs`), mortgage economics (`src/mint/operate/diamond_lending.rs`), RPC surface (`src/server/security.rs`, `src/server/rpc/mortgage.rs`), and wallet flow (`wallet/hip25/index.html`). Feedback welcome before fork activation heights are announced on live mainnet. +We treat this PR as **discussion + reference**, not legacy-rust mainnet merge. --- **Fork:** https://github.com/Moskyera/rust/tree/hip-25-staking -**Tag:** `v0.1.0-hip25-mainnet` \ No newline at end of file +**Reference tag:** `v0.1.0-hip25-reference` +**Spec:** https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_SPEC.md \ No newline at end of file From 6f33b065c2665ff738ae61263d5ebd17ac68fc1d Mon Sep 17 00:00:00 2001 From: Moskyera Date: Sun, 21 Jun 2026 00:40:26 +0200 Subject: [PATCH 35/35] docs: add GitHub PR body file for hacash/rust#13 --- docs/PR_BODY_GITHUB.md | 100 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/PR_BODY_GITHUB.md diff --git a/docs/PR_BODY_GITHUB.md b/docs/PR_BODY_GITHUB.md new file mode 100644 index 0000000..de745ac --- /dev/null +++ b/docs/PR_BODY_GITHUB.md @@ -0,0 +1,100 @@ +### Summary + +**Reference implementation** of HIP-25 HACD staking on legacy Hacash Rust architecture ([Moskyera/rust](https://github.com/Moskyera/rust) fork). Intended for spec validation, audits, and future port to **fullnodedev**. + +**HIP-25 (actions 34/35)** +- Global reward index pool, **v3 supply-neutral economics** +- **10% DiamondMint miner share** (`fee_got`) → staking pool when active +- Inscription fees: **100% burn** (unchanged from pre-HIP-25) +- Idle pool burn after 1008 blocks with zero stakers +- Min stake ~90d (`25714` blocks), cooldown ~3d (`864` blocks) +- 24 staking unit tests + +**HIP-2 v2.1 (actions 15/16)** — on same branch, complementary +- HAC IOU mortgage: 1% origination burn, 3% flat APR, mutual exclusion with staking +- 15 unit + 9 E2E mortgage tests + +**Shared** +- Local WASM wallet at `/hip25/wallet` (client-side signing on mainnet) +- Mainnet RPC hardening (loopback default, rate limits, no server `prikey` on `chain_id=0`) + +### Economics v3 (current) + +| Source | v2 (superseded) | **v3** | +|--------|-----------------|--------| +| Staking pool | 10% inscription protocol fees | **10% DiamondMint miner share** | +| Inscription | Partial redirect to pool | **100% burn** | +| Supply vs baseline | Higher circulating HAC | **Unchanged** (reallocation only) | + +Details: `docs/HIP25_ECONOMICS_V3.md` · Formal spec: `docs/HIP25_SPEC.md` + +### Audit status + +| Audit | HIP-25 @ `a798094` | HIP-2 | +|-------|-------------------|-------| +| Auth & secrets | PASS | PASS | +| Economics | PASS | PASS | +| API & DoS | PASS | PASS | +| Tx pipeline | PASS | PASS | +| WASM wallet | PASS | PASS | + +**48** consensus tests pass. CI: `.github/workflows/hip25-ci.yml`. + +### Mainnet safety + +- `chain_id=0` blocks server-side `prikey` signing RPCs +- `hip25_testnet_seed` / demo flags **panic** on mainnet `chain_id` +- Default `listen_host=127.0.0.1`, `allow_public_rpc=false` +- P2P tx ingress: signature + `try_execute_tx` before relay + +### How to test + +**Testnet demo:** +```cmd +scripts\START_WALLET.bat +``` + +**Mainnet wallet model:** +```powershell +scripts\BUILD_MAINNET_RELEASE.ps1 +scripts\START_MAINNET_WALLET.bat +``` + +### Breaking changes + +None until `staking_activation_height` is reached on a network running this consensus. + +### Configuration + +`hacash_mainnet_hip25.config.ini.example`: + +```ini +[mint] +chain_id = 0 +staking_activation_height = 0 +hip25_testnet_seed = false +hip25_testnet_demo_periods = false +``` + +Activation height set only after community + maintainer consensus (≥30 day notice). + +### Documentation + +- **HIP-25 spec:** `docs/HIP25_SPEC.md` +- **Roadmap:** `docs/HIP25_ROADMAP.md` +- Community review: `docs/HIP25_COMMUNITY_REVIEW.md` +- Economics v3: `docs/HIP25_ECONOMICS_V3.md` + +### Request for maintainers + +1. Should HIP-25 be ported to **fullnodedev**? +2. Separate fork height or post-Istanbul (765432)? +3. Parameter changes before port? + +We treat this PR as **discussion + reference**, not legacy-rust mainnet merge. + +--- + +**Fork:** https://github.com/Moskyera/rust/tree/hip-25-staking +**Reference tag:** `v0.1.0-hip25-reference` +**Spec:** https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_SPEC.md \ No newline at end of file