Skip to main content

foundry_common/provider/mpp/
session.rs

1//! Tempo session payment provider with expiring nonces.
2//!
3//! Custom implementation that mirrors `tempoxyz/wallet`'s approach: uses
4//! expiring nonces (`nonce=0`, `nonceKey=MAX`, `validBefore=now+25s`) for
5//! channel open transactions instead of fetching sequential nonces via
6//! `eth_getTransactionCount`. This avoids the chicken-and-egg problem when
7//! the RPC endpoint is itself 402-gated.
8
9use super::persist;
10use alloy_primitives::{Address, B256, Bytes, TxKind, U256};
11use alloy_sol_types::SolCall as _;
12use foundry_wallets::Channel;
13use mpp::{
14    client::{
15        PaymentProvider,
16        channel_ops::{
17            ChannelEntry, OpenPayloadOptions, build_credential, compute_expiring_nonce_hash,
18            create_voucher_payload, is_precompile_escrow, resolve_chain_id, resolve_escrow,
19        },
20        tempo::signing::{
21            TempoSigningMode, sign_and_encode_async, sign_and_encode_fee_payer_envelope_async,
22            sign_and_encode_fee_payer_request_async,
23        },
24    },
25    error::MppError,
26    protocol::{
27        core::{PaymentChallenge, PaymentCredential},
28        intents::SessionRequest,
29        methods::tempo::{
30            precompile_voucher::{
31                PRECOMPILE_MAX_CUMULATIVE_AMOUNT, compute_precompile_channel_id,
32                sign_precompile_voucher,
33            },
34            session::{ChannelDescriptor, TempoSessionExt},
35        },
36    },
37    tempo::{Call, SessionCredentialPayload, compute_channel_id, sign_voucher},
38};
39use std::{
40    collections::HashMap,
41    sync::{Arc, Mutex, OnceLock},
42};
43use tempo_alloy::contracts::precompiles::{ITIP20ChannelReserve, TIP20_CHANNEL_RESERVE_ADDRESS};
44
45/// Shared per-origin in-memory channel state: (channels, precompile descriptors, key_provisioned).
46type SharedChannelState = (
47    Arc<Mutex<HashMap<String, ChannelEntry>>>,
48    Arc<Mutex<HashMap<String, ChannelDescriptor>>>,
49    Arc<Mutex<bool>>,
50);
51
52/// Process-wide channel state registry, keyed by origin URL.
53///
54/// Stores per-origin in-memory channel maps and key provisioning state.
55static GLOBAL_CHANNELS: OnceLock<Mutex<HashMap<String, SharedChannelState>>> = OnceLock::new();
56
57/// Process-wide persisted channel state, shared across ALL origins.
58///
59/// Using a single map ensures saves from different origins don't clobber
60/// each other's state.
61static GLOBAL_PERSISTED: OnceLock<Arc<Mutex<HashMap<String, Channel>>>> = OnceLock::new();
62
63/// Tracks uncommitted channel state from the most recent payment.
64///
65/// Used to defer persistence until the server confirms acceptance, preventing
66/// local state from getting ahead of reality on failed open/top-up.
67#[derive(Clone, Debug)]
68enum PendingAction {
69    /// A new channel was opened but not yet confirmed by the server.
70    Open { key: String },
71    /// A top-up was prepared but not yet confirmed by the server.
72    TopUp { key: String, old_deposit: String },
73    /// A voucher cumulative_amount was advanced but not yet confirmed.
74    Voucher { key: String, old_cumulative: u128 },
75}
76
77/// Expiring nonce key (U256::MAX) — matches the charge flow.
78const EXPIRING_NONCE_KEY: U256 = U256::MAX;
79
80/// Validity window (in seconds) for expiring nonce transactions.
81const VALID_BEFORE_SECS: u64 = 25;
82
83/// Default gas limit for session open transactions.
84const SESSION_OPEN_GAS_LIMIT: u64 = 10_000_000;
85
86/// Gas limit for session open transactions when the sponsor pays
87/// (`feePayer: true`). Set to the mpp-rs `FeePayerPolicy::max_gas` ceiling
88/// (`MAX_FEE_PAYER_GAS_LIMIT = 2_000_000`, inclusive); exceeding it causes the
89/// sponsor to reject the tx with `verification-failed`. The previous value of
90/// 1M was too tight for Tempo mainnet passkey-wallet `escrow.open`, which
91/// together with the inner `approve` consumes ~1.2M gas and ran out of gas
92/// on-chain (tx reverted, sponsor returned generic `verification-failed`).
93const SESSION_OPEN_FEE_PAYER_GAS_LIMIT: u64 = 2_000_000;
94
95/// Max fee per gas (20 gwei — Tempo's fixed base fee).
96const MAX_FEE_PER_GAS: u128 = 20_000_000_000;
97
98/// Max priority fee per gas.
99const MAX_PRIORITY_FEE_PER_GAS: u128 = 20_000_000_000;
100
101/// Priority fee per gas when the sponsor pays (`feePayer: true`). Must stay
102/// under the server-enforced `MAX_PRIORITY_FEE_PER_GAS_DEFAULT` (10 gwei)
103/// defined by the mpp-rs `FeePayerPolicy`.
104const MAX_PRIORITY_FEE_PER_GAS_FEE_PAYER: u128 = 1_000_000_000;
105
106/// Tempo session provider using expiring nonces.
107///
108/// Unlike mpp-rs's `TempoSessionProvider` which fetches sequential nonces
109/// (requiring a non-gated RPC), this provider uses expiring nonces for
110/// channel open transactions — matching how `tempoxyz/wallet` works.
111#[derive(Clone)]
112pub struct SessionProvider {
113    signer: mpp::PrivateKeySigner,
114    signing_mode: TempoSigningMode,
115    authorized_signer: Option<Address>,
116    default_deposit: Option<u128>,
117    channels: Arc<Mutex<HashMap<String, ChannelEntry>>>,
118    precompile_descriptors: Arc<Mutex<HashMap<String, ChannelDescriptor>>>,
119    key_provisioned: Arc<Mutex<bool>>,
120    persisted: Arc<Mutex<HashMap<String, Channel>>>,
121    /// Tracks uncommitted open/top-up state for deferred persistence.
122    pending: Arc<Mutex<Option<PendingAction>>>,
123    /// Chain ID from the key entry in `keys.toml` that was used to initialize
124    /// this provider. Used to reject challenges for a different chain.
125    key_chain_id: Option<u64>,
126    /// Currencies from the key's spending limits. Used to reject challenges
127    /// for currencies the key cannot pay with.
128    key_currencies: Vec<Address>,
129    origin: String,
130}
131
132impl std::fmt::Debug for SessionProvider {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.debug_struct("SessionProvider")
135            .field("signing_mode", &self.signing_mode)
136            .field("authorized_signer", &self.authorized_signer)
137            .field("default_deposit", &self.default_deposit)
138            .finish_non_exhaustive()
139    }
140}
141
142impl SessionProvider {
143    /// Create a new session provider with the given signer and RPC origin URL.
144    ///
145    /// Channel state is shared process-wide: all `SessionProvider` instances
146    /// share the same in-memory channels and persisted state. This prevents
147    /// concurrent providers (e.g. multiple `forge script` providers for the
148    /// same URL) from reading stale `cumulative_amount` values from disk and
149    /// producing duplicate vouchers.
150    pub fn new(signer: mpp::PrivateKeySigner, origin: String) -> Self {
151        // Global persisted map shared across all origins.
152        let persisted =
153            GLOBAL_PERSISTED.get_or_init(|| Arc::new(Mutex::new(persist::load_channels()))).clone();
154
155        // Per-origin in-memory channel map + key provisioning state.
156        let (channels, precompile_descriptors, key_provisioned) = {
157            let global = GLOBAL_CHANNELS.get_or_init(|| Mutex::new(HashMap::new()));
158            let mut map = global.lock().unwrap();
159            map.entry(origin.clone())
160                .or_insert_with(|| {
161                    // Hydrate only channels belonging to this origin.
162                    let mut channels: HashMap<String, ChannelEntry> = HashMap::new();
163                    for (key, ch) in persisted.lock().unwrap().iter() {
164                        if ch.origin == origin
165                            && let Some(entry) = persist::to_channel_entry(ch)
166                        {
167                            channels.insert(key.clone(), entry);
168                        }
169                    }
170                    (
171                        Arc::new(Mutex::new(channels)),
172                        Arc::new(Mutex::new(HashMap::new())),
173                        Arc::new(Mutex::new(true)),
174                    )
175                })
176                .clone()
177        };
178
179        Self {
180            signer,
181            signing_mode: TempoSigningMode::Direct,
182            authorized_signer: None,
183            default_deposit: None,
184            channels,
185            precompile_descriptors,
186            key_provisioned,
187            persisted,
188            pending: Arc::new(Mutex::new(None)),
189            key_chain_id: None,
190            key_currencies: vec![],
191            origin,
192        }
193    }
194
195    /// Set the signing mode (direct or keychain).
196    pub fn with_signing_mode(mut self, mode: TempoSigningMode) -> Self {
197        self.signing_mode = mode;
198        self
199    }
200
201    /// Set the authorized signer address for keychain mode.
202    pub const fn with_authorized_signer(mut self, addr: Address) -> Self {
203        self.authorized_signer = Some(addr);
204        self
205    }
206
207    /// Set the default deposit amount.
208    pub const fn with_default_deposit(mut self, deposit: u128) -> Self {
209        self.default_deposit = Some(deposit);
210        self
211    }
212
213    /// Address that funds payments for this provider.
214    pub fn funding_wallet_address(&self) -> Address {
215        self.signing_mode.from_address(self.signer.address())
216    }
217
218    /// Chain ID from the selected wallet key, when known.
219    pub const fn key_chain_id(&self) -> Option<u64> {
220        self.key_chain_id
221    }
222
223    /// Set the chain ID and currencies from the key entry used to initialize
224    /// this provider. Used to reject challenges for incompatible chains/currencies.
225    /// When `chain_id` is `None` (e.g. env var key), chain filtering is skipped.
226    pub fn with_key_filters(mut self, chain_id: Option<u64>, currencies: Vec<Address>) -> Self {
227        self.key_chain_id = chain_id;
228        self.key_currencies = currencies;
229        self
230    }
231
232    /// Check whether this provider's key is compatible with the given
233    /// chain ID and currency from a 402 challenge.
234    pub fn matches_challenge(&self, chain_id: Option<u64>, currency: Option<Address>) -> bool {
235        if let Some(cid) = chain_id
236            && self.key_chain_id.is_some_and(|k| k != cid)
237        {
238            return false;
239        }
240        if let Some(cur) = currency
241            && !self.key_currencies.is_empty()
242            && !self.key_currencies.contains(&cur)
243        {
244            return false;
245        }
246        true
247    }
248
249    /// Clear channels belonging to this origin (e.g. after server 410).
250    ///
251    /// Only removes channels whose `origin` matches `self.origin`, preserving
252    /// channels for other RPC endpoints.
253    pub fn clear_channels(&self) {
254        let origin = &self.origin;
255        // Lock order: channels → persisted (consistent with pay_session)
256        let mut channels = self.channels.lock().unwrap();
257        let mut persisted = self.persisted.lock().unwrap();
258        let keys_to_remove: Vec<(String, String)> = persisted
259            .iter()
260            .filter(|(_, ch)| ch.origin == *origin)
261            .map(|(k, ch): (&String, &Channel)| (k.clone(), ch.channel_id.clone()))
262            .collect();
263        for (key, channel_id) in &keys_to_remove {
264            channels.remove(key);
265            self.precompile_descriptors.lock().unwrap().remove(key);
266            persisted.remove(key);
267            persist::delete_channel_from_db(channel_id);
268        }
269    }
270
271    /// Mark whether the access key has been provisioned on-chain.
272    pub fn set_key_provisioned(&self, provisioned: bool) {
273        *self.key_provisioned.lock().unwrap() = provisioned;
274    }
275
276    /// Check whether the access key has been provisioned on-chain.
277    pub fn is_key_provisioned(&self) -> bool {
278        *self.key_provisioned.lock().unwrap()
279    }
280
281    /// Persist any pending open/top-up/voucher state to disk.
282    ///
283    /// Called by the transport after the server confirms acceptance.
284    pub fn flush_pending(&self) {
285        let pending = self.pending.lock().unwrap().take();
286        if pending.is_some() {
287            persist::save_channels(&self.persisted.lock().unwrap());
288        }
289    }
290
291    /// Commit a pending top-up (deposit increase) without flushing to disk.
292    ///
293    /// Called by the transport when the server returns 204 (top-up accepted).
294    /// The deposit increase is now committed, but the follow-up voucher is
295    /// tracked as a new pending action.
296    pub fn commit_topup_and_track_voucher(&self) {
297        let pending = self.pending.lock().unwrap().take();
298        if let Some(PendingAction::TopUp { key, .. }) = pending {
299            // Top-up is now committed — read the current cumulative_amount
300            // so we can roll back just the voucher increment if needed.
301            let old_cumulative =
302                self.channels.lock().unwrap().get(&key).map(|e| e.cumulative_amount).unwrap_or(0);
303            *self.pending.lock().unwrap() = Some(PendingAction::Voucher { key, old_cumulative });
304        }
305    }
306
307    /// Roll back pending open/top-up/voucher state on failure.
308    ///
309    /// Called by the transport when the server rejects the payment or times out.
310    pub fn rollback_pending(&self) {
311        let pending = self.pending.lock().unwrap().take();
312        if let Some(action) = pending {
313            match action {
314                PendingAction::Open { key } => {
315                    self.channels.lock().unwrap().remove(&key);
316                    self.precompile_descriptors.lock().unwrap().remove(&key);
317                    self.persisted.lock().unwrap().remove(&key);
318                }
319                PendingAction::TopUp { key, old_deposit } => {
320                    if let Some(p) = self.persisted.lock().unwrap().get_mut(&key) {
321                        p.deposit = old_deposit;
322                    }
323                }
324                PendingAction::Voucher { key, old_cumulative } => {
325                    if let Some(entry) = self.channels.lock().unwrap().get_mut(&key) {
326                        entry.cumulative_amount = old_cumulative;
327                    }
328                    if let Some(p) = self.persisted.lock().unwrap().get_mut(&key) {
329                        p.cumulative_amount = old_cumulative.to_string();
330                    }
331                }
332            }
333        }
334    }
335
336    #[allow(clippy::too_many_arguments)]
337    fn channel_key(
338        origin: &str,
339        payer: &Address,
340        authorized_signer: Option<Address>,
341        payee: &Address,
342        currency: &Address,
343        escrow: &Address,
344        chain_id: u64,
345        operator: Address,
346    ) -> String {
347        // Use first 8 bytes of origin hash to scope the key without persisting
348        // the full URL (which may contain secrets in query params).
349        // `operator` scopes precompile channels (legacy passes ZERO).
350        let origin_hash = &alloy_primitives::keccak256(origin.as_bytes()).to_string()[..18];
351        let signer = authorized_signer.unwrap_or(*payer);
352        format!("{origin_hash}:{chain_id}:{payer}:{signer}:{payee}:{currency}:{escrow}:{operator}")
353            .to_lowercase()
354    }
355
356    /// Compute the TIP-1034 expiring nonce hash for fee-sponsored opens.
357    ///
358    /// The submitted transaction uses a fee-payer marker signature that the
359    /// sponsor replaces, and TIP-1034 derives channel identity from that same
360    /// marked transaction preimage.
361    fn compute_fee_payer_expiring_nonce_hash(
362        unsigned_tx: &tempo_primitives::transaction::TempoTransaction,
363        sender: Address,
364    ) -> B256 {
365        let mut tx = unsigned_tx.clone();
366        tx.fee_payer_signature =
367            Some(alloy_primitives::Signature::new(U256::ZERO, U256::ZERO, false));
368        compute_expiring_nonce_hash(&tx, sender)
369    }
370
371    fn resolve_deposit(&self, suggested: Option<&str>) -> Result<u128, MppError> {
372        let suggested_val = suggested.and_then(|s| s.parse::<u128>().ok());
373
374        // Local config takes priority. Warn when server suggests more so users
375        // can bump MPP_DEPOSIT if the default is too low.
376        if let (Some(sv), Some(local)) = (suggested_val, self.default_deposit)
377            && sv > local
378        {
379            let _ = sh_warn!(
380                "server-suggested deposit ({sv}) exceeds local default ({local}); \
381                 set MPP_DEPOSIT to override"
382            );
383        }
384
385        let amount = self.default_deposit.or(suggested_val);
386
387        amount.ok_or_else(|| {
388            MppError::InvalidConfig("no deposit amount: set default_deposit".to_string())
389        })
390    }
391
392    async fn create_open_tx(
393        &self,
394        payer: Address,
395        options: OpenPayloadOptions,
396        operator: Address,
397    ) -> Result<(ChannelEntry, SessionCredentialPayload), MppError> {
398        if is_precompile_escrow(options.escrow_contract) {
399            return self.create_precompile_open_tx(payer, options, operator).await;
400        }
401
402        let authorized_signer = options.authorized_signer.unwrap_or(payer);
403        let salt = B256::random();
404
405        let channel_id = compute_channel_id(
406            payer,
407            options.payee,
408            options.currency,
409            salt,
410            authorized_signer,
411            options.escrow_contract,
412            options.chain_id,
413        );
414
415        alloy_sol_types::sol! {
416            interface ITIP20 {
417                function approve(address spender, uint256 amount) external returns (bool);
418            }
419            interface IEscrow {
420                function open(
421                    address payee,
422                    address token,
423                    uint128 deposit,
424                    bytes32 salt,
425                    address authorizedSigner
426                ) external;
427            }
428        }
429
430        let approve_data =
431            ITIP20::approveCall::new((options.escrow_contract, U256::from(options.deposit)))
432                .abi_encode();
433
434        let open_data = IEscrow::openCall::new((
435            options.payee,
436            options.currency,
437            options.deposit,
438            salt,
439            authorized_signer,
440        ))
441        .abi_encode();
442
443        let calls = vec![
444            Call {
445                to: TxKind::Call(options.currency),
446                value: U256::ZERO,
447                input: Bytes::from(approve_data),
448            },
449            Call {
450                to: TxKind::Call(options.escrow_contract),
451                value: U256::ZERO,
452                input: Bytes::from(open_data),
453            },
454        ];
455
456        let valid_before = {
457            let now = std::time::SystemTime::now()
458                .duration_since(std::time::UNIX_EPOCH)
459                .unwrap_or_default()
460                .as_secs();
461            Some(now + VALID_BEFORE_SECS)
462        };
463
464        let tx = mpp::client::tempo::charge::tx_builder::build_tempo_tx(
465            mpp::client::tempo::charge::tx_builder::TempoTxOptions {
466                calls,
467                chain_id: options.chain_id,
468                fee_token: options.currency,
469                nonce: 0,
470                nonce_key: EXPIRING_NONCE_KEY,
471                gas_limit: if options.fee_payer {
472                    SESSION_OPEN_FEE_PAYER_GAS_LIMIT
473                } else {
474                    SESSION_OPEN_GAS_LIMIT
475                },
476                max_fee_per_gas: MAX_FEE_PER_GAS,
477                max_priority_fee_per_gas: if options.fee_payer {
478                    MAX_PRIORITY_FEE_PER_GAS_FEE_PAYER
479                } else {
480                    MAX_PRIORITY_FEE_PER_GAS
481                },
482                fee_payer: options.fee_payer,
483                valid_before,
484                key_authorization: (!*self.key_provisioned.lock().unwrap())
485                    .then(|| self.signing_mode.key_authorization().cloned())
486                    .flatten(),
487            },
488        );
489
490        let signed_tx = if options.fee_payer {
491            sign_and_encode_fee_payer_envelope_async(tx, &self.signer, &self.signing_mode).await?
492        } else {
493            sign_and_encode_async(tx, &self.signer, &self.signing_mode).await?
494        };
495
496        let voucher = sign_voucher(
497            &self.signer,
498            channel_id,
499            options.initial_amount,
500            options.escrow_contract,
501            options.chain_id,
502        )
503        .await?;
504
505        let entry = ChannelEntry {
506            channel_id,
507            salt,
508            cumulative_amount: options.initial_amount,
509            escrow_contract: options.escrow_contract,
510            chain_id: options.chain_id,
511            opened: true,
512        };
513
514        let signed_tx_hex = alloy_primitives::hex::encode_prefixed(&signed_tx);
515        let voucher_sig_hex = alloy_primitives::hex::encode_prefixed(&voucher);
516
517        Ok((
518            entry,
519            SessionCredentialPayload::Open {
520                payload_type: "transaction".to_string(),
521                channel_id: channel_id.to_string(),
522                transaction: signed_tx_hex,
523                descriptor: None,
524                authorized_signer: Some(format!("{authorized_signer}")),
525                cumulative_amount: options.initial_amount.to_string(),
526                signature: voucher_sig_hex,
527            },
528        ))
529    }
530
531    /// Open path for the T5 TIP-1034 reserve channel precompile.
532    /// Single-call tx (no `approve` — TIP-1035). `expiringNonceHash` is
533    /// computed before signing so the derived `channel_id` matches on-chain.
534    async fn create_precompile_open_tx(
535        &self,
536        payer: Address,
537        options: OpenPayloadOptions,
538        operator: Address,
539    ) -> Result<(ChannelEntry, SessionCredentialPayload), MppError> {
540        if options.deposit > PRECOMPILE_MAX_CUMULATIVE_AMOUNT
541            || options.initial_amount > PRECOMPILE_MAX_CUMULATIVE_AMOUNT
542        {
543            return Err(MppError::InvalidConfig(
544                "precompile escrow deposit/initial_amount must fit uint96".to_string(),
545            ));
546        }
547
548        let authorized_signer = options.authorized_signer.unwrap_or(payer);
549        let salt = B256::random();
550
551        let open_data = ITIP20ChannelReserve::openCall::new((
552            options.payee,
553            operator,
554            options.currency,
555            alloy_primitives::Uint::<96, 2>::from(options.deposit),
556            salt,
557            authorized_signer,
558        ))
559        .abi_encode();
560
561        let calls = vec![Call {
562            to: TxKind::Call(TIP20_CHANNEL_RESERVE_ADDRESS),
563            value: U256::ZERO,
564            input: Bytes::from(open_data),
565        }];
566
567        let valid_before = {
568            let now = std::time::SystemTime::now()
569                .duration_since(std::time::UNIX_EPOCH)
570                .unwrap_or_default()
571                .as_secs();
572            Some(now + VALID_BEFORE_SECS)
573        };
574
575        let tx = mpp::client::tempo::charge::tx_builder::build_tempo_tx(
576            mpp::client::tempo::charge::tx_builder::TempoTxOptions {
577                calls,
578                chain_id: options.chain_id,
579                fee_token: options.currency,
580                nonce: 0,
581                nonce_key: EXPIRING_NONCE_KEY,
582                gas_limit: if options.fee_payer {
583                    SESSION_OPEN_FEE_PAYER_GAS_LIMIT
584                } else {
585                    SESSION_OPEN_GAS_LIMIT
586                },
587                max_fee_per_gas: MAX_FEE_PER_GAS,
588                max_priority_fee_per_gas: if options.fee_payer {
589                    MAX_PRIORITY_FEE_PER_GAS_FEE_PAYER
590                } else {
591                    MAX_PRIORITY_FEE_PER_GAS
592                },
593                fee_payer: options.fee_payer,
594                valid_before,
595                key_authorization: (!*self.key_provisioned.lock().unwrap())
596                    .then(|| self.signing_mode.key_authorization().cloned())
597                    .flatten(),
598            },
599        );
600
601        // Must derive before signing: expiringNonceHash binds signing-payload bytes.
602        let expiring_nonce_hash = if options.fee_payer {
603            Self::compute_fee_payer_expiring_nonce_hash(&tx, payer)
604        } else {
605            compute_expiring_nonce_hash(&tx, payer)
606        };
607        let descriptor = ChannelDescriptor {
608            payer: payer.to_string(),
609            payee: options.payee.to_string(),
610            operator: operator.to_string(),
611            token: options.currency.to_string(),
612            salt: salt.to_string(),
613            authorized_signer: authorized_signer.to_string(),
614            expiring_nonce_hash: expiring_nonce_hash.to_string(),
615        };
616        let channel_id = compute_precompile_channel_id(
617            payer,
618            options.payee,
619            operator,
620            options.currency,
621            salt,
622            authorized_signer,
623            expiring_nonce_hash,
624            options.chain_id,
625        );
626
627        let signed_tx = if options.fee_payer {
628            sign_and_encode_fee_payer_request_async(tx, &self.signer, &self.signing_mode).await?
629        } else {
630            sign_and_encode_async(tx, &self.signer, &self.signing_mode).await?
631        };
632
633        let voucher = sign_precompile_voucher(
634            &self.signer,
635            channel_id,
636            options.initial_amount,
637            options.chain_id,
638        )
639        .await?;
640
641        let entry = ChannelEntry {
642            channel_id,
643            salt,
644            cumulative_amount: options.initial_amount,
645            escrow_contract: TIP20_CHANNEL_RESERVE_ADDRESS,
646            chain_id: options.chain_id,
647            opened: true,
648        };
649
650        Ok((
651            entry,
652            SessionCredentialPayload::Open {
653                payload_type: "transaction".to_string(),
654                channel_id: channel_id.to_string(),
655                transaction: alloy_primitives::hex::encode_prefixed(&signed_tx),
656                descriptor: Some(descriptor),
657                authorized_signer: Some(format!("{authorized_signer}")),
658                cumulative_amount: options.initial_amount.to_string(),
659                signature: alloy_primitives::hex::encode_prefixed(&voucher),
660            },
661        ))
662    }
663
664    async fn create_topup_tx(
665        &self,
666        entry: &ChannelEntry,
667        additional_deposit: u128,
668        currency: Address,
669        fee_payer: bool,
670    ) -> Result<SessionCredentialPayload, MppError> {
671        // Precompile top-up uses different (descriptor-based, uint96) calldata;
672        // fail loudly rather than send legacy calldata to the precompile.
673        if is_precompile_escrow(entry.escrow_contract) {
674            return Err(MppError::InvalidConfig(
675                "T5 precompile escrow top-up is not supported. Close the channel (or wait \
676                 for expiry) and re-run with a larger MPP_DEPOSIT. T5 cumulative_amount is \
677                 capped at uint96."
678                    .to_string(),
679            ));
680        }
681
682        alloy_sol_types::sol! {
683            interface ITIP20 {
684                function approve(address spender, uint256 amount) external returns (bool);
685            }
686            interface IEscrow {
687                function topUp(bytes32 channelId, uint256 additionalDeposit) external;
688            }
689        }
690
691        let approve_data =
692            ITIP20::approveCall::new((entry.escrow_contract, U256::from(additional_deposit)))
693                .abi_encode();
694        let topup_data =
695            IEscrow::topUpCall::new((entry.channel_id, U256::from(additional_deposit)))
696                .abi_encode();
697
698        let calls = vec![
699            Call {
700                to: TxKind::Call(currency),
701                value: U256::ZERO,
702                input: Bytes::from(approve_data),
703            },
704            Call {
705                to: TxKind::Call(entry.escrow_contract),
706                value: U256::ZERO,
707                input: Bytes::from(topup_data),
708            },
709        ];
710
711        let valid_before = {
712            let now = std::time::SystemTime::now()
713                .duration_since(std::time::UNIX_EPOCH)
714                .unwrap_or_default()
715                .as_secs();
716            Some(now + VALID_BEFORE_SECS)
717        };
718
719        let tx = mpp::client::tempo::charge::tx_builder::build_tempo_tx(
720            mpp::client::tempo::charge::tx_builder::TempoTxOptions {
721                calls,
722                chain_id: entry.chain_id,
723                fee_token: currency,
724                nonce: 0,
725                nonce_key: EXPIRING_NONCE_KEY,
726                gas_limit: if fee_payer {
727                    SESSION_OPEN_FEE_PAYER_GAS_LIMIT
728                } else {
729                    SESSION_OPEN_GAS_LIMIT
730                },
731                max_fee_per_gas: MAX_FEE_PER_GAS,
732                max_priority_fee_per_gas: if fee_payer {
733                    MAX_PRIORITY_FEE_PER_GAS_FEE_PAYER
734                } else {
735                    MAX_PRIORITY_FEE_PER_GAS
736                },
737                fee_payer,
738                valid_before,
739                key_authorization: None,
740            },
741        );
742
743        let signed_tx = if fee_payer {
744            sign_and_encode_fee_payer_envelope_async(tx, &self.signer, &self.signing_mode).await?
745        } else {
746            sign_and_encode_async(tx, &self.signer, &self.signing_mode).await?
747        };
748
749        Ok(SessionCredentialPayload::TopUp {
750            payload_type: "transaction".to_string(),
751            channel_id: entry.channel_id.to_string(),
752            transaction: alloy_primitives::hex::encode_prefixed(&signed_tx),
753            descriptor: None,
754            additional_deposit: additional_deposit.to_string(),
755        })
756    }
757}
758
759impl SessionProvider {
760    /// Handle a charge intent by building and signing a TIP-20 transfer transaction.
761    async fn pay_charge(
762        &self,
763        challenge: &PaymentChallenge,
764    ) -> Result<PaymentCredential, MppError> {
765        use mpp::client::tempo::charge::{SignOptions, TempoCharge};
766
767        let charge = TempoCharge::from_challenge(challenge)?;
768
769        // Strip key_authorization from the signing mode when the key is already
770        // provisioned on-chain. Otherwise the payment tx includes a redundant
771        // key provisioning call that fails with "access key already exists".
772        let signing_mode = if *self.key_provisioned.lock().unwrap() {
773            match &self.signing_mode {
774                TempoSigningMode::Keychain { wallet, version, .. } => TempoSigningMode::Keychain {
775                    wallet: *wallet,
776                    key_authorization: None,
777                    version: *version,
778                },
779                other => other.clone(),
780            }
781        } else {
782            self.signing_mode.clone()
783        };
784
785        let options = SignOptions { signing_mode: Some(signing_mode), ..Default::default() };
786        let signed = charge.sign_with_options(&self.signer, options).await?;
787        Ok(signed.into_credential())
788    }
789}
790
791impl PaymentProvider for SessionProvider {
792    fn supports(&self, method: &str, intent: &str) -> bool {
793        method == "tempo" && (intent == "session" || intent == "charge")
794    }
795
796    async fn pay(&self, challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
797        if challenge.intent.as_str() == "charge" {
798            return self.pay_charge(challenge).await;
799        }
800        self.pay_session(challenge).await
801    }
802}
803
804impl SessionProvider {
805    async fn pay_session(
806        &self,
807        challenge: &PaymentChallenge,
808    ) -> Result<PaymentCredential, MppError> {
809        let session_req: SessionRequest = challenge.request.decode().map_err(|e| {
810            MppError::InvalidConfig(format!("failed to decode session request: {e}"))
811        })?;
812
813        let chain_id = resolve_chain_id(challenge);
814        let escrow_contract = resolve_escrow(challenge, chain_id, None)?;
815        let payee: Address = session_req
816            .recipient
817            .as_deref()
818            .ok_or_else(|| {
819                MppError::InvalidConfig("session challenge missing recipient".to_string())
820            })?
821            .parse()
822            .map_err(|_e| MppError::InvalidConfig("invalid recipient address".to_string()))?;
823        let currency: Address = session_req
824            .currency
825            .parse()
826            .map_err(|_e| MppError::InvalidConfig("invalid currency address".to_string()))?;
827        let amount: u128 = session_req.parse_amount()?;
828
829        // Operator only applies to precompile escrow; legacy escrow forces ZERO
830        // so a stray `methodDetails.operator` can't fragment the cache key.
831        let operator = if is_precompile_escrow(escrow_contract) {
832            match session_req.method_details.as_ref().and_then(|v| v.get("operator")) {
833                None => Address::ZERO,
834                Some(v) => {
835                    let s = v.as_str().ok_or_else(|| {
836                        MppError::InvalidConfig(
837                            "methodDetails.operator must be a string".to_string(),
838                        )
839                    })?;
840                    s.parse::<Address>().map_err(|_| {
841                        MppError::InvalidConfig(format!("invalid operator address: {s}"))
842                    })?
843                }
844            }
845        } else {
846            Address::ZERO
847        };
848
849        let payer = self.signing_mode.from_address(self.signer.address());
850
851        let key = Self::channel_key(
852            &self.origin,
853            &payer,
854            self.authorized_signer,
855            &payee,
856            &currency,
857            &escrow_contract,
858            chain_id,
859            operator,
860        );
861
862        let voucher_info = {
863            let mut channels = self.channels.lock().unwrap();
864            if let Some(entry) = channels.get_mut(&key)
865                && entry.opened
866            {
867                let deposit = self
868                    .persisted
869                    .lock()
870                    .unwrap()
871                    .get(&key)
872                    .and_then(|p| p.deposit.parse::<u128>().ok())
873                    .unwrap_or(u128::MAX);
874
875                // checked_add: hostile amount must not wrap or panic.
876                let projected = entry.cumulative_amount.checked_add(amount).ok_or_else(|| {
877                    MppError::InvalidConfig("voucher cumulative_amount overflow".to_string())
878                });
879                Some(match projected {
880                    Err(e) => return Err(e),
881                    Ok(p) if p > deposit => Err((entry.clone(), deposit)),
882                    Ok(_) => Ok(entry.clone()),
883                })
884            } else {
885                None
886            }
887        };
888
889        if let Some(result) = voucher_info {
890            match result {
891                Err((entry, deposit)) => {
892                    let additional =
893                        self.resolve_deposit(session_req.suggested_deposit.as_deref())?;
894                    tracing::debug!(
895                        cumulative = entry.cumulative_amount,
896                        amount,
897                        deposit,
898                        additional,
899                        "channel deposit exhausted, topping up"
900                    );
901
902                    let payload = self
903                        .create_topup_tx(&entry, additional, currency, session_req.fee_payer())
904                        .await?;
905
906                    // Update in-memory state but defer persistence until server confirms.
907                    let old_deposit = {
908                        let mut persisted = self.persisted.lock().unwrap();
909                        if let Some(p) = persisted.get_mut(&key) {
910                            let old = p.deposit.clone();
911                            let old_val: u128 = old.parse().unwrap_or(0);
912                            let new_val = old_val.checked_add(additional).ok_or_else(|| {
913                                MppError::InvalidConfig("top-up deposit overflow".to_string())
914                            })?;
915                            p.deposit = new_val.to_string();
916                            old
917                        } else {
918                            "0".to_string()
919                        }
920                    };
921                    *self.pending.lock().unwrap() =
922                        Some(PendingAction::TopUp { key: key.clone(), old_deposit });
923
924                    return Ok(build_credential(challenge, payload, chain_id, payer));
925                }
926                Ok(entry) => {
927                    let old_cumulative = entry.cumulative_amount;
928                    let new_cumulative = old_cumulative.checked_add(amount).ok_or_else(|| {
929                        MppError::InvalidConfig("voucher cumulative_amount overflow".to_string())
930                    })?;
931                    let payload = if is_precompile_escrow(escrow_contract) {
932                        if new_cumulative > PRECOMPILE_MAX_CUMULATIVE_AMOUNT {
933                            return Err(MppError::InvalidConfig(
934                                "precompile voucher cumulative_amount must fit uint96".to_string(),
935                            ));
936                        }
937                        let descriptor = self
938                            .precompile_descriptors
939                            .lock()
940                            .unwrap()
941                            .get(&key)
942                            .cloned()
943                            .ok_or_else(|| {
944                                MppError::InvalidConfig(
945                                    "missing TIP-1034 descriptor for cached precompile channel"
946                                        .to_string(),
947                                )
948                            })?;
949                        let sig = sign_precompile_voucher(
950                            &self.signer,
951                            entry.channel_id,
952                            new_cumulative,
953                            chain_id,
954                        )
955                        .await?;
956                        SessionCredentialPayload::Voucher {
957                            channel_id: entry.channel_id.to_string(),
958                            descriptor: Some(descriptor),
959                            cumulative_amount: new_cumulative.to_string(),
960                            signature: alloy_primitives::hex::encode_prefixed(&sig),
961                        }
962                    } else {
963                        create_voucher_payload(
964                            &self.signer,
965                            entry.channel_id,
966                            new_cumulative,
967                            escrow_contract,
968                            chain_id,
969                        )
970                        .await?
971                    };
972
973                    // Payload succeeded — now commit the cumulative increment.
974                    {
975                        let mut channels = self.channels.lock().unwrap();
976                        if let Some(e) = channels.get_mut(&key) {
977                            e.cumulative_amount = new_cumulative;
978                        }
979                    }
980
981                    // Update in-memory persisted state but never write to disk
982                    // here — flush_pending() handles persistence after server
983                    // confirms acceptance.
984                    let updated_entry = ChannelEntry { cumulative_amount: new_cumulative, ..entry };
985                    let mut persisted = self.persisted.lock().unwrap();
986                    persist::upsert_channel_in_memory(&mut persisted, &key, &updated_entry);
987                    drop(persisted);
988
989                    // Track the voucher so we can roll back cumulative_amount
990                    // if the server rejects.
991                    if self.pending.lock().unwrap().is_none() {
992                        *self.pending.lock().unwrap() =
993                            Some(PendingAction::Voucher { key, old_cumulative });
994                    }
995
996                    return Ok(build_credential(challenge, payload, chain_id, payer));
997                }
998            }
999        }
1000
1001        // No existing channel — open with expiring nonces
1002        let deposit = self.resolve_deposit(session_req.suggested_deposit.as_deref())?;
1003
1004        let (entry, payload) = self
1005            .create_open_tx(
1006                payer,
1007                OpenPayloadOptions {
1008                    authorized_signer: self.authorized_signer,
1009                    escrow_contract,
1010                    payee,
1011                    currency,
1012                    deposit,
1013                    initial_amount: amount,
1014                    chain_id,
1015                    fee_payer: session_req.fee_payer(),
1016                },
1017                operator,
1018            )
1019            .await?;
1020
1021        // Update in-memory state but defer disk persistence until server confirms.
1022        self.channels.lock().unwrap().insert(key.clone(), entry.clone());
1023        if let SessionCredentialPayload::Open { descriptor: Some(descriptor), .. } = &payload {
1024            self.precompile_descriptors.lock().unwrap().insert(key.clone(), descriptor.clone());
1025        }
1026        let authorized_signer = self.authorized_signer.unwrap_or(payer);
1027        self.persisted.lock().unwrap().insert(
1028            key.clone(),
1029            persist::from_channel_entry(
1030                &entry,
1031                deposit,
1032                &self.origin,
1033                &payer,
1034                &payee,
1035                &currency,
1036                &authorized_signer,
1037            ),
1038        );
1039        *self.pending.lock().unwrap() = Some(PendingAction::Open { key });
1040        Ok(build_credential(challenge, payload, chain_id, payer))
1041    }
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047    use mpp::client::tempo::signing::KeychainVersion;
1048    use tempo_alloy::contracts::precompiles::DEFAULT_FEE_TOKEN;
1049    use tempo_primitives::transaction::{
1050        KeyAuthorization, PrimitiveSignature, SignatureType, SignedKeyAuthorization,
1051    };
1052
1053    /// Create a dummy `SignedKeyAuthorization` for tests.
1054    fn test_key_authorization() -> SignedKeyAuthorization {
1055        KeyAuthorization::unrestricted(4217, SignatureType::Secp256k1, Address::ZERO)
1056            .into_signed(PrimitiveSignature::default())
1057    }
1058
1059    fn strip_key_auth_if_provisioned(
1060        mode: &TempoSigningMode,
1061        provisioned: bool,
1062    ) -> TempoSigningMode {
1063        if provisioned {
1064            match mode {
1065                TempoSigningMode::Keychain { wallet, version, .. } => TempoSigningMode::Keychain {
1066                    wallet: *wallet,
1067                    key_authorization: None,
1068                    version: *version,
1069                },
1070                other => other.clone(),
1071            }
1072        } else {
1073            mode.clone()
1074        }
1075    }
1076
1077    /// Generate a unique origin URL per test to avoid shared state collisions.
1078    fn unique_origin() -> String {
1079        format!("https://rpc-{}.example.com", alloy_primitives::B256::random())
1080    }
1081
1082    #[test]
1083    fn test_key_provisioned_default_is_true() {
1084        let signer = mpp::PrivateKeySigner::random();
1085        let provider = SessionProvider::new(signer, unique_origin());
1086        assert!(*provider.key_provisioned.lock().unwrap());
1087    }
1088
1089    #[test]
1090    fn test_set_key_provisioned() {
1091        let signer = mpp::PrivateKeySigner::random();
1092        let provider = SessionProvider::new(signer, unique_origin());
1093        provider.set_key_provisioned(false);
1094        assert!(!*provider.key_provisioned.lock().unwrap());
1095        provider.set_key_provisioned(true);
1096        assert!(*provider.key_provisioned.lock().unwrap());
1097    }
1098
1099    #[test]
1100    fn test_pay_charge_strips_key_auth_when_provisioned() {
1101        let signer = mpp::PrivateKeySigner::random();
1102        let wallet = Address::repeat_byte(0xAA);
1103        let signing_mode = TempoSigningMode::Keychain {
1104            wallet,
1105            key_authorization: Some(Box::new(test_key_authorization())),
1106            version: KeychainVersion::V2,
1107        };
1108        let provider =
1109            SessionProvider::new(signer, unique_origin()).with_signing_mode(signing_mode);
1110
1111        let provisioned = *provider.key_provisioned.lock().unwrap();
1112        let result_mode = strip_key_auth_if_provisioned(&provider.signing_mode, provisioned);
1113
1114        assert!(
1115            result_mode.key_authorization().is_none(),
1116            "key_authorization should be stripped when key is provisioned"
1117        );
1118    }
1119
1120    #[test]
1121    fn test_pay_charge_keeps_key_auth_when_not_provisioned() {
1122        let signer = mpp::PrivateKeySigner::random();
1123        let wallet = Address::repeat_byte(0xAA);
1124        let signing_mode = TempoSigningMode::Keychain {
1125            wallet,
1126            key_authorization: Some(Box::new(test_key_authorization())),
1127            version: KeychainVersion::V2,
1128        };
1129        let provider =
1130            SessionProvider::new(signer, unique_origin()).with_signing_mode(signing_mode);
1131
1132        provider.set_key_provisioned(false);
1133
1134        let provisioned = *provider.key_provisioned.lock().unwrap();
1135        let result_mode = strip_key_auth_if_provisioned(&provider.signing_mode, provisioned);
1136
1137        assert!(
1138            result_mode.key_authorization().is_some(),
1139            "key_authorization should be preserved when key is NOT provisioned"
1140        );
1141    }
1142
1143    #[test]
1144    fn test_pay_charge_direct_mode_unaffected() {
1145        let signer = mpp::PrivateKeySigner::random();
1146        let provider = SessionProvider::new(signer, unique_origin())
1147            .with_signing_mode(TempoSigningMode::Direct);
1148
1149        let provisioned = *provider.key_provisioned.lock().unwrap();
1150        let result_mode = strip_key_auth_if_provisioned(&provider.signing_mode, provisioned);
1151
1152        assert!(
1153            matches!(result_mode, TempoSigningMode::Direct),
1154            "Direct mode should pass through unchanged"
1155        );
1156    }
1157
1158    fn payload_transaction_type(payload: &SessionCredentialPayload) -> u8 {
1159        let transaction = match payload {
1160            SessionCredentialPayload::Open { transaction, .. }
1161            | SessionCredentialPayload::TopUp { transaction, .. } => transaction,
1162            _ => panic!("expected transaction payload"),
1163        };
1164        let bytes =
1165            alloy_primitives::hex::decode(transaction.strip_prefix("0x").unwrap_or(transaction))
1166                .expect("valid transaction hex");
1167        bytes[0]
1168    }
1169
1170    /// Regression test for <https://github.com/foundry-rs/foundry/issues/14588>:
1171    /// session open/topup transactions must be encoded as `0x78` fee-payer
1172    /// envelope when `fee_payer == true`, not as a bare `0x76` Tempo tx.
1173    #[tokio::test]
1174    async fn test_session_transactions_use_fee_payer_envelope_when_fee_payer_true() {
1175        let signer = mpp::PrivateKeySigner::random();
1176        let payer = signer.address();
1177        let provider = SessionProvider::new(signer, unique_origin())
1178            .with_signing_mode(TempoSigningMode::Direct);
1179        let escrow_contract = Address::repeat_byte(0x11);
1180        let payee = Address::repeat_byte(0x22);
1181        let chain_id = 4217;
1182
1183        let (entry, open_payload) = provider
1184            .create_open_tx(
1185                payer,
1186                OpenPayloadOptions {
1187                    authorized_signer: None,
1188                    escrow_contract,
1189                    payee,
1190                    currency: DEFAULT_FEE_TOKEN,
1191                    deposit: 1_000_000,
1192                    initial_amount: 10,
1193                    chain_id,
1194                    fee_payer: true,
1195                },
1196                Address::ZERO,
1197            )
1198            .await
1199            .unwrap();
1200        assert_eq!(payload_transaction_type(&open_payload), 0x78);
1201
1202        let topup_payload =
1203            provider.create_topup_tx(&entry, 1_000_000, DEFAULT_FEE_TOKEN, true).await.unwrap();
1204        assert_eq!(payload_transaction_type(&topup_payload), 0x78);
1205
1206        let (_, open_without_fee_payer) = provider
1207            .create_open_tx(
1208                payer,
1209                OpenPayloadOptions {
1210                    authorized_signer: None,
1211                    escrow_contract,
1212                    payee,
1213                    currency: DEFAULT_FEE_TOKEN,
1214                    deposit: 1_000_000,
1215                    initial_amount: 10,
1216                    chain_id,
1217                    fee_payer: false,
1218                },
1219                Address::ZERO,
1220            )
1221            .await
1222            .unwrap();
1223        assert_eq!(payload_transaction_type(&open_without_fee_payer), 0x76);
1224    }
1225
1226    /// Legacy escrow address must flow into the entry's channel id.
1227    #[tokio::test]
1228    async fn legacy_solidity_escrow_address_flows_through_open_payload() {
1229        let escrow: Address = "0x33b901018174DDabE4841042ab76ba85D4e24f25".parse().unwrap();
1230
1231        let signer = mpp::PrivateKeySigner::random();
1232        let provider = SessionProvider::new(signer, unique_origin());
1233        let payer = provider.funding_wallet_address();
1234        let opts = OpenPayloadOptions {
1235            authorized_signer: None,
1236            escrow_contract: escrow,
1237            payee: Address::repeat_byte(0x11),
1238            currency: Address::repeat_byte(0x22),
1239            deposit: 100_000,
1240            initial_amount: 1_000,
1241            chain_id: 4217,
1242            fee_payer: false,
1243        };
1244        let (payee, currency, chain_id) = (opts.payee, opts.currency, opts.chain_id);
1245
1246        let (entry, _payload) = provider
1247            .create_open_tx(payer, opts, Address::ZERO)
1248            .await
1249            .expect("create_open_tx with legacy Solidity escrow");
1250
1251        assert_eq!(entry.escrow_contract, escrow);
1252
1253        let recomputed =
1254            compute_channel_id(payer, payee, currency, entry.salt, payer, escrow, chain_id);
1255        assert_eq!(entry.channel_id, recomputed);
1256    }
1257
1258    /// Precompile escrow routes to the precompile branch; id ≠ legacy formula.
1259    #[tokio::test]
1260    async fn precompile_escrow_open_uses_precompile_channel_id() {
1261        let signer = mpp::PrivateKeySigner::random();
1262        let provider = SessionProvider::new(signer, unique_origin());
1263        let payer = provider.funding_wallet_address();
1264        let opts = OpenPayloadOptions {
1265            authorized_signer: None,
1266            escrow_contract: TIP20_CHANNEL_RESERVE_ADDRESS,
1267            payee: Address::repeat_byte(0x11),
1268            currency: Address::repeat_byte(0x22),
1269            deposit: 100_000,
1270            initial_amount: 1_000,
1271            chain_id: 4217,
1272            fee_payer: false,
1273        };
1274
1275        let (entry, _payload) =
1276            provider.create_open_tx(payer, opts, Address::ZERO).await.expect("precompile open");
1277
1278        assert_eq!(entry.escrow_contract, TIP20_CHANNEL_RESERVE_ADDRESS);
1279        let legacy = compute_channel_id(
1280            payer,
1281            Address::repeat_byte(0x11),
1282            Address::repeat_byte(0x22),
1283            entry.salt,
1284            payer,
1285            TIP20_CHANNEL_RESERVE_ADDRESS,
1286            4217,
1287        );
1288        assert_ne!(entry.channel_id, legacy);
1289    }
1290
1291    /// Precompile open rejects `deposit`/`initial_amount` > uint96.
1292    #[tokio::test]
1293    async fn precompile_escrow_open_rejects_uint96_overflow() {
1294        let signer = mpp::PrivateKeySigner::random();
1295        let provider = SessionProvider::new(signer, unique_origin());
1296        let payer = provider.funding_wallet_address();
1297        let opts = OpenPayloadOptions {
1298            authorized_signer: None,
1299            escrow_contract: TIP20_CHANNEL_RESERVE_ADDRESS,
1300            payee: Address::repeat_byte(0x11),
1301            currency: Address::repeat_byte(0x22),
1302            deposit: PRECOMPILE_MAX_CUMULATIVE_AMOUNT + 1,
1303            initial_amount: 1,
1304            chain_id: 4217,
1305            fee_payer: false,
1306        };
1307        let err = provider
1308            .create_open_tx(payer, opts, Address::ZERO)
1309            .await
1310            .expect_err("deposit > uint96 must reject");
1311        assert!(matches!(err, MppError::InvalidConfig(_)));
1312    }
1313
1314    /// Precompile channels must not fall through to legacy top-up calldata.
1315    #[tokio::test]
1316    async fn precompile_escrow_topup_is_rejected() {
1317        let signer = mpp::PrivateKeySigner::random();
1318        let provider = SessionProvider::new(signer, unique_origin());
1319        let entry = ChannelEntry {
1320            channel_id: B256::ZERO,
1321            salt: B256::ZERO,
1322            cumulative_amount: 0,
1323            escrow_contract: TIP20_CHANNEL_RESERVE_ADDRESS,
1324            chain_id: 4217,
1325            opened: true,
1326        };
1327        let err = provider
1328            .create_topup_tx(&entry, 1_000, Address::repeat_byte(0x22), false)
1329            .await
1330            .expect_err("precompile top-up must be rejected");
1331        assert!(matches!(err, MppError::InvalidConfig(_)));
1332    }
1333
1334    /// Operator must reach the precompile `open` calldata and bind into the
1335    /// returned `channel_id`. Decodes the produced tx to verify both.
1336    #[tokio::test]
1337    async fn precompile_escrow_open_binds_operator_into_calldata() {
1338        use alloy_eips::eip2718::Decodable2718;
1339        use tempo_primitives::transaction::TempoTxEnvelope;
1340
1341        let signer = mpp::PrivateKeySigner::random();
1342        let provider = SessionProvider::new(signer, unique_origin());
1343        let payer = provider.funding_wallet_address();
1344        let base_opts = || OpenPayloadOptions {
1345            authorized_signer: None,
1346            escrow_contract: TIP20_CHANNEL_RESERVE_ADDRESS,
1347            payee: Address::repeat_byte(0x11),
1348            currency: Address::repeat_byte(0x22),
1349            deposit: 100_000,
1350            initial_amount: 1_000,
1351            chain_id: 4217,
1352            fee_payer: false,
1353        };
1354        let expected_operator = Address::repeat_byte(0x99);
1355
1356        let (entry, payload) = provider
1357            .create_open_tx(payer, base_opts(), expected_operator)
1358            .await
1359            .expect("precompile open with explicit operator");
1360
1361        let SessionCredentialPayload::Open { transaction, .. } = payload else {
1362            panic!("expected Open payload");
1363        };
1364        let tx_bytes = alloy_primitives::hex::decode(&transaction).expect("hex tx");
1365        let envelope =
1366            TempoTxEnvelope::decode_2718(&mut tx_bytes.as_slice()).expect("decode envelope");
1367        let TempoTxEnvelope::AA(aa_signed) = envelope else {
1368            panic!("expected AA envelope (0x76)");
1369        };
1370        let unsigned = aa_signed.strip_signature();
1371        assert_eq!(unsigned.calls.len(), 1);
1372        let call = &unsigned.calls[0];
1373        assert_eq!(call.to, TxKind::Call(TIP20_CHANNEL_RESERVE_ADDRESS));
1374
1375        let decoded =
1376            ITIP20ChannelReserve::openCall::abi_decode(&call.input).expect("decode openCall");
1377        assert_eq!(decoded.operator, expected_operator);
1378
1379        let authorized_signer = payer;
1380        let expected_id = compute_precompile_channel_id(
1381            payer,
1382            base_opts().payee,
1383            expected_operator,
1384            base_opts().currency,
1385            entry.salt,
1386            authorized_signer,
1387            mpp::client::channel_ops::compute_expiring_nonce_hash(&unsigned, payer),
1388            base_opts().chain_id,
1389        );
1390        assert_eq!(entry.channel_id, expected_id);
1391    }
1392
1393    /// Cumulative addition near `u128::MAX` must not wrap.
1394    #[test]
1395    fn voucher_cumulative_overflow_is_rejected() {
1396        assert!((u128::MAX - 1).checked_add(5).is_none());
1397    }
1398
1399    /// Precompile open carries the key-auth witness iff the key is not yet
1400    /// provisioned (otherwise the tx reverts with "access key already exists").
1401    #[tokio::test]
1402    async fn precompile_open_key_auth_tracks_provisioned_flag() {
1403        use alloy_eips::eip2718::Decodable2718;
1404        use tempo_primitives::transaction::TempoTxEnvelope;
1405
1406        async fn key_auth_present(provisioned: bool) -> bool {
1407            let signer = mpp::PrivateKeySigner::random();
1408            let signing_mode = TempoSigningMode::Keychain {
1409                wallet: Address::repeat_byte(0xAA),
1410                key_authorization: Some(Box::new(test_key_authorization())),
1411                version: KeychainVersion::V2,
1412            };
1413            let provider =
1414                SessionProvider::new(signer, unique_origin()).with_signing_mode(signing_mode);
1415            provider.set_key_provisioned(provisioned);
1416
1417            let payer = provider.funding_wallet_address();
1418            let opts = OpenPayloadOptions {
1419                authorized_signer: None,
1420                escrow_contract: TIP20_CHANNEL_RESERVE_ADDRESS,
1421                payee: Address::repeat_byte(0x11),
1422                currency: Address::repeat_byte(0x22),
1423                deposit: 100_000,
1424                initial_amount: 1_000,
1425                chain_id: 4217,
1426                fee_payer: false,
1427            };
1428            let (_entry, payload) =
1429                provider.create_open_tx(payer, opts, Address::ZERO).await.expect("precompile open");
1430            let SessionCredentialPayload::Open { transaction, .. } = payload else {
1431                panic!("expected Open payload");
1432            };
1433            let tx_bytes = alloy_primitives::hex::decode(&transaction).expect("hex tx");
1434            let envelope =
1435                TempoTxEnvelope::decode_2718(&mut tx_bytes.as_slice()).expect("decode envelope");
1436            let TempoTxEnvelope::AA(aa_signed) = envelope else {
1437                panic!("expected AA envelope (0x76)");
1438            };
1439            aa_signed.strip_signature().key_authorization.is_some()
1440        }
1441
1442        assert!(key_auth_present(false).await, "must include witness when not provisioned");
1443        assert!(!key_auth_present(true).await, "must omit witness once provisioned");
1444    }
1445
1446    /// Live T5 probe: pays one challenge fetched from
1447    /// `FOUNDRY_MPP_T5_RPC_URL` with `FOUNDRY_MPP_T5_PRIVATE_KEY`.
1448    #[tokio::test]
1449    #[ignore = "integration-only: requires FOUNDRY_MPP_T5_RPC_URL + FOUNDRY_MPP_T5_PRIVATE_KEY"]
1450    async fn integration_pay_t5_precompile_endpoint() {
1451        use mpp::protocol::core::parse_www_authenticate_all;
1452
1453        let Ok(url) = std::env::var("FOUNDRY_MPP_T5_RPC_URL") else {
1454            let _ = crate::sh_eprintln!("skip: set FOUNDRY_MPP_T5_RPC_URL");
1455            return;
1456        };
1457        let Ok(key_hex) = std::env::var("FOUNDRY_MPP_T5_PRIVATE_KEY") else {
1458            let _ = crate::sh_eprintln!("skip: set FOUNDRY_MPP_T5_PRIVATE_KEY");
1459            return;
1460        };
1461
1462        let signer: mpp::PrivateKeySigner = key_hex.parse().expect("invalid private key");
1463        let deposit: u128 =
1464            std::env::var("MPP_DEPOSIT").ok().and_then(|s| s.parse().ok()).unwrap_or(100_000);
1465        let provider = SessionProvider::new(signer, url.clone()).with_default_deposit(deposit);
1466
1467        let client = reqwest::Client::builder().no_proxy().build().unwrap();
1468        let body = serde_json::json!({
1469            "jsonrpc": "2.0",
1470            "id": 1,
1471            "method": "eth_blockNumber",
1472            "params": []
1473        });
1474        let resp = client
1475            .post(&url)
1476            .header("content-type", "application/json")
1477            .body(serde_json::to_vec(&body).unwrap())
1478            .send()
1479            .await
1480            .expect("request");
1481
1482        assert_eq!(
1483            resp.status(),
1484            reqwest::StatusCode::PAYMENT_REQUIRED,
1485            "endpoint did not return 402; not MPP-gated?"
1486        );
1487        let www: Vec<_> = resp
1488            .headers()
1489            .get_all("www-authenticate")
1490            .into_iter()
1491            .filter_map(|v| v.to_str().ok())
1492            .collect();
1493        // Only accept a T5 precompile-escrow session challenge: filtering on
1494        // `supports()` alone would pass against a legacy session endpoint.
1495        let challenge = parse_www_authenticate_all(www)
1496            .into_iter()
1497            .filter_map(|r| r.ok())
1498            .find(|c| {
1499                if c.intent.as_str() != "session"
1500                    || !provider.supports(c.method.as_str(), c.intent.as_str())
1501                {
1502                    return false;
1503                }
1504                let Ok(req) = c.request.decode::<mpp::protocol::intents::SessionRequest>() else {
1505                    return false;
1506                };
1507                req.escrow_contract().ok().and_then(|s| s.parse::<Address>().ok())
1508                    == Some(TIP20_CHANNEL_RESERVE_ADDRESS)
1509            })
1510            .expect("no T5 precompile session challenge offered by endpoint");
1511
1512        let credential = provider.pay(&challenge).await.expect("pay must succeed");
1513        let payload: mpp::protocol::methods::tempo::session::SessionCredentialPayload =
1514            credential.payload_as().expect("session payload");
1515        match payload {
1516            mpp::protocol::methods::tempo::session::SessionCredentialPayload::Open {
1517                channel_id,
1518                ..
1519            }
1520            | mpp::protocol::methods::tempo::session::SessionCredentialPayload::Voucher {
1521                channel_id,
1522                ..
1523            } => {
1524                assert!(channel_id.starts_with("0x") && channel_id.len() == 66, "{channel_id}");
1525            }
1526            other => panic!("unexpected payload variant: {other:?}"),
1527        }
1528    }
1529
1530    /// `channel_key` scopes by escrow + operator so cached channels can't be
1531    /// reused across legacy/precompile or across operators.
1532    #[test]
1533    fn channel_key_distinguishes_legacy_and_precompile_escrow() {
1534        let origin = "https://rpc.example.com";
1535        let payer = Address::repeat_byte(0xAA);
1536        let payee = Address::repeat_byte(0x11);
1537        let currency = Address::repeat_byte(0x22);
1538        let chain_id = 4217u64;
1539
1540        let legacy_escrow: Address = "0x33b901018174DDabE4841042ab76ba85D4e24f25".parse().unwrap();
1541        let t5_precompile: Address = "0x4D50500000000000000000000000000000000000".parse().unwrap();
1542
1543        let legacy_key = SessionProvider::channel_key(
1544            origin,
1545            &payer,
1546            None,
1547            &payee,
1548            &currency,
1549            &legacy_escrow,
1550            chain_id,
1551            Address::ZERO,
1552        );
1553        let precompile_key = SessionProvider::channel_key(
1554            origin,
1555            &payer,
1556            None,
1557            &payee,
1558            &currency,
1559            &t5_precompile,
1560            chain_id,
1561            Address::ZERO,
1562        );
1563
1564        assert_ne!(legacy_key, precompile_key);
1565
1566        let precompile_op_a = SessionProvider::channel_key(
1567            origin,
1568            &payer,
1569            None,
1570            &payee,
1571            &currency,
1572            &t5_precompile,
1573            chain_id,
1574            Address::repeat_byte(0xAA),
1575        );
1576        let precompile_op_b = SessionProvider::channel_key(
1577            origin,
1578            &payer,
1579            None,
1580            &payee,
1581            &currency,
1582            &t5_precompile,
1583            chain_id,
1584            Address::repeat_byte(0xBB),
1585        );
1586        assert_ne!(precompile_op_a, precompile_op_b);
1587        assert_ne!(precompile_key, precompile_op_a);
1588    }
1589
1590    /// Verify that a payment serialization lock (mirroring `lock_pay()` in
1591    /// `LazySessionProvider`) prevents concurrent voucher increments from
1592    /// producing duplicate cumulative amounts.
1593    #[tokio::test]
1594    async fn test_concurrent_voucher_increments_are_unique() {
1595        let channels: Arc<Mutex<HashMap<String, ChannelEntry>>> =
1596            Arc::new(Mutex::new(HashMap::new()));
1597        let key = "test-channel".to_string();
1598        channels.lock().unwrap().insert(
1599            key.clone(),
1600            ChannelEntry {
1601                channel_id: Default::default(),
1602                salt: Default::default(),
1603                cumulative_amount: 0,
1604                escrow_contract: Address::ZERO,
1605                chain_id: 42431,
1606                opened: true,
1607            },
1608        );
1609
1610        // Mirrors the `pay_lock` tokio::sync::Mutex used in LazySessionProvider
1611        // to serialize the 402 → pay → retry cycle.
1612        let pay_lock = std::sync::Arc::new(tokio::sync::Mutex::new(()));
1613        let amount: u128 = 1000;
1614        let num_tasks = 20;
1615        let results: Arc<Mutex<Vec<u128>>> = Arc::new(Mutex::new(Vec::new()));
1616
1617        let mut handles = Vec::new();
1618        for _ in 0..num_tasks {
1619            let channels = channels.clone();
1620            let key = key.clone();
1621            let results = results.clone();
1622            let pay_lock = pay_lock.clone();
1623            handles.push(tokio::spawn(async move {
1624                let _guard = pay_lock.lock().await;
1625                let cumulative = {
1626                    let mut ch = channels.lock().unwrap();
1627                    let entry = ch.get_mut(&key).unwrap();
1628                    entry.cumulative_amount += amount;
1629                    entry.cumulative_amount
1630                };
1631                results.lock().unwrap().push(cumulative);
1632            }));
1633        }
1634
1635        for h in handles {
1636            h.await.unwrap();
1637        }
1638
1639        let mut amounts = results.lock().unwrap().clone();
1640        amounts.sort();
1641        amounts.dedup();
1642        assert_eq!(
1643            amounts.len(),
1644            num_tasks,
1645            "each concurrent increment should produce a unique cumulative_amount"
1646        );
1647        assert_eq!(
1648            *amounts.last().unwrap(),
1649            amount * num_tasks as u128,
1650            "final cumulative_amount should equal amount × num_tasks"
1651        );
1652    }
1653}