diff --git a/.changelog/machine-token-payments.md b/.changelog/machine-token-payments.md new file mode 100644 index 00000000..c813371a --- /dev/null +++ b/.changelog/machine-token-payments.md @@ -0,0 +1,5 @@ +--- +"mpp": minor +--- + +Add first-party Tempo machine-token settlement support to charge clients and servers. diff --git a/src/client/tempo/charge/mod.rs b/src/client/tempo/charge/mod.rs index 45fb66b2..6f989e35 100644 --- a/src/client/tempo/charge/mod.rs +++ b/src/client/tempo/charge/mod.rs @@ -253,6 +253,11 @@ impl TempoCharge { Ok(self) } + pub(crate) fn with_calls(mut self, calls: Vec) -> Self { + self.calls = Some(calls); + self + } + /// Sign the charge with default options. /// /// This is the simple path — resolves the RPC provider from chain_id, diff --git a/src/client/tempo/provider.rs b/src/client/tempo/provider.rs index 8d6fa724..edd7451b 100644 --- a/src/client/tempo/provider.rs +++ b/src/client/tempo/provider.rs @@ -2,6 +2,7 @@ use crate::error::{MppError, ResultExt}; use crate::protocol::core::{PaymentChallenge, PaymentCredential}; +use crate::protocol::methods::tempo::TempoChargeExt; use super::autoswap::AutoswapConfig; use super::charge::{SignOptions, TempoCharge}; @@ -50,6 +51,37 @@ pub(super) async fn prepare_charge( from: alloy::primitives::Address, ) -> Result { let charge = prepare_charge_request(challenge, expected_chain_id, client_id)?; + let details = challenge + .request + .decode::()? + .tempo_method_details()?; + if details.machine_token_enabled() { + let transfers = crate::protocol::methods::tempo::transfers::get_transfers( + charge.amount(), + charge.recipient(), + charge.memo(), + charge.splits(), + )?; + if let Some(calls) = crate::protocol::methods::tempo::machine_token::route( + charge.chain_id(), + charge.currency(), + &transfers, + ) { + use tempo_alloy::contracts::precompiles::ITIP20; + let deployment = + crate::protocol::methods::tempo::machine_token::deployment(charge.chain_id()) + .unwrap(); + if ITIP20::new(deployment.token, provider) + .balanceOf(from) + .call() + .await + .map(|balance| balance >= charge.amount()) + .unwrap_or(false) + { + return Ok(charge.with_calls(calls.to_vec())); + } + } + } apply_autoswap(charge, autoswap, provider, from).await } diff --git a/src/protocol/methods/tempo/machine_token.rs b/src/protocol/methods/tempo/machine_token.rs new file mode 100644 index 00000000..d29b0b46 --- /dev/null +++ b/src/protocol/methods/tempo/machine_token.rs @@ -0,0 +1,135 @@ +//! First-party machine-token settlement routes. + +use alloy::primitives::{address, Address, Bytes, TxKind, U256}; +use alloy::sol; +use alloy::sol_types::SolCall; +use tempo_alloy::contracts::precompiles::ITIP20; +use tempo_alloy::primitives::transaction::Call; + +use super::{transfers::Transfer, CHAIN_ID, MODERATO_CHAIN_ID}; + +sol! { + function swapTo(address inputToken, uint256 amount, address targetToken, address recipient, bytes32 memo); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Deployment { + pub swap: Address, + pub token: Address, +} + +pub const MAINNET: Deployment = Deployment { + swap: address!("C6D32f013E0fA3e83B63Dc680E99826761595732"), + token: address!("20C0000000000000000000003793c39601711f19"), +}; +pub const MODERATO: Deployment = Deployment { + swap: address!("07f1FE0467Ae01DE340024aa4b7DD9729b1c169b"), + token: address!("20c000000000000000000000f85bbCa724044De0"), +}; + +pub fn deployment(chain_id: u64) -> Option { + match chain_id { + CHAIN_ID => Some(MAINNET), + MODERATO_CHAIN_ID => Some(MODERATO), + _ => None, + } +} + +pub fn route(chain_id: u64, currency: Address, transfers: &[Transfer]) -> Option<[Call; 2]> { + let deployment = deployment(chain_id)?; + let [transfer] = transfers else { return None }; + let memo = transfer.memo?; + Some([ + Call { + to: TxKind::Call(deployment.token), + value: U256::ZERO, + input: Bytes::from( + ITIP20::approveCall::new((deployment.swap, transfer.amount)).abi_encode(), + ), + }, + Call { + to: TxKind::Call(deployment.swap), + value: U256::ZERO, + input: Bytes::from( + swapToCall::new(( + deployment.token, + transfer.amount, + currency, + transfer.recipient, + memo.into(), + )) + .abi_encode(), + ), + }, + ]) +} + +pub fn matches_route( + calls: &[Call], + chain_id: u64, + currency: Address, + transfers: &[Transfer], +) -> bool { + let [transfer] = transfers else { return false }; + let Some(swap) = calls.get(1) else { + return false; + }; + if swap.input.len() < 4 || swap.input[..4] != swapToCall::SELECTOR { + return false; + } + let Ok(decoded) = swapToCall::abi_decode_raw(&swap.input[4..]) else { + return false; + }; + let transfer = Transfer { + memo: Some(decoded.memo.into()), + ..transfer.clone() + }; + let Some(expected) = route(chain_id, currency, &[transfer]) else { + return false; + }; + calls == expected +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_and_matches_the_canonical_route() { + let transfers = [Transfer { + amount: U256::from(42), + recipient: Address::repeat_byte(1), + memo: Some([2; 32]), + }]; + let calls = route(MODERATO_CHAIN_ID, Address::repeat_byte(3), &transfers).unwrap(); + assert!(matches_route( + &calls, + MODERATO_CHAIN_ID, + Address::repeat_byte(3), + &transfers + )); + let transfer_without_bound_memo = Transfer { + memo: None, + ..transfers[0].clone() + }; + assert!(matches_route( + &calls, + MODERATO_CHAIN_ID, + Address::repeat_byte(3), + &[transfer_without_bound_memo] + )); + assert_eq!(calls[0].to, TxKind::Call(MODERATO.token)); + assert_eq!(calls[1].to, TxKind::Call(MODERATO.swap)); + } + + #[test] + fn rejects_unsupported_or_non_single_transfer_routes() { + assert!(route(1, Address::ZERO, &[]).is_none()); + let transfer = Transfer { + amount: U256::from(1), + recipient: Address::repeat_byte(1), + memo: None, + }; + assert!(route(MODERATO_CHAIN_ID, Address::ZERO, &[transfer]).is_none()); + } +} diff --git a/src/protocol/methods/tempo/method.rs b/src/protocol/methods/tempo/method.rs index f19101b3..eca75d31 100644 --- a/src/protocol/methods/tempo/method.rs +++ b/src/protocol/methods/tempo/method.rs @@ -770,7 +770,21 @@ where let expected = Self::expected_transfers(charge)?; // Use the source address if present, otherwise the receipt sender. - let expected_sender = source_address.unwrap_or_else(|| receipt.from()); + let machine_token_enabled = charge + .tempo_method_details() + .map_err(|e| VerificationError::new(format!("Invalid charge request: {e}")))? + .machine_token_enabled(); + let expected_sender = if machine_token_enabled { + super::machine_token::deployment(expected_chain_id) + .ok_or_else(|| { + VerificationError::new(format!( + "Machine tokens are not supported on chain ID {expected_chain_id}" + )) + })? + .swap + } else { + source_address.unwrap_or_else(|| receipt.from()) + }; // Tempo uses TIP-20 tokens exclusively (no native token transfers) let matched_logs = self.verify_tip20_transfers( @@ -843,6 +857,7 @@ where expected: &[Transfer], expected_chain_id: u64, require_exact_calls: bool, + machine_token_enabled: bool, ) -> Result<(), VerificationError> { if currency.is_zero() { return Err(VerificationError::new( @@ -880,6 +895,16 @@ where ))); } + if machine_token_enabled { + if super::machine_token::matches_route(&tx.calls, expected_chain_id, currency, expected) + { + return Ok(()); + } + return Err(VerificationError::new( + "Invalid transaction: machine-token route does not match the charge".to_string(), + )); + } + let transfer_calls = get_transfer_calls(&tx.calls)?; if require_exact_calls { @@ -1035,6 +1060,10 @@ where &expected, expected_chain_id, charge.fee_payer(), + charge + .tempo_method_details() + .map_err(|e| VerificationError::new(format!("Invalid charge request: {e}")))? + .machine_token_enabled(), )?; // The sponsor pays the gas here, so simulate first and bail if the tx @@ -1080,8 +1109,29 @@ where } // Verify the receipt contains the expected TIP-20 transfer(s). - let matched_logs = - self.verify_tip20_transfers(&receipt, receipt.from(), currency, &expected, None, None)?; + let machine_token_enabled = charge + .tempo_method_details() + .map_err(|e| VerificationError::new(format!("Invalid charge request: {e}")))? + .machine_token_enabled(); + let expected_sender = if machine_token_enabled { + super::machine_token::deployment(expected_chain_id) + .ok_or_else(|| { + VerificationError::new(format!( + "Machine tokens are not supported on chain ID {expected_chain_id}" + )) + })? + .swap + } else { + receipt.from() + }; + let matched_logs = self.verify_tip20_transfers( + &receipt, + expected_sender, + currency, + &expected, + None, + None, + )?; if charge.memo().is_none() { assert_challenge_bound_memo(&matched_logs, challenge_id, realm)?; } @@ -2427,7 +2477,7 @@ mod tests { ); let error = method - .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true) + .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true, false) .unwrap_err(); assert!( @@ -2474,7 +2524,7 @@ mod tests { ); method - .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true) + .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true, false) .unwrap(); } @@ -2529,7 +2579,7 @@ mod tests { ); method - .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true) + .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true, false) .unwrap(); } @@ -2566,7 +2616,7 @@ mod tests { ); let error = method - .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true) + .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true, false) .unwrap_err(); assert!( @@ -2613,7 +2663,7 @@ mod tests { ); let error = method - .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true) + .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true, false) .unwrap_err(); assert!(error.to_string().contains("approve spender is not the DEX")); @@ -2657,7 +2707,7 @@ mod tests { ); let error = method - .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true) + .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true, false) .unwrap_err(); assert!(error @@ -2703,7 +2753,7 @@ mod tests { ); let error = method - .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true) + .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true, false) .unwrap_err(); assert!(error.to_string().contains("swap target is not the DEX")); @@ -2734,7 +2784,7 @@ mod tests { ); let error = method - .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true) + .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true, false) .unwrap_err(); assert!(error.to_string().contains("exceeds maximum")); @@ -2775,6 +2825,7 @@ mod tests { &expected, CHAIN_ID, true, + false, ) .unwrap_err(); assert!(error.to_string().contains("exceeds maximum 500000")); @@ -2792,6 +2843,7 @@ mod tests { &expected, CHAIN_ID, true, + false, ) .expect("override should raise ceiling above default"); } diff --git a/src/protocol/methods/tempo/mod.rs b/src/protocol/methods/tempo/mod.rs index 1b17a910..ec8cfe65 100644 --- a/src/protocol/methods/tempo/mod.rs +++ b/src/protocol/methods/tempo/mod.rs @@ -99,6 +99,7 @@ pub mod charge; pub mod fee_payer_envelope; +pub mod machine_token; pub mod network; #[cfg(feature = "tempo")] pub mod precompile_voucher; diff --git a/src/protocol/methods/tempo/types.rs b/src/protocol/methods/tempo/types.rs index ad8ca5a1..71511bde 100644 --- a/src/protocol/methods/tempo/types.rs +++ b/src/protocol/methods/tempo/types.rs @@ -55,8 +55,7 @@ pub struct Split { /// let details = TempoMethodDetails { /// chain_id: Some(42431), /// fee_payer: Some(true), -/// memo: None, -/// splits: None, +/// ..Default::default() /// }; /// assert!(details.fee_payer()); /// ``` @@ -73,6 +72,13 @@ pub struct TempoMethodDetails { #[serde(rename = "feePayer", skip_serializing_if = "Option::is_none")] pub fee_payer: Option, + /// Whether the client may settle through Tempo's first-party machine-token route. + #[serde( + rename = "machineTokenEnabled", + skip_serializing_if = "Option::is_none" + )] + pub machine_token_enabled: Option, + /// Optional memo for `transferWithMemo` calls. /// /// When present, the server verifies `TransferWithMemo` logs instead of `Transfer`. @@ -95,6 +101,10 @@ impl TempoMethodDetails { self.fee_payer.unwrap_or(false) } + pub fn machine_token_enabled(&self) -> bool { + self.machine_token_enabled.unwrap_or(false) + } + /// Check if this is for the Tempo Moderato network. pub fn is_tempo_moderato(&self) -> bool { self.chain_id == Some(MODERATO_CHAIN_ID) @@ -115,6 +125,7 @@ mod tests { let details = TempoMethodDetails { chain_id: Some(42431), fee_payer: Some(true), + machine_token_enabled: None, memo: None, splits: None, }; @@ -133,6 +144,19 @@ mod tests { fn test_fee_payer_default() { let details = TempoMethodDetails::default(); assert!(!details.fee_payer()); + assert!(!details.machine_token_enabled()); + } + + #[test] + fn test_machine_token_serialization() { + let details = TempoMethodDetails { + machine_token_enabled: Some(true), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(details).unwrap()["machineTokenEnabled"], + true + ); } #[test] @@ -178,6 +202,7 @@ mod tests { let details = TempoMethodDetails { chain_id: Some(42431), fee_payer: Some(true), + machine_token_enabled: None, memo: Some("0xabcdef".to_string()), splits: None, }; diff --git a/src/server/mpp.rs b/src/server/mpp.rs index 5554c73d..8797d677 100644 --- a/src/server/mpp.rs +++ b/src/server/mpp.rs @@ -109,6 +109,7 @@ pub struct Mpp { recipient: Option, decimals: u32, fee_payer: bool, + machine_token_enabled: bool, chain_id: Option, opaque: Option, events: ServerEvents, @@ -131,6 +132,7 @@ where recipient: None, decimals: DEFAULT_DECIMALS, fee_payer: false, + machine_token_enabled: false, chain_id: None, opaque: None, events: ServerEvents::default(), @@ -154,6 +156,7 @@ where recipient: Some(recipient.into()), decimals: DEFAULT_DECIMALS, fee_payer: false, + machine_token_enabled: false, chain_id: None, opaque: None, events: ServerEvents::default(), @@ -176,6 +179,7 @@ where recipient: self.recipient, decimals: self.decimals, fee_payer: self.fee_payer, + machine_token_enabled: self.machine_token_enabled, chain_id: self.chain_id, opaque: self.opaque, events: self.events, @@ -502,6 +506,9 @@ where if options.fee_payer || self.fee_payer { details.insert("feePayer".into(), serde_json::json!(true)); } + if self.machine_token_enabled { + details.insert("machineTokenEnabled".into(), serde_json::json!(true)); + } if let Some(chain_id) = self.chain_id { details.insert("chainId".into(), serde_json::json!(chain_id)); } @@ -1041,6 +1048,16 @@ impl Mpp> { /// let challenge = mpp.charge("1.00")?; /// ``` pub fn create(builder: super::TempoBuilder) -> Result { + if builder.machine_token_enabled { + let chain_id = builder + .chain_id + .unwrap_or(crate::protocol::methods::tempo::CHAIN_ID); + if crate::protocol::methods::tempo::machine_token::deployment(chain_id).is_none() { + return Err(crate::error::MppError::InvalidConfig(format!( + "machine tokens are not supported on chain ID {chain_id}" + ))); + } + } let secret_key = builder .secret_key .or_else(|| std::env::var(SECRET_KEY_ENV_VAR).ok()) @@ -1085,6 +1102,7 @@ impl Mpp> { recipient: Some(builder.recipient), decimals: builder.decimals, fee_payer: builder.fee_payer, + machine_token_enabled: builder.machine_token_enabled, chain_id: builder.chain_id, opaque: None, events: ServerEvents::default(), @@ -1245,6 +1263,7 @@ impl Mpp { recipient: None, decimals: builder.decimals as u32, fee_payer: false, + machine_token_enabled: false, chain_id: None, opaque: None, events: ServerEvents::default(), @@ -1899,6 +1918,7 @@ mod tests { recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()), decimals: DEFAULT_DECIMALS, fee_payer: false, + machine_token_enabled: false, chain_id: None, opaque: None, events: ServerEvents::default(), @@ -2505,6 +2525,7 @@ mod tests { recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()), decimals: DEFAULT_DECIMALS, fee_payer: false, + machine_token_enabled: false, chain_id: None, opaque: None, events: ServerEvents::default(), @@ -2582,6 +2603,7 @@ mod tests { recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()), decimals: DEFAULT_DECIMALS, fee_payer: false, + machine_token_enabled: false, chain_id: None, opaque: None, events: ServerEvents::default(), @@ -2626,6 +2648,7 @@ mod tests { recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()), decimals: DEFAULT_DECIMALS, fee_payer: false, + machine_token_enabled: false, chain_id: None, opaque: None, events: ServerEvents::default(), @@ -2728,6 +2751,7 @@ mod tests { recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()), decimals: DEFAULT_DECIMALS, fee_payer: false, + machine_token_enabled: false, chain_id: None, opaque: None, events: ServerEvents::default(), diff --git a/src/server/tempo.rs b/src/server/tempo.rs index 1c5a32d3..c472010f 100644 --- a/src/server/tempo.rs +++ b/src/server/tempo.rs @@ -33,6 +33,7 @@ pub struct TempoBuilder { pub(crate) secret_key: Option, pub(crate) decimals: u32, pub(crate) fee_payer: bool, + pub(crate) machine_token_enabled: bool, pub(crate) chain_id: Option, pub(crate) fee_payer_signer: Option, pub(crate) fee_payer_allowed_fee_tokens: Option>, @@ -95,6 +96,12 @@ impl TempoBuilder { self } + /// Enable first-party machine-token settlement for charge challenges. + pub fn machine_token_enabled(mut self, enabled: bool) -> Self { + self.machine_token_enabled = enabled; + self + } + /// Set the signer used for fee sponsorship. /// /// When clients send transactions with `feePayer: true`, the server @@ -180,6 +187,7 @@ pub fn tempo(config: TempoConfig<'_>) -> TempoBuilder { secret_key: None, decimals: 6, fee_payer: false, + machine_token_enabled: false, chain_id: None, fee_payer_signer: None, fee_payer_allowed_fee_tokens: None, @@ -262,4 +270,13 @@ mod tests { "without_store() should clear the configured store" ); } + + #[test] + fn tempo_builder_enables_machine_tokens() { + let builder = tempo(TempoConfig { + recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2", + }) + .machine_token_enabled(true); + assert!(builder.machine_token_enabled); + } }