use serde::{Deserialize, Serialize};
use tracing::warn;
use bsv::primitives::private_key::PrivateKey;
use bsv::script::locking_script::LockingScript;
use bsv::script::templates::push_drop::PushDrop;
use bsv::script::templates::ScriptTemplateLock;
use bsv::wallet::interfaces::{
CreateActionArgs, CreateActionOptions, CreateActionOutput, ListOutputsArgs, OutputInclude,
QueryMode, WalletInterface,
};
use crate::WalletError;
pub const UMP_BASKET: &str = "admin user management token";
pub const UMP_PROTOCOL_SECURITY_LEVEL: u8 = 2;
pub const UMP_PROTOCOL_NAME: &str = "admin user management token";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UMPToken {
pub password_salt: Vec<u8>,
pub password_presentation_primary: Vec<u8>,
pub password_recovery_primary: Vec<u8>,
pub presentation_recovery_primary: Vec<u8>,
pub password_primary_privileged: Vec<u8>,
pub presentation_recovery_privileged: Vec<u8>,
pub presentation_hash: Vec<u8>,
pub recovery_hash: Vec<u8>,
pub presentation_key_encrypted: Vec<u8>,
pub password_key_encrypted: Vec<u8>,
pub recovery_key_encrypted: Vec<u8>,
pub profiles_encrypted: Option<Vec<u8>>,
pub current_outpoint: Option<String>,
}
fn encode_ump_token_fields(token: &UMPToken) -> Vec<Vec<u8>> {
let mut fields = vec![
token.password_salt.clone(),
token.password_presentation_primary.clone(),
token.password_recovery_primary.clone(),
token.presentation_recovery_primary.clone(),
token.password_primary_privileged.clone(),
token.presentation_recovery_privileged.clone(),
token.presentation_hash.clone(),
token.recovery_hash.clone(),
token.presentation_key_encrypted.clone(),
token.password_key_encrypted.clone(),
token.recovery_key_encrypted.clone(),
];
if let Some(ref profiles) = token.profiles_encrypted {
fields.push(profiles.clone());
}
fields
}
pub async fn lookup_ump_token(
wallet: &(dyn WalletInterface + Send + Sync),
presentation_key_hash: &str,
) -> Result<Option<UMPToken>, WalletError> {
let result = wallet
.list_outputs(
ListOutputsArgs {
basket: UMP_BASKET.to_string(),
tags: vec![format!("presentationHash {}", presentation_key_hash)],
tag_query_mode: Some(QueryMode::All),
include: Some(OutputInclude::LockingScripts),
include_custom_instructions: Some(false).into(),
include_tags: Some(false).into(),
include_labels: Some(false).into(),
limit: Some(1),
offset: None,
seek_permission: Some(false).into(),
},
None,
)
.await
.map_err(|e| WalletError::Internal(format!("UMP token lookup failed: {}", e)))?;
if result.outputs.is_empty() {
return Ok(None);
}
let output = &result.outputs[0];
let script_bytes = match &output.locking_script {
Some(s) => s.clone(),
None => {
warn!("UMP token output has no locking script, treating as not found");
return Ok(None);
}
};
let locking_script = LockingScript::from_binary(&script_bytes);
match PushDrop::decode(&locking_script) {
Ok(pd) => match decode_ump_token_fields(&pd.fields) {
Ok(mut token) => {
token.current_outpoint = Some(output.outpoint.clone());
Ok(Some(token))
}
Err(e) => {
warn!("Failed to decode UMP token fields: {}", e);
Ok(None)
}
},
Err(e) => {
warn!(
"PushDrop decode failed ({}), trying length-prefixed fallback",
e
);
match parse_length_prefixed_fields(&script_bytes) {
Some(fields) if fields.len() >= 11 => match decode_ump_token_fields(&fields) {
Ok(mut token) => {
token.current_outpoint = Some(output.outpoint.clone());
Ok(Some(token))
}
Err(e2) => {
warn!("Length-prefixed decode also failed: {}", e2);
Ok(None)
}
},
_ => {
warn!("UMP token script could not be parsed in any format");
Ok(None)
}
}
}
}
}
fn parse_length_prefixed_fields(data: &[u8]) -> Option<Vec<Vec<u8>>> {
let mut fields = Vec::new();
let mut pos = 0;
while pos + 4 <= data.len() {
let len =
u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
if pos + len > data.len() {
return None;
}
fields.push(data[pos..pos + len].to_vec());
pos += len;
}
if fields.is_empty() {
None
} else {
Some(fields)
}
}
pub async fn create_ump_token(
wallet: &(dyn WalletInterface + Send + Sync),
token: &UMPToken,
admin_originator: &str,
) -> Result<(), WalletError> {
let fields = encode_ump_token_fields(token);
let signing_key = PrivateKey::from_random().map_err(|e| {
WalletError::Internal(format!(
"Failed to generate signing key for UMP token: {}",
e
))
})?;
let pushdrop = PushDrop::new(fields, signing_key);
let locking_script = pushdrop.lock().map_err(|e| {
WalletError::Internal(format!("Failed to create UMP token locking script: {}", e))
})?;
let script_bytes = locking_script.to_binary();
let presentation_hash_hex: String = token
.presentation_hash
.iter()
.map(|b| format!("{:02x}", b))
.collect();
wallet
.create_action(
CreateActionArgs {
description: "Create UMP token".to_string(),
input_beef: None,
inputs: vec![],
outputs: vec![CreateActionOutput {
locking_script: Some(script_bytes),
satoshis: 1,
output_description: "UMP token PushDrop output".to_string(),
basket: Some(UMP_BASKET.to_string()),
custom_instructions: None,
tags: vec![format!("presentationHash {}", presentation_hash_hex)],
}],
lock_time: None,
version: None,
labels: vec![],
options: Some(CreateActionOptions {
accept_delayed_broadcast: Some(true).into(),
..Default::default()
}),
reference: None,
},
Some(admin_originator),
)
.await
.map_err(|e| WalletError::Internal(format!("Failed to create UMP token action: {}", e)))?;
Ok(())
}
pub fn decode_ump_token_fields(fields: &[Vec<u8>]) -> Result<UMPToken, WalletError> {
if fields.len() < 11 {
return Err(WalletError::Internal(format!(
"UMP token requires at least 11 fields, got {}",
fields.len()
)));
}
Ok(UMPToken {
password_salt: fields[0].clone(),
password_presentation_primary: fields[1].clone(),
password_recovery_primary: fields[2].clone(),
presentation_recovery_primary: fields[3].clone(),
password_primary_privileged: fields[4].clone(),
presentation_recovery_privileged: fields[5].clone(),
presentation_hash: fields[6].clone(),
recovery_hash: fields[7].clone(),
presentation_key_encrypted: fields[8].clone(),
password_key_encrypted: fields[9].clone(),
recovery_key_encrypted: fields[10].clone(),
profiles_encrypted: fields.get(11).cloned(),
current_outpoint: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ump_token_serialization() {
let token = UMPToken {
password_salt: vec![1, 2, 3],
password_presentation_primary: vec![4, 5, 6],
password_recovery_primary: vec![7, 8, 9],
presentation_recovery_primary: vec![10, 11, 12],
password_primary_privileged: vec![13, 14, 15],
presentation_recovery_privileged: vec![16, 17, 18],
presentation_hash: vec![19, 20, 21],
recovery_hash: vec![22, 23, 24],
presentation_key_encrypted: vec![25, 26, 27],
password_key_encrypted: vec![28, 29, 30],
recovery_key_encrypted: vec![31, 32, 33],
profiles_encrypted: None,
current_outpoint: Some("abc123.0".to_string()),
};
let json = serde_json::to_string(&token).expect("serialize UMPToken");
let back: UMPToken = serde_json::from_str(&json).expect("deserialize UMPToken");
assert_eq!(back.password_salt, vec![1, 2, 3]);
assert_eq!(back.current_outpoint.as_deref(), Some("abc123.0"));
assert!(back.profiles_encrypted.is_none());
}
#[test]
fn test_decode_ump_token_fields() {
let fields: Vec<Vec<u8>> = (0..11).map(|i| vec![i as u8; 4]).collect();
let token = decode_ump_token_fields(&fields).expect("decode 11 fields");
assert_eq!(token.password_salt, vec![0u8; 4]);
assert_eq!(token.recovery_key_encrypted, vec![10u8; 4]);
assert!(token.profiles_encrypted.is_none());
}
#[test]
fn test_decode_ump_token_fields_with_profiles() {
let mut fields: Vec<Vec<u8>> = (0..12).map(|i| vec![i as u8; 4]).collect();
fields[11] = vec![0xFF, 0xFE];
let token = decode_ump_token_fields(&fields).expect("decode 12 fields");
assert_eq!(token.profiles_encrypted, Some(vec![0xFF, 0xFE]));
}
#[test]
fn test_decode_ump_token_fields_too_few() {
let fields: Vec<Vec<u8>> = (0..10).map(|i| vec![i as u8; 4]).collect();
let result = decode_ump_token_fields(&fields);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("at least 11 fields"), "Error: {}", msg);
}
#[test]
fn test_encode_decode_roundtrip() {
let token = UMPToken {
password_salt: vec![1, 2, 3, 4],
password_presentation_primary: vec![5, 6, 7, 8],
password_recovery_primary: vec![9, 10, 11, 12],
presentation_recovery_primary: vec![13, 14, 15, 16],
password_primary_privileged: vec![17, 18, 19, 20],
presentation_recovery_privileged: vec![21, 22, 23, 24],
presentation_hash: vec![25, 26, 27, 28],
recovery_hash: vec![29, 30, 31, 32],
presentation_key_encrypted: vec![33, 34, 35, 36],
password_key_encrypted: vec![37, 38, 39, 40],
recovery_key_encrypted: vec![41, 42, 43, 44],
profiles_encrypted: Some(vec![45, 46, 47, 48]),
current_outpoint: None,
};
let fields = encode_ump_token_fields(&token);
assert_eq!(fields.len(), 12);
let decoded = decode_ump_token_fields(&fields).unwrap();
assert_eq!(decoded.password_salt, token.password_salt);
assert_eq!(decoded.profiles_encrypted, Some(vec![45, 46, 47, 48]));
}
#[test]
fn test_encode_without_profiles() {
let token = UMPToken {
password_salt: vec![1],
password_presentation_primary: vec![2],
password_recovery_primary: vec![3],
presentation_recovery_primary: vec![4],
password_primary_privileged: vec![5],
presentation_recovery_privileged: vec![6],
presentation_hash: vec![7],
recovery_hash: vec![8],
presentation_key_encrypted: vec![9],
password_key_encrypted: vec![10],
recovery_key_encrypted: vec![11],
profiles_encrypted: None,
current_outpoint: None,
};
let fields = encode_ump_token_fields(&token);
assert_eq!(fields.len(), 11);
}
#[test]
fn test_pushdrop_encode_decode_roundtrip() {
let token = UMPToken {
password_salt: vec![0xAA; 16],
password_presentation_primary: vec![0xBB; 32],
password_recovery_primary: vec![0xCC; 32],
presentation_recovery_primary: vec![0xDD; 32],
password_primary_privileged: vec![0xEE; 32],
presentation_recovery_privileged: vec![0x11; 32],
presentation_hash: vec![0x22; 32],
recovery_hash: vec![0x33; 32],
presentation_key_encrypted: vec![0x44; 32],
password_key_encrypted: vec![0x55; 32],
recovery_key_encrypted: vec![0x66; 32],
profiles_encrypted: None,
current_outpoint: None,
};
let fields = encode_ump_token_fields(&token);
let key = PrivateKey::from_random().unwrap();
let pd = PushDrop::new(fields.clone(), key);
let script = pd.lock().expect("PushDrop lock should succeed");
let decoded_pd = PushDrop::decode(&script).expect("PushDrop decode should succeed");
assert_eq!(decoded_pd.fields.len(), fields.len());
for (i, (original, decoded)) in fields.iter().zip(decoded_pd.fields.iter()).enumerate() {
assert_eq!(original, decoded, "field {} mismatch", i);
}
let decoded_token =
decode_ump_token_fields(&decoded_pd.fields).expect("UMP token decode should succeed");
assert_eq!(decoded_token.password_salt, token.password_salt);
assert_eq!(decoded_token.presentation_hash, token.presentation_hash);
}
#[test]
fn test_parse_length_prefixed_fields() {
let field1 = vec![0x01, 0x02, 0x03];
let field2 = vec![0x04, 0x05];
let mut data = Vec::new();
data.extend_from_slice(&(field1.len() as u32).to_le_bytes());
data.extend_from_slice(&field1);
data.extend_from_slice(&(field2.len() as u32).to_le_bytes());
data.extend_from_slice(&field2);
let fields = parse_length_prefixed_fields(&data).expect("should parse");
assert_eq!(fields.len(), 2);
assert_eq!(fields[0], field1);
assert_eq!(fields[1], field2);
}
#[test]
fn test_parse_length_prefixed_fields_truncated() {
let data = vec![0x05, 0x00, 0x00, 0x00, 0x01, 0x02]; let result = parse_length_prefixed_fields(&data);
assert!(result.is_none());
}
#[test]
fn test_constants() {
assert_eq!(UMP_BASKET, "admin user management token");
assert_eq!(UMP_PROTOCOL_SECURITY_LEVEL, 2);
assert_eq!(UMP_PROTOCOL_NAME, "admin user management token");
}
}