Skip to main content

altius_tx_sdk/
transaction.rs

1//! Transaction building for Altius USD multi-token transactions.
2//!
3//! Supports the 0x7a transaction type.
4
5use alloy_primitives::{Address, Bytes, B256, U256, keccak256, ChainId};
6use alloy_rlp::Encodable;
7
8/// Transaction type for USD Multi-Token (EIP-2718)
9pub const TX_TYPE_USD_MULTI_TOKEN: u8 = 0x7a;
10
11/// Magic byte for fee payer signature
12pub const FEE_PAYER_SIGNATURE_MAGIC_BYTE: u8 = 0x7b;
13
14/// Access list entry (address + storage keys)
15#[derive(Debug, Clone, Default)]
16pub struct AccessListItem {
17    pub address: Address,
18    pub storage_keys: Vec<B256>,
19}
20
21/// TxBuilder for building USD Multi-Token transactions
22#[derive(Debug, Clone)]
23pub struct TxBuilder {
24    pub chain_id: u64,
25    pub nonce: u64,
26    pub gas_limit: u64,
27    /// Recipient address. None = contract creation.
28    pub to: Option<Address>,
29    pub value: U256,
30    pub data: Bytes,
31    pub access_list: Vec<AccessListItem>,  // Added: access_list field
32    pub max_priority_fee_per_gas: u128,
33    pub max_fee_per_gas: u128,
34    pub fee_token: Address,
35    pub fee_payer: Option<Address>,
36    pub max_fee_per_gas_usd: Option<u128>,
37    pub fee_payer_signature: Option<Bytes>,
38}
39
40impl Default for TxBuilder {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl TxBuilder {
47    pub fn new() -> Self {
48        Self {
49            chain_id: 0,
50            nonce: 0,
51            gas_limit: 21000,
52            to: Some(Address::ZERO),
53            value: U256::ZERO,
54            data: Bytes::new(),
55            access_list: Vec::new(),  // Default: empty access list
56            max_priority_fee_per_gas: 0,
57            max_fee_per_gas: 0,
58            fee_token: Address::ZERO,
59            fee_payer: None, // None means use sender (sender-pays)
60            max_fee_per_gas_usd: Some(40_000_000_000), // $0.04/gas default
61            fee_payer_signature: None,
62        }
63    }
64
65    pub fn chain_id(mut self, chain_id: u64) -> Self {
66        self.chain_id = chain_id;
67        self
68    }
69
70    pub fn nonce(mut self, nonce: u64) -> Self {
71        self.nonce = nonce;
72        self
73    }
74
75    pub fn gas_limit(mut self, gas_limit: u64) -> Self {
76        self.gas_limit = gas_limit;
77        self
78    }
79
80    pub fn to(mut self, to: Address) -> Self {
81        self.to = Some(to);
82        self
83    }
84
85    /// Set recipient address (convenience method)
86    pub fn to_address(mut self, address: Address) -> Self {
87        self.to = Some(address);
88        self
89    }
90
91    /// Set as contract creation (no recipient).
92    pub fn create(mut self) -> Self {
93        self.to = None;
94        self
95    }
96
97    pub fn value(mut self, value: U256) -> Self {
98        self.value = value;
99        self
100    }
101
102    pub fn data(mut self, data: Bytes) -> Self {
103        self.data = data;
104        self
105    }
106
107    pub fn max_priority_fee_per_gas(mut self, max_priority_fee_per_gas: u128) -> Self {
108        self.max_priority_fee_per_gas = max_priority_fee_per_gas;
109        self
110    }
111
112    pub fn max_fee_per_gas(mut self, max_fee_per_gas: u128) -> Self {
113        self.max_fee_per_gas = max_fee_per_gas;
114        self
115    }
116
117    pub fn fee_token(mut self, fee_token: Address) -> Self {
118        self.fee_token = fee_token;
119        self
120    }
121
122    pub fn fee_payer(mut self, fee_payer: Option<Address>) -> Self {
123        self.fee_payer = fee_payer;
124        self
125    }
126
127    pub fn max_fee_per_gas_usd(mut self, max_fee_per_gas_usd: u128) -> Self {
128        self.max_fee_per_gas_usd = Some(max_fee_per_gas_usd);
129        self
130    }
131
132    pub fn fee_payer_signature(mut self, signature: Bytes) -> Self {
133        self.fee_payer_signature = Some(signature);
134        self
135    }
136
137    /// Build ERC20 transfer transaction
138    pub fn erc20_transfer(mut self, token: Address, to: Address, amount: U256) -> Self {
139        // ERC20 transfer selector: 0xa9059cbb
140        let mut data = vec![0xa9, 0x05, 0x9c, 0xbb];
141        // Add recipient (padded to 32 bytes)
142        let mut recipient = [0u8; 32];
143        recipient[12..].copy_from_slice(to.as_slice());
144        data.extend_from_slice(&recipient);
145        // Add amount (padded to 32 bytes, big-endian for ERC20)
146        let mut amount_padded = [0u8; 32];
147        let amount_bytes: [u8; 32] = amount.to_be_bytes();
148        amount_padded.copy_from_slice(&amount_bytes);
149        data.extend_from_slice(&amount_padded);
150
151        self.to = Some(token);
152        self.data = Bytes::from(data);
153        self
154    }
155
156    /// Build the transaction fields for signing
157    pub fn build(&self) -> TxFields {
158        TxFields {
159            chain_id: self.chain_id,
160            nonce: self.nonce,
161            gas_limit: self.gas_limit,
162            to: self.to,
163            value: self.value,
164            data: self.data.clone(),
165            access_list: self.access_list.clone(),  // Added: access_list
166            max_priority_fee_per_gas: self.max_priority_fee_per_gas,
167            max_fee_per_gas: self.max_fee_per_gas,
168            fee_token: self.fee_token,
169            // NOTE: fee_payer is resolved to sender during signing (line below)
170            // This is just for the RLP encoding, actual fee_payer is set in sign()
171            fee_payer: self.fee_payer.unwrap_or(Address::ZERO),
172            max_fee_per_gas_usd: self.max_fee_per_gas_usd.unwrap_or(40_000_000_000),
173            fee_payer_signature: self.fee_payer_signature.clone(),
174        }
175    }
176
177    /// Compute the sender signature hash for this transaction
178    /// Matches node's encode_for_signing EXACTLY (without fee_payer_signature)
179    /// NOTE: Node calculates hash WITH type byte (0x7a) AND with outer RLP list header!
180    pub fn signature_hash(&self) -> B256 {
181        let fields = self.build();
182
183        // Get payload length (same as node's payload_len_for_signature)
184        let payload_len = fields.payload_len_for_signing();
185
186        // Build buffer: type byte + RLP list header + fields
187        let mut full_buf = Vec::new();
188        full_buf.push(TX_TYPE_USD_MULTI_TOKEN);
189        alloy_rlp::Header { list: true, payload_length: payload_len }.encode(&mut full_buf);
190
191        // Encode all fields
192        fields.encode_for_signing(&mut full_buf);
193
194        let hash = keccak256(&full_buf);
195
196        hash
197    }
198
199    /// Sign the transaction with a signer
200    pub fn sign(&self, signer: &impl Signer) -> Result<SignedTx, Box<dyn std::error::Error>> {
201        let sender = signer.address();
202
203        // Determine fee_payer: use sender if not specified.
204        // MUST resolve BEFORE computing signature_hash, because the signing
205        // payload includes fee_payer and the node recovers using the encoded value.
206        let fee_payer = self.fee_payer.unwrap_or(sender);
207
208        // Compute signing hash with the resolved fee_payer
209        let hash = {
210            let mut resolved = self.clone();
211            resolved.fee_payer = Some(fee_payer);
212            resolved.signature_hash()
213        };
214        let signature = signer.sign_hash(&hash)?;
215
216        // Auto-generate fee_payer_signature if not set
217        // When fee_payer == sender, use the SAME signature for both fee_payer_signature and transaction signature
218        // When fee_payer != sender, need to sign fee_payer_signature_hash separately
219        let fee_payer_signature = if self.fee_payer_signature.is_none() {
220            if fee_payer == sender {
221                // fee_payer == sender: use the same r, s, v from the sender's signature
222                Some(signature.to_bytes())
223            } else {
224                // fee_payer != sender: requires external fee_payer_signature
225                return Err("fee_payer != sender requires external fee_payer_signature".into());
226            }
227        } else {
228            self.fee_payer_signature.clone()
229        };
230
231        // Build fields with resolved fee_payer and fee_payer_signature
232        let fields = TxFields {
233            chain_id: self.chain_id,
234            nonce: self.nonce,
235            gas_limit: self.gas_limit,
236            to: self.to,
237            value: self.value.clone(),
238            data: self.data.clone(),
239            access_list: self.access_list.clone(),  // Added: access_list
240            max_priority_fee_per_gas: self.max_priority_fee_per_gas,
241            max_fee_per_gas: self.max_fee_per_gas,
242            fee_token: self.fee_token,
243            fee_payer,
244            max_fee_per_gas_usd: self.max_fee_per_gas_usd.unwrap_or(40_000_000_000),
245            fee_payer_signature,
246        };
247
248        let y_parity = signature.y_parity();
249
250        // Encode the signed transaction
251        // Format: [chainId, nonce, ..., feePayerSignature, yParity, r, s]
252        let raw_tx = fields.encode_signed(y_parity, signature.r(), signature.s());
253
254        let tx_hash = keccak256(&raw_tx);
255
256        Ok(SignedTx {
257            raw_transaction: format!("0x{}", hex::encode(&raw_tx)),
258            transaction_hash: tx_hash,
259            chain_id: fields.chain_id,
260            nonce: fields.nonce,
261            gas_limit: fields.gas_limit,
262            to: fields.to,
263            value: fields.value,
264            data: fields.data,
265            max_priority_fee_per_gas: fields.max_priority_fee_per_gas,
266            max_fee_per_gas: fields.max_fee_per_gas,
267            fee_token: fields.fee_token,
268            fee_payer: fields.fee_payer,
269            max_fee_per_gas_usd_attodollars: fields.max_fee_per_gas_usd,
270            fee_payer_signature: fields.fee_payer_signature,
271            v: y_parity,
272            r: signature.r(),
273            s: signature.s(),
274        })
275    }
276}
277
278/// Signer trait for transaction signing
279pub trait Signer {
280    fn sign_hash(&self, hash: &B256) -> Result<Signature, Box<dyn std::error::Error>>;
281    fn address(&self) -> Address;
282}
283
284/// Signature components
285#[derive(Debug, Clone)]
286pub struct Signature {
287    pub r: B256,
288    pub s: B256,
289    pub v: u8,
290}
291
292impl Signature {
293    pub fn new(r: B256, s: B256, v: u8) -> Self {
294        Self { r, s, v }
295    }
296
297    pub fn y_parity(&self) -> u8 {
298        self.v
299    }
300
301    pub fn r(&self) -> B256 {
302        self.r
303    }
304
305    pub fn s(&self) -> B256 {
306        self.s
307    }
308
309    /// Convert signature to bytes (r + s + v) - matches node's expected layout
310    /// Uses v = 27 + y_parity format (Ethereum legacy format)
311    pub fn to_bytes(&self) -> Bytes {
312        let mut buf = Vec::with_capacity(65);
313        buf.extend_from_slice(self.r.as_slice()); // r first (32 bytes)
314        buf.extend_from_slice(self.s.as_slice()); // s next (32 bytes)
315        let v_legacy = 27 + self.v; // Convert y_parity to legacy v format
316        buf.push(v_legacy);                         // v last (1 byte)
317        Bytes::from(buf)
318    }
319}
320
321/// Signed transaction
322#[derive(Debug, Clone)]
323pub struct SignedTx {
324    pub raw_transaction: String,
325    pub transaction_hash: B256,
326    pub chain_id: u64,
327    pub nonce: u64,
328    pub gas_limit: u64,
329    pub to: Option<Address>,
330    pub value: U256,
331    pub data: Bytes,
332    pub max_priority_fee_per_gas: u128,
333    pub max_fee_per_gas: u128,
334    pub fee_token: Address,
335    pub fee_payer: Address,
336    pub max_fee_per_gas_usd_attodollars: u128,
337    pub fee_payer_signature: Option<Bytes>,
338    pub v: u8,
339    pub r: B256,
340    pub s: B256,
341}
342
343/// Transaction fields for encoding
344#[derive(Debug, Clone)]
345pub struct TxFields {
346    pub chain_id: u64,
347    pub nonce: u64,
348    pub gas_limit: u64,
349    /// None = contract creation, Some(addr) = call.
350    pub to: Option<Address>,
351    pub value: U256,
352    pub data: Bytes,
353    pub access_list: Vec<AccessListItem>,  // Added: access_list field
354    pub max_priority_fee_per_gas: u128,
355    pub max_fee_per_gas: u128,
356    pub fee_token: Address,
357    pub fee_payer: Address,
358    pub max_fee_per_gas_usd: u128,
359    pub fee_payer_signature: Option<Bytes>,
360}
361
362impl TxFields {
363    /// Encode access list (EIP-2930 format)
364    fn encode_access_list(access_list: &[AccessListItem], out: &mut Vec<u8>) {
365        // Access list is encoded as a list of [address, [storage_keys...]]
366        if access_list.is_empty() {
367            // Empty list: 0xc0 (empty list)
368            alloy_rlp::Header { list: true, payload_length: 0 }.encode(out);
369        } else {
370            let payload_len = access_list.iter().map(|item| {
371                item.address.length() + item.storage_keys.iter().map(|k| k.length()).sum::<usize>()
372            }).sum::<usize>() + access_list.len(); // +1 for each inner list header
373
374            alloy_rlp::Header { list: true, payload_length: payload_len }.encode(out);
375
376            for item in access_list {
377                // Inner list: [address, [storage_keys...]]
378                let inner_len = item.address.length() +
379                    item.storage_keys.iter().map(|k| k.length()).sum::<usize>() +
380                    1; // for inner list header
381                alloy_rlp::Header { list: true, payload_length: inner_len }.encode(out);
382                item.address.encode(out);
383                // Storage keys list
384                if item.storage_keys.is_empty() {
385                    alloy_rlp::Header { list: true, payload_length: 0 }.encode(out);
386                } else {
387                    let keys_len: usize = item.storage_keys.iter().map(|k| k.length()).sum();
388                    alloy_rlp::Header { list: true, payload_length: keys_len }.encode(out);
389                    for key in &item.storage_keys {
390                        key.encode(out);
391                    }
392                }
393            }
394        }
395    }
396
397    /// Encode for signing (without fee_payer_signature)
398    /// EXACTLY matches node's encode_for_signing
399    pub fn encode_for_signing(&self, out: &mut Vec<u8>) {
400        // NOTE: Node encodes fields directly without outer list header
401        // Order must match node exactly!
402
403        ChainId::from(self.chain_id).encode(out);
404        self.nonce.encode(out);
405        self.max_priority_fee_per_gas.encode(out);
406        self.max_fee_per_gas.encode(out);
407        self.gas_limit.encode(out);
408        // to: None = contract create (0x80), Some(addr) = call (20 bytes)
409        Self::encode_to(&self.to, out);
410        self.value.encode(out);
411        self.data.encode(out);
412        // Access list (added for 0x7a compatibility)
413        Self::encode_access_list(&self.access_list, out);
414        self.fee_token.encode(out);
415        self.fee_payer.encode(out);
416        self.max_fee_per_gas_usd.encode(out);
417    }
418
419    /// Encode `to` field: None → 0x80 (empty, contract creation), Some → address.
420    fn encode_to(to: &Option<Address>, out: &mut Vec<u8>) {
421        match to {
422            Some(addr) => addr.encode(out),
423            None => out.push(0x80), // RLP empty string = contract creation
424        }
425    }
426
427    /// RLP length of `to` field.
428    fn to_length(to: &Option<Address>) -> usize {
429        match to {
430            Some(addr) => addr.length(),
431            None => 1, // 0x80
432        }
433    }
434
435    fn access_list_len(access_list: &[AccessListItem]) -> usize {
436        if access_list.is_empty() {
437            return 1; // Empty list header
438        }
439        let keys_len: usize = access_list.iter()
440            .map(|item| item.address.length() + item.storage_keys.iter().map(|k| k.length()).sum::<usize>() + 1)
441            .sum();
442        // Outer list header
443        1 + keys_len
444    }
445
446    fn payload_len_for_signing(&self) -> usize {
447        self.chain_id.length() +
448            self.nonce.length() +
449            self.max_priority_fee_per_gas.length() +
450            self.max_fee_per_gas.length() +
451            self.gas_limit.length() +
452            Self::to_length(&self.to) +
453            self.value.length() +
454            self.data.length() +
455            Self::access_list_len(&self.access_list) +
456            self.fee_token.length() +
457            self.fee_payer.length() +
458            self.max_fee_per_gas_usd.length()
459    }
460
461    /// Encode signed transaction (with fee_payer_signature and signature)
462    /// Must match node's encoding exactly
463    pub fn encode_signed(&self, y_parity: u8, r: B256, s: B256) -> Vec<u8> {
464        let mut buf = Vec::new();
465
466        // Type byte
467        buf.push(TX_TYPE_USD_MULTI_TOKEN);
468
469        // RLP encode fields
470        let payload_len = self.payload_len_for_signed(&r, &s);
471        alloy_rlp::Header { list: true, payload_length: payload_len }.encode(&mut buf);
472
473        self.chain_id.encode(&mut buf);
474        self.nonce.encode(&mut buf);
475        self.max_priority_fee_per_gas.encode(&mut buf);
476        self.max_fee_per_gas.encode(&mut buf);
477        self.gas_limit.encode(&mut buf);
478        Self::encode_to(&self.to, &mut buf);
479        self.value.encode(&mut buf);
480        self.data.encode(&mut buf);
481        // Access list (added for 0x7a compatibility)
482        Self::encode_access_list(&self.access_list, &mut buf);
483        self.fee_token.encode(&mut buf);
484        self.fee_payer.encode(&mut buf);
485        self.max_fee_per_gas_usd.encode(&mut buf);
486
487        // fee_payer_signature
488        if let Some(ref sig) = self.fee_payer_signature {
489            sig.encode(&mut buf);
490        } else {
491            Bytes::new().encode(&mut buf);
492        }
493
494        // Signature: y_parity, r, s (encode as U256 integers, not fixed B256 bytes)
495        y_parity.encode(&mut buf);
496        U256::from_be_bytes(r.0).encode(&mut buf);
497        U256::from_be_bytes(s.0).encode(&mut buf);
498
499        buf
500    }
501
502    fn payload_len_for_signed(&self, r: &B256, s: &B256) -> usize {
503        let fee_payer_sig_len = self.fee_payer_signature.as_ref()
504            .map(|sig| sig.length())
505            .unwrap_or_else(|| Bytes::new().length());
506
507        self.payload_len_for_signing() +
508            fee_payer_sig_len +
509            1 + // y_parity
510            U256::from_be_bytes(r.0).length() +
511            U256::from_be_bytes(s.0).length()
512    }
513}
514
515/// Compute fee payer signature hash
516///
517/// This creates the message that the fee payer signs to authorize
518/// the transaction fees to be paid from their account.
519///
520/// IMPORTANT: The node encodes fields as an RLP list with a header.
521/// Format: keccak256(0x7B || RLP_LIST_HEADER || chain_id || nonce || gas_limit || fee_token || fee_payer || max_fee_per_gas_usd || sender)
522pub fn fee_payer_signature_hash(
523    chain_id: u64,
524    nonce: u64,
525    gas_limit: u64,
526    fee_token: Address,
527    fee_payer: Address,
528    max_fee_per_gas_usd: u128,
529    sender: Address,
530) -> B256 {
531    use alloy_rlp::{Encodable, Header};
532
533    // Calculate RLP payload length (sum of all field lengths)
534    let payload_len = chain_id.length()
535        + nonce.length()
536        + gas_limit.length()
537        + fee_token.length()
538        + fee_payer.length()
539        + max_fee_per_gas_usd.length()
540        + sender.length();
541
542    // Build the buffer: magic byte + RLP list header + fields
543    let mut buf = Vec::with_capacity(1 + Header { list: true, payload_length: payload_len }.length() + payload_len);
544
545    // Magic byte prefix (0x7B)
546    buf.push(FEE_PAYER_SIGNATURE_MAGIC_BYTE);
547
548    // RLP list header
549    Header { list: true, payload_length: payload_len }.encode(&mut buf);
550
551    // Encode all fields
552    chain_id.encode(&mut buf);
553    nonce.encode(&mut buf);
554    gas_limit.encode(&mut buf);
555    fee_token.encode(&mut buf);
556    fee_payer.encode(&mut buf);
557    max_fee_per_gas_usd.encode(&mut buf);
558    sender.encode(&mut buf);
559
560    let hash = keccak256(&buf);
561
562    hash
563}
564
565/// Create a new transaction (convenience function)
566pub fn create_transaction() -> TxBuilder {
567    TxBuilder::new()
568}