diff --git a/src/Cargo.lock b/src/Cargo.lock index 85c99e3b..3bea901f 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -14044,6 +14044,7 @@ name = "visualsign" version = "0.1.0" dependencies = [ "base64 0.22.1", + "chrono", "generated", "hex", "pretty_assertions", diff --git a/src/chain_parsers/visualsign-tron/src/lib.rs b/src/chain_parsers/visualsign-tron/src/lib.rs index 890528d7..a40a075a 100644 --- a/src/chain_parsers/visualsign-tron/src/lib.rs +++ b/src/chain_parsers/visualsign-tron/src/lib.rs @@ -4,11 +4,14 @@ use anychain_tron::protocol::balance_contract::{ UnDelegateResourceContract, UnfreezeBalanceV2Contract, WithdrawExpireUnfreezeContract, }; use anychain_tron::protocol::common::ResourceCode; +use anychain_tron::protocol::witness_contract::VoteWitnessContract; use base64::{Engine as _, engine::general_purpose::STANDARD as b64}; use protobuf::Message; use visualsign::field_builders::{create_address_field, create_amount_field, create_text_field}; +use visualsign::time_fmt::{format_relative_ms, format_timestamp_ms}; use visualsign::{ - AnnotatedPayloadField, SignablePayload, + AnnotatedPayloadField, SignablePayload, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, encodings::SupportedEncodings, vsptrait::{ Transaction, TransactionParseError, VisualSignConverter, VisualSignConverterFromString, @@ -135,22 +138,14 @@ fn convert_to_visual_sign_payload( fields.push(create_text_field("Network", "Tron")?); + let now_ms = chrono::Utc::now().timestamp_millis(); fields.push(create_text_field( "Timestamp", - &format!( - "{} ({} ms)", - format_timestamp(raw_data.timestamp), - raw_data.timestamp - ), + &render_time_field(raw_data.timestamp, now_ms), )?); - fields.push(create_text_field( "Expiration", - &format!( - "{} ({} ms)", - format_timestamp(raw_data.expiration), - raw_data.expiration - ), + &render_time_field(raw_data.expiration, now_ms), )?); fields.push(create_amount_field( @@ -382,6 +377,76 @@ fn decode_contract( "TRX", )?); } + "type.googleapis.com/protocol.VoteWitnessContract" => { + let vote = VoteWitnessContract::parse_from_bytes(value).map_err(|e| { + VisualSignError::ConversionError(format!("decode VoteWitnessContract: {e}")) + })?; + fields.push(create_text_field("Contract Type", "Vote Witness")?); + fields.push(create_address_field( + "Owner", + &address_to_base58(&vote.owner_address), + None, + None, + None, + None, + )?); + + let mut detail_fields: Vec = Vec::new(); + for (i, v) in vote.votes.iter().enumerate() { + let n = i + 1; + detail_fields.push(create_address_field( + &format!("Vote {n} (SR)"), + &address_to_base58(&v.vote_address), + None, + None, + None, + None, + )?); + detail_fields.push(create_text_field( + &format!("Vote {n} (Count)"), + &v.vote_count.to_string(), + )?); + } + + // i64 sum may overflow only for adversarial inputs; saturating keeps the + // summary readable rather than panicking. Per-vote counts are still shown + // verbatim in the expanded list. + let total: i64 = vote + .votes + .iter() + .map(|v| v.vote_count) + .fold(0i64, i64::saturating_add); + let subtitle = format!("{} votes across {} SRs", total, vote.votes.len()); + let fallback = format!("Vote Witness: {subtitle}"); + + // Condensed view targets hardware-wallet screens with limited room: + // only the totals plus the owner are echoed. Expanded carries the full + // per-vote breakdown so signers can audit every SR before signing. + let condensed_fields = vec![create_text_field("Summary", &subtitle)?]; + + fields.push(AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: fallback.clone(), + label: "Votes".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Vote Witness".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: subtitle }), + condensed: Some(SignablePayloadFieldListLayout { + fields: condensed_fields, + }), + expanded: Some(SignablePayloadFieldListLayout { + fields: detail_fields, + }), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }); + } other => { fields.push(create_text_field( "Contract Type", @@ -459,12 +524,15 @@ fn sun_to_trx_string(sun: i64) -> String { } } -// Returns only the human date; callers append the raw millis themselves to avoid a -// doubled "(N ms)" suffix when the timestamp is out of chrono's representable range. -fn format_timestamp(timestamp_ms: i64) -> String { - chrono::DateTime::from_timestamp_millis(timestamp_ms) - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) - .unwrap_or_else(|| "invalid timestamp".to_string()) +// Renders " ( ms[, ])" — the relative tag is omitted when +// the timestamp is outside chrono's representable range so signers still see +// the raw bytes without a misleading "N years ago". +fn render_time_field(ms: i64, now_ms: i64) -> String { + let abs = format_timestamp_ms(ms); + match format_relative_ms(ms, now_ms) { + Some(rel) => format!("{abs} ({ms} ms, {rel})"), + None => format!("{abs} ({ms} ms)"), + } } #[cfg(test)] @@ -895,11 +963,167 @@ mod tests { ); } + fn build_vote_witness_bytes(owner: &[u8], votes: &[(&[u8], i64)]) -> Vec { + use anychain_tron::protocol::witness_contract::vote_witness_contract::Vote; + let mut contract = VoteWitnessContract { + owner_address: owner.to_vec(), + ..Default::default() + }; + for (addr, count) in votes { + contract.votes.push(Vote { + vote_address: addr.to_vec(), + vote_count: *count, + ..Default::default() + }); + } + contract.write_to_bytes().unwrap() + } + + fn preview_layout_subtitle(field: &SignablePayloadField) -> &str { + match field { + SignablePayloadField::PreviewLayout { preview_layout, .. } => preview_layout + .subtitle + .as_ref() + .map(|t| t.text.as_str()) + .unwrap_or(""), + _ => panic!("expected PreviewLayout"), + } + } + + fn preview_layout_expanded(field: &SignablePayloadField) -> &SignablePayloadFieldListLayout { + match field { + SignablePayloadField::PreviewLayout { preview_layout, .. } => preview_layout + .expanded + .as_ref() + .expect("expanded must be Some"), + _ => panic!("expected PreviewLayout"), + } + } + + fn preview_layout_condensed(field: &SignablePayloadField) -> &SignablePayloadFieldListLayout { + match field { + SignablePayloadField::PreviewLayout { preview_layout, .. } => preview_layout + .condensed + .as_ref() + .expect("condensed must be Some"), + _ => panic!("expected PreviewLayout"), + } + } + #[test] - fn format_timestamp_handles_invalid_value() { + fn vote_witness_decodes_owner_and_votes() { + // 21-byte SR addresses (0x41 prefix + 20 bytes), deterministic. + let sr1 = hex::decode("4100000000000000000000000000000000000001").unwrap(); + let sr2 = hex::decode("4100000000000000000000000000000000000002").unwrap(); + let owner = owner_bytes(); + let bytes = build_vote_witness_bytes(&owner, &[(&sr1, 1000), (&sr2, 500)]); + let raw = + build_raw_with_contract("type.googleapis.com/protocol.VoteWitnessContract", bytes); + let payload = TronVisualSignConverter + .to_visual_sign_payload( + TronTransactionWrapper::from_string(&encode_hex(&raw)).unwrap(), + VisualSignOptions::default(), + ) + .unwrap(); + + assert_eq!( + text_value(find_field(&payload, "Contract Type").unwrap()), + "Vote Witness", + ); + // Owner round-trips through base58check. + assert_eq!( + address_value(find_field(&payload, "Owner").unwrap()), + address_to_base58(&owner), + ); + + let votes_field = find_field(&payload, "Votes").expect("Votes preview layout"); + assert_eq!( + preview_layout_subtitle(votes_field), + "1500 votes across 2 SRs" + ); + + // Condensed view: signers with constrained screens see only the summary line. + let condensed = preview_layout_condensed(votes_field); + assert_eq!(condensed.fields.len(), 1); + assert_eq!( + text_value(&condensed.fields[0].signable_payload_field), + "1500 votes across 2 SRs", + ); + + // Expanded view: full per-vote breakdown, two fields per vote, in input order. + let expanded = preview_layout_expanded(votes_field); + assert_eq!(expanded.fields.len(), 4); + let labels: Vec<&str> = expanded + .fields + .iter() + .map(|f| field_label(&f.signable_payload_field)) + .collect(); + assert_eq!( + labels, + vec![ + "Vote 1 (SR)", + "Vote 1 (Count)", + "Vote 2 (SR)", + "Vote 2 (Count)" + ], + ); + assert_eq!( + address_value(&expanded.fields[0].signable_payload_field), + address_to_base58(&sr1), + ); + assert_eq!( + text_value(&expanded.fields[1].signable_payload_field), + "1000", + ); + assert_eq!( + text_value(&expanded.fields[3].signable_payload_field), + "500", + ); + } + + #[test] + fn vote_witness_empty_votes_renders_zero_summary() { + let owner = owner_bytes(); + let bytes = build_vote_witness_bytes(&owner, &[]); + let raw = + build_raw_with_contract("type.googleapis.com/protocol.VoteWitnessContract", bytes); + let payload = TronVisualSignConverter + .to_visual_sign_payload( + TronTransactionWrapper::from_string(&encode_hex(&raw)).unwrap(), + VisualSignOptions::default(), + ) + .unwrap(); + + let votes_field = find_field(&payload, "Votes").expect("Votes preview layout"); + assert_eq!(preview_layout_subtitle(votes_field), "0 votes across 0 SRs"); + assert!(preview_layout_expanded(votes_field).fields.is_empty()); + } + + #[test] + fn render_time_field_includes_relative_tag() { + // Past timestamp -> " ms, N minutes ago". + let rendered = render_time_field(1_700_000_000_000, 1_700_000_120_000); + assert_eq!( + rendered, + "2023-11-14 22:13:20 UTC (1700000000000 ms, 2 minutes ago)", + ); + + // Future timestamp -> " ms, in about N hours". + let rendered = render_time_field(1_700_000_000_000 + 3_600_000, 1_700_000_000_000); + assert!( + rendered.ends_with(", in about 1 hour)"), + "unexpected render: {rendered}", + ); + } + + #[test] + fn render_time_field_omits_relative_tag_for_unrepresentable() { // i64::MAX is out of chrono's representable range; the helper must NOT include - // "(N ms)" so the caller's own "(N ms)" suffix isn't doubled. - assert_eq!(format_timestamp(i64::MAX), "invalid timestamp"); + // a misleading relative tag and must not double the "(N ms)" suffix. + assert_eq!( + render_time_field(i64::MAX, 0), + format!("invalid timestamp ({} ms)", i64::MAX), + ); } #[test] diff --git a/src/parser/cli/tests/cli_test.rs b/src/parser/cli/tests/cli_test.rs index b9b84ea6..795086bc 100644 --- a/src/parser/cli/tests/cli_test.rs +++ b/src/parser/cli/tests/cli_test.rs @@ -208,8 +208,10 @@ fn test_cli_with_fixtures() { // Non-JSON output (text/human): strip diagnostic blocks from the // actual Debug-formatted payload so the display fixture stays // diagnostics-agnostic, matching how the JSON branch filters them above. + // Also strip the wall-clock-dependent relative-time tag the Tron parser + // appends to Timestamp/Expiration so the snapshot doesn't drift over time. #[cfg_attr(not(feature = "diagnostics"), allow(unused_mut))] - let mut actual_display = actual_output.trim().to_string(); + let mut actual_display = strip_relative_time_tag(actual_output.trim()); #[cfg(feature = "diagnostics")] { actual_display = strip_debug_diagnostic_blocks(&actual_display); @@ -254,6 +256,64 @@ fn strip_debug_diagnostic_blocks(input: &str) -> String { out } +/// Strip the wall-clock-dependent relative-time suffix from `( ms, )` +/// patterns so timestamp fixtures don't have to be regenerated as time passes. +/// Leaves `( ms)` untouched and matches each occurrence independently. +fn strip_relative_time_tag(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut rest = input; + while let Some(idx) = rest.find(" ms, ") { + let before = &rest[..idx]; + let digit_run_start = before.rfind('(').map(|p| p + 1); + let is_paren_digits = digit_run_start.is_some_and(|start| { + let candidate = &before[start..]; + !candidate.is_empty() && candidate.bytes().all(|b| b.is_ascii_digit()) + }); + if is_paren_digits { + out.push_str(&rest[..idx + 3]); // keep " ms" + let after_ms = &rest[idx + 5..]; // skip ", " + match after_ms.find(')') { + Some(close) => { + out.push(')'); // close the parens we kept + rest = &after_ms[close + 1..]; // continue scanning after ')' + } + None => { + rest = ""; + } + } + } else { + // Not the pattern we care about — advance past this match and continue. + out.push_str(&rest[..idx + 5]); + rest = &rest[idx + 5..]; + } + } + out.push_str(rest); + out +} + +#[test] +fn strip_relative_time_tag_normalizes_only_ms_paren_pattern() { + // Strips the relative tag inside `( ms, )`. + assert_eq!( + strip_relative_time_tag("(1779381252000 ms, about 6 days ago)"), + "(1779381252000 ms)", + ); + // Leaves `( ms)` without a relative tag untouched. + assert_eq!(strip_relative_time_tag("(123 ms)"), "(123 ms)",); + // Handles multiple occurrences and unrelated commas in the same string. + assert_eq!( + strip_relative_time_tag( + "Timestamp: T (10 ms, in 5 seconds), unrelated, ms, comma, Expiration: T (20 ms, 1 minute ago)" + ), + "Timestamp: T (10 ms), unrelated, ms, comma, Expiration: T (20 ms)", + ); + // Non-digit prefix inside parens is left alone. + assert_eq!( + strip_relative_time_tag("(label ms, ignored)"), + "(label ms, ignored)", + ); +} + fn assert_strings_match(test_name: &str, fixture_type: &str, expected: &str, actual: &str) { if expected != actual { let diff = TextDiff::from_lines(expected, actual); diff --git a/src/visualsign/Cargo.toml b/src/visualsign/Cargo.toml index c86e5410..2d78d4b3 100644 --- a/src/visualsign/Cargo.toml +++ b/src/visualsign/Cargo.toml @@ -14,6 +14,7 @@ pretty_assertions = "1.4.1" thiserror = "2.0.12" # the most minimal regex import so that I can do number validation regex = { version = "1.11.1", default-features = false, features = ["std"] } +chrono = { version = "0.4", default-features = false, features = ["clock"] } tracing = { workspace = true } generated = { path = "../generated" } diff --git a/src/visualsign/src/lib.rs b/src/visualsign/src/lib.rs index 589cac0d..2800d0eb 100644 --- a/src/visualsign/src/lib.rs +++ b/src/visualsign/src/lib.rs @@ -9,6 +9,7 @@ pub mod field_builders; pub mod lint; pub mod registry; pub mod test_utils; +pub mod time_fmt; pub mod vsptrait; // Marker trait to ensure types implement deterministic ordering in their serialization diff --git a/src/visualsign/src/time_fmt.rs b/src/visualsign/src/time_fmt.rs new file mode 100644 index 00000000..07e076a7 --- /dev/null +++ b/src/visualsign/src/time_fmt.rs @@ -0,0 +1,163 @@ +//! Shared timestamp formatting helpers for chain parsers. +//! +//! Absolute formatting via chrono; relative ("N minutes ago") is hand-rolled +//! integer math so the wording stays consistent across chains and the helper +//! has zero extra dependencies. + +use chrono::DateTime; + +/// Format epoch-milliseconds as `YYYY-MM-DD HH:MM:SS UTC`. +/// +/// Returns `"invalid timestamp"` for values outside chrono's representable +/// range — callers should still print the raw epoch alongside so signers can +/// see the original bytes even when the date is unrepresentable. +pub fn format_timestamp_ms(ms: i64) -> String { + DateTime::from_timestamp_millis(ms) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| "invalid timestamp".to_string()) +} + +/// Format epoch-ms relative to `now_ms`, e.g. `"about 2 hours ago"` or +/// `"in about 23 hours"`. Coarse one-unit precision, integer math, no f64. +/// +/// Returns `None` when `ms` is outside chrono's representable range — callers +/// should omit the relative tag rather than emit a misleading one. +pub fn format_relative_ms(ms: i64, now_ms: i64) -> Option { + // Validate the timestamp via chrono so we behave the same way as + // format_timestamp_ms. + DateTime::from_timestamp_millis(ms)?; + + let diff_ms = (ms as i128) - (now_ms as i128); + let abs_ms = diff_ms.unsigned_abs(); + let future = diff_ms > 0; + + const SEC: u128 = 1_000; + const MIN: u128 = 60 * SEC; + const HOUR: u128 = 60 * MIN; + const DAY: u128 = 24 * HOUR; + const MONTH: u128 = 30 * DAY; + + if abs_ms < SEC { + return Some("just now".to_string()); + } + + let (n, unit, approx) = if abs_ms < MIN { + (abs_ms / SEC, "second", false) + } else if abs_ms < HOUR { + (abs_ms / MIN, "minute", false) + } else if abs_ms < DAY { + (abs_ms / HOUR, "hour", true) + } else if abs_ms < MONTH { + (abs_ms / DAY, "day", true) + } else { + (abs_ms / MONTH, "month", true) + }; + + let plural = if n == 1 { "" } else { "s" }; + let approx_word = if approx { "about " } else { "" }; + let rendered = if future { + format!("in {approx_word}{n} {unit}{plural}") + } else { + format!("{approx_word}{n} {unit}{plural} ago") + }; + Some(rendered) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + const NOW: i64 = 1_700_000_000_000; // 2023-11-14 22:13:20 UTC + + #[test] + fn format_timestamp_ms_renders_known_epoch() { + assert_eq!(format_timestamp_ms(NOW), "2023-11-14 22:13:20 UTC"); + } + + #[test] + fn format_timestamp_ms_returns_marker_for_unrepresentable() { + assert_eq!(format_timestamp_ms(i64::MAX), "invalid timestamp"); + } + + #[test] + fn relative_ms_just_now_both_directions() { + assert_eq!(format_relative_ms(NOW, NOW).as_deref(), Some("just now")); + assert_eq!( + format_relative_ms(NOW + 500, NOW).as_deref(), + Some("just now"), + ); + assert_eq!( + format_relative_ms(NOW - 500, NOW).as_deref(), + Some("just now"), + ); + } + + #[test] + fn relative_ms_seconds() { + assert_eq!( + format_relative_ms(NOW + 1_000, NOW).as_deref(), + Some("in 1 second"), + ); + assert_eq!( + format_relative_ms(NOW - 45_000, NOW).as_deref(), + Some("45 seconds ago"), + ); + } + + #[test] + fn relative_ms_minutes() { + assert_eq!( + format_relative_ms(NOW + 60_000, NOW).as_deref(), + Some("in 1 minute"), + ); + assert_eq!( + format_relative_ms(NOW - 120_000, NOW).as_deref(), + Some("2 minutes ago"), + ); + } + + #[test] + fn relative_ms_hours() { + assert_eq!( + format_relative_ms(NOW + 3_600_000, NOW).as_deref(), + Some("in about 1 hour"), + ); + assert_eq!( + format_relative_ms(NOW - 23 * 3_600_000, NOW).as_deref(), + Some("about 23 hours ago"), + ); + } + + #[test] + fn relative_ms_days() { + let day = 86_400_000_i64; + assert_eq!( + format_relative_ms(NOW + day, NOW).as_deref(), + Some("in about 1 day"), + ); + assert_eq!( + format_relative_ms(NOW - 5 * day, NOW).as_deref(), + Some("about 5 days ago"), + ); + } + + #[test] + fn relative_ms_months() { + let month = 30 * 86_400_000_i64; + assert_eq!( + format_relative_ms(NOW + 2 * month, NOW).as_deref(), + Some("in about 2 months"), + ); + assert_eq!( + format_relative_ms(NOW - 6 * month, NOW).as_deref(), + Some("about 6 months ago"), + ); + } + + #[test] + fn relative_ms_returns_none_for_unrepresentable() { + assert!(format_relative_ms(i64::MAX, NOW).is_none()); + assert!(format_relative_ms(i64::MIN, NOW).is_none()); + } +}