Skip to content

Add Coinbase(Transaction) newtype to distinguish coinbase transactions #4563

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

jrakibi
Copy link
Contributor

@jrakibi jrakibi commented May 27, 2025

Coinbase transactions are structurally and semantically different from normal transactions.

We introduce a distinct Coinbase(Transaction) newtype to make this distinction explicit at the type level, helping to catch logic that shouldn't apply to coinbases (e.g attempting to look up coinbase inputs in the UTXO set).

This PR is split into several patches to keep changes reviewable and to document the rationale behind each step.

@github-actions github-actions bot added C-bitcoin PRs modifying the bitcoin crate C-primitives labels May 27, 2025
@jrakibi jrakibi marked this pull request as draft May 27, 2025 09:42
Copy link

🚨 API BREAKING CHANGE DETECTED

To see the changes click details on "Check semver breaks / PR Semver - stable toolchain" job then expand "Run semver checker script" and scroll to the end of the section.

@github-actions github-actions bot added the API break This PR requires a version bump for the next release label May 27, 2025
@coveralls
Copy link

coveralls commented May 27, 2025

Pull Request Test Coverage Report for Build 15969096369

Details

  • 116 of 122 (95.08%) changed or added relevant lines in 2 files are covered.
  • 2 unchanged lines in 2 files lost coverage.
  • Overall coverage increased (+0.07%) to 83.611%

Changes Missing Coverage Covered Lines Changed/Added Lines %
bitcoin/src/blockdata/block.rs 93 99 93.94%
Files with Coverage Reduction New Missed Lines %
bitcoin/src/blockdata/transaction.rs 1 93.34%
bitcoin/src/psbt/mod.rs 1 86.65%
Totals Coverage Status
Change from base Build 15948098312: 0.07%
Covered Lines: 21397
Relevant Lines: 25591

💛 - Coveralls

@tcharding
Copy link
Member

Why is this draft mate? Are you chasing concept ack or full review?

@jrakibi
Copy link
Contributor Author

jrakibi commented May 27, 2025

Why is this draft mate? Are you chasing concept ack or full review?

I just marked it as a draft to double-check a few things before opening it up. it's ready for full review now

@jrakibi jrakibi marked this pull request as ready for review May 27, 2025 10:39
Copy link
Member

@tcharding tcharding left a comment

Choose a reason for hiding this comment

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

I'm going to give an unhelpful review: something doesn't quite feel right about this but I can't work out what. Perhaps one of the other fellas can shed light on it. The fact that we map all the errors using |_| is a hint I think. I'll re-review after the error variant thing is fixed, maybe something will fall out.



impl<'a> TryFrom<&'a Transaction> for &'a Coinbase {
type Error = CoinbaseError;
Copy link
Member

@tcharding tcharding May 28, 2025

Choose a reason for hiding this comment

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

This error includes a variant that is never returned.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I feel like a better approach for error handling would be to rename Bip34Error to something more generic and use it for both methods: coinbase() and bip34_block_height()

That way I can have all error variants related to coinbase operations in one place instead of creating two separate error types !

Copy link
Member

Choose a reason for hiding this comment

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

In this repo we favour having exact errors that only include variants that are returned by the function.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, thanks for the clarification

Copy link
Collaborator

@Kixunil Kixunil left a comment

Choose a reason for hiding this comment

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

At minimum, I want the transparent_newtype macro to be used so that we don't have to burn brain power on thinking about unsafe. The remaining requests are fairly important though might be controversial.

#[non_exhaustive]
pub enum CoinbaseError {
/// Block has no transactions.
NoTransactions,
Copy link
Collaborator

Choose a reason for hiding this comment

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

I guess this is acceptable for now but we really should have a type invariant that a block must have at least one tx.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed
We're not using CoinbaseError anymore. Moved error handling to block-level validation with NoTransactions and InvalidCoinbase in InvalidBlockError instead of a separate CoinbaseError

/// This type exists to distinguish coinbase transactions from regular ones at the type level.
#[derive(Clone, PartialEq, Eq, Debug, Hash)]
#[repr(transparent)]
pub struct Coinbase(Transaction);
Copy link
Collaborator

Choose a reason for hiding this comment

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

This needs to use the transparent_newtype macro from internals.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

fn deref(&self) -> &Self::Target {
&self.0
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not really convinced about this. It's generally an anti-pattern to do this kind of "inheritance". I'd rather conservatively remove it now and perhaps add it later if not having it is way too annoying (but most likely it's not correct to add it anyway).


fn try_from(tx: &'a Transaction) -> Result<Self, Self::Error> {
if tx.is_coinbase() {
Ok(unsafe { &*(tx as *const Transaction as *const Coinbase) })
Copy link
Collaborator

Choose a reason for hiding this comment

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

transparent_newtype would avoid this.

Anyway, I'm not sure if we should have this impl. I think that being coinbase is more a property of a block rather than transaction. If we want to have a method I think it should be something like assume_first_transaction(tx: Transaction) -> Coinbase, and _ref for references.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done! Used transparent_newtype macro and removed the try_from approach

@@ -315,7 +315,7 @@ impl BlockCheckedExt for Block<Checked> {
return Err(Bip34Error::Unsupported);
}

let cb = self.coinbase().map_err(|_| Bip34Error::NotPresent)?;
let cb = self.coinbase().map_err(|_| Bip34Error::NoCoinbase)?;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Thinking about it more, I think having a mostly-valid coinbase should be an invariant of Block. The only invalidity I'd allow is precisely BIP-34 check because that one requires block height which needs external information to check.

@jrakibi
Copy link
Contributor Author

jrakibi commented Jun 23, 2025

Apologise for the delay in addressing your feedback. I think all review comments have been addressed:

  • Used transparent_newtype macro for the Coinbase type, which eliminate unsafe concerns

  • Implemented block invariant validation - blocks must have at least one transaction, enforced during Block::validate() with NoTransactions and InvalidCoinbase error variants

  • Added assume_first_transaction & assume_first_transaction_ref methods as suggested

  • Separated error concerns - moved coinbase validation to block-level validation rather than adding variants to Bip34Error that would never be returned by BIP34 parsing

  • Made coinbase() infallible for checked blocks


impl fmt::Display for CoinbaseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
use CoinbaseError::*;
Copy link
Member

Choose a reason for hiding this comment

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

In 6dce354:

Please don't do wildcard imports like this. Just use Self:: prefixes on the variants in your match.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Noted. Also, I'm not using CoinbaseError anymore, I’ve already removed it


impl Coinbase {
/// Computes the [`Txid`] of this coinbase transaction.
pub fn compute_txid(&self) -> Txid {
Copy link
Member

Choose a reason for hiding this comment

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

In 6dce354:

Why have these methods? They are available through Deref.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've moved away from using implicit inheritance via Deref to avoid inheriting the entire Transaction API. Instead, we now expose only the methods that make sense for coinbase transactions.

inner() serves as an explicit escape hatch when full Transaction access is needed. This forces callers to be intentional about when they need the underlying Transaction functionalities.

Copy link
Member

Choose a reason for hiding this comment

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

Why shouldn't we inherit the entire Transaction API? A coinbase is a transaction.

@@ -76,6 +76,12 @@ impl Block<Unchecked> {
Block { header, transactions, witness_root: None, marker: PhantomData::<Unchecked> }
}

/// Adds a transaction to the block.
#[inline]
pub fn push_transaction(&mut self, transaction: Transaction) {
Copy link
Member

Choose a reason for hiding this comment

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

In 1babd2c:

This method does not update the merkle root in the block header.

Copy link
Member

Choose a reason for hiding this comment

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

This method isn't used in this PR. Presumably you want to drop it, since adding it properly would be a bit involved (I guess we want to maintain at least the rightmost merkle path in the block?)

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it fits better into block::Builder where the merkle root is only computed once all transactions were added.

@apoelstra
Copy link
Member

Can you squash the review responses into the commits that they affect? It's very hard to review a "grab bag" commit of stuff that undoes changes from previous commits.

@jrakibi jrakibi force-pushed the 26-06-add-coinbase-type branch from 7dfd510 to dfc9c4b Compare June 24, 2025 10:00
@jrakibi
Copy link
Contributor Author

jrakibi commented Jun 24, 2025

Thanks for the review.

I believe I’ve addressed all the feedback. You can also find the reasoning behind each change in the commit messages

@tcharding
Copy link
Member

At minimum, I want the transparent_newtype macro to be used so that we don't have to burn brain power on thinking about unsafe

Being a mere mortal I cannot read that macro without devoting a whole bunch of time to it, which I don't want to right now but AFAICT the current usage does not enforce any invariants. So the Coinbase type also does not enforce the invariant that the inner Transaction is actually a coinbase transaction - is that what we want?

/// Computes the [`Wtxid`] of this coinbase transaction.
pub fn compute_wtxid(&self) -> Wtxid {
self.0.compute_wtxid()
}
Copy link
Member

Choose a reason for hiding this comment

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

API suggestion:

    /// Returns a reference to the inner transaction.
    pub fn as_inner(&self) -> &Transaction { &self.0 }

    /// Returns the inner transaction.
    pub fn into_inner(self) -> Transaction { self.0 }

ref: https://rust-lang.github.io/api-guidelines/naming.html

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea, added as_inner and into_inner as suggested
thanks for pointing to the guidelines


impl Coinbase {
/// Creates a reference to `Coinbase` from a reference to the inner `Transaction`.
pub fn from_ref(inner: &_) -> &Self;
Copy link
Member

Choose a reason for hiding this comment

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

From looking at other call sites I think this should probably be from_transaction_ref.

Copy link
Member

Choose a reason for hiding this comment

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

Or if there is not supposed to be an invariant perhaps it should be from_first_transaction_ref or something (to match the naming of the other constructors).

Copy link
Collaborator

Choose a reason for hiding this comment

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

There's definitely an invariant that the transaction is valid coinbase (including being zeroth in the block - something it cannot check). It wouldn't solve anything otherwise.

The name should be something like assume_coinbase_ref. It definitely needs the assume word (or unchecked but that is often used with UB). But I wonder if we need it to be public - it should only need to be called inside Block and some kind of CoinbaseBuilder.

Copy link
Member

Choose a reason for hiding this comment

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

I think it should be public. People will create coinbase transactions in all sorts of ways, including by reading them off the wire in ad-hoc mining protocols.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Renamed the method to assume_coinbase_ref to reflect that the caller must ensure it's a valid coinbase.

I Kept it public as per the feedback on ad-hoc mining protocols. Open to further changes if needed!

@jrakibi jrakibi force-pushed the 26-06-add-coinbase-type branch from dfc9c4b to 0abadc3 Compare June 29, 2025 11:15
@tcharding
Copy link
Member

Can you move the last commit into the commit that originally creates the function please mate.

This is repository specific, other repos may, and will, have different strategies. In this repo after review we make changes to the patch set and then force push. Its a reasonably common strategy, you should be able to find someone online explaining it. Or this prompt into opus gave me a pretty good response "when working on github with git why do some projects fix patches and force push instead of adding new patches onto the end after review".

jrakibi added 6 commits June 30, 2025 17:08
Coinbase transactions have unique consensus rules and exceptions that
distinguish them from regular txs. We represent them using a
dedicated Coinbase(Transaction) new type

We use the transparent_newtype macro from internals for safe reference
conversion (&Transaction -> &Coinbase) without manual unsafe code
also it automatically adds #[repr(transparent)] which guarantees that
Coinbase has the exact same memory layout as Transaction
We provide explicit convenience methods (compute_txid, compute_wtxid)
rather than implementing Deref to only expose methods that make sense
for coinbase transactions. This prevents inheritance of the entire
Transaction API.

inner() serves as an escape hatch when full Transaction access is needed.
This forces the user to be explicit about when they need the underlying
Transaction functionalities.

Following this approach, we avoid implicit inheritance through Deref.

We also added assume_* methods to make it clear that the caller is
responsible for ensuring the transaction is actually a coinbase transaction
in the first position of a block
…action>

This change has semantic meaning: it guarantees that every checked Block
has a coinbase as its first transaction. The method is now infallible
for validated blocks.
Add validation checks to Block::validate() to ensure blocks have a valid coinbase:

- Check for empty transaction list (NoTransactions error)
- Verify first transaction is coinbase (InvalidCoinbase error)

The validation now happens during Block::validate() rather than requiring
every caller to check coinbase presence separately.
Remove unnecessary error handling from bip34_block_height since coinbase()
now returns &Coinbase instead of Option<&Transaction>.

Use cb.inner() to access the underlying Transaction fields for input
processing.
Add thorough test coverage for the new Coinbase type and validation logic
@jrakibi jrakibi force-pushed the 26-06-add-coinbase-type branch from 42d5517 to 23ef6c4 Compare June 30, 2025 09:24
@jrakibi
Copy link
Contributor Author

jrakibi commented Jun 30, 2025

Can you move the last commit into the commit that originally creates the function please mate.

Done

This is repository specific, other repos may, and will, have different strategies. ...

Appreciate the time you take to explain these best practices

Comment on lines -296 to +311
/// Returns the coinbase transaction, if one is present.
fn coinbase(&self) -> Option<&Transaction> { self.transactions().first() }
/// Returns the coinbase transaction.
///
/// This method is infallible because validation ensures a valid coinbase transaction is always present.
fn coinbase(&self) -> &Coinbase {
Copy link
Member

Choose a reason for hiding this comment

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

nit: On the trait method impls we don't need to repeat the docs. If you are keen you could add a patch at the front that removes the original line also the one on bip34_block_height.

Copy link
Member

Choose a reason for hiding this comment

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

In ba5fb26

Copy link
Member

@tcharding tcharding left a comment

Choose a reason for hiding this comment

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

ACK 23ef6c4

@tcharding
Copy link
Member

This has shaped up to be a nice PR.

Process comment: I ack'd so you can leave it as is if you want. Or you can see to the nit if you want to. Its trivial for me to re-ack since I posted the commit hash and can use git range-diff to see any changes you force push on their own.

@tcharding tcharding dismissed Kixunil’s stale review June 30, 2025 22:35

Changes seen to already.

///
/// This method does not validate that the transaction is actually a coinbase transaction.
/// The caller must ensure that this transaction is indeed the first transaction in a valid block.
pub fn assume_first_transaction(tx: Transaction) -> Self {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This should be just assume_coinbase. Coinbase has more validity requirements than just being the first, so simply saying "first" is misleading and enumerating all of them in the method name would make the name insanely long.

/// The caller must ensure that this transaction is indeed the first transaction in a valid block.
pub fn assume_first_transaction_ref(tx: &Transaction) -> &Self {
Self::assume_coinbase_ref(tx)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This isn't needed, the assume_coinbase_ref method already exists.

}

/// Returns a reference to the inner transaction.
pub fn as_inner(&self) -> &Transaction { &self.0 }
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we want this name here? Maybe as_transaction or as_any_transaction or something along these lines?

Copy link
Member

Choose a reason for hiding this comment

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

I could go either way on this. Leaning toward agreeing with you since we've generally moved away from *_inner naming in this library.

Whatever we choose we should doc-alias it to the other.

}

/// Computes the [`Wtxid`] of this coinbase transaction.
pub fn compute_wtxid(&self) -> Wtxid {
Copy link
Collaborator

Choose a reason for hiding this comment

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

IIRC coinbase transactions have a constant Wtxid so this method looks misleading and probably shouldn't exist.

Copy link
Member

Choose a reason for hiding this comment

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

Agreed, let's put just wtxid here but make sure there's a doc(alias="compute_wtxid"). I'm not sure if the compiler is smart enough to make use of doc aliases in its error messages.

Self::assume_coinbase_ref(tx)
}

/// Returns a reference to the inner transaction.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Perhaps should contain a disclaimer that accessing this might enable nonsensical usage.

Copy link
Member

Choose a reason for hiding this comment

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

How could it? A coinbase is a transaction.

return Err(InvalidBlockError::NoTransactions);
}

if !transactions[0].is_coinbase() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

FTR, perhaps the method name should be changed to looks_like_coinbase.

Copy link
Member

Choose a reason for hiding this comment

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

I think is_coinbase is fine. Though I think we should have an explicit conversion method that does Transaction -> Coinbase and this should be redefined just as self.to_coinbase().is_ok().

In principle any coinbase-looking transaction "is a coinbase" in the sense that you could make a block with it as the first transaction.

let cb = self.coinbase().ok_or(Bip34Error::NotPresent)?;
let input = cb.input.first().ok_or(Bip34Error::NotPresent)?;
let cb = self.coinbase();
let input = cb.as_inner().input.first().ok_or(Bip34Error::NotPresent)?;
Copy link
Collaborator

Choose a reason for hiding this comment

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

As I understand it this commit is not standalone and thus a previous one will not compile. We require that each commit can be compiled so that git bisect can be used reliably.

Copy link
Collaborator

@Kixunil Kixunil left a comment

Choose a reason for hiding this comment

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

At minimum, the compile error needs to be fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
API break This PR requires a version bump for the next release C-bitcoin PRs modifying the bitcoin crate
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Should we add a new NotCoinbase variant to Bip34Error? Consider adding a newtype for coinbase
5 participants
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy