Hacktoberfest 2026 : les issues que les mainteneurs ont marquées pour octobre, ouvertes et accessibles aux débutants. Parcourir les issues Hacktoberfest

PSET outputs lose their nonce on round trip: `extract_tx` for unblinded outputs, `to_txout` for blinded ones

Ouverte
#299 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub

Personne n'a encore pris cette issue.

Évaluation

Difficulté
4/5
Temps estimé
3-5 jours
Accessibilité débutants
52/100
Type d'issue
Bug
Clarté
Plutôt claire
Activité
Active
Stack technique
rust
Domaine
blockchain

Piste de recherche

Start with the cited conversion code in src/pset/map/output.rs and src/pset/mod.rs, then run the two nonce round-trip tests from the reproduction. Trace how from_txout, Output::to_txout, and extract_tx choose nonce fields, and confirm the intended PSET semantics before changing them. Done means both unblinded and blinded round trips preserve the nonce while missing asset/value errors remain correct.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Description

While working on Sighashes in SimplicityHL, the LLM picked up this issue. I unfortunately don't know enough of the design to know whether this field was left out on purpose. I've created an issue so long to document the problem.

Summary

A transaction loaded with PartiallySignedTransaction::from_tx doesn't always come back with the
same output nonces. There are two converters from a PSET output back to a TxOut, and each one
drops the nonce in a different case:

Output loaded with from_tx extract_tx Output::to_txout
unblinded, with a nonce nonce lost (Null) ok
blinded, with a nonce ok nonce lost (Null)

Checked at 582ea613cb. The code is the same in 0.25.3 and 0.26.2.

Cause

Output::from_txout puts the nonce in ecdh_pubkey for a blinded output and in blinding_key
for an unblinded one
(src/pset/map/output.rs#L212-L222):

if txout.is_partially_blinded() {
    rv.ecdh_pubkey = txout.nonce.commitment().map(|pk| PublicKey { inner: pk, compressed: true });
} else {
    rv.blinding_key = txout.nonce.commitment().map(|pk| PublicKey { inner: pk, compressed: true });
}

PartiallySignedTransaction::extract_tx only reads ecdh_pubkey
(src/pset/mod.rs#L330-L333),
so it misses the unblinded case:

nonce: out
    .ecdh_pubkey
    .map(|x| confidential::Nonce::from(x.inner))
    .unwrap_or_default(),

Output::to_txout picks the field with Output::is_partially_blinded
(src/pset/map/output.rs#L250-L256).
That function requires is_marked_for_blinding(), which means blinding_key.is_some()
(L272-L283).
A blinded output from from_txout has ecdh_pubkey set but no blinding_key, so to_txout takes
the else branch, reads blinding_key, and gets nothing.

Because of this, changing extract_tx to call to_txout as it is now would move the bug to
blinded outputs rather than fix it.

Reproduction

use elements::confidential::{Asset, Nonce, Value};
use elements::pset::PartiallySignedTransaction;
use elements::secp256k1_zkp::{Generator, Keypair, Secp256k1, Tag, Tweak};
use elements::{AssetId, LockTime, Script, Transaction, TxOut, TxOutWitness};

fn tx_with_output(asset: Asset) -> Transaction {
    let secp = Secp256k1::new();
    let pk = Keypair::from_seckey_slice(&secp, &[1u8; 32]).unwrap().public_key();
    Transaction {
        version: 2,
        lock_time: LockTime::ZERO,
        input: vec![],
        output: vec![TxOut {
            asset,
            value: Value::Explicit(1_000),
            nonce: Nonce::Confidential(pk),
            script_pubkey: Script::new(),
            witness: TxOutWitness::default(),
        }],
    }
}

#[test]
fn unblinded_output_nonce_round_trip() {
    let tx = tx_with_output(Asset::Explicit(AssetId::default()));
    let pset = PartiallySignedTransaction::from_tx(tx.clone());

    assert_eq!(pset.outputs()[0].to_txout().nonce, tx.output[0].nonce); // passes
    assert_eq!(pset.extract_tx().unwrap().output[0].nonce, tx.output[0].nonce); // fails: Null
}

#[test]
fn blinded_output_nonce_round_trip() {
    let secp = Secp256k1::new();
    let asset = Generator::new_blinded(&secp, Tag::from([2u8; 32]), Tweak::from_slice(&[3u8; 32]).unwrap());
    let tx = tx_with_output(Asset::Confidential(asset));
    let pset = PartiallySignedTransaction::from_tx(tx.clone());

    assert_eq!(pset.extract_tx().unwrap().output[0].nonce, tx.output[0].nonce); // passes
    assert_eq!(pset.outputs()[0].to_txout().nonce, tx.output[0].nonce); // fails: Null
}

Both tests fail on their second assertion, with left: Null.

Impact

Values, assets and scripts are unaffected. The nonce is part of what signatures commit to (the
taproot sighash's sha_outputs, and Simplicity's outputs_hash). So the txid changes on a round
trip, and a signature made over the original transaction doesn't verify on the extracted one, and
vice versa. As far as I can tell this fails closed, with invalid signatures or a changed txid, and
doesn't put funds at risk.

The extract_tx case needs an unblinded output that carries a nonce, which is unusual. The
to_txout case affects ordinary blinded transactions loaded with from_tx.

Possible fix

Use one rule in both places, for example ecdh_pubkey.or(blinding_key), and have extract_tx build
its outputs with to_txout. extract_tx would still need to return its own errors when the asset
or value is missing. That makes both rows of the table round-trip, and ecdh_pubkey wins whenever
an output has been blinded.

I'm not sure whether this is the intended semantics. Putting an unblinded output's nonce in
blinding_key marks that output for blinding. Under strict PSET rules, extracting it before it has
been blinded might be meant to fail instead. I also haven't checked what Elements Core does here.
The history suggests the inconsistency is accidental: 724a14d changed extract_tx from
blinding_key to ecdh_pubkey, and dc350a0 replaced the ecdh_pubkey.or(blinding_key) rule in
to_txout that e73ee1f had added.

Unrelated, but nearby: extract_tx seems to return swapped errors. It returns
MissingOutputValue when the asset is missing and MissingOutputAsset when the value is missing
(src/pset/mod.rs#L319-L329).

I'm happy to open a PR with the tests and a fix if this direction sounds right.

Related

  • #292 fixed a similar round-trip problem for inputs: extract_tx left the peg-in and issuance
    flags in previous_output.vout.
Langage dominant
Rust
Étoiles
58
Forks
40
Merge moyen
2 j 2 h
PR mergées (30 j)
1

Préparer son environnement

Ce projet ne fournit ni conteneur de développement, ni Dockerfile, ni guide de contribution : l'installation est à votre charge. Commencez par son README, et consultez notre guide de la première contribution pour les étapes générales.

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Autres issues de ElementsProject/rust-elements

Toutes les issues de ElementsProject/rust-elements

Issues similaires

Plus d'issues Rust

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.