diff --git a/applications/tari_validator_node/src/bootstrap.rs b/applications/tari_validator_node/src/bootstrap.rs index 9dafa31b63..1af5d1fab4 100644 --- a/applications/tari_validator_node/src/bootstrap.rs +++ b/applications/tari_validator_node/src/bootstrap.rs @@ -81,6 +81,7 @@ use crate::consensus::metrics::PrometheusConsensusMetrics; use crate::{ consensus::{self, ConsensusHandle, TarBlockTransactionExecutor, ValidationContext}, file_l1_submitter::FileLayerOneSubmitter, + genesis_state::create_genesis_state, p2p::{ create_tari_validator_node_rpc_service, services::{ @@ -90,7 +91,6 @@ use crate::{ }, NopLogger, }, - state_bootstrap::bootstrap_state, transaction_validators::{ EpochRangeValidator, FeeTransactionValidator, @@ -218,7 +218,7 @@ pub async fn spawn_services( let state_store = ValidatorNodeStateStore::open(&config.validator_node.state_db_path, DatabaseOptions::default())?; - state_store.with_write_tx(|tx| bootstrap_state(tx, config.network, consensus_constants.num_preshards))?; + state_store.with_write_tx(|tx| create_genesis_state(tx, config.network, consensus_constants.num_preshards))?; info!(target: LOG_TARGET, "Epoch manager initializing"); let epoch_manager_config = EpochManagerConfig { diff --git a/applications/tari_validator_node/src/state_bootstrap.rs b/applications/tari_validator_node/src/genesis_state.rs similarity index 92% rename from applications/tari_validator_node/src/state_bootstrap.rs rename to applications/tari_validator_node/src/genesis_state.rs index f0aa9295d8..011cb5afeb 100644 --- a/applications/tari_validator_node/src/state_bootstrap.rs +++ b/applications/tari_validator_node/src/genesis_state.rs @@ -54,7 +54,11 @@ pub fn has_bootstrapped(tx: &TTx) -> Result(tx: &mut TTx, network: Network, num_preshards: NumPreshards) -> Result<(), StorageError> +pub fn create_genesis_state( + tx: &mut TTx, + network: Network, + num_preshards: NumPreshards, +) -> Result<(), StorageError> where TTx: StateStoreWriteTransaction + Deref, TTx::Target: StateStoreReadTransaction, @@ -94,7 +98,11 @@ where None, None, 6, - true, + // Disable total supply tracking for XTR. This is because it is not feasible to include "the fee exhaust" in + // the tracking (as that would require mutating the resource on every transaction). Tracking supply can + // be done by summing up the total burn claims (ClaimedOutputTombstone) and subtracting the total exhaust in + // fee receipts. + false, ); if is_testnet { diff --git a/applications/tari_validator_node/src/lib.rs b/applications/tari_validator_node/src/lib.rs index 90712b4b08..41f34af135 100644 --- a/applications/tari_validator_node/src/lib.rs +++ b/applications/tari_validator_node/src/lib.rs @@ -25,6 +25,7 @@ mod config; pub mod consensus; mod event_subscription; mod file_l1_submitter; +mod genesis_state; #[cfg(feature = "web_ui")] mod http_ui; mod json_rpc; @@ -32,7 +33,6 @@ mod json_rpc; mod metrics; mod node; mod p2p; -mod state_bootstrap; pub mod transaction_validators; mod validator; diff --git a/bindings/package.json b/bindings/package.json index 1255ddf0d3..b39714e081 100644 --- a/bindings/package.json +++ b/bindings/package.json @@ -1,6 +1,6 @@ { "name": "@tari-project/typescript-bindings", - "version": "1.17.1", + "version": "1.17.2", "description": "TypeScript types synchronized to the Tari Ootle Rust codebase", "homepage": "https://github.com/tari-project/tari-ootle#readme", "bugs": { diff --git a/bindings/src/index.ts b/bindings/src/index.ts index 74a99edbb3..c764d9a92b 100644 --- a/bindings/src/index.ts +++ b/bindings/src/index.ts @@ -28,11 +28,11 @@ export * from "./types/ComponentBody"; export * from "./types/ComponentCall"; export * from "./types/ComponentHeader"; export * from "./types/ComponentKey"; -export * from "./types/CompressedElgamalVerifiableBalance"; export * from "./types/ConfidentialOutputStatement"; export * from "./types/ConfidentialTransferInputSelection"; export * from "./types/ConfidentialWithdrawProof"; export * from "./types/Decision"; +export * from "./types/ElgamalVerifiableBalanceBytes"; export * from "./types/EncodedMerkleProof"; export * from "./types/EncryptedData"; export * from "./types/EntityId"; @@ -110,6 +110,7 @@ export * from "./types/StealthInput"; export * from "./types/StealthOutputsStatement"; export * from "./types/StealthTransferStatement"; export * from "./types/StealthUnspentOutput"; +export * from "./types/StealthValueProof"; export * from "./types/SubstateAddress"; export * from "./types/SubstateCreated"; export * from "./types/SubstateDestroyed"; @@ -157,6 +158,7 @@ export * from "./types/ValidatorFeePoolAddress"; export * from "./types/ValidatorFeePool"; export * from "./types/ValidatorFeeWithdrawal"; export * from "./types/ValidatorSignatureBytes"; +export * from "./types/ValueKnowledgeProof"; export * from "./types/VaultFreezeFlags"; export * from "./types/VaultId"; export * from "./types/Vault"; diff --git a/bindings/src/types/CompressedElgamalVerifiableBalance.ts b/bindings/src/types/ElgamalVerifiableBalanceBytes.ts similarity index 83% rename from bindings/src/types/CompressedElgamalVerifiableBalance.ts rename to bindings/src/types/ElgamalVerifiableBalanceBytes.ts index 8fd664f26b..447bc72612 100644 --- a/bindings/src/types/CompressedElgamalVerifiableBalance.ts +++ b/bindings/src/types/ElgamalVerifiableBalanceBytes.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { RistrettoPublicKeyBytes } from "./RistrettoPublicKeyBytes"; -export type CompressedElgamalVerifiableBalance = { +export type ElgamalVerifiableBalanceBytes = { encrypted: RistrettoPublicKeyBytes; public_nonce: RistrettoPublicKeyBytes; }; diff --git a/bindings/src/types/PrivateOutput.ts b/bindings/src/types/PrivateOutput.ts index f2508274fa..54e9a8adc8 100644 --- a/bindings/src/types/PrivateOutput.ts +++ b/bindings/src/types/PrivateOutput.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { CompressedElgamalVerifiableBalance } from "./CompressedElgamalVerifiableBalance"; +import type { ElgamalVerifiableBalanceBytes } from "./ElgamalVerifiableBalanceBytes"; import type { EncryptedData } from "./EncryptedData"; import type { RistrettoPublicKeyBytes } from "./RistrettoPublicKeyBytes"; @@ -7,5 +7,5 @@ export type PrivateOutput = { public_nonce: RistrettoPublicKeyBytes; encrypted_data: EncryptedData; minimum_value_promise: number | bigint; - viewable_balance: CompressedElgamalVerifiableBalance | null; + viewable_balance: ElgamalVerifiableBalanceBytes | null; }; diff --git a/bindings/src/types/StealthValueProof.ts b/bindings/src/types/StealthValueProof.ts new file mode 100644 index 0000000000..b989052c71 --- /dev/null +++ b/bindings/src/types/StealthValueProof.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Amount } from "./Amount"; +import type { ValueKnowledgeProof } from "./ValueKnowledgeProof"; + +/** + * Proof of knowledge of the opening to a commitment and that the commitment commits to a specific value. + * Currently used when burning UTXOs to allow the total supply to be adjusted. + */ +export type StealthValueProof = { + /** + * The claimed value to prove + */ + value: Amount; + knowledge_proof: ValueKnowledgeProof; +}; diff --git a/bindings/src/types/ValueKnowledgeProof.ts b/bindings/src/types/ValueKnowledgeProof.ts new file mode 100644 index 0000000000..c29622bb22 --- /dev/null +++ b/bindings/src/types/ValueKnowledgeProof.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RistrettoPublicKeyBytes } from "./RistrettoPublicKeyBytes"; +import type { SchnorrSignatureBytes } from "./SchnorrSignatureBytes"; + +export type ValueKnowledgeProof = + | { + Commitment: { + /** + * Signed by C - v.H, where C is the commitment being proven and v is the claimed value + * Proving knowledge of the opening to C, and that the commitment C = m.G + v.H + */ + mask_knowledge_proof: SchnorrSignatureBytes; + }; + } + | { + ElgamalEncrypted: { + /** + * The R.p term of the ElGamal encryption. This allows validators to check the provided value is correct using + * the viewable balance. This assumes that the verifiable proof was originally validated correctly. + */ + reveal_key: RistrettoPublicKeyBytes; + }; + }; diff --git a/bindings/src/types/ViewableBalanceProof.ts b/bindings/src/types/ViewableBalanceProof.ts index f0e127e484..949edb9e1e 100644 --- a/bindings/src/types/ViewableBalanceProof.ts +++ b/bindings/src/types/ViewableBalanceProof.ts @@ -27,7 +27,8 @@ import type { Scalar32Bytes } from "./Scalar32Bytes"; export type ViewableBalanceProof = { /** * The encrypted value that takes the form: E = v.G + r.P - * where v is the value, G is the generator, r is the secret_nonce and P is the view key + * where v is the value, G is the generator, r is the secret_nonce and P is the view key. + * The value is decrypted by brute forcing E - R.p = v.G */ elgamal_encrypted: RistrettoPublicKeyBytes; /** diff --git a/crates/common_types/src/engine_signature.rs b/crates/common_types/src/engine_signature.rs index 92eb78c408..f149834681 100644 --- a/crates/common_types/src/engine_signature.rs +++ b/crates/common_types/src/engine_signature.rs @@ -59,6 +59,10 @@ impl Verifier for RistrettoSchnorrBlake2bVerifier { return false; }; + if sig.public_nonce().is_zero() { + return false; + } + let Ok(sig) = RistrettoSchnorr::convert_from_byte_type(sig) else { return false; }; diff --git a/crates/engine/src/runtime/impl.rs b/crates/engine/src/runtime/impl.rs index a3706e252b..119c463c58 100644 --- a/crates/engine/src/runtime/impl.rs +++ b/crates/engine/src/runtime/impl.rs @@ -40,6 +40,7 @@ use tari_engine_types::{ published_template::{PublishedTemplate, PublishedTemplateAddress}, resource::Resource, resource_container::{ResourceContainer, ResourceError}, + stealth, substate::{SubstateId, SubstateValue}, vault::Vault, Utxo, @@ -56,6 +57,7 @@ use tari_template_lib::{ BucketAction, BucketRef, BuiltinTemplateAction, + BurnStealthUtxoArg, CallAction, CallFunctionArg, CallMethodArg, @@ -959,14 +961,6 @@ impl> RuntimeInte let resource_lock = state_mut.read_lock_substate(&SubstateId::Resource(resource_address))?; let resource = state_mut.get_resource(&resource_lock)?; - if resource.resource_type().is_stealth() { - return Err(ResourceError::OperationNotAllowed(format!( - "Cannot recall stealth resources: {}", - resource_address - )) - .into()); - } - state_mut.authorization().check_resource_access_rules( ResourceAuthAction::Recall, resource.as_ownership(), @@ -1222,6 +1216,13 @@ impl> RuntimeInte })?; let arg: SetFreezeStealthUtxosArg = args.assert_one_arg()?; + if arg.utxos.is_empty() { + return Err(RuntimeError::InvalidArgument { + argument: "SetFreezeStealthUtxosArg", + reason: "Utxos list cannot be empty".to_string(), + }); + } + self.tracker.write_with(|state_mut| { let resource_lock = state_mut.read_lock_substate(&SubstateId::Resource(resource_address))?; @@ -1234,13 +1235,6 @@ impl> RuntimeInte }); } - if arg.utxos.is_empty() { - return Err(RuntimeError::InvalidArgument { - argument: "SetFreezeStealthUtxosArg", - reason: "Utxos list cannot be empty".to_string(), - }); - } - state_mut.authorization().check_resource_access_rules( ResourceAuthAction::Freeze, resource.as_ownership(), @@ -1251,7 +1245,7 @@ impl> RuntimeInte let id = SubstateId::Utxo(UtxoAddress::new(resource_address, utxo)); let locked = state_mut.write_lock_substate(&id)?; - let utxo = state_mut + let utxo_mut = state_mut .get_locked_substate_mut(&locked)? .as_utxo_mut() .ok_or_else(|| RuntimeError::LockSubstateMismatch { @@ -1262,15 +1256,98 @@ impl> RuntimeInte // Freeze is idempotent. if arg.freeze { - utxo.freeze(); + utxo_mut.freeze(); } else { - utxo.unfreeze(); + utxo_mut.unfreeze(); } state_mut.unlock_substate(locked)?; } state_mut.unlock_substate(resource_lock)?; + Ok(InvokeResult::unit()) + }) + }, + ResourceAction::StealthUtxoBurn => { + let resource_address = + resource_ref + .as_resource_address() + .ok_or_else(|| RuntimeError::InvalidArgument { + argument: "resource_ref", + reason: "BurnStealthUtxo resource action requires a resource address".to_string(), + })?; + let arg: BurnStealthUtxoArg = args.assert_one_arg()?; + + self.tracker.write_with(|state_mut| { + let resource_lock = state_mut.read_lock_substate(&SubstateId::Resource(resource_address))?; + + let resource = state_mut.get_resource(&resource_lock)?; + + if !resource.resource_type().is_stealth() { + return Err(RuntimeError::InvalidArgument { + argument: "resource_ref", + reason: "FreezeStealthUtxo can only be called on stealth resources".to_string(), + }); + } + + let is_total_supply_tracking_enabled = resource.is_supply_tracking_enabled(); + if is_total_supply_tracking_enabled && arg.value_proof.is_none() { + return Err(RuntimeError::InvalidArgument { + argument: "BurnStealthUtxoArg", + reason: "Burning from a total supply tracking resource requires a value proof".to_string(), + }); + } + + state_mut.authorization().check_resource_access_rules( + ResourceAuthAction::Burn, + resource.as_ownership(), + resource.access_rules(), + )?; + + state_mut.unlock_substate(resource_lock)?; + + let id = SubstateId::Utxo(UtxoAddress::new(resource_address, arg.utxo_id)); + let utxo_lock = state_mut.write_lock_substate(&id)?; + + let utxo_mut = state_mut + .get_locked_substate_mut(&utxo_lock)? + .as_utxo_mut() + .ok_or_else(|| RuntimeError::LockSubstateMismatch { + lock_id: utxo_lock.lock_id(), + expected_type: "Utxo", + id, + })?; + + if utxo_mut.is_burnt() { + return Err(RuntimeError::ResourceError(ResourceError::UtxoBurnFailed { + id: arg.utxo_id, + details: "already burnt".to_string(), + })); + } + + utxo_mut.burn(); + + if is_total_supply_tracking_enabled { + let value_proof = arg.value_proof.as_ref().expect( + "BUG: is_total_supply_tracking_enabled is true and value proof is some has been checked", + ); + let commitment = arg.utxo_id.into_commitment_bytes(); + let elgamal_proof = utxo_mut + .output + .as_ref() + .and_then(|o| o.output.viewable_balance.as_ref()); + let value = stealth::validate_value_proof(&commitment, elgamal_proof, value_proof)?; + if value.is_positive() { + let resource_lock = + state_mut.write_lock_substate(&SubstateId::Resource(resource_address))?; + let resource_mut = state_mut.get_resource_mut(&resource_lock)?; + resource_mut.decrease_total_supply(value); + state_mut.unlock_substate(resource_lock)?; + } + } + + state_mut.unlock_substate(utxo_lock)?; + Ok(InvokeResult::unit()) }) }, @@ -2392,22 +2469,6 @@ impl> RuntimeInte state_mut.new_substate(address, utxo)?; - // 4. Update the total supply - { - let resource_lock = state_mut.write_lock_substate(&SubstateId::Resource(XTR))?; - let resource_mut = state_mut.get_resource_mut(&resource_lock)?; - if !resource_mut.increase_total_supply(claim.value.into()) { - return Err(RuntimeError::ResourceSupplyWouldOverflow { - resource_address: XTR, - current_supply: resource_mut - .total_supply() - .expect("Resource supply tracking is enabled"), - amount: claim.value.into(), - }); - } - state_mut.unlock_substate(resource_lock)?; - } - Ok::<_, RuntimeError>(()) })?; diff --git a/crates/engine/src/runtime/working_state.rs b/crates/engine/src/runtime/working_state.rs index 0caec2c66a..7543d8ecc3 100644 --- a/crates/engine/src/runtime/working_state.rs +++ b/crates/engine/src/runtime/working_state.rs @@ -708,7 +708,7 @@ impl WorkingState { }); } - if !vault_mut.resource_type().is_fungible() { + if !vault_mut.resource_type().is_fungible() && !vault_mut.resource_type().is_stealth() { return Err(RuntimeError::InvalidArgument { argument: "resource", reason: format!( diff --git a/crates/engine/tests/burn.rs b/crates/engine/tests/burn.rs new file mode 100644 index 0000000000..14e417ff96 --- /dev/null +++ b/crates/engine/tests/burn.rs @@ -0,0 +1,86 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use tari_template_lib::{prelude::ComponentAddress, types::Amount}; +use tari_template_test_tooling::{support::confidential::generate_confidential_output_statement, TemplateTest}; +use tari_transaction::{args, Transaction}; + +#[test] +fn it_burns_all_resource_types() { + let mut test = TemplateTest::new(["tests/templates/burn"]); + let recall_template = test.get_template_address("Burn"); + + let (mut initial_supply, _mask, _) = generate_confidential_output_statement(Amount::from(1000), None); + initial_supply.output_revealed_amount = Amount::from(1000); + + let result = test.execute_expect_success( + Transaction::builder() + .call_function(recall_template, "new", args![initial_supply]) + .build_and_seal(test.secret_key()), + vec![], + ); + + let component: ComponentAddress = result.finalize.execution_results[0].decode().unwrap(); + + let fungible_vault = test.extract_component_value(component, "$.fungible"); + let non_fungible_vault = test.extract_component_value(component, "$.non_fungible"); + let confidential_vault = test.extract_component_value(component, "$.confidential"); + let stealth_vault = test.extract_component_value(component, "$.stealth"); + + { + let store = test.read_only_state_store(); + let fungible_resource = *store.get_vault(&fungible_vault).unwrap().resource_address(); + let resource = store.get_resource(&fungible_resource).unwrap(); + assert_eq!(resource.total_supply(), Some(Amount::from(1_000_000))); + + let non_fungible_resource = *store.get_vault(&non_fungible_vault).unwrap().resource_address(); + let resource = store.get_resource(&non_fungible_resource).unwrap(); + assert_eq!(resource.total_supply(), Some(Amount::from(10))); + + let confidential_resource = *store.get_vault(&confidential_vault).unwrap().resource_address(); + let resource = store.get_resource(&confidential_resource).unwrap(); + assert_eq!(resource.total_supply(), Some(Amount::from(1000))); + + let stealth_resource = *store.get_vault(&stealth_vault).unwrap().resource_address(); + let resource = store.get_resource(&stealth_resource).unwrap(); + assert_eq!(resource.total_supply(), Some(Amount::from(1_000_000))); + } + + test.execute_expect_success( + Transaction::builder() + .call_method(component, "burn_all", args![]) + .build_and_seal(test.secret_key()), + vec![], + ); + + let vault = test.read_only_state_store().get_vault(&fungible_vault).unwrap(); + assert_eq!(vault.balance(), 0); + + let vault = test.read_only_state_store().get_vault(&non_fungible_vault).unwrap(); + assert_eq!(vault.balance(), 0); + + let vault = test.read_only_state_store().get_vault(&confidential_vault).unwrap(); + assert_eq!(vault.balance(), 0); + + let vault = test.read_only_state_store().get_vault(&stealth_vault).unwrap(); + assert_eq!(vault.balance(), 0); + + { + let store = test.read_only_state_store(); + let fungible_resource = *store.get_vault(&fungible_vault).unwrap().resource_address(); + let resource = store.get_resource(&fungible_resource).unwrap(); + assert_eq!(resource.total_supply(), Some(Amount::ZERO)); + + let non_fungible_resource = *store.get_vault(&non_fungible_vault).unwrap().resource_address(); + let resource = store.get_resource(&non_fungible_resource).unwrap(); + assert_eq!(resource.total_supply(), Some(Amount::ZERO)); + + let confidential_resource = *store.get_vault(&confidential_vault).unwrap().resource_address(); + let resource = store.get_resource(&confidential_resource).unwrap(); + assert_eq!(resource.total_supply(), Some(Amount::ZERO)); + + let stealth_resource = *store.get_vault(&stealth_vault).unwrap().resource_address(); + let resource = store.get_resource(&stealth_resource).unwrap(); + assert_eq!(resource.total_supply(), Some(Amount::ZERO)); + } +} diff --git a/crates/engine/tests/confidential.rs b/crates/engine/tests/confidential.rs index 02e714a54f..0b11bb3803 100644 --- a/crates/engine/tests/confidential.rs +++ b/crates/engine/tests/confidential.rs @@ -69,11 +69,14 @@ fn setup( #[test] fn mint_initial_commitment() { let (confidential_proof, _mask, _change) = generate_confidential_output_statement(Amount::from(100), None); - let (mut template_test, faucet, _faucet_resx) = setup(confidential_proof, None); + let (test, _faucet, faucet_resx) = setup(confidential_proof, None); - let total_supply: Option = template_test.call_method(faucet, "total_supply", call_args![], vec![]); - // Total supply cannot be tracked for confidential resources - assert!(total_supply.is_none()); + let resource = test + .read_only_state_store() + .get_resource(&faucet_resx.as_resource_address().unwrap()) + .unwrap(); + // TODO: confidential total_supply tracking only tracks revealed funds + assert_eq!(resource.total_supply(), Some(Amount::from(0))); } #[test] diff --git a/crates/engine/tests/recall.rs b/crates/engine/tests/recall.rs index 4aff6e80e9..c99253c96c 100644 --- a/crates/engine/tests/recall.rs +++ b/crates/engine/tests/recall.rs @@ -37,6 +37,7 @@ fn it_recalls_all_resource_types() { result.finalize.execution_results[0].get_value("$.2").unwrap().unwrap(); let confidential_resource: ResourceAddress = result.finalize.execution_results[0].get_value("$.3").unwrap().unwrap(); + let stealth_resource: ResourceAddress = result.finalize.execution_results[0].get_value("$.4").unwrap().unwrap(); let withdraw = generate_withdraw_proof(&mask, 10, Some(980), 10); test.execute_expect_success( @@ -46,6 +47,7 @@ fn it_recalls_all_resource_types() { .call_method(account, "deposit", args![Workspace("buckets.0")]) .call_method(account, "deposit", args![Workspace("buckets.1")]) .call_method(account, "deposit", args![Workspace("buckets.2")]) + .call_method(account, "deposit", args![Workspace("buckets.3")]) .build_and_seal(test.secret_key()), vec![], ); @@ -54,13 +56,14 @@ fn it_recalls_all_resource_types() { let fungible_vault = vaults[&fungible_resource]; let non_fungible_vault = vaults[&non_fungible_resource]; let confidential_vault = vaults[&confidential_resource]; + let stealth_vault = vaults[&stealth_resource]; let commitment = withdraw .to_commitment_for_output(Amount::from(10)) .unwrap() .to_byte_type(); - let result = test.execute_expect_success( + test.execute_expect_success( Transaction::builder() .call_method(recall_component, "recall_fungible", args![fungible_vault, Amount(6)]) .call_method(recall_component, "recall_non_fungibles", args![non_fungible_vault, [ @@ -71,20 +74,24 @@ fn it_recalls_all_resource_types() { [commitment], Amount(4) ]) - .call_method(recall_component, "get_balances", args![]) - .call_method(account, "balance", args![fungible_resource]) - .call_method(account, "balance", args![non_fungible_resource]) - .call_method(account, "balance", args![confidential_resource]) + .call_method(recall_component, "recall_stealth", args![stealth_vault, Amount(8)]) .build_and_seal(test.secret_key()), vec![], ); - let fungible_balance = result.finalize.execution_results[4].decode::().unwrap(); + let vault = test.read_only_state_store().get_vault(&fungible_vault).unwrap(); + let fungible_balance = vault.balance(); assert_eq!(fungible_balance, Amount::from(4)); - let non_fungible_balance = result.finalize.execution_results[5].decode::().unwrap(); + let vault = test.read_only_state_store().get_vault(&non_fungible_vault).unwrap(); + let non_fungible_balance = vault.balance(); assert_eq!(non_fungible_balance, Amount::from(1)); - let confidential_balance = result.finalize.execution_results[6].decode::().unwrap(); + let vault = test.read_only_state_store().get_vault(&confidential_vault).unwrap(); + let confidential_balance = vault.balance(); assert_eq!(confidential_balance, Amount::from(6)); + + let vault = test.read_only_state_store().get_vault(&stealth_vault).unwrap(); + let stealth_balance = vault.balance(); + assert_eq!(stealth_balance, Amount::from(2)); } diff --git a/crates/engine/tests/stealth.rs b/crates/engine/tests/stealth.rs index 44deaf535e..64c8644ca6 100644 --- a/crates/engine/tests/stealth.rs +++ b/crates/engine/tests/stealth.rs @@ -5,7 +5,11 @@ use std::collections::BTreeMap; use rand::rngs::OsRng; use tari_common_types::types::PrivateKey; -use tari_crypto::{commitment::HomomorphicCommitmentFactory, keys::PublicKey, ristretto::RistrettoPublicKey}; +use tari_crypto::{ + commitment::HomomorphicCommitmentFactory, + keys::{PublicKey, SecretKey}, + ristretto::{RistrettoPublicKey, RistrettoSecretKey}, +}; use tari_engine_types::{ crypto::{get_commitment_factory, ElgamalVerifiableBalance, ValueLookupTable}, resource_container::ResourceError, @@ -14,7 +18,7 @@ use tari_engine_types::{ }; use tari_ootle_common_types::substate_type::SubstateType; use tari_template_lib::{ - models::{ComponentAddress, ResourceAddress, UtxoId}, + models::{ComponentAddress, ResourceAddress, UtxoAddress, UtxoId}, prelude::{PedersenCommitmentBytes, SchnorrSignatureBytes}, }; use tari_template_test_tooling::{ @@ -289,7 +293,12 @@ fn transfer_invalid_ownership_proof() { 0, ); // Set an invalid ownership proof - transfer_from_faucet.statement.inputs_statement.inputs[0].owner_proof = SchnorrSignatureBytes::zero(); + let sig = transfer_from_faucet.statement.inputs_statement.inputs[0].owner_proof; + let sig = SchnorrSignatureBytes::new( + RistrettoPublicKey::from_secret_key(&RistrettoSecretKey::random(&mut OsRng)).to_byte_type(), + *sig.signature(), + ); + transfer_from_faucet.statement.inputs_statement.inputs[0].owner_proof = sig; let reason = test.execute_expect_failure( Transaction::builder() @@ -530,3 +539,62 @@ fn freeze_then_attempt_spend() { assert_eq!(utxos.len(), 1); assert!(utxos[0].output().is_some()); } + +#[test] +fn burn_then_attempt_spend() { + let outputs = vec![100u64, 1000, 10000]; + let mint = stealth::generate_mint_statement(outputs.clone(), 0, None); + let (mut test, faucet, faucet_resx) = setup(&mint, None); + + let transfer = stealth::generate_transfer_data( + &[ + MaskAndValue { + mask: mint.output_masks[0].clone(), + value: outputs[0].into(), + }, + MaskAndValue { + mask: mint.output_masks[1].clone(), + value: outputs[1].into(), + }, + ], + 0, + Some(outputs[0] + outputs[1]), + 0, + ); + let owner = test.owner_proof(); + let utxos_and_proofs = mint.output_masks + .iter() + .zip(outputs) + .take(2) // Freeze the first two outputs + .map(|(mask, amount)| { + let commitment = get_commitment_factory().commit_value(mask, amount); + let utxo_id = UtxoId::from(commitment.to_byte_type()); + let proof = stealth::generate_value_proof_mask_knowledge(amount.into(), mask); + (utxo_id, proof) + }) + .collect::>(); + + test.execute_expect_success( + Transaction::builder() + .call_method(faucet, "burn_utxos", args![utxos_and_proofs.clone()]) + .build_and_seal(test.secret_key()), + vec![owner.clone()], + ); + + // Try and spend a burnt outputs + let reason = test.execute_expect_failure( + Transaction::builder() + .stealth_transfer(faucet_resx, transfer.statement.clone()) + .build_and_seal(test.secret_key()), + vec![], + ); + + assert_reject_reason(reason, ResourceError::InvalidSpend { details: String::new() }); + for (utxo_id, _) in utxos_and_proofs { + let utxo = test + .read_only_state_store() + .get_utxo(UtxoAddress::new(faucet_resx, utxo_id)) + .unwrap(); + assert!(utxo.is_burnt()); + } +} diff --git a/crates/engine/tests/templates/burn/Cargo.toml b/crates/engine/tests/templates/burn/Cargo.toml new file mode 100644 index 0000000000..d8e236a8c1 --- /dev/null +++ b/crates/engine/tests/templates/burn/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +[package] +name = "burn" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +tari_template_lib = { path = "../../../../template_lib" } + +[lib] +crate-type = ["cdylib", "lib"] diff --git a/crates/engine/tests/templates/burn/src/lib.rs b/crates/engine/tests/templates/burn/src/lib.rs new file mode 100644 index 0000000000..befeb7bf41 --- /dev/null +++ b/crates/engine/tests/templates/burn/src/lib.rs @@ -0,0 +1,57 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use tari_template_lib::prelude::*; + +#[template] +mod template { + + use super::*; + + pub struct Burn { + fungible: Vault, + non_fungible: Vault, + confidential: Vault, + stealth: Vault, + } + + impl Burn { + pub fn new(confidential_supply: ConfidentialOutputStatement) -> Component { + let fungible = ResourceBuilder::fungible() + .burnable(rule!(allow_all)) + .initial_supply(1_000_000); + + let non_fungible = ResourceBuilder::non_fungible() + .burnable(rule!(allow_all)) + .initial_supply((1..=10).map(NonFungibleId::from_u32)); + + let confidential = ResourceBuilder::confidential() + .burnable(rule!(allow_all)) + .initial_supply(confidential_supply); + + let stealth = ResourceBuilder::stealth() + .burnable(rule!(allow_all)) + .initial_supply(1_000_000); + + Component::new(Self { + fungible: Vault::from_bucket(fungible), + non_fungible: Vault::from_bucket(non_fungible), + confidential: Vault::from_bucket(confidential), + stealth: Vault::from_bucket(stealth), + }) + .with_access_rules(AccessRules::allow_all()) + .create() + } + + pub fn burn_all(&mut self) { + let bucket = self.fungible.withdraw_all(); + bucket.burn(); + let bucket = self.stealth.withdraw_all(); + bucket.burn(); + let bucket = self.non_fungible.withdraw_all(); + bucket.burn(); + let bucket = self.confidential.withdraw_all(); + bucket.burn(); + } + } +} diff --git a/crates/engine/tests/templates/nft/basic_nft/src/lib.rs b/crates/engine/tests/templates/nft/basic_nft/src/lib.rs index 9960e8fc38..bc959feeb1 100644 --- a/crates/engine/tests/templates/nft/basic_nft/src/lib.rs +++ b/crates/engine/tests/templates/nft/basic_nft/src/lib.rs @@ -44,7 +44,8 @@ mod sparkle_nft_template { NonFungibleId::from_string("Sparkle1"), NonFungibleId::from_u256([0u8; 32]), ]; - let bucket = ResourceBuilder::non_fungible().with_token_symbol("SPKL") + let bucket = ResourceBuilder::non_fungible() + .with_token_symbol("SPKL") // Allow minting and burning for tests .mintable(rule!(allow_all)) .burnable(rule!(allow_all)) diff --git a/crates/engine/tests/templates/recall/src/lib.rs b/crates/engine/tests/templates/recall/src/lib.rs index c778bfcb49..d9a2075de6 100644 --- a/crates/engine/tests/templates/recall/src/lib.rs +++ b/crates/engine/tests/templates/recall/src/lib.rs @@ -12,12 +12,19 @@ mod template { fungible: Vault, non_fungible: Vault, confidential: Vault, + stealth: Vault, } impl Recall { pub fn new( confidential_supply: ConfidentialOutputStatement, - ) -> (Component, ResourceAddress, ResourceAddress, ResourceAddress) { + ) -> ( + Component, + ResourceAddress, + ResourceAddress, + ResourceAddress, + ResourceAddress, + ) { let fungible = ResourceBuilder::fungible() .recallable(rule!(allow_all)) .initial_supply(1_000_000); @@ -34,10 +41,16 @@ mod template { .initial_supply(confidential_supply); let confidential_resource = confidential.resource_address(); + let stealth = ResourceBuilder::stealth() + .recallable(rule!(allow_all)) + .initial_supply(1_000_000); + let stealth_resource = stealth.resource_address(); + let component = Component::new(Self { fungible: Vault::from_bucket(fungible), non_fungible: Vault::from_bucket(non_fungible), confidential: Vault::from_bucket(confidential), + stealth: Vault::from_bucket(stealth), }) .with_access_rules(AccessRules::allow_all()) .create(); @@ -47,20 +60,22 @@ mod template { fungible_resource, non_fungible_resource, confidential_resource, + stealth_resource, ) } - pub fn withdraw_some(&mut self, confidential: ConfidentialWithdrawProof) -> (Bucket, Bucket, Bucket) { + pub fn withdraw_some(&mut self, confidential: ConfidentialWithdrawProof) -> (Bucket, Bucket, Bucket, Bucket) { let fungible = self.fungible.withdraw(10); let non_fungible = self .non_fungible .withdraw_non_fungibles([NonFungibleId::from_u32(1), NonFungibleId::from_u32(2)]); let confidential = self.confidential.withdraw_confidential(confidential); - (fungible, non_fungible, confidential) + let stealth = self.stealth.withdraw(10); + (fungible, non_fungible, confidential, stealth) } - pub fn recall_fungible_all(&mut self, vault_id: VaultId) { - let bucket = ResourceManager::get(self.fungible.resource_address()).recall_fungible_all(vault_id); + pub fn recall_all(&mut self, vault_id: VaultId) { + let bucket = ResourceManager::get(self.fungible.resource_address()).recall_all(vault_id); match bucket.resource_type() { ResourceType::Fungible => { self.fungible.deposit(bucket); @@ -72,7 +87,7 @@ mod template { self.confidential.deposit(bucket); }, ResourceType::Stealth => { - panic!("Stealth resources cannot be recalled"); + self.stealth.deposit(bucket); }, } } @@ -103,12 +118,10 @@ mod template { self.confidential.deposit(bucket); } - pub fn get_balances(&self) -> (Amount, Amount, Amount) { - ( - self.fungible.balance(), - self.non_fungible.balance(), - self.confidential.balance(), - ) + pub fn recall_stealth(&mut self, vault_id: VaultId, revealed_amount: Amount) { + let bucket = + ResourceManager::get(self.stealth.resource_address()).recall_fungible_amount(vault_id, revealed_amount); + self.stealth.deposit(bucket); } } } diff --git a/crates/engine/tests/templates/stealth/src/lib.rs b/crates/engine/tests/templates/stealth/src/lib.rs index 5b03e1a9a8..aec6dd581c 100644 --- a/crates/engine/tests/templates/stealth/src/lib.rs +++ b/crates/engine/tests/templates/stealth/src/lib.rs @@ -5,6 +5,8 @@ use tari_template_lib::prelude::*; #[template] mod template { + use tari_template_lib::prelude::crypto::StealthValueProof; + use super::*; pub struct StealthFaucet { @@ -78,5 +80,11 @@ mod template { pub fn unfreeze_utxos(&self, utxos: Vec) { self.manager.unfreeze_utxos(utxos); } + + pub fn burn_utxos(&self, utxos: Vec<(UtxoId, StealthValueProof)>) { + for (utxo, proof) in utxos { + self.manager.burn_utxo(utxo, Some(proof)); + } + } } } diff --git a/crates/engine_types/src/crypto/elgamal.rs b/crates/engine_types/src/crypto/elgamal.rs index 057dd5c00d..47583a707a 100644 --- a/crates/engine_types/src/crypto/elgamal.rs +++ b/crates/engine_types/src/crypto/elgamal.rs @@ -48,6 +48,12 @@ pub fn validate_elgamal_verifiable_balance_proof( } })?; + if proof.elgamal_public_nonce.is_zero() { + return Err(ResourceError::InvalidConfidentialProof { + details: "Public nonce for ElGamal encryption cannot be the identity point".to_string(), + }); + } + let elgamal_public_nonce = RistrettoPublicKey::from_canonical_bytes(&*proof.elgamal_public_nonce).map_err(|_| { ResourceError::InvalidConfidentialProof { @@ -129,15 +135,15 @@ pub fn validate_elgamal_verifiable_balance_proof( #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, BorshSerialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct CompressedElgamalVerifiableBalance { +pub struct ElgamalVerifiableBalanceBytes { pub encrypted: RistrettoPublicKeyBytes, pub public_nonce: RistrettoPublicKeyBytes, } -impl ConvertFromByteType for ElgamalVerifiableBalance { +impl ConvertFromByteType for ElgamalVerifiableBalance { type Error = tari_utilities::ByteArrayError; - fn convert_from_byte_type(bytes: &CompressedElgamalVerifiableBalance) -> Result { + fn convert_from_byte_type(bytes: &ElgamalVerifiableBalanceBytes) -> Result { let encrypted = RistrettoPublicKey::convert_from_byte_type(&bytes.encrypted)?; let public_nonce = RistrettoPublicKey::convert_from_byte_type(&bytes.public_nonce)?; Ok(ElgamalVerifiableBalance { @@ -147,13 +153,13 @@ impl ConvertFromByteType for ElgamalVerifiab } } -impl From for CompressedElgamalVerifiableBalance { +impl From for ElgamalVerifiableBalanceBytes { fn from(value: ElgamalVerifiableBalance) -> Self { (&value).into() } } -impl From<&ElgamalVerifiableBalance> for CompressedElgamalVerifiableBalance { +impl From<&ElgamalVerifiableBalance> for ElgamalVerifiableBalanceBytes { fn from(value: &ElgamalVerifiableBalance) -> Self { Self { encrypted: value.encrypted.to_byte_type(), @@ -226,10 +232,10 @@ impl ElgamalVerifiableBalance { } } -impl TryFrom<&CompressedElgamalVerifiableBalance> for ElgamalVerifiableBalance { +impl TryFrom<&ElgamalVerifiableBalanceBytes> for ElgamalVerifiableBalance { type Error = tari_utilities::ByteArrayError; - fn try_from(value: &CompressedElgamalVerifiableBalance) -> Result { + fn try_from(value: &ElgamalVerifiableBalanceBytes) -> Result { let encrypted = RistrettoPublicKey::convert_from_byte_type(&value.encrypted)?; let public_nonce = RistrettoPublicKey::convert_from_byte_type(&value.public_nonce)?; Ok(ElgamalVerifiableBalance { @@ -240,10 +246,10 @@ impl TryFrom<&CompressedElgamalVerifiableBalance> for ElgamalVerifiableBalance { } impl ToByteType for ElgamalVerifiableBalance { - type ByteType = CompressedElgamalVerifiableBalance; + type ByteType = ElgamalVerifiableBalanceBytes; fn to_byte_type(&self) -> Self::ByteType { - CompressedElgamalVerifiableBalance { + ElgamalVerifiableBalanceBytes { encrypted: self.encrypted.to_byte_type(), public_nonce: self.public_nonce.to_byte_type(), } diff --git a/crates/engine_types/src/crypto/helpers.rs b/crates/engine_types/src/crypto/helpers.rs index 65ad4c38d5..1d5266a6c4 100644 --- a/crates/engine_types/src/crypto/helpers.rs +++ b/crates/engine_types/src/crypto/helpers.rs @@ -2,20 +2,15 @@ // SPDX-License-Identifier: BSD-3-Clause use lazy_static::lazy_static; -use tari_common_types::types::{CommitmentFactory, PrivateKey}; +use tari_common_types::types::CommitmentFactory; use tari_crypto::{ commitment::HomomorphicCommitmentFactory, - ristretto::{ - bulletproofs_plus::BulletproofsPlusService, - pedersen::PedersenCommitment, - RistrettoPublicKey, - RistrettoSecretKey, - }, + ristretto::{bulletproofs_plus::BulletproofsPlusService, pedersen::PedersenCommitment, RistrettoSecretKey}, tari_utilities::ByteArray, }; use tari_template_lib::{prelude::SchnorrSignatureBytes, types::Amount}; -use crate::{hashing::EngineSchnorrSignature, ConvertFromByteType}; +use crate::{hashing::EngineSchnorrSignature, FromByteType}; // TODO RistrettoSecretKey should provide a constant ZERO pub const ZERO_SECRET_KEY: RistrettoSecretKey = unsafe { std::mem::transmute([0u8; 32]) }; @@ -59,6 +54,7 @@ pub fn get_commitment_factory() -> &'static CommitmentFactory { /// Creates a Pedersen commitment to the given amount using the provided mask. /// /// # Panics +/// /// Panics if the amount is not positive. pub fn commit_amount(mask: &RistrettoSecretKey, amount: Amount) -> PedersenCommitment { commit_amount_checked(mask, amount).expect("commitment amount is negative") @@ -67,6 +63,7 @@ pub fn commit_amount(mask: &RistrettoSecretKey, amount: Amount) -> PedersenCommi /// Creates a Pedersen commitment to the given amount using the provided mask. /// /// # Returns +/// /// Returns `None` if the amount is negative, otherwise returns a `PedersenCommitment`. pub fn commit_amount_checked(mask: &RistrettoSecretKey, amount: Amount) -> Option { let v = convert_amount_to_secret(&amount)?; @@ -74,7 +71,9 @@ pub fn commit_amount_checked(mask: &RistrettoSecretKey, amount: Amount) -> Optio } /// Converts a `Amount` to a `RistrettoSecretKey`. +/// /// # Returns +/// /// Returns `None` if the amount is negative, otherwise returns a `RistrettoSecretKey`. pub fn convert_amount_to_secret(amount: &Amount) -> Option { if amount.is_negative() { @@ -85,14 +84,12 @@ pub fn convert_amount_to_secret(amount: &Amount) -> Option { val_bytes[..Amount::BYTE_SIZE].copy_from_slice(&amount.to_le_bytes()); Some( RistrettoSecretKey::from_canonical_bytes(&val_bytes) - .expect("MSB in 256 bit integer is always zero and < ell (Ristretto base point) therefore canonical"), + .expect("MSB in 256-bit integer is always zero and < ell (Ristretto base point) therefore canonical"), ) } pub fn try_decode_to_signature(signature: &SchnorrSignatureBytes) -> Option { - let public_nonce = RistrettoPublicKey::convert_from_byte_type(signature.public_nonce()).ok()?; - let signature = PrivateKey::from_canonical_bytes(signature.signature().as_bytes()).ok()?; - Some(EngineSchnorrSignature::new(public_nonce, signature)) + signature.try_from_byte_type().ok() } #[cfg(test)] diff --git a/crates/engine_types/src/crypto/messages.rs b/crates/engine_types/src/crypto/messages.rs index 9c0c21b076..14312e7aa8 100644 --- a/crates/engine_types/src/crypto/messages.rs +++ b/crates/engine_types/src/crypto/messages.rs @@ -63,3 +63,10 @@ pub fn stealth_ownership64( .chain(public_output_nonce) .result() } + +pub fn value_proof_message(commitment: &PedersenCommitmentBytes, value: &Amount) -> [u8; 64] { + engine_hasher64(EngineHashDomainLabel::ValueProof) + .chain(commitment) + .chain(value) + .result() +} diff --git a/crates/engine_types/src/crypto/output.rs b/crates/engine_types/src/crypto/output.rs index 26d5d13e0d..00ecf0f346 100644 --- a/crates/engine_types/src/crypto/output.rs +++ b/crates/engine_types/src/crypto/output.rs @@ -7,7 +7,7 @@ use tari_crypto::ristretto::{pedersen::PedersenCommitment, RistrettoPublicKey}; use tari_template_lib::{models::EncryptedData, prelude::RistrettoPublicKeyBytes}; use crate::{ - crypto::{CompressedElgamalVerifiableBalance, ElgamalVerifiableBalance}, + crypto::{ElgamalVerifiableBalance, ElgamalVerifiableBalanceBytes}, ToByteType, }; @@ -18,7 +18,7 @@ pub struct PrivateOutput { pub encrypted_data: EncryptedData, #[cfg_attr(feature = "ts", ts(type = "number | bigint"))] pub minimum_value_promise: u64, - pub viewable_balance: Option, + pub viewable_balance: Option, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/engine_types/src/crypto/utxo_spend.rs b/crates/engine_types/src/crypto/utxo_spend.rs index 4f56a30f47..948e0f0df6 100644 --- a/crates/engine_types/src/crypto/utxo_spend.rs +++ b/crates/engine_types/src/crypto/utxo_spend.rs @@ -12,6 +12,12 @@ use crate::{ }; pub fn verify_utxo_spend_permission(utxo: &UtxoOutput, input: &StealthInput) -> Result<(), ResourceError> { + if input.owner_proof.public_nonce().is_zero() { + return Err(ResourceError::InvalidSpend { + details: "Ownership proof public nonce cannot be zero".to_string(), + }); + } + let balance_proof = try_decode_to_signature(&input.owner_proof).ok_or_else(|| ResourceError::InvalidSpend { details: "Malformed ownership proof".to_string(), })?; diff --git a/crates/engine_types/src/hashing.rs b/crates/engine_types/src/hashing.rs index febf491fba..e82b617edc 100644 --- a/crates/engine_types/src/hashing.rs +++ b/crates/engine_types/src/hashing.rs @@ -128,6 +128,7 @@ pub enum EngineHashDomainLabel { UtxoAddress, StealthTransfer, StealthOwnership, + ValueProof, } impl EngineHashDomainLabel { @@ -154,6 +155,7 @@ impl EngineHashDomainLabel { Self::UtxoAddress => "UtxoAddress", Self::StealthTransfer => "StealthTransfer", Self::StealthOwnership => "StealthOwnership", + Self::ValueProof => "ValueProof", } } } diff --git a/crates/engine_types/src/resource_container.rs b/crates/engine_types/src/resource_container.rs index c040afeffb..73ec30597e 100644 --- a/crates/engine_types/src/resource_container.rs +++ b/crates/engine_types/src/resource_container.rs @@ -16,6 +16,7 @@ use tari_template_lib::{ NonFungibleAddress, NonFungibleId, ResourceAddress, + UtxoId, }, prelude::ResourceType, types::{crypto::PedersenCommitmentBytes, Amount}, @@ -895,4 +896,6 @@ pub enum ResourceError { InvalidConfidentialMintWithChange, #[error("Invalid spend: {details}")] InvalidSpend { details: String }, + #[error("UTXO {id} failed to burn: {details}")] + UtxoBurnFailed { id: UtxoId, details: String }, } diff --git a/crates/engine_types/src/stealth/mod.rs b/crates/engine_types/src/stealth/mod.rs index f0786fc6d0..0da50f88f9 100644 --- a/crates/engine_types/src/stealth/mod.rs +++ b/crates/engine_types/src/stealth/mod.rs @@ -3,6 +3,8 @@ mod outputs; mod transfer; +mod value_proof; pub use outputs::*; pub use transfer::*; +pub use value_proof::*; diff --git a/crates/engine_types/src/stealth/transfer.rs b/crates/engine_types/src/stealth/transfer.rs index e9763345db..d2fd2929c4 100644 --- a/crates/engine_types/src/stealth/transfer.rs +++ b/crates/engine_types/src/stealth/transfer.rs @@ -31,6 +31,11 @@ pub fn validate_transfer( ) -> Result { basic_validations(transfer)?; let validated_outputs = stealth::validate_stealth_outputs_statement(&transfer.outputs_statement, view_key)?; + if transfer.balance_proof.public_nonce().is_zero() { + return Err(ResourceError::InvalidBalanceProof { + details: "Balance proof public nonce cannot be zero".to_string(), + }); + } let balance_proof = try_decode_to_signature(&transfer.balance_proof).ok_or_else(|| ResourceError::InvalidBalanceProof { diff --git a/crates/engine_types/src/stealth/value_proof.rs b/crates/engine_types/src/stealth/value_proof.rs new file mode 100644 index 0000000000..1b0174c8e9 --- /dev/null +++ b/crates/engine_types/src/stealth/value_proof.rs @@ -0,0 +1,166 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use tari_crypto::{ + keys::PublicKey, + ristretto::{pedersen::PedersenCommitment, RistrettoPublicKey, RistrettoSchnorr, RistrettoSecretKey}, +}; +use tari_template_lib::{ + models::UtxoId, + prelude::{ + crypto::{StealthValueProof, ValueKnowledgeProof}, + Amount, + PedersenCommitmentBytes, + }, +}; + +use crate::{ + crypto::{commit_amount, convert_amount_to_secret, messages, ElgamalVerifiableBalanceBytes}, + resource_container::ResourceError, + FromByteType, +}; + +pub fn validate_value_proof( + commitment_bytes: &PedersenCommitmentBytes, + elgamal_verifiable_balance: Option<&ElgamalVerifiableBalanceBytes>, + proof: &StealthValueProof, +) -> Result { + if proof.value.is_negative() { + return Err(ResourceError::UtxoBurnFailed { + id: UtxoId::from(*commitment_bytes), + details: "Value proof amount cannot be negative".to_string(), + }); + } + + let commitment: PedersenCommitment = + commitment_bytes + .try_from_byte_type() + .map_err(|e| ResourceError::UtxoBurnFailed { + id: UtxoId::from(*commitment_bytes), + details: format!("Invalid commitment bytes: {}", e), + })?; + + match proof.knowledge_proof { + ValueKnowledgeProof::Commitment { mask_knowledge_proof } => { + let public_mask = + commitment.as_public_key() - commit_amount(&RistrettoSecretKey::default(), proof.value).as_public_key(); + + let sig: RistrettoSchnorr = + mask_knowledge_proof + .try_from_byte_type() + .map_err(|e| ResourceError::UtxoBurnFailed { + id: UtxoId::from(*commitment_bytes), + details: format!("Invalid mask knowledge proof bytes: {}", e), + })?; + + let message = messages::value_proof_message(commitment_bytes, &proof.value); + + if !sig.verify(&public_mask, message) { + return Err(ResourceError::UtxoBurnFailed { + id: UtxoId::from(*commitment_bytes), + details: "Invalid mask knowledge proof".to_string(), + }); + } + }, + ValueKnowledgeProof::ElgamalEncrypted { reveal_key } => { + let elgamal = elgamal_verifiable_balance.ok_or_else(|| ResourceError::UtxoBurnFailed { + id: UtxoId::from(*commitment_bytes), + details: "Utxo does not have a viewable balance".to_string(), + })?; + + let encrypted: RistrettoPublicKey = + elgamal + .encrypted + .try_from_byte_type() + .map_err(|e| ResourceError::UtxoBurnFailed { + id: UtxoId::from(*commitment_bytes), + details: format!("Invalid encrypted balance bytes: {}", e), + })?; + + let reveal_key: RistrettoPublicKey = + reveal_key + .try_from_byte_type() + .map_err(|e| ResourceError::UtxoBurnFailed { + id: UtxoId::from(*commitment_bytes), + details: format!("Invalid reveal key bytes: {}", e), + })?; + + // E - R.p = v.G + let check_value = encrypted - reveal_key; + + let value = convert_amount_to_secret(&proof.value).expect("Value is non-negative, conversion must succeed"); + let value_g = RistrettoPublicKey::from_secret_key(&value); + if value_g != check_value { + return Err(ResourceError::UtxoBurnFailed { + id: UtxoId::from(*commitment_bytes), + details: "Invalid Elgamal encrypted value proof".to_string(), + }); + } + }, + } + + Ok(proof.value) +} + +#[cfg(test)] +mod tests { + use rand::rngs::OsRng; + use tari_crypto::keys::SecretKey; + use tari_template_lib::types::crypto::{StealthValueProof, ValueKnowledgeProof}; + + use super::*; + use crate::ToByteType; + + #[test] + fn it_proves_knowledge_of_the_value() { + let mask = RistrettoSecretKey::random(&mut OsRng); + let value = 100_321_123.into(); + let commitment = commit_amount(&mask, value); + let commitment_bytes = commitment.to_byte_type(); + + // Create the proof of knowledge of the value + let message = messages::value_proof_message(&commitment_bytes, &value); + let sig = RistrettoSchnorr::sign(&mask, message, &mut OsRng).unwrap(); + + let proof = StealthValueProof { + value, + knowledge_proof: ValueKnowledgeProof::Commitment { + mask_knowledge_proof: sig.to_byte_type(), + }, + }; + + // Validate the proof + let amount = validate_value_proof(&commitment_bytes, None, &proof).unwrap(); + assert_eq!(amount, value); + } + + #[test] + fn it_fails_if_the_value_differs() { + let mask = RistrettoSecretKey::random(&mut OsRng); + let value = 100_321_123.into(); + let commitment = commit_amount(&mask, value); + let commitment_bytes = commitment.to_byte_type(); + + let other_value = value + Amount::ONE; + + // Create the proof of knowledge of the value + let message = messages::value_proof_message(&commitment_bytes, &other_value); + let sig = RistrettoSchnorr::sign(&mask, message, &mut OsRng).unwrap(); + + let proof = StealthValueProof { + value: other_value, + knowledge_proof: ValueKnowledgeProof::Commitment { + mask_knowledge_proof: sig.to_byte_type(), + }, + }; + + // Validate the proof + let err = validate_value_proof(&commitment_bytes, None, &proof).unwrap_err(); + match err { + ResourceError::UtxoBurnFailed { details, .. } => { + assert_eq!(details, "Invalid mask knowledge proof"); + }, + _ => panic!("Unexpected error type {err}"), + } + } +} diff --git a/crates/template_lib/src/args/types.rs b/crates/template_lib/src/args/types.rs index e51231f0f2..2386e39dd0 100644 --- a/crates/template_lib/src/args/types.rs +++ b/crates/template_lib/src/args/types.rs @@ -26,6 +26,7 @@ use tari_template_abi::rust::{ fmt::{Display, Formatter}, str::FromStr, }; +use tari_template_lib_types::crypto::StealthValueProof; use crate::{ args::freeze_flags::VaultFreezeFlags, @@ -241,6 +242,8 @@ pub enum ResourceAction { StealthTransfer, /// Un/freezes one or more stealth UTXOs of a resource SetStealthUtxosFreeze, + /// Burns a stealth UTXO of a resource + StealthUtxoBurn, } /// All the possible minting operation types @@ -315,13 +318,13 @@ pub struct ResourceUpdateNonFungibleDataArg { /// A convenience enum that allows to specify resource types #[derive(Clone, Debug, Serialize, Deserialize)] pub enum ResourceDiscriminator { + /// Select all tokens Everything, - Fungible { - amount: Amount, - }, - NonFungible { - tokens: BTreeSet, - }, + /// Select a specific amount of fungible (public or stealth) tokens + Fungible { amount: Amount }, + /// Select specific non-fungible tokens + NonFungible { tokens: BTreeSet }, + /// Select specific confidential commitments and a revealed amount Confidential { commitments: BTreeSet, revealed_amount: Amount, @@ -714,3 +717,9 @@ pub struct SetFreezeStealthUtxosArg { pub utxos: Vec, pub freeze: bool, } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BurnStealthUtxoArg { + pub utxo_id: UtxoId, + pub value_proof: Option, +} diff --git a/crates/template_lib/src/models/viewable_balance.rs b/crates/template_lib/src/models/viewable_balance.rs index 33065991d8..16a1db421e 100644 --- a/crates/template_lib/src/models/viewable_balance.rs +++ b/crates/template_lib/src/models/viewable_balance.rs @@ -28,7 +28,8 @@ use tari_template_lib_types::crypto::{PedersenCommitmentBytes, RistrettoPublicKe #[cfg_attr(feature = "borsh", derive(borsh::BorshSerialize))] pub struct ViewableBalanceProof { /// The encrypted value that takes the form: E = v.G + r.P - /// where v is the value, G is the generator, r is the secret_nonce and P is the view key + /// where v is the value, G is the generator, r is the secret_nonce and P is the view key. + /// The value is decrypted by brute forcing E - R.p = v.G pub elgamal_encrypted: RistrettoPublicKeyBytes, /// The public nonce used in the ElGamal encryption R = r.G pub elgamal_public_nonce: RistrettoPublicKeyBytes, diff --git a/crates/template_lib/src/resource/builder/confidential.rs b/crates/template_lib/src/resource/builder/confidential.rs index 46cc1ee5a5..92638c9200 100644 --- a/crates/template_lib/src/resource/builder/confidential.rs +++ b/crates/template_lib/src/resource/builder/confidential.rs @@ -21,6 +21,7 @@ pub struct ConfidentialResourceBuilder { authorize_hook: Option, address_allocation: Option, divisibility: u8, + is_total_supply_tracking_enabled: bool, } impl ConfidentialResourceBuilder { @@ -35,6 +36,7 @@ impl ConfidentialResourceBuilder { authorize_hook: None, address_allocation: None, divisibility: DEFAULT_DIVISIBILITY, + is_total_supply_tracking_enabled: true, } } @@ -79,6 +81,23 @@ impl ConfidentialResourceBuilder { self } + /// Disables the tracking of total supply for the resource. + /// + /// By default, total supply tracking is enabled. `.disable_total_supply_tracking()` can be used to disable it. + /// Use cases include privacy focused tokens or utility tokens where the total supply is not relevant. + /// + /// # Examples + /// ```rust, ignore + /// use tari_template_lib::resource::builder::ResourceBuilder; + /// ResourceBuilder::confidential() + /// .disable_total_supply_tracking() + /// .build(); + /// ``` + pub fn disable_total_supply_tracking(mut self) -> Self { + self.is_total_supply_tracking_enabled = false; + self + } + /// Specify a view key for the confidential resource. This allows anyone with the secret key to uncover the balance /// of commitments generated for the resource. /// NOTE: it is not currently possible to change the view key after the resource is created. @@ -232,7 +251,7 @@ impl ConfidentialResourceBuilder { self.authorize_hook, self.address_allocation, self.divisibility, - false, + self.is_total_supply_tracking_enabled, ) } } diff --git a/crates/template_lib/src/resource/builder/stealth.rs b/crates/template_lib/src/resource/builder/stealth.rs index 83d0e12ad3..b1b03a9c42 100644 --- a/crates/template_lib/src/resource/builder/stealth.rs +++ b/crates/template_lib/src/resource/builder/stealth.rs @@ -223,8 +223,10 @@ impl StealthResourceBuilder { /// This builds the resource and mints the initial supply of tokens, returning the address of the resource. /// NOTE that stealth resources do not return the bucket of the initial supply since /// they are minted as individual UTXO substates and cannot be placed in vault. - pub fn initial_supply(self, initial_supply: Amount) -> Bucket { - let mint_arg = MintArg::Stealth { amount: initial_supply }; + pub fn initial_supply>(self, initial_supply: A) -> Bucket { + let mint_arg = MintArg::Stealth { + amount: initial_supply.into(), + }; let (_, bucket) = self.build_internal(Some(mint_arg)); bucket.expect("[initial_supply] Bucket not returned from engine") diff --git a/crates/template_lib/src/resource/manager.rs b/crates/template_lib/src/resource/manager.rs index 34ff5e694e..d577e41bf8 100644 --- a/crates/template_lib/src/resource/manager.rs +++ b/crates/template_lib/src/resource/manager.rs @@ -43,9 +43,11 @@ use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; use tari_bor::to_value; use tari_template_abi::{call_engine, rust::collections::BTreeMap, EngineOp}; +use tari_template_lib_types::crypto::StealthValueProof; use crate::{ args::{ + BurnStealthUtxoArg, CreateResourceArg, FreezeResourceArg, InvokeResult, @@ -503,6 +505,17 @@ impl ResourceManager { }) } + fn mint_internal(&self, arg: MintResourceArg) -> Bucket { + let resp: InvokeResult = call_engine(EngineOp::ResourceInvoke, &ResourceInvokeArg { + resource_ref: self.resource_address.into(), + action: ResourceAction::Mint, + args: invoke_args![arg], + }); + + let bucket_id: BucketId = resp.decode().expect("Failed to decode Bucket"); + Bucket::from_id(bucket_id) + } + /// Executes a stealth transfer for the resource managed by this [ResourceManager]. /// /// If the [StealthTransferStatement] is valid, and contains revealed outputs, this method will return a @@ -542,7 +555,7 @@ impl ResourceManager { .expect("[stealth_transfer] Failed to decode Option") } - /// Recalls all tokens of a fungible resource from the specified vault, returning them in a [`Bucket`]. + /// Recalls all tokens of a resource from the specified vault, returning them in a [`Bucket`]. /// /// This method withdraws the entire balance of the resource held in the vault. The caller must have /// the necessary permissions as defined by the resource's access rules to perform a recall. @@ -564,9 +577,9 @@ impl ResourceManager { /// /// ```rust,ignore /// let vault_id = component.get_user_vault("alice"); - /// let bucket = resource_manager.recall_fungible_all(vault_id); + /// let bucket = resource_manager.recall_all(vault_id); /// ``` - pub fn recall_fungible_all(&self, vault_id: VaultId) -> Bucket { + pub fn recall_all(&self, vault_id: VaultId) -> Bucket { self.recall_internal(RecallResourceArg { resource: ResourceDiscriminator::Everything, vault_id, @@ -716,6 +729,17 @@ impl ResourceManager { }) } + fn recall_internal(&self, arg: RecallResourceArg) -> Bucket { + let resp: InvokeResult = call_engine(EngineOp::ResourceInvoke, &ResourceInvokeArg { + resource_ref: self.resource_address.into(), + action: ResourceAction::Recall, + args: invoke_args![arg], + }); + + let bucket_id = resp.decode().expect("Failed to decode Bucket"); + Bucket::from_id(bucket_id) + } + /// Returns the total supply of tokens for the resource being managed in a [`ResourceManager`] instance. /// /// If the resource has total supply tracking enabled, the function will return the total supply of tokens. @@ -931,26 +955,19 @@ impl ResourceManager { resp.decode().expect("SetFreeze failed") } - fn recall_internal(&self, arg: RecallResourceArg) -> Bucket { + /// Burns the stealth UTXO, permanently removing them from circulation. + /// If total supply tracking is enabled for the resource, a valid `StealthValueProof` is required. + /// NOTE: that this essentially limits burns to the UTXO owner or the secret view key holder regardless of the + /// access rules (i.e. if burns are limited to an "admin" badge, the admin can only burn funds if they have the + /// secret view key or burn their own funds). This is a limitation of the current protocol. + pub fn burn_utxo(&self, utxo_id: UtxoId, value_proof: Option) { let resp: InvokeResult = call_engine(EngineOp::ResourceInvoke, &ResourceInvokeArg { resource_ref: self.resource_address.into(), - action: ResourceAction::Recall, - args: invoke_args![arg], + action: ResourceAction::StealthUtxoBurn, + args: invoke_args![BurnStealthUtxoArg { utxo_id, value_proof }], }); - let bucket_id = resp.decode().expect("Failed to decode Bucket"); - Bucket::from_id(bucket_id) - } - - fn mint_internal(&self, arg: MintResourceArg) -> Bucket { - let resp: InvokeResult = call_engine(EngineOp::ResourceInvoke, &ResourceInvokeArg { - resource_ref: self.resource_address.into(), - action: ResourceAction::Mint, - args: invoke_args![arg], - }); - - let bucket_id: BucketId = resp.decode().expect("Failed to decode Bucket"); - Bucket::from_id(bucket_id) + resp.decode().expect("BurnStealthUtxos failed") } } diff --git a/crates/template_lib_types/src/crypto/mod.rs b/crates/template_lib_types/src/crypto/mod.rs index b9c5e70150..1bff260dd6 100644 --- a/crates/template_lib_types/src/crypto/mod.rs +++ b/crates/template_lib_types/src/crypto/mod.rs @@ -14,6 +14,7 @@ mod utxo_tag; #[macro_use] mod signature; +mod value_proof; pub use balance_proof::*; pub use commitment::*; @@ -24,5 +25,6 @@ pub use scalar::*; pub use schnorr::*; pub use signature::*; pub use utxo_tag::*; +pub use value_proof::*; pub use crate::error::*; diff --git a/crates/template_lib_types/src/crypto/ristretto.rs b/crates/template_lib_types/src/crypto/ristretto.rs index 53898acd4f..8a056ec836 100644 --- a/crates/template_lib_types/src/crypto/ristretto.rs +++ b/crates/template_lib_types/src/crypto/ristretto.rs @@ -50,6 +50,10 @@ impl RistrettoPublicKeyBytes { &self.0 } + pub fn is_zero(&self) -> bool { + self.0.iter().all(|&b| b == 0) + } + pub fn into_array(self) -> [u8; Self::length()] { self.0 } diff --git a/crates/template_lib_types/src/crypto/value_proof.rs b/crates/template_lib_types/src/crypto/value_proof.rs new file mode 100644 index 0000000000..b0aff6423a --- /dev/null +++ b/crates/template_lib_types/src/crypto/value_proof.rs @@ -0,0 +1,34 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use serde::{Deserialize, Serialize}; + +use crate::{ + crypto::{RistrettoPublicKeyBytes, SchnorrSignatureBytes}, + Amount, +}; + +/// Proof of knowledge of the opening to a commitment and that the commitment commits to a specific value. +/// Currently used when burning UTXOs to allow the total supply to be adjusted. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct StealthValueProof { + /// The claimed value to prove + pub value: Amount, + pub knowledge_proof: ValueKnowledgeProof, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub enum ValueKnowledgeProof { + Commitment { + /// Signed by C - v.H, where C is the commitment being proven and v is the claimed value + /// Proving knowledge of the opening to C, and that the commitment C = m.G + v.H + mask_knowledge_proof: SchnorrSignatureBytes, + }, + ElgamalEncrypted { + /// The R.p term of the ElGamal encryption. This allows validators to check the provided value is correct using + /// the viewable balance. This assumes that the verifiable proof was originally validated correctly. + reveal_key: RistrettoPublicKeyBytes, + }, +} diff --git a/crates/template_test_tooling/src/read_only_state_store.rs b/crates/template_test_tooling/src/read_only_state_store.rs index 98f0d36f11..853b397259 100644 --- a/crates/template_test_tooling/src/read_only_state_store.rs +++ b/crates/template_test_tooling/src/read_only_state_store.rs @@ -10,8 +10,9 @@ use tari_engine_types::{ resource::Resource, substate::{Substate, SubstateId}, vault::Vault, + Utxo, }; -use tari_template_lib::models::{Account, ComponentAddress, ResourceAddress, VaultId}; +use tari_template_lib::models::{Account, ComponentAddress, ResourceAddress, UtxoAddress, VaultId}; pub struct ReadOnlyStateStore<'a> { store: &'a MemoryStateStore, @@ -55,6 +56,11 @@ impl<'a> ReadOnlyStateStore<'a> { Ok(substate.into_substate_value().into_vault().unwrap()) } + pub fn get_utxo(&self, utxo_addr: UtxoAddress) -> Result { + let substate = self.get_substate(&SubstateId::Utxo(utxo_addr))?; + Ok(substate.into_substate_value().into_utxo().unwrap()) + } + pub fn inspect_component(&self, component_address: ComponentAddress) -> Result { let component = self.get_component(component_address)?; Ok(IndexedValue::from_value(component.into_state()).unwrap()) diff --git a/crates/template_test_tooling/src/support/stealth.rs b/crates/template_test_tooling/src/support/stealth.rs index 0f4b7c21fd..707e0a795a 100644 --- a/crates/template_test_tooling/src/support/stealth.rs +++ b/crates/template_test_tooling/src/support/stealth.rs @@ -4,7 +4,11 @@ use rand::rngs::OsRng; use tari_crypto::{ keys::{PublicKey, SecretKey}, - ristretto::{RistrettoPublicKey, RistrettoSecretKey}, + ristretto::{RistrettoPublicKey, RistrettoSchnorr, RistrettoSecretKey}, +}; +use tari_engine_types::{ + crypto::{commit_amount, messages}, + ToByteType, }; use tari_ootle_wallet_crypto::{ stealth, @@ -15,7 +19,11 @@ use tari_ootle_wallet_crypto::{ }; use tari_template_lib::{ models::{EncryptedData, StealthOutputsStatement, StealthTransferStatement}, - types::{crypto::UtxoTag, Amount}, + prelude::{crypto::ValueKnowledgeProof, RistrettoPublicKeyBytes}, + types::{ + crypto::{StealthValueProof, UtxoTag}, + Amount, + }, }; pub fn generate_stealth_output_statement, A: Into>( @@ -209,3 +217,26 @@ fn generate_transfer_data_internal, A: Into>( statement: transfer, } } + +pub fn generate_value_proof_mask_knowledge(value: Amount, mask: &RistrettoSecretKey) -> StealthValueProof { + assert!(value.is_positive(), "Value must be positive"); + let commitment = commit_amount(mask, value); + let commitment_bytes = commitment.to_byte_type(); + let message = messages::value_proof_message(&commitment_bytes, &value); + let sig = RistrettoSchnorr::sign(mask, message, &mut OsRng).expect("Signing cannot fail"); + + StealthValueProof { + value, + knowledge_proof: ValueKnowledgeProof::Commitment { + mask_knowledge_proof: sig.to_byte_type(), + }, + } +} + +pub fn generate_value_proof_elgamal(value: Amount, reveal_key: RistrettoPublicKeyBytes) -> StealthValueProof { + assert!(value.is_positive(), "Value must be positive"); + StealthValueProof { + value, + knowledge_proof: ValueKnowledgeProof::ElgamalEncrypted { reveal_key }, + } +}