Repository: ergoplatform/sigma-rust (language: rust)

Issue:
Alter `TxBuilder` to allow missing tokens in the outputs only in case of explicit token burn request
Found while digging into #590

Add an explicit burn token request in `TxBuilder` API and update `TxBuilder::build()` to check the inputs preservation (minus explicit token burning).


Discussion / hints:

Fix patch:
diff --git a/bindings/ergo-lib-c-core/src/tx_builder.rs b/bindings/ergo-lib-c-core/src/tx_builder.rs
index f278d5c40..90703898f 100644
--- a/bindings/ergo-lib-c-core/src/tx_builder.rs
+++ b/bindings/ergo-lib-c-core/src/tx_builder.rs
@@ -8,6 +8,7 @@ use crate::{
     context_extension::ContextExtensionPtr,
     data_input::DataInput,
     ergo_box::{BoxIdPtr, BoxValue, BoxValuePtr, ConstBoxValuePtr, ErgoBoxCandidate},
+    token::ConstTokensPtr,
     transaction::{UnsignedTransaction, UnsignedTransactionPtr},
     util::{const_ptr_as_ref, mut_ptr_as_mut},
     Error,
@@ -89,7 +90,6 @@ pub unsafe fn tx_builder_set_context_extension(
 ) -> Result<(), Error> {
     let box_id = const_ptr_as_ref(box_id_ptr, "box_id_ptr")?;
     let ctx_ext = const_ptr_as_ref(ctx_ext_ptr, "ctx_ext_ptr")?;
-    // let data_inputs = const_ptr_as_ref(data_inputs_ptr, "data_inputs_ptr")?;
     let tx_builder_mut = mut_ptr_as_mut(tx_builder_mut, "tx_builder_mut")?;
     tx_builder_mut
         .0
@@ -97,6 +97,23 @@ pub unsafe fn tx_builder_set_context_extension(
     Ok(())
 }
 
+/// Permits the burn of the given token amount, i.e. allows this token amount to be omitted in the outputs
+pub unsafe fn tx_builder_set_token_burn_permit(
+    tx_builder_mut: TxBuilderPtr,
+    target_tokens_ptr: ConstTokensPtr,
+) -> Result<(), Error> {
+    let target_tokens = const_ptr_as_ref(target_tokens_ptr, "target_tokens_ptr")?;
+    let tx_builder_mut = mut_ptr_as_mut(tx_builder_mut, "tx_builder_mut")?;
+    tx_builder_mut.0.set_token_burn_permit(
+        target_tokens
+            .0
+            .clone()
+            .map(|tokens| tokens.mapped(|t| t.0).as_vec().clone())
+            .unwrap_or_else(Vec::new),
+    );
+    Ok(())
+}
+
 /// Build the unsigned transaction
 pub unsafe fn tx_builder_build(
     tx_builder_ptr: ConstTxBuilderPtr,
diff --git a/bindings/ergo-lib-c/src/tx_builder.rs b/bindings/ergo-lib-c/src/tx_builder.rs
index 16d18c8df..651e91b8a 100644
--- a/bindings/ergo-lib-c/src/tx_builder.rs
+++ b/bindings/ergo-lib-c/src/tx_builder.rs
@@ -7,6 +7,7 @@ use ergo_lib_c_core::{
     context_extension::ContextExtensionPtr,
     data_input::DataInput,
     ergo_box::{BoxIdPtr, BoxValuePtr, ConstBoxValuePtr, ErgoBoxCandidate},
+    token::ConstTokensPtr,
     transaction::UnsignedTransactionPtr,
     tx_builder::*,
     Error, ErrorPtr,
@@ -73,6 +74,16 @@ pub unsafe extern "C" fn ergo_lib_tx_builder_set_context_extension(
     tx_builder_set_context_extension(tx_builder_mut, box_id_ptr, ctx_ext_ptr).unwrap();
 }
 
+/// Permits the burn of the given token amount, i.e. allows this token amount to be omitted in the outputs
+#[no_mangle]
+pub unsafe extern "C" fn ergo_lib_tx_builder_set_token_burn_permit(
+    tx_builder_mut: TxBuilderPtr,
+    target_tokens_ptr: ConstTokensPtr,
+) {
+    #[allow(clippy::unwrap_used)]
+    tx_builder_set_token_burn_permit(tx_builder_mut, target_tokens_ptr).unwrap();
+}
+
 /// Build the unsigned transaction
 #[no_mangle]
 pub unsafe extern "C" fn ergo_lib_tx_builder_build(
diff --git a/bindings/ergo-lib-ios/Sources/ErgoLib/TxBuilder.swift b/bindings/ergo-lib-ios/Sources/ErgoLib/TxBuilder.swift
index 9a83ee34e..81e6cbc1f 100644
--- a/bindings/ergo-lib-ios/Sources/ErgoLib/TxBuilder.swift
+++ b/bindings/ergo-lib-ios/Sources/ErgoLib/TxBuilder.swift
@@ -51,6 +51,11 @@ class TxBuilder {
     func setContextExtension(boxId: BoxId, ctxExt: ContextExtension) {
         ergo_lib_tx_builder_set_context_extension(self.pointer, boxId.pointer, ctxExt.pointer)
     }
+
+    /// Permits the burn of the given token amount, i.e. allows this token amount to be omitted in the outputs
+    func setTokenBurnPermit(tokens: Tokens) {
+        ergo_lib_tx_builder_set_token_burn_permit(self.pointer, tokens.pointer)
+    }
     
     /// Get ``DataInputs``
     func getDataInputs() -> DataInputs {
diff --git a/bindings/ergo-lib-ios/Tests/ErgoLibTests/TransactionTests.swift b/bindings/ergo-lib-ios/Tests/ErgoLibTests/TransactionTests.swift
index 01d58b96e..7d09f079b 100644
--- a/bindings/ergo-lib-ios/Tests/ErgoLibTests/TransactionTests.swift
+++ b/bindings/ergo-lib-ios/Tests/ErgoLibTests/TransactionTests.swift
@@ -191,6 +191,7 @@ final class TransactionTests: XCTestCase {
         let dataInputs = DataInputs()
         let txBuilder = TxBuilder(boxSelection: boxSelection, outputCandidates: txOutputs, currentHeight: UInt32(0), feeAmount: fee, changeAddress: changeAddress, minChangeValue: minChangeValue)
         txBuilder.setDataInputs(dataInputs: dataInputs)
+        txBuilder.setTokenBurnPermit(tokens: tokens)
         XCTAssertNoThrow(try txBuilder.build())
     }
     
diff --git a/bindings/ergo-lib-wasm/src/tx_builder.rs b/bindings/ergo-lib-wasm/src/tx_builder.rs
index 1cdec43fc..659747e0a 100644
--- a/bindings/ergo-lib-wasm/src/tx_builder.rs
+++ b/bindings/ergo-lib-wasm/src/tx_builder.rs
@@ -7,10 +7,12 @@ use crate::context_extension::ContextExtension;
 use crate::data_input::DataInputs;
 use crate::ergo_box::BoxId;
 use crate::error_conversion::to_js;
+use crate::token::Tokens;
 use crate::{
     address::Address, box_coll::ErgoBoxCandidates, ergo_box::BoxValue,
     transaction::UnsignedTransaction,
 };
+use ergo_lib::ergotree_ir::chain;
 
 /// Unsigned transaction builder
 #[wasm_bindgen]
@@ -63,6 +65,18 @@ impl TxBuilder {
             .set_context_extension(box_id.clone().into(), context_extension.clone().into());
     }
 
+    /// Permits the burn of the given token amount, i.e. allows this token amount to be omitted in the outputs
+    pub fn set_token_burn_permit(&mut self, tokens: &Tokens) {
+        let tokens: Option<chain::ergo_box::BoxTokens> = tokens.clone().into();
+        self.0.set_token_burn_permit(
+            tokens
+                .as_ref()
+                .map(chain::ergo_box::BoxTokens::as_vec)
+                .unwrap_or(&vec![])
+                .clone(),
+        )
+    }
+
     /// Build the unsigned transaction
     pub fn build(&self) -> Result<UnsignedTransaction, JsValue> {
         self.0
diff --git a/ergo-lib/src/wallet/box_selector.rs b/ergo-lib/src/wallet/box_selector.rs
index 9aa4e3b67..fa0611f1d 100644
--- a/ergo-lib/src/wallet/box_selector.rs
+++ b/ergo-lib/src/wallet/box_selector.rs
@@ -189,6 +189,30 @@ pub fn sum_tokens_from_hashmaps(
     Ok(res)
 }
 
+/// Subtract tokens2 from tokens1
+/// subtracting amounts of the same token or removing the token if amount is the same
+/// Returns an error if trying to subtract more tokens than there are in tokens1
+pub fn subtract_tokens(
+    tokens1: &HashMap<TokenId, TokenAmount>,
+    tokens2: &HashMap<TokenId, TokenAmount>,
+) -> Result<HashMap<TokenId, TokenAmount>, TokenAmountError> {
+    let mut res: HashMap<TokenId, TokenAmount> = tokens1.clone();
+    tokens2.iter().try_for_each(|(id, t_amt)| {
+        if let Some(amt) = res.get_mut(id) {
+            if amt == t_amt {
+                res.remove(id);
+            } else {
+                *amt = amt.checked_sub(t_amt)?;
+            }
+        } else {
+            // trying to subtract a token not found in tokens1
+            return Err(TokenAmountError::OutOfBounds(-(*t_amt.as_u64() as i64)));
+        }
+        Ok(())
+    })?;
+    Ok(res)
+}
+
 /// Arbitrary impl for ErgoBoxAssetsData
 #[allow(clippy::unwrap_used, clippy::panic)]
 #[cfg(feature = "arbitrary")]
diff --git a/ergo-lib/src/wallet/tx_builder.rs b/ergo-lib/src/wallet/tx_builder.rs
index 696b5e58b..b93ac5f94 100644
--- a/ergo-lib/src/wallet/tx_builder.rs
+++ b/ergo-lib/src/wallet/tx_builder.rs
@@ -1,7 +1,7 @@
 //! Builder for an UnsignedTransaction
 
 use ergotree_interpreter::sigma_protocol::prover::ContextExtension;
-use ergotree_ir::chain::ergo_box::box_value::BoxValueError;
+use ergotree_ir::chain::token::TokenAmount;
 use ergotree_ir::chain::token::TokenAmountError;
 use std::collections::HashMap;
 use std::collections::HashSet;
@@ -27,11 +27,12 @@ use crate::chain::transaction::unsigned::UnsignedTransaction;
 use crate::chain::transaction::{DataInput, Input, Transaction, UnsignedInput};
 use crate::constants::MINERS_FEE_MAINNET_ADDRESS;
 
+use super::box_selector::subtract_tokens;
 use super::box_selector::sum_tokens_from_boxes;
 use super::box_selector::sum_value;
+use super::box_selector::BoxSelection;
 use super::box_selector::ErgoBoxAssets;
 use super::box_selector::ErgoBoxId;
-use super::box_selector::{BoxSelection, BoxSelectorError};
 
 /// Unsigned transaction builder
 #[derive(Clone)]
@@ -44,6 +45,7 @@ pub struct TxBuilder<S: ErgoBoxAssets> {
     change_address: Address,
     min_change_value: BoxValue,
     context_extensions: HashMap<BoxId, ContextExtension>,
+    token_burn_permit: Vec<Token>,
 }
 
 impl<S: ErgoBoxAssets + ErgoBoxId + Clone> TxBuilder<S> {
@@ -72,6 +74,7 @@ impl<S: ErgoBoxAssets + ErgoBoxId + Clone> TxBuilder<S> {
             change_address,
             min_change_value,
             context_extensions: HashMap::new(),
+            token_burn_permit: Vec::new(),
         }
     }
 
@@ -139,6 +142,11 @@ impl<S: ErgoBoxAssets + ErgoBoxId + Clone> TxBuilder<S> {
         Ok(signed_tx_mock.sigma_serialize_bytes()?.len())
     }
 
+    /// Permits the burn of the given token amount, i.e. allows this token amount to be omitted in the outputs
+    pub fn set_token_burn_permit(&mut self, tokens: Vec<Token>) {
+        self.token_burn_permit = tokens;
+    }
+
     fn build_tx(&self) -> Result<UnsignedTransaction, TxBuilderError> {
         if self.box_selection.boxes.is_empty() {
             return Err(TxBuilderError::InvalidArgs("inputs is empty".to_string()));
@@ -209,14 +217,15 @@ impl<S: ErgoBoxAssets + ErgoBoxId + Clone> TxBuilder<S> {
             ));
         }
         // check that inputs have enough tokens
-        let input_tokens = sum_tokens_from_boxes(self.box_selection.boxes.as_slice())?;
-        let output_tokens = sum_tokens_from_boxes(output_candidates.as_slice())?;
+        let input_tokens = sum_tokens_from_boxes(self.box_selection.boxes.as_slice())
+            .map_err(TxBuilderError::TooManyTokensInInputBoxes)?;
+        let output_tokens = sum_tokens_from_boxes(output_candidates.as_slice())
+            .map_err(TxBuilderError::TooManyTokensInOutputCandidates)?;
         let first_input_box_id: TokenId = self.box_selection.boxes.first().box_id().into();
         let output_tokens_len = output_tokens.len();
-        let output_tokens_without_minted: Vec<Token> = output_tokens
+        let output_tokens_without_minted: HashMap<TokenId, TokenAmount> = output_tokens
             .into_iter()
-            .map(Token::from)
-            .filter(|t| t.token_id != first_input_box_id)
+            .filter(|(id, _)| id != &first_input_box_id)
             .collect();
         if output_tokens_len - output_tokens_without_minted.len() > 1 {
             return Err(TxBuilderError::InvalidArgs(
@@ -225,13 +234,21 @@ impl<S: ErgoBoxAssets + ErgoBoxId + Clone> TxBuilder<S> {
         }
         output_tokens_without_minted
             .iter()
-            .try_for_each(|output_token| {
-                match input_tokens.get(&output_token.token_id).cloned() {
-                    Some(input_token_amount) if input_token_amount >= output_token.amount => Ok(()),
-                    _ => Err(TxBuilderError::NotEnoughTokens(vec![output_token.clone()])),
-                }
+            .try_for_each(|(id, amt)| match input_tokens.get(id).cloned() {
+                Some(input_token_amount) if input_token_amount >= *amt => Ok(()),
+                _ => Err(TxBuilderError::NotEnoughTokens(vec![
+                    (id.clone(), *amt).into()
+                ])),
             })?;
 
+        // check token burn
+        let burned_tokens = subtract_tokens(&input_tokens, &output_tokens_without_minted)
+            .map_err(TxBuilderError::TokensInOutputsExceedInputs)?;
+        let token_burn_permits = vec_tokens_to_map(self.token_burn_permit.clone())
+            .map_err(TxBuilderError::TooManyTokensInBurnPermit)?;
+        check_enough_token_burn_permit(&burned_tokens, &token_burn_permits)?;
+        check_unused_token_burn_permit(&burned_tokens, &token_burn_permits)?;
+
         let unsigned_inputs = self.box_selection.boxes.clone().mapped(|b| {
             let ctx_ext = self
                 .context_extensions
@@ -276,41 +293,98 @@ pub fn new_miner_fee_box(
 }
 
 /// Errors of TxBuilder
+#[allow(missing_docs)]
 #[derive(Error, PartialEq, Eq, Debug, Clone)]
 pub enum TxBuilderError {
-    /// Box selection error
-    #[error("Box selector error: {0}")]
-    BoxSelectorError(#[from] BoxSelectorError),
-    /// Box value error
-    #[error("Box value error")]
-    BoxValueError(#[from] BoxValueError),
-    /// Parsing error
-    #[error("Parsing error: {0}")]
+    #[error("SigmaParsingError: {0}")]
     ParsingError(#[from] SigmaParsingError),
-    /// Invalid arguments
     #[error("Invalid arguments: {0}")]
     InvalidArgs(String),
-    /// ErgoBoxCandidate error
     #[error("ErgoBoxCandidateBuilder error: {0}")]
     ErgoBoxCandidateBuilderError(#[from] ErgoBoxCandidateBuilderError),
-    /// Not enougn tokens
     #[error("Not enougn tokens: {0:?}")]
     NotEnoughTokens(Vec<Token>),
-    /// Not enough coins
     #[error("Not enough coins({0} nanoERGs are missing)")]
     NotEnoughCoins(u64),
-    /// Tx serialization failed (id calculation)
     #[error("Transaction serialization failed: {0}")]
     SerializationError(#[from] SigmaSerializationError),
-    /// Invalid Tx input count
     #[error("Invalid tx inputs count: {0}")]
     InvalidInputsCount(#[from] BoundedVecOutOfBounds),
-    /// Input box was unable to be retrieved
     #[error("Empty input box")]
     EmptyInputBoxSelection,
-    /// Token amount err
-    #[error("TokenAmountError: {0:?}")]
-    TokenAmountError(#[from] TokenAmountError),
+    #[error("Token burn permit exceeded. Permitted limit: {permit:?}, trying to burn: {try_to_burn:?}. Revisit the input to `set_token_burn_permit()` to increase the limit")]
+    TokenBurnPermitExceeded { permit: Token, try_to_burn: Token },
+    #[error("Token burn permit is missing. Trying to burn: {try_to_burn:?}. Call `set_token_burn_permit()` to set the limit")]
+    TokenBurnPermitMissing { try_to_burn: Token },
+    #[error("Unused token burn permit: token id {token_id:?}, amount {amount:?}")]
+    TokenBurnPermitUnused { token_id: TokenId, amount: u64 },
+    #[error("Too many tokens in burn permit: {0}")]
+    TooManyTokensInBurnPermit(TokenAmountError),
+    #[error("Too many tokens in input boxes: {0}")]
+    TooManyTokensInInputBoxes(TokenAmountError),
+    #[error("Too many tokens in output candidate boxes: {0}")]
+    TooManyTokensInOutputCandidates(TokenAmountError),
+    #[error("Tokens in output candidate exceed tokens in input boxes: {0}")]
+    TokensInOutputsExceedInputs(TokenAmountError),
+}
+
+/// Sums up the tokens into a hash map
+pub(crate) fn vec_tokens_to_map(
+    tokens: Vec<Token>,
+) -> Result<HashMap<TokenId, TokenAmount>, TokenAmountError> {
+    let mut res: HashMap<TokenId, TokenAmount> = HashMap::new();
+    tokens.iter().try_for_each(|b| {
+        if let Some(amt) = res.get_mut(&b.token_id) {
+            *amt = amt.checked_add(&b.amount)?;
+        } else {
+            res.insert(b.token_id.clone(), b.amount);
+        }
+        Ok(())
+    })?;
+    Ok(res)
+}
+
+fn check_enough_token_burn_permit(
+    burned_tokens: &HashMap<TokenId, TokenAmount>,
+    permits: &HashMap<TokenId, TokenAmount>,
+) -> Result<(), TxBuilderError> {
+    for (burn_token_id, burn_amt) in burned_tokens {
+        if let Some(burn_amt_permit) = permits.get(burn_token_id) {
+            if burn_amt > burn_amt_permit {
+                return Err(TxBuilderError::TokenBurnPermitExceeded {
+                    permit: (burn_token_id.clone(), *burn_amt_permit).into(),
+                    try_to_burn: (burn_token_id.clone(), *burn_amt).into(),
+                });
+            }
+        } else {
+            return Err(TxBuilderError::TokenBurnPermitMissing {
+                try_to_burn: (burn_token_id.clone(), *burn_amt).into(),
+            });
+        }
+    }
+    Ok(())
+}
+
+fn check_unused_token_burn_permit(
+    burned_tokens: &HashMap<TokenId, TokenAmount>,
+    permits: &HashMap<TokenId, TokenAmount>,
+) -> Result<(), TxBuilderError> {
+    for (permit_token_id, permit_amt) in permits {
+        if let Some(burn_amt) = burned_tokens.get(permit_token_id) {
+            if burn_amt < permit_amt {
+                return Err(TxBuilderError::TokenBurnPermitUnused {
+                    token_id: permit_token_id.clone(),
+                    amount: *permit_amt.as_u64() - *burn_amt.as_u64(),
+                });
+            }
+        } else {
+            return Err(TxBuilderError::TokenBurnPermitUnused {
+                token_id: permit_token_id.clone(),
+                amount: *permit_amt.as_u64(),
+            });
+        }
+    }
+    Ok(())
 }
 
 #[cfg(test)]
@@ -370,7 +444,7 @@ mod tests {
     }
 
     #[test]
-    fn test_burn_token() {
+    fn test_burn_token_wo_permit() {
         let token_pair = Token {
             token_id: force_any_val::<TokenId>(),
             amount: 100.try_into().unwrap(),
@@ -389,10 +463,11 @@ mod tests {
         let tx_fee = BoxValue::SAFE_USER_MIN;
         let out_box_value = BoxValue::SAFE_USER_MIN;
         let target_balance = out_box_value.checked_add(&tx_fee).unwrap();
-        let target_tokens = vec![Token {
+        let target_token = Token {
             amount: 10.try_into().unwrap(),
             ..token_pair
-        }];
+        };
+        let target_tokens = vec![target_token.clone()];
         let box_selection = SimpleBoxSelector::new()
             .select(inputs, target_balance, target_tokens.as_slice())
             .unwrap();
@@ -408,10 +483,164 @@ mod tests {
             force_any_val::<Address>(),
             BoxValue::SAFE_USER_MIN,
         );
-        let tx = tx_builder.build().unwrap();
-        assert!(
-            tx.output_candidates.get(0).unwrap().tokens().is_none(),
-            "expected empty tokens in the first output box"
+        let res = tx_builder.build();
+        assert!(res.is_err(), "error on burn token without permit");
+        assert_eq!(
+            res,
+            Err(TxBuilderError::TokenBurnPermitMissing {
+                try_to_burn: target_token
+            })
+        );
+    }
+
+    #[test]
+    fn test_burn_token_w_permit_too_low() {
+        let token_pair = Token {
+            token_id: force_any_val::<TokenId>(),
+            amount: 100.try_into().unwrap(),
+        };
+        let input_box = ErgoBox::new(
+            10000000i64.try_into().unwrap(),
+            force_any_val::<ErgoTree>(),
+            vec![token_pair.clone()].try_into().ok(),
+            NonMandatoryRegisters::empty(),
+            1,
+            force_any_val::<TxId>(),
+            0,
+        )
+        .unwrap();
+        let inputs: Vec<ErgoBox> = vec![input_box];
+        let tx_fee = BoxValue::SAFE_USER_MIN;
+        let out_box_value = BoxValue::SAFE_USER_MIN;
+        let target_balance = out_box_value.checked_add(&tx_fee).unwrap();
+        let token_to_burn = Token {
+            amount: 10.try_into().unwrap(),
+            ..token_pair.clone()
+        };
+        let target_tokens = vec![token_to_burn.clone()];
+        let box_selection = SimpleBoxSelector::new()
+            .select(inputs, target_balance, target_tokens.as_slice())
+            .unwrap();
+        let box_builder =
+            ErgoBoxCandidateBuilder::new(out_box_value, force_any_val::<ErgoTree>(), 0);
+        let out_box = box_builder.build().unwrap();
+        let outputs = vec![out_box];
+        let mut tx_builder = TxBuilder::new(
+            box_selection,
+            outputs,
+            0,
+            tx_fee,
+            force_any_val::<Address>(),
+            BoxValue::SAFE_USER_MIN,
+        );
+        let token_burn_permit = Token {
+            amount: 5.try_into().unwrap(),
+            ..token_pair
+        };
+        tx_builder.set_token_burn_permit(vec![token_burn_permit.clone()]);
+        let res = tx_builder.build();
+        assert!(res.is_err());
+        assert_eq!(
+            res,
+            Err(TxBuilderError::TokenBurnPermitExceeded {
+                try_to_burn: token_to_burn,
+                permit: token_burn_permit,
+            })
+        );
+    }
+
+    #[test]
+    fn test_burn_token() {
+        let token_pair = Token {
+            token_id: force_any_val::<TokenId>(),
+            amount: 100.try_into().unwrap(),
+        };
+        let input_box = ErgoBox::new(
+            10000000i64.try_into().unwrap(),
+            force_any_val::<ErgoTree>(),
+            vec![token_pair.clone()].try_into().ok(),
+            NonMandatoryRegisters::empty(),
+            1,
+            force_any_val::<TxId>(),
+            0,
+        )
+        .unwrap();
+        let inputs: Vec<ErgoBox> = vec![input_box];
+        let tx_fee = BoxValue::SAFE_USER_MIN;
+        let out_box_value = BoxValue::SAFE_USER_MIN;
+        let target_balance = out_box_value.checked_add(&tx_fee).unwrap();
+        let token_to_burn = Token {
+            amount: 10.try_into().unwrap(),
+            ..token_pair
+        };
+        let target_tokens = vec![token_to_burn.clone()];
+        let box_selection = SimpleBoxSelector::new()
+            .select(inputs, target_balance, target_tokens.as_slice())
+            .unwrap();
+        let box_builder =
+            ErgoBoxCandidateBuilder::new(out_box_value, force_any_val::<ErgoTree>(), 0);
+        let out_box = box_builder.build().unwrap();
+        let outputs = vec![out_box];
+        let mut tx_builder = TxBuilder::new(
+            box_selection,
+            outputs,
+            0,
+            tx_fee,
+            force_any_val::<Address>(),
+            BoxValue::SAFE_USER_MIN,
+        );
+        tx_builder.set_token_burn_permit(vec![token_to_burn]);
+        let _ = tx_builder.build().unwrap();
+    }
+
+    #[test]
+    fn test_token_burn_permit_wo_burn() {
+        let token_pair = Token {
+            token_id: force_any_val::<TokenId>(),
+            amount: 100.try_into().unwrap(),
+        };
+        let input_box = ErgoBox::new(
+            10000000i64.try_into().unwrap(),
+            force_any_val::<ErgoTree>(),
+            vec![token_pair.clone()].try_into().ok(),
+            NonMandatoryRegisters::empty(),
+            1,
+            force_any_val::<TxId>(),
+            0,
+        )
+        .unwrap();
+        let inputs: Vec<ErgoBox> = vec![input_box];
+        let tx_fee = BoxValue::SAFE_USER_MIN;
+        let out_box_value = BoxValue::SAFE_USER_MIN;
+        let target_balance = out_box_value.checked_add(&tx_fee).unwrap();
+        let token_to_burn = Token {
+            amount: 10.try_into().unwrap(),
+            ..token_pair
+        };
+        let box_selection = SimpleBoxSelector::new()
+            .select(inputs, target_balance, &Vec::new())
+            .unwrap();
+        let box_builder =
+            ErgoBoxCandidateBuilder::new(out_box_value, force_any_val::<ErgoTree>(), 0);
+        let out_box = box_builder.build().unwrap();
+        let outputs = vec![out_box];
+        let mut tx_builder = TxBuilder::new(
+            box_selection,
+            outputs,
+            0,
+            tx_fee,
+            force_any_val::<Address>(),
+            BoxValue::SAFE_USER_MIN,
+        );
+        tx_builder.set_token_burn_permit(vec![token_to_burn.clone()]);
+        let res = tx_builder.build();
+        assert!(res.is_err());
+        assert_eq!(
+            res,
+            Err(TxBuilderError::TokenBurnPermitUnused {
+                token_id: token_to_burn.token_id,
+                amount: *token_to_burn.amount.as_u64(),
+            })
         );
     }
 
diff --git a/ergotree-ir/src/chain/token.rs b/ergotree-ir/src/chain/token.rs
index 069b54c4b..10de62b7c 100644
--- a/ergotree-ir/src/chain/token.rs
+++ b/ergotree-ir/src/chain/token.rs
@@ -100,7 +100,7 @@ impl TokenAmount {
             .checked_add(rhs.0)
             .ok_or(TokenAmountError::Overflow)?;
         if raw > Self::MAX_RAW {
-            Err(TokenAmountError::OutOfBounds(raw))
+            Err(TokenAmountError::OutOfBounds(raw as i64))
         } else {
             Ok(Self(raw))
         }
@@ -113,7 +113,7 @@ impl TokenAmount {
             .checked_sub(rhs.0)
             .ok_or(TokenAmountError::Overflow)?;
         if raw < Self::MIN_RAW {
-            Err(TokenAmountError::OutOfBounds(raw))
+            Err(TokenAmountError::OutOfBounds(raw as i64))
         } else {
             Ok(Self(raw))
         }
@@ -130,7 +130,7 @@ impl TokenAmount {
 pub enum TokenAmountError {
     /// Value is out of bounds
     #[error("Token amount is out of bounds: {0}")]
-    OutOfBounds(u64),
+    OutOfBounds(i64),
     /// Overflow
     #[error("Overflow")]
     Overflow,
@@ -143,7 +143,7 @@ impl TryFrom<u64> for TokenAmount {
         if (TokenAmount::MIN_RAW..=TokenAmount::MAX_RAW).contains(&v) {
             Ok(TokenAmount(v))
         } else {
-            Err(TokenAmountError::OutOfBounds(v))
+            Err(TokenAmountError::OutOfBounds(v as i64))
         }
     }
 }


Test patch:
diff --git a/bindings/ergo-lib-wasm/tests/test_transaction.js b/bindings/ergo-lib-wasm/tests/test_transaction.js
index 19da1284f..07e5dbfd4 100644
--- a/bindings/ergo-lib-wasm/tests/test_transaction.js
+++ b/bindings/ergo-lib-wasm/tests/test_transaction.js
@@ -153,6 +153,7 @@ it('TxBuilder burn token test', async () => {
   const data_inputs = new ergo_wasm.DataInputs();
   const tx_builder = ergo_wasm.TxBuilder.new(box_selection, tx_outputs, 0, fee, change_address, min_change_value);
   tx_builder.set_data_inputs(data_inputs);
+  tx_builder.set_token_burn_permit(tokens);
   const tx = tx_builder.build();
   assert(tx != null);
 });
@@ -301,5 +302,5 @@ it('signing multi signature transaction (issue 597)', async () => {
   const prover = ergo_wasm.Wallet.from_secrets(sks)
   assert(prover != null);
   const signed = prover.sign_reduced_transaction(reduced)
-  console.log(JSON.stringify(signed.to_json()))
+  // console.log(JSON.stringify(signed.to_json()))
 });
\ No newline at end of file