From d8eeb0673df8090bac7edc025a898547db64dfeb Mon Sep 17 00:00:00 2001 From: zhenkas <858377+zhenkas@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:20:17 +0200 Subject: [PATCH 1/8] fix: [1/7] align created contract receipts with the VM address --- crates/tron-index/src/extract.rs | 6 +++--- crates/tron-index/tests/extract_rules.rs | 6 +++--- crates/tron-node/src/index_hook.rs | 16 +++++++++++++++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/tron-index/src/extract.rs b/crates/tron-index/src/extract.rs index b19eb17..909c5aa 100644 --- a/crates/tron-index/src/extract.rs +++ b/crates/tron-index/src/extract.rs @@ -244,15 +244,15 @@ pub fn count_block_txs_raw(mut buf: &[u8]) -> usize { count } -/// Created-contract address: `0x41 ‖ keccak256(owner ‖ tx_id)[12..]` — -/// java-tron's `Hash.sha3omit12(owner || tx_id)`, the same derivation +/// Created-contract address: `0x41 ‖ keccak256(tx_id ‖ owner)[12..]` — +/// java-tron's `Hash.sha3omit12(tx_id || owner)`, the same derivation /// `tron-tvm`'s CREATE path uses. Public so every consumer of the rule /// (the index extractor, the apply hook's TransactionInfo builder) /// shares ONE copy of this consensus-critical formula. pub fn created_contract_address(owner: &[u8], tx_id: &[u8; 32]) -> Addr { let mut input = Vec::with_capacity(owner.len() + 32); - input.extend_from_slice(owner); input.extend_from_slice(tx_id); + input.extend_from_slice(owner); let h = tron_crypto::hash::keccak256(&input); let mut a = [0u8; 21]; a[0] = 0x41; diff --git a/crates/tron-index/tests/extract_rules.rs b/crates/tron-index/tests/extract_rules.rs index 4516e4f..3a0c2b5 100644 --- a/crates/tron-index/tests/extract_rules.rs +++ b/crates/tron-index/tests/extract_rules.rs @@ -131,10 +131,10 @@ fn create_contract_indexes_deployer_and_derived_address() { ..Default::default() }; let tx = tx_with(ContractType::CreateSmartContract, c.encode_to_vec()); - // The expected created address: 0x41 ‖ keccak(owner ‖ tx_id)[12..]. + // The expected created address: 0x41 ‖ keccak(tx_id ‖ owner)[12..]. let tx_id = tron_crypto::hash::sha256(&tx.raw_data.as_ref().unwrap().encode_to_vec()); - let mut input = addr(1).to_vec(); - input.extend_from_slice(&tx_id); + let mut input = tx_id.to_vec(); + input.extend_from_slice(&addr(1)); let h = tron_crypto::hash::keccak256(&input); let mut created = [0u8; 21]; created[0] = 0x41; diff --git a/crates/tron-node/src/index_hook.rs b/crates/tron-node/src/index_hook.rs index 92644cd..314dee8 100644 --- a/crates/tron-node/src/index_hook.rs +++ b/crates/tron-node/src/index_hook.rs @@ -394,7 +394,7 @@ pub fn build_transaction_ret( /// The `contract_address` java-tron stamps on a VM tx's /// `TransactionInfo`: the called contract for a trigger, the derived -/// address (`0x41 ‖ keccak256(owner ‖ tx_id)[12..]`) for a create. +/// address (`0x41 ‖ keccak256(tx_id ‖ owner)[12..]`) for a create. /// Empty for non-VM txs. fn vm_contract_address(tx: &tron_proto::Transaction, tx_id: &[u8; 32]) -> Vec { let Some(contract) = tx.raw_data.as_ref().and_then(|r| r.contract.first()) else { @@ -662,3 +662,17 @@ mod tests { assert_eq!(got.transactioninfo[0].id, tx_id.to_vec()); } } + + +#[cfg(test)] +mod deployment_address_tests { + #[test] + fn indexed_creation_address_matches_the_vm_execution_address() { + let owner = [0x41u8; 21]; + let txid = [0xabu8; 32]; + assert_eq!( + tron_index::created_contract_address(&owner, &txid), + tron_tvm::execute::derive_top_level_contract_address(&txid, &owner) + ); + } +} From 3d6fda6db334883900fcb0cf05e53633f7698011 Mon Sep 17 00:00:00 2001 From: zhenkas <858377+zhenkas@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:22:08 +0200 Subject: [PATCH 2/8] fix: [2/7] preserve signatures in TronWeb broadcasts --- crates/tron-rpc/src/http_rest.rs | 11 +-- crates/tron-rpc/src/methods.rs | 106 ++++++++++++++++++++++++----- crates/tron-rpc/tests/http_rest.rs | 39 +++++++++++ 3 files changed, 131 insertions(+), 25 deletions(-) diff --git a/crates/tron-rpc/src/http_rest.rs b/crates/tron-rpc/src/http_rest.rs index f7d0957..b26cd33 100644 --- a/crates/tron-rpc/src/http_rest.rs +++ b/crates/tron-rpc/src/http_rest.rs @@ -1704,15 +1704,8 @@ async fn broadcast_transaction( State(state): State, Json(body): Json, ) -> impl IntoResponse { - // java-tron's HTTP form: full envelope `{txID, raw_data, raw_data_hex, - // signature, visible}`. We hand the underlying JSON-RPC handler - // either the raw_data_hex form or the envelope itself — it - // accepts both. - let inner = body - .get("raw_data_hex") - .cloned() - .or_else(|| body.get("transaction").cloned()) - .unwrap_or(body); + // Preserve raw_data_hex AND signatures from the TronWeb envelope. + let inner = body.get("transaction").cloned().unwrap_or(body); match methods::broadcast_transaction_v2(&Value::Array(vec![inner]), &state) { Ok(v) => api_ok(v), Err(e) => api_err(e), diff --git a/crates/tron-rpc/src/methods.rs b/crates/tron-rpc/src/methods.rs index 7bd4c3d..54d55ec 100644 --- a/crates/tron-rpc/src/methods.rs +++ b/crates/tron-rpc/src/methods.rs @@ -4003,6 +4003,66 @@ pub fn eth_send_raw_transaction_v2(p: &Value, s: &RpcState) -> Result Result, RpcError> { + if let Some(s) = value.as_str() { + return parse_hex_bytes(s); + } + let obj = value + .as_object() + .ok_or_else(|| RpcError::invalid_params("missing transaction"))?; + let decode = |s: &str| { + hex::decode(s.strip_prefix("0x").unwrap_or(s)) + .map_err(|e| RpcError::invalid_params(format!("bad transaction hex: {e}"))) + }; + let encoded = decode( + obj.get("raw_data_hex") + .and_then(Value::as_str) + .ok_or_else(|| { + RpcError::invalid_params("missing raw_data_hex on transaction object") + })?, + )?; + let raw = tron_proto::transaction::Raw::decode(encoded.as_slice()) + .map_err(|e| RpcError::invalid_params(format!("bad transaction raw_data: {e}")))?; + if raw.encode_to_vec() != encoded { + return Err(RpcError::invalid_params( + "non-canonical raw_data wire encoding", + )); + } + if let Some(id) = obj.get("txID").and_then(Value::as_str) { + if decode(id)? != tron_crypto::hash::sha256(&encoded) { + return Err(RpcError::invalid_params("txID does not match raw_data_hex")); + } + } + let signatures = obj + .get("signature") + .and_then(Value::as_array) + .ok_or_else(|| RpcError::invalid_params("missing transaction signatures"))?; + if signatures.is_empty() { + return Err(RpcError::invalid_params("missing transaction signatures")); + } + let signatures = signatures + .iter() + .map(|s| { + let bytes = decode( + s.as_str() + .ok_or_else(|| RpcError::invalid_params("invalid signature"))?, + )?; + if bytes.len() != 65 { + return Err(RpcError::invalid_params("signature must contain 65 bytes")); + } + Ok(bytes) + }) + .collect::, RpcError>>()?; + Ok(tron_proto::Transaction { + raw_data: Some(raw), + signature: signatures, + ..Default::default() + } + .encode_to_vec()) +} + pub fn broadcast_transaction_v2(p: &Value, s: &RpcState) -> Result { let Some(mempool) = &s.mempool else { return Ok(json!({ @@ -4011,22 +4071,7 @@ pub fn broadcast_transaction_v2(p: &Value, s: &RpcState) -> Result Ok(json!({ "result": true, @@ -8724,3 +8769,32 @@ mod eth_encoding_tests { ); } } + + +#[cfg(test)] +mod tronweb_broadcast_tests { + use super::*; + #[test] + fn signed_envelope_keeps_raw_wire_and_all_signatures() { + let raw = tron_proto::transaction::Raw { + timestamp: 123, + expiration: 456, + fee_limit: 789, + ..Default::default() + }; + let bytes = raw.encode_to_vec(); + let sig = vec![7u8; 65]; + let mut envelope = json!({"raw_data_hex":hex::encode(&bytes), + "txID":hex::encode(tron_crypto::hash::sha256(&bytes)), + "signature":[hex::encode(&sig),format!("0x{}",hex::encode(&sig))]}); + let wire = broadcast_wire(&envelope).unwrap(); + let tx = tron_proto::Transaction::decode(wire.as_slice()).unwrap(); + assert_eq!(tx.raw_data.unwrap(), raw); + assert_eq!(tx.signature, vec![sig.clone(), sig]); + envelope["txID"] = json!("00".repeat(32)); + assert!(broadcast_wire(&envelope).is_err()); + envelope.as_object_mut().unwrap().remove("txID"); + envelope["signature"] = json!([]); + assert!(broadcast_wire(&envelope).is_err()); + } +} diff --git a/crates/tron-rpc/tests/http_rest.rs b/crates/tron-rpc/tests/http_rest.rs index 78a9404..ea5113e 100644 --- a/crates/tron-rpc/tests/http_rest.rs +++ b/crates/tron-rpc/tests/http_rest.rs @@ -572,3 +572,42 @@ async fn wallet_getnowblock_concurrent_load_returns_consistent_block() { assert_eq!(h.await.unwrap(), 1); } } + +#[tokio::test] +async fn wallet_broadcast_preserves_signed_envelope_in_mempool() { + use prost::Message as _; + let (_, state, ..) = spawn_http_server().await; + let pool = tron_rpc::InMemoryMempool::new(); + let app = tron_rpc::http_rest::router(state.with_mempool(pool.clone())); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); + }); + let raw = tron_proto::transaction::Raw { + timestamp: 123, + expiration: 456, + fee_limit: 789, + ..Default::default() + }; + let bytes = raw.encode_to_vec(); + let signature = vec![7u8; 65]; + let response = http_post( + addr, + "/wallet/broadcasttransaction", + json!({ + "raw_data_hex": hex::encode(&bytes), + "txID": hex::encode(tron_crypto::hash::sha256(&bytes)), + "signature": [hex::encode(&signature), hex::encode(&signature)] + }), + ) + .await; + assert_eq!(response["result"], true, "{response}"); + let pending = pool.drain(); + assert_eq!(pending.len(), 1); + let tx = tron_proto::Transaction::decode(pending[0].as_slice()).unwrap(); + assert_eq!(tx.raw_data, Some(raw)); + assert_eq!(tx.signature, vec![signature.clone(), signature]); +} From 299db051c38601bf722078b387d90c00c25a26db Mon Sep 17 00:00:00 2001 From: zhenkas <858377+zhenkas@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:23:07 +0200 Subject: [PATCH 3/8] fix: [3/7] build TronWeb-compatible contract transactions --- crates/tron-rpc/src/builder.rs | 2 +- crates/tron-rpc/src/http_rest.rs | 35 +++++++++++++++- crates/tron-rpc/src/methods.rs | 64 +++++++++++++++--------------- crates/tron-rpc/tests/http_rest.rs | 49 +++++++++++++++++++++++ 4 files changed, 115 insertions(+), 35 deletions(-) diff --git a/crates/tron-rpc/src/builder.rs b/crates/tron-rpc/src/builder.rs index ebcbdfb..2a2ccc2 100644 --- a/crates/tron-rpc/src/builder.rs +++ b/crates/tron-rpc/src/builder.rs @@ -76,7 +76,7 @@ pub fn build_unsigned_tx( contract: vec![contract], ref_block_bytes: ref_bytes, ref_block_hash: ref_hash, - ref_block_num: head_num, + ref_block_num: 0, // Deprecated; TronWeb signs only the TAPOS bytes/hash. expiration: now_ms + DEFAULT_EXPIRATION_MS, timestamp: now_ms, fee_limit, diff --git a/crates/tron-rpc/src/http_rest.rs b/crates/tron-rpc/src/http_rest.rs index b26cd33..34de9ff 100644 --- a/crates/tron-rpc/src/http_rest.rs +++ b/crates/tron-rpc/src/http_rest.rs @@ -62,7 +62,7 @@ pub fn router_with_limits( // `params[0]` shape that `methods::*` expects. macro_rules! builder { ($name:literal, $method:path) => { - post(|state, query, body| forward_builder($method, state, query, body)) + post(|state, query, body| forward_builder($name, $method, state, query, body)) }; } // Forward a java-shaped POST body to a positional-params method: @@ -452,6 +452,7 @@ async fn rate_limit_middleware( /// address-rewritten JSON response. Handles the visible-flag base58 ↔ /// hex translation in both directions. async fn forward_builder( + name: &'static str, method: fn(&Value, &RpcState) -> Result, State(state): State, Query(query): Query>, @@ -468,8 +469,38 @@ async fn forward_builder( // under heavy sync load (see `crate::blocking`). match crate::blocking::run_blocking(|| method(¶ms, &state)) { Ok(mut v) => { + // Java omits signatures on unsigned transactions. An empty array + // is truthy in TronWeb and is rejected as "already signed". + if v.get("signature") + .and_then(Value::as_array) + .map_or(false, |s| s.is_empty()) + { + v.as_object_mut() + .expect("transaction envelope") + .remove("signature"); + } rewrite_addresses(&mut v, visible); - api_ok(v) + if name == "triggerSmartContract" { + for key in ["txID", "raw_data_hex"] { + if let Some(x) = v.get_mut(key) { + if let Some(s) = x.as_str() { + *x = json!(s.strip_prefix("0x").unwrap_or(s)); + } + } + } + if let Some(raw) = v.get_mut("raw_data") { + for key in ["ref_block_bytes", "ref_block_hash"] { + if let Some(x) = raw.get_mut(key) { + if let Some(s) = x.as_str() { + *x = json!(s.strip_prefix("0x").unwrap_or(s)); + } + } + } + } + api_ok(json!({"result":{"result":true},"txid":v["txID"],"transaction":v})) + } else { + api_ok(v) + } } Err(e) => api_err(e), } diff --git a/crates/tron-rpc/src/methods.rs b/crates/tron-rpc/src/methods.rs index 54d55ec..b342715 100644 --- a/crates/tron-rpc/src/methods.rs +++ b/crates/tron-rpc/src/methods.rs @@ -3445,6 +3445,32 @@ pub fn get_account_by_id(p: &Value, s: &RpcState) -> Result { } } +fn tron_calldata(obj: &serde_json::Map) -> Result, RpcError> { + fn decode(s: &str) -> Result, RpcError> { + hex::decode( + s.trim() + .strip_prefix("0x") + .or_else(|| s.trim().strip_prefix("0X")) + .unwrap_or(s.trim()), + ) + .map_err(|e| RpcError::invalid_params(format!("invalid calldata hex: {e}"))) + } + if let Some(sig) = obj.get("function_selector").and_then(Value::as_str) { + let mut data = keccak256(sig.trim().as_bytes())[..4].to_vec(); + if let Some(p) = obj.get("parameter").and_then(Value::as_str) { + data.extend(decode(p)?); + } + Ok(data) + } else { + decode( + obj.get("data") + .or_else(|| obj.get("input")) + .and_then(Value::as_str) + .unwrap_or(""), + ) + } +} + /// `triggerConstantContract(addr, owner_addr, data)` — TRON's name /// for `eth_call`. Accepts a flat-positional-args form for /// compatibility with tronweb-style clients. @@ -3505,33 +3531,8 @@ fn parse_constant_call_request( } }; - // Calldata: prefer `function_selector` (+ optional `parameter`), - // else fall back to raw `data`/`input` hex. `data`/`parameter` are - // decoded leniently (with or without a `0x` prefix) because java-tron's - // HTTP API sends bare hex while eth-style callers send `0x...`. - fn decode_hex_lenient(s: &str) -> Result, RpcError> { - let s = s.trim(); - let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s); - if s.is_empty() { - return Ok(Vec::new()); - } - hex::decode(s).map_err(|e| RpcError::invalid_params(format!("invalid hex: {e}"))) - } - let data = if let Some(sig) = obj.get("function_selector").and_then(|v| v.as_str()) { - let selector = keccak256(sig.trim().as_bytes()); - let mut d = selector[..4].to_vec(); - if let Some(param) = obj.get("parameter").and_then(|v| v.as_str()) { - d.extend_from_slice(&decode_hex_lenient(param)?); - } - d - } else { - match obj.get("data").or_else(|| obj.get("input")).and_then(|v| v.as_str()) { - Some(sx) => decode_hex_lenient(sx)?, - None => Vec::new(), - } - }; + let data = tron_calldata(obj)?; - // `value` may be eth-style hex or java-style `call_value` integer. let value = if let Some(s) = obj.get("value").and_then(|v| v.as_str()) { parse_hex_quantity(s)? as i64 } else if let Some(n) = obj.get("call_value").and_then(|v| v.as_i64()) { @@ -6840,11 +6841,6 @@ fn parse_bytes_field(p: &Value, field: &str) -> Result, RpcError> { parse_hex_bytes(s) } -/// Optional bytes field — returns empty vec when absent. -fn parse_bytes_field_opt(p: &Value, field: &str) -> Vec { - parse_bytes_field(p, field).unwrap_or_default() -} - /// `createTransaction({owner_address, to_address, amount, ...})` — /// TRX transfer. pub fn create_transaction(p: &Value, s: &RpcState) -> Result { @@ -6882,7 +6878,11 @@ pub fn build_trigger_smart_contract(p: &Value, s: &RpcState) -> Result Date: Fri, 30 Oct 2026 22:24:06 +0200 Subject: [PATCH 4/8] fix: [4/7] expose pending Stake V2 unfreezes in getaccount --- crates/tron-rpc/src/methods.rs | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/tron-rpc/src/methods.rs b/crates/tron-rpc/src/methods.rs index b342715..510ee1f 100644 --- a/crates/tron-rpc/src/methods.rs +++ b/crates/tron-rpc/src/methods.rs @@ -1770,6 +1770,9 @@ fn encode_account_for_rpc(a: &tron_proto::Account, genesis_ms: i64) -> Value { // presentation step (the stored list only carries actually-frozen // resources). Emit it the same way so the field matches byte-for-byte. m.insert("frozenV2".into(), json!(normalized_frozen_v2_json(a))); + if !a.unfrozen_v2.is_empty() { + m.insert("unfrozenV2".into(), json!(a.unfrozen_v2.iter().map(unfreeze_v2_json).collect::>())); + } if !a.frozen_supply.is_empty() { m.insert("frozen_supply".into(), json!(a.frozen_supply.iter().map(frozen_json).collect::>())); } @@ -1850,6 +1853,20 @@ fn frozen_json(f: &tron_proto::account::Frozen) -> Value { Value::Object(m) } +fn unfreeze_v2_json(f: &tron_proto::account::UnFreezeV2) -> Value { + let mut m = serde_json::Map::new(); + if f.r#type != 0 { + m.insert("type".into(), json!(resource_code_name(f.r#type))); + } + if f.unfreeze_amount != 0 { + m.insert("unfreeze_amount".into(), json!(f.unfreeze_amount)); + } + if f.unfreeze_expire_time != 0 { + m.insert("unfreeze_expire_time".into(), json!(f.unfreeze_expire_time)); + } + Value::Object(m) +} + fn freeze_v2_json(f: &tron_proto::account::FreezeV2) -> Value { let mut m = serde_json::Map::new(); if f.r#type != 0 { @@ -8115,6 +8132,32 @@ mod account_encoding_tests { use super::*; use tron_proto::account::{AccountResource, FreezeV2}; + #[test] + fn pending_v2_unfreezes_preserve_resource_amount_and_deadline() { + use tron_proto::account::UnFreezeV2; + let mut a = tron_proto::Account::default(); + assert!(encode_account_for_rpc(&a, 0).get("unfrozenV2").is_none()); + a.unfrozen_v2 = vec![ + UnFreezeV2 { + r#type: 0, + unfreeze_amount: 123, + unfreeze_expire_time: 1_800_000_000_000, + }, + UnFreezeV2 { + r#type: 1, + unfreeze_amount: 456, + unfreeze_expire_time: 1_800_000_030_000, + }, + ]; + assert_eq!( + encode_account_for_rpc(&a, 0)["unfrozenV2"], + json!([ + {"unfreeze_amount": 123, "unfreeze_expire_time": 1_800_000_000_000_i64}, + {"type": "ENERGY", "unfreeze_amount": 456, "unfreeze_expire_time": 1_800_000_030_000_i64} + ]) + ); + } + #[test] fn account_json_matches_java_tron_shape() { let a = tron_proto::Account { From 3fe6d11fbf07080bb6dcbd9228357c15e188178e Mon Sep 17 00:00:00 2001 From: zhenkas <858377+zhenkas@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:27:28 +0200 Subject: [PATCH 5/8] fix: [5/7] snapshot legacy voting power before the first V2 stake --- crates/tron-actuator/src/freeze_v2.rs | 3 + .../tests/legacy_voting_power.rs | 66 +++++++++++++++++++ crates/tron-tvm/src/tron_host.rs | 34 ++++++++++ crates/tron-tvm/src/votes.rs | 43 +++++++++++- 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 crates/tron-actuator/tests/legacy_voting_power.rs diff --git a/crates/tron-actuator/src/freeze_v2.rs b/crates/tron-actuator/src/freeze_v2.rs index 926f472..cb8b775 100644 --- a/crates/tron-actuator/src/freeze_v2.rs +++ b/crates/tron-actuator/src/freeze_v2.rs @@ -85,6 +85,9 @@ pub fn execute_freeze_balance_v2( let mut account = accounts .get(&owner)? .ok_or(ActuatorError::OwnerAccountMissing)?; + if dyn_props.get_long(b"ALLOW_NEW_RESOURCE_MODEL").unwrap_or(0) == 1 { + tron_tvm::votes::initialize_old_tron_power(&mut account); + } account.balance = check_sub(account.balance, contract.frozen_balance)?; // Capture the old weight basis BEFORE mutating frozen_v2 — java-tron's diff --git a/crates/tron-actuator/tests/legacy_voting_power.rs b/crates/tron-actuator/tests/legacy_voting_power.rs new file mode 100644 index 0000000..372d6d0 --- /dev/null +++ b/crates/tron-actuator/tests/legacy_voting_power.rs @@ -0,0 +1,66 @@ +use std::sync::Arc; +use tron_actuator::freeze_v2::{execute_freeze_balance_v2, validate_freeze_balance_v2}; +use tron_chainbase::{AccountStore, DynamicPropertiesStore, MemBackend}; +use tron_crypto::address::Address; +use tron_proto::account::FreezeV2; +use tron_proto::{Account, FreezeBalanceV2Contract}; + +#[test] +fn native_first_v2_stake_preserves_only_existing_legacy_power() { + for (new_model, legacy, expected_old) in [(1, 0, -1), (1, 7_000_000, 7_000_000), (0, 0, 0)] { + let accounts = AccountStore::new(Arc::new(MemBackend::new())); + let dp = DynamicPropertiesStore::new(Arc::new(MemBackend::new())); + dp.put_long(b"ALLOW_NEW_RESOURCE_MODEL", new_model); + dp.put_long(b"UNFREEZE_DELAY_DAYS", 1); + let mut bytes = [0x44; 21]; + bytes[0] = 0x41; + let owner = Address::from_raw(bytes); + accounts + .put( + &owner, + &Account { + address: bytes.to_vec(), + balance: 1_000_000_000, + frozen_v2: if legacy == 0 { + vec![] + } else { + vec![FreezeV2 { + r#type: 1, + amount: legacy, + }] + }, + ..Default::default() + }, + ) + .unwrap(); + for _ in 0..2 { + let stake = FreezeBalanceV2Contract { + owner_address: bytes.to_vec(), + frozen_balance: 2_000_000, + resource: 1, + }; + validate_freeze_balance_v2(&accounts, &dp, &stake).unwrap(); + execute_freeze_balance_v2(&accounts, &dp, &stake).unwrap(); + let account = accounts.get(&owner).unwrap().unwrap(); + assert_eq!(account.old_tron_power, expected_old); + if new_model == 1 { + assert_eq!(tron_tvm::votes::all_tron_power(&account), legacy); + } + } + if new_model == 1 { + let stake = FreezeBalanceV2Contract { + owner_address: bytes.to_vec(), + frozen_balance: 500_000_000, + resource: 2, + }; + validate_freeze_balance_v2(&accounts, &dp, &stake).unwrap(); + execute_freeze_balance_v2(&accounts, &dp, &stake).unwrap(); + let account = accounts.get(&owner).unwrap().unwrap(); + assert_eq!(account.old_tron_power, expected_old); + assert_eq!( + tron_tvm::votes::all_tron_power(&account), + legacy + 500_000_000 + ); + } + } +} diff --git a/crates/tron-tvm/src/tron_host.rs b/crates/tron-tvm/src/tron_host.rs index 71966ed..e71099c 100644 --- a/crates/tron-tvm/src/tron_host.rs +++ b/crates/tron-tvm/src/tron_host.rs @@ -1678,6 +1678,9 @@ impl TronDatabaseExt for TronDatabase { if owner_selfdestructed { return TRON_HOST_OUT_OF_TIME; } + if dyn_props.get_long(b"ALLOW_NEW_RESOURCE_MODEL").unwrap_or(0) == 1 { + crate::votes::initialize_old_tron_power(&mut account); + } account.balance = owner_balance - frozen_balance; let resource = resource_type as i32; // Weight is floored from `getFrozenV2BalanceWithDelegated` (held + @@ -3070,6 +3073,37 @@ mod tests { /// for resource TRON_POWER (2) — the old code's `_ => {}` arm dropped it. /// TRON_POWER is only a legal resource code while the new resource model is /// active (`FreezeBalanceV2Processor.validate`), so the fixture enables it. + #[test] + fn new_model_energy_stake_does_not_inflate_later_power_stake() { + let (mut db, dyn_props) = make_staking_db(); + dyn_props.put_long(b"ALLOW_NEW_RESOURCE_MODEL", 1); + let owner = TronAddress::from_raw(tron_addr(0x58)); + db.accounts + .put( + &owner, + &Account { + address: owner.as_bytes().to_vec(), + balance: 1_000_000_000, + ..Default::default() + }, + ) + .unwrap(); + let caller = tron_to_evm_address(&owner); + assert_eq!( + db.tron_freeze_balance_v2(caller, 100_000_000, 1, 1_000_000_000, false), + 1 + ); + let account = db.accounts.get(&owner).unwrap().unwrap(); + assert_eq!(account.old_tron_power, -1); + assert_eq!(crate::votes::all_tron_power(&account), 0); + assert_eq!( + db.tron_freeze_balance_v2(caller, 500_000_000, 2, 900_000_000, false), + 1 + ); + let account = db.accounts.get(&owner).unwrap().unwrap(); + assert_eq!(crate::votes::all_tron_power(&account), 500_000_000); + } + #[test] fn tvm_freeze_v2_updates_tron_power_weight() { let (db, dyn_props) = make_staking_db(); diff --git a/crates/tron-tvm/src/votes.rs b/crates/tron-tvm/src/votes.rs index 0f729d4..0467246 100644 --- a/crates/tron-tvm/src/votes.rs +++ b/crates/tron-tvm/src/votes.rs @@ -61,11 +61,21 @@ pub fn tron_power(account: &Account) -> i64 { tp } +/// Snapshot legacy voting power before the first stake under the new model. +/// Java initializes the zero sentinel once; -1 means there was no legacy power. +/// Must be called before adding the new frozen amount. +pub fn initialize_old_tron_power(account: &mut Account) { + if account.old_tron_power == 0 { + let legacy = tron_power(account); + account.old_tron_power = if legacy == 0 { -1 } else { legacy }; + } +} + /// java-tron's `AccountCapsule.getAllTronPower` (in sun) — the /// NEW-resource-model power source. The `old_tron_power` field selects how /// legacy power folds in: /// -/// * `-1` → not yet initialized: V1 + V2 TRON_POWER-typed frozen only; +/// * `-1` → no retained legacy power: V1 + V2 TRON_POWER-typed frozen only; /// * `0` → legacy `getTronPower()` (every other frozen source) plus the /// two TRON_POWER components; /// * `>0` → stored old power plus the two TRON_POWER components. @@ -166,6 +176,37 @@ pub fn update_vote_after_unstake( #[cfg(test)] mod tests { use super::*; + #[test] + fn first_new_model_stake_snapshots_legacy_power_once() { + let mut fresh = Account::default(); + initialize_old_tron_power(&mut fresh); + assert_eq!(fresh.old_tron_power, -1); + fresh.frozen_v2.push(FreezeV2 { + r#type: 1, + amount: 100_000_000, + }); + initialize_old_tron_power(&mut fresh); + assert_eq!(fresh.old_tron_power, -1); + assert_eq!(all_tron_power(&fresh), 0); + fresh.frozen_v2.push(FreezeV2 { + r#type: 2, + amount: 500_000_000_000, + }); + assert_eq!(all_tron_power(&fresh), 500_000_000_000); + let mut migrated = Account { + frozen_v2: vec![FreezeV2 { + r#type: 1, + amount: 7_000_000, + }], + ..Default::default() + }; + initialize_old_tron_power(&mut migrated); + assert_eq!(migrated.old_tron_power, 7_000_000); + migrated.frozen_v2[0].amount += 2_000_000; + initialize_old_tron_power(&mut migrated); + assert_eq!(all_tron_power(&migrated), 7_000_000); + } + use std::sync::Arc; use tron_chainbase::{KvBackend, MemBackend}; use tron_proto::account::{FreezeV2, Frozen}; From 7e71a8c948d8a2c5b4d2f07e8b8d97f04bea0b28 Mon Sep 17 00:00:00 2001 From: zhenkas <858377+zhenkas@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:28:52 +0200 Subject: [PATCH 6/8] fix: [6/7] receive inbound transactions with paced inventory fetches --- crates/tron-node/src/inbound.rs | 142 +++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 2 deletions(-) diff --git a/crates/tron-node/src/inbound.rs b/crates/tron-node/src/inbound.rs index c7eec4b..a13a939 100644 --- a/crates/tron-node/src/inbound.rs +++ b/crates/tron-node/src/inbound.rs @@ -308,7 +308,42 @@ where // always wakes to tick keepalive even when the peer is quiet. let mut last_inbound = Instant::now(); let mut last_ping = Instant::now(); + // FETCH_INV_DATA is limited to 3 requests/s by ordinary peers. Queue + // bursts of advertisements and pace requests instead of losing a request + // to the peer's silent rate-limit drop. + const TX_FETCH_INTERVAL: Duration = Duration::from_millis(400); + const TX_FETCH_QUEUE_CAP: usize = 1000; + let mut tx_fetch_queue = std::collections::VecDeque::<[u8; 32]>::new(); + let mut tx_fetch_queued = std::collections::HashSet::<[u8; 32]>::new(); + let mut last_tx_fetch: Option = None; loop { + + if !tx_fetch_queue.is_empty() + && last_tx_fetch.map_or(true, |t| t.elapsed() >= TX_FETCH_INTERVAL) + { + let mut ids = Vec::new(); + while ids.len() < 100 { + let Some(id) = tx_fetch_queue.pop_front() else { break; }; + tx_fetch_queued.remove(&id); + if server.mempool.as_ref().map_or(false, |mp| mp.get(&id).is_some()) { + continue; + } + ids.push(id.to_vec()); + } + if !ids.is_empty() { + let count = ids.len(); + conn.send_frame(Frame { + ty: MessageType::FetchInvData, + payload: Bytes::from(tron_proto::Inventory { + r#type: tron_proto::inventory::InventoryType::Trx as i32, + ids, + }.encode_to_vec()), + }).await.map_err(|e| format!("request inbound tx bodies: {e}"))?; + last_tx_fetch = Some(Instant::now()); + debug!(count, "requested inbound transaction bodies"); + } + } + if last_inbound.elapsed() > KEEPALIVE_INBOUND_DEADLINE { debug!(%peer_addr, "inbound peer silent past keepalive deadline; dropping"); return Ok(()); @@ -324,7 +359,13 @@ where last_ping = Instant::now(); } - let frame = match tokio::time::timeout(KEEPALIVE_INTERVAL, conn.next_frame()).await { + let mut read_timeout = KEEPALIVE_INTERVAL; + if !tx_fetch_queue.is_empty() { + let remaining = last_tx_fetch.map_or(Duration::ZERO, + |t| TX_FETCH_INTERVAL.saturating_sub(t.elapsed())); + read_timeout = read_timeout.min(remaining); + } + let frame = match tokio::time::timeout(read_timeout, conn.next_frame()).await { Ok(Ok(Some(f))) => f, Ok(Ok(None)) => { debug!(%peer_addr, "inbound peer closed connection"); @@ -384,6 +425,46 @@ where server.note_served(); debug!(%peer_addr, served = ids.len(), remain, "served SyncBlockChain"); } + MessageType::Inventory => { + // An inbound peer can submit transactions even if it only + // downloads blocks from us. A producer may have no other + // outbound connection to that peer. + if let (Some(mp), Ok(inv)) = ( + server.mempool.as_ref(), + tron_proto::Inventory::decode(frame.payload.as_ref()), + ) { + if inv.r#type == tron_proto::inventory::InventoryType::Trx as i32 { + for raw in inv.ids { + let Ok(id) = <[u8; 32]>::try_from(raw.as_slice()) else { + continue; + }; + if tx_fetch_queue.len() >= TX_FETCH_QUEUE_CAP { + break; + } + if mp.get(&id).is_none() && tx_fetch_queued.insert(id) { + tx_fetch_queue.push_back(id); + } + } + } + } + } + MessageType::Trx => { + if let Some(mp) = server.mempool.as_ref() { + let outcome = mp.submit(&frame.payload); + debug!(?outcome, "inbound transaction admission"); + } + } + MessageType::Trxs => { + if let (Some(mp), Ok(batch)) = ( + server.mempool.as_ref(), + tron_proto::Transactions::decode(frame.payload.as_ref()), + ) { + for tx in batch.transactions.into_iter().take(100) { + let outcome = mp.submit(&tx.encode_to_vec()); + debug!(?outcome, "inbound transaction admission"); + } + } + } MessageType::FetchInvData => { // The peer asks for the actual block (or tx) bodies. Served from // the BlockStore / mempool, shared with the outbound path. @@ -402,7 +483,7 @@ where debug!(%peer_addr, "inbound peer disconnected"); return Ok(()); } - // Inventory / Block / Trx announcements etc. from a peer we serve are + // Block announcements from a peer we serve are // not needed for the sync-from-us role — our outbound dialers handle // pulling new data. Ignore them (don't drop the peer). other => { @@ -644,4 +725,61 @@ mod tests { drop(conn); let _ = server_task.await; } + #[tokio::test] + async fn inbound_transaction_fetches_batch_bursts_under_peer_rate_limit() { + let mut server = Arc::try_unwrap(test_server()).ok().unwrap(); + server.mempool = Some(TxMempool::new_arc(Default::default())); + let (client_io, server_io) = tokio::io::duplex(1 << 16); + let task = + tokio::spawn(async move { serve_inbound_peer(&server, server_io, "tx-burst-peer").await }); + let mut conn = PeerConnection::new(client_io); + drive_client(&mut conn).await; + let limiter = crate::p2p_rate_limiter::P2pRateLimiter::new(); + limiter.register(MessageType::FetchInvData.as_byte(), 3.0); + let advertise = |ids| Frame { + ty: MessageType::Inventory, + payload: Bytes::from( + tron_proto::Inventory { + r#type: tron_proto::inventory::InventoryType::Trx as i32, + ids, + } + .encode_to_vec(), + ), + }; + conn.send_frame(advertise(vec![vec![1; 32]])).await.unwrap(); + let first = tokio::time::timeout(Duration::from_secs(2), conn.next_frame()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(first.ty, MessageType::FetchInvData); + assert!(limiter.try_acquire(first.ty.as_byte())); + assert_eq!( + tron_proto::Inventory::decode(first.payload).unwrap().ids, + vec![vec![1; 32]] + ); + // Distinct advertisements arrive together after the first permit was + // consumed. The next request must wait, batch, and deduplicate them. + conn.send_frame(advertise(vec![vec![2; 32]])).await.unwrap(); + conn.send_frame(advertise(vec![vec![2; 32], vec![3; 32]])) + .await + .unwrap(); + let second = tokio::time::timeout(Duration::from_secs(2), conn.next_frame()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(second.ty, MessageType::FetchInvData); + assert!( + limiter.try_acquire(second.ty.as_byte()), + "peer must accept the second fetch" + ); + assert_eq!( + tron_proto::Inventory::decode(second.payload).unwrap().ids, + vec![vec![2; 32], vec![3; 32]] + ); + drop(conn); + task.await.unwrap().unwrap(); + } + } From f7bafb8a6256f923d77acfc89aea64fd524c024f Mon Sep 17 00:00:00 2001 From: zhenkas <858377+zhenkas@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:30:14 +0200 Subject: [PATCH 7/8] fix: [7/7] use committed receipts for event outcomes and gaps --- crates/tron-index/src/extract.rs | 51 ++++++++++++++++++++++++++++---- crates/tron-index/src/lib.rs | 2 +- crates/tron-node/src/firehose.rs | 38 ++++++++++++++++++------ 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/crates/tron-index/src/extract.rs b/crates/tron-index/src/extract.rs index 909c5aa..4b42acc 100644 --- a/crates/tron-index/src/extract.rs +++ b/crates/tron-index/src/extract.rs @@ -522,6 +522,23 @@ impl<'a> TxInfoMatcher<'a> { } } +/// The block's contractRet is the advertised execution result. Locally +/// produced blocks can omit it; then use the committed execution receipt, +/// rather than labelling every such transaction as failed. +pub fn transaction_succeeded(tx: &Transaction, info: Option<&tron_proto::TransactionInfo>) -> bool { + use tron_proto::transaction::result::ContractResult; + if let Some(result) = tx.ret.first() { + return result.contract_ret == ContractResult::Success as i32; + } + info.map_or(false, |i| { + i.result == tron_proto::transaction_info::Code::Sucess as i32 + && i.receipt.as_ref().map_or(true, |r| { + r.result == ContractResult::Default as i32 + || r.result == ContractResult::Success as i32 + }) + }) +} + /// Extract every index entry one block contributes. /// /// `txinfo` is the block's stored `TransactionRet` (absent ⇒ the @@ -562,11 +579,7 @@ pub fn extract_block( let tx_id = matcher .positional_id(txidx as usize) .unwrap_or_else(|| tron_crypto::hash::sha256(&raw.encode_to_vec())); - let success = tx - .ret - .first() - .map(|r| r.contract_ret == tron_proto::transaction::result::ContractResult::Success as i32) - .unwrap_or(false); + let success = transaction_succeeded(tx, matcher.for_tx(&tx_id, txidx as usize)); // ---- idx_native: from the tx params alone -------------------- if caps.native { @@ -758,6 +771,34 @@ pub fn extract_block( #[cfg(test)] mod tests { use super::*; + #[test] + fn missing_block_ret_uses_committed_execution_outcome() { + use tron_proto::transaction::result::ContractResult; + let mut tx = Transaction::default(); + let mut info = tron_proto::TransactionInfo::default(); + assert!(!transaction_succeeded(&tx, None)); + assert!(transaction_succeeded(&tx, Some(&info))); + info.receipt = Some(tron_proto::ResourceReceipt { + result: ContractResult::Success as i32, + ..Default::default() + }); + assert!(transaction_succeeded(&tx, Some(&info))); + info.receipt.as_mut().unwrap().result = ContractResult::Revert as i32; + assert!(!transaction_succeeded(&tx, Some(&info))); + info.receipt = None; + info.result = tron_proto::transaction_info::Code::Failed as i32; + assert!(!transaction_succeeded(&tx, Some(&info))); + info.result = tron_proto::transaction_info::Code::Sucess as i32; + tx.ret.push(tron_proto::transaction::Result { + contract_ret: ContractResult::Revert as i32, + ..Default::default() + }); + assert!( + !transaction_succeeded(&tx, Some(&info)), + "explicit block result takes precedence" + ); + } + fn caps_internal() -> CaptureSet { CaptureSet { diff --git a/crates/tron-index/src/lib.rs b/crates/tron-index/src/lib.rs index 1e6178d..37bfc2f 100644 --- a/crates/tron-index/src/lib.rs +++ b/crates/tron-index/src/lib.rs @@ -49,7 +49,7 @@ pub use commitment::{ pub use db::{IndexDb, IndexError, InitOutcome, FORMAT_VERSION}; pub use engine::{EngineOptions, IndexCounters, IndexEngine, IndexStatus, Tick}; pub use extract::{ - created_contract_address, extract_block, tx_facts, CaptureSet, TxFacts, TxInfoMatcher, + created_contract_address, extract_block, transaction_succeeded, tx_facts, CaptureSet, TxFacts, TxInfoMatcher, TRANSFER_TOPIC, }; pub use firehose_log::{ diff --git a/crates/tron-node/src/firehose.rs b/crates/tron-node/src/firehose.rs index e0b2dfe..ff82c44 100644 --- a/crates/tron-node/src/firehose.rs +++ b/crates/tron-node/src/firehose.rs @@ -400,6 +400,8 @@ pub fn build_apply( // identical blocks. let matcher = tron_index::TxInfoMatcher::new(ret); + let mut txinfo_missing = ret.is_none() && !block.transactions.is_empty(); + let txs = block .transactions .iter() @@ -414,14 +416,8 @@ pub fn build_apply( .unwrap_or_else(|| tron_crypto::hash::sha256(&raw_tx.encode_to_vec())); let facts = tron_index::tx_facts(tx, &tx_id); let info = matcher.for_tx(&tx_id, i); - let success = tx - .ret - .first() - .map(|r| { - r.contract_ret - == tron_proto::transaction::result::ContractResult::Success as i32 - }) - .unwrap_or(false); + txinfo_missing |= info.is_none(); + let success = tron_index::transaction_succeeded(tx, info); Some(fh::Tx { txid: tx_id.to_vec(), contract_type: facts.as_ref().map(|f| f.contract_type).unwrap_or(0), @@ -484,7 +480,7 @@ pub fn build_apply( timestamp_ms: raw.map(|r| r.timestamp).unwrap_or(0), witness: raw.map(|r| r.witness_address.clone()).unwrap_or_default(), solidified_height: solidified, - txinfo_missing: ret.is_none() && !block.transactions.is_empty(), + txinfo_missing, txs, } } @@ -494,6 +490,30 @@ mod tests { use super::*; use tron_chainbase::MemBackend; + #[test] + fn receipt_status_without_block_ret_and_partial_receipt_gap() { + let rig = Rig::new("receipt-status"); + let (mut block, _, mut ret) = rig.make_block(1, 0); + block.transactions[0].ret.clear(); + ret.transactioninfo[0].receipt = Some(tron_proto::ResourceReceipt { + result: tron_proto::transaction::result::ContractResult::Success as i32, + ..Default::default() + }); + let event = build_apply(1, &block, Some(&ret), 1); + assert!(event.txs[0].success); + assert!(!event.txinfo_missing); + ret.transactioninfo[0].receipt.as_mut().unwrap().result = + tron_proto::transaction::result::ContractResult::Revert as i32; + assert!(!build_apply(1, &block, Some(&ret), 1).txs[0].success); + // A present TransactionRet container is insufficient: every tx needs + // its own info or the consumer must stop rather than miss events. + let mut second = block.transactions[0].clone(); + second.raw_data.as_mut().unwrap().data.push(99); + block.transactions.push(second); + assert!(build_apply(1, &block, Some(&ret), 1).txinfo_missing); + std::fs::remove_dir_all(rig.dir).unwrap(); + } + fn mem() -> Arc { Arc::new(MemBackend::new()) } From 513faa294b684463e7d5939f2ae343f1aac2794b Mon Sep 17 00:00:00 2001 From: zhenkas <858377+zhenkas@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:25:31 +0200 Subject: [PATCH 8/8] bonus: rename CHANGED_DELEGATION to CHANGE_DELEGATION at genesis --- crates/tron-node/src/runtime.rs | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/tron-node/src/runtime.rs b/crates/tron-node/src/runtime.rs index a973cb5..6ea97e1 100644 --- a/crates/tron-node/src/runtime.rs +++ b/crates/tron-node/src/runtime.rs @@ -266,7 +266,10 @@ pub async fn run(config: NodeConfig, shutdown: ShutdownSignal) -> Result<(), Run // `Manager.initGenesisData` calling each `save*` setter from // the committee config. match config.resolve_committee() { - Ok(committee) => seed_committee_initial_values(&stores, &committee), + Ok(committee) => seed_committee_initial_values( + &tron_chainbase::DynamicPropertiesStore::new(stores.dyn_props.clone()), + &committee, + ), Err(e) => warn!(error = %e, "committee.* validation failed; skipping bootstrap"), } } @@ -3346,10 +3349,9 @@ fn initialize_genesis(stores: &OpenedStores) -> Result<(), crate::storage::Stora /// `save*` setter unconditionally; on-chain proposal records can then /// override values later via the normal maintenance flow. fn seed_committee_initial_values( - stores: &crate::storage::OpenedStores, + dp: &tron_chainbase::DynamicPropertiesStore, cfg: &crate::config::CommitteeConfig, ) { - let dp = tron_chainbase::DynamicPropertiesStore::new(stores.dyn_props.clone()); let write = |key: &[u8], v: i64| dp.put_long(key, v); write(b"ALLOW_CREATION_OF_CONTRACTS", cfg.allow_creation_of_contracts); @@ -3375,7 +3377,7 @@ fn seed_committee_initial_values( write(b"ALLOW_TVM_ISTANBUL", cfg.allow_tvm_istanbul); write(b"ALLOW_PROTO_FILTER_NUM", cfg.allow_proto_filter_num); write(b"ALLOW_ACCOUNT_STATE_ROOT", cfg.allow_account_state_root); - write(b"CHANGED_DELEGATION", cfg.changed_delegation); + write(b"CHANGE_DELEGATION", cfg.changed_delegation); write(b"ALLOW_PBFT", cfg.allow_pbft); write(b"PBFT_EXPIRE_NUM", cfg.pbft_expire_num); write(b"ALLOW_TVM_FREEZE", cfg.allow_tvm_freeze); @@ -3626,3 +3628,25 @@ mod shutdown_signal_tests { assert!(waited.is_ok(), "guarded wait must not block when shutdown already fired"); } } + + +#[cfg(test)] +mod committee_seeding_tests { + use super::*; + use std::sync::Arc; + use tron_chainbase::{DynamicPropertiesStore, MemBackend}; + + #[test] + fn change_delegation_config_reaches_the_canonical_property() { + let dp = DynamicPropertiesStore::new(Arc::new(MemBackend::new())); + for configured in [1, 0] { + let cfg = crate::config::CommitteeConfig { + changed_delegation: configured, + ..Default::default() + }; + seed_committee_initial_values(&dp, &cfg); + assert_eq!(dp.get_long(b"CHANGE_DELEGATION"), Some(configured)); + assert_eq!(dp.get_long(b"CHANGED_DELEGATION"), None); + } + } +}