Skip to main content

tempo_contracts/precompiles/
tip20_channel_reserve.rs

1pub use ITIP20ChannelReserve::{
2    ITIP20ChannelReserveErrors as TIP20ChannelReserveError,
3    ITIP20ChannelReserveEvents as TIP20ChannelReserveEvent,
4};
5use alloy_primitives::{Address, address};
6use alloy_sol_types::{SolCall, SolType};
7
8/// Native TIP-1034 channel reserve precompile address.
9pub const TIP20_CHANNEL_RESERVE_ADDRESS: Address =
10    address!("0x4D50500000000000000000000000000000000000");
11
12crate::sol! {
13    /// TIP-20 channel reserve ABI.
14    ///
15    /// The reserve locks payer deposits, verifies EIP-712 cumulative vouchers, pays the payee
16    /// incrementally, and lets the payer withdraw the remaining balance after a close grace period.
17    #[derive(Debug, PartialEq, Eq)]
18    #[sol(abi)]
19    #[allow(clippy::too_many_arguments)]
20    interface ITIP20ChannelReserve {
21        /// Immutable channel identity supplied to all descriptor-based methods.
22        struct ChannelDescriptor {
23            /// Account that funded the channel and receives refunds.
24            address payer;
25            /// Account that receives settled voucher payments.
26            address payee;
27            /// Optional relayer allowed to submit `settle` for the payee.
28            address operator;
29            /// TIP-20 token address held by the channel.
30            address token;
31            /// User-supplied salt to distinguish otherwise identical channels.
32            bytes32 salt;
33            /// Optional signer for vouchers. Zero means `payer` signs.
34            address authorizedSigner;
35            /// Transaction-derived hash assigned when the channel was opened.
36            bytes32 expiringNonceHash;
37        }
38
39        /// Mutable channel state packed into one native storage slot.
40        struct ChannelState {
41            /// Cumulative amount already paid to the payee.
42            uint96 settled;
43            /// Total deposit currently locked by the channel.
44            uint96 deposit;
45            /// Payer close-request timestamp, or zero when no close is pending.
46            uint32 closeRequestedAt;
47        }
48
49        /// Full descriptor plus current state.
50        struct Channel {
51            /// Channel identity fields.
52            ChannelDescriptor descriptor;
53            /// Mutable channel accounting state.
54            ChannelState state;
55        }
56
57        /// Delay between payer `requestClose` and `withdraw`.
58        function CLOSE_GRACE_PERIOD() external view returns (uint64);
59        /// EIP-712 type hash for `Voucher(bytes32 channelId,uint96 cumulativeAmount)`.
60        function VOUCHER_TYPEHASH() external view returns (bytes32);
61
62        /// Opens a channel and pulls `deposit` TIP-20 units from `msg.sender`.
63        function open(
64            address payee,
65            address operator,
66            address token,
67            uint96 deposit,
68            bytes32 salt,
69            address authorizedSigner
70        )
71            external
72            returns (bytes32 channelId);
73
74        /// Pays the unsettled delta up to `cumulativeAmount` using a valid voucher.
75        function settle(
76            ChannelDescriptor calldata descriptor,
77            uint96 cumulativeAmount,
78            bytes calldata signature
79        )
80            external;
81
82        /// Adds deposit to a channel and cancels any pending close request.
83        function topUp(
84            ChannelDescriptor calldata descriptor,
85            uint96 additionalDeposit
86        )
87            external;
88
89        /// Closes the channel from the payee/operator side and refunds uncaptured deposit.
90        function close(
91            ChannelDescriptor calldata descriptor,
92            uint96 cumulativeAmount,
93            uint96 captureAmount,
94            bytes calldata signature
95        )
96            external;
97
98        /// Starts the payer withdrawal timer.
99        function requestClose(ChannelDescriptor calldata descriptor) external;
100
101        /// Withdraws the payer refund after the close grace period has elapsed.
102        function withdraw(ChannelDescriptor calldata descriptor) external;
103
104        /// Returns the descriptor and state for a channel.
105        function getChannel(ChannelDescriptor calldata descriptor)
106            external
107            view
108            returns (Channel memory);
109
110        /// Returns the state for `channelId`, or the zero state when absent.
111        function getChannelState(bytes32 channelId) external view returns (ChannelState memory);
112
113        /// Returns states for `channelIds` in order.
114        function getChannelStatesBatch(bytes32[] calldata channelIds)
115            external
116            view
117            returns (ChannelState[] memory);
118
119        /// Computes the canonical channel id for a descriptor.
120        function computeChannelId(
121            address payer,
122            address payee,
123            address operator,
124            address token,
125            bytes32 salt,
126            address authorizedSigner,
127            bytes32 expiringNonceHash
128        )
129            external
130            view
131            returns (bytes32);
132
133        /// Computes the EIP-712 digest signed by the payer or authorized signer.
134        function getVoucherDigest(bytes32 channelId, uint96 cumulativeAmount)
135            external
136            view
137            returns (bytes32);
138
139        /// Returns the EIP-712 domain separator for the current chain.
140        function domainSeparator() external view returns (bytes32);
141
142        /// Emitted after a channel is opened and funded.
143        event ChannelOpened(
144            bytes32 indexed channelId,
145            address indexed payer,
146            address indexed payee,
147            address operator,
148            address token,
149            address authorizedSigner,
150            bytes32 salt,
151            bytes32 expiringNonceHash,
152            uint96 deposit
153        );
154
155        /// Emitted after voucher settlement pays a delta to the payee.
156        event Settled(
157            bytes32 indexed channelId,
158            address indexed payer,
159            address indexed payee,
160            uint96 cumulativeAmount,
161            uint96 deltaPaid,
162            uint96 newSettled
163        );
164
165        /// Emitted after channel deposit changes or a close request is cancelled by top-up.
166        event TopUp(
167            bytes32 indexed channelId,
168            address indexed payer,
169            address indexed payee,
170            uint96 additionalDeposit,
171            uint96 newDeposit
172        );
173
174        /// Emitted when the payer starts the close grace timer.
175        event CloseRequested(
176            bytes32 indexed channelId,
177            address indexed payer,
178            address indexed payee,
179            uint256 closeGraceEnd
180        );
181
182        /// Emitted when a channel is deleted by payee close or payer withdraw.
183        event ChannelClosed(
184            bytes32 indexed channelId,
185            address indexed payer,
186            address indexed payee,
187            uint96 settledToPayee,
188            uint96 refundedToPayer
189        );
190
191        /// Emitted when top-up clears a pending close request.
192        event CloseRequestCancelled(
193            bytes32 indexed channelId,
194            address indexed payer,
195            address indexed payee
196        );
197
198        /// Channel id already exists in persistent state or earlier in this transaction.
199        error ChannelAlreadyExists();
200        /// Descriptor resolves to an empty channel slot.
201        error ChannelNotFound();
202        /// Caller must be the descriptor payer.
203        error NotPayer();
204        /// Caller must be the descriptor payee or nonzero operator.
205        error NotPayeeOrOperator();
206        /// Payee is zero or a TIP-20-prefix address.
207        error InvalidPayee();
208        /// Initial deposit cannot be zero.
209        error ZeroDeposit();
210        /// Handler did not seed the transaction-scoped open context hash.
211        error ExpiringNonceHashNotSet();
212        /// Voucher signature did not recover to the expected signer.
213        error InvalidSignature();
214        /// Voucher or capture amount exceeds the channel deposit.
215        error AmountExceedsDeposit();
216        /// Settlement amount must be greater than the current settled amount.
217        error AmountNotIncreasing();
218        /// Close capture is below settled amount or above voucher amount.
219        error CaptureAmountInvalid();
220        /// Payer withdraw was attempted before the close grace period elapsed.
221        error CloseNotReady();
222        /// Top-up would overflow the packed deposit.
223        error DepositOverflow();
224    }
225}
226
227/// TIP-1045 Maximum calldata length (in bytes) for payment-eligible calls with dynamic params.
228pub const MAX_PAYMENT_CALLDATA_LEN: usize = 2048;
229
230impl ITIP20ChannelReserve::ITIP20ChannelReserveCalls {
231    /// Returns `true` if `input` matches one of the recognized [TIP-20 channel reserve payment]
232    /// selectors: `open`, `topUp`, `settle`, `close`, `requestClose`, `withdraw`.
233    ///
234    /// # NOTES
235    /// - Only validates calldata; caller must check that `to == TIP20_CHANNEL_RESERVE_ADDRESS`.
236    /// - Static-only calls require exact ABI-encoded length.
237    /// - Dynamic calls require valid ABI decoding and calldata length <= [`MAX_PAYMENT_CALLDATA_LEN`].
238    /// - Dynamic calls also require valid `signature` encoding.
239    ///
240    /// [TIP-20 channel reserve payment]: <https://docs.tempo.xyz/protocol/tip20/overview#get-predictable-payment-fees>
241    pub fn is_payment_with_valid_signature(
242        input: &[u8],
243        validate_signature: impl Fn(&[u8]) -> bool,
244    ) -> bool {
245        fn is_static_call<C: SolCall>(input: &[u8]) -> bool {
246            input.first_chunk::<4>() == Some(&C::SELECTOR)
247                && <C::Parameters<'_> as SolType>::ENCODED_SIZE
248                    .is_some_and(|canonical_size| input.len() == 4 + canonical_size)
249        }
250
251        fn decode_dynamic_call<C: SolCall>(input: &[u8]) -> Option<C> {
252            if input.first_chunk::<4>() != Some(&C::SELECTOR)
253                || input.len() > MAX_PAYMENT_CALLDATA_LEN
254            {
255                return None;
256            }
257
258            C::abi_decode_validate(input).ok()
259        }
260
261        is_static_call::<ITIP20ChannelReserve::openCall>(input)
262            || is_static_call::<ITIP20ChannelReserve::topUpCall>(input)
263            || decode_dynamic_call::<ITIP20ChannelReserve::closeCall>(input)
264                .is_some_and(|call| validate_signature(call.signature.as_ref()))
265            || decode_dynamic_call::<ITIP20ChannelReserve::settleCall>(input)
266                .is_some_and(|call| validate_signature(call.signature.as_ref()))
267            || is_static_call::<ITIP20ChannelReserve::requestCloseCall>(input)
268            || is_static_call::<ITIP20ChannelReserve::withdrawCall>(input)
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use alloc::{vec, vec::Vec};
276    use alloy_primitives::{B256, aliases::U96};
277
278    impl ITIP20ChannelReserve::ITIP20ChannelReserveCalls {
279        /// Test-only helper that accepts any decoded signature.
280        /// Avoids depending on `tempo-primitives`, which performs real signature validation.
281        fn is_payment(input: &[u8]) -> bool {
282            Self::is_payment_with_valid_signature(input, |_| true)
283        }
284    }
285
286    fn descriptor() -> ITIP20ChannelReserve::ChannelDescriptor {
287        ITIP20ChannelReserve::ChannelDescriptor {
288            payer: Address::random(),
289            payee: Address::random(),
290            operator: Address::random(),
291            token: Address::random(),
292            salt: B256::random(),
293            authorizedSigner: Address::random(),
294            expiringNonceHash: B256::random(),
295        }
296    }
297
298    #[rustfmt::skip]
299    fn payment_calldatas() -> [Vec<u8>; 6] {
300        let descriptor = descriptor();
301        [
302            ITIP20ChannelReserve::openCall { payee: Address::random(), operator: Address::random(), token: Address::random(), deposit: U96::from(1), salt: B256::random(), authorizedSigner: Address::random() }.abi_encode(),
303            ITIP20ChannelReserve::topUpCall { descriptor: descriptor.clone(), additionalDeposit: U96::ONE }.abi_encode(),
304            ITIP20ChannelReserve::settleCall { descriptor: descriptor.clone(), cumulativeAmount: U96::ONE, signature: vec![1, 2, 3].into() }.abi_encode(),
305            ITIP20ChannelReserve::closeCall { descriptor: descriptor.clone(), cumulativeAmount: U96::ONE, captureAmount: U96::ONE, signature: vec![1, 2, 3].into() }.abi_encode(),
306            ITIP20ChannelReserve::requestCloseCall { descriptor: descriptor.clone() }.abi_encode(),
307            ITIP20ChannelReserve::withdrawCall { descriptor }.abi_encode(),
308        ]
309    }
310
311    #[test]
312    fn test_is_payment() {
313        for calldata in payment_calldatas() {
314            assert!(ITIP20ChannelReserve::ITIP20ChannelReserveCalls::is_payment(
315                &calldata
316            ));
317        }
318
319        let mut unknown = payment_calldatas()[0].clone();
320        unknown[..4].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
321        assert!(!ITIP20ChannelReserve::ITIP20ChannelReserveCalls::is_payment(&unknown));
322    }
323
324    #[test]
325    fn test_is_payment_rejects_malformed_dynamic_calldata() {
326        let mut calldata = ITIP20ChannelReserve::settleCall {
327            descriptor: descriptor(),
328            cumulativeAmount: U96::from(1),
329            signature: vec![1, 2, 3].into(),
330        }
331        .abi_encode();
332        // Corrupt the dynamic `signature` offset word.
333        calldata[4 + 8 * 32 + 31] = 0;
334        assert!(!ITIP20ChannelReserve::ITIP20ChannelReserveCalls::is_payment(&calldata));
335
336        let mut oversized = ITIP20ChannelReserve::settleCall {
337            descriptor: descriptor(),
338            cumulativeAmount: U96::from(1),
339            signature: vec![0; 2048].into(),
340        }
341        .abi_encode();
342        assert!(oversized.len() > 2048);
343        assert!(!ITIP20ChannelReserve::ITIP20ChannelReserveCalls::is_payment(&oversized));
344
345        oversized.truncate(4);
346        assert!(!ITIP20ChannelReserve::ITIP20ChannelReserveCalls::is_payment(&oversized));
347    }
348}