use crate::wallet;
const TYPE_BYTE: u8 = 0x76;
const SENDER_DOMAIN: u8 = 0x76;
const FEE_PAYER_DOMAIN: u8 = 0x78;
#[derive(Debug, Clone)]
pub struct TempoCall {
pub to: [u8; 20],
pub value_wei: u128,
pub input: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct TempoTx {
pub chain_id: u64,
pub max_priority_fee_per_gas: u128,
pub max_fee_per_gas: u128,
pub gas_limit: u128,
pub calls: Vec<TempoCall>,
pub access_list: Vec<AccessListItem>,
pub nonce_key: u128,
pub nonce: u128,
pub valid_before: Option<u64>,
pub valid_after: Option<u64>,
pub fee_token: Option<[u8; 20]>,
pub aa_authorization_list: Vec<SignedAuthorization>,
pub key_authorization: Option<KeyAuthorization>,
pub(crate) sponsored: bool,
pub(crate) create: bool,
}
#[derive(Debug, Clone)]
pub struct AccessListItem {
pub address: [u8; 20],
pub storage_keys: Vec<[u8; 32]>,
}
#[derive(Debug, Clone)]
pub struct SignedAuthorization {
pub chain_id: u64,
pub address: [u8; 20],
pub nonce: u64,
pub signature: [u8; 65],
}
#[derive(Debug, Clone)]
pub struct KeyAuthorization {
pub raw_rlp: Vec<u8>,
}
impl TempoTx {
pub fn sender_hash(&self) -> [u8; 32] {
let mut items = self.common_rlp_items();
if self.is_sponsored() {
items.push(wallet::rlp_bytes(&[])); items.push(vec![0x00]); } else {
items.push(rlp_fee_token(self.fee_token.as_ref())); items.push(wallet::rlp_bytes(&[])); }
items.push(rlp_authorization_list(&self.aa_authorization_list));
if self.key_authorization.is_some() {
items.push(rlp_key_authorization(self.key_authorization.as_ref()));
}
let body = wallet::rlp_list(&items);
let mut payload = Vec::with_capacity(1 + body.len());
payload.push(SENDER_DOMAIN);
payload.extend_from_slice(&body);
keccak(&payload)
}
pub fn fee_payer_hash(&self, sender_address: &[u8; 20]) -> [u8; 32] {
let mut items = self.common_rlp_items();
items.push(rlp_fee_token(self.fee_token.as_ref()));
items.push(wallet::rlp_bytes(sender_address));
items.push(rlp_authorization_list(&self.aa_authorization_list));
if self.key_authorization.is_some() {
items.push(rlp_key_authorization(self.key_authorization.as_ref()));
}
let body = wallet::rlp_list(&items);
let mut payload = Vec::with_capacity(1 + body.len());
payload.push(FEE_PAYER_DOMAIN);
payload.extend_from_slice(&body);
keccak(&payload)
}
pub fn serialize_signed(
&self,
sender_sig: &[u8; 65],
fee_payer_sig: Option<&[u8; 65]>,
) -> Vec<u8> {
let mut items = self.common_rlp_items();
items.push(rlp_fee_token(self.fee_token.as_ref()));
match fee_payer_sig {
Some(sig) => items.push(rlp_vrs_signature(sig)),
None => items.push(wallet::rlp_bytes(&[])), }
items.push(rlp_authorization_list(&self.aa_authorization_list));
if let Some(_ka) = self.key_authorization.as_ref() {
items.push(rlp_key_authorization(self.key_authorization.as_ref()));
}
items.push(rlp_compact_signature(sender_sig));
let body = wallet::rlp_list(&items);
let mut out = Vec::with_capacity(1 + body.len());
out.push(TYPE_BYTE);
out.extend_from_slice(&body);
out
}
pub fn is_sponsored(&self) -> bool {
self.sponsored
}
}
#[derive(Debug, Clone)]
pub struct TempoTxBuilder {
inner: TempoTx,
sponsored: bool,
}
impl TempoTxBuilder {
pub fn new(chain_id: u64) -> Self {
Self {
inner: TempoTx {
chain_id,
max_priority_fee_per_gas: 0,
max_fee_per_gas: 0,
gas_limit: 0,
calls: Vec::new(),
access_list: Vec::new(),
nonce_key: 0,
nonce: 0,
valid_before: None,
valid_after: None,
fee_token: None,
aa_authorization_list: Vec::new(),
key_authorization: None,
sponsored: false,
create: false,
},
sponsored: false,
}
}
pub fn max_priority_fee_per_gas(mut self, v: u128) -> Self {
self.inner.max_priority_fee_per_gas = v;
self
}
pub fn max_fee_per_gas(mut self, v: u128) -> Self {
self.inner.max_fee_per_gas = v;
self
}
pub fn gas_limit(mut self, v: u128) -> Self {
self.inner.gas_limit = v;
self
}
pub fn nonce(mut self, v: u128) -> Self {
self.inner.nonce = v;
self
}
pub fn nonce_key(mut self, v: u128) -> Self {
self.inner.nonce_key = v;
self
}
pub fn fee_token(mut self, addr: [u8; 20]) -> Self {
self.inner.fee_token = Some(addr);
self
}
pub fn call(mut self, call: TempoCall) -> Self {
self.inner.calls.push(call);
self
}
pub fn calls(mut self, calls: Vec<TempoCall>) -> Self {
self.inner.calls = calls;
self
}
pub fn sponsored(mut self) -> Self {
self.sponsored = true;
self.inner.sponsored = true;
self
}
pub fn create(mut self) -> Self {
self.inner.create = true;
self
}
pub fn build(self) -> TempoTx {
self.inner
}
}
pub fn sign_self_paid(tx: TempoTx, sender: &k256::ecdsa::SigningKey) -> Vec<u8> {
let sender_hash = tx.sender_hash();
let sig = crate::wallet::sign_hash(sender, &sender_hash);
tx.serialize_signed(&sig, None)
}
pub fn sign_sponsored(
tx: TempoTx,
sender: &k256::ecdsa::SigningKey,
fee_payer: &k256::ecdsa::SigningKey,
) -> Vec<u8> {
debug_assert!(
tx.sponsored,
"sign_sponsored called on a non-sponsored TempoTx — \
use TempoTxBuilder::sponsored()"
);
let sender_addr = crate::wallet::address(sender);
let sender_hash = tx.sender_hash();
let fp_hash = tx.fee_payer_hash(&sender_addr);
let sender_sig = crate::wallet::sign_hash(sender, &sender_hash);
let fp_sig = crate::wallet::sign_hash(fee_payer, &fp_hash);
tx.serialize_signed(&sender_sig, Some(&fp_sig))
}
impl TempoTx {
#[allow(dead_code)]
pub(crate) fn set_sponsored(mut self, sponsored: bool) -> Self {
self.sponsored = sponsored;
self
}
}
fn rlp_fee_token(addr: Option<&[u8; 20]>) -> Vec<u8> {
match addr {
Some(a) => wallet::rlp_bytes(a),
None => wallet::rlp_bytes(&[]),
}
}
fn rlp_key_authorization(ka: Option<&KeyAuthorization>) -> Vec<u8> {
match ka {
Some(k) => k.raw_rlp.clone(),
None => wallet::rlp_bytes(&[]),
}
}
fn rlp_authorization_list(list: &[SignedAuthorization]) -> Vec<u8> {
if list.is_empty() {
return wallet::rlp_list(&[]);
}
let items: Vec<Vec<u8>> = list
.iter()
.map(|a| {
wallet::rlp_list(&[
wallet::rlp_uint(a.chain_id as u128),
wallet::rlp_bytes(&a.address),
wallet::rlp_uint(a.nonce as u128),
wallet::rlp_bytes(&a.signature),
])
})
.collect();
wallet::rlp_list(&items)
}
fn rlp_call(call: &TempoCall) -> Vec<u8> {
wallet::rlp_list(&[
wallet::rlp_bytes(&call.to),
wallet::rlp_uint(call.value_wei),
wallet::rlp_bytes(&call.input),
])
}
fn rlp_create_call(call: &TempoCall) -> Vec<u8> {
wallet::rlp_list(&[
wallet::rlp_bytes(&[]),
wallet::rlp_uint(call.value_wei),
wallet::rlp_bytes(&call.input),
])
}
fn rlp_access_list(list: &[AccessListItem]) -> Vec<u8> {
if list.is_empty() {
return wallet::rlp_list(&[]);
}
let items: Vec<Vec<u8>> = list
.iter()
.map(|item| {
let keys: Vec<Vec<u8>> =
item.storage_keys.iter().map(|k| wallet::rlp_bytes(k)).collect();
wallet::rlp_list(&[
wallet::rlp_bytes(&item.address),
wallet::rlp_list(&keys),
])
})
.collect();
wallet::rlp_list(&items)
}
fn rlp_compact_signature(sig: &[u8; 65]) -> Vec<u8> {
let mut packed = [0u8; 65];
packed.copy_from_slice(sig);
packed[64] = packed[64].saturating_sub(27); wallet::rlp_bytes(&packed)
}
fn rlp_vrs_signature(sig: &[u8; 65]) -> Vec<u8> {
let v = sig[64].saturating_sub(27); wallet::rlp_list(&[
wallet::rlp_uint(v as u128),
rlp_int_bytes(&sig[..32]),
rlp_int_bytes(&sig[32..64]),
])
}
fn rlp_int_bytes(be: &[u8]) -> Vec<u8> {
let first_non_zero = be.iter().position(|&b| b != 0).unwrap_or(be.len());
wallet::rlp_bytes(&be[first_non_zero..])
}
impl TempoTx {
fn common_rlp_items(&self) -> Vec<Vec<u8>> {
let call_items: Vec<Vec<u8>> = self
.calls
.iter()
.map(|c| if self.create { rlp_create_call(c) } else { rlp_call(c) })
.collect();
vec![
wallet::rlp_uint(self.chain_id as u128),
wallet::rlp_uint(self.max_priority_fee_per_gas),
wallet::rlp_uint(self.max_fee_per_gas),
wallet::rlp_uint(self.gas_limit),
wallet::rlp_list(&call_items),
rlp_access_list(&self.access_list),
rlp_uint_u256(self.nonce_key),
wallet::rlp_uint(self.nonce),
self.valid_before
.map(|v| wallet::rlp_uint(v as u128))
.unwrap_or_else(|| wallet::rlp_bytes(&[])),
self.valid_after
.map(|v| wallet::rlp_uint(v as u128))
.unwrap_or_else(|| wallet::rlp_bytes(&[])),
]
}
}
fn rlp_uint_u256(value: u128) -> Vec<u8> {
wallet::rlp_uint(value)
}
fn keccak(input: &[u8]) -> [u8; 32] {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update(input);
let mut out = [0u8; 32];
out.copy_from_slice(&hasher.finalize());
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_flag_encodes_empty_to() {
let init = vec![0x60u8, 0x00, 0x60, 0x00, 0xf3]; let calls_rlp = |creating: bool| {
let mut b = TempoTxBuilder::new(1)
.gas_limit(1_000_000)
.call(TempoCall { to: [0xab; 20], value_wei: 0, input: init.clone() });
if creating {
b = b.create();
}
b.build().common_rlp_items()[4].clone() };
assert!(
calls_rlp(false).windows(20).any(|w| w == [0xab; 20]),
"plain call must carry the 20-byte `to`"
);
assert!(
!calls_rlp(true).windows(20).any(|w| w == [0xab; 20]),
"create tx leaked the `to` address — it must encode empty (0x80)"
);
}
#[test]
fn rlp_vrs_signature_strips_leading_zero_r_and_s() {
let mut sig = [0u8; 65];
for i in 0..32 {
sig[i] = 0xaa;
sig[32 + i] = 0x11;
}
sig[0] = 0x00; sig[32] = 0x00; sig[64] = 28;
let enc = rlp_vrs_signature(&sig);
let (body_off, body_len, is_list) = rlp_header(&enc, 0);
assert!(is_list, "fee_payer sig must be an RLP list");
let mut i = body_off;
let (voff, vlen, _) = rlp_header(&enc, i);
i = voff + vlen;
let (roff, rlen, _) = rlp_header(&enc, i);
assert_eq!(rlen, 31, "r must be stripped to 31 minimal bytes, not 32");
assert_ne!(enc[roff], 0x00, "r must not start with a zero byte");
i = roff + rlen;
let (soff, slen, _) = rlp_header(&enc, i);
assert_eq!(slen, 31, "s must be stripped to 31 minimal bytes, not 32");
assert_ne!(enc[soff], 0x00, "s must not start with a zero byte");
i = soff + slen;
assert_eq!(i, body_off + body_len, "list body must be fully consumed");
assert!(
!enc.windows(2).any(|w| w == [0xa0, 0x00]),
"non-minimal integer (0xa0 0x00 ..) leaked into the fee_payer sig RLP"
);
}
fn dummy_tx() -> TempoTx {
TempoTxBuilder::new(42431)
.max_priority_fee_per_gas(1_000_000_000)
.max_fee_per_gas(40_000_000_000)
.gas_limit(200_000)
.nonce(0)
.call(TempoCall {
to: [0x11; 20],
value_wei: 0,
input: vec![0xde, 0xad, 0xbe, 0xef],
})
.build()
}
#[test]
fn sender_hash_self_paid_is_32_bytes() {
let tx = dummy_tx();
let h = tx.sender_hash();
assert_eq!(h.len(), 32);
}
#[test]
fn sender_hash_sponsored_differs_from_self_paid() {
let tx_self = dummy_tx();
let tx_sponsored = dummy_tx().set_sponsored(true);
assert_ne!(tx_self.sender_hash(), tx_sponsored.sender_hash());
}
#[test]
fn serialized_starts_with_type_byte() {
let tx = dummy_tx();
let sig = [0u8; 65];
let bytes = tx.serialize_signed(&sig, None);
assert_eq!(bytes[0], 0x76);
}
#[test]
fn fee_payer_hash_includes_sender_address() {
let tx = dummy_tx().set_sponsored(true);
let sender = [0x42; 20];
let other_sender = [0x99; 20];
assert_ne!(
tx.fee_payer_hash(&sender),
tx.fee_payer_hash(&other_sender)
);
}
const GOLDEN_SPONSORED_SENDER_HASH: &str =
"3e6d7f767fb15c062735b045126a54e9ea8f4d098cebe942cb18761532242d17";
const GOLDEN_SELF_PAID_SENDER_HASH: &str =
"3c842190b039b46368cfe5d12268bce7a539274d88c48ae43d0f7ef230f164d7";
const GOLDEN_FEE_PAYER_HASH: &str =
"a6e9b8ae237b8711335dad82bdcb3cda9b52278f4a479392bbc153e888a4b5b5";
const GOLDEN_SPONSORED_RAW_TX: &str =
"76f9011482a5bf843b9aca0084773594008316e360f85ef85c94d7d7d7d7d7d7d7d7d7d7\
d7d7d7d7d7d7d7d7d7d780b844a9059cbb000102030405060708090a0b0c0d0e0f101112\
131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f30313233343536\
3738393a3b3c3d3e3fc0800780809420c0000000000000000000000000000000000001f8\
4380a0bedf191eaaaa41e9b67003e472eed8eb0577b09a96a337158819aee742f8b951a0\
3352b344cad1fadc97aee9f08ddbc42d1648e76f6e16a937f6aa8703636b79c1c0b8419b\
46f696dddfbd4739b1bbf7a108ee4cde2de6826dcf49079fba621ca473a5f51f20fd463c\
4c1573accb51f4021bc108a1bfb44d2fbecd2bff45faf9969dcf6900";
const GOLDEN_SELF_PAID_RAW_TX: &str =
"76f8d082a5bf843b9aca0084773594008316e360f85ef85c94d7d7d7d7d7d7d7d7d7d7d7\
d7d7d7d7d7d7d7d7d780b844a9059cbb000102030405060708090a0b0c0d0e0f10111213\
1415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f3031323334353637\
38393a3b3c3d3e3fc0800780809420c000000000000000000000000000000000000180c0\
b8413f550f9766ba12e152f0a9ea828f3eaa45363c80278c98706d773d1b5f359c71538a\
9dbdd8140c8e7c6df77c6593727d34ecf36150484f59ae24912f759e3ce800";
fn golden_tx() -> TempoTx {
let mut alpha_usd = [0u8; 20];
alpha_usd[0] = 0x20;
alpha_usd[1] = 0xc0;
alpha_usd[19] = 0x01;
let mut input = vec![0xa9, 0x05, 0x9c, 0xbb];
input.extend(0u8..64);
debug_assert!(input.len() > 55);
TempoTxBuilder::new(42431)
.max_priority_fee_per_gas(1_000_000_000)
.max_fee_per_gas(2_000_000_000)
.gas_limit(1_500_000)
.nonce(7)
.fee_token(alpha_usd)
.call(TempoCall {
to: [0xd7; 20],
value_wei: 0,
input,
})
.build()
}
fn golden_keys() -> (k256::ecdsa::SigningKey, k256::ecdsa::SigningKey) {
let sender = wallet::from_private_key_hex(
"0x0000000000000000000000000000000000000000000000000000000000000001",
)
.unwrap();
let fee_payer = wallet::from_private_key_hex(
"0x0000000000000000000000000000000000000000000000000000000000000002",
)
.unwrap();
(sender, fee_payer)
}
fn hex(b: &[u8]) -> String {
crate::encoding::bytes_to_hex(b)
}
#[test]
fn sponsored_tx_golden_vector() {
let (sender, fee_payer) = golden_keys();
let tx = golden_tx().set_sponsored(true);
let sender_addr = wallet::address(&sender);
assert_eq!(
hex(&tx.sender_hash()),
GOLDEN_SPONSORED_SENDER_HASH,
"sponsored sender-hash preimage changed — on-chain ecrecover \
would now yield a PHANTOM sender (identity brick + sponsor drain)"
);
assert_eq!(
hex(&tx.fee_payer_hash(&sender_addr)),
GOLDEN_FEE_PAYER_HASH,
"fee_payer-hash preimage changed — sponsor signature would no \
longer validate"
);
let raw = sign_sponsored(tx, &sender, &fee_payer);
assert_eq!(raw[0], 0x76);
assert_eq!(
hex(&raw),
GOLDEN_SPONSORED_RAW_TX,
"serialized sponsored 0x76 tx changed — the WIRE FORMAT moved; \
prove the new bytes via examples/tempo_tx_live.rs before \
regenerating"
);
}
#[test]
fn self_paid_tx_golden_vector() {
let (sender, _) = golden_keys();
let tx = golden_tx();
assert_eq!(
hex(&tx.sender_hash()),
GOLDEN_SELF_PAID_SENDER_HASH,
"self-paid sender-hash preimage changed — signatures would \
recover to a phantom address"
);
assert_ne!(GOLDEN_SPONSORED_SENDER_HASH, GOLDEN_SELF_PAID_SENDER_HASH);
let raw = sign_self_paid(tx, &sender);
assert_eq!(raw[0], 0x76);
assert_eq!(
hex(&raw),
GOLDEN_SELF_PAID_RAW_TX,
"serialized self-paid 0x76 tx changed — the WIRE FORMAT moved; \
prove the new bytes via examples/tempo_tx_live.rs before \
regenerating"
);
}
fn rlp_header(buf: &[u8], i: usize) -> (usize, usize, bool) {
let b = buf[i];
match b {
0x00..=0x7f => (i, 1, false), 0x80..=0xb7 => (i + 1, (b - 0x80) as usize, false),
0xb8..=0xbf => {
let n = (b - 0xb7) as usize;
let len = be_to_usize(&buf[i + 1..i + 1 + n]);
(i + 1 + n, len, false)
}
0xc0..=0xf7 => (i + 1, (b - 0xc0) as usize, true),
0xf8..=0xff => {
let n = (b - 0xf7) as usize;
let len = be_to_usize(&buf[i + 1..i + 1 + n]);
(i + 1 + n, len, true)
}
}
}
fn be_to_usize(bytes: &[u8]) -> usize {
bytes.iter().fold(0usize, |acc, &b| (acc << 8) | b as usize)
}
fn rlp_list_len(buf: &[u8], start: usize, end: usize) -> usize {
let mut i = start;
let mut count = 0;
while i < end {
let (off, len, _) = rlp_header(buf, i);
i = off + len;
count += 1;
}
assert_eq!(i, end, "RLP item ran past its parent list bound");
count
}
#[test]
fn approve_u128_max_envelope_is_well_formed() {
let (sender, fee_payer) = golden_keys();
let mut input = vec![0x09, 0x5e, 0xa7, 0xb3]; let mut spender = [0u8; 32];
spender[12..].copy_from_slice(&[0x6c; 20]);
input.extend_from_slice(&spender);
let mut amount = [0u8; 32];
amount[16..].copy_from_slice(&u128::MAX.to_be_bytes()); input.extend_from_slice(&amount);
assert_eq!(input.len(), 68);
let tx = TempoTxBuilder::new(42431)
.max_priority_fee_per_gas(20_000_000_000)
.max_fee_per_gas(20_000_000_000)
.gas_limit(300_000)
.nonce(43)
.fee_token([0x20; 20])
.call(TempoCall { to: [0x90; 20], value_wei: 0, input: input.clone() })
.sponsored()
.build();
let raw = sign_sponsored(tx, &sender, &fee_payer);
assert_eq!(raw[0], 0x76);
let (body_off, body_len, is_list) = rlp_header(&raw, 1);
assert!(is_list, "0x76 body must be an RLP list");
assert_eq!(
body_off + body_len,
raw.len(),
"RLP list header length must match the actual body — a mismatch is \
exactly what reth rejects as 'failed to decode signed transaction'"
);
assert_eq!(
rlp_list_len(&raw, body_off, body_off + body_len),
14,
"sponsored 0x76 envelope must carry 14 top-level items \
(key_authorization omitted when None)"
);
let needle = &input[36..68]; assert!(
raw.windows(needle.len()).any(|w| w == needle),
"u128::MAX allowance word must be preserved verbatim in the envelope"
);
}
}