commit f49110a8233a86fb588be2f5fb713db81dbfa0fe
parent 536772bdcc469037581341da1e02519693552315
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sat, 29 Aug 2026 19:43:30 +0200
Activate chain-bound signing at height 1000
Diffstat:
16 files changed, 253 insertions(+), 156 deletions(-)
diff --git a/docs/operator-playbooks.md b/docs/operator-playbooks.md
@@ -147,12 +147,15 @@ Avoid:
- deleting or replacing wallets as part of the chain reset;
- starting before the published genesis hash and release checksum are available.
-## Height 1000 Grinding-Resistance Upgrade
+## Height 1000 Consensus Upgrade
Height `1000` is a coordinated consensus activation. At that height, VDF seeds
start committing to block content and ticket draws stop using the final block
-hash. This preserves blocks below `1000`, but nodes running the earlier rule will
-reject the upgraded chain or build an incompatible fork at activation.
+hash. Transaction signatures and native and Stratum mine proofs also switch to
+chain-bound binary format v1. This preserves blocks, snapshots, and UTXOs below
+`1000`, but nodes running the earlier rule will reject the upgraded chain or
+build an incompatible fork at activation. No database reset, new genesis, or
+migration command is needed for this height activation.
Before height `1000`:
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -44,11 +44,12 @@ The current mainnet-candidate parameter set is intentionally close to Bitcoin wh
- maximum signed burn bundle size: `10,000` bytes;
- burn committee lineage maturity: `20` blocks;
- fallback ticket invalidation activation height: `300`;
-- grinding-resistance activation height: `1000`.
+- grinding-resistance activation height: `1000`;
+- transaction signing format v1 activation height: `1000`.
Changing any value in this section requires a conscious mainnet-candidate reset or later hard-fork process.
-Mainnet must start from a newly generated genesis and a distinct mainnet chain ID. The candidate chain, its UTXOs, and its signatures cannot be promoted in place: transaction signing format v1 intentionally makes old signatures invalid on the new genesis. A chain-ID change is therefore a consensus reset, not only a P2P network cutover.
+Transaction signing format v1 activates automatically at height `1000`. Existing chain state and history remain valid; operators only need to upgrade every consensus node before activation. A chain-ID or genesis change remains a separate consensus reset.
The consensus block-size limit is the exact number of bytes produced by the compact snapshot v6 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.
@@ -68,11 +69,11 @@ Burn and transfer fees are chosen by the sender. Mine action reward and mine act
### Transaction signing format v1
-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. All hexadecimal wire fields must use canonical lowercase encoding; alternate casing is rejected before signature validation so it cannot malleate addresses, transaction IDs, block hashes, or persisted snapshots.
+At height `1000`, every transfer, burn, and mine action becomes 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. There is no legacy-signature fallback.
+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. Blocks below height `1000` retain the legacy text signatures and proof preimages permanently so existing history and snapshots replay unchanged. Blocks at height `1000` and later accept only format v1; there is no post-activation legacy fallback.
-Synthetic genesis-allocation outpoints use a separate typed binary commitment over the chain ID and allocation address. This avoids a circular dependency on the final genesis block hash while ensuring that otherwise identical allocations on different network identities do not create the same outpoints. Changing the chain ID or signing format requires a new genesis.
+Synthetic genesis-allocation outpoints retain their original address-based derivation for the lifetime of the chain. Changing them at activation would rewrite the existing UTXO set, so chain isolation is introduced only in new signatures and proofs.
## Burns Become Tickets
diff --git a/docs/security-review.md b/docs/security-review.md
@@ -53,12 +53,14 @@ Primary code:
- `src/domain/selection.rs`
- `src/domain/validation.rs`
-Transaction signing uses binary format v1 and commits the launch-profile chain ID
-and local genesis hash for transfers, burns, native mine actions, and Stratum mine
-actions. Genesis allocation outpoints are independently scoped to the chain ID.
-Fixed vectors and replay tests cover candidate/mainnet/testnet IDs, distinct
-genesis hashes, legacy text signatures, identical allocations, and hexadecimal
-casing malleability across validation and compact persistence.
+Transaction signing automatically switches at height `1000` from the legacy
+format to binary format v1, which commits the launch-profile chain ID and local
+genesis hash for transfers, burns, native mine actions, and Stratum mine actions.
+Historical blocks and genesis allocation outpoints retain their original rules,
+while blocks from the activation height have no legacy fallback. Fixed vectors
+and boundary/replay tests cover pre-activation compatibility, candidate/mainnet/
+testnet IDs, distinct genesis hashes, native and Stratum proofs, and hexadecimal
+casing malleability under format v1.
Evidence already in the tree:
@@ -240,9 +242,10 @@ see which revision was tested.
playbooks are the maintained in-tree references.
- Release evidence: keep successful release-gate logs from the exact tagged
candidate revision.
-- Grinding resistance: height `1000` activates a VDF content commitment and
- removes the final block hash from future ticket draws. All candidate nodes
- must upgrade before activation; mixed versions will split at height `1000`.
+- Height `1000` activation: the release activates both grinding resistance and
+ transaction signing format v1 automatically. All candidate nodes must upgrade
+ before activation; the height activation itself requires no chain-state reset
+ or operator migration command, but mixed versions will split at height `1000`.
## Sign-Off Table
diff --git a/src/adapters/chain_store/compact.rs b/src/adapters/chain_store/compact.rs
@@ -654,14 +654,14 @@ impl CompactWriter {
}
fn hex(&mut self, value: &str) -> Result<()> {
- let bytes = decode_canonical_hex(value)?;
+ let bytes = decode_hex(value)?;
self.varint(bytes.len() as u64);
self.bytes(&bytes);
Ok(())
}
fn fixed_hex<const N: usize>(&mut self, value: &str, label: &str) -> Result<()> {
- let bytes = decode_canonical_hex(value).with_context(|| format!("invalid {label}"))?;
+ let bytes = decode_hex(value).with_context(|| format!("invalid {label}"))?;
if bytes.len() != N {
bail!("invalid {label}: expected {N} bytes, got {}", bytes.len());
}
@@ -687,7 +687,7 @@ impl CompactWriter {
self.varint(*index);
return Ok(());
}
- let bytes = decode_canonical_hex(value).context("invalid protocol id")?;
+ let bytes = decode_hex(value).context("invalid protocol id")?;
match bytes.len() {
32 => self.u8(1),
64 => self.u8(2),
@@ -890,14 +890,6 @@ fn decode_hex(input: &str) -> Result<Vec<u8>> {
Ok(bytes)
}
-fn decode_canonical_hex(input: &str) -> Result<Vec<u8>> {
- let bytes = decode_hex(input)?;
- if hex_encode(&bytes) != input {
- bail!("hex must use canonical lowercase encoding");
- }
- Ok(bytes)
-}
-
fn hex_value(byte: u8) -> Result<u8> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
@@ -1041,26 +1033,6 @@ mod tests {
}
#[test]
- fn compact_transaction_writer_rejects_noncanonical_hex() {
- let signature = "3".repeat(128);
- let transaction = Transaction::Transfer {
- inputs: vec![input(&"2".repeat(64), &signature)],
- outputs: vec![TxOutput {
- address: "AB".repeat(32),
- amount: 10,
- }],
- fee: 1,
- signature,
- };
- let mut writer = CompactWriter::default();
-
- let error = encode_transaction(&mut writer, &transaction, &mut EncodeTables::default())
- .unwrap_err();
-
- assert!(format!("{error:#}").contains("canonical lowercase"));
- }
-
- #[test]
fn repeated_burn_references_shrink_to_small_varints() {
let owner = "2".repeat(64);
let signature = "3".repeat(128);
diff --git a/src/adapters/ui_index.rs b/src/adapters/ui_index.rs
@@ -62,7 +62,7 @@ fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOu
continue;
}
outputs.insert(
- genesis_allocation_outpoint(&snapshot.launch_profile.profile_id, address),
+ genesis_allocation_outpoint(address),
TxOutput {
address: address.clone(),
amount: *amount,
@@ -138,8 +138,7 @@ mod tests {
};
let index = build_ui_chain_index(&snapshot);
- let outpoint =
- genesis_allocation_outpoint(&snapshot.launch_profile.profile_id, wallet.address());
+ let outpoint = genesis_allocation_outpoint(wallet.address());
assert_eq!(
index.outputs.get(&outpoint).map(|output| output.amount),
diff --git a/src/domain.rs b/src/domain.rs
@@ -59,7 +59,8 @@ pub use protocol::{
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,
- RECOVERY_BLOCK_DELAY_MS, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS,
+ RECOVERY_BLOCK_DELAY_MS, 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,
diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs
@@ -1430,7 +1430,7 @@ fn mini_lineage_state(snapshot: &ChainSnapshot) -> Option<MiniLineageState> {
{
mini_insert_output(
&mut state,
- genesis_allocation_outpoint(&snapshot.launch_profile.profile_id, address),
+ genesis_allocation_outpoint(address),
TxOutput {
address: address.clone(),
amount: *amount,
diff --git a/src/domain/genesis.rs b/src/domain/genesis.rs
@@ -42,10 +42,9 @@ pub(super) fn build_genesis_block(
pub(super) fn utxos_after_genesis(
genesis_allocations: &BTreeMap<String, Amount>,
genesis: &Block,
- chain_id: &str,
) -> Result<BTreeMap<OutPoint, TxOutput>> {
- let mut utxos = genesis_allocation_utxos(genesis_allocations, chain_id);
- let signing_domain = TransactionSigningDomain::new(chain_id, genesis.hash.clone());
+ let mut utxos = genesis_allocation_utxos(genesis_allocations);
+ let signing_domain = TransactionSigningDomain::legacy();
for transaction in &genesis.transactions {
match transaction {
Transaction::Burn { .. } => {
@@ -63,14 +62,13 @@ pub(super) fn utxos_after_genesis(
fn genesis_allocation_utxos(
genesis_allocations: &BTreeMap<String, Amount>,
- chain_id: &str,
) -> BTreeMap<OutPoint, TxOutput> {
genesis_allocations
.iter()
.filter(|(_, amount)| **amount > 0)
.map(|(address, amount)| {
(
- genesis_allocation_outpoint(chain_id, address),
+ genesis_allocation_outpoint(address),
TxOutput {
address: address.clone(),
amount: *amount,
@@ -91,14 +89,9 @@ pub(super) fn balances_from_utxos(
balances
}
-pub(crate) fn genesis_allocation_outpoint(chain_id: &str, address: &str) -> OutPoint {
- let mut payload = b"IUNA-GENESIS-ALLOCATION".to_vec();
- payload.extend_from_slice(&(chain_id.len() as u64).to_be_bytes());
- payload.extend_from_slice(chain_id.as_bytes());
- payload.extend_from_slice(&(address.len() as u64).to_be_bytes());
- payload.extend_from_slice(address.as_bytes());
+pub(crate) fn genesis_allocation_outpoint(address: &str) -> OutPoint {
OutPoint {
- txid: hex_hash(payload),
+ txid: hex_hash(format!("iuna-genesis-allocation:{address}")),
index: 0,
}
}
@@ -174,17 +167,10 @@ mod tests {
let bob = Wallet::from_seed("genesis-outpoint-bob");
assert_ne!(
- genesis_allocation_outpoint("chain-a", alice.address()),
- genesis_allocation_outpoint("chain-a", bob.address())
- );
- assert_ne!(
- genesis_allocation_outpoint("chain-a", alice.address()),
- genesis_allocation_outpoint("chain-b", alice.address())
- );
- assert_eq!(
- genesis_allocation_outpoint("chain-a", alice.address()).index,
- 0
+ genesis_allocation_outpoint(alice.address()),
+ genesis_allocation_outpoint(bob.address())
);
+ assert_eq!(genesis_allocation_outpoint(alice.address()).index, 0);
}
#[test]
diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs
@@ -74,7 +74,7 @@ impl Ledger {
let mut utxo_lineage = self.utxo_lineage.clone();
let mut lineage_values = self.lineage_values.clone();
let mut lineage_owners = self.lineage_owners.clone();
- let signing_domain = self.transaction_signing_domain();
+ let signing_domain = self.transaction_signing_domain_at(block.height);
let mut signatures = BTreeSet::new();
for tx in &block.transactions {
if !signatures.insert(tx.signature()) {
@@ -111,6 +111,7 @@ impl Ledger {
self.lineage_owners = lineage_owners;
self.tickets = tickets;
self.chain.push(block);
+ let next_signing_domain = self.transaction_signing_domain();
let available = self.utxos.clone();
let pending = std::mem::take(&mut self.pending);
self.pending = pending
@@ -119,6 +120,7 @@ impl Ledger {
!mined_signatures.contains(tx.signature())
&& transaction_inputs_available(tx, &available)
&& self.validate_transaction_terms(tx).is_ok()
+ && tx.verify_signature(&next_signing_domain).is_ok()
})
.collect();
let orphans = std::mem::take(&mut self.orphans);
@@ -127,6 +129,7 @@ impl Ledger {
.filter(|tx| {
!mined_signatures.contains(tx.signature())
&& self.validate_transaction_terms(tx).is_ok()
+ && tx.verify_signature(&next_signing_domain).is_ok()
})
.collect();
self.refresh_pending_pool_byte_counters()?;
diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs
@@ -50,12 +50,7 @@ impl Ledger {
.get(&burn.from)
.copied()
.unwrap_or_default();
- Transaction::genesis_burn_with_allocation(
- burn.from,
- burn.amount,
- allocation,
- &launch_profile.profile_id,
- )
+ Transaction::genesis_burn_with_allocation(burn.from, burn.amount, allocation)
})
.collect::<Result<Vec<_>>>()?;
Self::new_with_genesis_transactions(
@@ -74,8 +69,7 @@ impl Ledger {
) -> Result<Self> {
validate_genesis_allocations(&genesis_allocations)?;
let genesis = build_genesis_block(&genesis_allocations, genesis_transactions);
- let utxos =
- utxos_after_genesis(&genesis_allocations, &genesis, &launch_profile.profile_id)?;
+ let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?;
let tickets = genesis_tickets(&genesis_allocations, &genesis, &launch_profile)?;
let compact_block_context = if genesis_allocations.is_empty() {
CompactBlockContext::default()
@@ -142,8 +136,7 @@ impl Ledger {
if genesis != expected_genesis {
bail!("chain snapshot genesis does not match its allocations and transactions");
}
- let utxos =
- utxos_after_genesis(&genesis_allocations, &genesis, &launch_profile.profile_id)?;
+ let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?;
let compact_block_context =
CompactBlockContext::for_chain(&genesis_allocations, std::slice::from_ref(&genesis))?;
@@ -338,7 +331,8 @@ mod tests {
use super::fork_rewrites_finalized_history;
use crate::domain::{
- FORK_FINALITY_DEPTH, LaunchProfile, Ledger, StratumMineShare, Transaction, Wallet,
+ FORK_FINALITY_DEPTH, LaunchProfile, Ledger, StratumMineShare,
+ TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, Transaction, Wallet,
};
fn profile(profile_id: &str) -> LaunchProfile {
@@ -354,6 +348,10 @@ mod tests {
.unwrap()
}
+ fn set_next_height(ledger: &mut Ledger, next_height: u64) {
+ ledger.chain.last_mut().unwrap().height = next_height.saturating_sub(1);
+ }
+
#[test]
fn forks_may_rewrite_six_blocks_but_not_seven() {
assert_eq!(FORK_FINALITY_DEPTH, 6);
@@ -374,10 +372,12 @@ mod tests {
];
for foreign_chain_id in &chain_ids[1..] {
- let source = ledger_with_profile(allocations.clone(), chain_ids[0]);
+ let mut source = ledger_with_profile(allocations.clone(), chain_ids[0]);
let mut foreign = ledger_with_profile(allocations.clone(), foreign_chain_id);
+ set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
+ set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
assert_eq!(source.genesis_hash(), foreign.genesis_hash());
- assert_ne!(source.utxos, foreign.utxos);
+ assert_eq!(source.utxos, foreign.utxos);
let transfer = source.build_transfer(&alice, bob.address(), 10, 1).unwrap();
let transfer_error = foreign.submit_transaction(transfer).unwrap_err();
@@ -398,6 +398,21 @@ mod tests {
}
#[test]
+ fn legacy_signatures_remain_compatible_before_height_1000() {
+ let alice = Wallet::from_seed("pre-activation-replay-alice");
+ let bob = Wallet::from_seed("pre-activation-replay-bob");
+ let allocations = BTreeMap::from([(alice.address().to_string(), 100)]);
+ let mut source = ledger_with_profile(allocations.clone(), "legacy-chain-a");
+ let mut foreign = ledger_with_profile(allocations, "legacy-chain-b");
+ set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT - 1);
+ set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT - 1);
+
+ let transfer = source.build_transfer(&alice, bob.address(), 10, 1).unwrap();
+
+ assert!(foreign.submit_transaction(transfer).unwrap());
+ }
+
+ #[test]
fn signatures_cannot_replay_between_distinct_genesis_hashes() {
let alice = Wallet::from_seed("genesis-replay-alice");
let bob = Wallet::from_seed("genesis-replay-bob");
@@ -406,8 +421,10 @@ mod tests {
(alice.address().to_string(), 100),
(bob.address().to_string(), 1),
]);
- let source = ledger_with_profile(base_allocations, "same-chain-id");
+ let mut source = ledger_with_profile(base_allocations, "same-chain-id");
let mut foreign = ledger_with_profile(other_allocations, "same-chain-id");
+ set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
+ set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
assert_ne!(source.genesis_hash(), foreign.genesis_hash());
let transfer = source.build_transfer(&alice, bob.address(), 10, 1).unwrap();
@@ -424,8 +441,10 @@ mod tests {
fn mine_proofs_cannot_replay_between_chain_ids() {
let miner = Wallet::from_seed("mine-replay-miner");
let allocations = BTreeMap::from([(miner.address().to_string(), 100)]);
- let source = ledger_with_profile(allocations.clone(), "mine-chain-a");
+ let mut source = ledger_with_profile(allocations.clone(), "mine-chain-a");
let mut foreign = ledger_with_profile(allocations, "mine-chain-b");
+ set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
+ set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
assert_eq!(source.genesis_hash(), foreign.genesis_hash());
let mine = source.build_mine(miner.address()).unwrap();
@@ -442,8 +461,10 @@ mod tests {
fn stratum_mine_proofs_cannot_replay_between_chain_ids() {
let miner = Wallet::from_seed("stratum-replay-miner");
let allocations = BTreeMap::from([(miner.address().to_string(), 100)]);
- let source = ledger_with_profile(allocations.clone(), "stratum-chain-a");
+ let mut source = ledger_with_profile(allocations.clone(), "stratum-chain-a");
let mut foreign = ledger_with_profile(allocations, "stratum-chain-b");
+ set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
+ set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
let template = source
.stratum_mine_template(
miner.address(),
@@ -452,15 +473,19 @@ mod tests {
source.current_mine_difficulty_bits(),
)
.unwrap();
- let mine = source
- .build_stratum_mine(
- template,
- StratumMineShare {
- extranonce2: [0; 4],
- header_nonce: [0; 4],
- },
- )
- .unwrap();
+ let mine = (0..u32::MAX)
+ .find_map(|nonce| {
+ source
+ .build_stratum_mine(
+ template.clone(),
+ StratumMineShare {
+ extranonce2: [0; 4],
+ header_nonce: nonce.to_le_bytes(),
+ },
+ )
+ .ok()
+ })
+ .expect("test difficulty must yield a Stratum share");
let error = foreign.submit_transaction(mine).unwrap_err();
@@ -471,10 +496,11 @@ mod tests {
fn transaction_hex_casing_cannot_be_malleated_after_signing() {
let alice = Wallet::from_seed("hex-malleability-alice");
let bob = Wallet::from_seed("hex-malleability-bob");
- let ledger = ledger_with_profile(
+ let mut ledger = ledger_with_profile(
BTreeMap::from([(alice.address().to_string(), 100)]),
"hex-malleability-chain",
);
+ set_next_height(&mut ledger, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
let transaction = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap();
let reject = |mutated| {
diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs
@@ -403,9 +403,17 @@ impl Ledger {
}
pub(super) fn transaction_signing_domain(&self) -> super::TransactionSigningDomain {
- super::TransactionSigningDomain::new(
+ self.transaction_signing_domain_at(self.height().saturating_add(1))
+ }
+
+ pub(super) fn transaction_signing_domain_at(
+ &self,
+ height: u64,
+ ) -> super::TransactionSigningDomain {
+ super::TransactionSigningDomain::for_height(
self.launch_profile.profile_id.clone(),
self.genesis_hash().to_string(),
+ height,
)
}
diff --git a/src/domain/mining.rs b/src/domain/mining.rs
@@ -20,14 +20,24 @@ pub(super) fn mine_signature(
nonce: u64,
difficulty_bits: u32,
) -> Result<String> {
- Ok(hex_hash(mine_signing_bytes(
- domain,
- recipient,
- anchor,
- salt,
- nonce,
- difficulty_bits,
- )?))
+ if domain.is_chain_bound() {
+ Ok(hex_hash(mine_signing_bytes(
+ domain,
+ recipient,
+ anchor,
+ salt,
+ nonce,
+ difficulty_bits,
+ )?))
+ } else {
+ Ok(hex_hash(mine_payload(
+ recipient,
+ anchor,
+ salt,
+ nonce,
+ difficulty_bits,
+ )))
+ }
}
#[cfg(test)]
@@ -55,4 +65,20 @@ mod tests {
.unwrap()
);
}
+
+ #[test]
+ fn legacy_mine_signature_keeps_the_original_payload_hash() {
+ assert_eq!(
+ mine_signature(
+ &TransactionSigningDomain::legacy(),
+ "recipient",
+ "anchor",
+ 1,
+ 2,
+ 12,
+ )
+ .unwrap(),
+ "47f9ad353685fdb9b4932cefa9dd1d27f8af70e27eaedf15c4f9ffbbb64300a3"
+ );
+ }
}
diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs
@@ -17,6 +17,7 @@ pub const BURN_COMMITTEE_SIZE: usize = 5;
pub const MAX_BURN_BUNDLE_BYTES: usize = 10_000;
pub const BURN_LINEAGE_MATURITY_HEIGHTS: u64 = 20;
pub const GRINDING_RESISTANCE_ACTIVATION_HEIGHT: u64 = 1_000;
+pub const TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT: u64 = 1_000;
pub const MAX_PENDING_TRANSACTIONS: usize = 10_000;
pub(super) const MAX_PENDING_POOL_BYTES: usize = 8 * 1024 * 1024;
@@ -63,6 +64,7 @@ mod tests {
assert_eq!(MAX_BURN_BUNDLE_BYTES, 10_000);
assert_eq!(BURN_LINEAGE_MATURITY_HEIGHTS, 20);
assert_eq!(GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 1_000);
+ assert_eq!(TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, 1_000);
assert_eq!(MAX_PENDING_TRANSACTIONS, 10_000);
assert_eq!(MAX_PENDING_POOL_BYTES, 8 * 1024 * 1024);
assert_eq!(MAX_ORPHAN_TRANSACTIONS, 1_024);
diff --git a/src/domain/stratum.rs b/src/domain/stratum.rs
@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use super::{
- HASH_BYTES, TransactionSigningDomain, hex_encode, mine_signing_bytes,
+ HASH_BYTES, TransactionSigningDomain, decode_hex_array, hex_encode, mine_signing_bytes,
validation::{decode_canonical_hex_array, validate_address, validate_hash},
};
@@ -58,7 +58,14 @@ fn stratum_coinbase_prefix(
salt: u64,
difficulty_bits: u32,
) -> Result<Vec<u8>> {
- mine_signing_bytes(domain, recipient, anchor, salt, 0, difficulty_bits)
+ if domain.is_chain_bound() {
+ mine_signing_bytes(domain, recipient, anchor, salt, 0, difficulty_bits)
+ } else {
+ Ok(
+ format!("iuna-stratum-mine:{recipient}:{anchor}:{salt}:{difficulty_bits}:")
+ .into_bytes(),
+ )
+ }
}
fn stratum_coinbase_bytes(
@@ -92,8 +99,12 @@ pub(super) fn stratum_mine_header_bytes(
) -> Result<[u8; 80]> {
let mut header = [0_u8; STRATUM_MINE_HEADER_BYTES];
header[0..4].copy_from_slice(&STRATUM_MINE_VERSION);
- let anchor_bytes = decode_canonical_hex_array::<HASH_BYTES>(anchor)
- .context("mine transaction anchor is not hex")?;
+ let anchor_bytes = if domain.is_chain_bound() {
+ decode_canonical_hex_array::<HASH_BYTES>(anchor)
+ } else {
+ decode_hex_array::<HASH_BYTES>(anchor)
+ }
+ .context("mine transaction anchor is not hex")?;
header[4..36].copy_from_slice(&anchor_bytes);
let merkle_root = double_sha256(&stratum_coinbase_bytes(
domain,
@@ -127,8 +138,12 @@ pub(super) fn stratum_mine_template(
let recipient = recipient.into();
validate_address(&recipient, "mine recipient")?;
validate_hash(anchor, "mine transaction anchor")?;
- let anchor_bytes = decode_canonical_hex_array::<HASH_BYTES>(anchor)
- .context("mine transaction anchor is not hex")?;
+ let anchor_bytes = if domain.is_chain_bound() {
+ decode_canonical_hex_array::<HASH_BYTES>(anchor)
+ } else {
+ decode_hex_array::<HASH_BYTES>(anchor)
+ }
+ .context("mine transaction anchor is not hex")?;
Ok(StratumMineTemplate {
recipient: recipient.clone(),
anchor: anchor.to_string(),
diff --git a/src/domain/transaction.rs b/src/domain/transaction.rs
@@ -7,9 +7,9 @@ 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, genesis_allocation_outpoint, hash_meets_difficulty,
- hex_encode, hex_hash, mine_payload, mine_signature, stratum_mine_header_bytes,
- stratum_mine_signature,
+ 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,
};
pub const TRANSACTION_SIGNING_FORMAT_VERSION: u16 = 1;
@@ -18,6 +18,7 @@ pub const TRANSACTION_SIGNING_FORMAT_VERSION: u16 = 1;
pub(super) struct TransactionSigningDomain {
chain_id: String,
genesis_hash: String,
+ chain_bound: bool,
}
impl TransactionSigningDomain {
@@ -25,10 +26,38 @@ impl TransactionSigningDomain {
Self {
chain_id: chain_id.into(),
genesis_hash: genesis_hash.into(),
+ chain_bound: true,
}
}
+ pub(super) fn legacy() -> Self {
+ Self {
+ chain_id: String::new(),
+ genesis_hash: String::new(),
+ chain_bound: false,
+ }
+ }
+
+ pub(super) fn for_height(
+ chain_id: impl Into<String>,
+ genesis_hash: impl Into<String>,
+ height: u64,
+ ) -> Self {
+ if height >= super::TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT {
+ Self::new(chain_id, genesis_hash)
+ } else {
+ Self::legacy()
+ }
+ }
+
+ pub(super) fn is_chain_bound(&self) -> bool {
+ self.chain_bound
+ }
+
pub(super) fn encode(&self, bytes: &mut Vec<u8>) -> Result<()> {
+ if !self.chain_bound {
+ bail!("legacy transaction signing has no binary chain domain");
+ }
bytes.extend_from_slice(b"IUNA-TX");
bytes.extend_from_slice(&TRANSACTION_SIGNING_FORMAT_VERSION.to_be_bytes());
encode_bytes(bytes, self.chain_id.as_bytes(), "chain ID")?;
@@ -98,19 +127,13 @@ pub struct MineSearchOutcome {
impl Transaction {
pub fn genesis_burn(from: impl Into<String>, amount: Amount) -> Self {
let from = from.into();
- Self::genesis_burn_with_change(
- from,
- amount,
- Vec::new(),
- &super::LaunchProfile::default().profile_id,
- )
+ Self::genesis_burn_with_change(from, amount, Vec::new())
}
pub(super) fn genesis_burn_with_allocation(
from: impl Into<String>,
amount: Amount,
allocation: Amount,
- chain_id: &str,
) -> Result<Self> {
if amount > allocation {
bail!("genesis burn exceeds allocation");
@@ -125,19 +148,12 @@ impl Transaction {
} else {
Vec::new()
};
- Ok(Self::genesis_burn_with_change(
- from, amount, change, chain_id,
- ))
+ Ok(Self::genesis_burn_with_change(from, amount, change))
}
- fn genesis_burn_with_change(
- from: String,
- amount: Amount,
- change: Vec<TxOutput>,
- chain_id: &str,
- ) -> Self {
+ fn genesis_burn_with_change(from: String, amount: Amount, change: Vec<TxOutput>) -> Self {
let input = TxInput {
- outpoint: genesis_allocation_outpoint(chain_id, &from),
+ outpoint: genesis_allocation_outpoint(&from),
owner: from.clone(),
signature: "genesis".to_string(),
};
@@ -310,15 +326,28 @@ impl Transaction {
bail!("transaction input signature does not match transaction signature");
}
let sender = self.sender();
- let public_key = decode_canonical_hex_array::<PUBLIC_KEY_BYTES>(sender)
- .with_context(|| format!("invalid public key for {sender}"))?;
- let signature = decode_canonical_hex_array::<SIGNATURE_BYTES>(self.signature())
- .context("invalid signature hex")?;
+ let public_key = if domain.is_chain_bound() {
+ decode_canonical_hex_array::<PUBLIC_KEY_BYTES>(sender)
+ } else {
+ decode_hex_array::<PUBLIC_KEY_BYTES>(sender)
+ }
+ .with_context(|| format!("invalid public key for {sender}"))?;
+ let signature = if domain.is_chain_bound() {
+ decode_canonical_hex_array::<SIGNATURE_BYTES>(self.signature())
+ } else {
+ decode_hex_array::<SIGNATURE_BYTES>(self.signature())
+ }
+ .context("invalid signature hex")?;
let verifying_key =
VerifyingKey::from_bytes(&public_key).context("invalid transaction public key")?;
let signature = Signature::from_bytes(&signature);
+ let signing_bytes = if domain.is_chain_bound() {
+ self.signing_bytes(domain)?
+ } else {
+ self.signing_payload().into_bytes()
+ };
verifying_key
- .verify(&self.signing_bytes(domain)?, &signature)
+ .verify(&signing_bytes, &signature)
.context("transaction signature is invalid")
}
@@ -419,7 +448,11 @@ impl UnsignedUtxoTransaction {
wallet: &Wallet,
domain: &TransactionSigningDomain,
) -> Result<Transaction> {
- let signature = wallet.sign_bytes(&self.signing_bytes(domain)?);
+ let signature = if domain.is_chain_bound() {
+ wallet.sign_bytes(&self.signing_bytes(domain)?)
+ } else {
+ wallet.sign_payload(&self.canonical())
+ };
let signed_inputs = self
.inputs()
.iter()
@@ -633,6 +666,23 @@ mod tests {
use super::*;
#[test]
+ fn signing_domain_switches_exactly_at_height_1000() {
+ let before = TransactionSigningDomain::for_height(
+ "activation-chain",
+ "11".repeat(32),
+ crate::domain::TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT - 1,
+ );
+ let activated = TransactionSigningDomain::for_height(
+ "activation-chain",
+ "11".repeat(32),
+ crate::domain::TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT,
+ );
+
+ assert!(!before.is_chain_bound());
+ assert!(activated.is_chain_bound());
+ }
+
+ #[test]
fn signing_format_v1_has_a_stable_typed_binary_vector() {
let transaction = UnsignedUtxoTransaction::Transfer {
inputs: vec![UnsignedTxInput {
@@ -714,5 +764,8 @@ mod tests {
let domain = TransactionSigningDomain::new("iuna-mainnet-v1", "22".repeat(32));
assert!(transaction.verify_signature(&domain).is_err());
+ transaction
+ .verify_signature(&TransactionSigningDomain::legacy())
+ .unwrap();
}
}
diff --git a/src/domain/validation.rs b/src/domain/validation.rs
@@ -2,34 +2,33 @@ use anyhow::{Context, Result, bail};
use super::{
HASH_BYTES, PUBLIC_KEY_BYTES, SIGNATURE_BYTES, Transaction, TxInput, TxOutput, decode_hex,
- hex_encode, stratum::STRATUM_MINE_HEADER_BYTES,
+ decode_hex_array, hex_encode, stratum::STRATUM_MINE_HEADER_BYTES,
};
pub fn validate_address(address: &str, label: &str) -> Result<()> {
- decode_canonical_hex_array::<PUBLIC_KEY_BYTES>(address)
+ decode_hex_array::<PUBLIC_KEY_BYTES>(address)
.with_context(|| format!("invalid {label} address"))?;
Ok(())
}
pub(super) fn validate_hash(hash: &str, label: &str) -> Result<()> {
- decode_canonical_hex_array::<HASH_BYTES>(hash).with_context(|| format!("invalid {label}"))?;
+ decode_hex_array::<HASH_BYTES>(hash).with_context(|| format!("invalid {label}"))?;
Ok(())
}
pub(super) fn validate_signature(signature: &str, label: &str) -> Result<()> {
- decode_canonical_hex_array::<SIGNATURE_BYTES>(signature)
- .with_context(|| format!("invalid {label}"))?;
+ decode_hex_array::<SIGNATURE_BYTES>(signature).with_context(|| format!("invalid {label}"))?;
Ok(())
}
pub(super) fn validate_stratum_header(header: &str) -> Result<()> {
- decode_canonical_hex_array::<STRATUM_MINE_HEADER_BYTES>(header)
+ decode_hex_array::<STRATUM_MINE_HEADER_BYTES>(header)
.context("invalid mine transaction proof header")?;
Ok(())
}
pub(super) fn validate_protocol_id(value: &str, label: &str) -> Result<()> {
- let bytes = decode_canonical_hex(value).with_context(|| format!("invalid {label}"))?;
+ let bytes = decode_hex(value).with_context(|| format!("invalid {label}"))?;
match bytes.len() {
HASH_BYTES | SIGNATURE_BYTES => Ok(()),
length => bail!("invalid {label}: expected 32 or 64 bytes, got {length}"),
@@ -190,12 +189,12 @@ mod tests {
}
#[test]
- fn validators_reject_noncanonical_uppercase_hex() {
- assert!(validate_address(&"AB".repeat(32), "test").is_err());
- assert!(validate_hash(&"AB".repeat(32), "test").is_err());
- assert!(validate_signature(&"AB".repeat(64), "test").is_err());
- assert!(validate_stratum_header(&"AB".repeat(80)).is_err());
- assert!(validate_protocol_id(&"AB".repeat(32), "test").is_err());
+ fn validators_keep_accepting_legacy_uppercase_hex() {
+ assert!(validate_address(&"AB".repeat(32), "test").is_ok());
+ assert!(validate_hash(&"AB".repeat(32), "test").is_ok());
+ assert!(validate_signature(&"AB".repeat(64), "test").is_ok());
+ assert!(validate_stratum_header(&"AB".repeat(80)).is_ok());
+ assert!(validate_protocol_id(&"AB".repeat(32), "test").is_ok());
}
#[test]