Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion mm2src/coins/eth/tron.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

mod address;
pub mod api;
pub(crate) mod proto;

/// Integration tests using real TRON testnet (Nile).
/// These tests require network access and are gated behind the `tron-network-tests` feature.
Expand All @@ -13,7 +14,7 @@ pub mod api;
mod api_integration_tests;

pub use address::Address as TronAddress;
pub use api::{TronApiClient, TronHttpClient, TronHttpNode};
pub use api::{TaposBlockData, TronApiClient, TronHttpClient, TronHttpNode};

use ethereum_types::U256;
use serde::{Deserialize, Serialize};
Expand Down
138 changes: 136 additions & 2 deletions mm2src/coins/eth/tron/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ use http::Uri;
use mm2_p2p::Keypair;
use proxy_signature::RawMessage;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value as Json;
use serde_json::{self as json};
use std::convert::TryInto;
use std::sync::Arc;
use std::time::Duration;

Expand Down Expand Up @@ -356,6 +357,15 @@ struct GetNowBlockRequest {}
/// Response from `/wallet/getnowblock`.
#[derive(Deserialize, Debug)]
pub struct GetNowBlockResponse {
/// Computed block identifier (not in protobuf — added by the HTTP servlet layer).
/// First 8 bytes duplicate the block number (big-endian) for sortability; remaining 24 bytes
/// are from SHA256 of `block_header.raw_data`. We only need bytes `[8..16]` for TAPOS
/// (`ref_block_hash`); the block number itself comes from `block_header.raw_data.number`.
/// Deserialized from a 64-char hex string; `None` if absent.
/// See [`generateBlockId`](https://github.com/tronprotocol/java-tron/blob/1e35f79/common/src/main/java/org/tron/common/utils/Sha256Hash.java#L252-L258).
#[serde(default, rename = "blockID", deserialize_with = "deserialize_opt_block_id")]
pub block_id: Option<[u8; 32]>,
/// Block header containing raw block data (number, timestamp, etc.).
#[serde(default)]
pub block_header: Option<BlockHeader>,
}
Comment on lines 352 to 365
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: since the two fields inside are optional, in what cases would they be None? and can one of them be Some while the other isn't?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In normal operation the node always returns both. blockID is derived from block_header in the java-tron code, so it's not possible to have one without the other. The servlet either returns a complete Block or {} when the block store is empty (e.g. node still syncing).

Good point though, since they always come together there's no reason for them to be Option. I'll make them non-optional and drop the #[serde(default)]. If the node returns {}, deserialization fails immediately and we catch it at the RPC call site as a bad response error. Cleaner than creating a half-valid struct and validating later.

Copy link
Collaborator Author

@shamardy shamardy Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d1508f7f19

Expand All @@ -366,11 +376,51 @@ pub struct BlockHeader {
pub raw_data: BlockRawData,
}

/// Raw block data containing the block number.
/// Raw block data from a TRON block header.
#[derive(Deserialize, Debug)]
pub struct BlockRawData {
/// Block height.
pub number: i64,
/// Block timestamp in milliseconds since epoch.
#[serde(default)]
pub timestamp: i64,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as the other comment in proto.rs: what is since epoch? since the start of the current epoch i presume?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as #2714 (comment), fixed in the same commit.

}

/// Deserialize an optional hex string into `Option<[u8; 32]>`.
///
/// Handles TRON's `blockID` field: a 64-char hex string (no `0x` prefix).
/// Returns `None` if the field is absent (via `#[serde(default)]`).
/// Returns an error if the hex is invalid or not exactly 32 bytes.
fn deserialize_opt_block_id<'de, D>(deserializer: D) -> Result<Option<[u8; 32]>, D::Error>
where
D: Deserializer<'de>,
{
let Some(hex_str) = Option::<String>::deserialize(deserializer)? else {
return Ok(None);
};
let hex_str = hex_str.strip_prefix("0x").unwrap_or(&hex_str);
let bytes = hex::decode(hex_str).map_err(serde::de::Error::custom)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|v: Vec<u8>| serde::de::Error::custom(format!("blockID must be 32 bytes, got {}", v.len())))?;
Ok(Some(arr))
}

/// Validated block data needed for TAPOS (Transaction as Proof of Stake) reference.
///
/// TRON transactions include a reference to a recent block (TAPOS) for replay protection:
/// - `ref_block_bytes`: last 2 bytes of `number` (big-endian) → `number.to_be_bytes()[6..8]`
/// - `ref_block_hash`: bytes 8..16 of `block_id` (the SHA256 portion)
///
/// TAPOS validity window is 65,536 blocks (~54 hours).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TaposBlockData {
/// Block height.
pub number: u64,
/// Full 32-byte block identifier.
pub block_id: [u8; 32],
/// Block timestamp in milliseconds since epoch.
pub timestamp: i64,
}

/// Request body for `/wallet/getaccount`.
Expand Down Expand Up @@ -490,6 +540,38 @@ impl TronHttpClient {
self.post("/wallet/getnowblock", &GetNowBlockRequest {}).await
}

/// Get validated block data for TAPOS transaction references.
///
/// Calls `/wallet/getnowblock` and validates that `blockID`, block number, and timestamp
/// are all present and sane. Returns `BadResponse` (retryable) for missing/invalid data.
pub async fn get_block_for_tapos(&self) -> Web3RpcResult<TaposBlockData> {
let response = self.get_now_block().await?;
let block_header = response.block_header.ok_or_else(|| {
Web3RpcError::BadResponse("TRON node returned getnowblock without block_header".to_string())
})?;
let block_id = response
.block_id
.ok_or_else(|| Web3RpcError::BadResponse("TRON node returned getnowblock without blockID".to_string()))?;
let number = block_header.raw_data.number;
if number < 0 {
return Err(Web3RpcError::BadResponse(format!(
"TRON node returned invalid negative block number: {number}"
))
.into());
}
let timestamp = block_header.raw_data.timestamp;
if timestamp <= 0 {
return Err(
Web3RpcError::BadResponse(format!("TRON node returned invalid block timestamp: {timestamp}")).into(),
);
}
Comment on lines +729 to +734
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not also verify the timestamp in validated_header() like what we did with the block number.

Ok(TaposBlockData {
number: number as u64,
block_id,
timestamp,
})
}

/// Get account information for a TRON address.
pub async fn get_account(&self, address: &TronAddress) -> Web3RpcResult<GetAccountResponse> {
let request = GetAccountRequest {
Expand Down Expand Up @@ -588,6 +670,12 @@ impl TronApiClient {
.into())
}

/// Get validated block data for TAPOS with node rotation.
pub async fn get_block_for_tapos(&self) -> Web3RpcResult<TaposBlockData> {
self.try_clients(|client| async move { client.get_block_for_tapos().await })
.await
}

/// Get account information with node rotation.
pub async fn get_account(&self, address: &TronAddress) -> Web3RpcResult<GetAccountResponse> {
self.try_clients(|client| {
Expand Down Expand Up @@ -772,3 +860,49 @@ impl std::fmt::Debug for TronApiClient {
f.debug_struct("TronApiClient").finish_non_exhaustive()
}
}

#[cfg(test)]
mod tests {
use super::*;

/// Verifies the custom `blockID` hex deserializer parses correctly and that the
/// block number embedded in `blockID[0..8]` matches `block_header.raw_data.number`.
/// Test data: Nile testnet block [54242114](https://nile.tronscan.org/#/block/54242114).
#[test]
fn parse_getnowblock_and_tapos_derivation() {
let json = r#"{
"blockID": "00000000033bab42567444cc8af3dbaeb5cf26b514b7e90b9a23424ea8392641",
"block_header": {
"raw_data": {
"number": 54242114,
"timestamp": 1738799040000
}
}
}"#;
let resp: GetNowBlockResponse = serde_json::from_str(json).unwrap();

let block_id = resp.block_id.expect("block_id should be Some");
let header = resp.block_header.expect("block_header should be Some");
assert_eq!(header.raw_data.number, 54_242_114);
assert_eq!(header.raw_data.timestamp, 1_738_799_040_000);

// Block number embedded in blockID[0..8] matches block_header
let number_from_id = u64::from_be_bytes(block_id[..8].try_into().unwrap());
assert_eq!(number_from_id, 54_242_114);
}

/// Non-hex `blockID` must fail deserialization (triggers `BadResponse` → node rotation).
#[test]
fn parse_getnowblock_rejects_invalid_block_id_hex() {
let json = r#"{ "blockID": "not_valid_hex!!", "block_header": { "raw_data": { "number": 1 } } }"#;
assert!(serde_json::from_str::<GetNowBlockResponse>(json).is_err());
}

/// `blockID` that isn't exactly 32 bytes must fail deserialization.
#[test]
fn parse_getnowblock_rejects_wrong_length_block_id() {
// 31 bytes (62 hex chars) — too short
let json = r#"{ "blockID": "00000000033bab42e37d025dc14e9ebc26e8f6cb6b6e26e08d2bf2db29c3b4", "block_header": { "raw_data": { "number": 1 } } }"#;
assert!(serde_json::from_str::<GetNowBlockResponse>(json).is_err());
}
Comment on lines +1193 to +1206
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's be more strict and not just check is_err(). let's check the error structure/variant.

}
28 changes: 28 additions & 0 deletions mm2src/coins/eth/tron/api_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ use ethereum_types::Address as EthAddress;
use http::Uri;
use mm2_test_helpers::for_tests::{TRON_NILE_NODES, TRON_TESTNET_KNOWN_ADDRESS};
use rand::RngCore;
use std::convert::TryInto;

#[cfg(target_arch = "wasm32")]
use wasm_bindgen_test::*;
Expand Down Expand Up @@ -224,6 +225,29 @@ async fn test_balance_native_impl() {
);
}

async fn test_get_block_for_tapos_impl() {
let client = tron_nile_api_client();
let tapos = client.get_block_for_tapos().await.unwrap();

assert!(
tapos.number > 1_000_000,
"Nile testnet should have more than 1M blocks, got {}",
tapos.number
);
assert!(
tapos.timestamp > 0,
"Block timestamp should be positive, got {}",
tapos.timestamp
);

// blockID first 8 bytes encode the block number in big-endian
let number_from_id = u64::from_be_bytes(tapos.block_id[..8].try_into().unwrap());
assert_eq!(
number_from_id, tapos.number,
"Block number in blockID should match block_header.raw_data.number"
);
}

// ============================================================================
// Cross-Platform Integration Tests
// ============================================================================
Expand All @@ -248,6 +272,10 @@ cross_test!(tron_nile_balance_native, {
test_balance_native_impl().await;
});

cross_test!(tron_nile_get_block_for_tapos, {
test_get_block_for_tapos_impl().await;
});

// ============================================================================
// Error Response Tests
// ============================================================================
Expand Down
Loading