use sha2::Digest;
use thruput_json_rpc_types::{base58::Base58, transaction::TxHash};
use thruput_tx_validation::{NetworkConfigParams, check_transaction};
use crate::{ValidatedTransaction, error::RpcServerError};
type SignaturesField = xdrs::VecM<thruput_xdr::transaction::DecoratedSignature>;
type PubkeyClaimKeyField = Option<thruput_xdr::types::PublicKey>;
const _: fn(thruput_xdr::transaction::SignedTransaction) = |signed| {
let thruput_xdr::transaction::SignedTransaction {
tx: _,
signatures,
pubkey_claim_key,
} = signed;
let _: SignaturesField = signatures;
let _: PubkeyClaimKeyField = pubkey_claim_key;
};
pub fn validate_raw_transaction_bytes(
tx_bytes: Vec<u8>,
params: &NetworkConfigParams,
) -> Result<ValidatedTransaction, RpcServerError> {
if u32::try_from(tx_bytes.len()).is_err() {
return Err(RpcServerError::TxInvalid("transaction too large".into()));
}
let mut reader = xdrs::txs::buf_reader::BufReader::new(&tx_bytes);
let tx: thruput_xdr::transaction::Transaction = xdrs::read_xdr(&mut reader)
.map_err(|e| RpcServerError::TxInvalid(format!("invalid XDR: {e}")))?;
let tx_end = reader.position();
let _signatures: SignaturesField = xdrs::read_xdr(&mut reader)
.map_err(|e| RpcServerError::TxInvalid(format!("invalid XDR: {e}")))?;
let pubkey_claim_key: PubkeyClaimKeyField = xdrs::read_xdr(&mut reader)
.map_err(|e| RpcServerError::TxInvalid(format!("invalid XDR: {e}")))?;
if reader.remaining() > 0 {
return Err(RpcServerError::TxInvalid(
"invalid XDR: trailing bytes after signed transaction".into(),
));
}
let pubkey_claim = matches!(
tx.auth_claim,
thruput_xdr::auth_claim::AuthClaim::kPubkeySignature { .. }
);
if pubkey_claim != pubkey_claim_key.is_some() {
return Err(RpcServerError::TxInvalid(if pubkey_claim {
"pubkey signature claim requires pubkey_claim_key".into()
} else {
"pubkey_claim_key is only allowed with a pubkey signature claim".into()
}));
}
check_transaction(&tx, params).map_err(RpcServerError::TxInvalidDetailed)?;
let hash_bytes: [u8; 32] = sha2::Sha256::digest(&tx_bytes[..tx_end]).into();
let hash = TxHash(Base58(thruput_xdr::types::SHA256Hash::from(hash_bytes)));
Ok(ValidatedTransaction {
bytes: tx_bytes,
hash,
block_expiry_height: tx.uniquifier.block_expiry_height,
})
}
#[cfg(test)]
pub(crate) mod test_fixtures {
use thruput_xdr::{contract::Contract, transaction::SignedTransaction};
pub(crate) const CANONICAL_HASH_VECTOR_SIGNED_TX_XDR: &str = "\
bc10f38522c4e14265a1483af9e5375f5dea0e2c6bf25727ee8b429bf3c1a9b7\
000000000000000000000000000f42400000000000000000000f424000000000000000010000000000000001\
13df30e7ceeb7c4c78ba6287e9d8830cabf5a744f3d6be3139f2428d7bb4f9e7000000000000000000000001\
000000000000000041d0144af2776cae9804ed899018a6bc17a5705d74c84e02556b9695031c9d3fe2554e5c\
e62ceadc95ba43c4aa0884cd2f9ebea4e3845ef877761a6222e91f0d000000010000000050ec72dbafd4898c\
5df0e15ea6ba3e46240945b7210fe2d63dde1e896f42107a";
pub(crate) const CANONICAL_HASH: &str =
"319674d13f52cc88935d86c37bb2c87710c24a3371e74fbc1480fc960aebb808";
pub(crate) fn canonical_signed_transaction() -> SignedTransaction {
let canonical = hex::decode(CANONICAL_HASH_VECTOR_SIGNED_TX_XDR)
.expect("bug: canonical hash vector should be valid hex");
xdrs::txs::decode(&canonical).expect("bug: canonical hash vector should decode")
}
pub(crate) fn signed_transaction_with_contracts(contracts: Vec<Contract>) -> SignedTransaction {
let mut signed = canonical_signed_transaction();
signed.tx.contracts_to_deploy = xdrs::VecM(contracts);
signed
}
pub(crate) fn signed_tx_bytes_with_contracts(contracts: Vec<Contract>) -> Vec<u8> {
xdrs::txs::encode(&signed_transaction_with_contracts(contracts))
.expect("bug: re-encoded signed tx should encode")
}
}
#[cfg(test)]
mod tests {
use thruput_json_rpc_types::error::TxInvalidDetail;
use thruput_xdr::contract::{Contract, ContractBlob};
use xdr_common::BytesM;
use super::test_fixtures::{
CANONICAL_HASH, CANONICAL_HASH_VECTOR_SIGNED_TX_XDR, canonical_signed_transaction,
signed_tx_bytes_with_contracts,
};
use super::*;
use thruput_xdr::contract::kDefaultMaxContractSizeBytes;
fn canonical_bytes() -> Vec<u8> {
hex::decode(CANONICAL_HASH_VECTOR_SIGNED_TX_XDR).expect("bug: fixture should be valid hex")
}
fn rv32(size: usize, fill: u8) -> Contract {
Contract::kRv32 {
rv32_contract: ContractBlob {
data: BytesM(vec![fill; size]),
},
}
}
fn signed_tx_with_signature_count(count: usize) -> Vec<u8> {
let mut signed = canonical_signed_transaction();
let signature = signed.signatures.0[0].clone();
signed.signatures = xdrs::VecM(vec![signature; count]);
xdrs::txs::encode(&signed).expect("bug: re-encoded signed tx should encode")
}
#[test]
fn prefix_hash_matches_reencoded_inner_transaction() {
for count in [0usize, 1, 2, 5] {
let bytes = signed_tx_with_signature_count(count);
let signed: thruput_xdr::transaction::SignedTransaction =
xdrs::txs::decode(&bytes).expect("bug: re-encoded signed tx should decode");
let reencoded = xdrs::txs::encode(&signed.tx).expect("bug: inner tx should encode");
let expected: [u8; 32] = sha2::Sha256::digest(&reencoded).into();
let validated = validate_raw_transaction_bytes(bytes, &NetworkConfigParams::default())
.expect("bug: re-encoded signed tx should validate");
assert_eq!(
validated.hash.0.0.as_ref(),
expected.as_slice(),
"hashed prefix diverged from the canonical Transaction encoding \
at {count} signature(s); SignedTransaction layout may have changed"
);
}
}
#[test]
fn signature_count_does_not_affect_the_hash() {
for count in [0usize, 1, 2, 5] {
let validated = validate_raw_transaction_bytes(
signed_tx_with_signature_count(count),
&NetworkConfigParams::default(),
)
.expect("bug: re-encoded signed tx should validate");
assert_eq!(
hex::encode(validated.hash.0.0.as_ref()),
CANONICAL_HASH,
"hash changed at {count} signature(s)"
);
}
}
#[test]
fn invalid_xdr_is_rejected() {
let err = validate_raw_transaction_bytes(vec![0], &NetworkConfigParams::default())
.expect_err("bug: invalid XDR should fail");
assert!(matches!(err, RpcServerError::TxInvalid(_)));
}
#[test]
fn exposes_block_expiry_height_from_decoded_transaction() {
let tx = validate_raw_transaction_bytes(canonical_bytes(), &NetworkConfigParams::default())
.expect("bug: canonical hash vector should validate as XDR");
assert_eq!(*tx.block_expiry_height, 1_000_000);
}
#[test]
fn computes_canonical_hash_from_inner_transaction() {
let tx = validate_raw_transaction_bytes(canonical_bytes(), &NetworkConfigParams::default())
.expect("bug: canonical hash vector should validate as XDR");
assert_eq!(hex::encode(tx.hash.0.0.as_ref()), CANONICAL_HASH);
}
#[test]
fn trailing_bytes_after_signed_transaction_are_rejected() {
let mut bytes = canonical_bytes();
bytes.push(0);
let err = validate_raw_transaction_bytes(bytes, &NetworkConfigParams::default())
.expect_err("bug: trailing bytes should be rejected");
assert!(matches!(err, RpcServerError::TxInvalid(_)));
}
#[test]
fn truncated_envelope_is_rejected() {
let mut truncated = canonical_bytes();
truncated.truncate(truncated.len() - 4);
let err = validate_raw_transaction_bytes(truncated, &NetworkConfigParams::default())
.expect_err("bug: a truncated envelope should be rejected");
assert!(matches!(err, RpcServerError::TxInvalid(_)));
}
#[test]
fn pubkey_claim_requires_its_claim_key() {
let mut signed = canonical_signed_transaction();
signed.pubkey_claim_key = None;
let bytes = xdrs::txs::encode(&signed).unwrap();
let err = validate_raw_transaction_bytes(bytes, &NetworkConfigParams::default())
.expect_err("bug: a pubkey claim without its key should be rejected");
assert_eq!(
err,
RpcServerError::TxInvalid("pubkey signature claim requires pubkey_claim_key".into())
);
}
#[test]
fn claim_key_is_rejected_without_a_pubkey_claim() {
let mut signed = canonical_signed_transaction();
signed.tx.auth_claim = thruput_xdr::auth_claim::AuthClaim::kSourceAddressAuthData {
source_address_claim: thruput_xdr::auth_claim::AuthClaim_source_address_claim {
allowed_keys: 1,
required_weight: 1,
},
};
let bytes = xdrs::txs::encode(&signed).unwrap();
let err = validate_raw_transaction_bytes(bytes, &NetworkConfigParams::default())
.expect_err("bug: a claim key without a pubkey claim should be rejected");
assert_eq!(
err,
RpcServerError::TxInvalid(
"pubkey_claim_key is only allowed with a pubkey signature claim".into()
)
);
}
#[test]
fn bound_violation_is_rejected_with_structured_detail() {
let params = NetworkConfigParams {
max_new_contracts_per_tx: Some(1),
..NetworkConfigParams::default()
};
let bytes = signed_tx_bytes_with_contracts(vec![rv32(8, 1), rv32(8, 2)]);
let err = validate_raw_transaction_bytes(bytes, ¶ms)
.expect_err("bug: two contracts should exceed a cap of one");
assert_eq!(
err,
RpcServerError::TxInvalidDetailed(TxInvalidDetail::TooManyContracts {
attached: 2,
max: 1
})
);
}
#[test]
fn framing_errors_are_reported_before_bound_violations() {
let params = NetworkConfigParams {
max_new_contracts_per_tx: Some(1),
..NetworkConfigParams::default()
};
let mut bytes = signed_tx_bytes_with_contracts(vec![rv32(8, 1), rv32(8, 2)]);
bytes.push(0);
let err = validate_raw_transaction_bytes(bytes, ¶ms)
.expect_err("bug: trailing bytes should be rejected");
assert_eq!(
err,
RpcServerError::TxInvalid(
"invalid XDR: trailing bytes after signed transaction".into()
)
);
}
#[test]
fn oversized_contract_is_rejected_without_a_config_reading() {
let bytes = signed_tx_bytes_with_contracts(vec![rv32(
kDefaultMaxContractSizeBytes as usize + 1,
1,
)]);
let err = validate_raw_transaction_bytes(bytes, &NetworkConfigParams::default())
.expect_err("bug: oversized contract should be rejected");
assert!(matches!(
err,
RpcServerError::TxInvalidDetailed(TxInvalidDetail::ContractTooLarge { index: 0, .. })
));
}
#[test]
fn attached_contracts_within_bounds_still_hash_the_inner_transaction() {
let params = NetworkConfigParams {
max_new_contracts_per_tx: Some(2),
..NetworkConfigParams::default()
};
let signed = super::test_fixtures::signed_transaction_with_contracts(vec![rv32(8, 1)]);
let bytes = xdrs::txs::encode(&signed).unwrap();
let reencoded = xdrs::txs::encode(&signed.tx).unwrap();
let expected: [u8; 32] = sha2::Sha256::digest(&reencoded).into();
let validated = validate_raw_transaction_bytes(bytes, ¶ms)
.expect("bug: in-bounds attachments should validate");
assert_eq!(validated.hash.0.0.as_ref(), expected.as_slice());
}
#[test]
fn empty_input_is_rejected() {
let err = validate_raw_transaction_bytes(vec![], &NetworkConfigParams::default())
.expect_err("bug: empty input should fail XDR decode");
assert!(matches!(err, RpcServerError::TxInvalid(_)));
}
}