Contractless/src/bin/lookup_nft.rs

234 lines
8.1 KiB
Rust

use blockchain::common::binary_conversions::binary_to_string;
use blockchain::common::cli_prompts::prompt_hidden_nonempty;
use blockchain::common::network_startup::get_connections;
use blockchain::encode;
use blockchain::env;
use blockchain::json;
use blockchain::records::memory::response_channels::generate_uid;
use blockchain::standalone_tools::connections::handshake;
use blockchain::wallets::structures::Wallet;
use blockchain::{Map, Value};
const NFT_NAME_BYTES: usize = 15;
const SERIES_BYTES: usize = 4;
const GENESIS_HASH_BYTES: usize = 32;
const CREATOR_BYTES: usize = Wallet::SHORT_ADDRESS_BYTES_LENGTH;
const IPFS_BYTES: usize = 100;
const HOLDER_BYTES: usize = Wallet::SHORT_ADDRESS_BYTES_LENGTH;
const HISTORY_COUNT_BYTES: usize = 4;
const HISTORY_ENTRY_SIZE: usize =
32 + 4 + 1 + 1 + (2 * Wallet::SHORT_ADDRESS_BYTES_LENGTH) + 15 + 4 + 8;
const NFT_LOOKUP_FIXED_BYTES: usize = NFT_NAME_BYTES
+ SERIES_BYTES
+ GENESIS_HASH_BYTES
+ CREATOR_BYTES
+ IPFS_BYTES
+ HOLDER_BYTES
+ HISTORY_COUNT_BYTES;
fn action_name(action: u8) -> &'static str {
// History action bytes are stored compactly on the wire and expanded for display.
match action {
1 => "create_nft",
2 => "transfer",
3 => "swap",
4 => "loan_locked",
5 => "loan_payment",
6 => "collateral_claimed",
7 => "loan_issued",
_ => "unknown",
}
}
fn decode_wallet(bytes: &[u8]) -> String {
// Empty wallet fields are encoded as all zeroes; otherwise decode a short address.
if bytes.iter().all(|b| *b == 0) {
String::new()
} else {
Wallet::bytes_to_short_address(bytes).unwrap_or_default()
}
}
fn decode_history_entry(entry: &[u8]) -> Option<Value> {
// Each history row has a fixed binary size so bad lengths mean an invalid response.
if entry.len() != HISTORY_ENTRY_SIZE {
return None;
}
// Decode the history fields in the same order the RPC command writes them.
let txid = encode(&entry[0..32]);
let block = u32::from_le_bytes(entry[32..36].try_into().ok()?);
let txtype = entry[36];
let action = entry[37];
let from = decode_wallet(&entry[38..60]);
let to = decode_wallet(&entry[60..82]);
let received_asset = binary_to_string(entry[82..97].to_vec()).trim().to_string();
let received_series = u32::from_le_bytes(entry[97..101].try_into().ok()?);
let received_value_raw = u64::from_le_bytes(entry[101..109].try_into().ok()?);
let received_value = received_value_raw as f64 / 100_000_000.0;
// Omit empty optional fields so the CLI output stays readable.
let mut obj = Map::new();
obj.insert("txid".to_string(), json!(txid));
obj.insert("block".to_string(), json!(block));
obj.insert("txtype".to_string(), json!(txtype));
obj.insert("action".to_string(), json!(action_name(action)));
if !from.is_empty() {
obj.insert("from".to_string(), json!(from));
}
if !to.is_empty() {
obj.insert("to".to_string(), json!(to));
}
if !received_asset.is_empty() {
obj.insert("received_asset".to_string(), json!(received_asset));
if received_series > 0 {
obj.insert("received_series".to_string(), json!(received_series));
}
obj.insert("received_value".to_string(), json!(received_value));
}
Some(Value::Object(obj))
}
fn decode_nft_lookup(response: &[u8]) -> Option<String> {
// The fixed NFT fields must exist before any history entries can be decoded.
if response.len() < NFT_LOOKUP_FIXED_BYTES {
return None;
}
// Decode the fixed-width NFT metadata fields in wire order.
let mut cursor = 0usize;
let nft_name = binary_to_string(response[cursor..cursor + NFT_NAME_BYTES].to_vec())
.trim()
.to_string();
cursor += NFT_NAME_BYTES;
let series = u32::from_le_bytes(response[cursor..cursor + SERIES_BYTES].try_into().ok()?);
cursor += SERIES_BYTES;
let genesis = encode(&response[cursor..cursor + GENESIS_HASH_BYTES]);
cursor += GENESIS_HASH_BYTES;
let creator = Wallet::bytes_to_short_address(&response[cursor..cursor + CREATOR_BYTES])?;
cursor += CREATOR_BYTES;
let ipfs_cid = binary_to_string(response[cursor..cursor + IPFS_BYTES].to_vec())
.trim()
.to_string();
cursor += IPFS_BYTES;
let current_holder = decode_wallet(&response[cursor..cursor + HOLDER_BYTES]);
cursor += HOLDER_BYTES;
let history_count = u32::from_le_bytes(
response[cursor..cursor + HISTORY_COUNT_BYTES]
.try_into()
.ok()?,
);
cursor += HISTORY_COUNT_BYTES;
// Any remaining bytes are fixed-size history entries.
let mut history = Vec::new();
let mut offset = cursor;
while offset + HISTORY_ENTRY_SIZE <= response.len() {
if let Some(entry) = decode_history_entry(&response[offset..offset + HISTORY_ENTRY_SIZE]) {
history.push(entry);
}
offset += HISTORY_ENTRY_SIZE;
}
// Convert the binary record into user-facing JSON.
let mut output = Map::new();
output.insert("nft_name".to_string(), json!(nft_name));
output.insert("series".to_string(), json!(series));
if series == 0 {
output.insert("ipfs".to_string(), json!(format!("ipfs://{ipfs_cid}")));
} else {
output.insert(
"ipfs".to_string(),
json!(format!("ipfs://{ipfs_cid}/{series}.json")),
);
}
if series > 0 {
output.insert(
"asset_name".to_string(),
json!(format!("{nft_name}_{series}")),
);
}
output.insert("genesis".to_string(), json!(genesis));
output.insert("creator".to_string(), json!(creator));
output.insert("current_holder".to_string(), json!(current_holder));
output.insert("history_count".to_string(), json!(history_count));
output.insert("history".to_string(), Value::Array(history));
serde_json::to_string_pretty(&Value::Object(output)).ok()
}
#[tokio::main]
async fn main() {
// Command 36 asks a peer for NFT metadata by name and series/item number.
let hashmap_key = generate_uid();
let rpc_command = 36;
// Item number 0 means a 1/1 NFT; nonzero values identify a series item.
let args: Vec<String> = env::args().collect();
if args.len() != 3 {
println!("Usage: ./lookup_nft <nft_name> <item_number>");
println!("Use item_number 0 for a 1/1 NFT, or the specific item number for a series NFT.");
return;
}
let nft_name = args[1].clone();
let item_number = args[2].clone();
let encryption_key = prompt_hidden_nonempty(
"What is your wallet decryption key? ",
"Wallet key cannot be empty. Please try again.",
)
.await;
// sending_request expects command 36 lookup input as "name|series".
let payload = format!("{nft_name}|{item_number}");
// Try each configured peer until one returns a parsable NFT record or text error.
let connections = get_connections().await;
let mut connected = false;
for conn in connections {
if connected {
break;
}
let socket_address = conn.parse().expect("Failed to parse the socket address");
let result = handshake::connect_and_handshake(
socket_address,
payload.clone(),
rpc_command,
handshake::HandshakeWallet::WalletKey(encryption_key.clone()),
hashmap_key,
)
.await;
match result {
Ok(response) => {
// Successful NFT lookups are binary records; errors are returned as text.
if let Some(output) = decode_nft_lookup(&response) {
println!("{output}");
connected = true;
} else {
let response_text = String::from_utf8_lossy(&response);
let trimmed = response_text.trim();
if !trimmed.is_empty() {
println!("{trimmed}");
connected = true;
}
}
}
Err(_) => connected = false,
}
}
if !connected {
eprintln!("failed to connect");
}
}