Skip to content

blind: accept zero-value outputs in verify_tx_amt_proofs - #300

Open
KyrylR wants to merge 2 commits into
ElementsProject:masterfrom
KyrylR:feature/zero-value-op-return-verification
Open

KyrylR wants to merge 2 commits into
ElementsProject:masterfrom
KyrylR:feature/zero-value-op-return-verification

Conversation

@KyrylR

@KyrylR KyrylR commented Oct 14, 2026

Copy link
Copy Markdown

This PR adds a condition check to verify_tx_amt_proofs function to filter out outputs that are provably unspendable and with explicit 0 value.


Current Transaction::verify_tx_amt_proofs converts every output into a Pedersen commitment:

rust-elements/src/blind.rs

Lines 1150 to 1187 in 582ea61

for (i, out) in self.output.iter().enumerate() {
// Compute the value commitments and asset generator
let out_commit = out
.get_value_commit(secp)
.map_err(|e| VerificationError::SpentTxOutError(i, e))?;
out_commits.push(out_commit);
// rangeproof checks
if let Some(comm) = out.value.commitment() {
let gen = out
.get_asset_gen(secp)
.map_err(|e| VerificationError::TxOutError(i, e))?;
let rangeproof = out
.witness
.rangeproof
.as_ref()
.ok_or(VerificationError::RangeProofMissing(i))?;
rangeproof
.verify(secp, comm, out.script_pubkey.as_bytes(), gen)
.map_err(|e| VerificationError::RangeProofError(i, e))?;
} else {
// No rangeproof checks for explicit values
}
// Surjection proof checks
if let Some(gen) = out.asset.commitment() {
let surjectionproof = out
.witness
.surjection_proof
.as_ref()
.ok_or(VerificationError::SurjectionProofMissing(i))?;
if !surjectionproof.verify(secp, gen, &domain) {
return Err(VerificationError::SurjectionProofVerificationError(i));
}
} else {
// No surjection proof checks for explicit assets
}
}

Explicit zero values have no nonzero commitment, so valid zero-value data outputs cannot pass this path.

The commitment helper already distinguishes unspendable zero outputs from invalid spendable zero outputs:

rust-elements/src/blind.rs

Lines 735 to 742 in 582ea61

if value == 0 {
// zero values are only allowed if they are provably
// unspendable.
if self.script_pubkey.is_provably_unspendable() {
return Err(TxOutError::ZeroValueCommitment);
}
return Err(TxOutError::NonUnspendableZeroValue);
}

but the transaction verifier (verify_tx_amt_proofs) propagates the former as an error.

Because of this callers must clone a transaction and filter valid zero outputs before verification.

Comment thread src/blind.rs Outdated
Comment on lines +1159 to +1164
if !out.witness.rangeproof.is_empty() {
return Err(VerificationError::RangeProofError(
i,
secp256k1_zkp::Error::InvalidRangeProof,
));
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Comment thread src/blind.rs Outdated
Comment on lines +1165 to +1170
if out.asset.is_explicit() && !out.witness.surjection_proof.is_empty() {
return Err(VerificationError::SurjectionProofError(
i,
secp256k1_zkp::Error::InvalidSurjectionProof,
));
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Comment thread src/blind.rs
Comment on lines +1155 to +1157
if out.asset.is_null() {
return Err(VerificationError::TxOutError(i, TxOutError::UnExpectedNullAsset));
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@delta1 delta1 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.

Mostly looks good but 2 issues

Comment thread src/blind.rs
.get_value_commit(secp)
.map_err(|e| VerificationError::SpentTxOutError(i, e))?;
out_commits.push(out_commit);
if out.value == Value::Explicit(0) && out.script_pubkey.is_provably_unspendable() {

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.

If an output has out.value == Value::Explicit(0) with a spendable script, it falls through to line 1175 where it maps to SpentTxOutError(i, e), which formats as "Input index {i} spent utxo error: ...".

A spendable zero-value output at index 0 is diagnosed as a corrupt prevout at input 0 which is incorrect

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed

Comment thread src/blind.rs Outdated

assert_eq!(
verify_with(0, explicit(0, spk.clone())),
Err(VerificationError::SpentTxOutError(0, TxOutError::NonUnspendableZeroValue))

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.

SpentTxOutError is not the correct error variant for this case it should be VerificationError::TxOutError

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This predates my changes

This error: VerificationError::SpentTxOutError(0, TxOutError::NonUnspendableZeroValue) in particular is thrown from get_value_commit function

I am not really sure whether it should be Spent or just TxOutError

The rustdoc of those errors does not help either:

    /// Spent Txout error
    SpentTxOutError(usize, TxOutError),
    /// Current transaction txout error
    TxOutError(usize, TxOutError),

Will change anyway, following the suggestion

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed

Comment thread src/blind.rs Outdated
return Err(VerificationError::TxOutError(i, TxOutError::UnExpectedNullAsset));
}
// An explicit value or asset leaves its proof nothing to prove.
if !out.witness.rangeproof.is_empty() {

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.

These checks for non-empty range/surjection proofs are only in the unspendable branch, but they are invalid for any explicit output not just unspendable outputs

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Make sense, moved those checks below

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed

@apoelstra

Copy link
Copy Markdown
Member

Also, can you split the unit test into a separate commit (after the fix) so it's easy to verify that it fails without the fix?

@KyrylR

KyrylR commented Oct 14, 2026

Copy link
Copy Markdown
Author

Also, can you split the unit test into a separate commit (after the fix) so it's easy to verify that it fails without the fix?

Sure, will do

@KyrylR
KyrylR force-pushed the feature/zero-value-op-return-verification branch from 5266aac to 52f4674 Compare October 14, 2026 14:34
@KyrylR
KyrylR force-pushed the feature/zero-value-op-return-verification branch from 52f4674 to 8cbfe53 Compare October 14, 2026 14:34
@KyrylR

KyrylR commented Oct 14, 2026

Copy link
Copy Markdown
Author

Also, can you split the unit test into a separate commit (after the fix) so it's easy to verify that it fails without the fix?

Decided to keep it as one big regression test, let me know if I should split it

@KyrylR
KyrylR requested a review from delta1 October 14, 2026 14:35
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.

3 participants