commit c55bb70ba47377a2763cb1bffdf78c2ad515f73b
parent 7c7b705c654fcf02692c3dc0cc486949e1a54bed
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Tue, 15 Sep 2026 06:07:54 +0200
feat(protocol): prepare height 3000 quantum migration
Diffstat:
15 files changed, 1064 insertions(+), 92 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -11,6 +11,10 @@ from the Git history and Conventional Commit titles by `deployment.sh`.
- add dormant transaction-v2 encoding and activation gating
- verify both components of dormant hybrid transaction authorizations
- add pinned ML-DSA audit vectors and transaction-v2 fuzz coverage
+- define the independent-review scope and activation blockers for the quantum migration
+- derive dormant hybrid wallet keys and addresses from existing seed-phrase backups
+- set candidate-mainnet transaction-v2 activation height to 3000
+- build explicit v2 migrations from legacy UTXOs into a hybrid wallet output
## [0.4.32] - 2026-09-13
diff --git a/Cargo.lock b/Cargo.lock
@@ -689,6 +689,7 @@ checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"ctutils",
"typenum",
+ "zeroize",
]
[[package]]
@@ -1054,6 +1055,7 @@ dependencies = [
"pkcs8 0.11.0",
"shake",
"signature 3.0.0",
+ "zeroize",
]
[[package]]
@@ -1065,6 +1067,7 @@ dependencies = [
"ctutils",
"hybrid-array",
"num-traits",
+ "zeroize",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
@@ -23,7 +23,7 @@ sha2 = "0.10.9"
rusqlite = { version = "0.32.1", features = ["bundled"] }
tokio = { version = "1.45.1", features = ["full"] }
kyn-vdf = "=0.1.1"
-ml-dsa = "=0.1.1"
+ml-dsa = { version = "=0.1.1", features = ["zeroize"] }
secrecy = { version = "0.10.3", default-features = false, features = ["serde"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"], optional = true }
semver = { version = "1", optional = true }
diff --git a/README.md b/README.md
@@ -337,6 +337,7 @@ peers.
- [Protocol](docs/protocol.md)
- [Quantum-resistance migration](docs/quantum-migration.md)
+- [Quantum migration audit scope](docs/quantum-audit-scope.md)
- [Operator failure playbooks](docs/operator-playbooks.md)
## Contributing
diff --git a/docs/quantum-audit-scope.md b/docs/quantum-audit-scope.md
@@ -0,0 +1,167 @@
+# Quantum migration audit scope
+
+## Purpose and decision boundary
+
+This document defines the review package for Iuna's post-quantum migration. The current code
+reserves versioned addresses and transaction encodings, can verify hybrid Ed25519 + ML-DSA-44
+authorizations, and records height 3000 as the candidate activation target. Live consensus,
+blocks, and gossip do not yet accept or produce v2 transactions.
+
+An audit of this scope may approve shipping dormant code for continued testing. It must not be
+interpreted as approval to activate transaction v2. Activation requires a separate review of the
+final integration commit and the height-3000 migration rehearsal described in
+`quantum-migration.md`.
+
+## Security claims
+
+The candidate design intends to provide the following properties after a separately reviewed
+activation:
+
+1. Spending an address-v1 output requires valid Ed25519 and ML-DSA-44 signatures over exactly the
+ same canonical payload.
+2. An address-v1 output commits to the scheme identifier, component lengths, and exact public-key
+ encodings without publishing those keys before the output is spent.
+3. A transaction-v2 signature cannot be replayed across chain IDs, genesis blocks, transaction
+ kinds, inputs, outputs, amounts, fees, or authorization order.
+4. Unknown versions and signature schemes fail closed.
+5. Transaction identity is a fixed-size domain-separated hash of the complete canonical
+ transaction, not a signature value.
+6. Malformed lengths, counts, and encodings are rejected before attacker-controlled allocation or
+ cryptographic work becomes unbounded.
+7. Version-0 historical data and transactions remain valid under their existing rules.
+8. An explicit transaction-v2 migration may reference a tagged 32-byte legacy hash or 64-byte
+ legacy signature ID and spend its version-0 output with the existing Ed25519 key. Ordinary v2
+ transactions use 32-byte hash IDs, and the resulting version-1 output cannot be spent without
+ both signature components.
+
+The repository does not currently claim that wallet transactions, consensus identities, the VDF,
+P2P identity, TLS, or software updates are post-quantum secure.
+
+## Threat model
+
+The review should consider:
+
+- a remote unauthenticated peer supplying arbitrary transaction-v2 bytes;
+- a malicious spender choosing related Ed25519 and ML-DSA keys, signatures, and encodings;
+- replay across networks, genesis blocks, transaction kinds, inputs, and activation boundaries;
+- parser differentials between mempool, block, snapshot, JSON, and compact encodings;
+- denial of service through large counts, lengths, signature verification, or repeated invalid
+ signatures;
+- compromise of either the classical or post-quantum algorithm, but not both simultaneously;
+- implementation or supply-chain defects in the pinned ML-DSA backend;
+- a future cryptographically relevant quantum computer attacking public keys already visible on
+ chain;
+- rollback, partial deployment, and partitions involving nodes that do not understand the new
+ protocol.
+
+Compromise of both signature components, endpoint compromise while signing, malicious release
+binaries, and recovery of value whose owner has lost all signing material remain out of scope for
+the transaction-v2 cryptographic claim.
+
+## Review targets
+
+The minimum code-review scope is:
+
+- `src/domain/signature.rs`: scheme identifiers, exact lengths, backend decoding, and verification;
+- `src/domain/address.rs`: version retention, Bech32m parsing, and key commitments;
+- `src/domain/transaction_v2.rs`: canonical encoding, parsing, domain separation, transaction IDs,
+ authorization binding, resource limits, and the fixed activation gate;
+- `src/domain.rs`: public boundaries and fuzz-only exposure;
+- `tests/vectors/`: pinned NIST and Wycheproof provenance and expected verdicts;
+- `fuzz/fuzz_targets/transaction_v2.rs`: decoder and verifier coverage;
+- `deployment.sh`: the release fuzzing gate and retained evidence;
+- all future call sites that connect transaction v2 to wallets, mempool, gossip, blocks, compact
+ storage, fees, or fork validation.
+
+The exact review commit and all three Cargo lockfile hashes must be recorded when an engagement
+starts. Any subsequent change to the files above invalidates approval until the auditor assesses
+the delta. From a clean review checkout, `scripts/quantum-audit-manifest.sh --output
+quantum-audit-manifest.json` records these identifiers, tool versions, and upstream vector
+provenance in one machine-readable file.
+
+## Required invariants and negative tests
+
+An auditor should independently confirm at least these cases:
+
+- accepting either signature alone is impossible;
+- a version-0 input accepts only its matching Ed25519 authorization, while a version-1 input
+ accepts only its matching hybrid authorization;
+- swapping either public-key or signature component fails;
+- signatures over different chain IDs or genesis hashes fail;
+- reordering inputs, outputs, or authorizations fails or produces the uniquely specified payload;
+- duplicate inputs and authorization-count mismatches are rejected by the eventual consensus call
+ site;
+- non-canonical and trailing encodings fail rather than normalize;
+- address commitments cannot be ambiguous across schemes or component boundaries;
+- transaction IDs change when any authorization byte changes;
+- maximum counts and lengths cannot overflow size accounting or cause excessive allocation;
+- transaction v2 remains rejected through height 2999 and becomes eligible at height 3000;
+- 0.4.30-shaped handshakes ignore optional capabilities and are never sent unsupported v2 data.
+
+## Techniques adopted and rejected
+
+Iuna adopts the key-hiding and versioned-output pattern proposed by Bitcoin P2QRH, but does not
+depend on that proposal's deployment or exact script design. It adopts hybrid signatures for the
+migration interval and a staged read-before-activation rollout.
+
+XMSS, used by QRL and standardized for restricted use by NIST SP 800-208, is not selected for
+ordinary wallets because safe signing depends on durable one-time-signature state across backups
+and devices. Stateless SLH-DSA remains a possible emergency recovery scheme, but would receive a
+new scheme identifier and a separate size, fee, and implementation review.
+
+Algorand-style post-quantum state proofs motivate a later checkpoint phase, but Iuna must first
+specify who owns post-quantum checkpoint keys and how signer authority follows consensus. A
+maintainer-signed snapshot is useful release evidence, but is not a decentralized finality proof
+and must never be presented as one.
+
+## Reproduction
+
+From the repository root, reviewers should run:
+
+```sh
+cargo test --all-targets --all-features --locked
+cargo clippy --all-targets --all-features -- -D warnings
+cargo build --locked --manifest-path fuzz/Cargo.toml --bins
+cargo test ml_dsa44_matches_pinned_nist_and_wycheproof_vectors --lib
+cargo +nightly fuzz run transaction_v2 -- -max_total_time=300 -timeout=10
+```
+
+The curated vector set is a regression suite, not a substitute for running complete upstream
+corpora or reviewing the cryptographic backend. Reviewers should verify the upstream file hashes
+recorded in `tests/vectors/ml_dsa44_audit.json` and retain tool versions, corpus, coverage data, and
+crash artifacts with their report.
+
+## Activation blockers
+
+Transaction v2 must remain dormant until all of the following are resolved:
+
+- the cryptographic backend and Iuna integration are independently reviewed;
+- the final consensus call sites and byte-based fee accounting exist and are reviewed;
+- wallet backup compatibility, migration, and no-address-reuse behavior are implemented and
+ reviewed; deterministic hybrid key generation already exists but is not yet exposed in the UI;
+- migration progress is observable without exposing wallet secrets;
+- P2P capability negotiation, restored old-node behavior, and activation-boundary recovery are
+ rehearsed on the mainnet-candidate network;
+- post-quantum plans exist for peer identity and update signing;
+- checkpoint signer authority is specified before any PQ checkpoint format is trusted;
+- the VDF has a separate quantum threat analysis;
+- an activation abort procedure exists before a release can reach activation height 3000.
+
+## Expected audit deliverables
+
+The engagement should produce a public report containing the reviewed commit, scope exclusions,
+toolchain and dependency versions, findings with severity and exploit prerequisites, test evidence,
+and an explicit verdict for dormant shipping versus mainnet activation. Fixes must be reviewed as a
+documented delta rather than assumed resolved by the project team.
+
+## Primary references
+
+- NIST FIPS 204, Module-Lattice-Based Digital Signature Standard:
+ <https://csrc.nist.gov/pubs/fips/204/final>
+- NIST FIPS 205, Stateless Hash-Based Digital Signature Standard:
+ <https://csrc.nist.gov/pubs/fips/205/final>
+- NIST SP 800-208, stateful hash-based signature recommendations:
+ <https://csrc.nist.gov/pubs/sp/800/208/final>
+- RFC 8391, XMSS: <https://www.rfc-editor.org/rfc/rfc8391>
+- Bitcoin BIP 360, Pay to Quantum Resistant Hash: <https://bips.dev/360/>
+- Algorand State Proofs: <https://developer.algorand.org/docs/get-details/stateproofs/>
diff --git a/docs/quantum-migration.md b/docs/quantum-migration.md
@@ -87,29 +87,37 @@ Application, transport, and consensus versions move independently:
1. A protocol-v2 application release advertises read capabilities in the optional `capabilities`
field. Old nodes ignore the field and an omitted field means no advertised capabilities.
-2. A later application release ships dormant transaction-v2 and hybrid verification code. It does
- not choose an activation height.
-3. Only after deployment coverage is measured does another release announce a future activation
- height and protocol-v3 transition. The feature must not be introduced and activated in the same
- release.
+2. A later application release ships dormant transaction-v2 and hybrid verification code.
+3. After deployment coverage is measured, the complete integration release announces candidate
+ height 3000 as the protocol-v3 transition. The feature must not be introduced and activated in
+ the same release.
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.
+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
+available, that candidate network may begin value migration at the fixed, reviewed activation
+height 3000. This does not turn activation into a runtime flag: nodes restored from old backups
+must still deterministically reach the same rule at the same height.
+
Capability names are sorted, unique, lowercase ASCII tokens. A hello may advertise at most 16
tokens of at most 64 bytes each. These limits are enforced before the handshake is accepted.
-### Dormant transaction-v2 implementation
+### Transaction-v2 activation target
The transaction-v2 binary envelope and its canonical hash identifier are compiled into the node,
-but remain separate from the live JSON `Transaction`, `Block`, and gossip types. The consensus
-activation constant is `None`: it is not an operator-controlled feature flag and cannot be enabled
-through configuration. Nodes may parse and inspect the reserved format, but must reject it from
-the mempool and chain until a later reviewed release assigns an activation height.
+but remain separate from the live JSON `Transaction`, `Block`, and gossip types. Candidate height
+3000 is compiled in as the consensus activation target; it is not an operator-controlled feature
+flag and cannot be changed through configuration. Nodes must continue rejecting v2 from the live
+mempool and chain until the complete integration routes every acceptance path through that gate.
The reserved format binds the chain ID and genesis hash, uses typed versioned addresses, stores one
length-delimited authorization per spending input, and hashes the complete canonical signed bytes
-for its transaction ID. The initial spending authorization is Ed25519 + ML-DSA-44. Dormant
-verification uses the exact-pinned RustCrypto `ml-dsa` 0.1.1 implementation. That implementation
+for its transaction ID. An explicit migration transaction tags and references legacy 32-byte hash
+or 64-byte signature transaction IDs and retains Ed25519 authorization so existing value can move
+to a version-1 output. Ordinary v2 transactions use 32-byte hash IDs; every later spend of a
+version-1 output requires Ed25519 + ML-DSA-44.
+Verification uses the exact-pinned RustCrypto `ml-dsa` 0.1.1 implementation. That implementation
has not been independently audited, so an independent review and an explicit backend acceptance
decision remain prerequisites before activation. No transaction-v2 gossip capability is
advertised yet.
@@ -121,6 +129,18 @@ both the transaction-v2 decoder and arbitrary ML-DSA-44 verification inputs; it
release's time-bounded, coverage-guided `cargo fuzz` gate while transaction v2 is dormant. Seed
corpora, newly discovered coverage inputs, and crash artifacts are retained as release evidence.
+### Dormant hybrid wallet keys
+
+The existing wallet seed phrase now deterministically derives a separate ML-DSA-44 seed using the
+fixed `iuna-wallet-ml-dsa44-seed-v1` domain. The original Ed25519 derivation is unchanged, so
+existing addresses, encrypted wallet files, and backups remain valid. The wallet can construct an
+address-v1 commitment and create an Ed25519 + ML-DSA-44 authorization over one byte-identical
+payload. ML-DSA secret intermediates use the backend's zeroization support.
+
+This key capability alone does not create spendable address-v1 outputs. The wallet UI must not
+offer the address until transaction-v2 submission, mempool, block, gossip, persistence, and fee
+accounting are connected and activated together on the candidate network.
+
## Other trust boundaries
- P2P node IDs need versioned, algorithm-tagged proofs independent of wallet activation.
diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock
@@ -568,6 +568,7 @@ checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"ctutils",
"typenum",
+ "zeroize",
]
[[package]]
@@ -773,6 +774,7 @@ dependencies = [
"pkcs8 0.11.0",
"shake",
"signature 3.0.0",
+ "zeroize",
]
[[package]]
@@ -784,6 +786,7 @@ dependencies = [
"ctutils",
"hybrid-array",
"num-traits",
+ "zeroize",
]
[[package]]
diff --git a/fuzz/fuzz_targets/transaction_v2.rs b/fuzz/fuzz_targets/transaction_v2.rs
@@ -22,7 +22,7 @@ fuzz_target!(|data: &[u8]| {
fn exercise(data: &[u8]) {
if let Ok((domain, transaction)) = TransactionV2::decode(data) {
- let _ = transaction.verify_hybrid_authorizations(&domain);
+ let _ = transaction.verify_authorizations(&domain);
let _ = transaction.encode(&domain);
}
diff --git a/scripts/quantum-audit-manifest.sh b/scripts/quantum-audit-manifest.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+
+usage() {
+ echo "usage: scripts/quantum-audit-manifest.sh [--output MANIFEST.json]" >&2
+ exit 2
+}
+
+output=""
+if [ "$#" -gt 0 ]; then
+ [ "$#" -eq 2 ] && [ "$1" = "--output" ] || usage
+ output="$2"
+ [ -n "$output" ] || usage
+ if [ -e "$output" ]; then
+ echo "error: refusing to overwrite existing manifest: ${output}" >&2
+ exit 1
+ fi
+fi
+
+for command_name in cargo git jq rustc rustup; do
+ command -v "$command_name" >/dev/null 2>&1 || {
+ echo "error: missing required command: ${command_name}" >&2
+ exit 1
+ }
+done
+
+if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then
+ echo "error: refusing to describe a dirty worktree; commit or stash every change first" >&2
+ exit 1
+fi
+
+sha256_file() {
+ local path="$1"
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$path" | awk '{print $1}'
+ else
+ shasum -a 256 "$path" | awk '{print $1}'
+ fi
+}
+
+root_lock_sha256="$(sha256_file Cargo.lock)"
+fuzz_lock_sha256="$(sha256_file fuzz/Cargo.lock)"
+tauri_lock_sha256="$(sha256_file src-tauri/Cargo.lock)"
+git_commit="$(git rev-parse HEAD)"
+rustc_version="$(rustc --version)"
+cargo_version="$(cargo --version)"
+nightly_version="$(rustup run nightly rustc --version)"
+cargo_fuzz_version="$(cargo fuzz --version)"
+captured_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+
+manifest="$(
+ jq -n \
+ --arg captured_at "$captured_at" \
+ --arg git_commit "$git_commit" \
+ --arg rustc "$rustc_version" \
+ --arg cargo "$cargo_version" \
+ --arg nightly_rustc "$nightly_version" \
+ --arg cargo_fuzz "$cargo_fuzz_version" \
+ --arg root_lock "$root_lock_sha256" \
+ --arg fuzz_lock "$fuzz_lock_sha256" \
+ --arg tauri_lock "$tauri_lock_sha256" \
+ --slurpfile vectors tests/vectors/ml_dsa44_audit.json \
+ '{
+ format: 1,
+ captured_at: $captured_at,
+ git: {commit: $git_commit, tree_state: "clean"},
+ toolchain: {
+ rustc: $rustc,
+ cargo: $cargo,
+ nightly_rustc: $nightly_rustc,
+ cargo_fuzz: $cargo_fuzz
+ },
+ lockfiles_sha256: {
+ "Cargo.lock": $root_lock,
+ "fuzz/Cargo.lock": $fuzz_lock,
+ "src-tauri/Cargo.lock": $tauri_lock
+ },
+ vector_sources: $vectors[0].sources
+ }'
+)"
+
+if [ -n "$output" ]; then
+ printf '%s\n' "$manifest" > "$output"
+ echo "wrote quantum audit manifest to ${output}"
+else
+ printf '%s\n' "$manifest"
+fi
diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs
@@ -711,6 +711,8 @@ mod tests {
use tempfile::tempdir;
+ use crate::domain::AddressNetwork;
+
use super::{
load_or_create, load_with_password, read_wallet_file, replace_with_imported_seed_phrase,
replace_with_imported_seed_phrase_encrypted,
@@ -719,6 +721,24 @@ mod tests {
const TEST_SEED: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
#[test]
+ fn existing_seed_phrase_recovers_the_same_hybrid_key_from_plaintext_and_encrypted_wallets() {
+ let dir = tempdir().unwrap();
+ let plaintext_path = dir.path().join("plaintext-wallet.json");
+ let encrypted_path = dir.path().join("encrypted-wallet.json");
+ let plaintext = replace_with_imported_seed_phrase(&plaintext_path, TEST_SEED).unwrap();
+ replace_with_imported_seed_phrase_encrypted(&encrypted_path, TEST_SEED, "password-123456")
+ .unwrap();
+ let encrypted = load_with_password(&encrypted_path, "password-123456").unwrap();
+
+ assert_eq!(plaintext.address(), encrypted.address());
+ assert_eq!(plaintext.hybrid_public_key(), encrypted.hybrid_public_key());
+ assert_eq!(
+ plaintext.hybrid_address(AddressNetwork::Mainnet),
+ encrypted.hybrid_address(AddressNetwork::Mainnet)
+ );
+ }
+
+ #[test]
fn stale_atomic_temp_file_does_not_replace_saved_wallet() {
let dir = tempdir().unwrap();
let path = dir.path().join("wallet.json");
diff --git a/src/domain.rs b/src/domain.rs
@@ -86,7 +86,8 @@ pub use reveal::{
use selection::BlockSelection;
pub use signature::{ProtocolPublicKey, ProtocolSignature, SignatureScheme};
pub(crate) use signature::{
- ed25519_public_key, sign_ed25519, validate_ed25519_public_key, verify_ed25519, verify_ml_dsa44,
+ ed25519_public_key, ml_dsa44_public_key, sign_ed25519, sign_ml_dsa44,
+ validate_ed25519_public_key, verify_ed25519, verify_ml_dsa44,
};
#[cfg(feature = "fuzzing")]
@@ -110,9 +111,10 @@ pub use transaction::{
};
use transaction::{TransactionSigningDomain, mine_signing_bytes};
pub use transaction_v2::{
- TRANSACTION_V2_ACTIVATION_HEIGHT, TRANSACTION_V2_WIRE_VERSION, TransactionV2,
- TransactionV2Domain, TransactionV2Input, TransactionV2Output, V2SpendingAuthorization,
- ensure_transaction_v2_active, hybrid_key_commitment_address, transaction_v2_is_active,
+ LegacyTransactionId, TRANSACTION_V2_ACTIVATION_HEIGHT, TRANSACTION_V2_WIRE_VERSION,
+ TransactionV2, TransactionV2Domain, TransactionV2Input, TransactionV2LegacyInput,
+ TransactionV2Output, V2SpendingAuthorization, ensure_transaction_v2_active,
+ hybrid_key_commitment_address, transaction_v2_is_active,
};
pub(crate) use validation::minimum_transfer_economic_size_bytes;
pub use validation::validate_address;
diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs
@@ -1,4 +1,4 @@
-use super::hex::hex_encode;
+use super::hex::{decode_hex, decode_hex_array, hex_encode};
use super::mining::mine_signature;
use super::stratum::{
hash_meets_difficulty, stratum_mine_header_bytes, stratum_mine_signature, stratum_mine_template,
@@ -6,12 +6,74 @@ use super::stratum::{
use super::transaction::{UnsignedTxInput, UnsignedUtxoTransaction};
use super::validation::validate_address;
use super::{
- Amount, Ledger, MineSearchOutcome, OutPoint, StratumMineShare, StratumMineTemplate,
- Transaction, TxOutput, Wallet,
+ Amount, Ledger, LegacyTransactionId, MineSearchOutcome, OutPoint, StratumMineShare,
+ StratumMineTemplate, Transaction, TransactionV2, TransactionV2Domain, TransactionV2LegacyInput,
+ TransactionV2Output, TxOutput, Wallet,
};
use anyhow::{Context, Result, bail};
impl Ledger {
+ /// Builds one consolidation transaction from every currently spendable legacy wallet output
+ /// into the wallet's hybrid address. Submission remains subject to the height-3000 gate.
+ pub fn build_v2_migration(&self, wallet: &Wallet, fee: Amount) -> Result<TransactionV2> {
+ let available = self.available_utxos_for_address(wallet.address())?;
+ if available.is_empty() {
+ bail!("no legacy outputs are available for migration");
+ }
+
+ let mut total = 0_u64;
+ let mut inputs = Vec::with_capacity(available.len());
+ for (outpoint, output) in available {
+ total = total
+ .checked_add(output.amount)
+ .context("migration input total overflows")?;
+ inputs.push(TransactionV2LegacyInput {
+ outpoint_id: legacy_transaction_id(&outpoint.txid)?,
+ outpoint_index: outpoint.index,
+ owner: wallet.legacy_versioned_address(),
+ });
+ }
+ let migrated_amount = total
+ .checked_sub(fee)
+ .context("migration fee exceeds available value")?;
+ if migrated_amount == 0 {
+ bail!("migration output must be greater than zero");
+ }
+
+ let domain = TransactionV2Domain::new(
+ self.launch_profile.profile_id.clone(),
+ decode_hex_array::<32>(self.genesis_hash())
+ .context("ledger genesis hash is not a 32-byte hexadecimal value")?,
+ )?;
+ let mut transaction = TransactionV2::Migration {
+ inputs,
+ outputs: vec![TransactionV2Output {
+ address: wallet.hybrid_versioned_address(),
+ amount: migrated_amount,
+ }],
+ fee,
+ authorizations: Vec::new(),
+ };
+ let payload = transaction.signing_bytes(&domain)?;
+ let authorization =
+ wallet.sign_v2_authorization(wallet.legacy_versioned_address(), &payload)?;
+ if let TransactionV2::Migration {
+ inputs,
+ authorizations,
+ ..
+ } = &mut transaction
+ {
+ authorizations.resize(inputs.len(), authorization);
+ }
+ transaction.verify_authorizations(&domain)?;
+ ensure_v2_transaction_within_block_budget(
+ &transaction,
+ &domain,
+ self.launch_profile.max_block_bytes,
+ )?;
+ Ok(transaction)
+ }
+
pub fn build_transfer(
&self,
wallet: &Wallet,
@@ -367,3 +429,99 @@ impl Ledger {
Ok(transaction)
}
}
+
+fn legacy_transaction_id(txid: &str) -> Result<LegacyTransactionId> {
+ let bytes = decode_hex(txid).context("legacy outpoint ID is not hexadecimal")?;
+ match bytes.len() {
+ 32 => Ok(LegacyTransactionId::Hash(
+ bytes.try_into().expect("checked legacy hash length"),
+ )),
+ 64 => Ok(LegacyTransactionId::Signature(
+ bytes.try_into().expect("checked legacy signature length"),
+ )),
+ length => bail!("legacy outpoint ID must contain 32 or 64 bytes, got {length}"),
+ }
+}
+
+fn ensure_v2_transaction_within_block_budget(
+ transaction: &TransactionV2,
+ domain: &TransactionV2Domain,
+ max_block_bytes: usize,
+) -> Result<()> {
+ let transaction_bytes = transaction.encode(domain)?.len();
+ if transaction_bytes > max_block_bytes {
+ bail!(
+ "transaction v2 requires {transaction_bytes} bytes and exceeds the {max_block_bytes}-byte block budget"
+ );
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod v2_migration_tests {
+ use std::collections::BTreeMap;
+
+ use super::*;
+
+ #[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);
+
+ let transaction = ledger.build_v2_migration(&wallet, 3).unwrap();
+ let TransactionV2::Migration {
+ inputs,
+ outputs,
+ fee,
+ authorizations,
+ } = &transaction
+ else {
+ panic!("builder returned a non-migration transaction");
+ };
+ assert_eq!(inputs.len(), 1);
+ assert!(matches!(
+ inputs[0].outpoint_id,
+ LegacyTransactionId::Hash(_)
+ ));
+ assert_eq!(outputs.len(), 1);
+ assert_eq!(outputs[0].address, wallet.hybrid_versioned_address());
+ assert_eq!(outputs[0].amount, 97);
+ assert_eq!(*fee, 3);
+ assert_eq!(authorizations.len(), inputs.len());
+
+ let domain = TransactionV2Domain::new(
+ ledger.launch_profile.profile_id.clone(),
+ decode_hex_array::<32>(ledger.genesis_hash()).unwrap(),
+ )
+ .unwrap();
+ transaction.verify_authorizations(&domain).unwrap();
+ let encoded = transaction.encode(&domain).unwrap();
+ assert_eq!(
+ TransactionV2::decode(&encoded).unwrap(),
+ (domain, transaction)
+ );
+ }
+
+ #[test]
+ fn migration_builder_rejects_a_transaction_larger_than_the_block_budget() {
+ let wallet = Wallet::from_seed("v2-oversized-migration-wallet");
+ let profile = crate::domain::LaunchProfile {
+ max_block_bytes: 1,
+ ..crate::domain::LaunchProfile::default()
+ };
+ let ledger = Ledger::new_with_genesis_burns_and_profile(
+ BTreeMap::from([(wallet.address().to_string(), 100)]),
+ Vec::new(),
+ 1,
+ profile,
+ )
+ .unwrap();
+
+ let error = ledger.build_v2_migration(&wallet, 1).unwrap_err();
+ assert!(
+ error
+ .to_string()
+ .contains("exceeds the 1-byte block budget")
+ );
+ }
+}
diff --git a/src/domain/signature.rs b/src/domain/signature.rs
@@ -1,8 +1,10 @@
use anyhow::{Context, Result, bail};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use ml_dsa::{
- EncodedVerifyingKey, MlDsa44, Signature as MlDsaSignature, VerifyingKey as MlDsaVerifyingKey,
+ EncodedVerifyingKey, Keypair, MlDsa44, Seed, Signature as MlDsaSignature,
+ SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey,
};
+use secrecy::zeroize::Zeroize;
/// Signature schemes understood by the protocol implementation.
///
@@ -207,6 +209,36 @@ fn verify_ml_dsa44_with_context(
Ok(())
}
+pub(crate) fn ml_dsa44_public_key(signing_seed: &[u8; 32]) -> [u8; 1_312] {
+ let signing_key = ml_dsa44_signing_key(signing_seed);
+ signing_key
+ .verifying_key()
+ .encode()
+ .as_slice()
+ .try_into()
+ .expect("ML-DSA-44 public key has a fixed 1,312-byte encoding")
+}
+
+pub(crate) fn sign_ml_dsa44(signing_seed: &[u8; 32], payload: &[u8]) -> Result<[u8; 2_420]> {
+ let signing_key = ml_dsa44_signing_key(signing_seed);
+ let signature = signing_key
+ .expanded_key()
+ .sign_deterministic(payload, &[])
+ .context("failed to create deterministic ML-DSA-44 signature")?;
+ Ok(signature
+ .encode()
+ .as_slice()
+ .try_into()
+ .expect("ML-DSA-44 signature has a fixed 2,420-byte encoding"))
+}
+
+fn ml_dsa44_signing_key(signing_seed: &[u8; 32]) -> MlDsaSigningKey<MlDsa44> {
+ let mut seed = Seed::from(*signing_seed);
+ let signing_key = MlDsaSigningKey::<MlDsa44>::from_seed(&seed);
+ seed.zeroize();
+ signing_key
+}
+
#[cfg(test)]
mod tests {
use serde::Deserialize;
diff --git a/src/domain/transaction_v2.rs b/src/domain/transaction_v2.rs
@@ -1,3 +1,5 @@
+use std::collections::BTreeSet;
+
use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
@@ -16,9 +18,12 @@ const STRATUM_PROOF_HEADER_BYTES: usize = 80;
/// Reserved wire version. It is deliberately separate from the live `Transaction` JSON type.
pub const TRANSACTION_V2_WIRE_VERSION: u16 = 2;
-/// `None` is an explicit dormant state, not a distant placeholder height.
-/// Activating v2 requires a reviewed protocol release that changes this constant.
-pub const TRANSACTION_V2_ACTIVATION_HEIGHT: Option<u64> = None;
+/// Fixed consensus activation height for the `iuna-mainnet-candidate` migration.
+///
+/// This is deliberately compiled into the protocol rather than exposed as an
+/// operator-controlled feature flag. Live consensus must not route v2
+/// transactions through this gate until the complete integration is present.
+pub const TRANSACTION_V2_ACTIVATION_HEIGHT: Option<u64> = Some(3_000);
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransactionV2Domain {
@@ -33,6 +38,19 @@ pub struct TransactionV2Input {
pub owner: VersionedAddress,
}
+#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub enum LegacyTransactionId {
+ Hash([u8; 32]),
+ Signature([u8; 64]),
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TransactionV2LegacyInput {
+ pub outpoint_id: LegacyTransactionId,
+ pub outpoint_index: u32,
+ pub owner: VersionedAddress,
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransactionV2Output {
pub address: VersionedAddress,
@@ -47,6 +65,12 @@ pub struct V2SpendingAuthorization {
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TransactionV2 {
+ Migration {
+ inputs: Vec<TransactionV2LegacyInput>,
+ outputs: Vec<TransactionV2Output>,
+ fee: u64,
+ authorizations: Vec<V2SpendingAuthorization>,
+ },
Transfer {
inputs: Vec<TransactionV2Input>,
outputs: Vec<TransactionV2Output>,
@@ -116,7 +140,27 @@ impl V2SpendingAuthorization {
&self.signature
}
- /// Computes the address-v1 commitment using unambiguous component lengths.
+ /// Computes the address authorized by this public key.
+ pub fn authorized_address(&self) -> Result<VersionedAddress> {
+ match self.public_key.scheme() {
+ SignatureScheme::Ed25519 => Ok(VersionedAddress {
+ version: AddressVersion::Ed25519PublicKey,
+ payload: self
+ .public_key
+ .as_bytes()
+ .try_into()
+ .expect("validated Ed25519 public key length"),
+ }),
+ SignatureScheme::HybridEd25519MlDsa44 => {
+ hybrid_key_commitment_address(&self.public_key)
+ }
+ SignatureScheme::MlDsa44 => {
+ bail!("ML-DSA-only transaction v2 authorizations are not supported")
+ }
+ }
+ }
+
+ /// Computes the address-v1 commitment for a hybrid authorization.
pub fn committed_address(&self) -> Result<VersionedAddress> {
hybrid_key_commitment_address(&self.public_key)
}
@@ -211,6 +255,11 @@ impl TransactionV2 {
proof_hash,
}
}
+ 4 => UnsignedDecoded::Migration {
+ inputs: decode_legacy_inputs(&mut reader)?,
+ outputs: decode_outputs(&mut reader)?,
+ fee: reader.u64("migration fee")?,
+ },
_ => bail!("unsupported transaction v2 kind {kind}"),
};
@@ -249,86 +298,184 @@ impl TransactionV2 {
pub fn validate_authorization_commitments(&self) -> Result<()> {
self.validate_shape()?;
- for (input, authorization) in self.inputs().iter().zip(self.authorizations()) {
- if authorization.scheme() != SignatureScheme::HybridEd25519MlDsa44 {
- bail!("transaction v2 spends require hybrid authorization");
+ match self {
+ Self::Migration {
+ inputs,
+ authorizations,
+ ..
+ } => {
+ for (input, authorization) in inputs.iter().zip(authorizations) {
+ if input.owner.version != AddressVersion::Ed25519PublicKey
+ || authorization.scheme() != SignatureScheme::Ed25519
+ || input.owner != authorization.authorized_address()?
+ {
+ bail!(
+ "transaction v2 migration authorization does not match its legacy owner"
+ );
+ }
+ }
}
- if input.owner.version != AddressVersion::HybridKeyCommitment {
- bail!("transaction v2 input owner must be an address-v1 commitment");
+ Self::Transfer {
+ inputs,
+ authorizations,
+ ..
}
- if input.owner.payload != public_key_commitment(authorization.public_key()) {
- bail!("transaction v2 authorization does not match its owner commitment");
+ | Self::Burn {
+ inputs,
+ authorizations,
+ ..
+ } => {
+ for (input, authorization) in inputs.iter().zip(authorizations) {
+ if input.owner.version != AddressVersion::HybridKeyCommitment
+ || authorization.scheme() != SignatureScheme::HybridEd25519MlDsa44
+ || input.owner != authorization.authorized_address()?
+ {
+ bail!(
+ "transaction v2 authorization does not match its hybrid owner commitment"
+ );
+ }
+ }
}
+ Self::Mine { .. } => {}
}
Ok(())
}
- /// Verifies both components of every hybrid spending authorization. This remains unreachable
- /// from live consensus while `TRANSACTION_V2_ACTIVATION_HEIGHT` is `None`.
- pub fn verify_hybrid_authorizations(&self, domain: &TransactionV2Domain) -> Result<()> {
+ /// Verifies the authorization required by each input version. Version-0 inputs retain their
+ /// Ed25519 rule so existing value can migrate; version-1 inputs require both signature
+ /// components. Live consensus must call `ensure_transaction_v2_active` before acceptance.
+ pub fn verify_authorizations(&self, domain: &TransactionV2Domain) -> Result<()> {
self.validate_authorization_commitments()?;
let payload = self.signing_bytes(domain)?;
for authorization in self.authorizations() {
- let (ed25519_public_key, ml_dsa_public_key) =
- authorization.public_key().as_bytes().split_at(32);
- let (ed25519_signature, ml_dsa_signature) =
- authorization.signature().as_bytes().split_at(64);
- let ed25519_public_key: [u8; 32] = ed25519_public_key
- .try_into()
- .expect("validated hybrid public key length");
- let ml_dsa_public_key: [u8; 1_312] = ml_dsa_public_key
- .try_into()
- .expect("validated hybrid public key length");
- let ed25519_signature: [u8; 64] = ed25519_signature
- .try_into()
- .expect("validated hybrid signature length");
- let ml_dsa_signature: [u8; 2_420] = ml_dsa_signature
- .try_into()
- .expect("validated hybrid signature length");
- verify_ed25519(
- &ed25519_public_key,
- &payload,
- &ed25519_signature,
- "transaction v2 classical component",
- )?;
- verify_ml_dsa44(
- &ml_dsa_public_key,
- &payload,
- &ml_dsa_signature,
- "transaction v2 post-quantum component",
- )?;
+ match authorization.scheme() {
+ SignatureScheme::Ed25519 => {
+ let public_key = authorization
+ .public_key()
+ .as_bytes()
+ .try_into()
+ .expect("validated Ed25519 public key length");
+ let signature = authorization
+ .signature()
+ .as_bytes()
+ .try_into()
+ .expect("validated Ed25519 signature length");
+ verify_ed25519(public_key, &payload, signature, "transaction v2 input")?;
+ }
+ SignatureScheme::HybridEd25519MlDsa44 => {
+ let (ed25519_public_key, ml_dsa_public_key) =
+ authorization.public_key().as_bytes().split_at(32);
+ let (ed25519_signature, ml_dsa_signature) =
+ authorization.signature().as_bytes().split_at(64);
+ verify_ed25519(
+ ed25519_public_key
+ .try_into()
+ .expect("validated hybrid key length"),
+ &payload,
+ ed25519_signature
+ .try_into()
+ .expect("validated hybrid signature length"),
+ "transaction v2 classical component",
+ )?;
+ verify_ml_dsa44(
+ ml_dsa_public_key
+ .try_into()
+ .expect("validated hybrid key length"),
+ &payload,
+ ml_dsa_signature
+ .try_into()
+ .expect("validated hybrid signature length"),
+ "transaction v2 post-quantum component",
+ )?;
+ }
+ SignatureScheme::MlDsa44 => {
+ bail!("ML-DSA-only transaction v2 authorizations are not supported")
+ }
+ }
}
Ok(())
}
fn validate_shape(&self) -> Result<()> {
self.validate_unsigned_shape()?;
- if self.authorizations().len() != self.inputs().len() {
+ if self.authorizations().len() != self.input_count() {
bail!("transaction v2 requires exactly one authorization per input");
}
Ok(())
}
fn validate_unsigned_shape(&self) -> Result<()> {
- if self.inputs().len() > MAX_V2_INPUTS {
+ if self.input_count() > MAX_V2_INPUTS {
bail!("transaction v2 has too many inputs");
}
if self.outputs().len() > MAX_V2_OUTPUTS {
bail!("transaction v2 has too many outputs");
}
+ if self.outputs().iter().any(|output| output.amount == 0) {
+ bail!("transaction v2 outputs must be greater than zero");
+ }
+ match self {
+ Self::Migration {
+ inputs, outputs, ..
+ } => {
+ if inputs.is_empty() {
+ bail!("transaction v2 migration requires at least one input");
+ }
+ if outputs.len() != 1
+ || outputs[0].address.version != AddressVersion::HybridKeyCommitment
+ {
+ bail!("transaction v2 migration requires exactly one address-v1 output");
+ }
+ let unique = inputs
+ .iter()
+ .map(|input| (&input.outpoint_id, input.outpoint_index))
+ .collect::<BTreeSet<_>>();
+ if unique.len() != inputs.len() {
+ bail!("transaction v2 migration contains a duplicate input");
+ }
+ }
+ Self::Transfer {
+ inputs, outputs, ..
+ } => {
+ if inputs.is_empty() || outputs.is_empty() {
+ bail!("transaction v2 transfer requires inputs and outputs");
+ }
+ let unique = inputs
+ .iter()
+ .map(|input| (input.outpoint_txid, input.outpoint_index))
+ .collect::<BTreeSet<_>>();
+ if unique.len() != inputs.len() {
+ bail!("transaction v2 transfer contains a duplicate input");
+ }
+ }
+ Self::Burn { inputs, amount, .. } => {
+ if inputs.is_empty() || *amount == 0 {
+ bail!("transaction v2 burn requires inputs and a positive amount");
+ }
+ let unique = inputs
+ .iter()
+ .map(|input| (input.outpoint_txid, input.outpoint_index))
+ .collect::<BTreeSet<_>>();
+ if unique.len() != inputs.len() {
+ bail!("transaction v2 burn contains a duplicate input");
+ }
+ }
+ Self::Mine { .. } => {}
+ }
Ok(())
}
- fn inputs(&self) -> &[TransactionV2Input] {
+ fn input_count(&self) -> usize {
match self {
- Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs,
- Self::Mine { .. } => &[],
+ Self::Migration { inputs, .. } => inputs.len(),
+ Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs.len(),
+ Self::Mine { .. } => 0,
}
}
fn outputs(&self) -> &[TransactionV2Output] {
match self {
- Self::Transfer { outputs, .. } => outputs,
+ Self::Migration { outputs, .. } | Self::Transfer { outputs, .. } => outputs,
Self::Burn { change, .. } => change,
Self::Mine { .. } => &[],
}
@@ -336,15 +483,26 @@ impl TransactionV2 {
fn authorizations(&self) -> &[V2SpendingAuthorization] {
match self {
- Self::Transfer { authorizations, .. } | Self::Burn { authorizations, .. } => {
- authorizations
- }
+ Self::Migration { authorizations, .. }
+ | Self::Transfer { authorizations, .. }
+ | Self::Burn { authorizations, .. } => authorizations,
Self::Mine { .. } => &[],
}
}
fn encode_unsigned_body(&self, bytes: &mut Vec<u8>) -> Result<()> {
match self {
+ Self::Migration {
+ inputs,
+ outputs,
+ fee,
+ ..
+ } => {
+ bytes.push(4);
+ encode_legacy_inputs(bytes, inputs)?;
+ encode_outputs(bytes, outputs)?;
+ bytes.extend_from_slice(&fee.to_be_bytes());
+ }
Self::Transfer {
inputs,
outputs,
@@ -472,6 +630,45 @@ fn decode_inputs(reader: &mut Reader<'_>) -> Result<Vec<TransactionV2Input>> {
Ok(inputs)
}
+fn encode_legacy_inputs(bytes: &mut Vec<u8>, inputs: &[TransactionV2LegacyInput]) -> Result<()> {
+ encode_count(bytes, inputs.len(), "legacy input count")?;
+ for input in inputs {
+ match input.outpoint_id {
+ LegacyTransactionId::Hash(hash) => {
+ bytes.push(0);
+ bytes.extend_from_slice(&hash);
+ }
+ LegacyTransactionId::Signature(signature) => {
+ bytes.push(1);
+ bytes.extend_from_slice(&signature);
+ }
+ }
+ bytes.extend_from_slice(&input.outpoint_index.to_be_bytes());
+ encode_address(bytes, &input.owner);
+ }
+ Ok(())
+}
+
+fn decode_legacy_inputs(reader: &mut Reader<'_>) -> Result<Vec<TransactionV2LegacyInput>> {
+ let count = reader.count(MAX_V2_INPUTS, "legacy input count")?;
+ let mut inputs = Vec::with_capacity(count);
+ for _ in 0..count {
+ let outpoint_id = match reader.u8("legacy input ID kind")? {
+ 0 => LegacyTransactionId::Hash(reader.array::<32>("legacy input hash")?),
+ 1 => LegacyTransactionId::Signature(
+ reader.array::<64>("legacy input transaction signature")?,
+ ),
+ kind => bail!("unsupported legacy input ID kind {kind}"),
+ };
+ inputs.push(TransactionV2LegacyInput {
+ outpoint_id,
+ outpoint_index: reader.u32("legacy input output index")?,
+ owner: decode_address(reader, "legacy input owner")?,
+ });
+ }
+ Ok(inputs)
+}
+
fn encode_outputs(bytes: &mut Vec<u8>, outputs: &[TransactionV2Output]) -> Result<()> {
encode_count(bytes, outputs.len(), "output count")?;
for output in outputs {
@@ -542,6 +739,11 @@ fn encode_bytes(bytes: &mut Vec<u8>, value: &[u8], label: &str) -> Result<()> {
}
enum UnsignedDecoded {
+ Migration {
+ inputs: Vec<TransactionV2LegacyInput>,
+ outputs: Vec<TransactionV2Output>,
+ fee: u64,
+ },
Transfer {
inputs: Vec<TransactionV2Input>,
outputs: Vec<TransactionV2Output>,
@@ -571,6 +773,16 @@ impl UnsignedDecoded {
authorizations: Vec<V2SpendingAuthorization>,
) -> Result<TransactionV2> {
Ok(match self {
+ Self::Migration {
+ inputs,
+ outputs,
+ fee,
+ } => TransactionV2::Migration {
+ inputs,
+ outputs,
+ fee,
+ authorizations,
+ },
Self::Transfer {
inputs,
outputs,
@@ -725,6 +937,23 @@ mod tests {
.unwrap()
}
+ fn ed25519_authorization(payload: &[u8]) -> V2SpendingAuthorization {
+ let signing_key = SigningKey::from_bytes(&[7; 32]);
+ V2SpendingAuthorization::new(
+ ProtocolPublicKey::new(
+ SignatureScheme::Ed25519,
+ signing_key.verifying_key().to_bytes().to_vec(),
+ )
+ .unwrap(),
+ ProtocolSignature::new(
+ SignatureScheme::Ed25519,
+ signing_key.sign(payload).to_bytes().to_vec(),
+ )
+ .unwrap(),
+ )
+ .unwrap()
+ }
+
fn unsigned_transfer(owner: VersionedAddress) -> TransactionV2 {
TransactionV2::Transfer {
inputs: vec![TransactionV2Input {
@@ -745,11 +974,14 @@ mod tests {
}
#[test]
- fn v2_is_explicitly_dormant_at_every_height() {
- assert_eq!(TRANSACTION_V2_ACTIVATION_HEIGHT, None);
+ fn v2_activates_at_the_fixed_consensus_height() {
+ assert_eq!(TRANSACTION_V2_ACTIVATION_HEIGHT, Some(3_000));
assert!(!transaction_v2_is_active(0));
- assert!(!transaction_v2_is_active(u64::MAX));
- assert!(ensure_transaction_v2_active(u64::MAX).is_err());
+ assert!(!transaction_v2_is_active(2_999));
+ assert!(ensure_transaction_v2_active(2_999).is_err());
+ assert!(transaction_v2_is_active(3_000));
+ assert!(transaction_v2_is_active(u64::MAX));
+ ensure_transaction_v2_active(3_000).unwrap();
}
#[test]
@@ -768,7 +1000,7 @@ mod tests {
owner
);
transaction.validate_authorization_commitments().unwrap();
- transaction.verify_hybrid_authorizations(&domain()).unwrap();
+ transaction.verify_authorizations(&domain()).unwrap();
let encoded = transaction.encode(&domain()).unwrap();
let (decoded_domain, decoded) = TransactionV2::decode(&encoded).unwrap();
assert_eq!(decoded_domain, domain());
@@ -805,12 +1037,58 @@ mod tests {
}
assert!(
invalid_post_quantum_signature
- .verify_hybrid_authorizations(&domain())
+ .verify_authorizations(&domain())
.is_err()
);
}
#[test]
+ fn version_zero_input_can_migrate_to_a_hybrid_output() {
+ let signing_key = SigningKey::from_bytes(&[7; 32]);
+ let legacy_owner = VersionedAddress {
+ version: AddressVersion::Ed25519PublicKey,
+ payload: signing_key.verifying_key().to_bytes(),
+ };
+ let hybrid_recipient = hybrid_key_commitment_address(&hybrid_public_key()).unwrap();
+ let mut transaction = TransactionV2::Migration {
+ inputs: vec![TransactionV2LegacyInput {
+ outpoint_id: LegacyTransactionId::Signature([0x11; 64]),
+ outpoint_index: 7,
+ owner: legacy_owner,
+ }],
+ outputs: vec![TransactionV2Output {
+ address: hybrid_recipient,
+ amount: 5,
+ }],
+ fee: 1,
+ authorizations: Vec::new(),
+ };
+ let authorization = ed25519_authorization(&transaction.signing_bytes(&domain()).unwrap());
+ if let TransactionV2::Migration { authorizations, .. } = &mut transaction {
+ authorizations.push(authorization);
+ }
+
+ transaction.verify_authorizations(&domain()).unwrap();
+ let encoded = transaction.encode(&domain()).unwrap();
+ let (decoded_domain, decoded) = TransactionV2::decode(&encoded).unwrap();
+ assert_eq!(decoded_domain, domain());
+ assert_eq!(decoded, transaction);
+ assert_eq!(
+ transaction.authorizations()[0]
+ .authorized_address()
+ .unwrap(),
+ legacy_owner
+ );
+
+ let mut wrong_scheme = transaction;
+ let wrong_scheme_payload = wrong_scheme.signing_bytes(&domain()).unwrap();
+ if let TransactionV2::Migration { authorizations, .. } = &mut wrong_scheme {
+ *authorizations = vec![hybrid_authorization(&wrong_scheme_payload)];
+ }
+ assert!(wrong_scheme.verify_authorizations(&domain()).is_err());
+ }
+
+ #[test]
fn decoder_fails_closed_for_versions_lengths_and_trailing_bytes() {
let transaction = TransactionV2::Mine {
recipient: VersionedAddress {
@@ -869,7 +1147,7 @@ mod tests {
] {
let encoded = decode_hex(seed.trim().strip_prefix("hex:").unwrap()).unwrap();
let (domain, transaction) = TransactionV2::decode(&encoded).unwrap();
- transaction.verify_hybrid_authorizations(&domain).unwrap();
+ transaction.verify_authorizations(&domain).unwrap();
}
}
diff --git a/src/domain/wallet.rs b/src/domain/wallet.rs
@@ -1,36 +1,40 @@
-use std::{fmt, sync::Arc};
+use std::{
+ fmt,
+ sync::{Arc, OnceLock},
+};
use secrecy::{ExposeSecret, SecretBox, zeroize::Zeroize};
use sha2::{Digest, Sha256};
use super::block::LeaderProofPayload;
use super::{
- BurnBundle, BurnBundlePayload, LeaderProof, ed25519_public_key, hex_encode, sign_ed25519,
+ AddressNetwork, BurnBundle, BurnBundlePayload, LeaderProof, ProtocolPublicKey,
+ ProtocolSignature, SignatureScheme, V2SpendingAuthorization, VersionedAddress,
+ ed25519_public_key, encode_versioned_address, hex_encode, hybrid_key_commitment_address,
+ ml_dsa44_public_key, sign_ed25519, sign_ml_dsa44,
};
const WALLET_SEED_DOMAIN: &str = "iuna-wallet-seed";
+const WALLET_ML_DSA44_SEED_DOMAIN: &str = "iuna-wallet-ml-dsa44-seed-v1";
#[derive(Clone)]
pub struct Wallet {
address: String,
signing_seed: Arc<SecretBox<[u8; 32]>>,
+ ml_dsa44_signing_seed: Arc<SecretBox<[u8; 32]>>,
+ hybrid_public_key: Arc<OnceLock<ProtocolPublicKey>>,
}
impl Wallet {
pub fn from_seed(seed: &str) -> Self {
- let mut hasher = Sha256::new();
- hasher.update(WALLET_SEED_DOMAIN.as_bytes());
- hasher.update(b":");
- hasher.update(seed.as_bytes());
- let mut seed_hash = hasher.finalize();
- let signing_seed = SecretBox::init_with_mut(|signing_seed: &mut [u8; 32]| {
- signing_seed.copy_from_slice(&seed_hash);
- });
- seed_hash.zeroize();
+ let signing_seed = derive_signing_seed(WALLET_SEED_DOMAIN, seed);
+ let ml_dsa44_signing_seed = derive_signing_seed(WALLET_ML_DSA44_SEED_DOMAIN, seed);
let address = hex_encode(ed25519_public_key(signing_seed.expose_secret()));
Self {
address,
signing_seed: Arc::new(signing_seed),
+ ml_dsa44_signing_seed: Arc::new(ml_dsa44_signing_seed),
+ hybrid_public_key: Arc::new(OnceLock::new()),
}
}
@@ -38,6 +42,78 @@ impl Wallet {
&self.address
}
+ pub fn legacy_versioned_address(&self) -> VersionedAddress {
+ VersionedAddress {
+ version: super::AddressVersion::Ed25519PublicKey,
+ payload: ed25519_public_key(self.signing_seed.expose_secret()),
+ }
+ }
+
+ /// Returns the committed hybrid address derived from the existing wallet seed phrase.
+ /// This does not make transaction v2 consensus-active.
+ pub fn hybrid_versioned_address(&self) -> VersionedAddress {
+ hybrid_key_commitment_address(self.hybrid_public_key())
+ .expect("wallet always constructs a valid hybrid public key")
+ }
+
+ pub fn hybrid_address(&self, network: AddressNetwork) -> String {
+ encode_versioned_address(self.hybrid_versioned_address(), network)
+ .expect("wallet hybrid address has a valid fixed-size commitment")
+ }
+
+ pub fn hybrid_public_key(&self) -> &ProtocolPublicKey {
+ self.hybrid_public_key.get_or_init(|| {
+ let mut public_key =
+ Vec::with_capacity(SignatureScheme::HybridEd25519MlDsa44.public_key_bytes());
+ public_key.extend_from_slice(&ed25519_public_key(self.signing_seed.expose_secret()));
+ public_key.extend_from_slice(&ml_dsa44_public_key(
+ self.ml_dsa44_signing_seed.expose_secret(),
+ ));
+ ProtocolPublicKey::new(SignatureScheme::HybridEd25519MlDsa44, public_key)
+ .expect("wallet hybrid public key has the scheme-defined length")
+ })
+ }
+
+ /// Creates both signatures over the same canonical transaction-v2 payload.
+ pub fn sign_hybrid_authorization(
+ &self,
+ payload: &[u8],
+ ) -> anyhow::Result<V2SpendingAuthorization> {
+ let mut signature =
+ Vec::with_capacity(SignatureScheme::HybridEd25519MlDsa44.signature_bytes());
+ signature.extend_from_slice(&sign_ed25519(self.signing_seed.expose_secret(), payload));
+ signature.extend_from_slice(&sign_ml_dsa44(
+ self.ml_dsa44_signing_seed.expose_secret(),
+ payload,
+ )?);
+ V2SpendingAuthorization::new(
+ self.hybrid_public_key().clone(),
+ ProtocolSignature::new(SignatureScheme::HybridEd25519MlDsa44, signature)?,
+ )
+ }
+
+ /// Signs a transaction-v2 input owned by this wallet. Existing version-0 value uses its
+ /// original Ed25519 key; migrated version-1 value uses the hybrid key.
+ pub fn sign_v2_authorization(
+ &self,
+ owner: VersionedAddress,
+ payload: &[u8],
+ ) -> anyhow::Result<V2SpendingAuthorization> {
+ if owner == self.legacy_versioned_address() {
+ return V2SpendingAuthorization::new(
+ ProtocolPublicKey::new(SignatureScheme::Ed25519, owner.payload.to_vec())?,
+ ProtocolSignature::new(
+ SignatureScheme::Ed25519,
+ sign_ed25519(self.signing_seed.expose_secret(), payload).to_vec(),
+ )?,
+ );
+ }
+ if owner == self.hybrid_versioned_address() {
+ return self.sign_hybrid_authorization(payload);
+ }
+ anyhow::bail!("transaction v2 input is not owned by this wallet")
+ }
+
pub(super) fn sign_payload(&self, payload: &str) -> String {
self.sign_bytes(payload.as_bytes())
}
@@ -74,6 +150,7 @@ impl fmt::Debug for Wallet {
.debug_struct("Wallet")
.field("address", &self.address)
.field("signing_seed", &"[REDACTED]")
+ .field("ml_dsa44_signing_seed", &"[REDACTED]")
.finish()
}
}
@@ -86,20 +163,138 @@ impl PartialEq for Wallet {
impl Eq for Wallet {}
+fn derive_signing_seed(domain: &str, seed: &str) -> SecretBox<[u8; 32]> {
+ let mut hasher = Sha256::new();
+ hasher.update(domain.as_bytes());
+ hasher.update(b":");
+ hasher.update(seed.as_bytes());
+ let mut seed_hash = hasher.finalize();
+ let signing_seed = SecretBox::init_with_mut(|signing_seed: &mut [u8; 32]| {
+ signing_seed.copy_from_slice(&seed_hash);
+ });
+ seed_hash.zeroize();
+ signing_seed
+}
+
#[cfg(test)]
mod tests {
use secrecy::ExposeSecret;
use super::Wallet;
- use crate::domain::hex_encode;
+ use crate::domain::{
+ AddressNetwork, SignatureScheme, hex_encode, verify_ed25519, verify_ml_dsa44,
+ };
#[test]
fn debug_output_redacts_the_wallet_signing_seed() {
let wallet = Wallet::from_seed("debug-redaction-wallet-seed");
let signing_seed = hex_encode(wallet.signing_seed.expose_secret());
+ let ml_dsa44_signing_seed = hex_encode(wallet.ml_dsa44_signing_seed.expose_secret());
let debug = format!("{wallet:?}");
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains(&signing_seed));
+ assert!(!debug.contains(&ml_dsa44_signing_seed));
+ }
+
+ #[test]
+ fn existing_seed_deterministically_derives_a_hybrid_address() {
+ let first = Wallet::from_seed("hybrid-wallet-seed");
+ let second = Wallet::from_seed("hybrid-wallet-seed");
+ let other = Wallet::from_seed("other-hybrid-wallet-seed");
+
+ assert!(first.hybrid_public_key.get().is_none());
+ assert_eq!(first.address(), second.address());
+ assert_eq!(first.hybrid_public_key(), second.hybrid_public_key());
+ assert!(first.hybrid_public_key.get().is_some());
+ assert_eq!(
+ first.hybrid_address(AddressNetwork::Mainnet),
+ second.hybrid_address(AddressNetwork::Mainnet)
+ );
+ assert_ne!(
+ first.hybrid_address(AddressNetwork::Mainnet),
+ other.hybrid_address(AddressNetwork::Mainnet)
+ );
+ assert_ne!(
+ first.hybrid_address(AddressNetwork::Mainnet),
+ first.hybrid_address(AddressNetwork::Testnet)
+ );
+ assert_eq!(
+ first.hybrid_public_key().scheme(),
+ SignatureScheme::HybridEd25519MlDsa44
+ );
+ assert_eq!(
+ first.hybrid_address(AddressNetwork::Mainnet),
+ "iuna1py9qlrnw6cm3mpz26hwwkww9spaa96zrw2g34gu0p4y3ea5cqhg0qa82x9s"
+ );
+ }
+
+ #[test]
+ fn hybrid_authorization_signs_both_components_over_the_same_payload() {
+ let wallet = Wallet::from_seed("hybrid-signing-wallet-seed");
+ let payload = b"canonical transaction-v2 payload";
+ let authorization = wallet.sign_hybrid_authorization(payload).unwrap();
+ let signature = authorization.signature().as_bytes();
+ let public_key = authorization.public_key().as_bytes();
+ let ed25519_public_key: &[u8; 32] = public_key[..32].try_into().unwrap();
+ let ed25519_signature: &[u8; 64] = signature[..64].try_into().unwrap();
+ let ml_dsa44_public_key: &[u8; 1_312] = public_key[32..].try_into().unwrap();
+ let ml_dsa44_signature: &[u8; 2_420] = signature[64..].try_into().unwrap();
+
+ assert_eq!(
+ authorization.committed_address().unwrap(),
+ wallet.hybrid_versioned_address()
+ );
+ verify_ed25519(
+ ed25519_public_key,
+ payload,
+ ed25519_signature,
+ "wallet test",
+ )
+ .unwrap();
+ verify_ml_dsa44(
+ ml_dsa44_public_key,
+ payload,
+ ml_dsa44_signature,
+ "wallet test",
+ )
+ .unwrap();
+ assert!(
+ verify_ml_dsa44(
+ ml_dsa44_public_key,
+ b"tampered",
+ ml_dsa44_signature,
+ "wallet test",
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn v2_authorization_uses_the_scheme_required_by_the_owned_address() {
+ let wallet = Wallet::from_seed("v2-migration-wallet-seed");
+ let payload = b"migration payload";
+
+ let legacy = wallet
+ .sign_v2_authorization(wallet.legacy_versioned_address(), payload)
+ .unwrap();
+ assert_eq!(legacy.scheme(), SignatureScheme::Ed25519);
+ assert_eq!(
+ legacy.authorized_address().unwrap(),
+ wallet.legacy_versioned_address()
+ );
+
+ let hybrid = wallet
+ .sign_v2_authorization(wallet.hybrid_versioned_address(), payload)
+ .unwrap();
+ assert_eq!(hybrid.scheme(), SignatureScheme::HybridEd25519MlDsa44);
+ assert!(
+ wallet
+ .sign_v2_authorization(
+ Wallet::from_seed("another-wallet").legacy_versioned_address(),
+ payload,
+ )
+ .is_err()
+ );
}
}