Skip to main content

r402_aptos/chain/
codec.rs

1//! TS `@x402/aptos` payload encoding and `aptos-sdk` BCS decode.
2//!
3//! Wire: base64(UTF-8 JSON `{ transaction: [u8], senderAuthenticator: [u8] }`).
4//! Inner `transaction` is BCS of TS `SimpleTransaction` (raw txn + optional
5//! fee-payer address). Inner `senderAuthenticator` is BCS
6//! `AccountAuthenticator`. Construction/sign/verify use `aptos-sdk` 0.6.
7
8use aptos_sdk::account::Account;
9use aptos_sdk::crypto::{AnyPublicKey, AnySignature, MultiKeyPublicKey, MultiKeySignature};
10use aptos_sdk::transaction::authenticator::AccountAuthenticator;
11use aptos_sdk::transaction::payload::TransactionPayload;
12use aptos_sdk::transaction::{
13    EntryFunction, FeePayerRawTransaction, RawTransaction, SignedTransaction,
14    TransactionAuthenticator,
15};
16use aptos_sdk::types::{AccountAddress, TypeTag};
17use aptos_sdk::{AptosConfig, aptos_bcs};
18use base64::Engine;
19use serde::{Deserialize, Serialize};
20
21/// TS `DecodedAptosPayload`: JSON arrays of BCS bytes.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct DecodedAptosPayload {
25    /// BCS `SimpleTransaction` bytes.
26    pub transaction: Vec<u8>,
27    /// BCS `AccountAuthenticator` bytes.
28    pub sender_authenticator: Vec<u8>,
29}
30
31/// TS `SimpleTransaction`: raw txn plus optional fee-payer address.
32///
33/// BCS layout is positional (`RawTransaction` then `Option<AccountAddress>`),
34/// matching `@aptos-labs/ts-sdk` `SimpleTransaction.serialize`.
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36pub struct SimpleTransaction {
37    /// Unsigned transaction body.
38    pub raw_transaction: RawTransaction,
39    /// Fee-payer address when sponsored; `None` when the sender pays gas.
40    pub fee_payer_address: Option<AccountAddress>,
41}
42
43impl SimpleTransaction {
44    /// Signing message the sender authenticator must cover.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`AptosCodecError::Bcs`] if the SDK cannot serialize the
49    /// signing domain.
50    pub fn signing_message(&self) -> Result<Vec<u8>, AptosCodecError> {
51        self.fee_payer_address.map_or_else(
52            || {
53                self.raw_transaction
54                    .signing_message()
55                    .map_err(|e| AptosCodecError::from_sdk(&e))
56            },
57            |fee_payer| {
58                FeePayerRawTransaction::new_simple(self.raw_transaction.clone(), fee_payer)
59                    .signing_message()
60                    .map_err(|e| AptosCodecError::from_sdk(&e))
61            },
62        )
63    }
64}
65
66/// Errors from payload encode/decode.
67#[derive(Debug, thiserror::Error)]
68pub enum AptosCodecError {
69    /// Outer base64 or JSON-of-bytes is not the TS shape.
70    #[error("invalid aptos payload encoding: {0}")]
71    Encoding(String),
72    /// Inner BCS could not be parsed with `aptos-sdk`.
73    #[error("invalid aptos payload bcs: {0}")]
74    Bcs(String),
75}
76
77impl AptosCodecError {
78    fn from_sdk(err: &aptos_sdk::AptosError) -> Self {
79        Self::Bcs(err.to_string())
80    }
81}
82
83/// Encodes a simple transaction + sender authenticator as TS `encodeAptosPayload`.
84///
85/// # Errors
86///
87/// Returns [`AptosCodecError`] if BCS or JSON serialization fails.
88pub fn encode_aptos_payload(
89    transaction: &SimpleTransaction,
90    sender_authenticator: &AccountAuthenticator,
91) -> Result<String, AptosCodecError> {
92    let transaction_bytes =
93        aptos_bcs::to_bytes(transaction).map_err(|e| AptosCodecError::Bcs(e.to_string()))?;
94    let authenticator_bytes = aptos_bcs::to_bytes(sender_authenticator)
95        .map_err(|e| AptosCodecError::Bcs(e.to_string()))?;
96    let decoded = DecodedAptosPayload {
97        transaction: transaction_bytes,
98        sender_authenticator: authenticator_bytes,
99    };
100    let json =
101        serde_json::to_vec(&decoded).map_err(|e| AptosCodecError::Encoding(e.to_string()))?;
102    Ok(base64::engine::general_purpose::STANDARD.encode(json))
103}
104
105/// Decodes the TS outer envelope into JSON-of-bytes.
106///
107/// # Errors
108///
109/// Returns [`AptosCodecError::Encoding`] if base64 or JSON is invalid.
110pub fn decode_aptos_payload(
111    transaction_base64: &str,
112) -> Result<DecodedAptosPayload, AptosCodecError> {
113    let json = base64::engine::general_purpose::STANDARD
114        .decode(transaction_base64.trim())
115        .map_err(|e| AptosCodecError::Encoding(e.to_string()))?;
116    serde_json::from_slice(&json).map_err(|e| AptosCodecError::Encoding(e.to_string()))
117}
118
119/// Fully decoded payment: simple txn + sender authenticator.
120#[derive(Clone, Debug)]
121pub struct DecodedAptosPayment {
122    /// Deserialized simple transaction.
123    pub transaction: SimpleTransaction,
124    /// Deserialized sender authenticator.
125    pub sender_authenticator: AccountAuthenticator,
126}
127
128impl DecodedAptosPayment {
129    /// Decodes the x402 `payload.transaction` string.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`AptosCodecError`] if the envelope or inner BCS is invalid.
134    pub fn from_base64(transaction_base64: &str) -> Result<Self, AptosCodecError> {
135        let decoded = decode_aptos_payload(transaction_base64)?;
136        let transaction = aptos_bcs::from_bytes::<SimpleTransaction>(&decoded.transaction)
137            .map_err(|e| AptosCodecError::Bcs(e.to_string()))?;
138        let sender_authenticator =
139            deserialize_account_authenticator(&decoded.sender_authenticator)?;
140        Ok(Self {
141            transaction,
142            sender_authenticator,
143        })
144    }
145
146    /// Sender long-form address (`0x` + 64 hex).
147    #[must_use]
148    pub fn sender_long(&self) -> String {
149        self.transaction.raw_transaction.sender.to_long_string()
150    }
151
152    /// Entry-function payload when the txn is an entry function.
153    #[must_use]
154    pub const fn entry_function(&self) -> Option<&EntryFunction> {
155        match &self.transaction.raw_transaction.payload {
156            TransactionPayload::EntryFunction(entry) => Some(entry),
157            _ => None,
158        }
159    }
160}
161
162/// Builds an `AccountAuthenticator` for `account` over `message`.
163///
164/// # Errors
165///
166/// Returns [`AptosCodecError`] if signing or key length is invalid.
167pub fn account_authenticator_for(
168    account: &impl Account,
169    message: &[u8],
170) -> Result<AccountAuthenticator, AptosCodecError> {
171    let signature = account
172        .sign(message)
173        .map_err(|e| AptosCodecError::from_sdk(&e))?;
174    let public_key = account.public_key_bytes();
175    if public_key.len() != 32 || signature.len() != 64 {
176        return Err(AptosCodecError::Bcs(
177            "ed25519 authenticator requires 32-byte public key and 64-byte signature".to_owned(),
178        ));
179    }
180    Ok(AccountAuthenticator::ed25519(public_key, signature))
181}
182
183/// Wraps a sender authenticator as a submit-ready [`TransactionAuthenticator`].
184#[must_use]
185pub fn transaction_authenticator_from_sender(
186    sender: AccountAuthenticator,
187    fee_payer: Option<AccountAddress>,
188) -> TransactionAuthenticator {
189    match fee_payer {
190        Some(fee_payer_address) => TransactionAuthenticator::fee_payer(
191            sender,
192            Vec::new(),
193            Vec::new(),
194            fee_payer_address,
195            AccountAuthenticator::no_account_authenticator(),
196        ),
197        None => match sender {
198            AccountAuthenticator::Ed25519 {
199                public_key,
200                signature,
201            } => TransactionAuthenticator::Ed25519 {
202                public_key,
203                signature,
204            },
205            AccountAuthenticator::MultiEd25519 {
206                public_key,
207                signature,
208            } => TransactionAuthenticator::MultiEd25519 {
209                public_key,
210                signature,
211            },
212            other => TransactionAuthenticator::single_sender(other),
213        },
214    }
215}
216
217/// Signed transaction used for simulate/submit from a decoded payment.
218#[must_use]
219pub fn signed_from_decoded(decoded: &DecodedAptosPayment) -> SignedTransaction {
220    SignedTransaction::new(
221        decoded.transaction.raw_transaction.clone(),
222        transaction_authenticator_from_sender(
223            decoded.sender_authenticator.clone(),
224            decoded.transaction.fee_payer_address,
225        ),
226    )
227}
228
229/// `aptos-sdk` client config for a CAIP-2 Aptos network.
230///
231/// # Errors
232///
233/// Returns [`AptosCodecError::Encoding`] if `rpc_url` is not a valid HTTP(S) URL.
234pub fn aptos_config_for(
235    chain_id: u8,
236    rpc_url: Option<&str>,
237) -> Result<AptosConfig, AptosCodecError> {
238    if let Some(url) = rpc_url {
239        return AptosConfig::custom(url).map_err(|e| AptosCodecError::Encoding(e.to_string()));
240    }
241    match chain_id {
242        1 => Ok(AptosConfig::mainnet()),
243        2 => Ok(AptosConfig::testnet()),
244        _ => Err(AptosCodecError::Encoding(format!(
245            "unsupported aptos chain id {chain_id}"
246        ))),
247    }
248}
249
250/// Returns `true` when `tag` is `0x1::fungible_asset::Metadata`.
251#[must_use]
252pub fn is_metadata_type_tag(tag: &TypeTag) -> bool {
253    match tag {
254        TypeTag::Struct(st) => {
255            st.address == AccountAddress::ONE
256                && st.module.as_str() == "fungible_asset"
257                && st.name.as_str() == "Metadata"
258                && st.type_args.is_empty()
259        }
260        _ => false,
261    }
262}
263
264/// Returns `true` when the entry function is a supported FA transfer.
265#[must_use]
266pub fn is_supported_transfer(entry: &EntryFunction) -> bool {
267    if entry.module.address != AccountAddress::ONE || entry.function != "transfer" {
268        return false;
269    }
270    let module = entry.module.name.as_str();
271    module == "primary_fungible_store" || module == "fungible_asset"
272}
273
274fn deserialize_account_authenticator(
275    bytes: &[u8],
276) -> Result<AccountAuthenticator, AptosCodecError> {
277    if let Ok(auth) = aptos_bcs::from_bytes::<AccountAuthenticator>(bytes) {
278        return Ok(auth);
279    }
280    // On-chain SingleKey/MultiKey bytes differ from SDK Deserialize; this parser is the product.
281    let tag = bytes
282        .first()
283        .copied()
284        .ok_or_else(|| AptosCodecError::Bcs("empty account authenticator".to_owned()))?;
285    match tag {
286        2 => {
287            let rest = bytes.get(1..).ok_or_else(|| {
288                AptosCodecError::Bcs("truncated SingleKey authenticator".to_owned())
289            })?;
290            let (pk, pk_len) = take_any_public_key(rest)?;
291            let sig_bytes = rest
292                .get(pk_len..)
293                .ok_or_else(|| AptosCodecError::Bcs("truncated SingleKey signature".to_owned()))?;
294            let sig = AnySignature::from_bcs_bytes(sig_bytes)
295                .map_err(|e| AptosCodecError::from_sdk(&e))?;
296            Ok(AccountAuthenticator::single_key(
297                pk.to_bcs_bytes(),
298                sig.to_bcs_bytes(),
299            ))
300        }
301        3 => {
302            let rest = bytes.get(1..).ok_or_else(|| {
303                AptosCodecError::Bcs("truncated MultiKey authenticator".to_owned())
304            })?;
305            let (pk, pk_len) = take_multi_key_public(rest)?;
306            let sig_bytes = rest
307                .get(pk_len..)
308                .ok_or_else(|| AptosCodecError::Bcs("truncated MultiKey signature".to_owned()))?;
309            let sig = MultiKeySignature::from_bytes(sig_bytes)
310                .map_err(|e| AptosCodecError::from_sdk(&e))?;
311            Ok(AccountAuthenticator::multi_key(
312                pk.to_bytes(),
313                sig.to_bytes(),
314            ))
315        }
316        _ => Err(AptosCodecError::Bcs(format!(
317            "unsupported account authenticator variant {tag}"
318        ))),
319    }
320}
321
322/// `AnyPublicKey` on-chain: `variant || ULEB128(len) || bytes`.
323fn take_any_public_key(bytes: &[u8]) -> Result<(AnyPublicKey, usize), AptosCodecError> {
324    let len = any_public_key_declared_len(bytes)?;
325    let prefix = bytes
326        .get(..len)
327        .ok_or_else(|| AptosCodecError::Bcs("truncated AnyPublicKey".to_owned()))?;
328    let pk = AnyPublicKey::from_bcs_bytes(prefix).map_err(|e| AptosCodecError::from_sdk(&e))?;
329    Ok((pk, len))
330}
331
332fn any_public_key_declared_len(bytes: &[u8]) -> Result<usize, AptosCodecError> {
333    let rest = bytes
334        .get(1..)
335        .ok_or_else(|| AptosCodecError::Bcs("truncated AnyPublicKey".to_owned()))?;
336    let (payload_len, prefix_len) = uleb128_len(rest)?;
337    1usize
338        .checked_add(prefix_len)
339        .and_then(|n| n.checked_add(payload_len))
340        .ok_or_else(|| AptosCodecError::Bcs("AnyPublicKey length overflow".to_owned()))
341}
342
343/// `MultiKeyPublicKey` on-chain: `num_keys || AnyPublicKey* || threshold`.
344fn take_multi_key_public(bytes: &[u8]) -> Result<(MultiKeyPublicKey, usize), AptosCodecError> {
345    let num_keys = usize::from(
346        *bytes
347            .first()
348            .ok_or_else(|| AptosCodecError::Bcs("truncated MultiKeyPublicKey".to_owned()))?,
349    );
350    if num_keys == 0 || num_keys > 32 {
351        return Err(AptosCodecError::Bcs(format!(
352            "invalid MultiKeyPublicKey key count {num_keys}"
353        )));
354    }
355    let mut offset = 1usize;
356    for _ in 0..num_keys {
357        let used =
358            any_public_key_declared_len(bytes.get(offset..).ok_or_else(|| {
359                AptosCodecError::Bcs("truncated MultiKeyPublicKey key".to_owned())
360            })?)?;
361        offset = offset
362            .checked_add(used)
363            .ok_or_else(|| AptosCodecError::Bcs("MultiKeyPublicKey offset overflow".to_owned()))?;
364    }
365    offset = offset
366        .checked_add(1)
367        .ok_or_else(|| AptosCodecError::Bcs("truncated MultiKeyPublicKey threshold".to_owned()))?;
368    let prefix = bytes
369        .get(..offset)
370        .ok_or_else(|| AptosCodecError::Bcs("truncated MultiKeyPublicKey".to_owned()))?;
371    let pk = MultiKeyPublicKey::from_bytes(prefix).map_err(|e| AptosCodecError::from_sdk(&e))?;
372    Ok((pk, offset))
373}
374
375fn uleb128_len(bytes: &[u8]) -> Result<(usize, usize), AptosCodecError> {
376    let mut value = 0usize;
377    let mut shift = 0;
378    for (i, &byte) in bytes.iter().enumerate() {
379        if i >= 5 {
380            return Err(AptosCodecError::Bcs("ULEB128 too long".to_owned()));
381        }
382        value |= usize::from(byte & 0x7f) << shift;
383        if byte & 0x80 == 0 {
384            return Ok((value, i + 1));
385        }
386        shift += 7;
387    }
388    Err(AptosCodecError::Bcs("truncated ULEB128".to_owned()))
389}
390
391#[cfg(test)]
392#[allow(clippy::unwrap_used, reason = "test assertions")]
393mod tests {
394    use aptos_sdk::account::Ed25519Account;
395    use aptos_sdk::transaction::InputEntryFunctionData;
396    use aptos_sdk::transaction::builder::TransactionBuilder;
397    use aptos_sdk::types::ChainId;
398
399    use super::*;
400    use crate::DEFAULT_CLIENT_MAX_GAS;
401    use crate::chain::USDC_TESTNET_FA;
402
403    fn sample_payment() -> (SimpleTransaction, AccountAuthenticator) {
404        let sender = Ed25519Account::generate();
405        let fee_payer = Ed25519Account::generate();
406        let pay_to = AccountAddress::ONE;
407        let asset = AccountAddress::from_hex(USDC_TESTNET_FA).unwrap();
408        let payload = InputEntryFunctionData::transfer_fungible_asset(asset, pay_to, 1000).unwrap();
409        let raw = TransactionBuilder::new()
410            .sender(sender.address())
411            .sequence_number(0)
412            .payload(payload)
413            .max_gas_amount(DEFAULT_CLIENT_MAX_GAS)
414            .gas_unit_price(100)
415            .expiration_timestamp_secs(1_900_000_000)
416            .chain_id(ChainId::testnet())
417            .build()
418            .unwrap();
419        let tx = SimpleTransaction {
420            raw_transaction: raw,
421            fee_payer_address: Some(fee_payer.address()),
422        };
423        let message = tx.signing_message().unwrap();
424        let auth = account_authenticator_for(&sender, &message).unwrap();
425        (tx, auth)
426    }
427
428    #[test]
429    fn encode_decode_roundtrip_matches_ts_json_shape() {
430        let (tx, auth) = sample_payment();
431        let encoded = encode_aptos_payload(&tx, &auth).unwrap();
432        let decoded_json = decode_aptos_payload(&encoded).unwrap();
433        assert!(!decoded_json.transaction.is_empty());
434        assert!(!decoded_json.sender_authenticator.is_empty());
435
436        let json = base64::engine::general_purpose::STANDARD
437            .decode(&encoded)
438            .unwrap();
439        let value: serde_json::Value = serde_json::from_slice(&json).unwrap();
440        assert!(
441            value
442                .get("transaction")
443                .and_then(serde_json::Value::as_array)
444                .is_some()
445        );
446        assert!(
447            value
448                .get("senderAuthenticator")
449                .and_then(serde_json::Value::as_array)
450                .is_some()
451        );
452
453        let payment = DecodedAptosPayment::from_base64(&encoded).unwrap();
454        assert_eq!(payment.transaction, tx);
455        assert_eq!(
456            payment.transaction.raw_transaction.chain_id,
457            ChainId::testnet()
458        );
459        let entry = payment.entry_function().unwrap();
460        assert!(is_supported_transfer(entry));
461        assert_eq!(entry.args.len(), 3);
462        let asset: AccountAddress = aptos_bcs::from_bytes(entry.args.first().unwrap()).unwrap();
463        assert_eq!(asset, AccountAddress::from_hex(USDC_TESTNET_FA).unwrap());
464        payment
465            .sender_authenticator
466            .verify(&tx.signing_message().unwrap())
467            .unwrap();
468    }
469
470    fn ts_fixture(name: &str) -> (String, serde_json::Value) {
471        let root: serde_json::Value = serde_json::from_str(include_str!(
472            "../exact/fixtures/ts_encode_aptos_payload.json"
473        ))
474        .unwrap();
475        let entry = root.get(name).unwrap();
476        (
477            entry
478                .get("encoded")
479                .and_then(serde_json::Value::as_str)
480                .unwrap()
481                .to_owned(),
482            entry.clone(),
483        )
484    }
485
486    fn assert_ts_payment(name: &str, expected_variant: fn(&AccountAuthenticator) -> bool) {
487        let (encoded, meta) = ts_fixture(name);
488        let payment = DecodedAptosPayment::from_base64(&encoded).unwrap();
489        assert!(
490            expected_variant(&payment.sender_authenticator),
491            "{name} authenticator variant"
492        );
493        assert_eq!(
494            payment.sender_long(),
495            meta.get("sender")
496                .and_then(serde_json::Value::as_str)
497                .unwrap()
498        );
499        assert_eq!(
500            payment
501                .transaction
502                .fee_payer_address
503                .unwrap()
504                .to_long_string(),
505            meta.get("feePayer")
506                .and_then(serde_json::Value::as_str)
507                .unwrap()
508        );
509        let entry = payment.entry_function().unwrap();
510        assert!(is_supported_transfer(entry));
511        let asset: AccountAddress = aptos_bcs::from_bytes(entry.args.first().unwrap()).unwrap();
512        assert_eq!(
513            asset.to_long_string(),
514            meta.get("asset")
515                .and_then(serde_json::Value::as_str)
516                .unwrap()
517        );
518        let pay_to: AccountAddress = aptos_bcs::from_bytes(entry.args.get(1).unwrap()).unwrap();
519        assert_eq!(
520            pay_to.to_long_string(),
521            meta.get("payTo")
522                .and_then(serde_json::Value::as_str)
523                .unwrap()
524        );
525        let amount: u64 = aptos_bcs::from_bytes(entry.args.get(2).unwrap()).unwrap();
526        assert_eq!(
527            amount.to_string(),
528            meta.get("amount")
529                .and_then(serde_json::Value::as_str)
530                .unwrap()
531        );
532        payment
533            .sender_authenticator
534            .verify(&payment.transaction.signing_message().unwrap())
535            .unwrap();
536    }
537
538    #[test]
539    fn decodes_ts_encode_aptos_payload_ed25519() {
540        assert_ts_payment("ed25519", |a| {
541            matches!(a, AccountAuthenticator::Ed25519 { .. })
542        });
543    }
544
545    #[test]
546    fn decodes_ts_encode_aptos_payload_single_key_ed25519() {
547        assert_ts_payment("singleKeyEd25519", |a| {
548            matches!(a, AccountAuthenticator::SingleKey { .. })
549        });
550    }
551
552    #[test]
553    fn decodes_ts_encode_aptos_payload_single_key_secp256k1() {
554        assert_ts_payment("singleKeySecp256k1", |a| {
555            matches!(a, AccountAuthenticator::SingleKey { .. })
556        });
557    }
558
559    #[test]
560    fn decodes_ts_encode_aptos_payload_multi_key() {
561        assert_ts_payment("multiKey", |a| {
562            matches!(a, AccountAuthenticator::MultiKey { .. })
563        });
564    }
565}