diff --git a/.changelog/machineusd-session-settlement.md b/.changelog/machineusd-session-settlement.md new file mode 100644 index 00000000..0c2f6fc1 --- /dev/null +++ b/.changelog/machineusd-session-settlement.md @@ -0,0 +1,6 @@ +--- +"mpp": minor +--- + +Add route-bound machineUSD session channels and atomic settlement into the +merchant's configured stablecoin. diff --git a/src/client/tempo/session/channel_ops.rs b/src/client/tempo/session/channel_ops.rs index 123da387..1404433b 100644 --- a/src/client/tempo/session/channel_ops.rs +++ b/src/client/tempo/session/channel_ops.rs @@ -65,6 +65,8 @@ pub struct ChannelEntry { pub deposit: u128, /// Full TIP-1034 descriptor. Legacy contract channels do not have one. pub descriptor: Option, + /// Immutable machine-token settlement route, when enabled. + pub settlement_route: Option, /// Escrow contract address. pub escrow_contract: Address, /// Chain ID where the escrow contract is deployed. @@ -147,6 +149,7 @@ pub async fn create_voucher_payload( Ok(SessionCredentialPayload::Voucher { channel_id: channel_id.to_string(), descriptor: None, + settlement_route: None, cumulative_amount: cumulative_amount.to_string(), signature: alloy::hex::encode_prefixed(&sig), }) @@ -166,6 +169,7 @@ pub async fn create_precompile_voucher_payload( Ok(SessionCredentialPayload::Voucher { channel_id: channel_id.to_string(), descriptor: None, + settlement_route: None, cumulative_amount: cumulative_amount.to_string(), signature: alloy::hex::encode_prefixed(&sig), }) @@ -184,6 +188,7 @@ pub async fn create_precompile_voucher_payload_primitive( Ok(SessionCredentialPayload::Voucher { channel_id: channel_id.to_string(), descriptor: None, + settlement_route: None, cumulative_amount: cumulative_amount.to_string(), signature: alloy::hex::encode_prefixed(&signature), }) @@ -232,6 +237,7 @@ pub async fn create_precompile_voucher_payload_with_descriptor_primitive( Ok(SessionCredentialPayload::Voucher { channel_id: channel_id.to_string(), descriptor: Some(descriptor), + settlement_route: None, cumulative_amount: cumulative_amount.to_string(), signature: alloy::hex::encode_prefixed(&signature), }) @@ -263,6 +269,7 @@ pub async fn create_precompile_voucher_payload_with_descriptor_and_escrow( Ok(SessionCredentialPayload::Voucher { channel_id: channel_id.to_string(), descriptor: Some(descriptor), + settlement_route: None, cumulative_amount: cumulative_amount.to_string(), signature: alloy::hex::encode_prefixed(&sig), }) @@ -288,6 +295,7 @@ pub async fn create_close_payload( Ok(SessionCredentialPayload::Close { channel_id: channel_id.to_string(), descriptor: None, + settlement_route: None, cumulative_amount: cumulative_amount.to_string(), signature: alloy::hex::encode_prefixed(&sig), }) @@ -306,6 +314,7 @@ pub async fn create_precompile_close_payload( Ok(SessionCredentialPayload::Close { channel_id: channel_id.to_string(), descriptor: None, + settlement_route: None, cumulative_amount: cumulative_amount.to_string(), signature: alloy::hex::encode_prefixed(&sig), }) @@ -324,6 +333,7 @@ pub async fn create_precompile_close_payload_primitive( Ok(SessionCredentialPayload::Close { channel_id: channel_id.to_string(), descriptor: None, + settlement_route: None, cumulative_amount: cumulative_amount.to_string(), signature: alloy::hex::encode_prefixed(&signature), }) @@ -381,6 +391,7 @@ pub async fn create_precompile_close_payload_with_descriptor_primitive( Ok(SessionCredentialPayload::Close { channel_id: channel_id.to_string(), descriptor: Some(descriptor), + settlement_route: None, cumulative_amount: cumulative_amount.to_string(), signature: alloy::hex::encode_prefixed(&signature), }) @@ -419,6 +430,7 @@ pub async fn create_precompile_close_payload_with_descriptor_and_escrow( Ok(SessionCredentialPayload::Close { channel_id: channel_id.to_string(), descriptor: Some(descriptor), + settlement_route: None, cumulative_amount: cumulative_amount.to_string(), signature: alloy::hex::encode_prefixed(&sig), }) @@ -567,6 +579,7 @@ where cumulative_amount: options.initial_amount, deposit: options.deposit, descriptor: None, + settlement_route: None, escrow_contract: options.escrow_contract, chain_id: options.chain_id, opened: true, @@ -577,6 +590,7 @@ where channel_id: channel_id.to_string(), transaction: signed_tx_hex, descriptor: None, + settlement_route: None, authorized_signer: Some(authorized_signer.to_string()), cumulative_amount: options.initial_amount.to_string(), signature: alloy::hex::encode_prefixed(&voucher_sig), @@ -875,6 +889,7 @@ pub fn create_precompile_top_up_payload( channel_id: channel_id.to_string(), transaction, descriptor: Some(descriptor), + settlement_route: None, additional_deposit: additional_deposit.to_string(), } } @@ -897,6 +912,8 @@ pub struct OpenPrecompilePayloadOptions { pub initial_amount: u128, pub chain_id: u64, pub fee_payer: bool, + /// Descriptor salt override used to bind settlement route metadata. + pub salt: Option, } /// Options for a descriptor-backed TIP-1034 top-up transaction. @@ -950,7 +967,7 @@ where let signature_type = primitive_signer.signature_type(); let authorized_signer = options.authorized_signer.unwrap_or(payer); - let salt = B256::random(); + let salt = options.salt.unwrap_or_else(B256::random); let open_data = ITIP20ChannelReserve::openCall::new(( options.payee, @@ -1069,6 +1086,7 @@ where cumulative_amount: options.initial_amount, deposit: options.deposit, descriptor: Some(descriptor.clone()), + settlement_route: None, escrow_contract: TIP20_CHANNEL_RESERVE_ADDRESS, chain_id: options.chain_id, opened: true, @@ -1079,6 +1097,7 @@ where channel_id: channel_id.to_string(), transaction: signed_tx_hex, descriptor: Some(descriptor), + settlement_route: None, authorized_signer: Some(authorized_signer.to_string()), cumulative_amount: options.initial_amount.to_string(), signature: alloy::hex::encode_prefixed(&voucher_sig), @@ -1307,6 +1326,7 @@ pub async fn try_recover_channel>( cumulative_amount: on_chain.settled, deposit: on_chain.deposit, descriptor: None, + settlement_route: None, escrow_contract, chain_id, opened: true, @@ -1457,6 +1477,7 @@ mod tests { initial_amount: 1, chain_id: 4217, fee_payer: false, + salt: None, }; let err = create_precompile_open_payload(&provider, &signer, None, payer, opts) .await @@ -1520,6 +1541,7 @@ mod tests { cumulative_amount: 1000, deposit: 0, descriptor: None, + settlement_route: None, escrow_contract: Address::ZERO, chain_id: 42431, opened: true, @@ -1713,6 +1735,7 @@ mod tests { let payload = SessionCredentialPayload::Voucher { channel_id: "0xabc".to_string(), descriptor: None, + settlement_route: None, cumulative_amount: "5000".to_string(), signature: "0xdef".to_string(), }; @@ -1746,6 +1769,7 @@ mod tests { descriptor, cumulative_amount, signature, + .. } => { assert!(cid.starts_with("0x")); assert!(descriptor.is_none()); @@ -1777,6 +1801,7 @@ mod tests { descriptor, cumulative_amount, signature, + .. } => { assert!(cid.starts_with("0x")); assert!(descriptor.is_none()); @@ -1999,6 +2024,7 @@ mod tests { let payload = SessionCredentialPayload::Voucher { channel_id: "0xabc".to_string(), descriptor: None, + settlement_route: None, cumulative_amount: "5000".to_string(), signature: "0xdef".to_string(), }; @@ -2096,6 +2122,7 @@ mod tests { cumulative_amount: 0, deposit: 0, descriptor: None, + settlement_route: None, escrow_contract: Address::ZERO, chain_id: 42431, opened: false, diff --git a/src/client/tempo/session/mod.rs b/src/client/tempo/session/mod.rs index 6dcf3e0d..db2f2281 100644 --- a/src/client/tempo/session/mod.rs +++ b/src/client/tempo/session/mod.rs @@ -33,10 +33,7 @@ use self::recovery::{ can_sign_descriptor, hydrate_session_snapshot, read_on_chain_channel_state, recover_stored_channel, RecoveryScope, }; -use self::store::{ - channel_key as persistent_channel_key, ChannelStore, ChannelStoreLease, MemoryChannelStore, - StoredChannelEntry, -}; +use self::store::{ChannelStore, ChannelStoreLease, MemoryChannelStore, StoredChannelEntry}; use super::autoswap::AutoswapConfig; use super::signing::TempoPrimitiveSigner; use crate::client::{PaymentContext, PaymentProvider}; @@ -94,6 +91,8 @@ pub struct TempoSessionProvider { channel_id_to_key: Arc>>, /// Newly prepared channels awaiting acceptance by the server. pending_opens: Arc>>, + settlement_routes: + Arc>>, /// Optional callback for channel state changes. on_channel_update: Option>, /// Last challenge received from the server, used for `close()`. @@ -148,6 +147,7 @@ impl TempoSessionProvider { channel_store: Arc::new(MemoryChannelStore::default()), channel_id_to_key: Arc::new(Mutex::new(HashMap::new())), pending_opens: Arc::new(Mutex::new(HashMap::new())), + settlement_routes: Arc::new(Mutex::new(HashMap::new())), on_channel_update: None, last_challenge: Arc::new(Mutex::new(None)), payment_lock: Arc::new(tokio::sync::Mutex::new(())), @@ -337,6 +337,7 @@ impl TempoSessionProvider { cumulative_amount: entry.cumulative_amount, deposit: entry.deposit, descriptor, + settlement_route: entry.settlement_route.clone(), escrow: entry.escrow_contract, chain_id: entry.chain_id, opened: entry.opened, @@ -444,20 +445,96 @@ impl TempoSessionProvider { cumulative_amount: entry.cumulative_amount, deposit: entry.deposit, descriptor: Some(entry.descriptor), + settlement_route: entry.settlement_route, escrow_contract: entry.escrow, chain_id: entry.chain_id, opened: entry.opened, }) } - /// Cache key identifying the reusable payment scope. - /// - /// This intentionally matches MPPx: operator is channel identity, not - /// payment scope, while chain ID must prevent cross-network reuse. - fn channel_key(payee: &Address, currency: &Address, escrow: &Address, chain_id: u64) -> String { + /// Cache key identifying the reusable payment and settlement scope. + fn channel_key_with_operator( + payee: &Address, + _operator: &Address, + currency: &Address, + escrow: &Address, + chain_id: u64, + ) -> String { format!("{:#x}:{:#x}:{:#x}:{chain_id}", payee, currency, escrow) } + #[cfg(test)] + fn channel_key(payee: &Address, currency: &Address, escrow: &Address, chain_id: u64) -> String { + Self::channel_key_with_operator(payee, &Address::ZERO, currency, escrow, chain_id) + } + + fn payment_route( + session_req: &SessionRequest, + chain_id: u64, + ) -> Result<(Address, Address, Address), MppError> { + let merchant: Address = session_req + .recipient + .as_deref() + .ok_or_else(|| MppError::InvalidConfig("session challenge missing recipient".into()))? + .parse() + .map_err(|_| MppError::InvalidConfig("invalid recipient address".into()))?; + let target: Address = session_req + .currency + .parse() + .map_err(|_| MppError::InvalidConfig("invalid currency address".into()))?; + if session_req.machine_token_enabled() { + let (token, swapper) = + crate::protocol::methods::tempo::machine_token::session_addresses(chain_id) + .ok_or_else(|| { + MppError::InvalidConfig(format!( + "machine tokens are not supported on chain ID {chain_id}" + )) + })?; + let details = session_req.tempo_session_details()?; + if details.settlement_adapter.as_deref() != Some(&swapper.to_string()) + || details.settlement_recipient.as_deref() != Some(&merchant.to_string()) + || details.settlement_token.as_deref() != Some(&target.to_string()) + { + return Err(MppError::InvalidConfig( + "machine-token settlement route does not match the session request".into(), + )); + } + Ok((swapper, Self::parse_operator(session_req)?, token)) + } else { + Ok((merchant, Self::parse_operator(session_req)?, target)) + } + } + + fn payment_scope_key( + session_req: &SessionRequest, + payee: Address, + operator: Address, + token: Address, + escrow: Address, + chain_id: u64, + ) -> Result { + if session_req.machine_token_enabled() { + let details = session_req.tempo_session_details()?; + return Ok(format!( + "{}:{}:{}:{:#x}:{}", + payee, + details + .settlement_recipient + .ok_or_else(|| MppError::InvalidConfig("missing settlement recipient".into()))? + .to_ascii_lowercase(), + details + .settlement_token + .ok_or_else(|| MppError::InvalidConfig("missing settlement token".into()))? + .to_ascii_lowercase(), + escrow, + chain_id, + )); + } + Ok(Self::channel_key_with_operator( + &payee, &operator, &token, &escrow, chain_id, + )) + } + /// Parse `methodDetails.operator` from a precompile session request. /// Missing → `Address::ZERO` (payee-only operator). fn parse_operator(session_req: &SessionRequest) -> Result { @@ -487,21 +564,17 @@ impl TempoSessionProvider { .request .decode() .mpp_config("failed to decode session request")?; - let payee: Address = session_req - .recipient - .as_deref() - .ok_or_else(|| { - MppError::InvalidConfig("session challenge missing recipient".to_string()) - })? - .parse() - .map_err(|_| MppError::InvalidConfig("invalid recipient address".to_string()))?; - let currency: Address = session_req - .currency - .parse() - .map_err(|_| MppError::InvalidConfig("invalid currency address".to_string()))?; + let (payee, operator, currency) = Self::payment_route(&session_req, chain_id)?; Ok(( - Self::channel_key(&payee, ¤cy, &escrow_contract, chain_id), + Self::payment_scope_key( + &session_req, + payee, + operator, + currency, + escrow_contract, + chain_id, + )?, chain_id, )) } @@ -509,6 +582,7 @@ impl TempoSessionProvider { async fn restore_precompile_channel

( &self, provider: &P, + store_key: &str, scope: RecoveryScope, snapshot: Option<&crate::protocol::methods::tempo::session::SessionSnapshot>, request_amount: u128, @@ -516,15 +590,9 @@ impl TempoSessionProvider { where P: alloy::providers::Provider, { - let store_key = persistent_channel_key( - &scope.payee.to_string(), - &scope.token.to_string(), - scope.escrow, - scope.chain_id, - ); let stored = self .channel_store - .get(&store_key) + .get(store_key) .await .map_err(Self::store_error)?; @@ -580,7 +648,7 @@ impl TempoSessionProvider { let state = read_on_chain_channel_state(provider, stored.channel_id).await?; if state.deposit == 0 || state.close_requested_at != 0 { self.channel_store - .delete(&store_key) + .delete(store_key) .await .map_err(Self::store_error)?; return Ok(None); @@ -818,6 +886,11 @@ impl TempoSessionProvider { .payee .parse() .mpp_config("invalid snapshot payee")?; + let operator = snapshot + .descriptor + .operator + .parse() + .mpp_config("invalid snapshot operator")?; let token = snapshot .descriptor .token @@ -838,6 +911,7 @@ impl TempoSessionProvider { payer, authorized_signer, payee, + operator, token, escrow, chain_id: snapshot.chain_id, @@ -850,7 +924,8 @@ impl TempoSessionProvider { .map_err(Self::store_error)?; let entry = Self::channel_entry(recovered.clone())?; - let key = Self::channel_key(&payee, &token, &escrow, snapshot.chain_id); + let key = + Self::channel_key_with_operator(&payee, &operator, &token, &escrow, snapshot.chain_id); self.channel_id_to_key .lock() .unwrap() @@ -939,7 +1014,7 @@ impl TempoSessionProvider { )); } - let payload = if is_precompile_escrow(entry.escrow_contract) { + let mut payload = if is_precompile_escrow(entry.escrow_contract) { create_precompile_voucher_payload_with_descriptor_primitive( &self.signer, entry.descriptor.clone().ok_or_else(|| { @@ -959,6 +1034,17 @@ impl TempoSessionProvider { ) .await? }; + if let SessionCredentialPayload::Close { + settlement_route, .. + } = &mut payload + { + *settlement_route = self + .settlement_routes + .lock() + .unwrap() + .get(channel_id_hex) + .cloned(); + } // Update the registry only after the voucher has been signed. self.persist_channel(&entry).await?; @@ -1555,30 +1641,45 @@ impl TempoSessionProvider { .decode() .mpp_config("failed to decode session request")?; - let payee: Address = session_req - .recipient - .as_deref() - .ok_or_else(|| { - MppError::InvalidConfig("session challenge missing recipient".to_string()) - })? - .parse() - .map_err(|_| MppError::InvalidConfig("invalid recipient address".to_string()))?; - - let currency: Address = session_req - .currency - .parse() - .map_err(|_| MppError::InvalidConfig("invalid currency address".to_string()))?; + let (payee, route_operator, currency) = Self::payment_route(&session_req, chain_id)?; + let settlement_route = if session_req.machine_token_enabled() { + let target_token: Address = session_req + .currency + .parse() + .map_err(|_| MppError::InvalidConfig("invalid settlement target token".into()))?; + let recipient: Address = session_req + .recipient + .as_deref() + .ok_or_else(|| MppError::InvalidConfig("missing settlement recipient".into()))? + .parse() + .map_err(|_| MppError::InvalidConfig("invalid settlement recipient".into()))?; + Some(crate::protocol::methods::tempo::session::SettlementRoute { + adapter: payee.to_string(), + recipient: recipient.to_string(), + target_token: target_token.to_string(), + route_salt: B256::random().to_string(), + }) + } else { + None + }; let amount = session_req.parse_amount()?; let payer = self.signing_mode.from_address(self.signer.address()); let authorized_signer = self.authorized_signer.unwrap_or(self.signer.address()); let precompile = is_precompile_escrow(escrow_contract); let operator = if precompile { - Some(Self::parse_operator(&session_req)?) + Some(route_operator) } else { None }; - let key = Self::channel_key(&payee, ¤cy, &escrow_contract, chain_id); + let key = Self::payment_scope_key( + &session_req, + payee, + route_operator, + currency, + escrow_contract, + chain_id, + )?; let session_snapshot = session_req.session_snapshot(); if let Some(snapshot) = &session_snapshot { self.commit_referenced_open(&snapshot.channel_id); @@ -1596,10 +1697,12 @@ impl TempoSessionProvider { existing = self .restore_precompile_channel( &self.rpc_provider, + &key, RecoveryScope { payer, authorized_signer, payee, + operator: route_operator, token: currency, escrow: escrow_contract, chain_id, @@ -1746,7 +1849,7 @@ impl TempoSessionProvider { ))); } - let (entry, payload) = if precompile { + let (mut entry, mut payload) = if precompile { let prefix_calls = self .autoswap_calls(&self.rpc_provider, payer, currency, deposit) .await?; @@ -1766,6 +1869,13 @@ impl TempoSessionProvider { initial_amount: amount, chain_id, fee_payer: session_req.fee_payer(), + salt: settlement_route.as_ref().map(|route| { + crate::protocol::methods::tempo::machine_token::compute_session_salt( + route.recipient.parse().expect("validated recipient"), + route.target_token.parse().expect("validated target token"), + route.route_salt.parse().expect("generated route salt"), + ) + }), }, ) .await? @@ -1788,6 +1898,19 @@ impl TempoSessionProvider { ) .await? }; + if let Some(route) = settlement_route { + entry.settlement_route = Some(route.clone()); + match &mut payload { + SessionCredentialPayload::Open { + settlement_route, .. + } => *settlement_route = Some(route.clone()), + _ => unreachable!("new session must produce an open payload"), + } + self.settlement_routes + .lock() + .unwrap() + .insert(entry.channel_id.to_string(), route); + } self.channel_id_to_key .lock() @@ -2109,6 +2232,7 @@ mod tests { cumulative_amount: 1000, deposit: 0, descriptor: None, + settlement_route: None, escrow_contract: Address::ZERO, chain_id: 42431, opened: true, @@ -2142,6 +2266,7 @@ mod tests { cumulative_amount: 0, deposit: 0, descriptor: None, + settlement_route: None, escrow_contract: Address::ZERO, chain_id: 42431, opened: true, @@ -2175,6 +2300,7 @@ mod tests { cumulative_amount: 42_000, deposit: 0, descriptor: None, + settlement_route: None, escrow_contract: Address::ZERO, chain_id: 42431, opened: true, @@ -2199,6 +2325,7 @@ mod tests { cumulative_amount: 99_000, deposit: 0, descriptor: None, + settlement_route: None, escrow_contract: Address::ZERO, chain_id: 42431, opened: false, @@ -2324,6 +2451,7 @@ mod tests { cumulative_amount: 0, deposit: 0, descriptor: None, + settlement_route: None, escrow_contract: Address::ZERO, chain_id: 42431, opened: true, @@ -2351,6 +2479,7 @@ mod tests { cumulative_amount: 0, deposit: 0, descriptor: None, + settlement_route: None, escrow_contract: Address::ZERO, chain_id: 42431, opened: true, @@ -2377,6 +2506,7 @@ mod tests { cumulative_amount: cumulative, deposit: 0, descriptor: None, + settlement_route: None, escrow_contract: Address::ZERO, chain_id: 42431, opened, @@ -2448,6 +2578,7 @@ mod tests { cumulative_amount: 100, deposit: 500_000, descriptor: Some(descriptor), + settlement_route: None, escrow_contract: TIP20_CHANNEL_RESERVE_ADDRESS, chain_id: 4217, opened: true, @@ -2487,6 +2618,7 @@ mod tests { SessionCredentialPayload::Voucher { channel_id: channel_id.to_string(), descriptor: None, + settlement_route: None, cumulative_amount: "100".into(), signature: "0x00".into(), }, @@ -2574,6 +2706,7 @@ mod tests { chain_id: 42431, opened: true, descriptor: None, + settlement_route: None, }, ); @@ -2655,6 +2788,7 @@ mod tests { cumulative_amount: 1000, deposit: 0, descriptor: None, + settlement_route: None, escrow_contract: escrow, chain_id: 42431, opened: true, @@ -2718,6 +2852,7 @@ mod tests { cumulative_amount: 1000, deposit: 0, descriptor: None, + settlement_route: None, escrow_contract: escrow, chain_id: 42431, opened: true, @@ -2905,6 +3040,7 @@ mod tests { cumulative_amount: 1_000, deposit: 10_000, descriptor: Some(descriptor.clone()), + settlement_route: None, escrow_contract: TIP20_CHANNEL_RESERVE_ADDRESS, chain_id, opened: true, @@ -3023,6 +3159,7 @@ mod tests { descriptor: actual_descriptor, cumulative_amount, signature, + .. } => { assert_eq!(actual_channel_id, channel_id.to_string()); assert_eq!(actual_descriptor, Some(descriptor)); @@ -3112,6 +3249,7 @@ mod tests { cumulative_amount: 1_000, deposit: 10_000, descriptor: Some(descriptor), + settlement_route: None, escrow_contract: TIP20_CHANNEL_RESERVE_ADDRESS, chain_id, opened: true, @@ -3200,6 +3338,7 @@ mod tests { cumulative_amount: 1_000, deposit: 10_000, descriptor: descriptor.clone(), + settlement_route: None, escrow: TIP20_CHANNEL_RESERVE_ADDRESS, chain_id: 42431, opened: true, @@ -3215,6 +3354,10 @@ mod tests { min_voucher_delta: None, chain_id: Some(42431), fee_payer: Some(true), + machine_token_enabled: None, + settlement_adapter: None, + settlement_recipient: None, + settlement_token: None, operator: Some(Address::ZERO.to_string()), session_protocol: Some("v2".into()), session_snapshot: Some(SessionSnapshot { @@ -3224,6 +3367,7 @@ mod tests { close_requested_at: None, deposit: "10000".into(), descriptor: descriptor.clone(), + settlement_route: None, escrow: TIP20_CHANNEL_RESERVE_ADDRESS.to_string(), highest_voucher: None, required_cumulative: "3000".into(), @@ -3311,6 +3455,7 @@ mod tests { cumulative_amount: 1_000, deposit: 10_000, descriptor: Some(descriptor), + settlement_route: None, escrow_contract: TIP20_CHANNEL_RESERVE_ADDRESS, chain_id: 42431, opened: true, diff --git a/src/client/tempo/session/recovery.rs b/src/client/tempo/session/recovery.rs index fdda8f5f..d88a87d5 100644 --- a/src/client/tempo/session/recovery.rs +++ b/src/client/tempo/session/recovery.rs @@ -70,6 +70,8 @@ pub struct RecoveryScope { pub authorized_signer: Address, /// Protected service payee. pub payee: Address, + /// Settlement recipient/operator. + pub operator: Address, /// TIP-20 token. pub token: Address, /// TIP-1034 escrow/precompile. @@ -190,6 +192,7 @@ pub fn hydrate_session_snapshot( cumulative_amount, deposit: state.deposit, descriptor: snapshot.descriptor.clone(), + settlement_route: snapshot.settlement_route.clone(), escrow, chain_id: snapshot.chain_id, opened: true, @@ -203,10 +206,12 @@ fn validate_descriptor( ) -> Result<(), MppError> { let payer = parse_address("descriptor payer", &descriptor.payer)?; let payee = parse_address("descriptor payee", &descriptor.payee)?; + let operator = parse_address("descriptor operator", &descriptor.operator)?; let token = parse_address("descriptor token", &descriptor.token)?; let authority = descriptor_authority(descriptor)?; if payer != scope.payer || payee != scope.payee + || operator != scope.operator || token != scope.token || authority != scope.authorized_signer { @@ -332,6 +337,7 @@ mod tests { payer: Address::repeat_byte(0x10), authorized_signer: authority, payee: Address::repeat_byte(0x20), + operator: Address::ZERO, token: Address::repeat_byte(0x30), escrow: TIP20_CHANNEL_RESERVE_ADDRESS, chain_id: 4217, @@ -354,6 +360,7 @@ mod tests { cumulative_amount: 200, deposit: 900, descriptor, + settlement_route: None, escrow: TIP20_CHANNEL_RESERVE_ADDRESS, chain_id: 4217, opened: true, @@ -402,6 +409,7 @@ mod tests { close_requested_at: None, deposit: "1000".into(), descriptor, + settlement_route: None, escrow: format!("{TIP20_CHANNEL_RESERVE_ADDRESS:#x}"), highest_voucher: Some(SnapshotVoucher { channel_id: format!("{channel_id:#x}"), diff --git a/src/client/tempo/session/store.rs b/src/client/tempo/session/store.rs index a872a878..f47d0bde 100644 --- a/src/client/tempo/session/store.rs +++ b/src/client/tempo/session/store.rs @@ -22,6 +22,8 @@ pub struct StoredChannelEntry { pub deposit: u128, /// Full descriptor required to derive the channel ID and sign vouchers. pub descriptor: ChannelDescriptor, + /// Immutable machine-token settlement route, when enabled. + pub settlement_route: Option, /// TIP-1034 escrow/precompile address. pub escrow: Address, /// EVM chain ID. @@ -33,6 +35,16 @@ pub struct StoredChannelEntry { impl StoredChannelEntry { /// Return the MPPx-compatible payment-scope key for this channel. pub fn key(&self) -> String { + if let Some(route) = &self.settlement_route { + return format!( + "{}:{}:{}:{:#x}:{}", + route.adapter.to_ascii_lowercase(), + route.recipient.to_ascii_lowercase(), + route.target_token.to_ascii_lowercase(), + self.escrow, + self.chain_id, + ); + } channel_key( &self.descriptor.payee, &self.descriptor.token, @@ -130,6 +142,8 @@ struct JsonChannelEntry { cumulative_amount: String, deposit: String, descriptor: ChannelDescriptor, + #[serde(default, skip_serializing_if = "Option::is_none")] + settlement_route: Option, escrow: String, chain_id: u64, opened: bool, @@ -142,6 +156,7 @@ impl From<&StoredChannelEntry> for JsonChannelEntry { cumulative_amount: entry.cumulative_amount.to_string(), deposit: entry.deposit.to_string(), descriptor: entry.descriptor.clone(), + settlement_route: entry.settlement_route.clone(), escrow: format!("{:#x}", entry.escrow), chain_id: entry.chain_id, opened: entry.opened, @@ -168,6 +183,7 @@ impl TryFrom for StoredChannelEntry { .map_err(|e| invalid("cumulativeAmount", e))?, deposit: entry.deposit.parse().map_err(|e| invalid("deposit", e))?, descriptor: entry.descriptor, + settlement_route: entry.settlement_route, escrow: entry.escrow.parse().map_err(|e| invalid("escrow", e))?, chain_id: entry.chain_id, opened: entry.opened, @@ -387,6 +403,7 @@ mod sqlite { deposit, descriptor: serde_json::from_str(&descriptor) .map_err(|e| ChannelStoreError::InvalidEntry(e.to_string()))?, + settlement_route: None, escrow, chain_id, opened: state == "active", @@ -659,6 +676,7 @@ mod tests { salt: format!("{:#x}", B256::repeat_byte(0x33)), token: "0x0000000000000000000000000000000000000004".into(), }, + settlement_route: None, escrow: "0x0000000000000000000000000000000000000005" .parse() .unwrap(), diff --git a/src/protocol/methods/tempo/machine_token.rs b/src/protocol/methods/tempo/machine_token.rs index 4b156d0c..94d5699b 100644 --- a/src/protocol/methods/tempo/machine_token.rs +++ b/src/protocol/methods/tempo/machine_token.rs @@ -7,6 +7,7 @@ use alloy::primitives::{address, Address, Bytes, TxKind, U256}; use alloy::providers::Provider; use alloy::sol_types::SolCall; +use alloy::sol_types::SolValue; use tempo_alloy::contracts::precompiles::ITIP20; use tempo_alloy::primitives::transaction::Call; use tempo_alloy::rpc::TempoTransactionRequest; @@ -22,7 +23,104 @@ alloy::sol! { address recipient, bytes32 memo ) external; + + struct ChannelDescriptor { + address payer; + address payee; + address operator; + address token; + bytes32 salt; + address authorizedSigner; + bytes32 expiringNonceHash; + } + + function settleSession( + ChannelDescriptor calldata descriptor, + address recipient, + address targetToken, + bytes32 routeSalt + ) external; + } +} + +/// Resolve the canonical machine-token and swapper for a supported chain. +pub fn session_addresses(chain_id: u64) -> Option<(Address, Address)> { + deployment(chain_id).map(|deployment| (deployment.token, deployment.swapper)) +} + +/// Bind a logical merchant settlement route into a TIP-1034 descriptor salt. +pub fn compute_session_salt( + merchant: Address, + target_token: Address, + route_salt: alloy::primitives::B256, +) -> alloy::primitives::B256 { + let typehash = alloy::primitives::keccak256( + b"MachineUsdSessionRoute(address merchant,address targetToken,bytes32 routeSalt)", + ); + alloy::primitives::keccak256((typehash, merchant, target_token, route_salt).abi_encode()) +} + +/// Encode a canonical machine-token session settlement call. +pub fn settle_session_call( + chain_id: u64, + descriptor: &super::session::ChannelDescriptor, + route: &super::session::SettlementRoute, +) -> Result { + let (_, swapper) = session_addresses(chain_id).ok_or_else(|| { + crate::error::MppError::InvalidConfig(format!( + "machine tokens are not supported on chain ID {chain_id}" + )) + })?; + let parse_address = |name: &str, value: &str| { + value.parse::

().map_err(|error| { + crate::error::MppError::InvalidConfig(format!( + "invalid session descriptor {name}: {error}" + )) + }) + }; + let parse_b256 = |name: &str, value: &str| { + value.parse::().map_err(|error| { + crate::error::MppError::InvalidConfig(format!( + "invalid session descriptor {name}: {error}" + )) + }) + }; + if route.adapter.parse::
().ok() != Some(swapper) { + return Err(crate::error::MppError::InvalidConfig( + "session settlement adapter is not canonical".into(), + )); } + let merchant = parse_address("settlement recipient", &route.recipient)?; + let target_token = parse_address("settlement targetToken", &route.target_token)?; + let route_salt = parse_b256("settlement routeSalt", &route.route_salt)?; + let descriptor_salt = parse_b256("salt", &descriptor.salt)?; + if descriptor_salt != compute_session_salt(merchant, target_token, route_salt) { + return Err(crate::error::MppError::InvalidConfig( + "session descriptor salt does not bind the settlement route".into(), + )); + } + let descriptor = IMachineTokenSwapper::ChannelDescriptor { + payer: parse_address("payer", &descriptor.payer)?, + payee: parse_address("payee", &descriptor.payee)?, + operator: parse_address("operator", &descriptor.operator)?, + token: parse_address("token", &descriptor.token)?, + salt: parse_b256("salt", &descriptor.salt)?, + authorizedSigner: parse_address("authorizedSigner", &descriptor.authorized_signer)?, + expiringNonceHash: parse_b256("expiringNonceHash", &descriptor.expiring_nonce_hash)?, + }; + Ok(Call { + to: TxKind::Call(swapper), + value: U256::ZERO, + input: Bytes::from( + IMachineTokenSwapper::settleSessionCall { + descriptor, + recipient: merchant, + targetToken: target_token, + routeSalt: route_salt, + } + .abi_encode(), + ), + }) } /// machineUSD on Tempo mainnet. @@ -238,6 +336,63 @@ mod tests { assert!(route(MODERATO_CHAIN_ID, currency, &[transfer(), transfer()]).is_none()); } + #[test] + fn encodes_session_settlement_with_the_full_bound_descriptor() { + let descriptor = super::super::session::ChannelDescriptor { + payer: Address::repeat_byte(0x11).to_string(), + payee: MACHINE_TOKEN_SWAPPER_TESTNET.to_string(), + operator: Address::repeat_byte(0x22).to_string(), + token: MACHINE_TOKEN_TESTNET.to_string(), + salt: alloy::primitives::B256::repeat_byte(0x33).to_string(), + authorized_signer: Address::repeat_byte(0x44).to_string(), + expiring_nonce_hash: alloy::primitives::B256::repeat_byte(0x55).to_string(), + }; + let route = super::super::session::SettlementRoute { + adapter: MACHINE_TOKEN_SWAPPER_TESTNET.to_string(), + recipient: Address::repeat_byte(0x22).to_string(), + target_token: Address::repeat_byte(0x66).to_string(), + route_salt: alloy::primitives::B256::repeat_byte(0x77).to_string(), + }; + let expected_salt = compute_session_salt( + Address::repeat_byte(0x22), + Address::repeat_byte(0x66), + alloy::primitives::B256::repeat_byte(0x77), + ); + let descriptor = super::super::session::ChannelDescriptor { + salt: expected_salt.to_string(), + ..descriptor + }; + let call = settle_session_call(MODERATO_CHAIN_ID, &descriptor, &route).unwrap(); + + assert_eq!(call.to, TxKind::Call(MACHINE_TOKEN_SWAPPER_TESTNET)); + let decoded = IMachineTokenSwapper::settleSessionCall::abi_decode(&call.input).unwrap(); + assert_eq!(decoded.descriptor.payee, MACHINE_TOKEN_SWAPPER_TESTNET); + assert_eq!(decoded.descriptor.operator, Address::repeat_byte(0x22)); + assert_eq!(decoded.descriptor.token, MACHINE_TOKEN_TESTNET); + assert_eq!(decoded.recipient, Address::repeat_byte(0x22)); + assert_eq!(decoded.targetToken, Address::repeat_byte(0x66)); + } + + #[test] + fn rejects_session_settlement_on_an_unsupported_chain() { + let descriptor = super::super::session::ChannelDescriptor { + payer: Address::ZERO.to_string(), + payee: Address::ZERO.to_string(), + operator: Address::ZERO.to_string(), + token: Address::ZERO.to_string(), + salt: alloy::primitives::B256::ZERO.to_string(), + authorized_signer: Address::ZERO.to_string(), + expiring_nonce_hash: alloy::primitives::B256::ZERO.to_string(), + }; + let route = super::super::session::SettlementRoute { + adapter: Address::ZERO.to_string(), + recipient: Address::ZERO.to_string(), + target_token: Address::ZERO.to_string(), + route_salt: alloy::primitives::B256::ZERO.to_string(), + }; + assert!(settle_session_call(1, &descriptor, &route).is_err()); + } + #[tokio::test] async fn finds_a_funded_route_after_successful_simulation() { use alloy::providers::{mock::Asserter, ProviderBuilder}; diff --git a/src/protocol/methods/tempo/session.rs b/src/protocol/methods/tempo/session.rs index 7766b913..1c661022 100644 --- a/src/protocol/methods/tempo/session.rs +++ b/src/protocol/methods/tempo/session.rs @@ -25,6 +25,16 @@ pub struct ChannelDescriptor { pub expiring_nonce_hash: String, } +/// Route preimage bound into a machine-token channel descriptor salt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettlementRoute { + pub adapter: String, + pub recipient: String, + pub target_token: String, + pub route_salt: String, +} + /// Server-provided reusable TIP-1034 channel state. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -36,6 +46,8 @@ pub struct SessionSnapshot { pub close_requested_at: Option, pub deposit: String, pub descriptor: ChannelDescriptor, + #[serde(skip_serializing_if = "Option::is_none")] + pub settlement_route: Option, pub escrow: String, /// Highest server-accepted voucher, used to resume into an empty store. #[serde(skip_serializing_if = "Option::is_none")] @@ -89,7 +101,11 @@ where /// min_voucher_delta: Some("1000".to_string()), /// chain_id: Some(42431), /// fee_payer: Some(true), +/// machine_token_enabled: None, /// operator: None, +/// settlement_adapter: None, +/// settlement_recipient: None, +/// settlement_token: None, /// session_protocol: None, /// session_snapshot: None, /// }; @@ -112,6 +128,20 @@ pub struct TempoSessionMethodDetails { #[serde(skip_serializing_if = "Option::is_none")] pub fee_payer: Option, + /// Set when the channel is funded in machineUSD and settles through the + /// canonical swapper into the request currency. + #[serde(skip_serializing_if = "Option::is_none")] + pub machine_token_enabled: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub settlement_adapter: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub settlement_recipient: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub settlement_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub operator: Option, @@ -151,6 +181,8 @@ pub enum SessionCredentialPayload { transaction: String, #[serde(skip_serializing_if = "Option::is_none")] descriptor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + settlement_route: Option, #[serde(rename = "authorizedSigner", skip_serializing_if = "Option::is_none")] authorized_signer: Option, #[serde(rename = "cumulativeAmount")] @@ -166,6 +198,8 @@ pub enum SessionCredentialPayload { transaction: String, #[serde(skip_serializing_if = "Option::is_none")] descriptor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + settlement_route: Option, #[serde(rename = "additionalDeposit")] additional_deposit: String, }, @@ -175,6 +209,8 @@ pub enum SessionCredentialPayload { channel_id: String, #[serde(skip_serializing_if = "Option::is_none")] descriptor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + settlement_route: Option, #[serde(rename = "cumulativeAmount")] cumulative_amount: String, signature: String, @@ -185,6 +221,8 @@ pub enum SessionCredentialPayload { channel_id: String, #[serde(skip_serializing_if = "Option::is_none")] descriptor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + settlement_route: Option, #[serde(rename = "cumulativeAmount")] cumulative_amount: String, signature: String, @@ -230,6 +268,9 @@ pub trait TempoSessionExt { /// Check if fee sponsorship is enabled. fn fee_payer(&self) -> bool; + /// Whether canonical machine-token session settlement is enabled. + fn machine_token_enabled(&self) -> bool; + /// Get the TIP-1034 operator address from methodDetails, if present. fn operator(&self) -> Option; @@ -294,6 +335,14 @@ impl TempoSessionExt for SessionRequest { .unwrap_or(false) } + fn machine_token_enabled(&self) -> bool { + self.method_details + .as_ref() + .and_then(|v| v.get("machineTokenEnabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } + fn operator(&self) -> Option { self.method_details .as_ref() @@ -375,6 +424,10 @@ mod tests { min_voucher_delta: Some("1000".to_string()), chain_id: Some(42431), fee_payer: Some(true), + machine_token_enabled: Some(true), + settlement_adapter: None, + settlement_recipient: None, + settlement_token: None, operator: Some("0x1111111111111111111111111111111111111111".to_string()), session_protocol: Some(SESSION_PROTOCOL_TIP1034.to_string()), session_snapshot: None, @@ -393,6 +446,7 @@ mod tests { assert_eq!(parsed.min_voucher_delta.as_deref(), Some("1000")); assert_eq!(parsed.chain_id, Some(42431)); assert_eq!(parsed.fee_payer, Some(true)); + assert_eq!(parsed.machine_token_enabled, Some(true)); assert_eq!( parsed.operator.as_deref(), Some("0x1111111111111111111111111111111111111111") @@ -411,6 +465,10 @@ mod tests { min_voucher_delta: None, chain_id: None, fee_payer: None, + machine_token_enabled: None, + settlement_adapter: None, + settlement_recipient: None, + settlement_token: None, operator: None, session_protocol: None, session_snapshot: None, @@ -436,6 +494,7 @@ mod tests { channel_id: "0xchannel123".to_string(), transaction: "0xtx456".to_string(), descriptor: None, + settlement_route: None, authorized_signer: Some("0xsigner789".to_string()), cumulative_amount: "10000".to_string(), signature: "0xsig".to_string(), @@ -473,6 +532,7 @@ mod tests { channel_id: "0xchannel123".to_string(), transaction: "0xtx456".to_string(), descriptor: None, + settlement_route: None, authorized_signer: None, cumulative_amount: "10000".to_string(), signature: "0xsig".to_string(), @@ -499,6 +559,7 @@ mod tests { channel_id: "0xchannel123".to_string(), transaction: "0xtx789".to_string(), descriptor: None, + settlement_route: None, additional_deposit: "5000".to_string(), }; @@ -530,6 +591,7 @@ mod tests { let payload = SessionCredentialPayload::Voucher { channel_id: "0xchannel123".to_string(), descriptor: None, + settlement_route: None, cumulative_amount: "15000".to_string(), signature: "0xvouchersig".to_string(), }; @@ -549,6 +611,7 @@ mod tests { descriptor, cumulative_amount, signature, + .. } => { assert_eq!(channel_id, "0xchannel123"); assert!(descriptor.is_none()); @@ -564,6 +627,7 @@ mod tests { let payload = SessionCredentialPayload::Close { channel_id: "0xchannel123".to_string(), descriptor: None, + settlement_route: None, cumulative_amount: "20000".to_string(), signature: "0xclosesig".to_string(), }; @@ -581,6 +645,7 @@ mod tests { descriptor, cumulative_amount, signature, + .. } => { assert_eq!(channel_id, "0xchannel123"); assert!(descriptor.is_none()); diff --git a/src/protocol/methods/tempo/session_method.rs b/src/protocol/methods/tempo/session_method.rs index bc4dcd6c..77dd7436 100644 --- a/src/protocol/methods/tempo/session_method.rs +++ b/src/protocol/methods/tempo/session_method.rs @@ -36,6 +36,9 @@ pub struct ChannelState { pub payer: Address, pub payee: Address, pub token: Address, + /// Immutable machine-token settlement route accepted at open. + #[serde(default)] + pub settlement_route: Option, pub authorized_signer: Address, pub deposit: u128, pub settled_on_chain: u128, @@ -221,6 +224,104 @@ fn validate_close_amount( Ok(()) } +#[allow(clippy::too_many_arguments)] +fn machine_session_close_calls( + chain_id: u64, + escrow: Address, + descriptor: &super::session::ChannelDescriptor, + route: &super::session::SettlementRoute, + cumulative_amount: u128, + deposit: u128, + settled: u128, + signature: &[u8], +) -> Result, VerificationError> { + use alloy::{ + primitives::{Bytes, TxKind, U256}, + sol_types::SolCall, + }; + if cumulative_amount != deposit { + return Err(VerificationError::invalid_payload( + "machine-token sessions cannot close with a nonzero refund", + )); + } + let cumulative_amount = alloy::primitives::Uint::<96, 2>::from(cumulative_amount); + let parse_address = |value: &str| { + value + .parse::
() + .map_err(|_| VerificationError::invalid_payload("invalid channel descriptor address")) + }; + let descriptor_wire = + tempo_alloy::contracts::precompiles::ITIP20ChannelReserve::ChannelDescriptor { + payer: parse_address(&descriptor.payer)?, + payee: parse_address(&descriptor.payee)?, + operator: parse_address(&descriptor.operator)?, + token: parse_address(&descriptor.token)?, + salt: descriptor + .salt + .parse() + .map_err(|_| VerificationError::invalid_payload("invalid descriptor salt"))?, + authorizedSigner: parse_address(&descriptor.authorized_signer)?, + expiringNonceHash: descriptor.expiring_nonce_hash.parse().map_err(|_| { + VerificationError::invalid_payload("invalid descriptor expiringNonceHash") + })?, + }; + let settle = tempo_alloy::contracts::precompiles::ITIP20ChannelReserve::settleCall::new(( + descriptor_wire.clone(), + cumulative_amount, + Bytes::copy_from_slice(signature), + )); + let close = tempo_alloy::contracts::precompiles::ITIP20ChannelReserve::closeCall::new(( + descriptor_wire, + cumulative_amount, + cumulative_amount, + Bytes::copy_from_slice(signature), + )); + let swap = crate::protocol::methods::tempo::machine_token::settle_session_call( + chain_id, descriptor, route, + ) + .map_err(|error| VerificationError::invalid_payload(error.to_string()))?; + let close_call = tempo_alloy::primitives::transaction::Call { + to: TxKind::Call(escrow), + value: U256::ZERO, + input: Bytes::from(close.abi_encode()), + }; + if settled == deposit { + return Ok(vec![close_call]); + } + Ok(vec![ + tempo_alloy::primitives::transaction::Call { + to: TxKind::Call(escrow), + value: U256::ZERO, + input: Bytes::from(settle.abi_encode()), + }, + swap, + close_call, + ]) +} + +fn validate_settlement_route( + channel: &ChannelState, + details: &TempoSessionMethodDetails, + route: Option<&super::session::SettlementRoute>, +) -> Result<(), VerificationError> { + if details.machine_token_enabled != Some(true) { + return Ok(()); + } + let route = route.ok_or_else(|| { + VerificationError::invalid_payload("machine-token credential is missing settlementRoute") + })?; + if channel.settlement_route.as_ref() != Some(route) + || details.settlement_adapter.as_deref() != Some(&route.adapter) + || details.settlement_recipient.as_deref() != Some(&route.recipient) + || details.settlement_token.as_deref() != Some(&route.target_token) + { + return Err(VerificationError::credential_mismatch( + "settlement route does not match the opened channel", + )); + } + Ok(()) +} + // ==================== TempoSessionMethod ==================== /// Configuration for the Tempo session method. @@ -313,6 +414,10 @@ where channel_id: None, min_voucher_delta: None, fee_payer: None, + machine_token_enabled: None, + settlement_adapter: None, + settlement_recipient: None, + settlement_token: None, operator: None, session_protocol: None, session_snapshot: None, @@ -447,6 +552,8 @@ where ) -> Result { let ( channel_id_str, + descriptor, + settlement_route, cumulative_amount_str, signature_str, _authorized_signer_str, @@ -454,6 +561,8 @@ where ) = match payload { SessionCredentialPayload::Open { channel_id, + descriptor, + settlement_route, cumulative_amount, signature, authorized_signer, @@ -461,6 +570,8 @@ where .. } => ( channel_id, + descriptor.as_ref(), + settlement_route.as_ref(), cumulative_amount, signature, authorized_signer, @@ -473,6 +584,41 @@ where let escrow = self.resolve_escrow(details)?; let chain_id = self.resolve_chain_id(details); + if details.machine_token_enabled == Some(true) { + let descriptor = descriptor.ok_or_else(|| { + VerificationError::invalid_payload( + "machine-token open credential is missing its channel descriptor", + ) + })?; + let route = settlement_route.ok_or_else(|| { + VerificationError::invalid_payload( + "machine-token open credential is missing its settlement route", + ) + })?; + let recipient = Self::parse_address(&route.recipient)?; + let target_token = Self::parse_address(&route.target_token)?; + let route_salt = route + .route_salt + .parse() + .map_err(|_| VerificationError::invalid_payload("invalid settlement routeSalt"))?; + let expected_salt = + crate::protocol::methods::tempo::machine_token::compute_session_salt( + recipient, + target_token, + route_salt, + ); + if descriptor.salt != expected_salt.to_string() + || details.settlement_adapter.as_deref() != Some(&route.adapter) + || details.settlement_recipient.as_deref() != Some(&route.recipient) + || details.settlement_token.as_deref() != Some(&route.target_token) + { + return Err(VerificationError::credential_mismatch( + "machine-token settlement route is not bound to the descriptor", + )); + } + } + let accepted_settlement_route = settlement_route.cloned(); + // Broadcast the client's signed open transaction (approve + escrow.open). let tx_bytes: Bytes = transaction_str.parse().map_err(|e| { VerificationError::invalid_payload(format!("invalid open transaction hex: {}", e)) @@ -590,6 +736,9 @@ where // Channel already exists — update if higher. if cumulative_amount > existing.highest_voucher_amount { Ok(Some(ChannelState { + settlement_route: existing + .settlement_route + .or_else(|| accepted_settlement_route.clone()), deposit: on_chain.deposit, settled_on_chain, spent, @@ -620,6 +769,7 @@ where payer: on_chain.payer, payee: on_chain.payee, token: on_chain.token, + settlement_route: accepted_settlement_route, authorized_signer, deposit: on_chain.deposit, settled_on_chain: on_chain.settled, @@ -651,15 +801,22 @@ where expected_payee: Address, expected_token: Address, ) -> Result { - let (channel_id_str, _additional_deposit_str, transaction_str) = match payload { - SessionCredentialPayload::TopUp { - channel_id, - additional_deposit, - transaction, - .. - } => (channel_id, additional_deposit, transaction), - _ => unreachable!(), - }; + let (channel_id_str, settlement_route, _additional_deposit_str, transaction_str) = + match payload { + SessionCredentialPayload::TopUp { + channel_id, + settlement_route, + additional_deposit, + transaction, + .. + } => ( + channel_id, + settlement_route.as_ref(), + additional_deposit, + transaction, + ), + _ => unreachable!(), + }; let channel = self .store @@ -677,6 +834,7 @@ where "channel token does not match session currency", )); } + validate_settlement_route(&channel, details, settlement_route)?; let channel_id_b256 = Self::parse_channel_id(channel_id_str)?; let escrow = self.resolve_escrow(details)?; @@ -749,13 +907,20 @@ where expected_payee: Address, expected_token: Address, ) -> Result { - let (channel_id_str, cumulative_amount_str, signature_str) = match payload { + let (channel_id_str, settlement_route, cumulative_amount_str, signature_str) = match payload + { SessionCredentialPayload::Voucher { channel_id, + settlement_route, cumulative_amount, signature, .. - } => (channel_id, cumulative_amount, signature), + } => ( + channel_id, + settlement_route.as_ref(), + cumulative_amount, + signature, + ), _ => unreachable!(), }; @@ -775,6 +940,7 @@ where "channel token does not match session currency", )); } + validate_settlement_route(&channel, details, settlement_route)?; if channel.finalized { return Err(VerificationError::channel_closed("channel is finalized")); @@ -867,15 +1033,24 @@ where expected_payee: Address, expected_token: Address, ) -> Result { - let (channel_id_str, cumulative_amount_str, signature_str) = match payload { - SessionCredentialPayload::Close { - channel_id, - cumulative_amount, - signature, - .. - } => (channel_id, cumulative_amount, signature), - _ => unreachable!(), - }; + let (channel_id_str, descriptor, settlement_route, cumulative_amount_str, signature_str) = + match payload { + SessionCredentialPayload::Close { + channel_id, + descriptor, + settlement_route, + cumulative_amount, + signature, + .. + } => ( + channel_id, + descriptor.as_ref(), + settlement_route.as_ref(), + cumulative_amount, + signature, + ), + _ => unreachable!(), + }; let channel = self .store @@ -893,6 +1068,7 @@ where "channel token does not match session currency", )); } + validate_settlement_route(&channel, details, settlement_route)?; if channel.finalized { return Err(VerificationError::channel_closed( @@ -996,17 +1172,41 @@ where VerificationError::network_error(format!("failed to get gas price: {}", e)) })?; + let mut calls = vec![Call { + to: alloy::primitives::TxKind::Call(escrow), + value: alloy::primitives::U256::ZERO, + input: Bytes::from(close_data), + }]; + if details.machine_token_enabled == Some(true) { + let descriptor = descriptor.ok_or_else(|| { + VerificationError::invalid_payload( + "machine-token close credential is missing its channel descriptor", + ) + })?; + let route = settlement_route.ok_or_else(|| { + VerificationError::invalid_payload( + "machine-token close credential is missing its settlement route", + ) + })?; + calls = machine_session_close_calls( + chain_id, + escrow, + descriptor, + route, + cumulative_amount, + on_chain.deposit, + on_chain.settled, + &sig_bytes, + )?; + } + let tempo_tx = TempoTransaction { chain_id, nonce, gas_limit: 2_000_000, max_fee_per_gas: gas_price, max_priority_fee_per_gas: gas_price, - calls: vec![Call { - to: alloy::primitives::TxKind::Call(escrow), - value: alloy::primitives::U256::ZERO, - input: Bytes::from(close_data), - }], + calls, ..Default::default() }; @@ -1239,6 +1439,10 @@ where min_voucher_delta: Some(self.config.min_voucher_delta.to_string()), channel_id: None, fee_payer: None, + machine_token_enabled: None, + settlement_adapter: None, + settlement_recipient: None, + settlement_token: None, operator: None, session_protocol: None, session_snapshot: None, @@ -1295,14 +1499,44 @@ where let details = this.resolve_method_details(&request)?; - let expected_payee = request + let merchant = request .recipient .as_deref() .ok_or_else(|| { VerificationError::invalid_payload("session challenge missing recipient") }) .and_then(Self::parse_address)?; - let expected_token = Self::parse_address(&request.currency)?; + let target_token = Self::parse_address(&request.currency)?; + let (expected_payee, expected_token) = if details.machine_token_enabled == Some(true) { + let (_, swapper) = + crate::protocol::methods::tempo::machine_token::session_addresses( + this.resolve_chain_id(&details), + ) + .ok_or_else(|| { + VerificationError::invalid_payload( + "machine tokens are unsupported on the session chain", + ) + })?; + if details.settlement_adapter.as_deref() != Some(&swapper.to_string()) + || details.settlement_recipient.as_deref() != Some(&merchant.to_string()) + || details.settlement_token.as_deref() != Some(&target_token.to_string()) + { + return Err(VerificationError::credential_mismatch( + "machine-token settlement route does not match the session request", + )); + } + crate::protocol::methods::tempo::machine_token::session_addresses( + this.resolve_chain_id(&details), + ) + .map(|(token, swapper)| (swapper, token)) + .ok_or_else(|| { + VerificationError::invalid_payload( + "machine tokens are unsupported on the session chain", + ) + })? + } else { + (merchant, target_token) + }; let payload: SessionCredentialPayload = credential.payload_as().map_err(|e| { VerificationError::invalid_payload(format!("Expected session payload: {}", e)) @@ -1484,6 +1718,7 @@ mod tests { token: "0x3333333333333333333333333333333333333333" .parse() .unwrap(), + settlement_route: None, authorized_signer: "0x4444444444444444444444444444444444444444" .parse() .unwrap(), @@ -2303,6 +2538,7 @@ mod tests { payer, payee, token, + settlement_route: None, authorized_signer, deposit: on_chain_deposit, settled_on_chain: on_chain_settled, @@ -2468,6 +2704,7 @@ mod tests { token: "0x4444444444444444444444444444444444444444" .parse() .unwrap(), + settlement_route: None, authorized_signer: "0x5555555555555555555555555555555555555555" .parse() .unwrap(), @@ -2812,6 +3049,7 @@ mod tests { SessionCredentialPayload::Voucher { channel_id, descriptor: None, + settlement_route: None, cumulative_amount: "1000".to_string(), signature: format!("0x{}", "aa".repeat(65)), }, @@ -2850,6 +3088,7 @@ mod tests { SessionCredentialPayload::Voucher { channel_id, descriptor: None, + settlement_route: None, cumulative_amount: "1000".to_string(), signature: format!("0x{}", "aa".repeat(65)), }, @@ -2889,6 +3128,7 @@ mod tests { SessionCredentialPayload::Voucher { channel_id, descriptor: None, + settlement_route: None, cumulative_amount: "1000".to_string(), signature: format!("0x{}", "aa".repeat(65)), }, @@ -2926,6 +3166,7 @@ mod tests { SessionCredentialPayload::Close { channel_id, descriptor: None, + settlement_route: None, cumulative_amount: "1000".to_string(), signature: format!("0x{}", "aa".repeat(65)), }, @@ -2964,6 +3205,7 @@ mod tests { payload_type: "transaction".to_string(), channel_id, descriptor: None, + settlement_route: None, additional_deposit: "5000".to_string(), transaction: format!("0x{}", "bb".repeat(32)), }, @@ -3142,4 +3384,87 @@ mod tests { // close at deposit boundary should succeed assert!(validate_close_amount(10_000_000, 0, 0, 10_000_000).is_ok()); } + + #[test] + fn machine_session_close_is_settle_swap_close_and_requires_full_consumption() { + use alloy::{ + primitives::{Address, B256}, + sol_types::SolCall, + }; + use tempo_alloy::contracts::precompiles::ITIP20ChannelReserve; + let recipient = Address::repeat_byte(0x22); + let target = Address::repeat_byte(0x33); + let route_salt = B256::repeat_byte(0x44); + let salt = crate::protocol::methods::tempo::machine_token::compute_session_salt( + recipient, target, route_salt, + ); + let (_, adapter) = + crate::protocol::methods::tempo::machine_token::session_addresses(42431).unwrap(); + let (token, _) = + crate::protocol::methods::tempo::machine_token::session_addresses(42431).unwrap(); + let descriptor = super::super::session::ChannelDescriptor { + payer: Address::repeat_byte(0x11).to_string(), + payee: adapter.to_string(), + operator: Address::repeat_byte(0x55).to_string(), + token: token.to_string(), + salt: salt.to_string(), + authorized_signer: Address::repeat_byte(0x66).to_string(), + expiring_nonce_hash: B256::repeat_byte(0x77).to_string(), + }; + let route = super::super::session::SettlementRoute { + adapter: adapter.to_string(), + recipient: recipient.to_string(), + target_token: target.to_string(), + route_salt: route_salt.to_string(), + }; + let calls = machine_session_close_calls( + 42431, + Address::repeat_byte(0x88), + &descriptor, + &route, + 100, + 100, + 0, + &[1; 64], + ) + .unwrap(); + assert_eq!( + &calls[0].input[..4], + &ITIP20ChannelReserve::settleCall::SELECTOR + ); + assert_eq!(calls[1].to, alloy::primitives::TxKind::Call(adapter)); + assert_eq!( + &calls[2].input[..4], + &ITIP20ChannelReserve::closeCall::SELECTOR + ); + assert!(machine_session_close_calls( + 42431, + Address::repeat_byte(0x88), + &descriptor, + &route, + 99, + 100, + 0, + &[1; 64], + ) + .unwrap_err() + .message + .contains("nonzero refund")); + let already_settled = machine_session_close_calls( + 42431, + Address::repeat_byte(0x88), + &descriptor, + &route, + 100, + 100, + 100, + &[1; 64], + ) + .unwrap(); + assert_eq!(already_settled.len(), 1); + assert_eq!( + &already_settled[0].input[..4], + &ITIP20ChannelReserve::closeCall::SELECTOR + ); + } } diff --git a/src/server/mpp.rs b/src/server/mpp.rs index fdd1a66e..588fc240 100644 --- a/src/server/mpp.rs +++ b/src/server/mpp.rs @@ -907,6 +907,29 @@ where } } + if self.machine_token_enabled { + let chain_id = self + .chain_id + .unwrap_or(crate::protocol::methods::tempo::CHAIN_ID); + let (_, adapter) = + crate::protocol::methods::tempo::machine_token::session_addresses(chain_id) + .ok_or_else(|| { + crate::error::MppError::InvalidConfig(format!( + "machine tokens are not supported on chain ID {chain_id}" + )) + })?; + let details = method_details.get_or_insert_with(|| serde_json::json!({})); + if let Some(obj) = details.as_object_mut() { + obj.insert("machineTokenEnabled".to_string(), serde_json::json!(true)); + obj.insert("settlementAdapter".to_string(), serde_json::json!(adapter)); + obj.insert( + "settlementRecipient".to_string(), + serde_json::json!(recipient), + ); + obj.insert("settlementToken".to_string(), serde_json::json!(currency)); + } + } + let request = SessionRequest { amount: amount.to_string(), unit_type: options.unit_type.map(|s| s.to_string()), diff --git a/src/server/sse.rs b/src/server/sse.rs index 97a3462b..5bbd31b6 100644 --- a/src/server/sse.rs +++ b/src/server/sse.rs @@ -565,6 +565,7 @@ mod tests { token: "0x3333333333333333333333333333333333333333" .parse() .unwrap(), + settlement_route: None, authorized_signer: "0x4444444444444444444444444444444444444444" .parse() .unwrap(), diff --git a/src/store.rs b/src/store.rs index ade746ed..23959bb5 100644 --- a/src/store.rs +++ b/src/store.rs @@ -708,6 +708,7 @@ mod adapter_tests { payer: Address::ZERO, payee: Address::ZERO, token: Address::ZERO, + settlement_route: None, authorized_signer: Address::ZERO, deposit: 1000, settled_on_chain: 0, diff --git a/tests/sqlite_interop.rs b/tests/sqlite_interop.rs index baf84a7c..173ae89b 100644 --- a/tests/sqlite_interop.rs +++ b/tests/sqlite_interop.rs @@ -22,6 +22,7 @@ fn interop_entry(cumulative_amount: u128) -> StoredChannelEntry { salt: format!("{:#x}", B256::repeat_byte(0x33)), token: "0x0000000000000000000000000000000000000004".into(), }, + settlement_route: None, escrow: "0x0000000000000000000000000000000000000005" .parse() .unwrap(),