1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
use crate::{
FeeRate, RawLockTime, Sats, TxIn, TxIndex, TxOut, TxStatus, TxVersionRaw, Txid, VSize, Weight,
Witness,
};
use bitcoin::Script;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use vecdb::CheckedSub;
/// Transaction information compatible with mempool.space API format
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Transaction {
/// Internal transaction index (brk-specific, not in mempool.space)
#[schemars(example = TxIndex::new(0))]
pub index: Option<TxIndex>,
/// Transaction ID
#[schemars(example = "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b")]
pub txid: Txid,
/// Transaction version (raw i32 from Bitcoin protocol, may contain non-standard values in coinbase txs)
#[schemars(example = 2)]
pub version: TxVersionRaw,
/// Transaction lock time
#[schemars(example = 0)]
#[serde(rename = "locktime")]
pub lock_time: RawLockTime,
/// Transaction inputs
#[serde(rename = "vin")]
pub input: Vec<TxIn>,
/// Transaction outputs
#[serde(rename = "vout")]
pub output: Vec<TxOut>,
/// Transaction size in bytes
#[schemars(example = 222)]
#[serde(rename = "size")]
pub total_size: usize,
/// Transaction weight
#[schemars(example = 558)]
pub weight: Weight,
/// Number of signature operations
#[schemars(example = 1)]
#[serde(rename = "sigops")]
pub total_sigop_cost: usize,
/// Transaction fee in satoshis
#[schemars(example = Sats::new(31))]
pub fee: Sats,
/// Confirmation status (confirmed, block height/hash/time)
pub status: TxStatus,
}
impl Transaction {
pub fn fee(tx: &Transaction) -> Option<Sats> {
let in_ = tx
.input
.iter()
.map(|txin| txin.prevout.as_ref().map(|txout| txout.value))
.sum::<Option<Sats>>()?;
let out = tx.output.iter().map(|txout| txout.value).sum::<Sats>();
Some(in_.checked_sub(out).unwrap())
}
pub fn compute_fee(&mut self) {
self.fee = Self::fee(self).unwrap_or_default();
}
/// Re-encode to canonical Bitcoin protocol bytes via
/// `bitcoin::Transaction`. Lossless for mempool/confirmed txs
/// (verified bytewise round-trip against Core over a 1000-tx live
/// sample). Coinbase txs don't round-trip because brk's `Vout` is
/// `u16` while the protocol's coinbase vout is `0xFFFFFFFF` -
/// callers that may see coinbase shouldn't rely on this.
pub fn encode_bytes(&self) -> Vec<u8> {
let bitcoin_tx: bitcoin::Transaction = self.into();
let mut buf = Vec::with_capacity(self.total_size);
bitcoin::consensus::Encodable::consensus_encode(&bitcoin_tx, &mut buf)
.expect("in-memory consensus_encode is infallible");
buf
}
/// Virtual size in vbytes (weight / 4, rounded up)
#[inline]
pub fn vsize(&self) -> VSize {
VSize::from(self.weight)
}
/// Fee rate in sat/vB
#[inline]
pub fn fee_rate(&self) -> FeeRate {
FeeRate::from((self.fee, self.vsize()))
}
/// Total sigop cost (BIP-141 weight units).
///
/// Mirrors `bitcoin::Transaction::total_sigop_cost`, but reads
/// prevouts from `TxIn.prevout` and uses bitcoin's public
/// `Script::redeem_script` (push-only check + last-push extraction
/// in one). Inputs whose `prevout` is `None` skip the P2SH and
/// witness components - legacy script-sig sigops are still counted.
pub fn total_sigop_cost(&self) -> usize {
let mut legacy: usize = 0;
let mut redeem: usize = 0;
let mut witness: usize = 0;
for input in &self.input {
legacy = legacy.saturating_add(input.script_sig.count_sigops_legacy());
let Some(prevout) = input.prevout.as_ref() else {
continue;
};
let spk: &Script = &prevout.script_pubkey;
let redeem_script = spk
.is_p2sh()
.then(|| input.script_sig.redeem_script())
.flatten();
if let Some(rs) = redeem_script {
redeem = redeem.saturating_add(rs.count_sigops());
}
let witness_program: Option<&Script> = if spk.is_witness_program() {
Some(spk)
} else {
redeem_script
};
if let Some(wp) = witness_program {
witness =
witness.saturating_add(count_sigops_with_witness_program(&input.witness, wp));
}
}
for output in &self.output {
legacy = legacy.saturating_add(output.script_pubkey.count_sigops_legacy());
}
legacy
.saturating_mul(4)
.saturating_add(redeem.saturating_mul(4))
.saturating_add(witness)
}
}
fn count_sigops_with_witness_program(witness: &Witness, witness_program: &Script) -> usize {
if witness_program.is_p2wpkh() {
1
} else if witness_program.is_p2wsh() {
witness
.last()
.map(|bytes| Script::from_bytes(bytes).count_sigops())
.unwrap_or(0)
} else {
0
}
}
/// Re-encode a brk `Transaction` to a canonical `bitcoin::Transaction`.
/// Lossless for mempool/confirmed txs (verified bytewise round-trip
/// against Core's `getrawtransaction` over a 1000-tx live sample).
///
/// Coinbase round-trip is **not** byte-perfect because brk's `Vout` is
/// a `u16` and coinbase encodes `vout = 0xFFFFFFFF` in the protocol;
/// the reconstructed value is `u16::MAX` (65535). Mempool txs are
/// never coinbase, and confirmed-tx callers don't go through this path.
impl From<&Transaction> for bitcoin::Transaction {
#[inline]
fn from(tx: &Transaction) -> Self {
Self {
version: tx.version.into(),
lock_time: tx.lock_time.into(),
input: tx.input.iter().map(bitcoin::TxIn::from).collect(),
output: tx.output.iter().map(bitcoin::TxOut::from).collect(),
}
}
}