Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/ootle_wasm/core/src/stealth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod encrypted_data;
pub mod inputs;
pub mod kdfs;
pub mod outputs;
pub mod transfer;
mod types;
pub mod validate;
pub mod viewable_balance;
Expand Down
256 changes: 256 additions & 0 deletions crates/ootle_wasm/core/src/stealth/transfer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
// Copyright 2026 The Tari Project
// SPDX-License-Identifier: BSD-3-Clause

//! Building a complete stealth transfer statement (inputs statement, outputs statement, balance
//! proof, and covenant claims) from unblinded input/output witnesses.

use tari_ootle_wallet_crypto::stealth::create_transfer_statement as crypto_create_transfer_statement;
use tari_template_lib_types::Amount;

use crate::{
error::OotleWasmError,
stealth::types::{StealthInputWitnessJson, StealthOutputWitnessJson},
};

/// Build a complete `StealthTransferStatement` JSON from unblinded input/output witnesses.
///
/// Unlike `generateStealthOutputsStatement` / `buildStealthInputsStatement(FromInputs)` +
/// `generateStealthBalanceProofSignature`, which build and sign each half of a transfer
/// separately, this wraps `create_transfer_statement` directly -- the single primitive that
/// produces the *entire*, internally-consistent statement in one call, including a covenant
/// balance-integrity proof for any script-path-spent input whose revealed leaf gates on
/// `Covenant::BalancePreserved` (or a `TemplateFunction` calling `SpendContext::covenant_balanced`).
/// A revealed leaf that gates on something else -- a `HashLock`, an `AfterEpoch`/`BeforeEpoch`
/// timelock, an `AccessRule` -- never reads `covenant_claims`, so this call is unneeded for those
/// spends; the separate-calls path above still produces a valid statement for them, just with no
/// (unused) covenant claim attached.
///
/// `input_witnesses_json` / `output_witnesses_json` are JSON arrays of
/// [`StealthInputWitnessJson`] / [`StealthOutputWitnessJson`] (see `stealth::types`) -- each
/// input witness carries its own `witness`/`condition_root` pair (from `buildScriptPathWitness`)
/// for a script-path spend, or neither for a plain key-path spend. A statement mixing key-path
/// and script-path inputs in the same transfer is fully supported -- each input's witness is
/// independent.
pub fn build_stealth_transfer_statement(
input_witnesses_json: &str,
revealed_input_amount_microtari: u64,
output_witnesses_json: &str,
revealed_output_amount_microtari: u64,
) -> Result<String, OotleWasmError> {
let inputs: Vec<StealthInputWitnessJson> = serde_json::from_str(input_witnesses_json)?;
let inputs = inputs
.into_iter()
.map(TryInto::try_into)
.collect::<Result<Vec<_>, OotleWasmError>>()?;

let outputs: Vec<StealthOutputWitnessJson> = serde_json::from_str(output_witnesses_json)?;
let outputs = outputs
.into_iter()
.map(TryInto::try_into)
.collect::<Result<Vec<_>, OotleWasmError>>()?;

let statement = crypto_create_transfer_statement(
inputs,
Amount::from_u64(revealed_input_amount_microtari),
outputs.iter(),
Amount::from_u64(revealed_output_amount_microtari),
)
.map_err(|e| OotleWasmError::Stealth(e.to_string()))?;

Ok(serde_json::to_string(&statement)?)
}

#[cfg(test)]
mod tests {
use ootle_byte_type::ToByteType;
use tari_crypto::{
keys::{PublicKey, SecretKey},
ristretto::{RistrettoPublicKey, RistrettoSecretKey},
tari_utilities::ByteArray,
};
use tari_engine_types::crypto::validate_covenant_balance_proof;
use tari_template_lib_types::{
EncryptedData,
Hash32,
crypto::UtxoTag,
stealth::{Covenant, SpendAuthorization, SpendCondition, StealthTransferStatement},
};

use super::*;
use crate::stealth::inputs::build_script_path_witness;

fn output_witness_json(amount: u64, mask: &RistrettoSecretKey) -> String {
let owner_pk = RistrettoPublicKey::from_secret_key(mask);
serde_json::to_string(&StealthOutputWitnessJson {
witness: crate::stealth::types::OutputWitnessJson {
amount,
mask: hex::encode(mask.as_bytes()),
sender_public_nonce: hex::encode(owner_pk.as_bytes()),
minimum_value_promise: 0,
encrypted_data: EncryptedData::try_from(vec![0; EncryptedData::min_size()]).unwrap(),
resource_view_key: None,
},
auth: SpendAuthorization::Key(owner_pk.to_byte_type()),
tag: UtxoTag::new(0),
})
.unwrap()
}

#[test]
fn key_path_only_round_trips_through_validation() {
let input_mask = RistrettoSecretKey::random(&mut rand::rng());
let output_mask = RistrettoSecretKey::random(&mut rand::rng());

let inputs_json = format!(
r#"[{{"mask_and_value":{{"value":500,"mask":"{}"}}}}]"#,
hex::encode(input_mask.as_bytes())
);
let outputs_json = format!("[{}]", output_witness_json(500, &output_mask));

let statement_json = build_stealth_transfer_statement(&inputs_json, 0, &outputs_json, 0).unwrap();
let statement: StealthTransferStatement = serde_json::from_str(&statement_json).unwrap();
assert!(statement.balance_proof.is_some());
assert!(
statement.covenant_claims.is_empty(),
"a key-path-only transfer needs no covenant claims"
);

crate::stealth::validate::validate_stealth_transfer(&statement_json, None).unwrap();
}

#[test]
fn explicit_key_path_witness_without_condition_root_is_accepted() {
let input_mask = RistrettoSecretKey::random(&mut rand::rng());
let output_mask = RistrettoSecretKey::random(&mut rand::rng());

let inputs_json = format!(
r#"[{{"mask_and_value":{{"value":500,"mask":"{}"}},"witness":"KeyPath"}}]"#,
hex::encode(input_mask.as_bytes())
);
let outputs_json = format!("[{}]", output_witness_json(500, &output_mask));

let statement_json = build_stealth_transfer_statement(&inputs_json, 0, &outputs_json, 0).unwrap();
let statement: StealthTransferStatement = serde_json::from_str(&statement_json).unwrap();
assert!(statement.covenant_claims.is_empty());

crate::stealth::validate::validate_stealth_transfer(&statement_json, None).unwrap();
}

/// A script-path input whose revealed leaf gates on `Covenant::BalancePreserved` is exactly the case
/// `covenant_claims` exists for -- the engine's `SpendScriptExecution::covenant_balanced` reconstructs the same
/// partition from the same statement and calls the same `validate_covenant_balance_proof` primitive this test
/// calls directly, so verifying against it here (rather than only checking the claim's shape) exercises what the
/// engine actually does with the claim.
#[test]
fn script_path_input_produces_a_verifiable_covenant_claim() {
let condition = SpendCondition::covenant(Covenant::BalancePreserved(0));
let conditions = vec![condition.clone()];
let conditions_json = serde_json::to_string(&conditions).unwrap();
let claim_leaf_json = serde_json::to_string(&condition).unwrap();

let witness_result_json = build_script_path_witness(&conditions_json, &claim_leaf_json, &[]).unwrap();
let witness_result: serde_json::Value = serde_json::from_str(&witness_result_json).unwrap();
let condition_root: Hash32 = serde_json::from_value(witness_result["condition_root"].clone()).unwrap();

let input_mask = RistrettoSecretKey::random(&mut rand::rng());
let output_mask = RistrettoSecretKey::random(&mut rand::rng());
let inputs_json = format!(
r#"[{{"mask_and_value":{{"value":500,"mask":"{}"}},"witness":{},"condition_root":{}}}]"#,
hex::encode(input_mask.as_bytes()),
witness_result["witness"],
witness_result["condition_root"]
);

// Re-locks the full 500 under exactly `condition_root`, with no key-path escape, so the partition's value is
// fully conserved -- the case `BalancePreserved(0)` (no cleartext outflow) admits.
let owner_pk = RistrettoPublicKey::from_secret_key(&output_mask);
let outputs_json = format!(
"[{}]",
serde_json::to_string(&StealthOutputWitnessJson {
witness: crate::stealth::types::OutputWitnessJson {
amount: 500,
mask: hex::encode(output_mask.as_bytes()),
sender_public_nonce: hex::encode(owner_pk.as_bytes()),
minimum_value_promise: 0,
encrypted_data: EncryptedData::try_from(vec![0; EncryptedData::min_size()]).unwrap(),
resource_view_key: None,
},
auth: SpendAuthorization::Script(condition_root),
tag: UtxoTag::new(0),
})
.unwrap()
);

let statement_json = build_stealth_transfer_statement(&inputs_json, 0, &outputs_json, 0).unwrap();
let statement: StealthTransferStatement = serde_json::from_str(&statement_json).unwrap();

assert_eq!(statement.covenant_claims.len(), 1);
let claim = &statement.covenant_claims[0];
assert_eq!(claim.partition_input_index, 0);
assert_eq!(claim.revealed_amount, tari_template_lib_types::Amount::zero());

// Reconstruct the partition exactly as `SpendScriptExecution::covenant_balanced` does: every input/output
// commitment sharing `condition_root`, with `KeyAndScript` outputs excluded (they don't stay in the vault).
let input_commitments = vec![statement.inputs_statement.inputs[0].commitment];
let output_commitments: Vec<_> = statement
.outputs_statement
.outputs
.iter()
.filter(|o| matches!(&o.auth, SpendAuthorization::Script(root) if *root == condition_root))
.map(|o| o.output.commitment)
.collect();
assert_eq!(output_commitments.len(), 1);
assert!(
validate_covenant_balance_proof(
&condition_root,
claim.revealed_amount,
&input_commitments,
&output_commitments,
&claim.signature,
),
"the claim must verify against the same primitive the engine's covenant_balanced() calls"
);

// A claim asserting the wrong revealed amount for this partition must not verify -- otherwise the assertions
// above would equally hold for a claim whose signature proves nothing about this partition's balance.
assert!(!validate_covenant_balance_proof(
&condition_root,
tari_template_lib_types::Amount::from_u64(1),
&input_commitments,
&output_commitments,
&claim.signature,
));

crate::stealth::validate::validate_stealth_transfer(&statement_json, None).unwrap();
}

#[test]
fn rejects_script_path_witness_without_condition_root() {
let conditions = vec![SpendCondition::covenant(Covenant::BalancePreserved(0))];
let conditions_json = serde_json::to_string(&conditions).unwrap();
let claim_leaf_json = serde_json::to_string(&conditions[0]).unwrap();
let witness_result_json = build_script_path_witness(&conditions_json, &claim_leaf_json, &[]).unwrap();
let witness_result: serde_json::Value = serde_json::from_str(&witness_result_json).unwrap();

let input_mask = RistrettoSecretKey::random(&mut rand::rng());
let inputs_json = format!(
r#"[{{"mask_and_value":{{"value":500,"mask":"{}"}},"witness":{}}}]"#,
hex::encode(input_mask.as_bytes()),
witness_result["witness"],
);
let err = build_stealth_transfer_statement(&inputs_json, 0, "[]", 500).unwrap_err();
assert!(matches!(err, OotleWasmError::Stealth(_)));
}

#[test]
fn rejects_key_path_witness_with_condition_root() {
let input_mask = RistrettoSecretKey::random(&mut rand::rng());
let inputs_json = format!(
r#"[{{"mask_and_value":{{"value":500,"mask":"{}"}},"witness":"KeyPath","condition_root":"{}"}}]"#,
hex::encode(input_mask.as_bytes()),
hex::encode(Hash32::zero().as_slice())
);
let err = build_stealth_transfer_statement(&inputs_json, 0, "[]", 500).unwrap_err();
assert!(matches!(err, OotleWasmError::Stealth(_)));
}
}
52 changes: 32 additions & 20 deletions crates/ootle_wasm/core/src/stealth/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ use tari_crypto::{
tari_utilities::ByteArray,
};
use tari_ootle_wallet_crypto::{MaskAndValue, OutputWitness, StealthInputWitness, StealthOutputWitness};
use tari_template_lib_types::{EncryptedData, crypto::UtxoTag, stealth::SpendAuthorization};
use tari_template_lib_types::{
EncryptedData,
Hash32,
crypto::UtxoTag,
stealth::{SpendAuthorization, SpendWitness},
};

use crate::{
error::OotleWasmError,
Expand Down Expand Up @@ -122,32 +127,39 @@ pub struct InputWitness {
pub mask: String,
}

/// A stealth input being spent (currently just the unblinded commitment opening).
/// A stealth input being spent: the unblinded commitment opening, plus (for a script-path spend
/// only) the `SpendWitness` revealing one condition-tree leaf and the committed `condition_root`
/// it was revealed against. `witness`/`condition_root` are the exact pair `buildScriptPathWitness`
/// returns -- merge that result's `witness`/`condition_root` fields straight in alongside
/// `mask_and_value` for a script-path input. For a plain key-path spend, either omit `witness`
/// entirely or set it to `"KeyPath"`; `condition_root` must then be absent.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum StealthInputWitnessJson {
/// Object form: `{ "mask_and_value": { "value": 100, "mask": "..." } }`.
Wrapped { mask_and_value: InputWitness },
/// Flat form: `{ "value": 100, "mask": "..." }`.
Flat(InputWitness),
}

impl StealthInputWitnessJson {
fn into_inner(self) -> InputWitness {
match self {
Self::Wrapped { mask_and_value } => mask_and_value,
Self::Flat(w) => w,
}
}
#[serde(deny_unknown_fields)]
pub struct StealthInputWitnessJson {
pub mask_and_value: InputWitness,
#[serde(default)]
pub witness: Option<SpendWitness>,
#[serde(default)]
pub condition_root: Option<Hash32>,
}

impl TryFrom<StealthInputWitnessJson> for StealthInputWitness {
type Error = OotleWasmError;

fn try_from(value: StealthInputWitnessJson) -> Result<Self, Self::Error> {
let inner = value.into_inner();
let mask = decode_secret_key(&inner.mask, "mask")?;
Ok(StealthInputWitness::new(MaskAndValue::new(inner.value, mask)))
let mask = decode_secret_key(&value.mask_and_value.mask, "mask")?;
let mask_and_value = MaskAndValue::new(value.mask_and_value.value, mask);
match (value.witness, value.condition_root) {
(None | Some(SpendWitness::KeyPath), None) => Ok(StealthInputWitness::new(mask_and_value)),
(Some(witness @ SpendWitness::ScriptPath { .. }), Some(condition_root)) => Ok(
StealthInputWitness::with_script_path(mask_and_value, witness, condition_root),
),
_ => Err(OotleWasmError::Stealth(
"a script-path input needs both witness (\"ScriptPath\") and condition_root; a key-path input needs \
neither, or witness \"KeyPath\" alone"
.to_string(),
)),
}
}
}

Expand Down
34 changes: 34 additions & 0 deletions crates/ootle_wasm/wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,40 @@ pub fn build_script_path_witness(conditions_json: &str, leaf_json: &str, data: &
.map_err(|e| JsError::new(&e.to_string()))
}

/// Build a complete `StealthTransferStatement` JSON from unblinded input/output witnesses.
///
/// Unlike `generateStealthOutputsStatement` / `buildStealthInputsStatementFromInputs` +
/// `generateStealthBalanceProofSignature`, which build and sign each half of a transfer
/// separately, this wraps the single primitive that produces the *entire*, internally-consistent
/// statement in one call, including a covenant balance-integrity proof for any script-path input
/// whose revealed leaf gates on `Covenant::BalancePreserved` (see `ootle_wasm_core::stealth::
/// transfer::build_stealth_transfer_statement` for when a claim is and isn't needed).
///
/// `input_witnesses_json` is a JSON array of `{ "mask_and_value": { "value": <u64>, "mask": <hex
/// 32 bytes> }, "witness"?: <SpendWitness>, "condition_root"?: <Hash32> }` -- each entry's
/// `witness`/`condition_root` are the exact pair `buildScriptPathWitness` returns, for a
/// script-path input; omit both for a plain key-path input. Mixing key-path and script-path
/// inputs in one call is supported.
///
/// `output_witnesses_json` is a JSON array of the same `{ "witness": {...}, "auth": ..., "tag":
/// ... }` shape `createStealthOutputWitness` returns -- collect one entry per output (including
/// change).
#[wasm_bindgen(js_name = "buildStealthTransferStatement")]
pub fn build_stealth_transfer_statement(
input_witnesses_json: &str,
revealed_input_amount_microtari: u64,
output_witnesses_json: &str,
revealed_output_amount_microtari: u64,
) -> Result<String, JsError> {
ootle_wasm_core::stealth::transfer::build_stealth_transfer_statement(
input_witnesses_json,
revealed_input_amount_microtari,
output_witnesses_json,
revealed_output_amount_microtari,
)
.map_err(|e| JsError::new(&e.to_string()))
}

/// Aggregate the commitment masks of stealth inputs into a single 32-byte Ristretto scalar.
///
/// `masks_concat` is the concatenated bytes of all input masks (32 bytes per mask, so the input
Expand Down
8 changes: 7 additions & 1 deletion crates/wallet/crypto/src/stealth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,13 @@ fn generate_covenant_claims(
let mut agg_output_mask = RistrettoSecretKey::default();
let mut output_value = Amount::zero();
let mut output_commitments = Vec::new();
for output in outputs.iter().filter(|o| o.auth.condition_root() == Some(&root)) {
// Only `Script(root)` keeps value under the covenant; a `KeyAndScript` output committing the same root is
// still key-spendable next block, so it must not be counted as conserving the partition (matches the
// engine's `StealthOutputView::is_locked_under`).
for output in outputs
.iter()
.filter(|o| matches!(&o.auth, SpendAuthorization::Script(r) if *r == root))
{
agg_output_mask = agg_output_mask + &output.witness.mask;
output_value = output_value
.checked_add(Amount::from_u64(output.witness.amount))
Expand Down
Loading