use std::str::FromStr;
use ootle_byte_type::ToByteType;
use ootle_network::Network;
use tari_crypto::{
keys::{PublicKey, SecretKey},
ristretto::{RistrettoPublicKey, RistrettoSecretKey},
tari_utilities::ByteArray,
};
use tari_ootle_wallet_crypto::{
OutputWitness,
StealthCryptoApi,
StealthOutputWitness,
bullet_proof::generate_extended_bullet_proof as crypto_generate_extended_bullet_proof,
memo::Memo,
pay_to::PayTo,
stealth::{create_outputs_statement, pay_to_output_authorization},
};
use tari_template_lib_types::{Amount, ResourceAddress, crypto::RangeProofBytes};
use crate::{
error::OotleWasmError,
keys::public_key_from_bytes,
stealth::types::{OutputWitnessJson, StealthOutputWitnessJson},
};
#[derive(Debug, Clone)]
pub struct StealthOutputsResult {
pub statement_json: String,
pub aggregated_output_mask: Vec<u8>,
}
pub fn generate_stealth_outputs_statement(
witnesses_json: &str,
revealed_output_amount_microtari: u64,
) -> Result<StealthOutputsResult, OotleWasmError> {
use tari_ootle_wallet_crypto::StealthOutputWitness;
let witnesses: Vec<StealthOutputWitnessJson> = serde_json::from_str(witnesses_json)?;
let witnesses: Vec<StealthOutputWitness> = witnesses
.into_iter()
.map(StealthOutputWitness::try_from)
.collect::<Result<Vec<_>, OotleWasmError>>()?;
let aggregated_output_mask = witnesses
.iter()
.map(|w| &w.witness.mask)
.fold(RistrettoSecretKey::default(), |acc, mask| acc + mask);
let statement = create_outputs_statement(witnesses.iter(), Amount::from_u64(revealed_output_amount_microtari))
.map_err(|e| OotleWasmError::Stealth(e.to_string()))?;
Ok(StealthOutputsResult {
statement_json: serde_json::to_string(&statement)?,
aggregated_output_mask: aggregated_output_mask.as_bytes().to_vec(),
})
}
pub struct CreateStealthOutputWitnessParams<'a> {
pub network: u8,
pub destination_account_public_key: &'a [u8],
pub destination_view_public_key: &'a [u8],
pub amount: u64,
pub resource_address: &'a str,
pub resource_view_key: Option<&'a [u8]>,
pub memo_json: Option<&'a str>,
pub pay_to_json: Option<&'a str>,
pub minimum_value_promise: u64,
}
pub fn create_stealth_output_witness(params: CreateStealthOutputWitnessParams<'_>) -> Result<String, OotleWasmError> {
let network = Network::try_from(params.network).map_err(|e| OotleWasmError::InvalidNetwork(e.to_string()))?;
let account_key = public_key_from_bytes(params.destination_account_public_key)?;
let view_key = public_key_from_bytes(params.destination_view_public_key)?;
let resource_address = ResourceAddress::from_str(params.resource_address)
.map_err(|e| OotleWasmError::InvalidAddress(e.to_string()))?;
let resource_view_key = params.resource_view_key.map(public_key_from_bytes).transpose()?;
let memo: Option<Memo> = params.memo_json.map(serde_json::from_str).transpose()?;
let pay_to: PayTo = params
.pay_to_json
.map(serde_json::from_str)
.transpose()?
.unwrap_or_default();
if params.minimum_value_promise > params.amount {
return Err(OotleWasmError::Stealth(format!(
"minimum_value_promise ({}) must be <= amount ({})",
params.minimum_value_promise, params.amount
)));
}
let mask = RistrettoSecretKey::random(&mut rand::rng());
let (nonce_secret, public_nonce) = RistrettoPublicKey::random_keypair(&mut rand::rng());
let crypto = StealthCryptoApi::new();
let encrypted_data = crypto
.encrypt_value_and_mask(params.amount, &mask, &view_key, &nonce_secret, memo.as_ref())
.map_err(|e| OotleWasmError::Stealth(e.to_string()))?;
let auth = pay_to_output_authorization(&pay_to, || {
crypto
.derive_stealth_owner_public_key(network, &account_key, &nonce_secret)
.to_byte_type()
})
.map_err(|e| OotleWasmError::Stealth(e.to_string()))?;
let tag = crypto.derive_stealth_output_tag(network, &nonce_secret, &view_key, &resource_address);
let witness = StealthOutputWitness {
witness: OutputWitness {
amount: params.amount,
mask,
sender_public_nonce: public_nonce,
minimum_value_promise: params.minimum_value_promise,
encrypted_data,
resource_view_key,
},
auth,
tag,
};
Ok(serde_json::to_string(&StealthOutputWitnessJson::from(&witness))?)
}
pub fn generate_extended_bullet_proof(witnesses_json: &str) -> Result<Vec<u8>, OotleWasmError> {
let witnesses: Vec<OutputWitnessJson> = serde_json::from_str(witnesses_json)?;
let witnesses = witnesses
.into_iter()
.map(OutputWitnessJson::try_into_witness)
.collect::<Result<Vec<_>, OotleWasmError>>()?;
let proof: RangeProofBytes =
crypto_generate_extended_bullet_proof(witnesses.iter()).map_err(|e| OotleWasmError::Stealth(e.to_string()))?;
Ok(proof.into_vec())
}
#[cfg(test)]
mod tests {
use ootle_byte_type::ToByteType;
use tari_crypto::{
keys::{PublicKey, SecretKey},
ristretto::{RistrettoPublicKey, RistrettoSecretKey},
};
use tari_engine_types::stealth::validate_stealth_outputs_statement;
use tari_template_lib_types::{EncryptedData, stealth::StealthOutputsStatement};
use super::*;
fn make_witness_json(amount: u64) -> String {
let mask = RistrettoSecretKey::random(&mut rand::rng());
let nonce = RistrettoPublicKey::from_secret_key(&mask);
let spend_pk: tari_template_lib_types::crypto::RistrettoPublicKeyBytes = nonce.to_byte_type();
format!(
r#"[{{"witness":{{"amount":{},"mask":"{}","sender_public_nonce":"{}","minimum_value_promise":0,"encrypted_data":"{}"}},"auth":{{"Key":"{}"}},"tag":0}}]"#,
amount,
hex::encode(mask.as_bytes()),
hex::encode(nonce.as_bytes()),
hex::encode(vec![0u8; EncryptedData::min_size()]),
hex::encode(spend_pk.as_bytes()),
)
}
#[test]
fn generate_outputs_produces_valid_statement() {
let witnesses = make_witness_json(1000);
let result = generate_stealth_outputs_statement(&witnesses, 0).unwrap();
let stmt: StealthOutputsStatement = serde_json::from_str(&result.statement_json).unwrap();
validate_stealth_outputs_statement(&stmt, None).unwrap();
assert_eq!(result.aggregated_output_mask.len(), 32);
}
#[test]
fn generate_outputs_with_empty_array() {
let result = generate_stealth_outputs_statement("[]", 100).unwrap();
let stmt: StealthOutputsStatement = serde_json::from_str(&result.statement_json).unwrap();
assert!(stmt.outputs.is_empty());
assert!(stmt.agg_range_proof.is_empty());
assert_eq!(result.aggregated_output_mask, vec![0u8; 32]);
}
#[test]
fn generate_extended_bullet_proof_empty() {
let proof = generate_extended_bullet_proof("[]").unwrap();
assert!(proof.is_empty());
}
#[test]
fn generate_extended_bullet_proof_non_empty() {
let mask = RistrettoSecretKey::random(&mut rand::rng());
let nonce = RistrettoPublicKey::from_secret_key(&mask);
let witness_json = format!(
r#"[{{"amount":50,"mask":"{}","sender_public_nonce":"{}","minimum_value_promise":0,"encrypted_data":"{}"}}]"#,
hex::encode(mask.as_bytes()),
hex::encode(nonce.as_bytes()),
hex::encode(vec![0u8; EncryptedData::min_size()]),
);
let proof = generate_extended_bullet_proof(&witness_json).unwrap();
assert!(!proof.is_empty());
}
fn tari_resource() -> String {
tari_template_lib_types::constants::STEALTH_TARI_RESOURCE_ADDRESS.to_string()
}
fn random_public_key() -> RistrettoPublicKey {
RistrettoPublicKey::random_keypair(&mut rand::rng()).1
}
#[test]
fn created_witness_produces_valid_outputs_statement() {
let account_pk = random_public_key();
let view_pk = random_public_key();
let resource = tari_resource();
let json = create_stealth_output_witness(CreateStealthOutputWitnessParams {
network: Network::LocalNet.as_byte(),
destination_account_public_key: account_pk.as_bytes(),
destination_view_public_key: view_pk.as_bytes(),
amount: 1000,
resource_address: &resource,
resource_view_key: None,
memo_json: None,
pay_to_json: None,
minimum_value_promise: 0,
})
.unwrap();
let result = generate_stealth_outputs_statement(&format!("[{json}]"), 0).unwrap();
let stmt: StealthOutputsStatement = serde_json::from_str(&result.statement_json).unwrap();
validate_stealth_outputs_statement(&stmt, None).unwrap();
assert_eq!(stmt.outputs.len(), 1);
assert!(stmt.outputs[0].auth.spend_key().is_some());
assert!(stmt.outputs[0].auth.condition_root().is_none());
}
#[test]
fn created_witness_is_spendable_and_decryptable() {
use tari_crypto::commitment::HomomorphicCommitmentFactory;
use tari_engine_types::crypto::get_commitment_factory;
let network = Network::LocalNet;
let (account_sk, account_pk) = RistrettoPublicKey::random_keypair(&mut rand::rng());
let (view_sk, view_pk) = RistrettoPublicKey::random_keypair(&mut rand::rng());
let amount = 12_345u64;
let resource = tari_resource();
let json = create_stealth_output_witness(CreateStealthOutputWitnessParams {
network: network.as_byte(),
destination_account_public_key: account_pk.as_bytes(),
destination_view_public_key: view_pk.as_bytes(),
amount,
resource_address: &resource,
resource_view_key: None,
memo_json: None,
pay_to_json: None,
minimum_value_promise: 0,
})
.unwrap();
let witness: StealthOutputWitness = serde_json::from_str::<StealthOutputWitnessJson>(&json)
.unwrap()
.try_into()
.unwrap();
let commitment = get_commitment_factory()
.commit_value(&witness.witness.mask, amount)
.to_byte_type();
let encryption_key = crate::stealth::kdfs::encrypted_data_dh_kdf(
view_sk.as_bytes(),
witness.witness.sender_public_nonce.as_bytes(),
)
.unwrap();
let decrypted = crate::stealth::encrypted_data::unblind_output(
commitment.as_bytes(),
witness.witness.encrypted_data.as_bytes(),
&encryption_key,
false,
)
.unwrap();
assert_eq!(decrypted.value, amount);
assert_eq!(decrypted.mask, witness.witness.mask.as_bytes().to_vec());
let stealth_secret_bytes = crate::stealth::kdfs::stealth_dh_secret(
network.as_byte(),
account_sk.as_bytes(),
witness.witness.sender_public_nonce.as_bytes(),
)
.unwrap();
let stealth_secret = RistrettoSecretKey::from_canonical_bytes(&stealth_secret_bytes).unwrap();
let derived_pub = RistrettoPublicKey::from_secret_key(&stealth_secret);
let expected = witness.auth.spend_key().expect("expected a key-path spend_key");
assert_eq!(derived_pub.to_byte_type(), *expected);
}
#[test]
fn created_witness_with_access_rule() {
let account_pk = random_public_key();
let view_pk = random_public_key();
let resource = tari_resource();
let json = create_stealth_output_witness(CreateStealthOutputWitnessParams {
network: Network::LocalNet.as_byte(),
destination_account_public_key: account_pk.as_bytes(),
destination_view_public_key: view_pk.as_bytes(),
amount: 500,
resource_address: &resource,
resource_view_key: None,
memo_json: None,
pay_to_json: Some(r#"{"AccessRule":"AllowAll"}"#),
minimum_value_promise: 0,
})
.unwrap();
let witness: StealthOutputWitnessJson = serde_json::from_str(&json).unwrap();
assert!(witness.auth.spend_key().is_none());
assert!(witness.auth.condition_root().is_some());
let result = generate_stealth_outputs_statement(&format!("[{json}]"), 0).unwrap();
let stmt: StealthOutputsStatement = serde_json::from_str(&result.statement_json).unwrap();
validate_stealth_outputs_statement(&stmt, None).unwrap();
}
#[test]
fn created_witness_with_memo_round_trips() {
use tari_crypto::commitment::HomomorphicCommitmentFactory;
use tari_engine_types::crypto::get_commitment_factory;
let (_, account_pk) = RistrettoPublicKey::random_keypair(&mut rand::rng());
let (view_sk, view_pk) = RistrettoPublicKey::random_keypair(&mut rand::rng());
let amount = 777u64;
let resource = tari_resource();
let json = create_stealth_output_witness(CreateStealthOutputWitnessParams {
network: Network::LocalNet.as_byte(),
destination_account_public_key: account_pk.as_bytes(),
destination_view_public_key: view_pk.as_bytes(),
amount,
resource_address: &resource,
resource_view_key: None,
memo_json: Some(r#"{"Message":"gm"}"#),
pay_to_json: None,
minimum_value_promise: 0,
})
.unwrap();
let witness: StealthOutputWitness = serde_json::from_str::<StealthOutputWitnessJson>(&json)
.unwrap()
.try_into()
.unwrap();
let commitment = get_commitment_factory()
.commit_value(&witness.witness.mask, amount)
.to_byte_type();
let encryption_key = crate::stealth::kdfs::encrypted_data_dh_kdf(
view_sk.as_bytes(),
witness.witness.sender_public_nonce.as_bytes(),
)
.unwrap();
let decrypted = crate::stealth::encrypted_data::unblind_output(
commitment.as_bytes(),
witness.witness.encrypted_data.as_bytes(),
&encryption_key,
false,
)
.unwrap();
assert_eq!(decrypted.memo_json.as_deref(), Some(r#"{"Message":"gm"}"#));
}
#[test]
fn create_witness_rejects_invalid_resource_address() {
let account_pk = random_public_key();
let view_pk = random_public_key();
let err = create_stealth_output_witness(CreateStealthOutputWitnessParams {
network: Network::LocalNet.as_byte(),
destination_account_public_key: account_pk.as_bytes(),
destination_view_public_key: view_pk.as_bytes(),
amount: 1,
resource_address: "not-a-resource",
resource_view_key: None,
memo_json: None,
pay_to_json: None,
minimum_value_promise: 0,
})
.unwrap_err();
assert!(matches!(err, OotleWasmError::InvalidAddress(_)));
}
#[test]
fn create_witness_rejects_minimum_value_promise_above_amount() {
let account_pk = random_public_key();
let view_pk = random_public_key();
let resource = tari_resource();
let err = create_stealth_output_witness(CreateStealthOutputWitnessParams {
network: Network::LocalNet.as_byte(),
destination_account_public_key: account_pk.as_bytes(),
destination_view_public_key: view_pk.as_bytes(),
amount: 100,
resource_address: &resource,
resource_view_key: None,
memo_json: None,
pay_to_json: None,
minimum_value_promise: 101,
})
.unwrap_err();
assert!(matches!(err, OotleWasmError::Stealth(_)));
}
}