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