Skip to content

pset: use the ECDH pubkey as the nonce of outputs not marked for blinding - #306

Open
KyrylR wants to merge 2 commits into
ElementsProject:masterfrom
KyrylR:feature/pset-to-txout-ecdh-nonce
Open

KyrylR wants to merge 2 commits into
ElementsProject:masterfrom
KyrylR:feature/pset-to-txout-ecdh-nonce

Conversation

@KyrylR

@KyrylR KyrylR commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

TL;DR of changes

Output state Before After
Has blinding key, not yet blinded blinding key blinding key (unchanged)
Has blinding key, blinded ECDH pubkey ECDH pubkey (unchanged)
No blinding key, has ECDH pubkey null ECDH pubkey
No blinding key, no ECDH pubkey null null (unchanged)

Output::from_txout on any confidential TxOut stores the nonce as ecdh_pubkey without a blinding key.
So from_txout(x).to_txout() dropps the nonce on the current master, even for fully blinded outputs.

Summary by AI

Background

Each output in an Elements transaction has a nonce field. For a blinded output, the nonce holds the sender's
ephemeral ECDH pubkey. The receiver combines it with their own blinding private key to get a shared secret. They
then use that secret to "rewind" the rangeproof and recover the value and blinding factors. With a null nonce, the
receiver can't unblind the output.

A PSET Output stores two related keys:

  • blinding_key: the receiver's public blinding key. It is set before blinding and marks the output as "to be
    blinded".
  • ecdh_pubkey: the ephemeral key, set by the blinder.

What was wrong

to_txout (src/pset/map/output.rs:254) picked the nonce like this:

nonce: if self .is_partially_blinded() { ecdh_pubkey } else { blinding_key }

is_partially_blinded() only returns true when a blinding_key is present. So for an output with an ecdh_pubkey but
no blinding_key, it took the else branch and returned the absent blinding key, giving a null nonce.

That happens in two cases:

  1. Already on master: Output::from_txout reads a confidential TxOut's nonce into ecdh_pubkey and sets no
    blinding_key. Converting it back with to_txout() silently dropped the nonce.
  2. Added by the value only blinding feature: blind_last_with_modes removes blinding_key from value-only outputs so Elements Core accepts the PSET. Without this fix, such an output's to_txout() would have a null nonce. Meanwhile extract_tx(), which already reads ecdh_pubkey directly, would give the correct one. The two functions would disagree about the same output.

The fix

Output state Before After
Has blinding key, not yet blinded blinding key blinding key (unchanged)
Has blinding key, blinded ECDH pubkey ECDH pubkey (unchanged)
No blinding key, has ECDH pubkey null ECDH pubkey
No blinding key, no ECDH pubkey null null (unchanged)

Only the third row changes. That matches extract_tx and Elements Core's GetUnsignedTx, both of which use the ECDH
pubkey whenever it's set.

The one internal caller, blind_non_last, only converts outputs in the first row, so its behaviour is the same.

Tests

  • In this commit: to_txout_nonce (output.rs:648) checks all four rows.

The one visible change to the public API is the third table row, Output::to_txout on a
key-less output with an ECDH pubkey, and the commit message says so.

@KyrylR
KyrylR force-pushed the feature/pset-to-txout-ecdh-nonce branch from 17d8a4c to db70ff5 Compare November 4, 2026 14:03
Comment thread src/pset/map/output.rs
Comment on lines -250 to +254
nonce: if self.is_partially_blinded() {
self.ecdh_pubkey
.map(|pk| confidential::Nonce::from(pk.inner))
} else {
self.blinding_key
.map(|pk| confidential::Nonce::from(pk.inner))
}
.unwrap_or_default(),
nonce: self
.ecdh_pubkey
.or(self.blinding_key)
.map(|pk| confidential::Nonce::from(pk.inner))
.unwrap_or_default(),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure why previous code had this if statement

After I added one more condition it started looking too complicated, so I decided to simplify it and it turned out that simple rule "use the ECDH pubkey if there is one, otherwise the receiver's blinding key" can be applied here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the ECDH pubkey becomes the nonce whenever it's set in elements core

PartiallySignedTransaction::GetUnsignedTx (
https://github.com/ElementsProject/elements/blob/2ac51cac13876bf14d3d709b07535b1868eee896/src/psbt.cpp#L183-L185) does:

if (output.m_ecdh_pubkey.IsValid() && !force_unblinded) {
    txout.nNonce.vchCommitment.insert(..., output.m_ecdh_pubkey.begin(), output.m_ecdh_pubkey.end());
}

@apoelstra apoelstra left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK db70ff5; successfully ran local tests; this looks good, though this whole construction is weird and I wonder if we can represent these states in the Rust type system better

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants