iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 0bf7592a18e13b9f0eebbd9ae2d9ea2ec64a72e0
parent 0a45c90e8ff126ba85158e0922477ddea1024537
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 23 Sep 2026 07:54:41 +0200

feat(consensus): enable hybrid rewards and burns

Diffstat:
MCHANGELOG.md | 10++++++++++
Mdocs/protocol.md | 39+++++++++++++++++++++------------------
Mdocs/quantum-migration.md | 41++++++++++++++++++++++++++++++++++-------
Msrc/adapters/chain_store/compact.rs | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/adapters/http.rs | 3+--
Msrc/adapters/http/api.rs | 26++++++++++++++------------
Msrc/adapters/http/metrics.rs | 2++
Msrc/adapters/http/ui.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/adapters/p2p/line_codec.rs | 4++++
Msrc/adapters/p2p/writer.rs | 2++
Msrc/adapters/ui_data_store.rs | 130++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/app.rs | 54+++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/app/automatic_mining.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/app/automatic_mining/pow.rs | 2+-
Msrc/app/receive.rs | 3+++
Msrc/app/wallet.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/domain.rs | 13+++++++------
Msrc/domain/adversarial_tests.rs | 21+++++++++++++++++++++
Msrc/domain/block.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/genesis.rs | 2++
Msrc/domain/ledger_apply.rs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/domain/ledger_builders.rs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/domain/ledger_ops.rs | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/domain/ledger_pending.rs | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/domain/ledger_prepare.rs | 50+++++++++++++++++++++++++++++++++++++++++++-------
Msrc/domain/ledger_reveal.rs | 195++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/domain/ledger_v2.rs | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/domain/protocol.rs | 4++++
Msrc/domain/reveal.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/domain/stratum.rs | 16++++++++++++++--
Msrc/domain/ticket.rs | 35+++++++++++++++++++++++++++++++++--
Msrc/domain/transaction.rs | 26+++++++++++++++++++-------
Msrc/domain/transaction_v2.rs | 54+++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/domain/vdf/mod.rs | 2++
Msrc/domain/wallet.rs | 2++
35 files changed, 1860 insertions(+), 183 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -5,6 +5,16 @@ from the Git history and Conventional Commit titles by `deployment.sh`. ## [Unreleased] +### Added + +- route PoW, finalizer, and committee rewards to hybrid wallets from height 3750 +- allow confirmed hybrid funds to create anchored v2 burns and lottery tickets +- carry v2 burns through burn bundles, compact snapshots, mempool views, and automatic burning + +### Changed + +- require the `transaction-v2-burns` peer capability once transaction v2 is active + ## [0.4.37] - 2026-09-22 ### Fixed diff --git a/docs/protocol.md b/docs/protocol.md @@ -57,20 +57,20 @@ Changing any value in this section requires a conscious mainnet-candidate reset The consensus block-size limit is the exact number of bytes produced by the current compact snapshot block-body encoder when the block is appended to its parent chain. The encoder's reference tables are seeded by genesis allocations and extended in chain order, so all nodes calculate the same context-dependent size. The snapshot header, launch profile, block-count field, SQLite row metadata, and SQLite page overhead are not charged to an individual block. -The compact representation stores binary hashes, addresses, signatures, and VDF data instead of their hexadecimal text. It uses base-128 varints for integers, chain-wide references for repeated addresses and protocol IDs, a single shared owner and signature for transaction inputs, implicit burn change where possible, and transaction indexes for burns repeated by the burn-bundle section. Heights, parent hashes, and block hashes are reconstructed from chain order and canonical block contents rather than repeated in each stored block body. Burns benefit most from this layout, followed by transfers and mine actions. +The compact representation stores binary hashes, addresses, signatures, and VDF data instead of their hexadecimal text. It uses base-128 varints for integers, chain-wide references for repeated addresses and protocol IDs, a single shared owner and signature for legacy transaction inputs, implicit legacy burn change where possible, and transaction indexes for legacy and v2 burns repeated by the burn-bundle section. Heights, parent hashes, and block hashes are reconstructed from chain order and canonical block contents rather than repeated in each stored block body. Burns benefit most from this layout, followed by transfers and mine actions. Snapshot v9 stores authenticated hybrid reward destinations and v2 burn attestations while retaining v7 and v8 read compatibility. P2P messages and management API responses still use JSON. Their byte length is not the consensus block size. Transaction fee-rate ordering uses a separate compact economic transaction weight, so changing JSON whitespace, key order, or hexadecimal formatting cannot change consensus size or fee priority. ## Wallet address format -Wallets display and accept version `0` Bech32m addresses. Mainnet and the +Wallets display and accept version `0` legacy and version `1` hybrid Bech32m addresses. Mainnet and the mainnet-candidate use the human-readable prefix `iuna`; local testnet uses -`tiuna`. The payload is one 5-bit version value followed by the wallet's exact -32-byte Ed25519 verifying key converted from 8-bit to 5-bit groups. Decoding -must verify the Bech32m constant, reject mixed case and non-zero padding, require -the expected network prefix and version, require exactly 32 payload bytes, and -parse those bytes as a non-weak Ed25519 verifying key. An all-uppercase address -is accepted and normalized to lowercase; mixed uppercase/lowercase is invalid. +`tiuna`. A version `0` payload contains the wallet's exact 32-byte Ed25519 +verifying key. A version `1` payload contains a commitment to the wallet's +Ed25519 and ML-DSA-44 public keys. Decoding verifies the Bech32m constant, +rejects mixed case and non-zero padding, and requires the expected network, +version, and payload length. An all-uppercase address is accepted and normalized +to lowercase; mixed uppercase/lowercase is invalid. Consensus identity is the exact 32-byte Ed25519 verifying key, represented in consensus objects and persisted chainstate as canonical 64-character lowercase @@ -86,7 +86,7 @@ iuna uses a UTXO-style ledger. The main transaction types are: 1. **Transfer:** moves IUNA from one address to another and pays a sender-chosen fee. 2. **Burn:** destroys an amount of IUNA, pays a sender-chosen fee, and creates a future lottery ticket. -3. **Mine action:** proves SHA-256-style PoW against the current chain tip. A valid mine action mints a fixed `1 IUNA` reward to its recipient and pays a fixed `1 IUNA` fee to the block finalizer. +3. **Mine action:** proves SHA-256-style PoW against the current chain tip. A valid mine action mints a fixed `1 IUNA` reward to its recipient and pays a fixed `1 IUNA` fee to the block finalizer. Starting at height 3750, the recipient must be a version-1 hybrid address. Burn and transfer fees are chosen by the sender. Mine action reward and mine action fee are deterministic protocol values. @@ -94,7 +94,7 @@ Burn and transfer fees are chosen by the sender. Mine action reward and mine act Every transfer, burn, and mine action is cryptographically scoped to one chain. Its signing or proof preimage starts with the fixed `IUNA-TX` type tag, the big-endian signing-format version `1`, a length-prefixed UTF-8 chain ID, and the length-prefixed 32-byte genesis block hash. The remaining payload uses an explicit one-byte transaction type and canonical binary fields: big-endian fixed-width integers, length-prefixed decoded hashes, signatures and Ed25519 keys, and ordered input/output counts. JSON spelling, field order, and separators never enter the sighash. Hexadecimal fields committed by format v1 must use canonical lowercase encoding; alternate casing is rejected during signature or proof validation. -Transfers and burns use Ed25519 over this binary preimage. Native and Stratum mine proofs commit the same domain and logical mine fields before proof-specific hashing. Validators reconstruct the domain from their local launch profile and genesis block, so a transaction valid on candidate, mainnet, testnet, or another genesis fails signature/proof validation everywhere else. Only format v1 is accepted. +Legacy transfers and burns use Ed25519 over this binary preimage. Transaction-v2 spends from version-1 addresses use both Ed25519 and ML-DSA-44 over one canonical v2 payload; this includes hybrid transfers and burns. Native and Stratum mine proofs commit the same domain and logical mine fields before proof-specific hashing. Validators reconstruct the domain from their local launch profile and genesis block, so a transaction valid on candidate, mainnet, testnet, or another genesis fails signature/proof validation everywhere else. Only the defined legacy and v2 formats are accepted. A block is invalid if any transaction ID already occurred earlier on that chain. This is especially important for inputless mine actions: without this check, someone could replay an included proof after spending its reward, recreate the same outpoint, and inflate the supply. @@ -130,6 +130,8 @@ Every normal block must include at least one burn. This keeps the future ticket Burns use a one-block admission pipeline. A public burn is signed against the current chain tip, then queued while the child of that tip is produced. It may appear only in the following block. Validators require its signed anchor to equal the containing block's grandparent hash (the containing block's parent `prev_hash`). This gives the burn a full VDF interval plus the next burn-collection window to propagate without allowing the finalizer to change the transaction list after starting its VDF. A queued burn survives the first tip change and expires after its single inclusion height if it was not included. The finalizer's mandatory local burn is signed directly for the next block using the same grandparent anchor rule. +Legacy and v2 burns follow the same admission, fee, inclusion, maturity, and expiry rules. A v2 burn spends hybrid outputs with dual signatures. Its ticket owner is the legacy Ed25519 component of that same hybrid wallet identity, so existing leader proofs and ticket ownership remain compatible while the funds being destroyed are protected by the hybrid authorization. + The anchor burn is not a fairness mechanism. By itself, it would mostly help the current finalizer keep creating future tickets. Fairness against self-serving finalizers comes from the burn inclusion committee described below. ## VDF Timing @@ -267,18 +269,18 @@ For normal ticket blocks, lower-rank finalization pays more to the independent b Recovery blocks pay `100%` to the recovery finalizer. -Only attestations actually included in the block earn a committee share. If no extra committee attestation is required, the finalizer receives the full reward. Integer amounts are rounded down into the committee half (`reward / 2`), so the finalizer receives the remainder when the reward is odd. Committee reward outputs do not create UTXO lineage; lineage selection remains based on mature mine-action descendants. +Only attestations actually included in the block earn a committee share. If no extra committee attestation is required, the finalizer receives the full reward. Integer amounts are rounded down into the committee half (`reward / 2`), so the finalizer receives the remainder when the reward is odd. Committee reward outputs do not create UTXO lineage; lineage selection remains based on mature mine-action descendants. Starting at height 3750, finalizer and rewarded committee outputs use authenticated version-1 hybrid payout addresses. The finalizer signs its block payout address with its legacy identity; each committee member binds its payout address into its signed burn bundle. -A committee member can sign one burn bundle for its slot, height, and parent hash. A bundle is at most `10,000` bytes and lists valid fee-paying pending burns eligible at that height, ordered by absolute fee with signature as the deterministic tie-breaker. Burns queued for the following height are not included yet. Honest committee policy is to include every valid burn it selects by that canonical ordering, or to sign an empty bundle only when the signer knows no valid burn for that height. Empty bundles are an honest-policy signal, not something validators can prove from their own mempools. Consensus checks committee membership, signature validity, lineage assignment, ordering, and threshold. +A committee member can sign one burn bundle for its slot, height, and parent hash. A bundle is at most `10,000` bytes and lists valid fee-paying pending legacy and v2 burns eligible at that height, ordered by absolute fee and then transaction identifier. Burns queued for the following height are not included yet. Honest committee policy is to include every valid burn it selects by that canonical ordering, or to sign an empty bundle only when the signer knows no valid burn for that height. Empty bundles are an honest-policy signal, not something validators can prove from their own mempools. Consensus checks committee membership, signature validity, lineage assignment, ordering, and threshold. Automatic nodes wait about `30 seconds` after seeing pending burns for the next height before signing a burn bundle or starting the burn-list-bound VDF. This gives burn gossip time to settle and avoids locking in an underfilled bundle from the first partial batch a node received. -A block contains transfers, burns, mine actions, and one compact burn-bundle section. The finalizer's local anchor burn is still reserved as the first block item. +A block contains legacy and v2 transactions plus one compact burn-bundle section. The finalizer's anchor burn is reserved before optional transactions, whether it uses legacy or hybrid funds. The compact burn-bundle section stores: - up to four explicit burn committee bundle signatures for non-finalizer slots, in slot order; -- one deduplicated required burn list; +- deduplicated required legacy and v2 burn lists; and - a small bitmask per burn saying which of the included committee bundles contained that burn. The required burn list is the union of fee-paying burns contained in the attestations the block uses. If one committee member signs an empty bundle and another signs a bundle with burns, the required list still includes the burns from the non-empty bundle. @@ -331,12 +333,13 @@ Nodes gossip: - transfers; - burns; +- canonical transaction-v2 envelopes, including hybrid transfers and burns; - mine actions; - signed burn bundles; - burn-bundle requests for missing committee slots at a specific next-block height and parent hash; - block inventory and blocks. -Anchor burns are prepared locally by the finalizer and are not normal wallet traffic. +Legacy anchor burns are prepared locally by the finalizer. Hybrid anchor burns use the v2 mempool so they survive restart and can be relayed and attested before inclusion. When a ticket finalizer is collecting burn-bundle attestations and has fewer signatures than its rank requires, it may request the missing slots. Peers answer from their local cache with matching @@ -351,10 +354,10 @@ When the pending count or byte bound is reached, a node admits an independent tr When a node builds a block, the flow is: 1. Collect valid signed burn bundles for the next height. -2. Reserve the local anchor burn as the first block item. +2. Reserve an eligible legacy or v2 anchor burn from the finalizer wallet. 3. For recovery blocks, ensure at least one anchor burn is from the recovery finalizer. -4. Include every burn required by the selected burn-bundle attestations. -5. Fill remaining block space with valid fee-paying transfers, additional burns, and mine actions ordered by fee rate. Mine actions are limited to `2` actions per anchor. +4. Include every legacy and v2 burn required by the selected burn-bundle attestations. +5. Fill remaining block space with valid fee-paying legacy and v2 transactions ordered by fee rate. Mine actions are limited to `2` actions per anchor. 6. Bind the VDF seed to the five burn-attestation slot hashes, using default hashes for missing slots. Slot `0` uses the synthetic finalizer attestation hash instead of a separate burn-bundle signature. Blocks are bounded by transaction count and exact compact stored block-body size. The mainnet-candidate maximum is `1,000,000` bytes. Block admission checks the finished block with the parent chain's compact reference context. Block construction uses the same encoder while reserving mandatory anchor and attested burns before filling the remaining space. diff --git a/docs/quantum-migration.md b/docs/quantum-migration.md @@ -94,6 +94,9 @@ Application, transport, and consensus versions move independently: 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. +5. At candidate height 3750, mine-action, finalizer, and committee rewards switch from legacy + Ed25519 destinations to authenticated version-1 hybrid payout addresses. This is a coordinated + consensus transition; every block producer and validating node must upgrade before the boundary. There is no separate public Iuna testnet. The `iuna-mainnet-candidate` network is the rehearsal network for this migration. Once hybrid wallet keys and the complete transaction-v2 path are @@ -122,10 +125,29 @@ Verification uses the exact-pinned RustCrypto `ml-dsa` 0.1.1 implementation. Tha has not been independently audited, so an independent review and an explicit backend acceptance decision remain prerequisites for treating the active rules as production-ready. Nodes advertise the transaction-v2 block capability and relay canonical transaction-v2 envelopes only to peers -that advertise the separate `transaction-v2-mempool` capability. This keeps 0.4.35 block-validating -peers connected during a gradual relay upgrade. The management -wallet can submit reviewed migration batches and ordinary hybrid transfers; address rotation and -broader recovery rehearsal remain release blockers. +that advertise the separate `transaction-v2-mempool` capability. Hybrid burns extend the active +height-3000 consensus path, including ticket creation and committee burn bundles. Nodes therefore +require the additional `transaction-v2-burns` capability once transaction v2 is active; validators +that lack it must be upgraded together rather than remaining connected through a gradual relay +rollout. The management wallet can submit reviewed migration batches, ordinary hybrid transfers, +and anchored hybrid burns. Address rotation and broader recovery rehearsal remain release blockers. + +### Hybrid reward activation target + +Candidate height 3750 is the fixed activation target for hybrid reward payouts. Through height +3749, mine actions and implicit block rewards retain their historical legacy destinations. From +height 3750 onward: + +- PoW mine actions must name an address-v1 hybrid recipient; +- every block must carry a version-1 finalizer payout address authenticated by the finalizer's + Ed25519 identity and committed by the block hash and VDF seed; +- every rewarded committee attestation must carry its own version-1 payout address inside the + signed burn-bundle payload; and +- peers preparing the activation boundary must advertise `hybrid-reward-payouts`. + +Compact snapshot v9 preserves the authenticated payout fields while v7 and v8 remain readable. +Nodes without the hybrid reward rules will diverge at height 3750, so unlike the earlier gradual +transaction-v2 mempool rollout, this boundary requires a coordinated validator upgrade. Pending and confirmed transaction-v2 entries are included in the management wallet history and chain views. Confirmed history is materialized from the canonical chain snapshot, so a chain @@ -161,12 +183,17 @@ ledger-derived chain domain, canonical encoded byte limits, UTXO ownership, valu 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 +in compact snapshot v8, and carried forward after reorgs. Snapshot v7 remains readable. Snapshot +v9 extends that framing with authenticated hybrid reward destinations and indexed v2 burn-bundle +attestations while retaining v7/v8 read compatibility. Blocks therefore propagate through the existing block P2P path. Standalone v2 mempool gossip now accepts, validates, canonicalizes, rebroadcasts, and periodically re-announces v2 envelopes. The management wallet reports legacy and hybrid balances, exposes an authenticated migration preview, submits one -reviewed block-bounded migration batch at a time, and can spend confirmed hybrid value to another -address-v1 recipient. Automatic address rotation and full recovery rehearsal remain incomplete. +reviewed block-bounded migration batch at a time, can spend confirmed hybrid value to another +address-v1 recipient, and can destroy confirmed hybrid value through the same one-block burn queue +used by legacy burns. Hybrid burns create ordinary lottery tickets linked to the Ed25519 component +of the hybrid wallet and participate in the same anti-censorship bundle rules. Automatic address +rotation and full recovery rehearsal remain incomplete. ## Other trust boundaries diff --git a/src/adapters/chain_store/compact.rs b/src/adapters/chain_store/compact.rs @@ -3,14 +3,16 @@ use std::{collections::BTreeMap, sync::Arc}; use anyhow::{Context, Result, bail}; use crate::domain::{ - Amount, Block, BurnBundleSection, BurnBundleSignature, ChainSnapshot, FinalizerMode, - LaunchProfile, LeaderProof, MaskedBurn, OutPoint, Transaction, TxInput, TxOutput, + AddressNetwork, Amount, Block, BurnBundleSection, BurnBundleSignature, ChainSnapshot, + FinalizerMode, LaunchProfile, LeaderProof, MaskedBurn, OutPoint, Transaction, TxInput, + TxOutput, decode_versioned_address, }; const COMPACT_SNAPSHOT_MAGIC: &[u8] = b"IUNA-SNAPSHOT"; const MIN_SUPPORTED_COMPACT_SNAPSHOT_VERSION: u8 = 6; -const COMPACT_SNAPSHOT_VERSION: u8 = 8; +const COMPACT_SNAPSHOT_VERSION: u8 = 9; const TRANSACTION_V2_COMPACT_SNAPSHOT_VERSION: u8 = 8; +const HYBRID_REWARD_COMPACT_SNAPSHOT_VERSION: u8 = 9; 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; @@ -74,7 +76,17 @@ impl CompactBlockContext { // 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() { + let has_hybrid_rewards = block.reward_address.is_some() + || block.reward_address_signature.is_some() + || !block.burn_bundle_section.burns_v2.is_empty() + || block + .burn_bundle_section + .signatures + .iter() + .any(|signature| signature.reward_address.is_some()); + let version = if has_hybrid_rewards { + HYBRID_REWARD_COMPACT_SNAPSHOT_VERSION + } else if block.transactions_v2.is_empty() { TRANSACTION_V2_COMPACT_SNAPSHOT_VERSION - 1 } else { TRANSACTION_V2_COMPACT_SNAPSHOT_VERSION @@ -343,6 +355,18 @@ fn encode_block_body_with_size_breakdown_for_version( let block_start = writer.bytes.len(); writer.varint(block.timestamp_ms); writer.address(&block.miner, tables)?; + if version >= HYBRID_REWARD_COMPACT_SNAPSHOT_VERSION { + writer.bool(block.reward_address.is_some()); + if let Some(address) = &block.reward_address { + writer.address(address, tables)?; + } + writer.bool(block.reward_address_signature.is_some()); + if let Some(signature) = &block.reward_address_signature { + writer.fixed_hex::<64>(signature, "reward address signature")?; + } + } else if block.reward_address.is_some() || block.reward_address_signature.is_some() { + bail!("compact snapshot version {version} cannot encode a hybrid reward address"); + } writer.u8(match block.finalizer_mode { FinalizerMode::Ticket => 0, FinalizerMode::Recovery => 1, @@ -385,7 +409,7 @@ fn encode_block_body_with_size_breakdown_for_version( bail!("compact snapshot version {version} cannot encode transaction v2 envelopes"); } let burn_bundle_start = writer.bytes.len(); - encode_burn_bundle_section(writer, block, tables)?; + encode_burn_bundle_section(writer, block, tables, version)?; let block_end = writer.bytes.len(); Ok(CompactBlockSizeBreakdown { total_bytes: block_end.saturating_sub(block_start), @@ -417,6 +441,17 @@ fn decode_block_body_for_version( ) -> Result<Block> { let timestamp_ms = reader.varint()?; let miner = reader.address(tables)?; + let reward_address = if version >= HYBRID_REWARD_COMPACT_SNAPSHOT_VERSION && reader.bool()? { + Some(reader.address(tables)?) + } else { + None + }; + let reward_address_signature = + if version >= HYBRID_REWARD_COMPACT_SNAPSHOT_VERSION && reader.bool()? { + Some(reader.fixed_hex::<64>()?) + } else { + None + }; let finalizer_mode = match reader.u8()? { 0 => FinalizerMode::Ticket, 1 => FinalizerMode::Recovery, @@ -453,12 +488,15 @@ fn decode_block_body_for_version( } else { Vec::new() }; - let burn_bundle_section = decode_burn_bundle_section(reader, &transactions, tables)?; + let burn_bundle_section = + decode_burn_bundle_section(reader, &transactions, &transactions_v2, tables, version)?; let mut block = Block { height, prev_hash, timestamp_ms, miner, + reward_address, + reward_address_signature, finalizer_mode, finalizer_rank, reward, @@ -478,12 +516,21 @@ fn encode_burn_bundle_section( writer: &mut CompactWriter, block: &Block, tables: &mut EncodeTables, + version: u8, ) -> Result<()> { let section = &block.burn_bundle_section; writer.varint(section.signatures.len() as u64); for signature in &section.signatures { writer.varint(u64::from(signature.slot)); writer.address(&signature.member, tables)?; + if version >= HYBRID_REWARD_COMPACT_SNAPSHOT_VERSION { + writer.bool(signature.reward_address.is_some()); + if let Some(address) = &signature.reward_address { + writer.address(address, tables)?; + } + } else if signature.reward_address.is_some() { + bail!("compact snapshot version {version} cannot encode a committee reward address"); + } writer.fixed_hex::<64>(&signature.signature, "burn bundle signature")?; } writer.varint(section.burns.len() as u64); @@ -496,13 +543,29 @@ fn encode_burn_bundle_section( writer.varint(transaction_index as u64); writer.u8(masked.bundle_mask); } + if version >= HYBRID_REWARD_COMPACT_SNAPSHOT_VERSION { + writer.varint(section.burns_v2.len() as u64); + for masked in &section.burns_v2 { + let transaction_index = block + .transactions_v2 + .iter() + .position(|envelope| envelope == &masked.envelope) + .context("transaction v2 burn bundle envelope is missing from block")?; + writer.varint(transaction_index as u64); + writer.u8(masked.bundle_mask); + } + } else if !section.burns_v2.is_empty() { + bail!("compact snapshot version {version} cannot encode transaction v2 burn attestations"); + } Ok(()) } fn decode_burn_bundle_section( reader: &mut CompactReader<'_>, transactions: &[Transaction], + transactions_v2: &[String], tables: &mut DecodeTables, + version: u8, ) -> Result<BurnBundleSection> { let signatures = decode_vec( reader, @@ -512,6 +575,13 @@ fn decode_burn_bundle_section( Ok(BurnBundleSignature { slot: u8::try_from(reader.varint()?).context("burn bundle slot does not fit u8")?, member: reader.address(tables)?, + reward_address: if version >= HYBRID_REWARD_COMPACT_SNAPSHOT_VERSION + && reader.bool()? + { + Some(reader.address(tables)?) + } else { + None + }, signature: reader.fixed_hex::<64>()?, }) }, @@ -538,7 +608,33 @@ fn decode_burn_bundle_section( }) }, )?; - Ok(BurnBundleSection { signatures, burns }) + let burns_v2 = if version >= HYBRID_REWARD_COMPACT_SNAPSHOT_VERSION { + decode_vec( + reader, + "transaction v2 burn bundle count", + MAX_COMPACT_VEC_ITEMS, + |reader| { + let transaction_index = reader.bounded_usize( + "transaction v2 burn bundle index", + transactions_v2.len().saturating_sub(1), + )?; + Ok(crate::domain::MaskedBurnV2 { + envelope: transactions_v2 + .get(transaction_index) + .context("transaction v2 burn bundle index is out of bounds")? + .clone(), + bundle_mask: reader.u8()?, + }) + }, + )? + } else { + Vec::new() + }; + Ok(BurnBundleSection { + signatures, + burns, + burns_v2, + }) } fn encode_transaction( @@ -869,8 +965,13 @@ impl CompactWriter { self.u8(0); self.varint(*index); } else { - self.u8(1); - self.fixed_hex::<32>(value, "address")?; + if decode_hex(value).is_ok() { + self.u8(1); + self.fixed_hex::<32>(value, "address")?; + } else { + self.u8(2); + self.string(value); + } tables.register_address(value); } Ok(()) @@ -1032,6 +1133,21 @@ impl<'a> CompactReader<'a> { tables.register_address(&value); Ok(value) } + 2 => { + let value = self.string()?; + let network = if value.starts_with("tiuna1") { + AddressNetwork::Testnet + } else { + AddressNetwork::Mainnet + }; + decode_versioned_address(&value, network) + .context("compact versioned address is invalid")?; + if tables.address_indices.contains_key(&value) { + bail!("compact address is encoded twice instead of referenced"); + } + tables.register_address(&value); + Ok(value) + } other => bail!("invalid compact address tag {other}"), } } @@ -1107,8 +1223,9 @@ mod tests { use std::{collections::BTreeMap, panic}; use crate::domain::{ - Block, BurnBundleSection, FinalizerMode, LaunchProfile, Ledger, MICRO_IUNA, MaskedBurn, - OutPoint, Transaction, TxInput, TxOutput, Wallet, + AddressNetwork, Block, BurnBundleSection, BurnBundleSignature, FinalizerMode, + LaunchProfile, Ledger, MICRO_IUNA, MaskedBurn, MaskedBurnV2, OutPoint, Transaction, + TxInput, TxOutput, Wallet, }; use super::{ @@ -1122,7 +1239,7 @@ mod tests { }; #[test] - fn compact_snapshot_v8_roundtrips_default_and_local_profiles() { + fn compact_snapshot_v9_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(); @@ -1359,6 +1476,8 @@ mod tests { prev_hash: "0".repeat(64), timestamp_ms: 1, miner: owner.clone(), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 0, @@ -1380,6 +1499,7 @@ mod tests { burn: burn.clone(), bundle_mask: 0b10, }], + burns_v2: Vec::new(), }); let encode = |block: &Block| { let mut writer = CompactWriter::default(); @@ -1393,7 +1513,7 @@ mod tests { let mut context = CompactBlockContext::default(); let breakdown = context.append_block_with_size_breakdown(&with).unwrap(); assert_eq!(breakdown.total_bytes, with_bytes.len()); - assert_eq!(breakdown.burn_bundle_bytes, 4); + assert_eq!(breakdown.burn_bundle_bytes, 5); assert!(breakdown.burn_bytes > 0); assert_eq!(breakdown.transfer_bytes, 0); assert_eq!(breakdown.mine_bytes, 0); @@ -1450,6 +1570,46 @@ mod tests { } #[test] + fn compact_v9_roundtrips_hybrid_reward_addresses() { + let wallet = Wallet::from_seed("compact-hybrid-reward"); + let mut block = Ledger::new( + BTreeMap::from([(wallet.address().to_string(), MICRO_IUNA)]), + 1, + ) + .snapshot() + .blocks[0] + .clone(); + block.reward_address = Some(wallet.hybrid_address(AddressNetwork::Mainnet)); + block.burn_bundle_section.signatures = vec![BurnBundleSignature { + slot: 1, + member: wallet.address().to_string(), + reward_address: Some(wallet.hybrid_address(AddressNetwork::Mainnet)), + signature: "11".repeat(64), + }]; + block.transactions_v2 = vec!["aa".to_string()]; + block.burn_bundle_section.burns_v2 = vec![MaskedBurnV2 { + envelope: "aa".to_string(), + bundle_mask: 1 << 1, + }]; + 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( @@ -1496,6 +1656,8 @@ mod tests { writer .fixed_hex::<32>(&"0".repeat(64), "test miner") .unwrap(); + writer.bool(false); + writer.bool(false); } fn block_body_through_leader_proof_flag(writer: &mut CompactWriter) { diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -63,8 +63,7 @@ use static_assets::{alpine_js, app_js, favicon, index}; use ui::{ add_pending_outputs, add_pending_v2_outputs, populate_wallet_reward_flow, transaction_v2_input_outpoints, ui_blocks_from_indexes, ui_transaction, ui_transaction_v2, - wallet_transaction_row, wallet_transaction_rows, wallet_transaction_v2_row, - wallet_transaction_v2_rows, + wallet_transaction_rows, wallet_transaction_v2_row, wallet_transaction_v2_rows, }; use wallet::{ api_wallet_setup, estimate_burn_fee, estimate_mine_fee, estimate_transfer_fee, diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -25,8 +25,8 @@ use super::{ DATASET_LIMIT, DATASET_PAGE_LIMIT, EXPLORER_LIMIT, EXPLORER_PAGE_LIMIT, HttpState, add_pending_outputs, add_pending_v2_outputs, metrics_response, network_health, populate_wallet_reward_flow, top_mine_proofs, transaction_v2_input_outpoints, - ui_blocks_from_indexes, ui_transaction, ui_transaction_v2, wallet_transaction_row, - wallet_transaction_rows, wallet_transaction_v2_row, wallet_transaction_v2_rows, + ui_blocks_from_indexes, ui_transaction, ui_transaction_v2, wallet_transaction_rows, + wallet_transaction_v2_row, wallet_transaction_v2_rows, }; pub(super) async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> { @@ -145,7 +145,7 @@ pub(super) async fn api_wallet_transactions( .unwrap_or(DATASET_PAGE_LIMIT) .clamp(1, DATASET_LIMIT); let filters = WalletTransactionFilters::from_query(query); - let (wallet, wallet_addresses, pending, pending_v2, domain, network, v2_outputs) = { + let (wallet_addresses, pending, pending_v2, domain, network, v2_outputs) = { let node = state.node.lock().await; let status = node.status(); let wallet = node.wallet_address().to_string(); @@ -164,7 +164,6 @@ pub(super) async fn api_wallet_transactions( }) .collect::<BTreeMap<_, _>>(); ( - wallet, wallet_addresses, node.pending_transactions(), pending_v2, @@ -179,8 +178,13 @@ pub(super) async fn api_wallet_transactions( .await .unwrap_or_default(); add_pending_outputs(&mut pending_outputs, &pending); - let mut pending_rows = - wallet_transaction_rows(&wallet, pending.clone(), &[], &pending_outputs, filters); + let mut pending_rows = wallet_transaction_rows( + &wallet_addresses, + pending.clone(), + &[], + &pending_outputs, + filters, + ); if let Some(domain) = domain.as_ref() { let mut v2_outputs = v2_outputs; let _ = add_pending_v2_outputs(&mut v2_outputs, &pending_v2, domain, network); @@ -206,14 +210,13 @@ pub(super) async fn api_wallet_transactions( let remaining_limit = limit.saturating_sub(items.len()); let kinds = wallet_transaction_filter_kinds(filters); let store = state.ui_data_store.clone(); - let wallet_for_query = wallet.clone(); let wallet_addresses_for_query = wallet_addresses.clone(); let confirmed_fetch_limit = confirmed_offset.saturating_add(remaining_limit); let ((confirmed_rows, confirmed_legacy_total), (confirmed_v2_rows, confirmed_v2_total)) = tokio::task::spawn_blocking(move || -> Result<_> { Ok(( - store.load_wallet_transactions( - &wallet_for_query, + store.load_wallet_transactions_for_addresses( + &wallet_addresses_for_query, &kinds, 0, confirmed_fetch_limit, @@ -267,8 +270,8 @@ pub(super) async fn api_wallet_transactions( .filter_map(|row| { let sort_key = row.sort_key; let is_reward = row.kind == "reward"; - let mut item = wallet_transaction_row( - &wallet, + let mut item = super::ui::wallet_transaction_row_for_addresses( + &wallet_addresses, &row.transaction, &confirmed_outputs, &WalletTransactionContext { @@ -281,7 +284,6 @@ pub(super) async fn api_wallet_transactions( if is_reward { item.kind = "reward"; item.from = "fees".to_string(); - item.to = Some(wallet.clone()); item.direction = "reward"; if let Some(block) = reward_blocks.get(&row.block_height) { populate_wallet_reward_flow(&mut item, block); diff --git a/src/adapters/http/metrics.rs b/src/adapters/http/metrics.rs @@ -447,6 +447,8 @@ mod tests { prev_hash: "0".repeat(64), timestamp_ms: 0, miner: "finalizer".to_string(), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 0, diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -16,7 +16,7 @@ use super::types::{ }; pub(super) fn wallet_transaction_rows( - wallet: &str, + wallet_addresses: &[String], pending: Vec<Transaction>, chain: &[Block], outputs: &BTreeMap<OutPoint, TxOutput>, @@ -34,7 +34,9 @@ pub(super) fn wallet_transaction_rows( if !filters.allows(tx) { continue; } - if let Some(row) = wallet_transaction_row(wallet, tx, outputs, &pending_context) { + if let Some(row) = + wallet_transaction_row_for_addresses(wallet_addresses, tx, outputs, &pending_context) + { rows.push((u128::MAX - index as u128, row)); } } @@ -44,8 +46,8 @@ pub(super) fn wallet_transaction_rows( if !filters.allows(tx) { continue; } - if let Some(row) = wallet_transaction_row( - wallet, + if let Some(row) = wallet_transaction_row_for_addresses( + wallet_addresses, tx, outputs, &WalletTransactionContext { @@ -163,26 +165,42 @@ pub(super) fn wallet_transaction_v2_row( })) } +#[cfg(test)] pub(super) fn wallet_transaction_row( wallet: &str, tx: &Transaction, outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, context: &WalletTransactionContext, ) -> Option<WalletTransactionRow> { + wallet_transaction_row_for_addresses( + std::slice::from_ref(&wallet.to_string()), + tx, + outputs_by_outpoint, + context, + ) +} + +pub(super) fn wallet_transaction_row_for_addresses( + wallet_addresses: &[String], + tx: &Transaction, + outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, + context: &WalletTransactionContext, +) -> Option<WalletTransactionRow> { + let owns = |address: &str| wallet_addresses.iter().any(|wallet| wallet == address); match tx { Transaction::Transfer { inputs, outputs, fee, signature, - } if tx.sender() == wallet || outputs.iter().any(|output| output.address == wallet) => { - let sent = tx.sender() == wallet; + } if owns(tx.sender()) || outputs.iter().any(|output| owns(&output.address)) => { + let sent = owns(tx.sender()); let amount = if sent { tx.amount() } else { outputs .iter() - .filter(|output| output.address == wallet) + .filter(|output| owns(&output.address)) .fold(0_u64, |total, output| total.saturating_add(output.amount)) }; Some(WalletTransactionRow { @@ -215,7 +233,7 @@ pub(super) fn wallet_transaction_row( fee, signature, .. - } if tx.sender() == wallet => Some(WalletTransactionRow { + } if owns(tx.sender()) => Some(WalletTransactionRow { kind: "burn", from: tx.sender().to_string(), to: None, @@ -242,7 +260,7 @@ pub(super) fn wallet_transaction_row( difficulty_bits, signature, .. - } if recipient == wallet => Some(WalletTransactionRow { + } if owns(recipient) => Some(WalletTransactionRow { kind: "mine", from: "pow".to_string(), to: Some(recipient.clone()), @@ -425,16 +443,28 @@ fn ui_block_with_v2( .burn_bundle_section .expand(block.height, &block.prev_hash) .into_iter() - .map(|bundle| UiBurnBundle { - slot: bundle.slot, - member: bundle.member.clone(), - hash: bundle.bundle_hash(), - byte_size: bundle.serialized_size_bytes().unwrap_or_default(), - burns: bundle + .map(|bundle| { + let mut burns = bundle .burns .iter() .map(|burn| ui_transaction(burn, outputs)) - .collect(), + .collect::<Vec<_>>(); + if let Some(domain) = transaction_v2_domain { + burns.extend(bundle.burns_v2.iter().filter_map(|envelope| { + let encoded = crate::domain::decode_hex(envelope).ok()?; + let (decoded_domain, transaction) = TransactionV2::decode(&encoded).ok()?; + (decoded_domain == *domain) + .then(|| ui_transaction_v2(&transaction, outputs, domain, network).ok()) + .flatten() + })); + } + UiBurnBundle { + slot: bundle.slot, + member: bundle.member.clone(), + hash: bundle.bundle_hash(), + byte_size: bundle.serialized_size_bytes().unwrap_or_default(), + burns, + } }) .collect(); let burn_bundle_bytes = storage_size @@ -1178,6 +1208,8 @@ mod tests { prev_hash: "parent".to_string(), timestamp_ms: 1_000, miner: "finalizer".to_string(), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 5, @@ -1188,9 +1220,11 @@ mod tests { signatures: vec![BurnBundleSignature { slot: 2, member: "committee".to_string(), + reward_address: None, signature: "attestation".to_string(), }], burns: Vec::new(), + burns_v2: Vec::new(), }, transactions: vec![transfer("transfer", 2), burn("burn")], transactions_v2: Vec::new(), @@ -1275,6 +1309,8 @@ mod tests { prev_hash: "parent".to_string(), timestamp_ms: 1, miner: "finalizer".to_string(), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 1, @@ -1285,12 +1321,14 @@ mod tests { signatures: vec![crate::domain::BurnBundleSignature { slot: 1, member: "member-1".to_string(), + reward_address: None, signature: "sig-1".to_string(), }], burns: vec![MaskedBurn { burn: burn.clone(), bundle_mask: 1 << 1, }], + burns_v2: Vec::new(), }, transactions: vec![burn], transactions_v2: Vec::new(), @@ -1332,6 +1370,8 @@ mod tests { prev_hash: "parent".to_string(), timestamp_ms: 1, miner: "finalizer".to_string(), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 1, @@ -1368,6 +1408,8 @@ mod tests { prev_hash: "parent".to_string(), timestamp_ms: 1, miner: "finalizer".to_string(), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 1, @@ -1378,9 +1420,11 @@ mod tests { signatures: vec![crate::domain::BurnBundleSignature { slot: 1, member: "member-1".to_string(), + reward_address: None, signature: "sig-1".to_string(), }], burns: Vec::new(), + burns_v2: Vec::new(), }, transactions: vec![burn("burn-a")], transactions_v2: Vec::new(), @@ -1445,6 +1489,8 @@ mod tests { prev_hash: "parent".to_string(), timestamp_ms: 1, miner: "fallback".to_string(), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 1, reward: 1, @@ -1455,9 +1501,11 @@ mod tests { signatures: vec![crate::domain::BurnBundleSignature { slot: 1, member: "member-1".to_string(), + reward_address: None, signature: "sig-1".to_string(), }], burns: Vec::new(), + burns_v2: Vec::new(), }, transactions: vec![burn("burn-a")], transactions_v2: Vec::new(), diff --git a/src/adapters/p2p/line_codec.rs b/src/adapters/p2p/line_codec.rs @@ -278,7 +278,9 @@ mod tests { prev_hash: "parent".to_string(), slot, member: format!("member-{slot}"), + reward_address: None, burns: vec![burn(signature)], + burns_v2: Vec::new(), signature: format!("bundle-{signature}"), } } @@ -289,6 +291,8 @@ mod tests { prev_hash: "0".repeat(64), timestamp_ms: height, miner: "0".repeat(64), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 0, diff --git a/src/adapters/p2p/writer.rs b/src/adapters/p2p/writer.rs @@ -89,6 +89,8 @@ mod tests { prev_hash: format!("{:064x}", height.saturating_sub(1)), timestamp_ms: height, miner: "0".repeat(64), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 0, diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs @@ -494,6 +494,18 @@ LIMIT ?1 }) } + pub fn load_wallet_transactions_for_addresses( + &self, + addresses: &[String], + kinds: &[&str], + offset: usize, + limit: usize, + ) -> Result<(Vec<WalletTransactionProjection>, usize)> { + self.with_connection(|connection| { + load_wallet_transactions_for_addresses(connection, addresses, kinds, offset, limit) + }) + } + pub fn load_wallet_transactions_v2( &self, addresses: &[String], @@ -1333,6 +1345,62 @@ LIMIT ? OFFSET ? Ok((rows, total)) } +fn load_wallet_transactions_for_addresses( + connection: &Connection, + addresses: &[String], + kinds: &[&str], + offset: usize, + limit: usize, +) -> Result<(Vec<WalletTransactionProjection>, usize)> { + if addresses.is_empty() || kinds.is_empty() { + return Ok((Vec::new(), 0)); + } + let address_placeholders = std::iter::repeat_n("?", addresses.len()) + .collect::<Vec<_>>() + .join(", "); + let kind_placeholders = std::iter::repeat_n("?", kinds.len()) + .collect::<Vec<_>>() + .join(", "); + let predicate = + format!("address IN ({address_placeholders}) AND kind IN ({kind_placeholders})"); + let mut filter_params = Vec::<Value>::with_capacity(addresses.len() + kinds.len()); + filter_params.extend(addresses.iter().cloned().map(Value::Text)); + filter_params.extend(kinds.iter().map(|kind| Value::Text((*kind).to_string()))); + + let count_sql = + format!("SELECT COUNT(DISTINCT signature) FROM ui_wallet_transactions WHERE {predicate}"); + let total = connection + .query_row(&count_sql, params_from_iter(filter_params.iter()), |row| { + row.get::<_, u64>(0) + }) + .context("failed to count UI wallet transactions")? as usize; + + let query_sql = format!( + r#" +SELECT DISTINCT sort_key, kind, block_height, timestamp_ms, block_finalizer, transaction_json, + signature +FROM ui_wallet_transactions +WHERE {predicate} +ORDER BY sort_key DESC, signature ASC +LIMIT ? OFFSET ? +"# + ); + let mut query_params = filter_params; + query_params.push(Value::Integer(limit as i64)); + query_params.push(Value::Integer(offset as i64)); + let mut statement = connection + .prepare(&query_sql) + .context("failed to prepare UI wallet transactions query")?; + let rows = read_wallet_transaction_rows( + statement + .query_map(params_from_iter(query_params.iter()), |row| { + wallet_transaction_projection_from_row(row) + }) + .context("failed to load UI wallet transactions")?, + )?; + Ok((rows, total)) +} + fn load_wallet_transactions_v2( connection: &Connection, addresses: &[String], @@ -1796,6 +1864,33 @@ fn incremental_metric_for_block( } } } + for envelope in &block.transactions_v2 { + let encoded = decode_hex(envelope).context("block transaction v2 is not hexadecimal")?; + let (_, transaction) = TransactionV2::decode(&encoded)?; + fees_amount = fees_amount + .checked_add(transaction.fee()) + .context("block metric transaction v2 fees overflow")?; + circulating_supply = circulating_supply + .checked_sub(transaction.fee()) + .context("transaction v2 fee exceeds circulating supply")?; + match transaction { + TransactionV2::Migration { .. } | TransactionV2::Transfer { .. } => { + transfer_count += 1; + } + TransactionV2::Burn { amount, .. } => { + burn_count += 1; + burned_amount = burned_amount + .checked_add(amount) + .context("block metric transaction v2 burns overflow")?; + circulating_supply = circulating_supply + .checked_sub(amount) + .context("transaction v2 burn exceeds circulating supply")?; + } + TransactionV2::Mine { .. } => { + mine_count += 1; + } + } + } circulating_supply = circulating_supply .checked_add(block.reward) .context("block reward circulating supply overflows")?; @@ -1933,7 +2028,8 @@ mod tests { use crate::domain::{AddressNetwork, ChainSnapshot, GenesisBurn, Ledger, Wallet, hex_encode}; use super::{ - SqliteUiDataStore, replace_ui_wallet_transactions_v2, wallet_transactions_v2_from_snapshot, + SqliteUiDataStore, replace_ui_wallet_transactions, replace_ui_wallet_transactions_v2, + wallet_transactions_from_snapshot, wallet_transactions_v2_from_snapshot, }; fn test_snapshot(seed: &str) -> ChainSnapshot { @@ -1986,6 +2082,38 @@ mod tests { } #[test] + fn wallet_projection_queries_hybrid_reward_addresses() { + let dir = tempdir().unwrap(); + let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); + let (mut ledger, wallet) = test_ledger("wallet-hybrid-reward-history"); + append_test_block(&mut ledger, &wallet, 1_234); + let hybrid_address = wallet.hybrid_address(AddressNetwork::Mainnet); + let mut snapshot = ledger.snapshot(); + snapshot.blocks[1].reward_address = Some(hybrid_address.clone()); + + let projection = wallet_transactions_from_snapshot(&snapshot); + store + .with_connection_mut(|connection| { + let transaction = connection.transaction()?; + replace_ui_wallet_transactions(&transaction, &projection)?; + transaction.commit()?; + Ok(()) + }) + .unwrap(); + let (rows, total) = store + .load_wallet_transactions_for_addresses( + &[wallet.address().to_string(), hybrid_address.clone()], + &["reward"], + 0, + 10, + ) + .unwrap(); + + assert_eq!(total, 1); + assert_eq!(rows[0].transaction.to(), Some(hybrid_address.as_str())); + } + + #[test] fn confirmed_v2_wallet_projection_is_replaced_on_reorg() { let dir = tempdir().unwrap(); let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); diff --git a/src/app.rs b/src/app.rs @@ -44,8 +44,10 @@ pub const PROTOCOL_VERSION: u32 = 2; 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_HYBRID_REWARD_PAYOUTS: &str = "hybrid-reward-payouts"; pub const CAPABILITY_SIGNATURE_SCHEMES_V1: &str = "signature-schemes-v1"; pub const CAPABILITY_TRANSACTION_V2_BLOCKS: &str = "transaction-v2-blocks"; +pub const CAPABILITY_TRANSACTION_V2_BURNS: &str = "transaction-v2-burns"; pub const CAPABILITY_TRANSACTION_V2_MEMPOOL: &str = "transaction-v2-mempool"; pub const MAINNET_CANDIDATE_NETWORK_ID: &str = "iuna-mainnet-candidate"; pub const MAINNET_CANDIDATE_GENESIS_HASH: &str = @@ -68,8 +70,10 @@ static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false); pub fn protocol_capabilities() -> Vec<String> { vec![ CAPABILITY_ADDRESS_V1_READ.to_string(), + CAPABILITY_HYBRID_REWARD_PAYOUTS.to_string(), CAPABILITY_SIGNATURE_SCHEMES_V1.to_string(), CAPABILITY_TRANSACTION_V2_BLOCKS.to_string(), + CAPABILITY_TRANSACTION_V2_BURNS.to_string(), CAPABILITY_TRANSACTION_V2_MEMPOOL.to_string(), ] } @@ -110,14 +114,31 @@ pub fn validate_transaction_v2_peer_capability( { anyhow::bail!("peer lacks transaction-v2 block capability near activation"); } + if activation_is_next + && !capabilities + .iter() + .any(|capability| capability == CAPABILITY_TRANSACTION_V2_BURNS) + { + anyhow::bail!("peer lacks transaction-v2 burn capability near activation"); + } + let hybrid_rewards_are_next = local_height.max(remote_height).saturating_add(1) + >= crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT; + if hybrid_rewards_are_next + && !capabilities + .iter() + .any(|capability| capability == CAPABILITY_HYBRID_REWARD_PAYOUTS) + { + anyhow::bail!("peer lacks hybrid reward payout capability near activation"); + } Ok(()) } #[cfg(test)] mod tests { use super::{ - BLOCK_REQUEST_LIMIT, CAPABILITY_ADDRESS_V1_READ, CAPABILITY_SIGNATURE_SCHEMES_V1, - CAPABILITY_TRANSACTION_V2_BLOCKS, CAPABILITY_TRANSACTION_V2_MEMPOOL, DEFAULT_VDF_ROUNDS, + BLOCK_REQUEST_LIMIT, CAPABILITY_ADDRESS_V1_READ, CAPABILITY_HYBRID_REWARD_PAYOUTS, + CAPABILITY_SIGNATURE_SCHEMES_V1, CAPABILITY_TRANSACTION_V2_BLOCKS, + CAPABILITY_TRANSACTION_V2_BURNS, CAPABILITY_TRANSACTION_V2_MEMPOOL, 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, @@ -154,8 +175,10 @@ mod tests { capabilities, [ CAPABILITY_ADDRESS_V1_READ, + CAPABILITY_HYBRID_REWARD_PAYOUTS, CAPABILITY_SIGNATURE_SCHEMES_V1, CAPABILITY_TRANSACTION_V2_BLOCKS, + CAPABILITY_TRANSACTION_V2_BURNS, CAPABILITY_TRANSACTION_V2_MEMPOOL, ] ); @@ -188,13 +211,38 @@ mod tests { assert!(validate_transaction_v2_peer_capability(&[], 2_999, 2_998).is_err()); assert!( validate_transaction_v2_peer_capability( - &[CAPABILITY_TRANSACTION_V2_BLOCKS.to_string()], + &[ + CAPABILITY_TRANSACTION_V2_BLOCKS.to_string(), + CAPABILITY_TRANSACTION_V2_BURNS.to_string(), + ], 2_999, 2_998, ) .is_ok() ); } + + #[test] + fn hybrid_reward_capability_is_required_when_activation_is_next() { + let v2 = CAPABILITY_TRANSACTION_V2_BLOCKS.to_string(); + let burns = CAPABILITY_TRANSACTION_V2_BURNS.to_string(); + assert!( + validate_transaction_v2_peer_capability(&[v2.clone(), burns.clone()], 3_748, 3_748) + .is_ok() + ); + assert!( + validate_transaction_v2_peer_capability(&[v2.clone(), burns.clone()], 3_749, 3_748) + .is_err() + ); + assert!( + validate_transaction_v2_peer_capability( + &[v2, burns, CAPABILITY_HYBRID_REWARD_PAYOUTS.to_string(),], + 3_749, + 3_748, + ) + .is_ok() + ); + } } pub fn validate_network_genesis(profile_id: &str, genesis_hash: &str) -> Result<()> { diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -304,6 +304,25 @@ impl NodeCore { self.last_auto_burn_height = Some(current_height); return Ok(anchor_burn); } + let hybrid_anchor_amount = self + .ledger + .pending_v2() + .iter() + .filter(|transaction| { + transaction.is_burn() + && self + .ledger + .transaction_v2_is_eligible_for_next_block(transaction) + && transaction.burn_legacy_owner().ok().flatten().as_deref() + == Some(self.wallet.address()) + }) + .map(crate::domain::TransactionV2::amount) + .max() + .unwrap_or(0); + if hybrid_anchor_amount >= self.burn_per_block { + self.last_auto_burn_height = Some(current_height); + return Ok(anchor_burn); + } if self.last_auto_burn_height == Some(current_height) { return Ok(anchor_burn); } @@ -313,6 +332,17 @@ impl NodeCore { let ledger = self.wallet_build_ledger()?; let best = self.best_automatic_burn_on_ledger(&ledger, fee_per_byte, balance); let Some(tx) = best else { + let hybrid_address = self.wallet.unlocked()?.hybrid_address( + crate::domain::AddressNetwork::from_profile_id( + &self.ledger.launch_profile().profile_id, + ), + ); + let hybrid_balance = self.ledger.balance_of(&hybrid_address); + if let Some((transaction, _)) = + self.best_automatic_v2_burn_on_ledger(&ledger, fee_per_byte, hybrid_balance, false) + { + self.submit_public_transaction_v2(transaction)?; + } self.last_auto_burn_height = Some(current_height); return Ok(anchor_burn); }; @@ -337,6 +367,20 @@ impl NodeCore { if self.last_auto_anchor_burn_height == Some(current_height) { return Ok(None); } + if self.ledger.pending_v2().iter().any(|transaction| { + transaction.is_burn() + && self + .ledger + .transaction_v2_is_eligible_for_next_block(transaction) + && transaction.burn_legacy_owner().ok().flatten().as_deref() + == Some(self.wallet.address()) + }) { + // A journal-restored hybrid anchor is already available for this + // height. Reuse it instead of trying to spend the same confirmed + // hybrid inputs again after a restart. + self.last_auto_anchor_burn_height = Some(current_height); + return Ok(None); + } // Pending wallet burns can themselves satisfy the block-anchor requirement. // Pending transfers cannot, so keep their confirmed inputs reserved. @@ -406,9 +450,27 @@ impl NodeCore { } }) { Ok((burn, _)) => burn, - Err(error) => { - self.last_auto_anchor_burn_height = Some(current_height); - return Err(error).context("automatic finalizer anchor burn failed"); + Err(legacy_error) => { + match self.build_v2_burn_with_fee_rate_on_ledger( + &ledger, + anchor_burn_amount, + self.burn_fee, + true, + ) { + Ok((transaction, _)) => { + self.submit_public_transaction_v2(transaction)?; + self.last_auto_anchor_burn_height = Some(current_height); + return Ok(None); + } + Err(hybrid_error) => { + self.last_auto_anchor_burn_height = Some(current_height); + return Err(hybrid_error).with_context(|| { + format!( + "automatic finalizer anchor burn failed; legacy path: {legacy_error:#}" + ) + }); + } + } } }; self.local_block_anchor_burn = Some((current_height, burn.clone())); @@ -473,6 +535,56 @@ impl NodeCore { best } + fn best_automatic_v2_burn_on_ledger( + &self, + ledger: &Ledger, + fee_per_byte: Amount, + balance: Amount, + for_next_block: bool, + ) -> Option<(crate::domain::TransactionV2, super::FeeEstimate)> { + let target = self.burn_per_block.min(balance); + if target == 0 { + return None; + } + if let Ok(candidate) = + self.build_v2_burn_with_fee_rate_on_ledger(ledger, target, fee_per_byte, for_next_block) + { + if target + .checked_add(candidate.1.fee) + .is_some_and(|required| required <= balance) + { + return Some(candidate); + } + } + + let mut low = 1; + let mut high = target; + let mut best = None; + while low <= high { + let amount = low + (high - low) / 2; + match self.build_v2_burn_with_fee_rate_on_ledger( + ledger, + amount, + fee_per_byte, + for_next_block, + ) { + Ok(candidate) + if amount + .checked_add(candidate.1.fee) + .is_some_and(|required| required <= balance) => + { + best = Some(candidate); + if amount == Amount::MAX { + break; + } + low = amount + 1; + } + Ok(_) | Err(_) => high = amount.saturating_sub(1), + } + } + best + } + fn automatic_burn_needs_plaintext_anchor(&self, timestamp_ms: u64) -> bool { self.ledger .finalizer_rank_for_next_block(self.wallet.address()) @@ -617,8 +729,18 @@ impl NodeCore { let finalizer_rank = ledger .finalizer_rank_for_next_block(self.wallet.address()) .context("cannot prepare ticket block without a mature burn ticket")?; + let reward_address = if ledger.height().saturating_add(1) + >= crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT + { + Some(self.wallet.unlocked()?.hybrid_address( + crate::domain::AddressNetwork::from_profile_id(&ledger.launch_profile().profile_id), + )) + } else { + None + }; ledger.prepare_next_block_with_required_burn_and_burn_bundles( self.wallet.address(), + reward_address.as_deref(), timestamp_ms, self.usable_burn_bundles_for_finalizer_rank(finalizer_rank), required_burn_signature.as_deref(), @@ -627,8 +749,18 @@ impl NodeCore { fn prepare_recovery_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> { let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor(); + let reward_address = if ledger.height().saturating_add(1) + >= crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT + { + Some(self.wallet.unlocked()?.hybrid_address( + crate::domain::AddressNetwork::from_profile_id(&ledger.launch_profile().profile_id), + )) + } else { + None + }; ledger.prepare_recovery_block_with_required_burn_and_burn_bundles( self.wallet.address(), + reward_address.as_deref(), timestamp_ms, Vec::new(), required_burn_signature.as_deref(), @@ -887,7 +1019,9 @@ mod tests { prev_hash: node.ledger.tip_hash().to_string(), slot: 1, member: "alpha".to_string(), + reward_address: None, burns: Vec::new(), + burns_v2: Vec::new(), signature: "sig-alpha".to_string(), }; let beta = BurnBundle { @@ -895,7 +1029,9 @@ mod tests { prev_hash: node.ledger.tip_hash().to_string(), slot: 1, member: "beta".to_string(), + reward_address: None, burns: Vec::new(), + burns_v2: Vec::new(), signature: "sig-beta".to_string(), }; @@ -1600,6 +1736,7 @@ mod tests { let without_bundle = ledger_with_anchor .prepare_next_block_with_required_burn_and_burn_bundles( node.wallet_address(), + None, 1, Vec::new(), required_burn_signature.as_deref(), diff --git a/src/app/automatic_mining/pow.rs b/src/app/automatic_mining/pow.rs @@ -107,7 +107,7 @@ impl NodeCore { self.auto_pow_mine_cursor = None; return Ok(None); } - let wallet_address = self.wallet.address().to_string(); + let wallet_address = self.reward_address_for_next_block()?; let needs_cursor = self .auto_pow_mine_cursor .as_ref() diff --git a/src/app/receive.rs b/src/app/receive.rs @@ -73,6 +73,9 @@ impl NodeCore { for burn in &bundle.burns { self.receive_gossiped_transaction(burn.clone())?; } + for envelope in &bundle.burns_v2 { + self.receive_gossiped_transaction_v2(envelope.clone())?; + } self.ledger .validate_next_block_burn_bundles(vec![bundle.clone()])?; if self.equivocated_burn_bundle_slots.contains(&key) { diff --git a/src/app/wallet.rs b/src/app/wallet.rs @@ -193,8 +193,18 @@ impl NodeCore { } pub fn estimate_burn_fee(&self, amount: Amount, fee_per_byte: Amount) -> Result<FeeEstimate> { - self.build_burn_with_fee_rate(amount, fee_per_byte) - .map(|(_, estimate)| estimate) + match self.build_burn_with_fee_rate(amount, fee_per_byte) { + Ok((_, estimate)) => Ok(estimate), + Err(legacy_error) => self + .build_v2_burn_with_fee_rate_on_ledger( + &self.wallet_build_ledger()?, + amount, + fee_per_byte, + false, + ) + .map(|(_, estimate)| estimate) + .with_context(|| format!("legacy burn unavailable: {legacy_error:#}")), + } } pub fn transfer(&mut self, to: impl Into<String>, amount: Amount) -> Result<Transaction> { @@ -261,7 +271,7 @@ impl NodeCore { pub fn estimate_mine_fee(&self, _fee_per_byte: Amount) -> Result<FeeEstimate> { let tx = Transaction::Mine { - recipient: self.wallet.address().to_string(), + recipient: self.reward_address_for_next_block()?, anchor: self.ledger.tip_hash().to_string(), salt: 0, nonce: 0, @@ -441,6 +451,38 @@ impl NodeCore { }) } + pub(super) fn build_v2_burn_with_fee_rate_on_ledger( + &self, + ledger: &Ledger, + amount: Amount, + fee_per_byte: Amount, + for_next_block: bool, + ) -> Result<(TransactionV2, FeeEstimate)> { + if fee_per_byte == 0 { + bail!("fee per byte must be greater than zero"); + } + let domain = ledger.transaction_v2_domain()?; + let wallet = self.wallet.unlocked()?; + let mut fee = 1; + for _ in 0..64 { + let transaction = if for_next_block { + ledger.build_v2_burn_for_next_block(wallet, amount, fee)? + } else { + ledger.build_v2_burn(wallet, amount, fee)? + }; + let bytes = transaction.encoded_size_bytes(&domain)?; + let required_fee = fee_per_byte + .checked_mul(bytes as Amount) + .context("fee per byte times transaction bytes overflows")? + .max(1); + if fee >= required_fee { + return Ok((transaction, FeeEstimate { bytes, fee })); + } + fee = required_fee; + } + bail!("hybrid burn fee did not converge") + } + pub(super) fn build_transfer_with_fee_rate( &self, to: impl Into<String>, @@ -466,7 +508,9 @@ impl NodeCore { } pub(super) fn build_mine_estimate(&self) -> Result<(Transaction, FeeEstimate)> { - let tx = self.ledger.build_mine(self.wallet.address())?; + let tx = self + .ledger + .build_mine(self.reward_address_for_next_block()?)?; Ok(( tx.clone(), FeeEstimate { @@ -482,6 +526,18 @@ impl NodeCore { Ok(ledger) } + pub(super) fn reward_address_for_next_block(&self) -> Result<String> { + if self.ledger.height().saturating_add(1) < crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT { + return Ok(self.wallet.address().to_string()); + } + Ok(self + .wallet + .unlocked()? + .hybrid_address(crate::domain::AddressNetwork::from_profile_id( + &self.ledger.launch_profile().profile_id, + ))) + } + pub(super) fn wallet_anchor_build_ledger(&self) -> Result<Ledger> { let mut ledger = self.ledger.clone(); self.reserve_local_block_anchor_inputs(&mut ledger)?; diff --git a/src/domain.rs b/src/domain.rs @@ -69,11 +69,12 @@ pub use profile::{GenesisBurn, LaunchProfile}; pub use protocol::{ Amount, BLOCK_REWARD, BURN_COMMITTEE_SIZE, BURN_LINEAGE_MATURITY_HEIGHTS, DEFAULT_FEE_PER_BYTE, DEFAULT_MINE_FEE, DEFAULT_TRANSACTION_FEE, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, - MAX_BLOCK_BYTES, MAX_BURN_BUNDLE_BYTES, MAX_PENDING_TRANSACTIONS, MAX_VDF_ROUNDS, MICRO_IUNA, - MINE_ACTIONS_PER_ANCHOR_LIMIT, MINE_DIFFICULTY_BITS, MINE_FINALIZER_FEE, MINE_REWARD, - OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, RECOVERY_BLOCK_DELAY_MS, - TIP_BOUND_BURN_ACTIVATION_HEIGHT, TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT, - TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, + HYBRID_REWARD_ACTIVATION_HEIGHT, MAX_BLOCK_BYTES, MAX_BURN_BUNDLE_BYTES, + MAX_PENDING_TRANSACTIONS, MAX_VDF_ROUNDS, MICRO_IUNA, MINE_ACTIONS_PER_ANCHOR_LIMIT, + MINE_DIFFICULTY_BITS, MINE_FINALIZER_FEE, MINE_REWARD, OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, + RECOVERY_BLOCK_DELAY_MS, TIP_BOUND_BURN_ACTIVATION_HEIGHT, + TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, + TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, }; use protocol::{ BLOCK_MEDIAN_TIME_PAST_WINDOW, DEFAULT_TICKET_EXPIRY_WINDOW, DEFAULT_TICKET_MATURITY_DELAY, @@ -83,7 +84,7 @@ use protocol::{ use reveal::BurnBundlePayload; pub use reveal::{ BurnBundle, BurnBundleSection, BurnBundleSignature, BurnCommitteeMember, MaskedBurn, - default_burn_bundle_hash, + MaskedBurnV2, default_burn_bundle_hash, }; use selection::BlockSelection; pub use signature::{ProtocolPublicKey, ProtocolSignature, SignatureScheme}; diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs @@ -306,7 +306,9 @@ impl Harness { prev_hash: prev_hash.clone(), slot: member.slot, member: member.owner, + reward_address: None, burns: burns.clone(), + burns_v2: Vec::new(), })) }) .collect() @@ -1930,12 +1932,20 @@ fn mini_validate_burn_bundle_section(ledger: &Ledger, block: &Block) -> Option<( .filter(|masked| masked.bundle_mask & slot_mask != 0) .map(|masked| masked.burn.clone()) .collect::<Vec<_>>(); + let burns_v2 = section + .burns_v2 + .iter() + .filter(|masked| masked.bundle_mask & slot_mask != 0) + .map(|masked| masked.envelope.clone()) + .collect::<Vec<_>>(); let bundle = BurnBundle { height: block.height, prev_hash: block.prev_hash.clone(), slot: signature.slot, member: signature.member.clone(), + reward_address: signature.reward_address.clone(), burns: burns.clone(), + burns_v2: burns_v2.clone(), signature: signature.signature.clone(), }; if bundle.serialized_size_bytes().ok()? > MAX_BURN_BUNDLE_BYTES { @@ -1946,7 +1956,9 @@ fn mini_validate_burn_bundle_section(ledger: &Ledger, block: &Block) -> Option<( prev_hash: block.prev_hash.clone(), slot: signature.slot, member: signature.member.clone(), + reward_address: signature.reward_address.clone(), burns, + burns_v2, }; if verify_address_signature( &signature.member, @@ -2256,7 +2268,9 @@ proptest! { prev_hash: harness.ledger.tip_hash().to_string(), slot: 0, member: finalizer.address().to_string(), + reward_address: None, burns: vec![victim_burn.clone()], + burns_v2: Vec::new(), }); let mut block = harness.finish_ticket_block_from_pending(0, vec![bundle]); block.transactions.retain(|tx| tx.signature() != victim_burn.signature()); @@ -2455,7 +2469,9 @@ fn attested_burn_is_not_selected_again_as_normal_transaction() { prev_hash: harness.ledger.tip_hash().to_string(), slot: 0, member: finalizer.address().to_string(), + reward_address: None, burns: vec![attested_burn.clone()], + burns_v2: Vec::new(), }); let block = harness.finish_ticket_block_from_pending(0, vec![bundle]); @@ -2658,7 +2674,9 @@ fn attested_burn_block_validates_independent_of_local_mempool() { prev_hash: harness.ledger.tip_hash().to_string(), slot: 0, member: finalizer.address().to_string(), + reward_address: None, burns: vec![attested_burn.clone()], + burns_v2: Vec::new(), }); let block = harness.finish_ticket_block_from_pending(0, vec![bundle]); let parent_snapshot = harness.ledger.snapshot(); @@ -2716,6 +2734,7 @@ fn misused_or_extra_committee_bundle_is_rejected() { .push(BurnBundleSignature { slot: 1, member: harness.attacker.wallet.address().to_string(), + reward_address: None, signature: "00".repeat(64), }); rehash(&mut block); @@ -2903,7 +2922,9 @@ fn rank_one_committee_excludes_missed_rank_zero_owner_and_requires_remaining_slo prev_hash: harness.ledger.tip_hash().to_string(), slot: member.slot, member: member.owner, + reward_address: None, burns: Vec::new(), + burns_v2: Vec::new(), }); let with_committee = finish_prepared_block( &finalizer, diff --git a/src/domain/block.rs b/src/domain/block.rs @@ -15,6 +15,10 @@ pub struct Block { pub prev_hash: String, pub timestamp_ms: u64, pub miner: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reward_address: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reward_address_signature: Option<String>, #[serde(default)] pub finalizer_mode: FinalizerMode, #[serde(default)] @@ -75,6 +79,7 @@ impl Block { self.height, &self.prev_hash, &self.miner, + self.reward_address.as_deref(), self.finalizer_mode, self.finalizer_rank, self.reward, @@ -107,6 +112,25 @@ impl Block { ) }) .unwrap_or_default(); + if let Some(reward_address) = &self.reward_address { + let reward_address_signature = self.reward_address_signature.as_deref().unwrap_or(""); + return hex_hash(format!( + "block-content-v6:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", + self.height, + self.prev_hash, + self.timestamp_ms, + self.miner, + reward_address, + reward_address_signature, + self.finalizer_rank, + self.reward, + self.vdf_rounds, + leader_proof, + txs, + txs_v2, + canonical_burn_block_items(&burn_section) + )); + } if self.transactions_v2.is_empty() { return hex_hash(format!( "block-content-v4:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", @@ -254,6 +278,7 @@ pub struct PreparedBlock { pub(super) prev_hash: String, pub(super) timestamp_ms: u64, pub(super) miner: String, + pub(super) reward_address: Option<String>, pub(super) finalizer_mode: FinalizerMode, pub(super) finalizer_rank: u32, pub(super) reward: Amount, @@ -317,11 +342,21 @@ impl PreparedBlock { }; wallet.leader_proof(&proof_payload) }); + let reward_address_signature = self.reward_address.as_ref().map(|reward_address| { + wallet.sign_payload(&reward_address_payload( + self.height, + &self.prev_hash, + &self.miner, + reward_address, + )) + }); let mut block = Block { height: self.height, prev_hash: self.prev_hash, timestamp_ms, miner: self.miner, + reward_address: self.reward_address, + reward_address_signature, finalizer_mode: self.finalizer_mode, finalizer_rank: self.finalizer_rank, reward: self.reward, @@ -338,6 +373,15 @@ impl PreparedBlock { } } +pub(super) fn reward_address_payload( + height: u64, + prev_hash: &str, + miner: &str, + reward_address: &str, +) -> String { + format!("iuna-hybrid-reward-v1:{height}:{prev_hash}:{miner}:{reward_address}") +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ChainStatus { pub height: u64, @@ -411,6 +455,8 @@ mod tests { prev_hash: "0".repeat(64), timestamp_ms: 1, miner: "miner".to_string(), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 0, @@ -435,6 +481,8 @@ mod tests { prev_hash: "0".repeat(64), timestamp_ms: 1, miner: "1".repeat(64), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 1, @@ -477,6 +525,8 @@ mod tests { prev_hash: "0".repeat(64), timestamp_ms: 1, miner: "1".repeat(64), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 1, @@ -507,6 +557,7 @@ mod tests { prev_hash: "0".repeat(64), timestamp_ms: 10, miner: wallet.address().to_string(), + reward_address: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 1, @@ -532,6 +583,7 @@ mod tests { prepared.height, &prepared.prev_hash, &prepared.miner, + prepared.reward_address.as_deref(), prepared.finalizer_mode, prepared.finalizer_rank, prepared.reward, @@ -576,4 +628,55 @@ mod tests { assert_eq!(block.json_size_bytes().unwrap(), canonical_json_len); assert!(canonical_json_len > compact_wire_json.len()); } + + #[test] + fn activated_reward_address_is_authenticated_by_finalizer() { + let wallet = Wallet::from_seed("hybrid-reward-finalizer"); + let reward_address = wallet.hybrid_address(crate::domain::AddressNetwork::Mainnet); + let prepared = PreparedBlock { + height: crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT, + prev_hash: "0".repeat(64), + timestamp_ms: 10, + miner: wallet.address().to_string(), + reward_address: Some(reward_address.clone()), + finalizer_mode: FinalizerMode::Recovery, + finalizer_rank: 0, + reward: 1, + vdf_rounds: 1, + vdf_seed: "seed".to_string(), + leader_ticket: None, + burn_bundle_section: BurnBundleSection::default(), + transactions: Vec::new(), + transactions_v2: Vec::new(), + }; + + let block = prepared.finish(&wallet, "vdf".to_string()); + let signature = block.reward_address_signature.as_deref().unwrap(); + crate::domain::ledger_ops::verify_address_signature( + wallet.address(), + &super::reward_address_payload( + block.height, + &block.prev_hash, + &block.miner, + &reward_address, + ), + signature, + "reward address", + ) + .unwrap(); + assert!( + crate::domain::ledger_ops::verify_address_signature( + wallet.address(), + &super::reward_address_payload( + block.height, + &block.prev_hash, + &block.miner, + "iuna1ptampered", + ), + signature, + "reward address", + ) + .is_err() + ); + } } diff --git a/src/domain/genesis.rs b/src/domain/genesis.rs @@ -25,6 +25,8 @@ pub(super) fn build_genesis_block( prev_hash: "0".repeat(64), timestamp_ms: 0, miner, + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward, diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -5,7 +5,7 @@ use anyhow::{Context, Result, bail}; use super::ledger_ops::{ block_reward, credit_reward_outputs, ensure_block_has_burn, ensure_outputs_do_not_overflow, ensure_single_input_owner, ensure_valid_recovery_block, validate_block_fee_policy, - verify_leader_proof, + verify_address_signature, verify_leader_proof, }; use super::ledger_v2::{ apply_transaction_v2_with_lineage, decode_canonical_transaction_v2_envelope, @@ -16,11 +16,11 @@ use super::ticket::{ }; use super::transaction::transaction_inputs_available; use super::{ - 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, + AddressNetwork, AddressVersion, 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 { @@ -109,6 +109,7 @@ impl Ledger { for envelope in &block.transactions_v2 { let transaction = decode_canonical_transaction_v2_envelope(envelope, &transaction_v2_domain)?; + self.validate_transaction_v2_anchor_for_block(&transaction, block.height)?; let transaction_id = hex_encode(transaction.transaction_id(&transaction_v2_domain)?); if !transaction_v2_ids.insert(transaction_id) { bail!("duplicate transaction v2 in block"); @@ -306,6 +307,32 @@ impl Ledger { if block.compute_hash() != block.hash { bail!("block hash is invalid"); } + self.validate_reward_address(block.height, block.reward_address.as_deref(), "finalizer")?; + if block.height < super::HYBRID_REWARD_ACTIVATION_HEIGHT { + if block.reward_address_signature.is_some() { + bail!("finalizer reward address signature is not active yet"); + } + } else { + let reward_address = block + .reward_address + .as_deref() + .context("finalizer hybrid reward address is required")?; + let signature = block + .reward_address_signature + .as_deref() + .context("finalizer reward address signature is required")?; + verify_address_signature( + &block.miner, + &super::block::reward_address_payload( + block.height, + &block.prev_hash, + &block.miner, + reward_address, + ), + signature, + "finalizer reward address", + )?; + } self.ensure_block_transactions_are_not_replays(block)?; if block.reward != self.expected_reward_for_block(block)? { bail!("block reward is invalid"); @@ -350,7 +377,7 @@ impl Ledger { bail!("block exceeds max block size"); } ensure_mine_anchor_limit(block.height, &block.transactions)?; - ensure_block_has_burn(&block.transactions)?; + ensure_block_has_burn(&block.transactions, &block.transactions_v2)?; validate_block_fee_policy(block)?; self.validate_burn_bundle_section_for_block(block)?; match block.finalizer_mode { @@ -409,6 +436,33 @@ impl Ledger { block_reward(&block.transactions, v2_fees) } + pub(super) fn validate_reward_address( + &self, + height: u64, + address: Option<&str>, + label: &str, + ) -> Result<()> { + if height < super::HYBRID_REWARD_ACTIVATION_HEIGHT { + if address.is_some() { + bail!( + "{label} hybrid reward address is not active before height {}", + super::HYBRID_REWARD_ACTIVATION_HEIGHT + ); + } + return Ok(()); + } + let address = + address.with_context(|| format!("{label} hybrid reward address is required"))?; + let decoded = super::decode_versioned_address( + address, + AddressNetwork::from_profile_id(&self.launch_profile.profile_id), + )?; + if decoded.version != AddressVersion::HybridKeyCommitment { + bail!("{label} reward address must use address v1"); + } + Ok(()) + } + fn transaction_v2_fees(&self, envelopes: &[String]) -> Result<Amount> { let domain = self.transaction_v2_domain()?; envelopes.iter().try_fold(0_u64, |total, envelope| { diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs @@ -212,6 +212,106 @@ impl Ledger { Ok(transaction) } + pub fn build_v2_burn( + &self, + wallet: &Wallet, + amount: Amount, + fee: Amount, + ) -> Result<TransactionV2> { + self.build_v2_burn_with_anchor(wallet, amount, fee, self.tip_hash()) + } + + pub(crate) fn build_v2_burn_for_next_block( + &self, + wallet: &Wallet, + amount: Amount, + fee: Amount, + ) -> Result<TransactionV2> { + self.build_v2_burn_with_anchor(wallet, amount, fee, &self.tip().prev_hash) + } + + fn build_v2_burn_with_anchor( + &self, + wallet: &Wallet, + amount: Amount, + fee: Amount, + anchor: &str, + ) -> Result<TransactionV2> { + if amount == 0 { + bail!("burn amount must be greater than zero"); + } + let required = amount + .checked_add(fee) + .context("burn amount plus fee overflows")?; + let owner = wallet.hybrid_versioned_address(); + let owner_address = wallet.hybrid_address(AddressNetwork::from_profile_id( + &self.launch_profile.profile_id, + )); + let mut available = self.available_utxos_for_address(&owner_address)?; + available.sort_by(|(left_point, left), (right_point, right)| { + right + .amount + .cmp(&left.amount) + .then_with(|| left_point.cmp(right_point)) + }); + let mut total = 0_u64; + let mut inputs = Vec::new(); + for (outpoint, output) in available { + total = total + .checked_add(output.amount) + .context("transaction v2 input total overflows")?; + inputs.push(TransactionV2Input { + outpoint_txid: decode_hex_array::<32>(&outpoint.txid) + .context("transaction v2 outpoint ID must be a 32-byte hash")?, + outpoint_index: outpoint.index, + owner, + }); + if total >= required { + break; + } + } + if total < required { + bail!("insufficient hybrid funds"); + } + let change_amount = total - required; + let change = (change_amount > 0) + .then_some(TransactionV2Output { + address: owner, + amount: change_amount, + }) + .into_iter() + .collect(); + let domain = self.transaction_v2_domain()?; + let mut transaction = TransactionV2::Burn { + inputs, + change, + amount, + fee, + anchor: Some( + decode_hex_array::<32>(anchor).context("transaction v2 burn anchor is invalid")?, + ), + authorizations: Vec::new(), + }; + let payload = transaction.signing_bytes(&domain)?; + let authorization = wallet.sign_v2_authorization(owner, &payload)?; + if let TransactionV2::Burn { + inputs, + authorizations, + .. + } = &mut transaction + { + authorizations.resize(inputs.len(), authorization); + } + transaction.verify_authorizations(&domain)?; + ensure_v2_transaction_within_block_budget( + self, + &transaction, + &domain, + self.launch_profile.max_block_bytes, + )?; + Ok(transaction) + } + pub fn build_transfer( &self, wallet: &Wallet, @@ -436,7 +536,7 @@ impl Ledger { pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> { let recipient = recipient.into(); - validate_address(&recipient, "mine recipient")?; + self.validate_mine_reward_address(&recipient)?; let anchor = self.tip().hash.clone(); let salt = 1; let difficulty_bits = self.current_mine_difficulty_bits(); @@ -479,7 +579,7 @@ impl Ledger { max_attempts: u64, ) -> Result<MineSearchOutcome> { let recipient = recipient.into(); - validate_address(&recipient, "mine recipient")?; + self.validate_mine_reward_address(&recipient)?; let anchor = self.tip().hash.clone(); let difficulty_bits = self.current_mine_difficulty_bits(); let signing_domain = self.transaction_signing_domain(); @@ -531,6 +631,8 @@ impl Ledger { salt: u64, difficulty_bits: u32, ) -> Result<StratumMineTemplate> { + let recipient = recipient.into(); + self.validate_mine_reward_address(&recipient)?; stratum_mine_template( &self.transaction_signing_domain(), recipient, @@ -540,6 +642,21 @@ impl Ledger { ) } + fn validate_mine_reward_address(&self, recipient: &str) -> Result<()> { + let height = self.height().saturating_add(1); + if height < super::HYBRID_REWARD_ACTIVATION_HEIGHT { + return validate_address(recipient, "mine recipient"); + } + let address = super::decode_versioned_address( + recipient, + AddressNetwork::from_profile_id(&self.launch_profile.profile_id), + )?; + if address.version != super::AddressVersion::HybridKeyCommitment { + bail!("mine reward must use a hybrid address at height {height}"); + } + Ok(()) + } + pub fn build_stratum_mine( &self, template: StratumMineTemplate, @@ -608,6 +725,30 @@ mod v2_migration_tests { use super::*; #[test] + fn mine_reward_destination_switches_to_hybrid_at_3750() { + let wallet = Wallet::from_seed("hybrid-mine-reward-wallet"); + let mut ledger = Ledger::new(BTreeMap::new(), 1); + ledger.chain.last_mut().unwrap().height = super::super::HYBRID_REWARD_ACTIVATION_HEIGHT - 2; + assert!( + ledger + .validate_mine_reward_address(wallet.address()) + .is_ok() + ); + + ledger.chain.last_mut().unwrap().height = super::super::HYBRID_REWARD_ACTIVATION_HEIGHT - 1; + assert!( + ledger + .validate_mine_reward_address(wallet.address()) + .is_err() + ); + assert!( + ledger + .validate_mine_reward_address(&wallet.hybrid_address(AddressNetwork::Mainnet)) + .is_ok() + ); + } + + #[test] fn migration_builder_consolidates_legacy_value_into_one_hybrid_output() { let wallet = Wallet::from_seed("v2-migration-builder-wallet"); let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); diff --git a/src/domain/ledger_ops.rs b/src/domain/ledger_ops.rs @@ -143,6 +143,8 @@ pub(super) fn estimated_block_selection_size_bytes( prev_hash: "f".repeat(64), timestamp_ms: u64::MAX, miner: "f".repeat(64), + reward_address: None, + reward_address_signature: None, finalizer_mode, finalizer_rank: 0, reward: u64::MAX, @@ -303,6 +305,7 @@ pub(super) fn vdf_content_commitment( height: u64, prev_hash: &str, miner: &str, + reward_address: Option<&str>, finalizer_mode: FinalizerMode, finalizer_rank: u32, reward: Amount, @@ -329,6 +332,15 @@ pub(super) fn vdf_content_commitment( "iuna-vdf-burn-section-v1:{}", burn_bundle_section.canonical() )); + if let Some(reward_address) = reward_address { + let transaction_v2_hash = hex_hash(format!( + "iuna-vdf-transactions-v2:{}", + transactions_v2.join("|") + )); + return hex_hash(format!( + "iuna-vdf-content-v3:{height}:{prev_hash}:{miner}:{reward_address}:{mode}:{finalizer_rank}:{reward}:{vdf_rounds}:{ticket_id}:{transaction_hash}:{transaction_v2_hash}:{burn_section_hash}" + )); + } 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}" @@ -593,7 +605,10 @@ pub fn reward_outputs_for_block( outputs.push(( reward_outpoint(&block.hash), TxOutput { - address: block.miner.clone(), + address: block + .reward_address + .clone() + .unwrap_or_else(|| block.miner.clone()), amount: finalizer_amount, }, )); @@ -614,7 +629,13 @@ pub fn reward_outputs_for_block( outputs.push(( committee_reward_outpoint(&block.hash, member.slot), TxOutput { - address: member.owner.clone(), + address: block + .burn_bundle_section + .signatures + .iter() + .find(|signature| signature.slot == member.slot) + .and_then(|signature| signature.reward_address.clone()) + .unwrap_or_else(|| member.owner.clone()), amount, }, )); @@ -655,18 +676,38 @@ pub(super) fn ensure_outputs_do_not_overflow( Ok(()) } -pub(super) fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> { - if !transactions.iter().any(Transaction::is_burn) { +pub(super) fn ensure_block_has_burn( + transactions: &[Transaction], + transactions_v2: &[String], +) -> Result<()> { + let has_v2_burn = transactions_v2.iter().any(|envelope| { + super::decode_hex(envelope) + .ok() + .and_then(|encoded| super::TransactionV2::decode(&encoded).ok()) + .is_some_and(|(_, transaction)| transaction.is_burn()) + }); + if !transactions.iter().any(Transaction::is_burn) && !has_v2_burn { bail!("block must include at least one burn transaction"); } Ok(()) } -pub(super) fn ensure_block_has_burn_from(transactions: &[Transaction], miner: &str) -> Result<()> { - if !transactions +pub(super) fn ensure_block_has_burn_from( + transactions: &[Transaction], + transactions_v2: &[String], + miner: &str, +) -> Result<()> { + let has_legacy_burn = transactions .iter() - .any(|transaction| transaction.is_burn() && transaction.sender() == miner) - { + .any(|transaction| transaction.is_burn() && transaction.sender() == miner); + let has_v2_burn = transactions_v2.iter().any(|envelope| { + super::decode_hex(envelope) + .ok() + .and_then(|encoded| super::TransactionV2::decode(&encoded).ok()) + .and_then(|(_, transaction)| transaction.burn_legacy_owner().ok().flatten()) + .is_some_and(|owner| owner == miner) + }); + if !has_legacy_burn && !has_v2_burn { bail!("recovery block must include a burn from the finalizer"); } Ok(()) @@ -683,7 +724,7 @@ pub(super) fn ensure_valid_recovery_block(block: &Block, parent: &Block) -> Resu if block.timestamp_ms < min_timestamp { bail!("recovery block is not available before timestamp {min_timestamp}"); } - ensure_block_has_burn_from(&block.transactions, &block.miner) + ensure_block_has_burn_from(&block.transactions, &block.transactions_v2, &block.miner) } pub(super) fn best_selectable_transaction_index( @@ -762,6 +803,8 @@ mod tests { prev_hash: "p".repeat(64), timestamp_ms: 1, miner: "finalizer".to_string(), + reward_address: None, + reward_address_signature: None, finalizer_mode, finalizer_rank, reward, @@ -792,6 +835,7 @@ mod tests { .map(|slot| BurnBundleSignature { slot: *slot, member: format!("committee-{slot}"), + reward_address: None, signature: format!("signature-{slot}"), }) .collect(); @@ -806,6 +850,30 @@ mod tests { } #[test] + fn activated_rewards_use_explicit_hybrid_payout_addresses() { + let mut block = reward_block(FinalizerMode::Ticket, 0, 100); + block.height = super::super::HYBRID_REWARD_ACTIVATION_HEIGHT; + block.reward_address = Some("finalizer-hybrid".to_string()); + block.burn_bundle_section.signatures = vec![BurnBundleSignature { + slot: 1, + member: "committee".to_string(), + reward_address: Some("committee-hybrid".to_string()), + signature: "signature".to_string(), + }]; + let committee = vec![ + committee_member(0, "finalizer"), + committee_member(1, "committee"), + ]; + + let outputs = reward_outputs_for_block(&block, &committee); + + assert_eq!(output_amount(&outputs, "finalizer-hybrid"), 50); + assert_eq!(output_amount(&outputs, "committee-hybrid"), 50); + assert_eq!(output_amount(&outputs, "finalizer"), 0); + assert_eq!(output_amount(&outputs, "committee"), 0); + } + + #[test] fn rank_zero_splits_half_to_two_extra_committee_members() { let mut block = reward_block(FinalizerMode::Ticket, 0, 100); attest(&mut block, &[1, 2]); diff --git a/src/domain/ledger_pending.rs b/src/domain/ledger_pending.rs @@ -127,30 +127,76 @@ impl Ledger { .filter(|transaction| self.transaction_is_eligible_for_next_block(transaction)) .collect::<Vec<_>>(); let mut selected = 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() + .filter(|transaction| self.transaction_v2_is_eligible_for_next_block(transaction)) + .collect::<Vec<_>>() + } else { + Vec::new() + }; + let mut selected_v2 = Vec::new(); let required_burn_signatures = burn_bundle_section .required_burns() .into_iter() .map(|burn| burn.signature().to_string()) .collect::<BTreeSet<_>>(); + let required_burns_v2 = burn_bundle_section + .required_burns_v2() + .into_iter() + .map(str::to_string) + .collect::<BTreeSet<_>>(); let mut selected_required_burn_signatures = BTreeSet::new(); + let mut selected_required_burns_v2 = BTreeSet::new(); + let mut anchor_v2_index = None; let anchor_index = if let Some(signature) = required_burn_signature { - Some( - remaining - .iter() - .position(|transaction| transaction.signature() == signature) - .with_context(|| format!("required burn {signature} is not pending"))?, - ) + let legacy = remaining + .iter() + .position(|transaction| transaction.signature() == signature); + if legacy.is_none() { + anchor_v2_index = remaining_v2.iter().position(|transaction| { + transaction + .transaction_id(&domain) + .ok() + .is_some_and(|id| hex_encode(id) == signature) + }); + } + if legacy.is_none() && anchor_v2_index.is_none() { + bail!("required burn {signature} is not pending"); + } + legacy } else if let Some(owner) = required_burn_owner { - best_selectable_burn_from_index(&remaining, &utxos, owner, &signing_domain) + let legacy = + best_selectable_burn_from_index(&remaining, &utxos, owner, &signing_domain); + if legacy.is_none() { + anchor_v2_index = remaining_v2.iter().position(|transaction| { + transaction.is_burn() + && transaction + .burn_legacy_owner() + .ok() + .flatten() + .is_some_and(|candidate| candidate == owner) + }); + } + legacy } else { - best_selectable_transaction_index( + let legacy = best_selectable_transaction_index( &remaining, &utxos, Some(TransactionKind::Burn), &signing_domain, - ) + ); + if legacy.is_none() { + anchor_v2_index = remaining_v2 + .iter() + .position(|transaction| transaction.is_burn()); + } + legacy }; if let Some(index) = anchor_index { let tx = remaining.remove(index); @@ -160,6 +206,24 @@ impl Ledger { selected_required_burn_signatures.insert(signature); } } + if let Some(index) = anchor_v2_index { + let transaction = remaining_v2.remove(index); + if !transaction.is_burn() { + bail!("required block anchor must be a burn transaction"); + } + if let Some(owner) = required_burn_owner { + if transaction.burn_legacy_owner()?.as_deref() != Some(owner) { + bail!("required block anchor burn must be from the recovery finalizer"); + } + } + apply_prevalidated_transaction_v2_to_utxos(transaction, &domain, network, &mut utxos) + .context("required transaction v2 anchor burn is not spendable")?; + let envelope = hex_encode(transaction.encode(&domain)?); + if required_burns_v2.contains(&envelope) { + selected_required_burns_v2.insert(envelope.clone()); + } + selected_v2.push(envelope); + } let mut index = 0; while index < remaining.len() @@ -189,10 +253,38 @@ impl Ledger { .expect("required burn set differs"); bail!("attested burn {missing} is not pending"); } + let mut index = 0; + while index < remaining_v2.len() + && selected_required_burns_v2.len() < required_burns_v2.len() + { + let envelope = hex_encode(remaining_v2[index].encode(&domain)?); + if !required_burns_v2.contains(&envelope) { + index += 1; + continue; + } + let transaction = remaining_v2.remove(index); + if !transaction.is_burn() { + bail!("attested transaction v2 must be a burn"); + } + if selected.len().saturating_add(selected_v2.len()) + >= self.launch_profile.max_block_transactions + { + bail!( + "attested transaction v2 burns do not fit within the block transaction count limit" + ); + } + apply_prevalidated_transaction_v2_to_utxos(transaction, &domain, network, &mut utxos) + .context("attested transaction v2 burn is not spendable")?; + selected_required_burns_v2.insert(envelope.clone()); + selected_v2.push(envelope); + } + if selected_required_burns_v2.len() != required_burns_v2.len() { + bail!("attested transaction v2 burn is not pending"); + } let required_selection = BlockSelection { transactions: selected.clone(), - transactions_v2: Vec::new(), + transactions_v2: selected_v2.clone(), }; if estimated_block_selection_size_bytes( block_context, @@ -206,15 +298,7 @@ impl Ledger { 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() + transactions_v2: selected_v2, }; loop { @@ -511,7 +595,18 @@ impl Ledger { signature, .. } => { - validate_address(recipient, "mine recipient")?; + let next_height = self.height().saturating_add(1); + if next_height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT { + let address = super::decode_versioned_address( + recipient, + super::AddressNetwork::from_profile_id(&self.launch_profile.profile_id), + )?; + if address.version != super::AddressVersion::HybridKeyCommitment { + bail!("mine reward must use a hybrid address at height {next_height}"); + } + } else { + validate_address(recipient, "mine recipient")?; + } validate_hash(anchor, "mine transaction anchor")?; validate_hash(signature, "mine transaction proof hash")?; if let Some(proof_header) = proof_header { @@ -946,6 +1041,7 @@ mod tests { burn: attested, bundle_mask: 0, }], + burns_v2: Vec::new(), }; assert!( diff --git a/src/domain/ledger_prepare.rs b/src/domain/ledger_prepare.rs @@ -8,13 +8,39 @@ use super::{ impl Ledger { pub fn mine_next_block(&self, wallet: &Wallet, timestamp_ms: u64) -> Result<super::Block> { - let prepared = self.prepare_next_block(wallet.address(), timestamp_ms)?; + let reward_address = (self.height().saturating_add(1) + >= super::HYBRID_REWARD_ACTIVATION_HEIGHT) + .then(|| { + wallet.hybrid_address(super::AddressNetwork::from_profile_id( + &self.launch_profile.profile_id, + )) + }); + let prepared = self.prepare_next_block_with_required_burn_and_burn_bundles( + wallet.address(), + reward_address.as_deref(), + timestamp_ms, + Vec::new(), + None, + )?; let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); Ok(prepared.finish(wallet, vdf_output)) } pub fn mine_recovery_block(&self, wallet: &Wallet, timestamp_ms: u64) -> Result<super::Block> { - let prepared = self.prepare_recovery_block(wallet.address(), timestamp_ms)?; + let reward_address = (self.height().saturating_add(1) + >= super::HYBRID_REWARD_ACTIVATION_HEIGHT) + .then(|| { + wallet.hybrid_address(super::AddressNetwork::from_profile_id( + &self.launch_profile.profile_id, + )) + }); + let prepared = self.prepare_recovery_block_with_required_burn_and_burn_bundles( + wallet.address(), + reward_address.as_deref(), + timestamp_ms, + Vec::new(), + None, + )?; let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); Ok(prepared.finish(wallet, vdf_output)) } @@ -31,6 +57,7 @@ impl Ledger { ) -> Result<PreparedBlock> { self.prepare_next_block_with_required_burn_and_burn_bundles( miner, + None, timestamp_ms, burn_bundles, None, @@ -40,11 +67,13 @@ impl Ledger { pub(crate) fn prepare_next_block_with_required_burn_and_burn_bundles( &self, miner: &str, + reward_address: Option<&str>, timestamp_ms: u64, burn_bundles: Vec<BurnBundle>, required_burn_signature: Option<&str>, ) -> Result<PreparedBlock> { let height = self.tip().height + 1; + self.validate_reward_address(height, reward_address, "finalizer")?; let Some((finalizer_rank, leader_ticket)) = self.finalizer_ticket_for_miner(height, miner) else { bail!("cannot mine block without a mature burn ticket"); @@ -55,13 +84,13 @@ impl Ledger { let burn_bundles = self.validate_next_block_burn_bundles_for_finalizer_rank(finalizer_rank, burn_bundles)?; - let burn_bundle_section = self.burn_bundle_section_from_bundles(burn_bundles); + let burn_bundle_section = self.burn_bundle_section_from_bundles(burn_bundles)?; let selection = self.select_block_transactions_with_burn_section( miner, required_burn_signature, &burn_bundle_section, )?; - ensure_block_has_burn(&selection.transactions)?; + ensure_block_has_burn(&selection.transactions, &selection.transactions_v2)?; let tip = self.tip(); let prev_hash = tip.hash.clone(); @@ -77,6 +106,7 @@ impl Ledger { height, &prev_hash, miner, + reward_address, FinalizerMode::Ticket, finalizer_rank, reward, @@ -92,6 +122,7 @@ impl Ledger { prev_hash, timestamp_ms, miner: miner.to_string(), + reward_address: reward_address.map(str::to_string), finalizer_mode: FinalizerMode::Ticket, reward, vdf_rounds, @@ -126,6 +157,7 @@ impl Ledger { ) -> Result<PreparedBlock> { self.prepare_recovery_block_with_required_burn_and_burn_bundles( miner, + None, timestamp_ms, burn_bundles, None, @@ -135,25 +167,27 @@ impl Ledger { pub(crate) fn prepare_recovery_block_with_required_burn_and_burn_bundles( &self, miner: &str, + reward_address: Option<&str>, timestamp_ms: u64, burn_bundles: Vec<BurnBundle>, required_burn_signature: Option<&str>, ) -> Result<PreparedBlock> { let height = self.tip().height + 1; + self.validate_reward_address(height, reward_address, "finalizer")?; let min_timestamp = self.recovery_block_min_timestamp(); if timestamp_ms < min_timestamp { bail!("recovery block is not available before timestamp {min_timestamp}"); } let burn_bundles = self.validate_next_block_burn_bundles(burn_bundles)?; - let burn_bundle_section = self.burn_bundle_section_from_bundles(burn_bundles); + let burn_bundle_section = self.burn_bundle_section_from_bundles(burn_bundles)?; let selection = self.select_recovery_block_transactions_with_burn_section( miner, required_burn_signature, &burn_bundle_section, )?; - ensure_block_has_burn(&selection.transactions)?; - ensure_block_has_burn_from(&selection.transactions, miner)?; + ensure_block_has_burn(&selection.transactions, &selection.transactions_v2)?; + ensure_block_has_burn_from(&selection.transactions, &selection.transactions_v2, miner)?; let tip = self.tip(); let prev_hash = tip.hash.clone(); @@ -169,6 +203,7 @@ impl Ledger { height, &prev_hash, miner, + reward_address, FinalizerMode::Recovery, 0, reward, @@ -190,6 +225,7 @@ impl Ledger { prev_hash, timestamp_ms, miner: miner.to_string(), + reward_address: reward_address.map(str::to_string), finalizer_mode: FinalizerMode::Recovery, finalizer_rank: 0, reward, diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs @@ -5,15 +5,18 @@ use anyhow::{Context, Result, bail}; use super::ledger_ops::verify_address_signature; use super::reveal::{burn_bundle_slot_mask, burn_committee_mask}; use super::{ - Amount, BURN_COMMITTEE_SIZE, Block, BurnBundle, BurnBundlePayload, BurnBundleSection, - BurnBundleSignature, BurnCommitteeMember, FinalizerMode, Ledger, MAX_BURN_BUNDLE_BYTES, - MaskedBurn, OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, Transaction, Wallet, + AddressNetwork, Amount, BURN_COMMITTEE_SIZE, Block, BurnBundle, BurnBundlePayload, + BurnBundleSection, BurnBundleSignature, BurnCommitteeMember, FinalizerMode, Ledger, + MAX_BURN_BUNDLE_BYTES, MaskedBurn, MaskedBurnV2, OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, + Transaction, Wallet, hex_encode, }; impl Ledger { pub fn burn_bundle_attestations_required_for_next_block(&self) -> bool { self.pending.iter().any(|transaction| { transaction.is_burn() && self.transaction_is_eligible_for_next_block(transaction) + }) || self.pending_v2.iter().any(|transaction| { + transaction.is_burn() && self.transaction_v2_is_eligible_for_next_block(transaction) }) } @@ -48,35 +51,68 @@ impl Ledger { if memberships.is_empty() { return Ok(Vec::new()); } - let mut burns = self + let burns = self .valid_pending_transactions() .into_iter() .filter(|transaction| { transaction.is_burn() && self.transaction_is_eligible_for_next_block(transaction) }) .collect::<Vec<_>>(); - burns.sort_by(|left, right| { - right - .fee() - .cmp(&left.fee()) - .then_with(|| left.signature().cmp(right.signature())) - }); + let domain = self.transaction_v2_domain()?; + let burns_v2 = self + .pending_v2 + .iter() + .filter(|transaction| { + transaction.is_burn() && self.transaction_v2_is_eligible_for_next_block(transaction) + }) + .map(|transaction| { + Ok(( + transaction.fee(), + hex_encode(transaction.transaction_id(&domain)?), + hex_encode(transaction.encode(&domain)?), + )) + }) + .collect::<Result<Vec<_>>>()?; + let mut candidates = burns + .into_iter() + .map(|burn| (burn.fee(), burn.signature().to_string(), Some(burn), None)) + .chain( + burns_v2 + .into_iter() + .map(|(fee, id, envelope)| (fee, id, None, Some(envelope))), + ) + .collect::<Vec<_>>(); + candidates.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); let mut bundles = Vec::new(); for member in memberships { let mut selected = Vec::new(); - for burn in &burns { + let mut selected_v2 = Vec::new(); + for (_, _, legacy_burn, v2_envelope) in &candidates { let mut candidate = selected.clone(); - candidate.push(burn.clone()); + let mut candidate_v2 = selected_v2.clone(); + if let Some(burn) = legacy_burn { + candidate.push(burn.clone()); + } + if let Some(envelope) = v2_envelope { + candidate_v2.push(envelope.clone()); + } let bundle = wallet.burn_bundle(BurnBundlePayload { height, prev_hash: prev_hash.clone(), slot: member.slot, member: wallet.address().to_string(), + reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT).then(|| { + wallet.hybrid_address(AddressNetwork::from_profile_id( + &self.launch_profile.profile_id, + )) + }), burns: candidate.clone(), + burns_v2: candidate_v2.clone(), }); if bundle.serialized_size_bytes()? <= MAX_BURN_BUNDLE_BYTES { selected = candidate; + selected_v2 = candidate_v2; } } bundles.push(wallet.burn_bundle(BurnBundlePayload { @@ -84,7 +120,13 @@ impl Ledger { prev_hash: prev_hash.clone(), slot: member.slot, member: wallet.address().to_string(), + reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT).then(|| { + wallet.hybrid_address(AddressNetwork::from_profile_id( + &self.launch_profile.profile_id, + )) + }), burns: selected, + burns_v2: selected_v2, })); } Ok(bundles) @@ -180,24 +222,32 @@ impl Ledger { prev_hash, slot: member.slot, member: wallet.address().to_string(), + reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT).then(|| { + wallet.hybrid_address(AddressNetwork::from_profile_id( + &self.launch_profile.profile_id, + )) + }), burns, + burns_v2: Vec::new(), }) } pub(super) fn burn_bundle_section_from_bundles( &self, bundles: Vec<BurnBundle>, - ) -> BurnBundleSection { + ) -> Result<BurnBundleSection> { let signatures = bundles .iter() .filter(|bundle| bundle.slot != 0) .map(|bundle| BurnBundleSignature { slot: bundle.slot, member: bundle.member.clone(), + reward_address: bundle.reward_address.clone(), signature: bundle.signature.clone(), }) .collect::<Vec<_>>(); let mut by_signature: BTreeMap<String, MaskedBurn> = BTreeMap::new(); + let mut by_v2_id: BTreeMap<String, MaskedBurnV2> = BTreeMap::new(); for bundle in bundles { let slot_mask = if bundle.slot == 0 { 0 @@ -213,6 +263,15 @@ impl Ledger { bundle_mask: slot_mask, }); } + for envelope in bundle.burns_v2 { + by_v2_id + .entry(envelope.clone()) + .and_modify(|masked| masked.bundle_mask |= slot_mask) + .or_insert(MaskedBurnV2 { + envelope, + bundle_mask: slot_mask, + }); + } } let mut burns = by_signature.into_values().collect::<Vec<_>>(); burns.sort_by(|left, right| { @@ -222,7 +281,27 @@ impl Ledger { .cmp(&left.burn.fee()) .then_with(|| left.burn.signature().cmp(right.burn.signature())) }); - BurnBundleSection { signatures, burns } + let domain = self.transaction_v2_domain()?; + let mut burns_v2 = by_v2_id + .into_values() + .map(|masked| { + let burn = super::ledger_v2::decode_canonical_transaction_v2_envelope( + &masked.envelope, + &domain, + )?; + Ok(( + burn.fee(), + hex_encode(burn.transaction_id(&domain)?), + masked, + )) + }) + .collect::<Result<Vec<_>>>()?; + burns_v2.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); + Ok(BurnBundleSection { + signatures, + burns, + burns_v2: burns_v2.into_iter().map(|(_, _, masked)| masked).collect(), + }) } pub(super) fn validate_burn_bundle_section_for_block(&self, block: &Block) -> Result<()> { @@ -264,6 +343,11 @@ impl Ledger { if signature.member != member.owner { bail!("burn bundle member is not assigned to slot"); } + self.validate_reward_address( + block.height, + signature.reward_address.as_deref(), + "committee member", + )?; included_mask |= burn_bundle_slot_mask(signature.slot)?; } let required_signatures = self.required_explicit_burn_signatures( @@ -306,6 +390,31 @@ impl Ledger { } previous_key = Some(key); } + let domain = self.transaction_v2_domain()?; + let mut seen_burns_v2 = BTreeSet::new(); + for masked in &section.burns_v2 { + if masked.bundle_mask & !burn_committee_mask() != 0 { + bail!("masked transaction v2 burn references an invalid burn bundle slot"); + } + if masked.bundle_mask & !included_mask != 0 { + bail!("masked transaction v2 burn references a missing burn bundle signature"); + } + let burn = super::ledger_v2::decode_canonical_transaction_v2_envelope( + &masked.envelope, + &domain, + )?; + if !burn.is_burn() { + bail!("burn bundle section contains a non-burn transaction v2"); + } + let id = hex_encode(burn.transaction_id(&domain)?); + if !seen_burns_v2.insert(id) { + bail!("duplicate transaction v2 burn in burn bundle section"); + } + if !block.transactions_v2.contains(&masked.envelope) { + bail!("attested transaction v2 burn is not included in the block"); + } + self.validate_transaction_v2_anchor_for_block(&burn, block.height)?; + } for bundle in section.expand(block.height, &block.prev_hash) { if bundle.serialized_size_bytes()? > MAX_BURN_BUNDLE_BYTES { @@ -401,6 +510,7 @@ impl Ledger { bail!("duplicate burn bundle slot"); } let mut seen_members = BTreeSet::new(); + let domain = self.transaction_v2_domain()?; for bundle in &bundles { if !seen_members.insert(bundle.member.clone()) { bail!("duplicate burn bundle member"); @@ -416,6 +526,20 @@ impl Ledger { bail!("burn bundle references a burn that is not in the mempool"); } } + for envelope in &bundle.burns_v2 { + let burn = + super::ledger_v2::decode_canonical_transaction_v2_envelope(envelope, &domain)?; + let id = burn.transaction_id(&domain)?; + if !self + .pending_v2 + .iter() + .any(|pending| pending.transaction_id(&domain).ok() == Some(id)) + { + bail!( + "burn bundle references a transaction v2 burn that is not in the mempool" + ); + } + } } Ok(bundles) } @@ -442,6 +566,11 @@ impl Ledger { if bundle.member != member.owner { bail!("burn bundle member is not assigned to slot"); } + self.validate_reward_address( + expected_height, + bundle.reward_address.as_deref(), + "committee member", + )?; if bundle.serialized_size_bytes()? > MAX_BURN_BUNDLE_BYTES { bail!("burn bundle exceeds max size"); } @@ -470,6 +599,28 @@ impl Ledger { } previous_key = Some(key); } + let domain = self.transaction_v2_domain()?; + let mut seen_bundle_burns_v2 = BTreeSet::new(); + let mut previous_v2_key: Option<(Amount, String)> = None; + for envelope in &bundle.burns_v2 { + let burn = + super::ledger_v2::decode_canonical_transaction_v2_envelope(envelope, &domain)?; + if !burn.is_burn() { + bail!("burn bundle contains a non-burn transaction v2"); + } + let id = hex_encode(burn.transaction_id(&domain)?); + if !seen_bundle_burns_v2.insert(id.clone()) { + bail!("duplicate transaction v2 burn in burn bundle"); + } + self.validate_transaction_v2_anchor_for_block(&burn, expected_height)?; + let key = (burn.fee(), id); + if let Some((previous_fee, previous_id)) = &previous_v2_key { + if key.0 > *previous_fee || key.0 == *previous_fee && key.1 < *previous_id { + bail!("transaction v2 burn bundle is not fee ordered"); + } + } + previous_v2_key = Some(key); + } Ok(()) } } @@ -650,6 +801,7 @@ mod tests { .map(|slot| BurnBundleSignature { slot, member: format!("member-{slot}"), + reward_address: None, signature: format!("signature-{slot}"), }) .collect(); @@ -667,6 +819,7 @@ mod tests { .push(BurnBundleSignature { slot: 3, member: "member-3".to_string(), + reward_address: None, signature: "signature-3".to_string(), }); block.finalizer_rank = 1; @@ -684,7 +837,9 @@ mod tests { prev_hash: "parent".to_string(), slot: 1, member: "member-1".to_string(), + reward_address: None, burns: vec![low_fee_burn.clone(), high_fee_burn.clone()], + burns_v2: Vec::new(), signature: "sig-1".to_string(), }, BurnBundle { @@ -692,12 +847,14 @@ mod tests { prev_hash: "parent".to_string(), slot: 2, member: "member-2".to_string(), + reward_address: None, burns: vec![high_fee_burn.clone()], + burns_v2: Vec::new(), signature: "sig-2".to_string(), }, ]; - let section = ledger.burn_bundle_section_from_bundles(bundles); + let section = ledger.burn_bundle_section_from_bundles(bundles).unwrap(); assert_eq!(section.signatures.len(), 2); assert_eq!(section.burns.len(), 2); @@ -738,7 +895,9 @@ mod tests { prev_hash: ledger.tip_hash().to_string(), slot: member.slot, member: alice.address().to_string(), + reward_address: None, burns: vec![attested_burn], + burns_v2: Vec::new(), }); let error = ledger @@ -766,7 +925,9 @@ mod tests { prev_hash: ledger.tip_hash().to_string(), slot: 1, member: member.address().to_string(), + reward_address: None, burns: vec![future_burn], + burns_v2: Vec::new(), }); let committee = BTreeMap::from([( 1, @@ -861,7 +1022,9 @@ mod tests { .unwrap() .finish(finalizer, "unused-vdf".to_string()); let bundle = ledger.test_burn_bundle(finalizer, vec![pending_burn.clone()]); - block.burn_bundle_section = ledger.burn_bundle_section_from_bundles(vec![bundle]); + block.burn_bundle_section = ledger + .burn_bundle_section_from_bundles(vec![bundle]) + .unwrap(); block .transactions .retain(|transaction| transaction.signature() != pending_burn.signature()); diff --git a/src/domain/ledger_v2.rs b/src/domain/ledger_v2.rs @@ -37,6 +37,7 @@ impl Ledger { transaction: &TransactionV2, height: u64, ) -> Result<()> { + self.validate_transaction_v2_anchor_for_block(transaction, height)?; self.validated_v2_utxos_at_height(transaction, height)?; Ok(()) } @@ -54,6 +55,7 @@ impl Ledger { height: u64, ) -> Result<TransactionSubmitOutcome> { ensure_transaction_v2_active(height)?; + self.validate_transaction_v2_anchor_for_pending(&transaction)?; let domain = self.transaction_v2_domain()?; let transaction_id = transaction.transaction_id(&domain)?; if self @@ -141,13 +143,17 @@ impl Ledger { for transaction in pending { let bytes = transaction.encoded_size_bytes(&domain)?; let mut candidate_utxos = utxos.clone(); - if apply_prevalidated_transaction_v2_to_utxos( - &transaction, - &domain, - network, - &mut candidate_utxos, - ) - .is_ok() + if self + .validate_transaction_v2_anchor_for_pending(&transaction) + .and_then(|()| { + apply_prevalidated_transaction_v2_to_utxos( + &transaction, + &domain, + network, + &mut candidate_utxos, + ) + }) + .is_ok() { utxos = candidate_utxos; self.pending_v2.push(transaction); @@ -173,6 +179,52 @@ impl Ledger { )?; Ok(utxos) } + + pub(super) fn validate_transaction_v2_anchor_for_block( + &self, + transaction: &TransactionV2, + height: u64, + ) -> Result<()> { + if !transaction.is_burn() { + return Ok(()); + } + let anchor = transaction + .burn_anchor() + .context("transaction v2 burn is missing its chain anchor")?; + let expected = decode_hex_array::<32>(&self.tip().prev_hash) + .context("block grandparent hash is invalid")?; + if height >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT && anchor != expected { + bail!("transaction v2 burn anchor does not match the block grandparent"); + } + Ok(()) + } + + pub(super) fn validate_transaction_v2_anchor_for_pending( + &self, + transaction: &TransactionV2, + ) -> Result<()> { + if !transaction.is_burn() { + return Ok(()); + } + let anchor = transaction + .burn_anchor() + .context("transaction v2 burn is missing its chain anchor")?; + let tip = decode_hex_array::<32>(self.tip_hash()).context("tip hash is invalid")?; + let parent = + decode_hex_array::<32>(&self.tip().prev_hash).context("tip parent hash is invalid")?; + if anchor != tip && anchor != parent { + return Err(super::ValidationError::BurnAnchorOutsidePendingWindow.into()); + } + Ok(()) + } + + pub(crate) fn transaction_v2_is_eligible_for_next_block( + &self, + transaction: &TransactionV2, + ) -> bool { + self.validate_transaction_v2_anchor_for_block(transaction, self.height().saturating_add(1)) + .is_ok() + } } #[allow(clippy::too_many_arguments)] @@ -314,7 +366,7 @@ fn apply_transaction_v2_to_utxos_with_policy( utxos: &mut BTreeMap<OutPoint, TxOutput>, verify_authorizations: bool, ) -> Result<()> { - let (spent, outputs, fee) = match transaction { + let (spent, outputs, fee, burned) = match transaction { TransactionV2::Migration { inputs, outputs, @@ -324,6 +376,7 @@ fn apply_transaction_v2_to_utxos_with_policy( spend_legacy_inputs(inputs, utxos)?, outputs.as_slice(), *fee, + 0, ), TransactionV2::Transfer { inputs, @@ -334,9 +387,22 @@ fn apply_transaction_v2_to_utxos_with_policy( spend_v2_inputs(inputs, network, utxos)?, outputs.as_slice(), *fee, + 0, ), - TransactionV2::Burn { .. } => { - bail!("transaction v2 burns are not integrated into live burn consensus") + TransactionV2::Burn { + inputs, + change, + amount, + fee, + .. + } => { + transaction.burn_legacy_owner()?; + ( + spend_v2_inputs(inputs, network, utxos)?, + change.as_slice(), + *fee, + *amount, + ) } TransactionV2::Mine { .. } => { bail!("transaction v2 mining is not integrated into live proof consensus") @@ -346,7 +412,9 @@ fn apply_transaction_v2_to_utxos_with_policy( let credited = sum_outputs(outputs)?; let required = credited .checked_add(fee) - .context("transaction v2 output value plus fee overflows")?; + .context("transaction v2 output value plus fee overflows")? + .checked_add(burned) + .context("transaction v2 output value plus burn overflows")?; if spent != required { bail!("transaction v2 input value does not equal outputs plus fee"); } @@ -567,6 +635,102 @@ mod tests { } #[test] + fn hybrid_wallet_can_build_select_and_create_a_ticket_from_a_v2_burn() { + let wallet = Wallet::from_seed("v2-burn-wallet"); + let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); + let migration = ledger.build_v2_migration(&wallet, 3).unwrap(); + ledger.utxos = ledger + .validated_v2_utxos_at_height(&migration, 3_000) + .unwrap(); + let split = ledger + .build_v2_transfer(&wallet, wallet.hybrid_versioned_address(), 40, 1) + .unwrap(); + ledger.utxos = ledger.validated_v2_utxos_at_height(&split, 3_000).unwrap(); + set_next_height(&mut ledger, post_activation_height()); + + let burn = ledger.build_v2_burn_for_next_block(&wallet, 9, 5).unwrap(); + assert_eq!( + burn.burn_legacy_owner().unwrap().as_deref(), + Some(wallet.address()) + ); + assert_eq!(burn.amount(), 9); + ledger.submit_transaction_v2(burn.clone()).unwrap(); + let mut low_fee_build_ledger = ledger.clone(); + low_fee_build_ledger + .utxos + .retain(|_, output| output.amount == 40); + let low_fee_burn = low_fee_build_ledger + .build_v2_burn_for_next_block(&wallet, 7, 1) + .unwrap(); + ledger.submit_transaction_v2(low_fee_burn.clone()).unwrap(); + + let bundles = ledger.build_burn_bundles(&wallet).unwrap(); + assert!(!bundles.is_empty()); + let domain = ledger.transaction_v2_domain().unwrap(); + let expected_burns = vec![ + hex_encode(burn.encode(&domain).unwrap()), + hex_encode(low_fee_burn.encode(&domain).unwrap()), + ]; + assert!( + bundles + .iter() + .all(|bundle| bundle.burns_v2 == vec![expected_burns[0].clone()]) + ); + let recovery = ledger + .prepare_recovery_block_with_required_burn_and_burn_bundles( + wallet.address(), + None, + ledger.recovery_block_min_timestamp(), + Vec::new(), + None, + ) + .unwrap(); + assert_eq!(recovery.transactions_v2.len(), 2); + let section = ledger.burn_bundle_section_from_bundles(bundles).unwrap(); + assert_eq!(section.burns_v2.len(), 1); + let sorted_section = ledger + .burn_bundle_section_from_bundles(vec![super::super::BurnBundle { + height: ledger.height() + 1, + prev_hash: ledger.tip_hash().to_string(), + slot: 1, + member: wallet.address().to_string(), + reward_address: None, + burns: Vec::new(), + burns_v2: vec![expected_burns[1].clone(), expected_burns[0].clone()], + signature: "test-signature".to_string(), + }]) + .unwrap(); + assert_eq!( + sorted_section.expand(ledger.height() + 1, ledger.tip_hash())[0].burns_v2, + expected_burns + ); + + let selection = ledger + .select_block_transactions_with_required_burn_owner( + Some(wallet.address()), + None, + super::super::FinalizerMode::Ticket, + &section, + ) + .unwrap(); + assert!(selection.transactions.is_empty()); + assert_eq!(selection.transactions_v2.len(), 2); + + let mut block = ledger.tip().clone(); + block.height = post_activation_height(); + block.transactions.clear(); + block.transactions_v2 = selection.transactions_v2; + let tickets = + super::super::ticket::tickets_created_by_block(&block, ledger.launch_profile()) + .unwrap(); + assert_eq!(tickets.len(), 2); + assert_eq!(tickets[0].owner, wallet.address()); + assert_eq!(tickets[0].amount, 9); + assert_eq!(tickets[1].owner, wallet.address()); + assert_eq!(tickets[1].amount, 7); + } + + #[test] fn decoder_rejects_an_attacker_selected_chain_domain() { let wallet = Wallet::from_seed("v2-ledger-domain-wallet"); let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs @@ -4,6 +4,9 @@ pub const MICRO_IUNA: Amount = 1_000_000; pub const BLOCK_REWARD: Amount = MICRO_IUNA; pub const MINE_REWARD: Amount = MICRO_IUNA; pub const MINE_FINALIZER_FEE: Amount = MICRO_IUNA; +/// Consensus height at which every newly-created reward output must use an +/// address-v1 hybrid Ed25519 + ML-DSA payout address. +pub const HYBRID_REWARD_ACTIVATION_HEIGHT: u64 = 3_750; pub const DEFAULT_MINE_FEE: Amount = MINE_FINALIZER_FEE; pub const DEFAULT_TRANSACTION_FEE: Amount = MICRO_IUNA; pub const DEFAULT_FEE_PER_BYTE: Amount = 1; @@ -61,6 +64,7 @@ mod tests { assert_eq!(BLOCK_REWARD, MICRO_IUNA); assert_eq!(MINE_REWARD, MICRO_IUNA); assert_eq!(MINE_FINALIZER_FEE, MICRO_IUNA); + assert_eq!(HYBRID_REWARD_ACTIVATION_HEIGHT, 3_750); assert_eq!(MAX_BLOCK_BYTES, 1_000_000); #[cfg(not(feature = "e2e"))] assert_eq!(VDF_TARGET_BLOCK_MS, 10 * 60 * 1_000); diff --git a/src/domain/reveal.rs b/src/domain/reveal.rs @@ -10,7 +10,11 @@ pub struct BurnBundle { pub prev_hash: String, pub slot: u8, pub member: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reward_address: Option<String>, pub burns: Vec<Transaction>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub burns_v2: Vec<String>, pub signature: String, } @@ -21,7 +25,9 @@ impl BurnBundle { prev_hash: self.prev_hash.clone(), slot: self.slot, member: self.member.clone(), + reward_address: self.reward_address.clone(), burns: self.burns.clone(), + burns_v2: self.burns_v2.clone(), } .canonical() } @@ -46,6 +52,8 @@ impl BurnBundle { pub struct BurnBundleSignature { pub slot: u8, pub member: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reward_address: Option<String>, pub signature: String, } @@ -56,6 +64,13 @@ pub struct MaskedBurn { pub bundle_mask: u8, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MaskedBurnV2 { + pub envelope: String, + pub bundle_mask: u8, +} + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct BurnBundleSection { @@ -63,17 +78,26 @@ pub struct BurnBundleSection { pub signatures: Vec<BurnBundleSignature>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub burns: Vec<MaskedBurn>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub burns_v2: Vec<MaskedBurnV2>, } impl BurnBundleSection { pub fn is_empty(&self) -> bool { - self.signatures.is_empty() && self.burns.is_empty() + self.signatures.is_empty() && self.burns.is_empty() && self.burns_v2.is_empty() } pub fn required_burns(&self) -> Vec<&Transaction> { self.burns.iter().map(|masked| &masked.burn).collect() } + pub fn required_burns_v2(&self) -> Vec<&str> { + self.burns_v2 + .iter() + .map(|masked| masked.envelope.as_str()) + .collect() + } + pub fn included_bundle_count(&self) -> usize { self.signatures.len() } @@ -89,12 +113,20 @@ impl BurnBundleSection { .filter(|masked| masked.bundle_mask & slot_mask != 0) .map(|masked| masked.burn.clone()) .collect(); + let burns_v2 = self + .burns_v2 + .iter() + .filter(|masked| masked.bundle_mask & slot_mask != 0) + .map(|masked| masked.envelope.clone()) + .collect(); BurnBundle { height, prev_hash: prev_hash.to_string(), slot: signature.slot, member: signature.member.clone(), + reward_address: signature.reward_address.clone(), burns, + burns_v2, signature: signature.signature.clone(), } }) @@ -109,7 +141,8 @@ impl BurnBundleSection { ) -> [String; BURN_COMMITTEE_SIZE] { let bundles = self.expand(height, prev_hash); let mut hashes = burn_bundle_hashes(&bundles); - hashes[0] = finalizer_attestation_hash(height, prev_hash, finalizer, &self.burns); + hashes[0] = + finalizer_attestation_hash(height, prev_hash, finalizer, &self.burns, &self.burns_v2); hashes } @@ -117,11 +150,15 @@ impl BurnBundleSection { let signatures = self .signatures .iter() - .map(|signature| { - format!( + .map(|signature| match &signature.reward_address { + Some(address) => format!( + "{}:{}:{}:{}", + signature.slot, signature.member, address, signature.signature + ), + None => format!( "{}:{}:{}", signature.slot, signature.member, signature.signature - ) + ), }) .collect::<Vec<_>>() .join("|"); @@ -131,7 +168,17 @@ impl BurnBundleSection { .map(|masked| format!("{}:{}", masked.bundle_mask, masked.burn.canonical())) .collect::<Vec<_>>() .join("|"); - format!("burn-bundle-section-v1:{signatures}:burns:{burns}") + let burns_v2 = self + .burns_v2 + .iter() + .map(|masked| format!("{}:{}", masked.bundle_mask, masked.envelope)) + .collect::<Vec<_>>() + .join("|"); + if burns_v2.is_empty() { + format!("burn-bundle-section-v1:{signatures}:burns:{burns}") + } else { + format!("burn-bundle-section-v2:{signatures}:burns:{burns}:burns-v2:{burns_v2}") + } } } @@ -141,7 +188,9 @@ pub struct BurnBundlePayload { pub prev_hash: String, pub slot: u8, pub member: String, + pub reward_address: Option<String>, pub burns: Vec<Transaction>, + pub burns_v2: Vec<String>, } impl BurnBundlePayload { @@ -152,10 +201,29 @@ impl BurnBundlePayload { .map(Transaction::canonical) .collect::<Vec<_>>() .join("|"); - format!( - "iuna-burn-bundle-v1:{}:{}:{}:{}:{}", - self.height, self.prev_hash, self.slot, self.member, burns - ) + let burns_v2 = self.burns_v2.join("|"); + if !burns_v2.is_empty() { + return format!( + "iuna-burn-bundle-v3:{}:{}:{}:{}:{}:{}:{}", + self.height, + self.prev_hash, + self.slot, + self.member, + self.reward_address.as_deref().unwrap_or(""), + burns, + burns_v2 + ); + } + match &self.reward_address { + Some(address) => format!( + "iuna-burn-bundle-v2:{}:{}:{}:{}:{}:{}", + self.height, self.prev_hash, self.slot, self.member, address, burns + ), + None => format!( + "iuna-burn-bundle-v1:{}:{}:{}:{}:{}", + self.height, self.prev_hash, self.slot, self.member, burns + ), + } } } @@ -198,15 +266,27 @@ pub(super) fn finalizer_attestation_hash( prev_hash: &str, finalizer: &str, burns: &[MaskedBurn], + burns_v2: &[MaskedBurnV2], ) -> String { let canonical_burns = burns .iter() .map(|masked| masked.burn.canonical()) .collect::<Vec<_>>() .join("|"); - hex_hash(format!( - "iuna-finalizer-burn-attestation-v1:{height}:{prev_hash}:{finalizer}:{canonical_burns}" - )) + let canonical_burns_v2 = burns_v2 + .iter() + .map(|masked| format!("{}:{}", masked.bundle_mask, masked.envelope)) + .collect::<Vec<_>>() + .join("|"); + if canonical_burns_v2.is_empty() { + hex_hash(format!( + "iuna-finalizer-burn-attestation-v1:{height}:{prev_hash}:{finalizer}:{canonical_burns}" + )) + } else { + hex_hash(format!( + "iuna-finalizer-burn-attestation-v2:{height}:{prev_hash}:{finalizer}:{canonical_burns}:{canonical_burns_v2}" + )) + } } pub(super) fn canonical_burn_bundle_hashes( @@ -226,7 +306,9 @@ mod tests { prev_hash: "parent".to_string(), slot: 4, member: "member".to_string(), + reward_address: None, burns: Vec::new(), + burns_v2: Vec::new(), signature: "signature".to_string(), }; diff --git a/src/domain/stratum.rs b/src/domain/stratum.rs @@ -2,7 +2,8 @@ use anyhow::{Context, Result}; use sha2::{Digest, Sha256}; use super::{ - HASH_BYTES, TransactionSigningDomain, decode_hex_array, hex_encode, mine_signing_bytes, + AddressNetwork, AddressVersion, HASH_BYTES, TransactionSigningDomain, decode_hex_array, + decode_versioned_address, hex_encode, mine_signing_bytes, validation::{decode_canonical_hex_array, validate_address, validate_hash}, }; @@ -136,7 +137,18 @@ pub(super) fn stratum_mine_template( difficulty_bits: u32, ) -> Result<StratumMineTemplate> { let recipient = recipient.into(); - validate_address(&recipient, "mine recipient")?; + if validate_address(&recipient, "mine recipient").is_err() { + let network = if recipient.starts_with("tiuna1") { + AddressNetwork::Testnet + } else { + AddressNetwork::Mainnet + }; + let decoded = + decode_versioned_address(&recipient, network).context("mine recipient is invalid")?; + if decoded.version != AddressVersion::HybridKeyCommitment { + anyhow::bail!("mine recipient must use a legacy or hybrid address"); + } + } validate_hash(anchor, "mine transaction anchor")?; let anchor_bytes = if domain.is_chain_bound() { decode_canonical_hex_array::<HASH_BYTES>(anchor) diff --git a/src/domain/ticket.rs b/src/domain/ticket.rs @@ -5,7 +5,8 @@ use sha2::{Digest, Sha256}; use super::{ Amount, Block, FinalizerMode, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, LaunchProfile, - MAX_VDF_ROUNDS, Transaction, VDF_TARGET_BLOCK_MS, hex_hash, + MAX_VDF_ROUNDS, Transaction, TransactionV2, VDF_TARGET_BLOCK_MS, decode_hex, hex_encode, + hex_hash, }; pub(super) const MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT: u64 = 300; @@ -134,7 +135,33 @@ pub(super) fn tickets_created_by_block( block: &Block, profile: &LaunchProfile, ) -> Result<Vec<BurnTicket>> { - tickets_created_by_transactions(block.height, &block.transactions, profile) + let mut tickets = tickets_created_by_transactions(block.height, &block.transactions, profile)?; + for envelope in &block.transactions_v2 { + let encoded = decode_hex(envelope).context("transaction v2 envelope is not hexadecimal")?; + let (domain, transaction) = TransactionV2::decode(&encoded)?; + if !transaction.is_burn() { + continue; + } + let owner = transaction + .burn_legacy_owner()? + .context("transaction v2 burn owner is missing")?; + let amount = transaction.amount(); + let target_height = block + .height + .checked_add(profile.ticket_maturity_delay_heights) + .with_context(|| format!("ticket target height overflow at block {}", block.height))?; + let eligible_until_height = target_height + .checked_add(profile.ticket_expiry_window_heights - 1) + .with_context(|| format!("ticket expiry height overflow at block {}", block.height))?; + tickets.push(BurnTicket { + id: hex_encode(transaction.transaction_id(&domain)?), + owner, + amount, + eligible_from_height: target_height, + eligible_until_height, + }); + } + Ok(tickets) } pub(super) fn tickets_created_by_transactions( @@ -347,6 +374,8 @@ mod tests { prev_hash: "0".repeat(64), timestamp_ms: 1_000, miner: "parent".to_string(), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: 0, @@ -398,6 +427,8 @@ mod tests { prev_hash: parent.hash.clone(), timestamp_ms: ticket_block_min_timestamp(parent, rank).unwrap(), miner: selected.owner.clone(), + reward_address: None, + reward_address_signature: None, finalizer_mode: FinalizerMode::Ticket, finalizer_rank: rank, reward: 0, diff --git a/src/domain/transaction.rs b/src/domain/transaction.rs @@ -5,10 +5,11 @@ use serde::{Deserialize, Serialize}; use super::validation::{decode_canonical_hex, decode_canonical_hex_array}; use super::{ - Amount, HASH_BYTES, MINE_FINALIZER_FEE, MINE_REWARD, PUBLIC_KEY_BYTES, SIGNATURE_BYTES, Wallet, - canonical_transaction_size_bytes, decode_hex_array, genesis_allocation_outpoint, - hash_meets_difficulty, hex_encode, hex_hash, mine_payload, mine_signature, - stratum_mine_header_bytes, stratum_mine_signature, verify_ed25519, + AddressNetwork, Amount, HASH_BYTES, MINE_FINALIZER_FEE, MINE_REWARD, PUBLIC_KEY_BYTES, + SIGNATURE_BYTES, Wallet, canonical_transaction_size_bytes, decode_hex_array, + decode_versioned_address, genesis_allocation_outpoint, hash_meets_difficulty, hex_encode, + hex_hash, mine_payload, mine_signature, stratum_mine_header_bytes, stratum_mine_signature, + verify_ed25519, }; pub const TRANSACTION_SIGNING_FORMAT_VERSION: u16 = 1; @@ -622,9 +623,20 @@ pub(super) fn mine_signing_bytes( let mut bytes = Vec::new(); domain.encode(&mut bytes)?; bytes.push(3); - let recipient = decode_canonical_hex_array::<PUBLIC_KEY_BYTES>(recipient) - .context("mine recipient is invalid")?; - encode_bytes(&mut bytes, &recipient, "mine recipient")?; + if let Ok(recipient) = decode_canonical_hex_array::<PUBLIC_KEY_BYTES>(recipient) { + encode_bytes(&mut bytes, &recipient, "mine recipient")?; + } else { + let network = if recipient.starts_with("tiuna1") { + AddressNetwork::Testnet + } else { + AddressNetwork::Mainnet + }; + let recipient = + decode_versioned_address(recipient, network).context("mine recipient is invalid")?; + let mut encoded = vec![recipient.version.wire_id()]; + encoded.extend_from_slice(&recipient.payload); + encode_bytes(&mut bytes, &encoded, "mine recipient")?; + } let anchor = decode_canonical_hex_array::<HASH_BYTES>(anchor).context("mine anchor is invalid")?; encode_bytes(&mut bytes, &anchor, "mine anchor")?; diff --git a/src/domain/transaction_v2.rs b/src/domain/transaction_v2.rs @@ -5,7 +5,7 @@ use sha2::{Digest, Sha256}; use super::{ AddressVersion, ProtocolPublicKey, ProtocolSignature, SignatureScheme, VersionedAddress, - verify_ed25519, verify_ml_dsa44, + hex_encode, verify_ed25519, verify_ml_dsa44, }; const TRANSACTION_V2_TAG: &[u8] = b"IUNA-TX-V2"; @@ -167,6 +167,58 @@ impl V2SpendingAuthorization { } impl TransactionV2 { + pub fn is_burn(&self) -> bool { + matches!(self, Self::Burn { .. }) + } + + pub fn amount(&self) -> u64 { + match self { + Self::Burn { amount, .. } => *amount, + Self::Migration { .. } | Self::Transfer { .. } | Self::Mine { .. } => 0, + } + } + + pub fn burn_anchor(&self) -> Option<[u8; 32]> { + match self { + Self::Burn { anchor, .. } => *anchor, + Self::Migration { .. } | Self::Transfer { .. } | Self::Mine { .. } => None, + } + } + + pub fn burn_legacy_owner(&self) -> Result<Option<String>> { + let Self::Burn { + inputs, + authorizations, + .. + } = self + else { + return Ok(None); + }; + let owner = inputs + .first() + .context("transaction v2 burn has no owner")? + .owner; + if inputs.iter().any(|input| input.owner != owner) { + bail!("transaction v2 burn inputs must have one owner"); + } + let authorization = authorizations + .first() + .context("transaction v2 burn has no authorization")?; + if authorization.scheme() != SignatureScheme::HybridEd25519MlDsa44 + || authorizations.iter().any(|candidate| { + candidate.public_key().as_bytes() != authorization.public_key().as_bytes() + }) + { + bail!("transaction v2 burn must use one hybrid wallet identity"); + } + if authorization.committed_address()? != owner { + bail!("transaction v2 burn authorization does not match its owner"); + } + Ok(Some(hex_encode( + &authorization.public_key().as_bytes()[..32], + ))) + } + pub fn fee(&self) -> u64 { match self { Self::Migration { fee, .. } | Self::Transfer { fee, .. } | Self::Burn { fee, .. } => { diff --git a/src/domain/vdf/mod.rs b/src/domain/vdf/mod.rs @@ -239,6 +239,8 @@ mod tests { prev_hash: format!("{height:064x}"), timestamp_ms, miner: "finalizer".to_string(), + reward_address: None, + reward_address_signature: None, finalizer_mode: mode, finalizer_rank: rank, reward: 0, diff --git a/src/domain/wallet.rs b/src/domain/wallet.rs @@ -138,7 +138,9 @@ impl Wallet { prev_hash: payload.prev_hash, slot: payload.slot, member: self.address.clone(), + reward_address: payload.reward_address, burns: payload.burns, + burns_v2: payload.burns_v2, signature, } }