commit cae387c48f6f2caec2757a2e2cd7d25f3cf8d203
parent 4b128569026a1c51ec4c18e3dee131e5d3f961b3
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Tue, 15 Sep 2026 07:11:48 +0200
feat(protocol): include v2 transactions in blocks
Diffstat:
25 files changed, 884 insertions(+), 80 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -9,6 +9,10 @@ from the Git history and Conventional Commit titles by `deployment.sh`.
- validate height-gated v2 migrations and hybrid transfers against the live UTXO model
- admit v2 transactions to a separate byte-limited mempool without legacy double-spends
+- select v2 transactions into height-gated blocks and bind their canonical envelopes to the VDF and block hash
+- persist v2 block envelopes in compact snapshot v8 while retaining snapshot v7 read compatibility
+- preserve UTXO lineage and reorg carry-forward behavior across legacy-to-hybrid migrations
+- advertise transaction-v2 block support to peers
- expose complete peer handshake details in the P2P interface
- add dormant transaction-v2 encoding and activation gating
diff --git a/docs/quantum-audit-scope.md b/docs/quantum-audit-scope.md
@@ -96,7 +96,9 @@ An auditor should independently confirm at least these cases:
- transaction IDs change when any authorization byte changes;
- maximum counts and lengths cannot overflow size accounting or cause excessive allocation;
- transaction v2 remains rejected through height 2999 and becomes eligible at height 3000;
-- 0.4.30-shaped handshakes ignore optional capabilities and are never sent unsupported v2 data.
+- 0.4.30-shaped handshakes remain compatible before the activation boundary; new handshakes are
+ rejected once either peer is preparing height 3000 and omits `transaction-v2-blocks`. Auditors
+ must still verify the release/reconnect procedure for sessions opened before that boundary.
## Techniques adopted and rejected
@@ -136,12 +138,13 @@ crash artifacts with their report.
Transaction v2 must remain dormant until all of the following are resolved:
- the cryptographic backend and Iuna integration are independently reviewed;
-- the final consensus call sites and byte-based fee accounting exist and are reviewed;
+- the final consensus call sites and byte-based fee accounting receive independent review;
- wallet backup compatibility, migration, and no-address-reuse behavior are implemented and
reviewed; deterministic hybrid key generation already exists but is not yet exposed in the UI;
- migration progress is observable without exposing wallet secrets;
-- P2P capability negotiation, restored old-node behavior, and activation-boundary recovery are
- rehearsed on the mainnet-candidate network;
+- advertised `transaction-v2-blocks` behavior (including already-open sessions), restored
+ snapshot-v7 behavior, and activation-boundary recovery are rehearsed on the mainnet-candidate
+ network;
- post-quantum plans exist for peer identity and update signing;
- checkpoint signer authority is specified before any PQ checkpoint format is trusted;
- the VDF has a separate quantum threat analysis;
diff --git a/docs/quantum-migration.md b/docs/quantum-migration.md
@@ -6,9 +6,10 @@ Iuna is not currently post-quantum secure. The live mainnet-candidate protocol u
wallet transactions, leader proofs, burn-bundle attestations, peer identity, and release signing.
Its class-group Wesolowski VDF also does not carry a post-quantum security claim.
-This document defines the migration constraints and staged protocol shape. It does **not** activate
-new consensus rules. Activation heights must only be chosen after implementation, independent
-cryptographic review, test vectors, adversarial tests, and a multi-node migration rehearsal.
+This document defines the migration constraints and staged protocol shape. The candidate-network
+implementation now contains the fixed height-3000 transaction-v2 consensus gate. Shipping a
+release that can reach that height still requires independent cryptographic review, adversarial
+tests, and a multi-node migration rehearsal.
The standardized signature candidates are ML-DSA (FIPS 204) and SLH-DSA (FIPS 205). The initial
transaction candidate is a hybrid of Ed25519 and ML-DSA-44: both signatures must verify. Hybrid
@@ -88,9 +89,9 @@ Application, transport, and consensus versions move independently:
1. A protocol-v2 application release advertises read capabilities in the optional `capabilities`
field. Old nodes ignore the field and an omitted field means no advertised capabilities.
2. A later application release ships dormant transaction-v2 and hybrid verification code.
-3. After deployment coverage is measured, the complete integration release announces candidate
- height 3000 as the protocol-v3 transition. The feature must not be introduced and activated in
- the same release.
+3. After deployment coverage is measured, the complete integration release advertises
+ `transaction-v2-blocks` and announces candidate height 3000 as the consensus transition. The
+ feature must not be introduced and activated in the same release.
4. Wallet defaults may change after activation without another consensus version. Refusing new
legacy outputs, changing the VDF, or removing Ed25519 each requires its own later activation.
@@ -105,11 +106,11 @@ tokens of at most 64 bytes each. These limits are enforced before the handshake
### Transaction-v2 activation target
-The transaction-v2 binary envelope and its canonical hash identifier are compiled into the node,
-but remain separate from the live JSON `Transaction`, `Block`, and gossip types. Candidate height
-3000 is compiled in as the consensus activation target; it is not an operator-controlled feature
-flag and cannot be changed through configuration. Nodes must continue rejecting v2 from the live
-mempool and chain until the complete integration routes every acceptance path through that gate.
+The transaction-v2 binary envelope and its canonical hash identifier are compiled into the node.
+Blocks carry canonical lowercase-hex envelopes in a separate `transactions_v2` list so legacy
+transaction JSON remains unchanged. Candidate height 3000 is compiled in as the consensus
+activation target; it is not an operator-controlled feature flag and cannot be changed through
+configuration. Nodes reject v2 mempool and block entries below that height.
The reserved format binds the chain ID and genesis hash, uses typed versioned addresses, stores one
length-delimited authorization per spending input, and hashes the complete canonical signed bytes
@@ -143,9 +144,12 @@ accounting are connected and activated together on the candidate network.
The domain layer now has a separate v2 pending pool. It checks the fixed height boundary, the
ledger-derived chain domain, canonical encoded byte limits, UTXO ownership, value conservation,
-legacy/v2 double-spends, and dependent v2 transactions. It is not reachable from the public API or
-P2P layer and is not selected into blocks yet, so this is still an integration stage rather than an
-activation-ready release.
+legacy/v2 double-spends, and dependent v2 transactions. At and after height 3000, block selection
+can include those transactions; their exact envelopes are committed by the VDF seed and block
+hash, counted against the shared transaction and byte limits, applied with UTXO lineage, persisted
+in compact snapshot v8, and carried forward after reorgs. Snapshot v7 remains readable. Blocks
+therefore propagate through the existing block P2P path, but standalone v2 mempool gossip and a
+public wallet/API submission route are still absent.
## Other trust boundaries
diff --git a/src/adapters/chain_store/compact.rs b/src/adapters/chain_store/compact.rs
@@ -9,7 +9,8 @@ use crate::domain::{
const COMPACT_SNAPSHOT_MAGIC: &[u8] = b"IUNA-SNAPSHOT";
const MIN_SUPPORTED_COMPACT_SNAPSHOT_VERSION: u8 = 6;
-const COMPACT_SNAPSHOT_VERSION: u8 = 7;
+const COMPACT_SNAPSHOT_VERSION: u8 = 8;
+const TRANSACTION_V2_COMPACT_SNAPSHOT_VERSION: u8 = 8;
const VDF_SOLUTION_PREFIX: &str = "classgroup-wesolowski-bqfc-v1:";
const MAX_COMPACT_GENESIS_ALLOCATIONS: usize = 100_000;
const MAX_COMPACT_SNAPSHOT_BLOCKS: usize = 10_000;
@@ -70,7 +71,15 @@ impl CompactBlockContext {
pub(crate) fn block_size_breakdown(&self, block: &Block) -> Result<CompactBlockSizeBreakdown> {
let mut tables = self.tables.clone();
let mut writer = CompactWriter::default();
- encode_block_body_with_size_breakdown(&mut writer, block, &mut tables)
+ // Preserve the exact pre-v2 consensus size for legacy-only blocks. Snapshot v8 has one
+ // additional count field, but that storage framing must not move the historical block-size
+ // boundary before height 3000.
+ let version = if block.transactions_v2.is_empty() {
+ TRANSACTION_V2_COMPACT_SNAPSHOT_VERSION - 1
+ } else {
+ TRANSACTION_V2_COMPACT_SNAPSHOT_VERSION
+ };
+ encode_block_body_with_size_breakdown_for_version(&mut writer, block, &mut tables, version)
}
pub(crate) fn append_block(&mut self, block: &Block) -> Result<()> {
@@ -260,7 +269,13 @@ pub(super) fn decode_compact_snapshot(bytes: &[u8]) -> Result<ChainSnapshot> {
let mut blocks = Vec::with_capacity(block_count);
let mut prev_hash = "0".repeat(64);
for height in 0..block_count {
- let block = decode_block_body(&mut reader, height as u64, prev_hash, &mut tables)?;
+ let block = decode_block_body_for_version(
+ &mut reader,
+ height as u64,
+ prev_hash,
+ &mut tables,
+ version,
+ )?;
tables.register_protocol_id(&block.hash);
prev_hash = block.hash.clone();
blocks.push(block);
@@ -311,6 +326,20 @@ fn encode_block_body_with_size_breakdown(
block: &Block,
tables: &mut EncodeTables,
) -> Result<CompactBlockSizeBreakdown> {
+ encode_block_body_with_size_breakdown_for_version(
+ writer,
+ block,
+ tables,
+ COMPACT_SNAPSHOT_VERSION,
+ )
+}
+
+fn encode_block_body_with_size_breakdown_for_version(
+ writer: &mut CompactWriter,
+ block: &Block,
+ tables: &mut EncodeTables,
+ version: u8,
+) -> Result<CompactBlockSizeBreakdown> {
let block_start = writer.bytes.len();
writer.varint(block.timestamp_ms);
writer.address(&block.miner, tables)?;
@@ -347,6 +376,14 @@ fn encode_block_body_with_size_breakdown(
}
tables.register_protocol_id(transaction.signature());
}
+ if version >= TRANSACTION_V2_COMPACT_SNAPSHOT_VERSION {
+ writer.varint(block.transactions_v2.len() as u64);
+ for envelope in &block.transactions_v2 {
+ writer.hex(envelope)?;
+ }
+ } else if !block.transactions_v2.is_empty() {
+ bail!("compact snapshot version {version} cannot encode transaction v2 envelopes");
+ }
let burn_bundle_start = writer.bytes.len();
encode_burn_bundle_section(writer, block, tables)?;
let block_end = writer.bytes.len();
@@ -361,12 +398,23 @@ fn encode_block_body_with_size_breakdown(
})
}
+#[cfg(test)]
fn decode_block_body(
reader: &mut CompactReader<'_>,
height: u64,
prev_hash: String,
tables: &mut DecodeTables,
) -> Result<Block> {
+ decode_block_body_for_version(reader, height, prev_hash, tables, COMPACT_SNAPSHOT_VERSION)
+}
+
+fn decode_block_body_for_version(
+ reader: &mut CompactReader<'_>,
+ height: u64,
+ prev_hash: String,
+ tables: &mut DecodeTables,
+ version: u8,
+) -> Result<Block> {
let timestamp_ms = reader.varint()?;
let miner = reader.address(tables)?;
let finalizer_mode = match reader.u8()? {
@@ -395,6 +443,16 @@ fn decode_block_body(
tables.register_protocol_id(transaction.signature());
transactions.push(transaction);
}
+ let transactions_v2 = if version >= TRANSACTION_V2_COMPACT_SNAPSHOT_VERSION {
+ decode_vec(
+ reader,
+ "block transaction v2 count",
+ MAX_COMPACT_VEC_ITEMS,
+ |reader| reader.hex(),
+ )?
+ } else {
+ Vec::new()
+ };
let burn_bundle_section = decode_burn_bundle_section(reader, &transactions, tables)?;
let mut block = Block {
height,
@@ -409,6 +467,7 @@ fn decode_block_body(
leader_proof,
burn_bundle_section,
transactions,
+ transactions_v2,
hash: String::new(),
};
block.hash = block.compute_hash();
@@ -1058,12 +1117,12 @@ mod tests {
MAX_COMPACT_GENESIS_ALLOCATIONS, MAX_COMPACT_SNAPSHOT_BLOCKS, MAX_COMPACT_VEC_ITEMS,
MIN_SUPPORTED_COMPACT_SNAPSHOT_VERSION, compact_snapshot_fixed_prefix_size,
compact_varint_size, decode_block_body, decode_compact_snapshot, decode_launch_profile,
- decode_transaction, encode_block_body, encode_compact_snapshot, encode_launch_profile,
- encode_transaction,
+ decode_transaction, encode_block_body, encode_block_body_with_size_breakdown_for_version,
+ encode_compact_snapshot, encode_launch_profile, encode_transaction,
};
#[test]
- fn compact_snapshot_v7_roundtrips_default_and_local_profiles() {
+ fn compact_snapshot_v8_roundtrips_default_and_local_profiles() {
let wallet = Wallet::from_seed("compact-profile-wire-version");
let allocations = BTreeMap::from([(wallet.address().to_string(), MICRO_IUNA)]);
let default_snapshot = Ledger::new(allocations.clone(), 1).snapshot();
@@ -1123,6 +1182,38 @@ mod tests {
}
#[test]
+ fn compact_snapshot_v7_remains_readable() {
+ let wallet = Wallet::from_seed("compact-v7-backward-compatibility");
+ let snapshot = Ledger::new(
+ BTreeMap::from([(wallet.address().to_string(), MICRO_IUNA)]),
+ 1,
+ )
+ .snapshot();
+ let mut writer = CompactWriter::default();
+ let mut tables = EncodeTables::default();
+ writer.bytes(COMPACT_SNAPSHOT_MAGIC);
+ writer.u8(7);
+ writer.varint(snapshot.genesis_allocations.len() as u64);
+ for (address, amount) in &snapshot.genesis_allocations {
+ writer.address(address, &mut tables).unwrap();
+ writer.varint(*amount);
+ }
+ writer.varint(snapshot.vdf_rounds);
+ encode_launch_profile(&mut writer, &snapshot.launch_profile);
+ writer.varint(snapshot.blocks.len() as u64);
+ for block in &snapshot.blocks {
+ encode_block_body_with_size_breakdown_for_version(&mut writer, block, &mut tables, 7)
+ .unwrap();
+ tables.register_protocol_id(&block.hash);
+ }
+
+ assert_eq!(
+ decode_compact_snapshot(&writer.into_inner()).unwrap(),
+ snapshot
+ );
+ }
+
+ #[test]
fn compact_launch_profile_roundtrips_local_lineage_maturity() {
let expected = LaunchProfile::local_testnet();
let mut writer = CompactWriter::default();
@@ -1276,6 +1367,7 @@ mod tests {
leader_proof: None,
burn_bundle_section,
transactions: vec![burn.clone()],
+ transactions_v2: Vec::new(),
hash: String::new(),
};
block.hash = block.compute_hash();
@@ -1328,6 +1420,61 @@ mod tests {
assert_eq!(decoded, with);
}
+ #[test]
+ fn compact_block_roundtrips_transaction_v2_envelopes() {
+ let wallet = Wallet::from_seed("compact-v2-envelope");
+ let mut block = Ledger::new(
+ BTreeMap::from([(wallet.address().to_string(), MICRO_IUNA)]),
+ 1,
+ )
+ .snapshot()
+ .blocks[0]
+ .clone();
+ block.transactions_v2 = vec!["000102ff".to_string()];
+ block.hash = block.compute_hash();
+ let mut writer = CompactWriter::default();
+ encode_block_body(&mut writer, &block, &mut EncodeTables::default()).unwrap();
+ let bytes = writer.into_inner();
+ let mut reader = CompactReader::new(&bytes);
+
+ let decoded = decode_block_body(
+ &mut reader,
+ block.height,
+ block.prev_hash.clone(),
+ &mut DecodeTables::default(),
+ )
+ .unwrap();
+
+ reader.finish().unwrap();
+ assert_eq!(decoded, block);
+ }
+
+ #[test]
+ fn legacy_only_consensus_block_size_keeps_v7_framing() {
+ let wallet = Wallet::from_seed("compact-legacy-consensus-size");
+ let block = Ledger::new(
+ BTreeMap::from([(wallet.address().to_string(), MICRO_IUNA)]),
+ 1,
+ )
+ .snapshot()
+ .blocks[0]
+ .clone();
+ let context = CompactBlockContext::default();
+ let mut writer = CompactWriter::default();
+ encode_block_body_with_size_breakdown_for_version(
+ &mut writer,
+ &block,
+ &mut EncodeTables::default(),
+ 7,
+ )
+ .unwrap();
+
+ assert_eq!(
+ context.block_size_bytes(&block).unwrap(),
+ writer.bytes.len()
+ );
+ }
+
fn snapshot_prefix(block_count: u64) -> Vec<u8> {
let mut writer = CompactWriter::default();
writer.bytes(COMPACT_SNAPSHOT_MAGIC);
diff --git a/src/adapters/http/metrics.rs b/src/adapters/http/metrics.rs
@@ -454,6 +454,7 @@ mod tests {
leader_proof: None,
burn_bundle_section: BurnBundleSection::default(),
transactions,
+ transactions_v2: Vec::new(),
hash: format!("block-{height}"),
};
diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs
@@ -670,6 +670,7 @@ mod tests {
burns: Vec::new(),
},
transactions: vec![transfer("transfer", 2), burn("burn")],
+ transactions_v2: Vec::new(),
hash: "block".to_string(),
};
@@ -769,6 +770,7 @@ mod tests {
}],
},
transactions: vec![burn],
+ transactions_v2: Vec::new(),
hash: "hash".to_string(),
};
@@ -815,6 +817,7 @@ mod tests {
leader_proof: None,
burn_bundle_section: BurnBundleSection::default(),
transactions: vec![burn("burn-a")],
+ transactions_v2: Vec::new(),
hash: "hash".to_string(),
};
let ranks = BTreeMap::from([(
@@ -857,6 +860,7 @@ mod tests {
burns: Vec::new(),
},
transactions: vec![burn("burn-a")],
+ transactions_v2: Vec::new(),
hash: "hash".to_string(),
};
let ranks = BTreeMap::from([(
@@ -933,6 +937,7 @@ mod tests {
burns: Vec::new(),
},
transactions: vec![burn("burn-a")],
+ transactions_v2: Vec::new(),
hash: "hash".to_string(),
};
let ranks = BTreeMap::from([(
diff --git a/src/adapters/p2p/fetch.rs b/src/adapters/p2p/fetch.rs
@@ -10,6 +10,7 @@ use crate::{
app::{
ChainBootstrap, GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, ProtocolHello, now_ms,
protocol_capabilities, validate_network_genesis, validate_protocol_capabilities,
+ validate_transaction_v2_peer_capability,
},
domain::{Block, ChainSnapshot, LaunchProfile, Ledger, verify_vdf},
};
@@ -54,6 +55,7 @@ async fn fetch_peer_status(peer: &str) -> Result<PeerStatus> {
);
}
validate_protocol_capabilities(&hello.capabilities)?;
+ validate_transaction_v2_peer_capability(&hello.capabilities, 0, hello.height)?;
Ok(PeerStatus::with_time(
hello.height,
hello.tip_hash,
@@ -100,6 +102,7 @@ pub async fn fetch_snapshot_with_announcement(
);
}
validate_protocol_capabilities(&hello.capabilities)?;
+ validate_transaction_v2_peer_capability(&hello.capabilities, 0, hello.height)?;
}
GossipEnvelope::PeerStatus { .. } => {}
other => anyhow::bail!("join peer {peer} sent {other:?} instead of peer status"),
diff --git a/src/adapters/p2p/handshake.rs b/src/adapters/p2p/handshake.rs
@@ -20,6 +20,7 @@ use crate::{
app::{
GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, PeerDirection, ProtocolHello,
debug_logging_enabled, now_ms, validate_protocol_capabilities,
+ validate_transaction_v2_peer_capability,
},
domain::Ledger,
};
@@ -145,13 +146,15 @@ async fn process_hello_inner(
hello.time_ms,
));
}
- let (local_genesis, local_accepts_remote_genesis) = {
+ let (local_genesis, local_accepts_remote_genesis, local_height) = {
let node = network.inner.node.lock().await;
(
node.ledger().genesis_hash().to_string(),
node.ledger().is_setup_placeholder(),
+ node.ledger().height(),
)
};
+ validate_transaction_v2_peer_capability(&hello.capabilities, local_height, hello.height)?;
let genesis_mismatch = hello.genesis_hash != local_genesis;
let remote_is_setup_placeholder =
hello.height == 0 && hello.genesis_hash == setup_placeholder_genesis_hash();
@@ -393,13 +396,19 @@ async fn advertised_peer_hello_is_compatible(
{
return false;
}
- let (local_genesis, local_accepts_remote_genesis) = {
+ let (local_genesis, local_accepts_remote_genesis, local_height) = {
let node = network.inner.node.lock().await;
(
node.ledger().genesis_hash().to_string(),
node.ledger().is_setup_placeholder(),
+ node.ledger().height(),
)
};
+ if validate_transaction_v2_peer_capability(&hello.capabilities, local_height, hello.height)
+ .is_err()
+ {
+ return false;
+ }
let remote_is_setup_placeholder =
hello.height == 0 && hello.genesis_hash == setup_placeholder_genesis_hash();
hello.genesis_hash == local_genesis
diff --git a/src/adapters/p2p/line_codec.rs b/src/adapters/p2p/line_codec.rs
@@ -279,6 +279,7 @@ mod tests {
leader_proof: None,
burn_bundle_section: BurnBundleSection::default(),
transactions: Vec::new(),
+ transactions_v2: Vec::new(),
hash: format!("{height:064x}"),
}
}
diff --git a/src/adapters/p2p/writer.rs b/src/adapters/p2p/writer.rs
@@ -97,6 +97,7 @@ mod tests {
leader_proof: None,
burn_bundle_section: BurnBundleSection::default(),
transactions: Vec::new(),
+ transactions_v2: Vec::new(),
hash: format!("{height:064x}"),
}
}
diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs
@@ -1579,7 +1579,10 @@ fn incremental_metric_for_block(
mine_difficulty_bits: metric_difficulty_for_block(snapshot, block, previous),
circulating_supply,
known_wallet_addresses: 0,
- transaction_count: block.transactions.len() as u64,
+ transaction_count: block
+ .transactions
+ .len()
+ .saturating_add(block.transactions_v2.len()) as u64,
transfer_count,
burn_count,
mine_count,
diff --git a/src/app.rs b/src/app.rs
@@ -45,6 +45,7 @@ pub const MAX_PROTOCOL_CAPABILITIES: usize = 16;
pub const MAX_PROTOCOL_CAPABILITY_BYTES: usize = 64;
pub const CAPABILITY_ADDRESS_V1_READ: &str = "address-v1-read";
pub const CAPABILITY_SIGNATURE_SCHEMES_V1: &str = "signature-schemes-v1";
+pub const CAPABILITY_TRANSACTION_V2_BLOCKS: &str = "transaction-v2-blocks";
pub const MAINNET_CANDIDATE_NETWORK_ID: &str = "iuna-mainnet-candidate";
pub const MAINNET_CANDIDATE_GENESIS_HASH: &str =
"3d677cd7ced1c04d3a276cbee7ea38076e34ac65f18a2c9b8286a4872d986a9a";
@@ -67,6 +68,7 @@ pub fn protocol_capabilities() -> Vec<String> {
vec![
CAPABILITY_ADDRESS_V1_READ.to_string(),
CAPABILITY_SIGNATURE_SCHEMES_V1.to_string(),
+ CAPABILITY_TRANSACTION_V2_BLOCKS.to_string(),
]
}
@@ -92,14 +94,31 @@ pub fn validate_protocol_capabilities(capabilities: &[String]) -> Result<()> {
Ok(())
}
+pub fn validate_transaction_v2_peer_capability(
+ capabilities: &[String],
+ local_height: u64,
+ remote_height: u64,
+) -> Result<()> {
+ let activation_is_next =
+ crate::domain::transaction_v2_is_active(local_height.max(remote_height).saturating_add(1));
+ if activation_is_next
+ && !capabilities
+ .iter()
+ .any(|capability| capability == CAPABILITY_TRANSACTION_V2_BLOCKS)
+ {
+ anyhow::bail!("peer lacks transaction-v2 block capability near activation");
+ }
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::{
BLOCK_REQUEST_LIMIT, CAPABILITY_ADDRESS_V1_READ, CAPABILITY_SIGNATURE_SCHEMES_V1,
- DEFAULT_VDF_ROUNDS, MAINNET_CANDIDATE_GENESIS_HASH, MAINNET_CANDIDATE_NETWORK_ID,
- MAINNET_NETWORK_ID, MAX_PROTOCOL_CAPABILITIES, NETWORK_ID, PROTOCOL_VERSION,
- TRANSACTION_BATCH_LIMIT, protocol_capabilities, validate_network_genesis,
- validate_protocol_capabilities,
+ CAPABILITY_TRANSACTION_V2_BLOCKS, DEFAULT_VDF_ROUNDS, MAINNET_CANDIDATE_GENESIS_HASH,
+ MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID, MAX_PROTOCOL_CAPABILITIES, NETWORK_ID,
+ PROTOCOL_VERSION, TRANSACTION_BATCH_LIMIT, protocol_capabilities, validate_network_genesis,
+ validate_protocol_capabilities, validate_transaction_v2_peer_capability,
};
#[test]
@@ -130,7 +149,11 @@ mod tests {
let capabilities = protocol_capabilities();
assert_eq!(
capabilities,
- [CAPABILITY_ADDRESS_V1_READ, CAPABILITY_SIGNATURE_SCHEMES_V1]
+ [
+ CAPABILITY_ADDRESS_V1_READ,
+ CAPABILITY_SIGNATURE_SCHEMES_V1,
+ CAPABILITY_TRANSACTION_V2_BLOCKS,
+ ]
);
validate_protocol_capabilities(&capabilities).unwrap();
validate_protocol_capabilities(&[]).unwrap();
@@ -154,6 +177,20 @@ mod tests {
.is_err()
);
}
+
+ #[test]
+ fn transaction_v2_block_capability_is_required_when_activation_is_next() {
+ assert!(validate_transaction_v2_peer_capability(&[], 2_998, 2_998).is_ok());
+ assert!(validate_transaction_v2_peer_capability(&[], 2_999, 2_998).is_err());
+ assert!(
+ validate_transaction_v2_peer_capability(
+ &[CAPABILITY_TRANSACTION_V2_BLOCKS.to_string()],
+ 2_999,
+ 2_998,
+ )
+ .is_ok()
+ );
+ }
}
pub fn validate_network_genesis(profile_id: &str, genesis_hash: &str) -> Result<()> {
diff --git a/src/domain.rs b/src/domain.rs
@@ -51,8 +51,9 @@ pub(crate) use genesis::genesis_allocation_outpoint;
pub use hex::hex_hash;
use hex::{decode_hex, decode_hex_array, hex_encode};
use ledger_lineage::{
- LineageOwnerValues, UtxoLineageRoot, insert_output_with_lineage,
- output_lineage_root_for_transaction, spend_inputs_with_lineage,
+ LineageOwnerValues, UtxoLineageRoot, attach_existing_output_lineage,
+ insert_output_with_lineage, newest_lineage_root, output_lineage_root_for_transaction,
+ remove_spent_output_lineage, spend_inputs_with_lineage,
};
pub use ledger_ops::reward_outputs_for_block;
use ledger_ops::{
diff --git a/src/domain/block.rs b/src/domain/block.rs
@@ -26,6 +26,9 @@ pub struct Block {
#[serde(default)]
pub burn_bundle_section: BurnBundleSection,
pub transactions: Vec<Transaction>,
+ /// Canonical hex-encoded transaction-v2 wire envelopes.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub transactions_v2: Vec<String>,
pub hash: String,
}
@@ -81,6 +84,7 @@ impl Block {
.map(|proof| proof.ticket_id.as_str()),
&self.burn_bundle_section,
&self.transactions,
+ &self.transactions_v2,
)
}
@@ -91,6 +95,7 @@ impl Block {
.map(Transaction::canonical)
.collect::<Vec<_>>()
.join("|");
+ let txs_v2 = self.transactions_v2.join("|");
let burn_section = self.burn_bundle_section.canonical();
let leader_proof = self
.leader_proof
@@ -102,8 +107,23 @@ impl Block {
)
})
.unwrap_or_default();
+ if self.transactions_v2.is_empty() {
+ return hex_hash(format!(
+ "block-content-v4:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}",
+ self.height,
+ self.prev_hash,
+ self.timestamp_ms,
+ self.miner,
+ self.finalizer_rank,
+ self.reward,
+ self.vdf_rounds,
+ leader_proof,
+ txs,
+ canonical_burn_block_items(&burn_section)
+ ));
+ }
hex_hash(format!(
- "block-content-v4:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}",
+ "block-content-v5:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}",
self.height,
self.prev_hash,
self.timestamp_ms,
@@ -113,6 +133,7 @@ impl Block {
self.vdf_rounds,
leader_proof,
txs,
+ txs_v2,
canonical_burn_block_items(&burn_section)
))
}
@@ -241,6 +262,7 @@ pub struct PreparedBlock {
pub(super) leader_ticket: Option<BurnTicket>,
pub(super) burn_bundle_section: BurnBundleSection,
pub(super) transactions: Vec<Transaction>,
+ pub(super) transactions_v2: Vec<String>,
}
impl PreparedBlock {
@@ -308,6 +330,7 @@ impl PreparedBlock {
leader_proof,
burn_bundle_section: self.burn_bundle_section,
transactions: self.transactions,
+ transactions_v2: self.transactions_v2,
hash: String::new(),
};
block.hash = block.compute_hash();
@@ -396,6 +419,7 @@ mod tests {
leader_proof: None,
burn_bundle_section: BurnBundleSection::default(),
transactions: vec![Transaction::genesis_burn("owner", 1)],
+ transactions_v2: Vec::new(),
hash: String::new(),
};
@@ -419,6 +443,7 @@ mod tests {
leader_proof: None,
burn_bundle_section: BurnBundleSection::default(),
transactions: vec![Transaction::genesis_burn("2".repeat(64), 1)],
+ transactions_v2: Vec::new(),
hash: String::new(),
};
let original_seed = block.vdf_seed();
@@ -446,6 +471,34 @@ mod tests {
}
#[test]
+ fn transaction_v2_envelopes_are_bound_to_vdf_and_block_hash() {
+ let mut block = Block {
+ height: GRINDING_RESISTANCE_ACTIVATION_HEIGHT,
+ prev_hash: "0".repeat(64),
+ timestamp_ms: 1,
+ miner: "1".repeat(64),
+ finalizer_mode: FinalizerMode::Ticket,
+ finalizer_rank: 0,
+ reward: 1,
+ vdf_rounds: 1,
+ vdf_output: "output".to_string(),
+ leader_proof: None,
+ burn_bundle_section: BurnBundleSection::default(),
+ transactions: vec![Transaction::genesis_burn("2".repeat(64), 1)],
+ transactions_v2: vec!["00".to_string()],
+ hash: String::new(),
+ };
+ block.hash = block.compute_hash();
+ let original_seed = block.vdf_seed();
+ let original_hash = block.hash.clone();
+
+ block.transactions_v2[0] = "01".to_string();
+
+ assert_ne!(block.vdf_seed(), original_seed);
+ assert_ne!(block.compute_hash(), original_hash);
+ }
+
+ #[test]
fn prepared_and_finished_blocks_share_post_activation_vdf_commitment() {
let wallet = Wallet::from_seed("vdf-commitment-finalizer");
let transactions = vec![Transaction::genesis_burn(wallet.address(), 1)];
@@ -468,6 +521,7 @@ mod tests {
}),
burn_bundle_section: BurnBundleSection::default(),
transactions,
+ transactions_v2: Vec::new(),
};
let bundle_hashes = prepared.burn_bundle_section.burn_bundle_hashes(
prepared.height,
@@ -488,6 +542,7 @@ mod tests {
.map(|ticket| ticket.id.as_str()),
&prepared.burn_bundle_section,
&prepared.transactions,
+ &prepared.transactions_v2,
);
prepared.vdf_seed = super::vdf_seed_for_child(
&prepared.prev_hash,
diff --git a/src/domain/genesis.rs b/src/domain/genesis.rs
@@ -33,6 +33,7 @@ pub(super) fn build_genesis_block(
leader_proof: None,
burn_bundle_section: BurnBundleSection::default(),
transactions,
+ transactions_v2: Vec::new(),
hash: String::new(),
};
genesis.hash = genesis.compute_hash();
diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs
@@ -7,16 +7,20 @@ use super::ledger_ops::{
ensure_single_input_owner, ensure_valid_recovery_block, validate_block_fee_policy,
verify_leader_proof,
};
+use super::ledger_v2::{
+ apply_transaction_v2_with_lineage, decode_canonical_transaction_v2_envelope,
+};
use super::mine_policy::ensure_mine_anchor_limit;
use super::ticket::{
apply_finalizer_ticket_effects, ticket_block_min_timestamp, tickets_created_by_block,
};
use super::transaction::transaction_inputs_available;
use super::{
- Amount, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block, BurnBundleSection, FinalityCheckpoint,
- FinalizerMode, Ledger, MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS,
- TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT, Transaction, insert_output_with_lineage,
- output_lineage_root_for_transaction, spend_inputs_with_lineage, unix_now_ms, verify_vdf,
+ AddressNetwork, Amount, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block, BurnBundleSection,
+ FinalityCheckpoint, FinalizerMode, Ledger, MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS,
+ TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT, Transaction, ensure_transaction_v2_active,
+ hex_encode, insert_output_with_lineage, output_lineage_root_for_transaction,
+ spend_inputs_with_lineage, unix_now_ms, verify_vdf,
};
impl Ledger {
@@ -99,7 +103,28 @@ impl Ledger {
Some(&signing_domain),
)?;
}
- let expected_reward = block_reward(&block.transactions, 0)?;
+ let transaction_v2_domain = self.transaction_v2_domain()?;
+ let network = AddressNetwork::from_profile_id(&self.launch_profile.profile_id);
+ let mut transaction_v2_ids = BTreeSet::new();
+ for envelope in &block.transactions_v2 {
+ let transaction =
+ decode_canonical_transaction_v2_envelope(envelope, &transaction_v2_domain)?;
+ let transaction_id = hex_encode(transaction.transaction_id(&transaction_v2_domain)?);
+ if !transaction_v2_ids.insert(transaction_id) {
+ bail!("duplicate transaction v2 in block");
+ }
+ apply_transaction_v2_with_lineage(
+ &transaction,
+ &transaction_v2_domain,
+ network,
+ &mut utxos,
+ &mut utxo_lineage,
+ &mut lineage_values,
+ &mut lineage_owners,
+ true,
+ )?;
+ }
+ let expected_reward = self.expected_reward_for_block(&block)?;
if block.reward != expected_reward {
bail!("block reward is invalid");
}
@@ -120,6 +145,8 @@ impl Ledger {
self.tickets = tickets;
self.mined_transaction_ids
.extend(mined_signatures.iter().cloned());
+ self.mined_transaction_ids
+ .extend(transaction_v2_ids.iter().cloned());
self.chain.push(block);
self.update_mine_difficulty_cache_after_tip();
if let Some(checkpoint) = certified_parent {
@@ -182,6 +209,25 @@ impl Ledger {
None,
)?;
}
+ let transaction_v2_domain = self.transaction_v2_domain()?;
+ let network = AddressNetwork::from_profile_id(&self.launch_profile.profile_id);
+ let mut transaction_v2_ids = BTreeSet::new();
+ for envelope in &block.transactions_v2 {
+ let transaction =
+ decode_canonical_transaction_v2_envelope(envelope, &transaction_v2_domain)?;
+ let transaction_id = hex_encode(transaction.transaction_id(&transaction_v2_domain)?);
+ apply_transaction_v2_with_lineage(
+ &transaction,
+ &transaction_v2_domain,
+ network,
+ &mut self.utxos,
+ &mut self.utxo_lineage,
+ &mut self.lineage_values,
+ &mut self.lineage_owners,
+ false,
+ )?;
+ transaction_v2_ids.insert(transaction_id);
+ }
let mined_signatures = block
.transactions
@@ -195,6 +241,7 @@ impl Ledger {
credit_reward_outputs(&mut self.utxos, &block, &reward_committee)?;
self.compact_block_context.append_trusted_block(&block)?;
self.mined_transaction_ids.extend(mined_signatures);
+ self.mined_transaction_ids.extend(transaction_v2_ids);
self.chain.push(block);
self.update_mine_difficulty_cache_after_tip();
if let Some(checkpoint) = certified_parent {
@@ -215,6 +262,18 @@ impl Ledger {
{
bail!("block replays a previously mined transaction");
}
+ let domain = self.transaction_v2_domain()?;
+ let mut transaction_v2_ids = BTreeSet::new();
+ for envelope in &block.transactions_v2 {
+ let transaction = decode_canonical_transaction_v2_envelope(envelope, &domain)?;
+ let transaction_id = hex_encode(transaction.transaction_id(&domain)?);
+ if !transaction_v2_ids.insert(transaction_id.clone()) {
+ bail!("duplicate transaction v2 in block");
+ }
+ if self.mined_transaction_ids.contains(&transaction_id) {
+ bail!("block replays a previously mined transaction v2");
+ }
+ }
Ok(())
}
@@ -276,9 +335,17 @@ impl Ledger {
if block.timestamp_ms > max_future_timestamp {
return Err(super::ValidationError::BlockTimestampTooFarInFuture.into());
}
- if block.transactions.len() > self.launch_profile.max_block_transactions {
+ if block
+ .transactions
+ .len()
+ .saturating_add(block.transactions_v2.len())
+ > self.launch_profile.max_block_transactions
+ {
bail!("block has too many transactions");
}
+ if !block.transactions_v2.is_empty() {
+ ensure_transaction_v2_active(block.height)?;
+ }
if self.consensus_block_size_bytes(block)? > self.launch_profile.max_block_bytes {
bail!("block exceeds max block size");
}
@@ -330,13 +397,29 @@ impl Ledger {
pub(super) fn expected_reward_for_next_block(
&self,
transactions: &[Transaction],
+ transactions_v2: &[String],
_burn_bundle_section: &BurnBundleSection,
) -> Result<Amount> {
- block_reward(transactions, 0)
+ let v2_fees = self.transaction_v2_fees(transactions_v2)?;
+ block_reward(transactions, v2_fees)
}
fn expected_reward_for_block(&self, block: &Block) -> Result<Amount> {
- block_reward(&block.transactions, 0)
+ let v2_fees = self.transaction_v2_fees(&block.transactions_v2)?;
+ block_reward(&block.transactions, v2_fees)
+ }
+
+ fn transaction_v2_fees(&self, envelopes: &[String]) -> Result<Amount> {
+ let domain = self.transaction_v2_domain()?;
+ envelopes.iter().try_fold(0_u64, |total, envelope| {
+ let transaction = decode_canonical_transaction_v2_envelope(envelope, &domain)?;
+ if transaction.fee() == 0 {
+ bail!("block transaction v2 fee must be greater than zero");
+ }
+ total
+ .checked_add(transaction.fee())
+ .context("block transaction v2 fees overflow")
+ })
}
}
diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs
@@ -411,6 +411,34 @@ impl Ledger {
}
}
+ let mut carry_forward_v2 = Vec::new();
+ for block in self
+ .chain
+ .iter()
+ .skip(fork_point.first_diverging_height() as usize)
+ {
+ for envelope in &block.transactions_v2 {
+ if let Ok(bytes) = super::decode_hex(envelope)
+ && let Ok(transaction) = self.decode_transaction_v2(&bytes)
+ {
+ carry_forward_v2.push(transaction);
+ }
+ }
+ }
+ carry_forward_v2.extend(self.pending_v2.clone());
+ let candidate_domain = candidate.transaction_v2_domain().ok();
+ for transaction in carry_forward_v2 {
+ let already_mined = candidate_domain.as_ref().is_some_and(|domain| {
+ transaction
+ .transaction_id(domain)
+ .map(super::hex_encode)
+ .is_ok_and(|id| candidate.mined_transaction_ids.contains(&id))
+ });
+ if !already_mined {
+ let _ = candidate.submit_transaction_v2(transaction);
+ }
+ }
+
*self = candidate;
}
}
diff --git a/src/domain/ledger_lineage.rs b/src/domain/ledger_lineage.rs
@@ -148,6 +148,48 @@ fn subtract_lineage_owner_value(
Ok(())
}
+pub(super) fn remove_spent_output_lineage(
+ outpoint: &OutPoint,
+ output: &TxOutput,
+ utxo_lineage: &mut BTreeMap<OutPoint, UtxoLineageRoot>,
+ lineage_values: &mut BTreeMap<UtxoLineageRoot, Amount>,
+ lineage_owners: &mut LineageOwnerValues,
+) -> Result<Option<UtxoLineageRoot>> {
+ let Some(root) = utxo_lineage.remove(outpoint) else {
+ return Ok(None);
+ };
+ subtract_lineage_value(lineage_values, &root, output.amount)?;
+ subtract_lineage_owner_value(lineage_owners, &root, &output.address, outpoint)?;
+ Ok(Some(root))
+}
+
+pub(super) fn attach_existing_output_lineage(
+ outpoint: OutPoint,
+ output: &TxOutput,
+ root: UtxoLineageRoot,
+ utxo_lineage: &mut BTreeMap<OutPoint, UtxoLineageRoot>,
+ lineage_values: &mut BTreeMap<UtxoLineageRoot, Amount>,
+ lineage_owners: &mut LineageOwnerValues,
+) -> Result<()> {
+ if utxo_lineage
+ .insert(outpoint.clone(), root.clone())
+ .is_some()
+ {
+ bail!("created output replaces existing UTXO lineage");
+ }
+ let value = lineage_values.entry(root.clone()).or_insert(0);
+ *value = value
+ .checked_add(output.amount)
+ .context("lineage value overflows")?;
+ lineage_owners
+ .entry(root)
+ .or_default()
+ .entry(output.address.clone())
+ .or_default()
+ .insert(outpoint, output.amount);
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/domain/ledger_ops.rs b/src/domain/ledger_ops.rs
@@ -155,6 +155,7 @@ pub(super) fn estimated_block_selection_size_bytes(
}),
burn_bundle_section: burn_bundle_section.clone(),
transactions: selection.transactions.clone(),
+ transactions_v2: selection.transactions_v2.clone(),
hash: "f".repeat(64),
};
context.block_size_bytes(&block)
@@ -167,6 +168,7 @@ pub(super) fn ensure_transaction_fits_empty_block(
) -> Result<()> {
let selection = BlockSelection {
transactions: vec![transaction.clone()],
+ transactions_v2: Vec::new(),
};
if estimated_block_selection_size_bytes(
context,
@@ -180,6 +182,27 @@ pub(super) fn ensure_transaction_fits_empty_block(
Ok(())
}
+pub(super) fn ensure_transaction_v2_fits_empty_block(
+ context: &CompactBlockContext,
+ envelope: &str,
+ max_block_bytes: usize,
+) -> Result<()> {
+ let selection = BlockSelection {
+ transactions: Vec::new(),
+ transactions_v2: vec![envelope.to_string()],
+ };
+ if estimated_block_selection_size_bytes(
+ context,
+ &selection,
+ FinalizerMode::Ticket,
+ &BurnBundleSection::default(),
+ )? > max_block_bytes
+ {
+ bail!("transaction v2 exceeds max block size");
+ }
+ Ok(())
+}
+
pub(super) fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> {
let Some(proof) = &block.leader_proof else {
bail!("block is missing leader proof");
@@ -287,6 +310,7 @@ pub(super) fn vdf_content_commitment(
leader_ticket_id: Option<&str>,
burn_bundle_section: &BurnBundleSection,
transactions: &[Transaction],
+ transactions_v2: &[String],
) -> String {
let mode = match finalizer_mode {
FinalizerMode::Ticket => "ticket",
@@ -305,8 +329,17 @@ pub(super) fn vdf_content_commitment(
"iuna-vdf-burn-section-v1:{}",
burn_bundle_section.canonical()
));
+ if transactions_v2.is_empty() {
+ return hex_hash(format!(
+ "iuna-vdf-content-v1:{height}:{prev_hash}:{miner}:{mode}:{finalizer_rank}:{reward}:{vdf_rounds}:{ticket_id}:{transaction_hash}:{burn_section_hash}"
+ ));
+ }
+ let transaction_v2_hash = hex_hash(format!(
+ "iuna-vdf-transactions-v2:{}",
+ transactions_v2.join("|")
+ ));
hex_hash(format!(
- "iuna-vdf-content-v1:{height}:{prev_hash}:{miner}:{mode}:{finalizer_rank}:{reward}:{vdf_rounds}:{ticket_id}:{transaction_hash}:{burn_section_hash}"
+ "iuna-vdf-content-v2:{height}:{prev_hash}:{miner}:{mode}:{finalizer_rank}:{reward}:{vdf_rounds}:{ticket_id}:{transaction_hash}:{transaction_v2_hash}:{burn_section_hash}"
))
}
@@ -737,6 +770,7 @@ mod tests {
leader_proof: None,
burn_bundle_section: BurnBundleSection::default(),
transactions: Vec::new(),
+ transactions_v2: Vec::new(),
hash: "h".repeat(64),
};
block.hash = block.compute_hash();
diff --git a/src/domain/ledger_pending.rs b/src/domain/ledger_pending.rs
@@ -9,10 +9,11 @@ use super::ledger_ops::{
estimated_block_selection_size_bytes, transaction_has_missing_inputs,
validate_transaction_inputs, validate_transaction_outputs,
};
+use super::ledger_v2::apply_prevalidated_transaction_v2_to_utxos;
use super::mine_policy::{
MINE_MAX_ANCHOR_AGE_BLOCKS, mine_anchor, mine_anchor_count_before_height,
};
-use super::selection::{BlockSelection, TransactionKind};
+use super::selection::{BlockSelection, TransactionKind, fee_rate_key};
use super::transaction::{
UnsignedTxInput, transaction_inputs_available, transaction_inputs_spent_by,
};
@@ -20,8 +21,9 @@ use super::validation::{
validate_address, validate_hash, validate_signature, validate_stratum_header,
};
use super::{
- Amount, BurnBundleSection, FinalizerMode, Ledger, MAX_PENDING_POOL_BYTES,
+ AddressNetwork, Amount, BurnBundleSection, FinalizerMode, Ledger, MAX_PENDING_POOL_BYTES,
MAX_PENDING_TRANSACTIONS, MINE_ACTIONS_PER_ANCHOR_LIMIT, OutPoint, Transaction, TxOutput,
+ hex_encode, transaction_v2_is_active,
};
impl Ledger {
@@ -190,6 +192,7 @@ impl Ledger {
let required_selection = BlockSelection {
transactions: selected.clone(),
+ transactions_v2: Vec::new(),
};
if estimated_block_selection_size_bytes(
block_context,
@@ -201,31 +204,95 @@ impl Ledger {
bail!("required block content does not fit in the block");
}
- while selected.len() < self.launch_profile.max_block_transactions {
- let Some(index) =
+ let mut selection = BlockSelection {
+ transactions: selected,
+ transactions_v2: Vec::new(),
+ };
+ let height = self.height().saturating_add(1);
+ let domain = self.transaction_v2_domain()?;
+ let network = AddressNetwork::from_profile_id(&self.launch_profile.profile_id);
+ let mut remaining_v2 = if transaction_v2_is_active(height) {
+ self.pending_v2.iter().collect::<Vec<_>>()
+ } else {
+ Vec::new()
+ };
+
+ loop {
+ if selection
+ .transactions
+ .len()
+ .saturating_add(selection.transactions_v2.len())
+ >= self.launch_profile.max_block_transactions
+ {
+ break;
+ }
+
+ let legacy =
best_selectable_transaction_index(&remaining, &utxos, None, &signing_domain)
- else {
+ .map(|index| (index, fee_rate_key(&remaining[index])));
+ let v2 = remaining_v2
+ .iter()
+ .enumerate()
+ .filter_map(|(index, transaction)| {
+ let mut candidate_utxos = utxos.clone();
+ apply_prevalidated_transaction_v2_to_utxos(
+ transaction,
+ &domain,
+ network,
+ &mut candidate_utxos,
+ )
+ .ok()?;
+ let bytes = transaction.encoded_size_bytes(&domain).ok()?;
+ let rate = if bytes == 0 {
+ 0
+ } else {
+ u128::from(transaction.fee()) * 1_000_000 / bytes as u128
+ };
+ Some((index, rate, candidate_utxos))
+ })
+ .max_by_key(|(index, rate, _)| (*rate, std::cmp::Reverse(*index)));
+
+ if legacy.is_none() && v2.is_none() {
break;
- };
- let tx = remaining.remove(index);
- let mut candidate = BlockSelection {
- transactions: selected.clone(),
- };
- candidate.transactions.push(tx.clone());
- if estimated_block_selection_size_bytes(
- block_context,
- &candidate,
- finalizer_mode,
- burn_bundle_section,
- )? <= self.launch_profile.max_block_bytes
+ }
+ if v2
+ .as_ref()
+ .is_some_and(|(_, v2_rate, _)| legacy.is_none_or(|(_, rate)| *v2_rate > rate))
{
- apply_transaction(&tx, &mut utxos, &signing_domain)?;
- selected.push(tx);
+ let (index, _, candidate_utxos) = v2.expect("v2 candidate was selected");
+ let transaction = remaining_v2.remove(index);
+ let mut candidate = selection.clone();
+ candidate
+ .transactions_v2
+ .push(hex_encode(transaction.encode(&domain)?));
+ if estimated_block_selection_size_bytes(
+ block_context,
+ &candidate,
+ finalizer_mode,
+ burn_bundle_section,
+ )? <= self.launch_profile.max_block_bytes
+ {
+ utxos = candidate_utxos;
+ selection = candidate;
+ }
+ } else {
+ let (index, _) = legacy.expect("legacy candidate was selected");
+ let transaction = remaining.remove(index);
+ let mut candidate = selection.clone();
+ candidate.transactions.push(transaction.clone());
+ if estimated_block_selection_size_bytes(
+ block_context,
+ &candidate,
+ finalizer_mode,
+ burn_bundle_section,
+ )? <= self.launch_profile.max_block_bytes
+ {
+ apply_transaction(&transaction, &mut utxos, &signing_domain)?;
+ selection = candidate;
+ }
}
}
- Ok(BlockSelection {
- transactions: selected,
- })
+ Ok(selection)
}
fn select_required_anchor_burn(
@@ -791,6 +858,7 @@ mod tests {
let required_selection = BlockSelection {
transactions: vec![anchor.clone()],
+ transactions_v2: Vec::new(),
};
let ticket_bytes = estimated_block_selection_size_bytes(
compact_block_context(&ledger),
diff --git a/src/domain/ledger_prepare.rs b/src/domain/ledger_prepare.rs
@@ -67,8 +67,11 @@ impl Ledger {
let prev_hash = tip.hash.clone();
let timestamp_ms = timestamp_ms.max(ticket_block_min_timestamp(tip, finalizer_rank)?);
let bundle_hashes = burn_bundle_section.burn_bundle_hashes(height, &prev_hash, miner);
- let reward =
- self.expected_reward_for_next_block(&selection.transactions, &burn_bundle_section)?;
+ let reward = self.expected_reward_for_next_block(
+ &selection.transactions,
+ &selection.transactions_v2,
+ &burn_bundle_section,
+ )?;
let vdf_rounds = self.vdf_rounds_for_finalizer_rank(finalizer_rank)?;
let content_commitment = vdf_content_commitment(
height,
@@ -81,6 +84,7 @@ impl Ledger {
Some(&leader_ticket.id),
&burn_bundle_section,
&selection.transactions,
+ &selection.transactions_v2,
);
let vdf_seed = vdf_seed_for_child(&prev_hash, height, &bundle_hashes, &content_commitment);
Ok(PreparedBlock {
@@ -96,6 +100,7 @@ impl Ledger {
leader_ticket: Some(leader_ticket),
burn_bundle_section,
transactions: selection.transactions,
+ transactions_v2: selection.transactions_v2,
})
}
@@ -154,8 +159,11 @@ impl Ledger {
let prev_hash = tip.hash.clone();
let timestamp_ms = timestamp_ms.max(tip.timestamp_ms + 1);
let bundle_hashes = burn_bundle_section.burn_bundle_hashes(height, &prev_hash, miner);
- let reward =
- self.expected_reward_for_next_block(&selection.transactions, &burn_bundle_section)?;
+ let reward = self.expected_reward_for_next_block(
+ &selection.transactions,
+ &selection.transactions_v2,
+ &burn_bundle_section,
+ )?;
let vdf_rounds = self.recovery_vdf_rounds()?;
let content_commitment = vdf_content_commitment(
height,
@@ -168,6 +176,7 @@ impl Ledger {
None,
&burn_bundle_section,
&selection.transactions,
+ &selection.transactions_v2,
);
let vdf_seed = recovery_vdf_seed_for_child(
&prev_hash,
@@ -189,6 +198,7 @@ impl Ledger {
leader_ticket: None,
burn_bundle_section,
transactions: selection.transactions,
+ transactions_v2: selection.transactions_v2,
})
}
}
diff --git a/src/domain/ledger_v2.rs b/src/domain/ledger_v2.rs
@@ -2,11 +2,14 @@ use std::collections::BTreeMap;
use anyhow::{Context, Result, bail};
+use super::ledger_ops::{compact_block_context, ensure_transaction_v2_fits_empty_block};
use super::{
- AddressNetwork, AddressVersion, Ledger, LegacyTransactionId, MAX_PENDING_POOL_BYTES, OutPoint,
- Transaction, TransactionSubmitOutcome, TransactionV2, TransactionV2Domain, TransactionV2Input,
- TransactionV2LegacyInput, TransactionV2Output, TxOutput, decode_hex_array,
- encode_versioned_address, ensure_transaction_v2_active, hex_encode,
+ AddressNetwork, AddressVersion, Amount, Ledger, LegacyTransactionId, LineageOwnerValues,
+ MAX_PENDING_POOL_BYTES, OutPoint, Transaction, TransactionSubmitOutcome, TransactionV2,
+ TransactionV2Domain, TransactionV2Input, TransactionV2LegacyInput, TransactionV2Output,
+ TxOutput, UtxoLineageRoot, attach_existing_output_lineage, decode_hex, decode_hex_array,
+ encode_versioned_address, ensure_transaction_v2_active, hex_encode, newest_lineage_root,
+ remove_spent_output_lineage,
};
impl Ledger {
@@ -63,10 +66,14 @@ impl Ledger {
if transaction.fee() == 0 {
bail!("public transaction v2 fee must be greater than zero");
}
- let transaction_bytes = transaction.encoded_size_bytes(&domain)?;
- if transaction_bytes > self.launch_profile.max_block_bytes {
- bail!("transaction v2 exceeds the maximum block byte budget");
- }
+ let encoded = transaction.encode(&domain)?;
+ let transaction_bytes = encoded.len();
+ let envelope = hex_encode(&encoded);
+ ensure_transaction_v2_fits_empty_block(
+ compact_block_context(self),
+ &envelope,
+ self.launch_profile.max_block_bytes,
+ )?;
if self.pending.len().saturating_add(self.pending_v2.len())
>= self.launch_profile.max_pending_transactions
{
@@ -168,6 +175,120 @@ impl Ledger {
}
}
+#[allow(clippy::too_many_arguments)]
+pub(super) fn apply_transaction_v2_with_lineage(
+ transaction: &TransactionV2,
+ domain: &TransactionV2Domain,
+ network: AddressNetwork,
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+ utxo_lineage: &mut BTreeMap<OutPoint, UtxoLineageRoot>,
+ lineage_values: &mut BTreeMap<UtxoLineageRoot, Amount>,
+ lineage_owners: &mut LineageOwnerValues,
+ verify_authorizations: bool,
+) -> Result<()> {
+ let spent = transaction_v2_outpoints(transaction);
+ let spent_outputs = spent
+ .iter()
+ .map(|outpoint| {
+ utxos
+ .get(outpoint)
+ .cloned()
+ .map(|output| (outpoint.clone(), output))
+ .with_context(|| format!("transaction v2 input {} is not spendable", outpoint.id()))
+ })
+ .collect::<Result<Vec<_>>>()?;
+
+ apply_transaction_v2_to_utxos_with_policy(
+ transaction,
+ domain,
+ network,
+ utxos,
+ verify_authorizations,
+ )?;
+
+ let mut inherited_root = None;
+ for (outpoint, output) in &spent_outputs {
+ let root = remove_spent_output_lineage(
+ outpoint,
+ output,
+ utxo_lineage,
+ lineage_values,
+ lineage_owners,
+ )?;
+ inherited_root = newest_lineage_root(inherited_root, root);
+ }
+ if let Some(root) = inherited_root {
+ let transaction_id = hex_encode(transaction.transaction_id(domain)?);
+ for (index, output) in transaction_v2_outputs(transaction).iter().enumerate() {
+ let outpoint = OutPoint {
+ txid: transaction_id.clone(),
+ index: u32::try_from(index).context("transaction v2 output index exceeds u32")?,
+ };
+ let internal = utxos
+ .get(&outpoint)
+ .context("transaction v2 output is missing after application")?;
+ attach_existing_output_lineage(
+ outpoint,
+ internal,
+ root.clone(),
+ utxo_lineage,
+ lineage_values,
+ lineage_owners,
+ )?;
+ debug_assert_eq!(internal.amount, output.amount);
+ }
+ }
+ Ok(())
+}
+
+pub(super) fn decode_canonical_transaction_v2_envelope(
+ envelope: &str,
+ expected_domain: &TransactionV2Domain,
+) -> Result<TransactionV2> {
+ let bytes = decode_hex(envelope).context("transaction v2 envelope is not hexadecimal")?;
+ if hex_encode(&bytes) != envelope {
+ bail!("transaction v2 envelope is not canonical lowercase hexadecimal");
+ }
+ let (domain, transaction) = TransactionV2::decode(&bytes)?;
+ if &domain != expected_domain {
+ bail!("transaction v2 belongs to a different chain domain");
+ }
+ if transaction.encode(expected_domain)? != bytes {
+ bail!("transaction v2 envelope is not canonically encoded");
+ }
+ Ok(transaction)
+}
+
+fn transaction_v2_outpoints(transaction: &TransactionV2) -> Vec<OutPoint> {
+ match transaction {
+ TransactionV2::Migration { inputs, .. } => inputs
+ .iter()
+ .map(|input| OutPoint {
+ txid: legacy_transaction_id_hex(&input.outpoint_id),
+ index: input.outpoint_index,
+ })
+ .collect(),
+ TransactionV2::Transfer { inputs, .. } | TransactionV2::Burn { inputs, .. } => inputs
+ .iter()
+ .map(|input| OutPoint {
+ txid: hex_encode(input.outpoint_txid),
+ index: input.outpoint_index,
+ })
+ .collect(),
+ TransactionV2::Mine { .. } => Vec::new(),
+ }
+}
+
+fn transaction_v2_outputs(transaction: &TransactionV2) -> &[TransactionV2Output] {
+ match transaction {
+ TransactionV2::Migration { outputs, .. } | TransactionV2::Transfer { outputs, .. } => {
+ outputs
+ }
+ TransactionV2::Burn { change, .. } => change,
+ TransactionV2::Mine { .. } => &[],
+ }
+}
+
pub(super) fn apply_transaction_v2_to_utxos(
transaction: &TransactionV2,
domain: &TransactionV2Domain,
@@ -177,7 +298,7 @@ pub(super) fn apply_transaction_v2_to_utxos(
apply_transaction_v2_to_utxos_with_policy(transaction, domain, network, utxos, true)
}
-fn apply_prevalidated_transaction_v2_to_utxos(
+pub(super) fn apply_prevalidated_transaction_v2_to_utxos(
transaction: &TransactionV2,
domain: &TransactionV2Domain,
network: AddressNetwork,
@@ -333,7 +454,7 @@ mod tests {
use std::collections::BTreeMap;
use super::*;
- use crate::domain::{SignatureScheme, TransactionV2Output, Wallet};
+ use crate::domain::{GenesisBurn, LaunchProfile, SignatureScheme, TransactionV2Output, Wallet};
#[test]
fn migration_validation_switches_at_3000_and_creates_a_hybrid_utxo() {
@@ -561,4 +682,143 @@ mod tests {
assert!(ledger.pending_v2().is_empty());
assert_eq!(ledger.pending_v2_bytes, 0);
}
+
+ #[test]
+ fn v2_mempool_rejects_an_envelope_that_cannot_fit_with_block_overhead() {
+ let wallet = Wallet::from_seed("v2-empty-block-budget-wallet");
+ let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
+ let migration = ledger.build_v2_migration(&wallet, 3).unwrap();
+ let domain = ledger.transaction_v2_domain().unwrap();
+ ledger.launch_profile.max_block_bytes = migration.encoded_size_bytes(&domain).unwrap();
+
+ assert!(
+ ledger
+ .submit_transaction_v2_at_height(migration, 3_000)
+ .unwrap_err()
+ .to_string()
+ .contains("exceeds max block size")
+ );
+ assert!(ledger.pending_v2().is_empty());
+ }
+
+ #[test]
+ fn block_selection_includes_v2_transactions_at_activation() {
+ let wallet = Wallet::from_seed("v2-block-selection-wallet");
+ let legacy_sender = Wallet::from_seed("v2-block-selection-legacy-sender");
+ let mut ledger = Ledger::new(
+ BTreeMap::from([
+ (wallet.address().to_string(), 100),
+ (legacy_sender.address().to_string(), 100),
+ ]),
+ 1,
+ );
+ ledger.chain[0].height = 2_999;
+ let migration = ledger.build_v2_migration(&wallet, 50).unwrap();
+ let legacy = ledger
+ .build_transfer(&legacy_sender, wallet.address(), 50, 1)
+ .unwrap();
+ ledger.submit_transaction(legacy).unwrap();
+ ledger.submit_transaction_v2(migration.clone()).unwrap();
+ ledger.launch_profile.max_block_transactions = 1;
+
+ let selection = ledger
+ .select_block_transactions_with_required_burn_owner(
+ None,
+ None,
+ super::super::FinalizerMode::Ticket,
+ &super::super::BurnBundleSection::default(),
+ )
+ .unwrap();
+
+ assert!(selection.transactions.is_empty());
+ assert_eq!(selection.transactions_v2.len(), 1);
+ let encoded = decode_hex(&selection.transactions_v2[0]).unwrap();
+ assert_eq!(ledger.decode_transaction_v2(&encoded).unwrap(), migration);
+ }
+
+ #[test]
+ fn block_application_preserves_legacy_output_lineage_through_migration() {
+ let wallet = Wallet::from_seed("v2-block-lineage-wallet");
+ let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
+ let migration = ledger.build_v2_migration(&wallet, 3).unwrap();
+ let spent = ledger.utxos.keys().next().unwrap().clone();
+ let root = UtxoLineageRoot {
+ outpoint: spent.clone(),
+ height: 1,
+ };
+ ledger.utxo_lineage.insert(spent.clone(), root.clone());
+ ledger.lineage_values.insert(root.clone(), 100);
+ ledger.lineage_owners.insert(
+ root.clone(),
+ BTreeMap::from([(wallet.address().to_string(), BTreeMap::from([(spent, 100)]))]),
+ );
+ let domain = ledger.transaction_v2_domain().unwrap();
+
+ apply_transaction_v2_with_lineage(
+ &migration,
+ &domain,
+ AddressNetwork::Mainnet,
+ &mut ledger.utxos,
+ &mut ledger.utxo_lineage,
+ &mut ledger.lineage_values,
+ &mut ledger.lineage_owners,
+ true,
+ )
+ .unwrap();
+
+ assert_eq!(
+ ledger.utxo_lineage.values().collect::<Vec<_>>(),
+ vec![&root]
+ );
+ assert_eq!(ledger.lineage_values.get(&root), Some(&97));
+ assert_eq!(
+ ledger
+ .lineage_owners
+ .get(&root)
+ .and_then(|owners| owners.get(&wallet.hybrid_address(AddressNetwork::Mainnet)))
+ .map(|outputs| outputs.values().copied().sum::<u64>()),
+ Some(97)
+ );
+ }
+
+ #[test]
+ fn height_3000_block_selects_applies_and_rewards_a_v2_migration() {
+ let finalizer = Wallet::from_seed("v2-height-3000-finalizer");
+ let migrator = Wallet::from_seed("v2-height-3000-migrator");
+ let mut ledger = Ledger::new_with_genesis_burns_and_profile(
+ BTreeMap::from([
+ (finalizer.address().to_string(), 100),
+ (migrator.address().to_string(), 100),
+ ]),
+ vec![GenesisBurn::new(finalizer.address(), 10)],
+ 1,
+ LaunchProfile::local_testnet(),
+ )
+ .unwrap();
+ ledger.chain[0].height = 2_999;
+ for ticket in &mut ledger.tickets {
+ ticket.eligible_from_height = 3_000;
+ ticket.eligible_until_height = 3_000;
+ }
+ let anchor = ledger.build_burn_for_next_block(&finalizer, 1, 1).unwrap();
+ ledger.submit_transaction(anchor).unwrap();
+ let migration = ledger.build_v2_migration(&migrator, 3).unwrap();
+ ledger.submit_transaction_v2(migration.clone()).unwrap();
+ let timestamp_ms = ledger.tip().timestamp_ms.saturating_add(1);
+
+ let prepared = ledger
+ .prepare_next_block(finalizer.address(), timestamp_ms)
+ .unwrap();
+ assert_eq!(prepared.transactions_v2.len(), 1);
+ let block = prepared.finish(&finalizer, "preverified-vdf".to_string());
+ assert_eq!(block.reward, 4);
+ ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
+
+ assert_eq!(ledger.height(), 3_000);
+ assert_eq!(
+ ledger.balance_of(&migrator.hybrid_address(AddressNetwork::Testnet)),
+ 97
+ );
+ assert!(ledger.pending_v2().is_empty());
+ }
}
diff --git a/src/domain/selection.rs b/src/domain/selection.rs
@@ -8,6 +8,7 @@ pub(super) enum TransactionKind {
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(super) struct BlockSelection {
pub(super) transactions: Vec<Transaction>,
+ pub(super) transactions_v2: Vec<String>,
}
pub(super) fn fee_rate_key(transaction: &Transaction) -> u128 {
diff --git a/src/domain/ticket.rs b/src/domain/ticket.rs
@@ -355,6 +355,7 @@ mod tests {
leader_proof: None,
burn_bundle_section: BurnBundleSection::default(),
transactions: Vec::new(),
+ transactions_v2: Vec::new(),
hash: "1".repeat(64),
}
}
@@ -409,6 +410,7 @@ mod tests {
}),
burn_bundle_section: BurnBundleSection::default(),
transactions: Vec::new(),
+ transactions_v2: Vec::new(),
hash: "3".repeat(64),
}
}
diff --git a/src/domain/vdf/mod.rs b/src/domain/vdf/mod.rs
@@ -247,6 +247,7 @@ mod tests {
leader_proof: None,
burn_bundle_section: BurnBundleSection::default(),
transactions: Vec::new(),
+ transactions_v2: Vec::new(),
hash: format!("{:064x}", height + 1),
}
}