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}