Skip to main content

foundry_evm_traces/
erc8021.rs

1//! [ERC-8021](https://github.com/ethereum/ERCs/pull/1209) transaction attribution suffixes.
2//!
3//! The suffix is `schemaData || schemaId (1 byte) || marker (16 bytes)` appended to calldata, and
4//! the length of `schemaData` is read backwards according to `schemaId`.
5
6use alloy_primitives::hex;
7use itertools::Itertools;
8use serde::Deserialize;
9use std::fmt;
10
11/// Marker that ends an ERC-8021 attribution suffix.
12const MARKER: [u8; 16] = hex!("80218021802180218021802180218021");
13
14/// Codes attributed by an ERC-8021 suffix.
15#[derive(Clone, Debug, PartialEq, Eq, Hash)]
16pub struct Attribution {
17    /// The attributed codes, with the role of the entity for schema 2.
18    codes: Vec<(String, Option<&'static str>)>,
19}
20
21impl Attribution {
22    /// Decodes the ERC-8021 attribution suffix at the end of the given calldata.
23    ///
24    /// Returns `None` if there is no suffix, its schema is unknown, or it attributes no codes.
25    /// Custom registries and metadata are not decoded.
26    pub fn decode(data: &[u8]) -> Option<Self> {
27        let (_, schema_id, schema_data) = split_suffix(data)?;
28        let codes = match schema_id {
29            // `... || codes || codesLength (1)`
30            0 | 1 => {
31                let (&codes_len, rest) = schema_data.split_last()?;
32                let codes = std::str::from_utf8(&rest[rest.len() - codes_len as usize..]).ok()?;
33                codes
34                    .split(',')
35                    .filter(|code| !code.is_empty())
36                    .map(|code| (code.into(), None))
37                    .collect::<Vec<_>>()
38            }
39            // `cborData || cborLength (2)`
40            2 => {
41                let cbor = &schema_data[..schema_data.len() - 2];
42                let CborAttribution { app, wallet, services } = ciborium::from_reader(cbor).ok()?;
43                app.map(|code| (code, Some("app")))
44                    .into_iter()
45                    .chain(wallet.map(|code| (code, Some("wallet"))))
46                    .chain(services.into_iter().map(|code| (code, Some("service"))))
47                    .collect::<Vec<_>>()
48            }
49            _ => return None,
50        };
51        (!codes.is_empty()).then_some(Self { codes })
52    }
53}
54
55impl fmt::Display for Attribution {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        // Codes come from calldata, so escape control characters before printing them.
58        let codes = self.codes.iter().format_with(", ", |(code, role), f| match role {
59            Some(role) => f(&format_args!("{} ({role})", code.escape_debug())),
60            None => f(&code.escape_debug()),
61        });
62        write!(f, "{codes}")
63    }
64}
65
66/// Entity codes of a schema 2 attribution.
67#[derive(Deserialize)]
68struct CborAttribution {
69    #[serde(rename = "a")]
70    app: Option<String>,
71    #[serde(rename = "w")]
72    wallet: Option<String>,
73    #[serde(rename = "s", default)]
74    services: Vec<String>,
75}
76
77/// Returns the given calldata without a trailing ERC-8021 attribution suffix.
78pub fn strip_suffix(data: &[u8]) -> &[u8] {
79    split_suffix(data).map_or(data, |(data, ..)| data)
80}
81
82/// Splits a trailing ERC-8021 attribution suffix off the given calldata, returning the calldata
83/// before it, the schema id and the schema data.
84fn split_suffix(data: &[u8]) -> Option<(&[u8], u8, &[u8])> {
85    let rest = data.strip_suffix(&MARKER)?;
86    let (&schema_id, rest) = rest.split_last()?;
87    let schema_data_len = match schema_id {
88        // `codes || codesLength (1)`
89        0 => 1 + *rest.last()? as usize,
90        // `codeRegistryAddress (20) || chainId || chainIdLength (1) || codes || codesLength (1)`
91        1 => {
92            let codes_len = *rest.last()? as usize;
93            let chain_id_len = *rest.get(rest.len().checked_sub(2 + codes_len)?)? as usize;
94            22 + codes_len + chain_id_len
95        }
96        // `schemaData || schemaDataLength (2)`
97        _ => 2 + u16::from_be_bytes(*rest.last_chunk()?) as usize,
98    };
99    let (data, schema_data) = rest.split_at(rest.len().checked_sub(schema_data_len)?);
100    Some((data, schema_id, schema_data))
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    // Test vectors from ERC-8021.
108    const SCHEMA_0: &[u8] = &hex!("dddddddd62617365617070070080218021802180218021802180218021");
109    const SCHEMA_1: &[u8] = &hex!(
110        "ddddddddcccccccccccccccccccccccccccccccccccccccc210502626173656170702C6D6F7270686F0E0180218021802180218021802180218021"
111    );
112    const SCHEMA_2: &[u8] =
113        &hex!("dddddddda161616762617365617070000b0280218021802180218021802180218021");
114    const SCHEMA_2_METADATA: &[u8] = &hex!(
115        "dddddddda46161676261736561707061776570726976796172a16161a26163663078323130356161782a307842636632423935393845633037383165453439323330436141434630464233433838314235313935616da26c75746d5f63616d706169676e6c77696e7465722d70726f6d6f66736f7572636566776562617070007b0280218021802180218021802180218021"
116    );
117    const SCHEMA_2_SERVICES: &[u8] = &hex!(
118        "dddddddda361616762617365617070617765707269767961738269666c617368626f747365746974616e00260280218021802180218021802180218021"
119    );
120    const UNKNOWN_SCHEMA: &[u8] = &hex!("ddddddddff80218021802180218021802180218021");
121
122    #[test]
123    fn test_strip_suffix() {
124        for data in [SCHEMA_0, SCHEMA_1, SCHEMA_2, SCHEMA_2_METADATA, SCHEMA_2_SERVICES] {
125            assert_eq!(strip_suffix(data), hex!("dddddddd"));
126        }
127        assert_eq!(strip_suffix(UNKNOWN_SCHEMA), UNKNOWN_SCHEMA);
128    }
129
130    #[test]
131    fn test_decode_attribution() {
132        let decode =
133            |data: &[u8]| Attribution::decode(data).map(|attribution| attribution.to_string());
134        assert_eq!(decode(SCHEMA_0).as_deref(), Some("baseapp"));
135        assert_eq!(decode(SCHEMA_1).as_deref(), Some("baseapp, morpho"));
136        assert_eq!(decode(SCHEMA_2).as_deref(), Some("baseapp (app)"));
137        assert_eq!(decode(SCHEMA_2_METADATA).as_deref(), Some("baseapp (app), privy (wallet)"));
138        assert_eq!(
139            decode(SCHEMA_2_SERVICES).as_deref(),
140            Some("baseapp (app), privy (wallet), flashbots (service), titan (service)")
141        );
142        assert_eq!(decode(UNKNOWN_SCHEMA), None);
143        // Control characters are escaped.
144        let data = [&hex!("1b5b33316d0500")[..], &MARKER].concat();
145        assert_eq!(decode(&data).as_deref(), Some("\\u{1b}[31m"));
146    }
147}