diff --git a/Cargo.lock b/Cargo.lock index 92fea276fcf..8b80e6dcc03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5842,6 +5842,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "rs-scripts" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "chrono", + "clap", + "data-contracts", + "dpp", + "hex", + "platform-version", + "serde_json", +] + [[package]] name = "rs-sdk-ffi" version = "3.1.0-dev.1" diff --git a/Cargo.toml b/Cargo.toml index 80a21c2046e..fe1346cea97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,9 @@ members = [ "packages/rs-platform-wallet", "packages/rs-platform-wallet-ffi", "packages/rs-platform-encryption", - "packages/wasm-sdk", "packages/rs-unified-sdk-ffi", + "packages/wasm-sdk", + "packages/rs-unified-sdk-ffi", + "packages/rs-scripts", ] [workspace.dependencies] diff --git a/packages/rs-scripts/Cargo.toml b/packages/rs-scripts/Cargo.toml new file mode 100644 index 00000000000..dc639760994 --- /dev/null +++ b/packages/rs-scripts/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "rs-scripts" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "decode-document" +path = "src/bin/decode_document.rs" + +[dependencies] +dpp = { path = "../rs-dpp", features = ["system_contracts"] } +data-contracts = { path = "../data-contracts" } +platform-version = { path = "../rs-platform-version" } +base64 = "0.22" +chrono = "0.4" +hex = "0.4" +clap = { version = "4", features = ["derive"] } +serde_json = "1" diff --git a/packages/rs-scripts/README.md b/packages/rs-scripts/README.md new file mode 100644 index 00000000000..32dee2822e2 --- /dev/null +++ b/packages/rs-scripts/README.md @@ -0,0 +1,60 @@ +# rs-scripts + +Utility scripts for debugging and inspecting Dash Platform data. + +## decode-document + +Decodes a hex or base64-encoded platform document into human-readable output. Uses the actual platform deserialization code, so it handles all document format versions correctly. + +### Usage + +```bash +cargo run -p rs-scripts --bin decode-document -- [OPTIONS] +``` + +### Options + +| Option | Required | Description | +|--------|----------|-------------| +| `-c, --contract` | yes | System data contract name or ID (base58/base64/hex) | +| `-d, --doc-type` | yes | Document type name within the contract | +| `-f, --format` | no | Input encoding: `base64`, `hex`, or `auto` (default: `auto`) | + +### Supported contracts + +`withdrawals`, `dpns`, `dashpay`, `masternode-reward-shares`, `feature-flags`, `wallet-utils`, `token-history`, `keyword-search` + +You can also pass the contract ID directly instead of a name (you'll need `-d` to specify the document type): +```bash +# base58 +cargo run -p rs-scripts --bin decode-document -- -c 4fJLR2GYTPFdomuTVvNy3VRrvWgvkKPzqehEBpNf2nk6 -d withdrawal "base64data..." +# base64 +cargo run -p rs-scripts --bin decode-document -- -c "NmK7YeF/rj6ilM9gMZf7CqttURgL2LYQTElEpi/i2X8=" -d withdrawal "base64data..." +# hex +cargo run -p rs-scripts --bin decode-document -- -c 3662bb61e17fae3ea294cf603197fb0aab6d51180bd8b6104c4944a62fe2d97f -d withdrawal "base64data..." +``` + +### Examples + +Decode a withdrawal document: +```bash +cargo run -p rs-scripts --bin decode-document -- -c withdrawals -d withdrawal "AgIintqUs1vl..." +``` + +Decode a DPNS domain document: +```bash +cargo run -p rs-scripts --bin decode-document -- -c dpns -d domain "base64data..." +``` + +Pipe from a gRPC query (decode each document from the response): +```bash +echo '{"v0":{"prove":false,"data_contract_id":"NmK7YeF/rj6ilM9gMZf7CqttURgL2LYQTElEpi/i2X8=","document_type":"withdrawal","where":"gYNmc3RhdHVzYT0C","limit":10}}' \ + | grpcurl -insecure -import-path packages/dapi-grpc/protos -d @ \ + -proto platform/v0/platform.proto \ + :443 org.dash.platform.dapi.v0.Platform/getDocuments \ + | jq -r '.v0.documents.documents[]' \ + | while read doc; do + cargo run -p rs-scripts --bin decode-document -- -c withdrawals -d withdrawal "$doc" + echo "---" + done +``` diff --git a/packages/rs-scripts/src/bin/decode_document.rs b/packages/rs-scripts/src/bin/decode_document.rs new file mode 100644 index 00000000000..79442016f39 --- /dev/null +++ b/packages/rs-scripts/src/bin/decode_document.rs @@ -0,0 +1,173 @@ +use base64::Engine; +use clap::Parser; +use data_contracts::SystemDataContract; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::Document; +use dpp::document::DocumentV0Getters; +use dpp::platform_value::Identifier; +use dpp::system_data_contracts::load_system_data_contract; +use platform_version::version::PlatformVersion; + +// Keep in sync with SystemDataContract enum in packages/data-contracts/src/lib.rs +const SYSTEM_CONTRACTS: &[(&str, SystemDataContract)] = &[ + ("withdrawals", SystemDataContract::Withdrawals), + ("dpns", SystemDataContract::DPNS), + ("dashpay", SystemDataContract::Dashpay), + ( + "masternode-reward-shares", + SystemDataContract::MasternodeRewards, + ), + ("feature-flags", SystemDataContract::FeatureFlags), + ("wallet-utils", SystemDataContract::WalletUtils), + ("token-history", SystemDataContract::TokenHistory), + ("keyword-search", SystemDataContract::KeywordSearch), +]; + +#[derive(Parser)] +#[command( + name = "decode-document", + about = "Decode a platform document from hex or base64 bytes" +)] +struct Args { + /// Document bytes (base64 or hex encoded) + doc_bytes: String, + + /// System data contract: name (e.g. "withdrawals") or ID in base58/base64/hex + #[arg(short, long)] + contract: String, + + /// Document type name within the contract (e.g. "withdrawal", "domain") + #[arg(short, long)] + doc_type: String, + + /// Input encoding: "base64", "hex", or "auto" (default: auto, tries base64 then hex) + #[arg(short, long, default_value = "auto")] + format: String, +} + +fn resolve_system_contract(input: &str) -> SystemDataContract { + // Try by name first + for (name, sc) in SYSTEM_CONTRACTS { + if input.eq_ignore_ascii_case(name) { + return *sc; + } + } + + // Try parsing as an identifier (base58, base64, or hex) + let id = Identifier::from_string_unknown_encoding(input).unwrap_or_else(|_| { + eprintln!("Unknown contract: '{input}'"); + eprintln!( + "Must be a name ({}) or an ID in base58/base64/hex", + SYSTEM_CONTRACTS + .iter() + .map(|(n, _)| *n) + .collect::>() + .join(", ") + ); + std::process::exit(1); + }); + + for (_, sc) in SYSTEM_CONTRACTS { + if sc.id() == id { + return *sc; + } + } + + eprintln!("No system contract found with ID {id}"); + std::process::exit(1); +} + +fn main() { + let args = Args::parse(); + + let platform_version = PlatformVersion::latest(); + + let system_contract = resolve_system_contract(&args.contract); + + let data_contract = match load_system_data_contract(system_contract, platform_version) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to load system data contract: {e}"); + std::process::exit(1); + } + }; + + let document_type = match data_contract.document_type_for_name(&args.doc_type) { + Ok(dt) => dt, + Err(e) => { + eprintln!("Unknown document type '{}': {e}", args.doc_type); + eprintln!( + "Available types: {}", + data_contract + .document_types() + .keys() + .cloned() + .collect::>() + .join(", ") + ); + std::process::exit(1); + } + }; + + let bytes = match args.format.as_str() { + "base64" => base64::engine::general_purpose::STANDARD + .decode(&args.doc_bytes) + .unwrap_or_else(|e| { + eprintln!("Invalid base64: {e}"); + std::process::exit(1); + }), + "hex" => hex::decode(&args.doc_bytes).unwrap_or_else(|e| { + eprintln!("Invalid hex: {e}"); + std::process::exit(1); + }), + "auto" | _ => { + // Try base64 first (most common — gRPC responses are base64), + // then hex. This avoids misinterpreting hex-only base64 strings. + if let Ok(b) = base64::engine::general_purpose::STANDARD.decode(&args.doc_bytes) { + b + } else if let Ok(b) = hex::decode(&args.doc_bytes) { + b + } else { + eprintln!("Failed to decode document bytes as base64 or hex"); + eprintln!("Hint: use --format base64 or --format hex to force a specific encoding"); + std::process::exit(1); + } + } + }; + + let document = match Document::from_bytes(&bytes, document_type, platform_version) { + Ok(doc) => doc, + Err(e) => { + eprintln!("Failed to deserialize document: {e}"); + std::process::exit(1); + } + }; + + println!("id: {}", document.id()); + println!("owner_id: {}", document.owner_id()); + if let Some(created_at) = document.created_at() { + println!("created_at: {} ({}ms)", format_ts(created_at), created_at); + } + if let Some(updated_at) = document.updated_at() { + println!("updated_at: {} ({}ms)", format_ts(updated_at), updated_at); + } + if let Some(revision) = document.revision() { + println!("revision: {}", revision); + } + println!(); + println!("properties:"); + for (key, value) in document.properties() { + println!(" {key}: {value}"); + } +} + +fn format_ts(ms: u64) -> String { + let secs = (ms / 1000) as i64; + let nanos = ((ms % 1000) * 1_000_000) as u32; + let dt = chrono::DateTime::from_timestamp(secs, nanos); + match dt { + Some(dt) => dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(), + None => format!("invalid timestamp: {ms}"), + } +}