commit 4de3186cb4b628ec2793665f42da50d3ed9ae2c0
parent 24c11779101ea36bc5d82717e67fefd71ff98d38
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sun, 13 Sep 2026 20:37:43 +0200
feat(protocol): add dormant transaction v2
Diffstat:
6 files changed, 900 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -8,6 +8,7 @@ from the Git history and Conventional Commit titles by `deployment.sh`.
### Added
- expose complete peer handshake details in the P2P interface
+- add dormant transaction-v2 encoding and activation gating
## [0.4.32] - 2026-09-13
diff --git a/docs/quantum-migration.md b/docs/quantum-migration.md
@@ -98,6 +98,20 @@ Application, transport, and consensus versions move independently:
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
+
+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.
+
+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. Only the
+classical component is currently executable; selecting and reviewing an ML-DSA backend remains a
+separate prerequisite before activation. No transaction-v2 gossip capability is advertised yet.
+
## Other trust boundaries
- P2P node IDs need versioned, algorithm-tagged proofs independent of wallet activation.
diff --git a/src/app/types.rs b/src/app/types.rs
@@ -126,8 +126,16 @@ pub struct BlockInventory {
#[cfg(test)]
mod protocol_hello_tests {
+ use serde::Deserialize;
+
use super::ProtocolHello;
+ #[derive(Deserialize)]
+ struct V0430ProtocolHello {
+ protocol_version: u32,
+ network_id: String,
+ }
+
#[test]
fn legacy_hello_without_capabilities_remains_compatible() {
let json = r#"{"protocol_version":2,"network_id":"test","genesis_hash":"genesis","listen_addr":null,"node_id":null,"height":0,"tip_hash":"tip","time_ms":1}"#;
@@ -140,6 +148,26 @@ mod protocol_hello_tests {
.contains("capabilities")
);
}
+
+ #[test]
+ fn v0430_shape_ignores_new_capabilities_field() {
+ let current = ProtocolHello {
+ protocol_version: 2,
+ capabilities: vec!["address-v1-read".to_string()],
+ network_id: "iuna-mainnet-candidate".to_string(),
+ genesis_hash: "00".repeat(32),
+ listen_addr: None,
+ node_id: None,
+ height: 0,
+ tip_hash: "00".repeat(32),
+ time_ms: 1,
+ };
+ let legacy: V0430ProtocolHello =
+ serde_json::from_str(&serde_json::to_string(¤t).unwrap()).unwrap();
+
+ assert_eq!(legacy.protocol_version, 2);
+ assert_eq!(legacy.network_id, "iuna-mainnet-candidate");
+ }
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
diff --git a/src/domain.rs b/src/domain.rs
@@ -32,6 +32,7 @@ mod signature;
mod stratum;
mod ticket;
mod transaction;
+mod transaction_v2;
mod validation;
mod vdf;
mod wallet;
@@ -97,6 +98,11 @@ pub use transaction::{
MineSearchOutcome, OutPoint, TRANSACTION_SIGNING_FORMAT_VERSION, Transaction, TxInput, TxOutput,
};
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,
+};
pub(crate) use validation::minimum_transfer_economic_size_bytes;
pub use validation::validate_address;
use validation::{
diff --git a/src/domain/address.rs b/src/domain/address.rs
@@ -25,7 +25,11 @@ pub struct VersionedAddress {
}
impl AddressVersion {
- fn from_wire_id(id: u8) -> Option<Self> {
+ pub const fn wire_id(self) -> u8 {
+ self as u8
+ }
+
+ pub(crate) const fn from_wire_id(id: u8) -> Option<Self> {
match id {
0 => Some(Self::Ed25519PublicKey),
1 => Some(Self::HybridKeyCommitment),
@@ -74,7 +78,7 @@ pub fn encode_versioned_address(
validate_public_key(&address.payload)?;
}
- let mut data = vec![address.version as u8];
+ let mut data = vec![address.version.wire_id()];
data.extend(convert_bits(&address.payload, 8, 5, true)?);
let checksum = create_checksum(network.hrp(), &data);
let mut encoded = String::with_capacity(network.hrp().len() + 1 + data.len() + 6);
diff --git a/src/domain/transaction_v2.rs b/src/domain/transaction_v2.rs
@@ -0,0 +1,845 @@
+use anyhow::{Context, Result, bail};
+use sha2::{Digest, Sha256};
+
+use super::{
+ AddressVersion, ProtocolPublicKey, ProtocolSignature, SignatureScheme, VersionedAddress,
+ verify_ed25519,
+};
+
+const TRANSACTION_V2_TAG: &[u8] = b"IUNA-TX-V2";
+const ADDRESS_V1_COMMITMENT_TAG: &[u8] = b"IUNA-ADDRESS-V1";
+const MAX_CHAIN_ID_BYTES: usize = 64;
+const MAX_V2_INPUTS: usize = 1_000;
+const MAX_V2_OUTPUTS: usize = 1_000;
+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;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TransactionV2Domain {
+ chain_id: String,
+ genesis_hash: [u8; 32],
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TransactionV2Input {
+ pub outpoint_txid: [u8; 32],
+ pub outpoint_index: u32,
+ pub owner: VersionedAddress,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TransactionV2Output {
+ pub address: VersionedAddress,
+ pub amount: u64,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct V2SpendingAuthorization {
+ public_key: ProtocolPublicKey,
+ signature: ProtocolSignature,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum TransactionV2 {
+ Transfer {
+ inputs: Vec<TransactionV2Input>,
+ outputs: Vec<TransactionV2Output>,
+ fee: u64,
+ authorizations: Vec<V2SpendingAuthorization>,
+ },
+ Burn {
+ inputs: Vec<TransactionV2Input>,
+ change: Vec<TransactionV2Output>,
+ amount: u64,
+ fee: u64,
+ anchor: Option<[u8; 32]>,
+ authorizations: Vec<V2SpendingAuthorization>,
+ },
+ Mine {
+ recipient: VersionedAddress,
+ anchor: [u8; 32],
+ salt: u64,
+ nonce: u64,
+ difficulty_bits: u32,
+ proof_header: Option<[u8; STRATUM_PROOF_HEADER_BYTES]>,
+ proof_hash: [u8; 32],
+ },
+}
+
+impl TransactionV2Domain {
+ pub fn new(chain_id: impl Into<String>, genesis_hash: [u8; 32]) -> Result<Self> {
+ let chain_id = chain_id.into();
+ if chain_id.is_empty() || chain_id.len() > MAX_CHAIN_ID_BYTES || !chain_id.is_ascii() {
+ bail!("transaction v2 chain ID must contain 1..={MAX_CHAIN_ID_BYTES} ASCII bytes");
+ }
+ Ok(Self {
+ chain_id,
+ genesis_hash,
+ })
+ }
+
+ pub fn chain_id(&self) -> &str {
+ &self.chain_id
+ }
+
+ pub fn genesis_hash(&self) -> &[u8; 32] {
+ &self.genesis_hash
+ }
+}
+
+impl V2SpendingAuthorization {
+ pub fn new(public_key: ProtocolPublicKey, signature: ProtocolSignature) -> Result<Self> {
+ if public_key.scheme() != signature.scheme() {
+ bail!("transaction v2 public key and signature schemes differ");
+ }
+ Ok(Self {
+ public_key,
+ signature,
+ })
+ }
+
+ pub fn scheme(&self) -> SignatureScheme {
+ self.public_key.scheme()
+ }
+
+ pub fn public_key(&self) -> &ProtocolPublicKey {
+ &self.public_key
+ }
+
+ pub fn signature(&self) -> &ProtocolSignature {
+ &self.signature
+ }
+
+ /// Computes the address-v1 commitment using unambiguous component lengths.
+ pub fn committed_address(&self) -> Result<VersionedAddress> {
+ hybrid_key_commitment_address(&self.public_key)
+ }
+}
+
+impl TransactionV2 {
+ /// Canonical bytes signed by every spending authorization. Signatures are excluded.
+ pub fn signing_bytes(&self, domain: &TransactionV2Domain) -> Result<Vec<u8>> {
+ self.validate_unsigned_shape()?;
+ let mut bytes = encode_prefix(domain)?;
+ self.encode_unsigned_body(&mut bytes)?;
+ Ok(bytes)
+ }
+
+ /// Canonical, length-delimited wire encoding. This is not used by live gossip or blocks.
+ pub fn encode(&self, domain: &TransactionV2Domain) -> Result<Vec<u8>> {
+ self.validate_shape()?;
+ let mut bytes = self.signing_bytes(domain)?;
+ let authorizations = self.authorizations();
+ encode_count(&mut bytes, authorizations.len(), "authorization count")?;
+ for authorization in authorizations {
+ bytes.push(authorization.scheme().wire_id());
+ encode_bytes(
+ &mut bytes,
+ authorization.public_key().as_bytes(),
+ "authorization public key",
+ )?;
+ encode_bytes(
+ &mut bytes,
+ authorization.signature().as_bytes(),
+ "authorization signature",
+ )?;
+ }
+ Ok(bytes)
+ }
+
+ pub fn decode(encoded: &[u8]) -> Result<(TransactionV2Domain, Self)> {
+ let mut reader = Reader::new(encoded);
+ if reader.take(TRANSACTION_V2_TAG.len(), "transaction v2 tag")? != TRANSACTION_V2_TAG {
+ bail!("transaction v2 tag is invalid");
+ }
+ let version = reader.u16("transaction version")?;
+ if version != TRANSACTION_V2_WIRE_VERSION {
+ bail!("unsupported transaction version {version}");
+ }
+ let chain_id = reader.length_prefixed(MAX_CHAIN_ID_BYTES, "chain ID")?;
+ let chain_id = std::str::from_utf8(chain_id)
+ .context("transaction v2 chain ID is not UTF-8")?
+ .to_string();
+ let genesis_hash = reader.array::<32>("genesis hash")?;
+ let domain = TransactionV2Domain::new(chain_id, genesis_hash)?;
+ let kind = reader.u8("transaction kind")?;
+
+ let unsigned = match kind {
+ 1 => UnsignedDecoded::Transfer {
+ inputs: decode_inputs(&mut reader)?,
+ outputs: decode_outputs(&mut reader)?,
+ fee: reader.u64("transfer fee")?,
+ },
+ 2 => {
+ let inputs = decode_inputs(&mut reader)?;
+ let change = decode_outputs(&mut reader)?;
+ let amount = reader.u64("burn amount")?;
+ let fee = reader.u64("burn fee")?;
+ let anchor = decode_optional_array::<32>(&mut reader, "burn anchor")?;
+ UnsignedDecoded::Burn {
+ inputs,
+ change,
+ amount,
+ fee,
+ anchor,
+ }
+ }
+ 3 => {
+ let recipient = decode_address(&mut reader, "mine recipient")?;
+ let anchor = reader.array::<32>("mine anchor")?;
+ let salt = reader.u64("mine salt")?;
+ let nonce = reader.u64("mine nonce")?;
+ let difficulty_bits = reader.u32("mine difficulty")?;
+ let proof_header = decode_optional_array::<STRATUM_PROOF_HEADER_BYTES>(
+ &mut reader,
+ "proof header",
+ )?;
+ let proof_hash = reader.array::<32>("proof hash")?;
+ UnsignedDecoded::Mine {
+ recipient,
+ anchor,
+ salt,
+ nonce,
+ difficulty_bits,
+ proof_header,
+ proof_hash,
+ }
+ }
+ _ => bail!("unsupported transaction v2 kind {kind}"),
+ };
+
+ let authorization_count = reader.count(MAX_V2_INPUTS, "authorization count")?;
+ let mut authorizations = Vec::with_capacity(authorization_count);
+ for _ in 0..authorization_count {
+ let scheme_id = reader.u8("authorization scheme")?;
+ let scheme = SignatureScheme::from_wire_id(scheme_id)
+ .with_context(|| format!("unknown signature scheme {scheme_id}"))?;
+ let public_key =
+ reader.length_prefixed(scheme.public_key_bytes(), "authorization public key")?;
+ if public_key.len() != scheme.public_key_bytes() {
+ bail!("authorization public key has the wrong scheme-specific length");
+ }
+ let signature =
+ reader.length_prefixed(scheme.signature_bytes(), "authorization signature")?;
+ if signature.len() != scheme.signature_bytes() {
+ bail!("authorization signature has the wrong scheme-specific length");
+ }
+ authorizations.push(V2SpendingAuthorization::new(
+ ProtocolPublicKey::new(scheme, public_key.to_vec())?,
+ ProtocolSignature::new(scheme, signature.to_vec())?,
+ )?);
+ }
+ reader.finish()?;
+
+ let transaction = unsigned.with_authorizations(authorizations)?;
+ transaction.validate_shape()?;
+ Ok((domain, transaction))
+ }
+
+ /// A v2 outpoint uses this fixed-size ID instead of a potentially variable signature.
+ pub fn transaction_id(&self, domain: &TransactionV2Domain) -> Result<[u8; 32]> {
+ Ok(Sha256::digest(self.encode(domain)?).into())
+ }
+
+ 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");
+ }
+ if input.owner.version != AddressVersion::HybridKeyCommitment {
+ bail!("transaction v2 input owner must be an address-v1 commitment");
+ }
+ if input.owner.payload != public_key_commitment(authorization.public_key()) {
+ bail!("transaction v2 authorization does not match its owner commitment");
+ }
+ }
+ Ok(())
+ }
+
+ /// Verifies the Ed25519 half of every hybrid signature. ML-DSA verification remains dormant
+ /// until a reviewed cryptographic backend is selected; this method must not imply activation.
+ pub fn verify_classical_hybrid_components(&self, domain: &TransactionV2Domain) -> Result<()> {
+ self.validate_authorization_commitments()?;
+ let payload = self.signing_bytes(domain)?;
+ for authorization in self.authorizations() {
+ let public_key: [u8; 32] = authorization.public_key().as_bytes()[..32]
+ .try_into()
+ .expect("validated hybrid public key length");
+ let signature: [u8; 64] = authorization.signature().as_bytes()[..64]
+ .try_into()
+ .expect("validated hybrid signature length");
+ verify_ed25519(
+ &public_key,
+ &payload,
+ &signature,
+ "transaction v2 classical component",
+ )?;
+ }
+ Ok(())
+ }
+
+ fn validate_shape(&self) -> Result<()> {
+ self.validate_unsigned_shape()?;
+ if self.authorizations().len() != self.inputs().len() {
+ bail!("transaction v2 requires exactly one authorization per input");
+ }
+ Ok(())
+ }
+
+ fn validate_unsigned_shape(&self) -> Result<()> {
+ if self.inputs().len() > MAX_V2_INPUTS {
+ bail!("transaction v2 has too many inputs");
+ }
+ if self.outputs().len() > MAX_V2_OUTPUTS {
+ bail!("transaction v2 has too many outputs");
+ }
+ Ok(())
+ }
+
+ fn inputs(&self) -> &[TransactionV2Input] {
+ match self {
+ Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs,
+ Self::Mine { .. } => &[],
+ }
+ }
+
+ fn outputs(&self) -> &[TransactionV2Output] {
+ match self {
+ Self::Transfer { outputs, .. } => outputs,
+ Self::Burn { change, .. } => change,
+ Self::Mine { .. } => &[],
+ }
+ }
+
+ fn authorizations(&self) -> &[V2SpendingAuthorization] {
+ match self {
+ Self::Transfer { authorizations, .. } | Self::Burn { authorizations, .. } => {
+ authorizations
+ }
+ Self::Mine { .. } => &[],
+ }
+ }
+
+ fn encode_unsigned_body(&self, bytes: &mut Vec<u8>) -> Result<()> {
+ match self {
+ Self::Transfer {
+ inputs,
+ outputs,
+ fee,
+ ..
+ } => {
+ bytes.push(1);
+ encode_inputs(bytes, inputs)?;
+ encode_outputs(bytes, outputs)?;
+ bytes.extend_from_slice(&fee.to_be_bytes());
+ }
+ Self::Burn {
+ inputs,
+ change,
+ amount,
+ fee,
+ anchor,
+ ..
+ } => {
+ bytes.push(2);
+ encode_inputs(bytes, inputs)?;
+ encode_outputs(bytes, change)?;
+ bytes.extend_from_slice(&amount.to_be_bytes());
+ bytes.extend_from_slice(&fee.to_be_bytes());
+ encode_optional_array(bytes, anchor);
+ }
+ Self::Mine {
+ recipient,
+ anchor,
+ salt,
+ nonce,
+ difficulty_bits,
+ proof_header,
+ proof_hash,
+ } => {
+ bytes.push(3);
+ encode_address(bytes, recipient);
+ bytes.extend_from_slice(anchor);
+ bytes.extend_from_slice(&salt.to_be_bytes());
+ bytes.extend_from_slice(&nonce.to_be_bytes());
+ bytes.extend_from_slice(&difficulty_bits.to_be_bytes());
+ encode_optional_array(bytes, proof_header);
+ bytes.extend_from_slice(proof_hash);
+ }
+ }
+ Ok(())
+ }
+}
+
+pub const fn transaction_v2_is_active(height: u64) -> bool {
+ match TRANSACTION_V2_ACTIVATION_HEIGHT {
+ Some(activation_height) => height >= activation_height,
+ None => false,
+ }
+}
+
+pub fn ensure_transaction_v2_active(height: u64) -> Result<()> {
+ if !transaction_v2_is_active(height) {
+ bail!("transaction v2 is recognized but not consensus-active");
+ }
+ Ok(())
+}
+
+pub fn hybrid_key_commitment_address(public_key: &ProtocolPublicKey) -> Result<VersionedAddress> {
+ if public_key.scheme() != SignatureScheme::HybridEd25519MlDsa44 {
+ bail!("address v1 requires an Ed25519 + ML-DSA-44 public key");
+ }
+ Ok(VersionedAddress {
+ version: AddressVersion::HybridKeyCommitment,
+ payload: public_key_commitment(public_key),
+ })
+}
+
+fn public_key_commitment(public_key: &ProtocolPublicKey) -> [u8; 32] {
+ let mut hasher = Sha256::new();
+ hasher.update(ADDRESS_V1_COMMITMENT_TAG);
+ hasher.update([public_key.scheme().wire_id()]);
+ match public_key.scheme() {
+ SignatureScheme::HybridEd25519MlDsa44 => {
+ let (ed25519, ml_dsa) = public_key.as_bytes().split_at(32);
+ hash_length_prefixed(&mut hasher, ed25519);
+ hash_length_prefixed(&mut hasher, ml_dsa);
+ }
+ SignatureScheme::Ed25519 | SignatureScheme::MlDsa44 => {
+ hash_length_prefixed(&mut hasher, public_key.as_bytes());
+ }
+ }
+ hasher.finalize().into()
+}
+
+fn hash_length_prefixed(hasher: &mut Sha256, bytes: &[u8]) {
+ hasher.update((bytes.len() as u32).to_be_bytes());
+ hasher.update(bytes);
+}
+
+fn encode_prefix(domain: &TransactionV2Domain) -> Result<Vec<u8>> {
+ let mut bytes = Vec::new();
+ bytes.extend_from_slice(TRANSACTION_V2_TAG);
+ bytes.extend_from_slice(&TRANSACTION_V2_WIRE_VERSION.to_be_bytes());
+ encode_bytes(&mut bytes, domain.chain_id.as_bytes(), "chain ID")?;
+ bytes.extend_from_slice(&domain.genesis_hash);
+ Ok(bytes)
+}
+
+fn encode_inputs(bytes: &mut Vec<u8>, inputs: &[TransactionV2Input]) -> Result<()> {
+ encode_count(bytes, inputs.len(), "input count")?;
+ for input in inputs {
+ bytes.extend_from_slice(&input.outpoint_txid);
+ bytes.extend_from_slice(&input.outpoint_index.to_be_bytes());
+ encode_address(bytes, &input.owner);
+ }
+ Ok(())
+}
+
+fn decode_inputs(reader: &mut Reader<'_>) -> Result<Vec<TransactionV2Input>> {
+ let count = reader.count(MAX_V2_INPUTS, "input count")?;
+ let mut inputs = Vec::with_capacity(count);
+ for _ in 0..count {
+ inputs.push(TransactionV2Input {
+ outpoint_txid: reader.array::<32>("input transaction ID")?,
+ outpoint_index: reader.u32("input output index")?,
+ owner: decode_address(reader, "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 {
+ encode_address(bytes, &output.address);
+ bytes.extend_from_slice(&output.amount.to_be_bytes());
+ }
+ Ok(())
+}
+
+fn decode_outputs(reader: &mut Reader<'_>) -> Result<Vec<TransactionV2Output>> {
+ let count = reader.count(MAX_V2_OUTPUTS, "output count")?;
+ let mut outputs = Vec::with_capacity(count);
+ for _ in 0..count {
+ outputs.push(TransactionV2Output {
+ address: decode_address(reader, "output address")?,
+ amount: reader.u64("output amount")?,
+ });
+ }
+ Ok(outputs)
+}
+
+fn encode_address(bytes: &mut Vec<u8>, address: &VersionedAddress) {
+ bytes.push(address.version.wire_id());
+ bytes.extend_from_slice(&address.payload);
+}
+
+fn decode_address(reader: &mut Reader<'_>, label: &str) -> Result<VersionedAddress> {
+ let version_id = reader.u8(label)?;
+ let version = AddressVersion::from_wire_id(version_id)
+ .with_context(|| format!("unsupported address version {version_id}"))?;
+ Ok(VersionedAddress {
+ version,
+ payload: reader.array::<32>(label)?,
+ })
+}
+
+fn encode_optional_array<const N: usize>(bytes: &mut Vec<u8>, value: &Option<[u8; N]>) {
+ match value {
+ Some(value) => {
+ bytes.push(1);
+ bytes.extend_from_slice(value);
+ }
+ None => bytes.push(0),
+ }
+}
+
+fn decode_optional_array<const N: usize>(
+ reader: &mut Reader<'_>,
+ label: &str,
+) -> Result<Option<[u8; N]>> {
+ match reader.u8(label)? {
+ 0 => Ok(None),
+ 1 => Ok(Some(reader.array::<N>(label)?)),
+ marker => bail!("{label} has invalid presence marker {marker}"),
+ }
+}
+
+fn encode_count(bytes: &mut Vec<u8>, count: usize, label: &str) -> Result<()> {
+ let count = u32::try_from(count).with_context(|| format!("{label} exceeds u32"))?;
+ bytes.extend_from_slice(&count.to_be_bytes());
+ Ok(())
+}
+
+fn encode_bytes(bytes: &mut Vec<u8>, value: &[u8], label: &str) -> Result<()> {
+ encode_count(bytes, value.len(), label)?;
+ bytes.extend_from_slice(value);
+ Ok(())
+}
+
+enum UnsignedDecoded {
+ Transfer {
+ inputs: Vec<TransactionV2Input>,
+ outputs: Vec<TransactionV2Output>,
+ fee: u64,
+ },
+ Burn {
+ inputs: Vec<TransactionV2Input>,
+ change: Vec<TransactionV2Output>,
+ amount: u64,
+ fee: u64,
+ anchor: Option<[u8; 32]>,
+ },
+ Mine {
+ recipient: VersionedAddress,
+ anchor: [u8; 32],
+ salt: u64,
+ nonce: u64,
+ difficulty_bits: u32,
+ proof_header: Option<[u8; STRATUM_PROOF_HEADER_BYTES]>,
+ proof_hash: [u8; 32],
+ },
+}
+
+impl UnsignedDecoded {
+ fn with_authorizations(
+ self,
+ authorizations: Vec<V2SpendingAuthorization>,
+ ) -> Result<TransactionV2> {
+ Ok(match self {
+ Self::Transfer {
+ inputs,
+ outputs,
+ fee,
+ } => TransactionV2::Transfer {
+ inputs,
+ outputs,
+ fee,
+ authorizations,
+ },
+ Self::Burn {
+ inputs,
+ change,
+ amount,
+ fee,
+ anchor,
+ } => TransactionV2::Burn {
+ inputs,
+ change,
+ amount,
+ fee,
+ anchor,
+ authorizations,
+ },
+ Self::Mine {
+ recipient,
+ anchor,
+ salt,
+ nonce,
+ difficulty_bits,
+ proof_header,
+ proof_hash,
+ } => {
+ if !authorizations.is_empty() {
+ bail!("mine transaction v2 cannot contain spending authorizations");
+ }
+ TransactionV2::Mine {
+ recipient,
+ anchor,
+ salt,
+ nonce,
+ difficulty_bits,
+ proof_header,
+ proof_hash,
+ }
+ }
+ })
+ }
+}
+
+struct Reader<'a> {
+ remaining: &'a [u8],
+}
+
+impl<'a> Reader<'a> {
+ fn new(bytes: &'a [u8]) -> Self {
+ Self { remaining: bytes }
+ }
+
+ fn take(&mut self, length: usize, label: &str) -> Result<&'a [u8]> {
+ if self.remaining.len() < length {
+ bail!("transaction v2 {label} is truncated");
+ }
+ let (value, remaining) = self.remaining.split_at(length);
+ self.remaining = remaining;
+ Ok(value)
+ }
+
+ fn u8(&mut self, label: &str) -> Result<u8> {
+ Ok(self.take(1, label)?[0])
+ }
+
+ fn u16(&mut self, label: &str) -> Result<u16> {
+ Ok(u16::from_be_bytes(self.array(label)?))
+ }
+
+ fn u32(&mut self, label: &str) -> Result<u32> {
+ Ok(u32::from_be_bytes(self.array(label)?))
+ }
+
+ fn u64(&mut self, label: &str) -> Result<u64> {
+ Ok(u64::from_be_bytes(self.array(label)?))
+ }
+
+ fn array<const N: usize>(&mut self, label: &str) -> Result<[u8; N]> {
+ Ok(self
+ .take(N, label)?
+ .try_into()
+ .expect("reader returned requested fixed length"))
+ }
+
+ fn count(&mut self, maximum: usize, label: &str) -> Result<usize> {
+ let count = self.u32(label)? as usize;
+ if count > maximum {
+ bail!("transaction v2 {label} exceeds {maximum}");
+ }
+ Ok(count)
+ }
+
+ fn length_prefixed(&mut self, maximum: usize, label: &str) -> Result<&'a [u8]> {
+ let length = self.u32(label)? as usize;
+ if length > maximum {
+ bail!("transaction v2 {label} exceeds {maximum} bytes");
+ }
+ self.take(length, label)
+ }
+
+ fn finish(self) -> Result<()> {
+ if !self.remaining.is_empty() {
+ bail!("transaction v2 contains trailing bytes");
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ed25519_dalek::{Signer, SigningKey};
+
+ use super::*;
+ use crate::domain::hex::hex_encode;
+
+ fn domain() -> TransactionV2Domain {
+ TransactionV2Domain::new("iuna-v2-test", [0x22; 32]).unwrap()
+ }
+
+ fn hybrid_authorization(payload: &[u8]) -> V2SpendingAuthorization {
+ let signing_key = SigningKey::from_bytes(&[7; 32]);
+ let mut public_key = signing_key.verifying_key().to_bytes().to_vec();
+ public_key.extend_from_slice(&vec![0x44; 1_312]);
+ let mut signature = signing_key.sign(payload).to_bytes().to_vec();
+ signature.extend_from_slice(&vec![0x55; 2_420]);
+ V2SpendingAuthorization::new(
+ ProtocolPublicKey::new(SignatureScheme::HybridEd25519MlDsa44, public_key).unwrap(),
+ ProtocolSignature::new(SignatureScheme::HybridEd25519MlDsa44, signature).unwrap(),
+ )
+ .unwrap()
+ }
+
+ fn unsigned_transfer(owner: VersionedAddress) -> TransactionV2 {
+ TransactionV2::Transfer {
+ inputs: vec![TransactionV2Input {
+ outpoint_txid: [0x11; 32],
+ outpoint_index: 7,
+ owner,
+ }],
+ outputs: vec![TransactionV2Output {
+ address: VersionedAddress {
+ version: AddressVersion::HybridKeyCommitment,
+ payload: [0x33; 32],
+ },
+ amount: 5,
+ }],
+ fee: 1,
+ authorizations: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn v2_is_explicitly_dormant_at_every_height() {
+ assert_eq!(TRANSACTION_V2_ACTIVATION_HEIGHT, None);
+ assert!(!transaction_v2_is_active(0));
+ assert!(!transaction_v2_is_active(u64::MAX));
+ assert!(ensure_transaction_v2_active(u64::MAX).is_err());
+ }
+
+ #[test]
+ fn hybrid_transfer_roundtrips_and_has_a_hash_id() {
+ let signing_key = SigningKey::from_bytes(&[7; 32]);
+ let mut public_key_bytes = signing_key.verifying_key().to_bytes().to_vec();
+ public_key_bytes.extend_from_slice(&vec![0x44; 1_312]);
+ let public_key =
+ ProtocolPublicKey::new(SignatureScheme::HybridEd25519MlDsa44, public_key_bytes)
+ .unwrap();
+ let owner = hybrid_key_commitment_address(&public_key).unwrap();
+ let mut transaction = unsigned_transfer(owner);
+ let signing_bytes = transaction.signing_bytes(&domain()).unwrap();
+ let authorization = hybrid_authorization(&signing_bytes);
+ if let TransactionV2::Transfer { authorizations, .. } = &mut transaction {
+ authorizations.push(authorization);
+ }
+
+ assert_eq!(
+ transaction.authorizations()[0].committed_address().unwrap(),
+ owner
+ );
+ transaction.validate_authorization_commitments().unwrap();
+ transaction
+ .verify_classical_hybrid_components(&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!(decoded.transaction_id(&domain()).unwrap().len(), 32);
+
+ let mut missing_authorization = transaction.clone();
+ if let TransactionV2::Transfer { authorizations, .. } = &mut missing_authorization {
+ authorizations.clear();
+ }
+ assert!(
+ missing_authorization
+ .validate_authorization_commitments()
+ .is_err()
+ );
+
+ let mut wrong_owner = transaction.clone();
+ if let TransactionV2::Transfer { inputs, .. } = &mut wrong_owner {
+ inputs[0].owner.payload[0] ^= 1;
+ }
+ assert!(wrong_owner.validate_authorization_commitments().is_err());
+ }
+
+ #[test]
+ fn decoder_fails_closed_for_versions_lengths_and_trailing_bytes() {
+ let transaction = TransactionV2::Mine {
+ recipient: VersionedAddress {
+ version: AddressVersion::Ed25519PublicKey,
+ payload: [3; 32],
+ },
+ anchor: [4; 32],
+ salt: 5,
+ nonce: 6,
+ difficulty_bits: 7,
+ proof_header: None,
+ proof_hash: [8; 32],
+ };
+ let encoded = transaction.encode(&domain()).unwrap();
+ assert_eq!(TransactionV2::decode(&encoded).unwrap().1, transaction);
+ assert_eq!(
+ hex_encode(&encoded),
+ concat!(
+ "49554e412d54582d5632", // IUNA-TX-V2
+ "0002", // wire version
+ "0000000c",
+ "69756e612d76322d74657374", // iuna-v2-test
+ "2222222222222222222222222222222222222222222222222222222222222222",
+ "03", // mine
+ "00", // address version 0
+ "0303030303030303030303030303030303030303030303030303030303030303",
+ "0404040404040404040404040404040404040404040404040404040404040404",
+ "0000000000000005", // salt
+ "0000000000000006", // nonce
+ "00000007", // difficulty
+ "00", // no proof header
+ "0808080808080808080808080808080808080808080808080808080808080808",
+ "00000000", // no spending authorizations
+ )
+ );
+ assert_eq!(
+ hex_encode(transaction.transaction_id(&domain()).unwrap()),
+ "cd611549a0d156e10f9ffae00058ddd2f372f1d46564158a5ccf1621b79beaef"
+ );
+
+ let mut unknown_version = encoded.clone();
+ unknown_version[TRANSACTION_V2_TAG.len() + 1] = 3;
+ assert!(TransactionV2::decode(&unknown_version).is_err());
+
+ let mut trailing = encoded.clone();
+ trailing.push(0);
+ assert!(TransactionV2::decode(&trailing).is_err());
+ assert!(TransactionV2::decode(&encoded[..encoded.len() - 1]).is_err());
+ }
+
+ #[test]
+ fn transaction_id_commits_to_authorization_bytes() {
+ let first = TransactionV2::Mine {
+ recipient: VersionedAddress {
+ version: AddressVersion::Ed25519PublicKey,
+ payload: [3; 32],
+ },
+ anchor: [4; 32],
+ salt: 5,
+ nonce: 6,
+ difficulty_bits: 7,
+ proof_header: None,
+ proof_hash: [8; 32],
+ };
+ let mut second = first.clone();
+ if let TransactionV2::Mine { proof_hash, .. } = &mut second {
+ proof_hash[0] ^= 1;
+ }
+ assert_ne!(
+ first.transaction_id(&domain()).unwrap(),
+ second.transaction_id(&domain()).unwrap()
+ );
+ }
+}