Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/machine-token-payments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mpp": minor
---

Add first-party Tempo machine-token settlement support to charge clients and servers.
5 changes: 5 additions & 0 deletions src/client/tempo/charge/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,11 @@ impl TempoCharge {
Ok(self)
}

pub(crate) fn with_calls(mut self, calls: Vec<Call>) -> Self {
self.calls = Some(calls);
self
}

/// Sign the charge with default options.
///
/// This is the simple path — resolves the RPC provider from chain_id,
Expand Down
32 changes: 32 additions & 0 deletions src/client/tempo/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -50,6 +51,37 @@ pub(super) async fn prepare_charge(
from: alloy::primitives::Address,
) -> Result<TempoCharge, MppError> {
let charge = prepare_charge_request(challenge, expected_chain_id, client_id)?;
let details = challenge
.request
.decode::<crate::protocol::intents::ChargeRequest>()?
.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
}

Expand Down
135 changes: 135 additions & 0 deletions src/protocol/methods/tempo/machine_token.rs
Original file line number Diff line number Diff line change
@@ -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<Deployment> {
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());
}
}
74 changes: 63 additions & 11 deletions src/protocol/methods/tempo/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)?;
}
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -2775,6 +2825,7 @@ mod tests {
&expected,
CHAIN_ID,
true,
false,
)
.unwrap_err();
assert!(error.to_string().contains("exceeds maximum 500000"));
Expand All @@ -2792,6 +2843,7 @@ mod tests {
&expected,
CHAIN_ID,
true,
false,
)
.expect("override should raise ceiling above default");
}
Expand Down
1 change: 1 addition & 0 deletions src/protocol/methods/tempo/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading