From 39c3ec5e1d5279fac5b9a945931d536ecd354834 Mon Sep 17 00:00:00 2001 From: GracedEternalKingCabbageMan <151803062+GracedEternalKingCabbageMan@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:26:02 +0200 Subject: [PATCH] pset: sign an issuing input over its own outpoint and denomination A PSET made from a transaction with an issuing input (from_tx, as a caller holding a whole transaction signs it) extracted another transaction than the one given, so the signature covered another hash: - Input::from_txin set bit 31 of the output index to mark the issuance, and extract_tx and issuance_ids read the index as the outpoint's. The transaction signed spent output 2^31 + n, and the issued asset's id was computed over it. - Input::asset_issuance() wrote denomination 8 whatever the issuance said. Sequentia serializes the denomination after the inflation keys, so it is part of the signature hash. The issuance is no longer flagged in the index (Elements Core's PSET does not flag it either), and Input::previous_outpoint() masks off any flag a PSET carries there, as upstream rust-elements 0.27 does; the extracted transaction, the asset id and the kit's readers of an input's outpoint use it. The denomination has no PSET field, so it is kept in a proprietary key (prefix "sequentia", subtype 0), written only when it is not the default 8, so existing PSETs serialize as before. The new tests fail before this change: the outpoint came back as vout 2147483648, the denomination as 8, and the kit's signature over an issuing input did not verify against the transaction's own sighash. --- SEQUENTIA.md | 11 ++++ lwk_bindings/src/pset/input.rs | 2 +- lwk_common/src/lib.rs | 7 +- lwk_common/src/model.rs | 2 +- lwk_signer/src/software.rs | 67 +++++++++++++++++++ lwk_wasm/src/pset.rs | 2 +- lwk_wollet/src/pset_create.rs | 2 +- rust-elements/src/pset/map/input.rs | 99 +++++++++++++++++++++++++---- rust-elements/src/pset/mod.rs | 61 +++++++++++++++++- 9 files changed, 231 insertions(+), 22 deletions(-) diff --git a/SEQUENTIA.md b/SEQUENTIA.md index 52113a5c4..d7139427d 100644 --- a/SEQUENTIA.md +++ b/SEQUENTIA.md @@ -63,6 +63,17 @@ its `sequentia` cargo feature: `src/sighash.rs`, `src/blind.rs` touch points): Sequentia issuance transactions carry an extra `nDenomination` byte; the feature adds it to issuance (de)serialization, PSET input mapping, and sighash computation. + The PSET format has no field for it, so a PSET input keeps it in a + proprietary key (prefix `sequentia`, subtype `0x00`, one byte; + `Input::issuance_denomination`, `set_issuance_denomination`), written only + when it is not the node's default, 8. `Input::from_txin` carries it from the + transaction, and the transaction a PSET extracts has it, so a PSET made from + a transaction is signed over that transaction. +- `src/pset/map/input.rs`, `src/pset/mod.rs`: an input's issuance is not + flagged in its output index (`Input::from_txin`), as Elements Core's PSET + does not flag it, and `Input::previous_outpoint()` masks off any flag a PSET + carries there. The extracted transaction and the issued asset's id use that + outpoint, so an issuance input is signed over the outpoint it spends. - `src/address.rs`: `AddressParams::SEQUENTIA_TESTNET` (base58 p2pkh 111 / p2sh 196 / blinded 70; bech32 HRP `tb`; blech32 HRP `tsqb`) and address-string parsing for those prefixes. Sequentia is transparent by default: the default diff --git a/lwk_bindings/src/pset/input.rs b/lwk_bindings/src/pset/input.rs index 30dc14fa2..1a2c5abc8 100644 --- a/lwk_bindings/src/pset/input.rs +++ b/lwk_bindings/src/pset/input.rs @@ -45,7 +45,7 @@ impl PsetInput { /// Prevout vout of the input. pub fn previous_vout(&self) -> u32 { - self.inner.previous_output_index + self.inner.previous_outpoint().vout } /// Prevout scriptpubkey of the input. diff --git a/lwk_common/src/lib.rs b/lwk_common/src/lib.rs index 6132f1ac6..d12e8b663 100644 --- a/lwk_common/src/lib.rs +++ b/lwk_common/src/lib.rs @@ -85,7 +85,7 @@ use elements_miniscript::elements::{ pset::PartiallySignedTransaction, script::Builder, secp256k1_zkp::{All, Generator, PedersenCommitment, Secp256k1}, - AssetId, BlindAssetProofs, BlindValueProofs, OutPoint, Script, TxOutSecrets, + AssetId, BlindAssetProofs, BlindValueProofs, Script, TxOutSecrets, }; use elements_miniscript::{ConfidentialDescriptor, DescriptorPublicKey}; use std::collections::btree_map::BTreeMap; @@ -194,10 +194,7 @@ pub fn pset_balance( for (idx, input) in pset.inputs().iter().enumerate() { match input.witness_utxo.as_ref() { None => { - let previous_outpoint = OutPoint { - txid: input.previous_txid, - vout: input.previous_output_index, - }; + let previous_outpoint = input.previous_outpoint(); return Err(Error::MissingPreviousOutput { idx, previous_outpoint, diff --git a/lwk_common/src/model.rs b/lwk_common/src/model.rs index 2bb31cb13..2a90b43dc 100644 --- a/lwk_common/src/model.rs +++ b/lwk_common/src/model.rs @@ -75,7 +75,7 @@ impl Issuance { // These are meaningless if inner is null let (asset, token) = input.issuance_ids(); - let prev_output = OutPoint::new(input.previous_txid, input.previous_output_index); + let prev_output = input.previous_outpoint(); Self { asset, token, diff --git a/lwk_signer/src/software.rs b/lwk_signer/src/software.rs index 212ec8b4e..e5935ec3f 100644 --- a/lwk_signer/src/software.rs +++ b/lwk_signer/src/software.rs @@ -568,6 +568,73 @@ mod tests { assert!(sig_low_r.len() < sig_no_grind.len()); } + /// A caller holding a whole transaction signs it through a PSET made + /// from it. For an input that issues an asset, the signature must cover + /// that transaction: the outpoint it spends and its issuance, whose + /// denomination Sequentia serializes and so signs over. + #[test] + fn an_issuing_input_is_signed_over_its_own_transaction() { + use elements_miniscript::elements::{ + confidential, hashes::hash160, secp256k1_zkp::ecdsa, AssetId, AssetIssuance, LockTime, + OutPoint, PubkeyHash, Script, Sequence, Transaction, TxIn, TxOut, Txid, WPubkeyHash, + }; + let signer = SwSigner::new(lwk_test_util::TEST_MNEMONIC, false).unwrap(); + let path: DerivationPath = "m/84'/1'/0'/0/0".parse().unwrap(); + let pk = bitcoin::PublicKey::new(signer.derive_xpub(&path).unwrap().public_key); + let h = hash160::Hash::hash(&pk.to_bytes()).to_byte_array(); + let spk = Script::new_v0_wpkh(&WPubkeyHash::from_byte_array(h)); + let script_code = Script::new_p2pkh(&PubkeyHash::from_byte_array(h)); + let asset = AssetId::from_slice(&[9; 32]).unwrap(); + let value = 10_000; + for (vout, denomination) in [(0, 8), (1, 8), (1, 0), (3, 2)] { + let input = TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([7; 32]), vout), + sequence: Sequence::MAX, + asset_issuance: AssetIssuance { + amount: confidential::Value::Explicit(1), + denomination, + ..AssetIssuance::null() + }, + ..Default::default() + }; + let tx = Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: vec![input], + output: vec![TxOut::new_fee(value, asset)], + }; + let mut pset = PartiallySignedTransaction::from_tx(tx.clone()); + let utxo = TxOut { + asset: confidential::Asset::Explicit(asset), + value: confidential::Value::Explicit(value), + nonce: confidential::Nonce::Null, + script_pubkey: spk.clone(), + witness: Default::default(), + }; + pset.inputs_mut()[0].witness_utxo = Some(utxo); + pset.inputs_mut()[0] + .bip32_derivation + .insert(pk, (signer.fingerprint(), path.clone())); + assert_eq!(signer.sign(&mut pset).unwrap(), 1); + let raw = pset.inputs()[0].partial_sigs[&pk].clone(); + let sig = ecdsa::Signature::from_der(&raw[..raw.len() - 1]).unwrap(); + // The signature hash of the transaction itself. + let digest = SighashCache::new(&tx).segwitv0_sighash( + 0, + &script_code, + confidential::Value::Explicit(value), + EcdsaSighashType::All, + ); + let msg = Message::from_digest(digest.to_byte_array()); + signer + .secp + .verify_ecdsa(&msg, &sig, &pk.inner) + .unwrap_or_else(|e| { + panic!("vout {vout}, denomination {denomination}: the signature does not cover the transaction: {e}") + }); + } + } + #[test] fn test_sign_verify() { let signer = SwSigner::new(lwk_test_util::TEST_MNEMONIC, true).unwrap(); diff --git a/lwk_wasm/src/pset.rs b/lwk_wasm/src/pset.rs index 698331af6..b98abc81d 100644 --- a/lwk_wasm/src/pset.rs +++ b/lwk_wasm/src/pset.rs @@ -156,7 +156,7 @@ impl PsetInput { /// Prevout vout of the input #[wasm_bindgen(js_name = previousVout)] pub fn previous_vout(&self) -> u32 { - self.inner.previous_output_index + self.inner.previous_outpoint().vout } /// Prevout scriptpubkey of the input diff --git a/lwk_wollet/src/pset_create.rs b/lwk_wollet/src/pset_create.rs index 8ab9b9c61..e506db8ee 100644 --- a/lwk_wollet/src/pset_create.rs +++ b/lwk_wollet/src/pset_create.rs @@ -113,7 +113,7 @@ impl Wollet { let (asset, token) = input.issuance_ids(); if let Some(contract) = contract.as_ref() { - let issuance_prevout = OutPoint::new(input.previous_txid, input.previous_output_index); + let issuance_prevout = input.previous_outpoint(); let contract = serde_json::to_string(&contract)?; pset.add_asset_metadata(asset, &AssetMetadata::new(contract, issuance_prevout)); // TODO: handle blinded issuance diff --git a/rust-elements/src/pset/map/input.rs b/rust-elements/src/pset/map/input.rs index 13bc22492..545ab88c3 100644 --- a/rust-elements/src/pset/map/input.rs +++ b/rust-elements/src/pset/map/input.rs @@ -168,6 +168,36 @@ const PSBT_ELEMENTS_IN_ASSET_PROOF: u8 = 0x14; /// Note that this does not indicate actual blinding status, /// but rather the expected blinding status prior to signing. const PSBT_ELEMENTS_IN_BLINDED_ISSUANCE: u8 = 0x15; + +/// Bit of [`Input::previous_output_index`] marking a pegin input. +const OUTPOINT_PEGIN_FLAG: u32 = 1 << 30; +/// Bit of [`Input::previous_output_index`] some writers set to mark an +/// issuance; never set here, and masked off when read. +const OUTPOINT_ISSUANCE_FLAG: u32 = 1 << 31; +/// The output index of a coinbase input: all 1's, and no flags. +const OUTPOINT_COINBASE_INDEX: u32 = 0xffff_ffff; + +/// SEQUENTIA: the prefix of the proprietary keys this crate adds to a PSET +/// input for what Sequentia's transactions carry and the PSET format does not. +#[cfg(feature = "sequentia")] +pub const SEQUENTIA_PROPRIETARY_PREFIX: &[u8] = b"sequentia"; +/// SEQUENTIA: the proprietary subtype holding an issuance's denomination, one +/// byte ([`Input::issuance_denomination`]). +#[cfg(feature = "sequentia")] +pub const SEQUENTIA_IN_ISSUANCE_DENOMINATION: u8 = 0x00; +/// SEQUENTIA: the denomination of an issuance that names none, as the node's +/// `CAssetIssuance` sets it. +#[cfg(feature = "sequentia")] +const DEFAULT_DENOMINATION: u8 = 8; + +#[cfg(feature = "sequentia")] +fn denomination_key() -> raw::ProprietaryKey { + raw::ProprietaryKey { + prefix: SEQUENTIA_PROPRIETARY_PREFIX.to_vec(), + subtype: SEQUENTIA_IN_ISSUANCE_DENOMINATION.into(), + key: vec![], + } +} /// A key-value map for an input of the corresponding index in the unsigned /// transaction. #[derive(Clone, Debug, PartialEq)] @@ -465,11 +495,17 @@ impl Input { ret.final_script_witness = Some(txin.witness.script_witness); if txin.is_pegin { - ret.previous_output_index |= 1 << 30; + ret.previous_output_index |= OUTPOINT_PEGIN_FLAG; ret.pegin_witness = Some(txin.witness.pegin_witness); } if has_issuance { - ret.previous_output_index |= 1 << 31; + // SEQUENTIA: the issuance is not flagged in the output index, as + // Elements Core's PSET does not flag it: the index is the + // outpoint's, and the issuance fields say there is one. A flag + // here named another outpoint in the transaction extracted to + // sign, and in the issued asset's id. + #[cfg(feature = "sequentia")] + ret.set_issuance_denomination(txin.asset_issuance.denomination); ret.issuance_blinding_nonce = Some(txin.asset_issuance.asset_blinding_nonce); ret.issuance_asset_entropy = Some(txin.asset_issuance.asset_entropy); match txin.asset_issuance.amount { @@ -492,16 +528,33 @@ impl Input { ret } + /// The outpoint spent by this input. + /// + /// Flags a PSET may carry in the high bits of + /// [`Input::previous_output_index`] (the pegin flag, and the issuance + /// flag some writers set) are masked off; use [`Input::is_pegin`] and + /// [`Input::has_issuance`] to query them. An all-1's index (a coinbase + /// input) has no flags and is returned as it is. + pub fn previous_outpoint(&self) -> OutPoint { + let vout = if self.previous_output_index == OUTPOINT_COINBASE_INDEX { + self.previous_output_index + } else { + self.previous_output_index & !(OUTPOINT_PEGIN_FLAG | OUTPOINT_ISSUANCE_FLAG) + }; + OutPoint { + txid: self.previous_txid, + vout, + } + } + /// Compute the issuance asset ids from pset. This function does not check /// whether there is an issuance in this input. Returns (asset_id, token_id) pub fn issuance_ids(&self) -> (AssetId, AssetId) { let issue_nonce = self.issuance_blinding_nonce.unwrap_or(ZERO_TWEAK); let entropy = if issue_nonce == ZERO_TWEAK { - // new issuance - let prevout = OutPoint { - txid: self.previous_txid, - vout: self.previous_output_index, - }; + // new issuance; the entropy commits to the outpoint spent, without + // flags + let prevout = self.previous_outpoint(); let contract_hash = ContractHash::from_byte_array(self.issuance_asset_entropy.unwrap_or_default()); AssetId::generate_asset_entropy(prevout, contract_hash) @@ -523,7 +576,33 @@ impl Input { /// If the Pset Input is pegin pub fn is_pegin(&self) -> bool { - self.previous_output_index & (1 << 30) != 0 + self.previous_output_index & OUTPOINT_PEGIN_FLAG != 0 + } + + /// SEQUENTIA: the denomination (decimal places) of this input's + /// issuance. Sequentia serializes it after the inflation keys, so it is + /// part of what a signature covers. The PSET format has no field for it; + /// it is kept in a proprietary key ([`SEQUENTIA_PROPRIETARY_PREFIX`], + /// subtype [`SEQUENTIA_IN_ISSUANCE_DENOMINATION`]), and an input without + /// that key has the node's default, 8. + #[cfg(feature = "sequentia")] + pub fn issuance_denomination(&self) -> u8 { + match self.proprietary.get(&denomination_key()) { + Some(v) if v.len() == 1 => v[0], + _ => DEFAULT_DENOMINATION, + } + } + + /// SEQUENTIA: set the denomination of this input's issuance + /// ([`Input::issuance_denomination`]). The default, 8, is kept as no key, + /// so a PSET whose issuances all have it is serialized as before. + #[cfg(feature = "sequentia")] + pub fn set_issuance_denomination(&mut self, denomination: u8) { + if denomination == DEFAULT_DENOMINATION { + self.proprietary.remove(&denomination_key()); + } else { + self.proprietary.insert(denomination_key(), vec![denomination]); + } } /// Get the issuance for this tx input @@ -544,10 +623,8 @@ impl Input { (_, Some(comm)) => confidential::Value::Confidential(comm), (Some(x), None) => confidential::Value::Explicit(x), }, - // SEQUENTIA: PSET does not (yet) carry the issuance denomination; - // default to 8 to match AssetIssuance::null(). #[cfg(feature = "sequentia")] - denomination: 8, + denomination: self.issuance_denomination(), } } } diff --git a/rust-elements/src/pset/mod.rs b/rust-elements/src/pset/mod.rs index a53ee2e35..62d507047 100644 --- a/rust-elements/src/pset/mod.rs +++ b/rust-elements/src/pset/mod.rs @@ -46,7 +46,7 @@ use crate::{ confidential::{AssetBlindingFactor, ValueBlindingFactor}, TxOutSecrets, }; -use crate::{OutPoint, LockTime, Sequence, SurjectionInput, Transaction, TxIn, TxInWitness, TxOut, TxOutWitness, Txid}; +use crate::{LockTime, Sequence, SurjectionInput, Transaction, TxIn, TxInWitness, TxOut, TxOutWitness, Txid}; use secp256k1_zkp::rand::{CryptoRng, RngCore}; use secp256k1_zkp::{self, RangeProof, SecretKey, SurjectionProof}; @@ -285,7 +285,7 @@ impl PartiallySignedTransaction { for psetin in self.inputs.iter() { let txin = TxIn { - previous_output: OutPoint::new(psetin.previous_txid, psetin.previous_output_index), + previous_output: psetin.previous_outpoint(), is_pegin: psetin.is_pegin(), script_sig: psetin.final_script_sig.clone().unwrap_or_default(), sequence: psetin.sequence.unwrap_or(Sequence::MAX), @@ -773,6 +773,63 @@ impl Decodable for PartiallySignedTransaction { mod tests { use super::*; use crate::hex::{FromHex, ToHex}; + use crate::OutPoint; + + /// A transaction with one input, at output `vout` of a made-up + /// transaction, that issues one explicit atom of a new asset with + /// `denomination` decimal places. + #[cfg(feature = "sequentia")] + fn issuing_tx(vout: u32, denomination: u8) -> Transaction { + use crate::hashes::Hash; + use crate::{AssetId, AssetIssuance}; + let issuance = AssetIssuance { + amount: confidential::Value::Explicit(1), + denomination, + ..AssetIssuance::null() + }; + let input = TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([7; 32]), vout), + sequence: Sequence::MAX, + asset_issuance: issuance, + ..Default::default() + }; + let fee = TxOut::new_fee(1_000, AssetId::from_slice(&[9; 32]).unwrap()); + Transaction { version: 2, lock_time: LockTime::ZERO, input: vec![input], output: vec![fee] } + } + + // SEQUENTIA: a PSET made from a transaction with an issuance input + // extracts that same transaction: the outpoint it spends (the issuance is + // no flag of the output index) and the issuance's denomination, which + // Sequentia serializes after the inflation keys and so signs over. + #[test] + #[cfg(feature = "sequentia")] + fn an_issuance_input_keeps_its_outpoint() { + for vout in [0, 1, 5] { + let tx = issuing_tx(vout, 8); + let pset = PartiallySignedTransaction::from_tx(tx.clone()); + let back = pset.extract_tx().unwrap(); + assert_eq!(back.input[0].previous_output, tx.input[0].previous_output, "vout {vout}"); + assert_eq!(back.txid(), tx.txid()); + // The issued asset's id commits to the outpoint spent. + assert_eq!(pset.inputs()[0].issuance_ids(), tx.input[0].issuance_ids()); + } + } + + #[test] + #[cfg(feature = "sequentia")] + fn an_issuance_input_keeps_its_denomination() { + for denomination in [0, 2, 8] { + let tx = issuing_tx(1, denomination); + let pset = PartiallySignedTransaction::from_tx(tx.clone()); + let back = pset.extract_tx().unwrap(); + assert_eq!(back.input[0].asset_issuance.denomination, denomination); + assert_eq!(back.input[0].asset_issuance, tx.input[0].asset_issuance); + // Through the PSET's serialized form too. + let bytes = encode::serialize(&pset); + let read: PartiallySignedTransaction = encode::deserialize(&bytes).unwrap(); + assert_eq!(read.extract_tx().unwrap().input[0].asset_issuance.denomination, denomination); + } + } fn tx_pset_rtt(tx_hex: &str) { let tx: Transaction =