Skip to main content

perpl_sdk/types/
extension.rs

1//! Builder attribution and the V2 order-extension envelope carrying it.
2
3use alloy::{
4    primitives::{Bytes, U256},
5    sol_types::SolValue,
6};
7use fastnum::UD64;
8use thiserror::Error;
9
10use crate::num;
11
12/// Envelope version tag understood by the contract's decoder
13/// (`OrderExtensionLib._ORDER_EXT_VERSION_1`).
14pub const ORDER_EXTENSION_VERSION: u16 = 1;
15
16/// Hard byte cap the contract's decoder applies to a single envelope
17/// (`OrderExtensionLib._MAX_ORDER_EXT_BYTES`).
18///
19/// Exceeding it is a *structural* fault: it reverts the whole call on every
20/// path, including batched ones with `revertOnFail = false`.
21pub const MAX_ORDER_EXTENSION_BYTES: usize = 256;
22
23/// Highest per-order builder fee rate the contract accepts, in `Per100K`
24/// (`C._MAX_FEE / C._FEE_UNIT_SCALE`, i.e. 1%).
25///
26/// The envelope's unit is `Per100K` at every contract version - the
27/// builder-code wire format is deliberately untouched by the v1.1.7.5 fee
28/// redenomination - but the ceiling moved with it: `C._MAX_FEE` kept its stored
29/// value while the denominator behind it went from 1e5 to 1e6, so the same
30/// number went from meaning 10% to meaning 1%.
31///
32/// Applied unconditionally, not per version: an envelope is encoded to be
33/// submitted against the head of the chain, and a rate above 1% is rejected
34/// there.
35pub const MAX_BUILDER_FEE_PER_100K: u32 = 1_000;
36
37/// Builder attribution of a single order: which builder submitted it and the
38/// additive fee rate that builder charges on it.
39///
40/// The rate is what the *order* requests; what a builder actually earns is
41/// reported per fill by the `builder_fee` of
42/// [`crate::state::OrderEventType::Filled`].
43///
44/// A non-zero [`Self::builder_id`] does not imply a fee: attribution with a
45/// zero fee is valid, and is what a client that wants attribution without a
46/// charge submits.
47#[derive(Clone, Copy, PartialEq, Eq, derive_more::Debug)]
48pub struct BuilderAttribution {
49    builder_id: super::BuilderId,
50    #[debug("{fee}")]
51    fee: UD64,
52}
53
54/// Failure decoding a V2 order-extension envelope.
55#[derive(Clone, Debug, Error)]
56pub enum OrderExtensionError {
57    /// Envelope is larger than the contract's decoder accepts. Structural
58    /// fault - reverts on every path.
59    #[error("order extension of {0} bytes exceeds maximum of {MAX_ORDER_EXTENSION_BYTES}")]
60    ExceedsMaximumSize(usize),
61
62    /// Envelope is not `abi.encode(uint16, bytes)`, or its payload is not
63    /// `abi.encode(uint256, uint256)`. Structural fault - reverts on every
64    /// path.
65    #[error("malformed order extension envelope")]
66    Malformed,
67
68    /// Envelope version tag is not [`ORDER_EXTENSION_VERSION`]. Recoverable
69    /// fault - the contract skips just this order on the batched/forwarded
70    /// paths.
71    #[error("unsupported order extension version: {0}")]
72    UnsupportedVersion(u16),
73
74    /// Builder code is out of the `uint8` range. Recoverable fault.
75    #[error("builder id {id} exceeds maximum of 255")]
76    BuilderIdExceedsMaximum { id: U256 },
77
78    /// Builder fee rate exceeds [`MAX_BUILDER_FEE_PER_100K`]. Recoverable
79    /// fault.
80    #[error("builder fee {0} exceeds maximum of {MAX_BUILDER_FEE_PER_100K} Per100K")]
81    FeeExceedsMaximum(U256),
82}
83
84impl BuilderAttribution {
85    /// Attribution of an order to `builder_id`, charging an additive `fee`
86    /// fraction of the traded amount on the size the order adds.
87    ///
88    /// The fee is bounded by [`MAX_BUILDER_FEE_PER_100K`] and quantized to
89    /// [`num::FEE_SCALE`] decimal places on encoding; use
90    /// [`Self::encode`] to detect an out-of-range rate before submitting.
91    pub fn new(builder_id: super::BuilderId, fee: UD64) -> Self { Self { builder_id, fee } }
92
93    /// Builder attribution as recorded on-chain, from the raw `Per100K` fee
94    /// rate.
95    pub(crate) fn from_raw(builder_id: super::BuilderId, fee_per_100k: U256) -> Self {
96        Self { builder_id, fee: num::fee_converter().from_unsigned(fee_per_100k) }
97    }
98
99    /// Builder code the order is attributed to. Zero means no builder, in which
100    /// case no envelope is submitted at all.
101    pub fn builder_id(&self) -> super::BuilderId { self.builder_id }
102
103    /// Additive builder fee *rate* requested by the order, as a fraction of the
104    /// traded amount.
105    pub fn fee(&self) -> UD64 { self.fee }
106
107    /// Raw `Per100K` fee rate as submitted on-chain.
108    pub fn fee_per_100k(&self) -> U256 { num::fee_converter().to_unsigned(self.fee) }
109
110    /// Encodes the envelope to submit with a V2 order entrypoint, rejecting a
111    /// fee rate the contract's decoder would reject.
112    ///
113    /// Mirrors `OrderExtensionLib.decodeOrderExtension`:
114    /// `abi.encode(uint16 version, bytes payload)` where the version-1 payload
115    /// is `abi.encode(uint256 builderId, uint256 builderFeePer100K)`.
116    ///
117    /// The contract *reverts* on an out-of-range fee on the single-order path
118    /// and skips the order (emitting `OrderExtensionRejected`) on the batched
119    /// and forwarded paths, so an envelope is never worth building without the
120    /// range check.
121    pub fn encode(&self) -> Result<Bytes, OrderExtensionError> {
122        let fee_per_100k = self.fee_per_100k();
123        if fee_per_100k > U256::from(MAX_BUILDER_FEE_PER_100K) {
124            return Err(OrderExtensionError::FeeExceedsMaximum(fee_per_100k));
125        }
126        let payload = (U256::from(self.builder_id), fee_per_100k).abi_encode_params();
127        Ok((ORDER_EXTENSION_VERSION, Bytes::from(payload))
128            .abi_encode_params()
129            .into())
130    }
131
132    /// Decodes the envelope emitted with `OrderRequestV2`.
133    ///
134    /// An empty envelope is the no-builder fast path and yields `None`.
135    /// Rejections mirror the contract's decoder, so an envelope this returns an
136    /// error for is one the contract itself rejected (see
137    /// [`OrderExtensionError`] for which failures revert and which skip a
138    /// single order).
139    pub fn decode(extension: &[u8]) -> Result<Option<Self>, OrderExtensionError> {
140        if extension.is_empty() {
141            return Ok(None);
142        }
143        if extension.len() > MAX_ORDER_EXTENSION_BYTES {
144            return Err(OrderExtensionError::ExceedsMaximumSize(extension.len()));
145        }
146        let (version, payload) = <(u16, Bytes)>::abi_decode_params(extension)
147            .map_err(|_| OrderExtensionError::Malformed)?;
148        if version != ORDER_EXTENSION_VERSION {
149            return Err(OrderExtensionError::UnsupportedVersion(version));
150        }
151        let (builder_id, fee_per_100k) = <(U256, U256)>::abi_decode_params(&payload)
152            .map_err(|_| OrderExtensionError::Malformed)?;
153        if builder_id > U256::from(u8::MAX) {
154            return Err(OrderExtensionError::BuilderIdExceedsMaximum { id: builder_id });
155        }
156        if fee_per_100k > U256::from(MAX_BUILDER_FEE_PER_100K) {
157            return Err(OrderExtensionError::FeeExceedsMaximum(fee_per_100k));
158        }
159        Ok(Some(Self::from_raw(builder_id.to(), fee_per_100k)))
160    }
161}
162
163impl std::fmt::Display for BuilderAttribution {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        write!(f, "🏗{}@{}", self.builder_id, self.fee)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use fastnum::udec64;
172
173    use super::*;
174
175    #[test]
176    fn builder_attribution_envelope_round_trip() {
177        let attribution = BuilderAttribution::new(7, udec64!(0.001));
178        let encoded = attribution.encode().expect("fee within range");
179
180        // A version-1 envelope is exactly 160 bytes: version word, payload
181        // offset, payload length, and the two payload words.
182        assert_eq!(encoded.len(), 160);
183        assert!(encoded.len() <= MAX_ORDER_EXTENSION_BYTES);
184        assert_eq!(attribution.fee_per_100k(), U256::from(100));
185        assert_eq!(BuilderAttribution::decode(&encoded).unwrap(), Some(attribution));
186    }
187
188    #[test]
189    fn empty_envelope_is_no_builder() {
190        assert_eq!(BuilderAttribution::decode(&[]).unwrap(), None);
191    }
192
193    #[test]
194    fn rejects_out_of_range_fee() {
195        // 11% - above the contract's 10% per-order cap.
196        let attribution = BuilderAttribution::new(1, udec64!(0.11));
197        assert!(matches!(attribution.encode(), Err(OrderExtensionError::FeeExceedsMaximum(_)),));
198
199        // ...and so is such an envelope on the way back in. It cannot be built
200        // with `encode`, only received from a third-party submitter, so it is
201        // hand-rolled here.
202        let payload = (U256::from(1), U256::from(11_000)).abi_encode_params();
203        let envelope: Bytes = (ORDER_EXTENSION_VERSION, Bytes::from(payload))
204            .abi_encode_params()
205            .into();
206        assert!(matches!(
207            BuilderAttribution::decode(&envelope),
208            Err(OrderExtensionError::FeeExceedsMaximum(_)),
209        ));
210    }
211
212    #[test]
213    fn rejects_unknown_version_and_malformed_envelope() {
214        let payload = (U256::from(1), U256::from(10)).abi_encode_params();
215        let envelope: Bytes = (2u16, Bytes::from(payload)).abi_encode_params().into();
216        assert!(matches!(
217            BuilderAttribution::decode(&envelope),
218            Err(OrderExtensionError::UnsupportedVersion(2)),
219        ));
220
221        assert!(matches!(
222            BuilderAttribution::decode(&[0xffu8; 3]),
223            Err(OrderExtensionError::Malformed),
224        ));
225        assert!(matches!(
226            BuilderAttribution::decode(&[0u8; MAX_ORDER_EXTENSION_BYTES + 1]),
227            Err(OrderExtensionError::ExceedsMaximumSize(_)),
228        ));
229    }
230}