diff --git a/Cargo.lock b/Cargo.lock index 9dcb19a5..ba81fd98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -559,6 +559,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fee-forwarder-example" +version = "0.5.1" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-macros", + "stellar-tokens", +] + [[package]] name = "ff" version = "0.13.1" diff --git a/Cargo.toml b/Cargo.toml index 4d42310d..8deca2d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "examples/fungible-blocklist", "examples/fungible-capped", "examples/fungible-merkle-airdrop", + "examples/fee-forwarder", "examples/fungible-pausable", "examples/fungible-vault", "examples/merkle-voting", diff --git a/examples/fee-forwarder/Cargo.toml b/examples/fee-forwarder/Cargo.toml new file mode 100644 index 00000000..54726d7d --- /dev/null +++ b/examples/fee-forwarder/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "fee-forwarder-example" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-tokens = { workspace = true } +stellar-macros = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/fee-forwarder/src/contract.rs b/examples/fee-forwarder/src/contract.rs new file mode 100644 index 00000000..a5101e8b --- /dev/null +++ b/examples/fee-forwarder/src/contract.rs @@ -0,0 +1,171 @@ +//! Fee Forwarder Contract +//! +//! This contract enables fee abstraction by allowing users to pay relayers in +//! tokens instead of native XLM. The contract accepts token payment and +//! forwards calls to target contracts, ensuring atomic execution. +//! +//! ## Flow Overview +//! +//! 1. **User prepares transaction** (off-chain): +//! - User wants to call `target_contract.target_fn(target_args)` +//! - User wants to pay the transaction fees with tokens different than XLM +//! (e.g., USDC) +//! - User gets a quote from relayer: max fee amount and expiration ledger +//! +//! 2. **User signs authorization** (first signature): +//! - User authorizes the fee-forwarder contract with these parameters: +//! - `fee_token`: Which token to use for payment +//! - `max_fee_amount`: Maximum fee they're willing to pay +//! - `expiration_ledger`: When the authorization expires +//! - `target_contract`, `target_fn`, `target_args`: The actual call to +//! make +//! - User MUST include at least one subinvocation: +//! - `fee_token.approve(fee_forwarder, max_fee_amount, expiration_ledger)` +//! - Depending on the target contract call, user may need to include a +//! second subinvocation: +//! - `target_contract.target_fn(target_args)` (if it requires some +//! authorization) +//! +//! - **Note**: User does NOT sign the exact `fee_amount` or `relayer` +//! address yet (these are unknown at signing time) +//! +//! 3. **Relayer picks up transaction** (off-chain): +//! - Relayer calculates actual `fee_amount` based on current network +//! conditions +//! - Relayer verifies `fee_amount <= max_fee_amount` +//! +//! 4. **Relayer signs authorization** (second signature): +//! - Relayer authorizes the fee-forwarder contract with these parameters: +//! - `fee_token`: Same token as user specified +//! - `fee_amount`: Exact fee to charge (≤ max_fee_amount) +//! - `target_contract`, `target_fn`, `target_args`: Same as user specified +//! - `user`: The user's address +//! - Relayer must have `executor` role to call `forward()` +//! +//! 5. **Relayer submits transaction**: +//! - Relayer pays native XLM fees for network inclusion +//! - Transaction contains call to `fee_forwarder.forward()` with both +//! authorizations +//! +//! 6. **Contract executes atomically**: +//! - Validates both user and relayer authorizations +//! - User approves contract to spend up to `max_fee_amount` tokens +//! - Contract transfers exactly `fee_amount` tokens from user to itself +//! - Contract forwards call to `target_contract.target_fn(target_args)` +//! - If any step fails, entire transaction reverts (including token +//! transfer) +//! +//! ## Authorization Summary +//! +//! **User authorizes** (signs first, before knowing relayer or exact fee): +//! - `fee_token`, `max_fee_amount`, `expiration_ledger` +//! - `target_contract`, `target_fn`, `target_args` +//! +//! **Relayer authorizes** (signs second, with exact fee): +//! - `fee_token`, `fee_amount` (exact amount ≤ max) +//! - `target_contract`, `target_fn`, `target_args` +//! - `user` (whose transaction is being relayed) +//! +//! ## Security Properties +//! +//! - User can't be charged more than `max_fee_amount` +//! - User's authorization expires at `expiration_ledger` +//! - Only whitelisted relayers (with `executor` role) can call `forward()` +//! - Token transfer and target call are atomic (both succeed or both fail) +//! - Relayer can't change the target call parameters signed by user + +#![allow(clippy::too_many_arguments)] +use soroban_sdk::{ + contract, contractimpl, symbol_short, token::TokenClient, Address, Env, IntoVal, Symbol, Val, + Vec, +}; +use stellar_access::access_control::{grant_role_no_auth, set_admin, AccessControl}; +use stellar_macros::{default_impl, has_role}; + +const EXECUTOR_ROLE: Symbol = symbol_short!("executor"); + +#[contract] +pub struct FeeForwarder; + +#[contractimpl] +impl FeeForwarder { + pub fn __constructor(e: &Env, admin: Address, executors: Vec
) { + set_admin(e, &admin); + + for executor in executors.iter() { + grant_role_no_auth(e, &executor, &EXECUTOR_ROLE, &admin); + } + } + + /// This function can be invoked only with authorizatons from both sides: + /// user and relayer. + #[has_role(relayer, "executor")] + pub fn forward( + e: &Env, + fee_token: Address, + fee_amount: i128, + max_fee_amount: i128, + expiration_ledger: u32, + target_contract: Address, + target_fn: Symbol, + target_args: Vec, + user: Address, + relayer: Address, + ) -> Val { + // TODO: check max_fee_amount >= fee_amount + // TODO: check fee_token is allowed + + // user and relayer authorize each the args that concern them, e.g. user is the + // 1st to sign the authorizatons, but at that moment they don't know the + // precise fee they will be charged and the address of the relayer who + // will sponsor the transaction. + + let user_args_for_auth = ( + fee_token.clone(), + max_fee_amount, + expiration_ledger, + target_contract.clone(), + target_fn.clone(), + target_args.clone(), + ) + .into_val(e); + user.require_auth_for_args(user_args_for_auth); + + let relayer_args_for_auth = ( + fee_token.clone(), + fee_amount, + target_contract.clone(), + target_fn.clone(), + target_args.clone(), + user.clone(), + ) + .into_val(e); + relayer.require_auth_for_args(relayer_args_for_auth); + + let token_client = TokenClient::new(e, &fee_token); + // user signs an approval for `max_fee_amount` so that this contract can charge + // <= `max_fee_amount` + token_client.approve( + &user, + &e.current_contract_address(), + &max_fee_amount, + &expiration_ledger, + ); + + token_client.transfer_from( + &e.current_contract_address(), + &user, + &e.current_contract_address(), + &fee_amount, + ); + + e.invoke_contract::(&target_contract, &target_fn, target_args) + } + + // TODO: more functions to sweep tokens + // TODO: allow/disallow tokens +} + +#[default_impl] +#[contractimpl] +impl AccessControl for FeeForwarder {} diff --git a/examples/fee-forwarder/src/lib.rs b/examples/fee-forwarder/src/lib.rs new file mode 100644 index 00000000..5d027c3f --- /dev/null +++ b/examples/fee-forwarder/src/lib.rs @@ -0,0 +1,6 @@ +#![no_std] + +mod contract; + +#[cfg(test)] +mod test; diff --git a/examples/fee-forwarder/src/test.rs b/examples/fee-forwarder/src/test.rs new file mode 100644 index 00000000..82edb9a6 --- /dev/null +++ b/examples/fee-forwarder/src/test.rs @@ -0,0 +1,238 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, MockAuth, MockAuthInvoke}, + vec, Address, Env, IntoVal, String, Symbol, TryIntoVal, Val, Vec, +}; +use stellar_macros::default_impl; +use stellar_tokens::fungible::{Base, FungibleToken}; + +use crate::contract::{FeeForwarder, FeeForwarderClient}; + +#[contract] +pub struct MockToken; + +#[contractimpl] +impl MockToken { + pub fn __constructor(e: &Env, to: Address) { + Base::set_metadata(e, 7, String::from_str(e, "Mock Token"), String::from_str(e, "MOCK")); + Base::mint(e, &to, 1_000_000_000); + } +} + +#[default_impl] +#[contractimpl] +impl FungibleToken for MockToken { + type ContractType = Base; +} + +#[contract] +pub struct MockTarget; + +#[contractimpl] +impl MockTarget { + pub fn greet(e: Env) -> String { + String::from_str(&e, "hello") + } + + pub fn require_auth_test(_e: Env, caller: Address) -> Address { + caller.require_auth(); + caller + } +} + +fn setup<'a>( + e: &Env, +) -> (FeeForwarderClient<'a>, MockTokenClient<'a>, MockTargetClient<'a>, Address, Address, i128, i128) +{ + let admin = Address::generate(e); + let user = Address::generate(e); + let relayer = Address::generate(e); + + let fee_forwarder_id = e.register(FeeForwarder, (admin, vec![e, relayer.clone()])); + let token_id = e.register(MockToken, (user.clone(),)); + let target_id = e.register(MockTarget, ()); + + let fee_forwarder = FeeForwarderClient::new(e, &fee_forwarder_id); + let token = MockTokenClient::new(e, &token_id); + let target = MockTargetClient::new(e, &target_id); + + (fee_forwarder, token, target, user, relayer, 100_000, 150_000) +} + +#[test] +fn forward_basic() { + let e = Env::default(); + let (fee_forwarder, token, target, user, relayer, fee_amount, max_fee_amount) = setup(&e); + + let current_ledger = e.ledger().sequence(); + let fn_name = Symbol::new(&e, "greet"); + let fn_args: Vec = vec![&e]; + + let initial_user_balance = token.balance(&user); + let initial_relayer_balance = token.balance(&relayer); + + e.mock_auths(&[ + // mock auth for user + MockAuth { + address: &user, + invoke: &MockAuthInvoke { + contract: &fee_forwarder.address, + fn_name: "forward", + args: ( + token.address.clone(), + max_fee_amount, + current_ledger, + target.address.clone(), + &fn_name, + &fn_args, + ) + .into_val(&e), + sub_invokes: &[MockAuthInvoke { + contract: &token.address, + fn_name: "approve", + args: ( + user.clone(), + fee_forwarder.address.clone(), + max_fee_amount, + current_ledger, + ) + .into_val(&e), + sub_invokes: &[], + }], + }, + }, + MockAuth { + // mock auth for relayer + address: &relayer, + invoke: &MockAuthInvoke { + contract: &fee_forwarder.address, + fn_name: "forward", + args: ( + token.address.clone(), + fee_amount, + target.address.clone(), + &fn_name, + &fn_args, + user.clone(), + ) + .into_val(&e), + sub_invokes: &[], + }, + }, + ]); + + // `greet` should return "hello" + let res: String = fee_forwarder + .forward( + &token.address, + &fee_amount, + &max_fee_amount, + ¤t_ledger, + &target.address, + &fn_name, + &fn_args, + &user, + &relayer, + ) + .try_into_val(&e) + .unwrap(); + + assert_eq!(res, String::from_str(&e, "hello")); + + assert_eq!(token.balance(&user), initial_user_balance - fee_amount); + assert_eq!(token.balance(&fee_forwarder.address), initial_relayer_balance + fee_amount); +} + +#[test] +fn forward_two_subinvokes() { + let e = Env::default(); + let (fee_forwarder, token, target, user, relayer, fee_amount, max_fee_amount) = setup(&e); + + let current_ledger = e.ledger().sequence(); + let fn_name = Symbol::new(&e, "require_auth_test"); + let fn_args: Vec = vec![&e, user.into_val(&e)]; + + let initial_user_balance = token.balance(&user); + let initial_relayer_balance = token.balance(&relayer); + + e.mock_auths(&[ + // mock auth for user + MockAuth { + address: &user, + invoke: &MockAuthInvoke { + contract: &fee_forwarder.address, + fn_name: "forward", + args: ( + token.address.clone(), + max_fee_amount, + current_ledger, + target.address.clone(), + &fn_name, + &fn_args, + ) + .into_val(&e), + sub_invokes: &[ + MockAuthInvoke { + contract: &token.address, + fn_name: "approve", + args: ( + user.clone(), + fee_forwarder.address.clone(), + max_fee_amount, + current_ledger, + ) + .into_val(&e), + sub_invokes: &[], + }, + MockAuthInvoke { + contract: &target.address, + fn_name: "require_auth_test", + args: (user.clone(),).into_val(&e), + sub_invokes: &[], + }, + ], + }, + }, + MockAuth { + // mock auth for relayer + address: &relayer, + invoke: &MockAuthInvoke { + contract: &fee_forwarder.address, + fn_name: "forward", + args: ( + token.address.clone(), + fee_amount, + target.address.clone(), + &fn_name, + &fn_args, + user.clone(), + ) + .into_val(&e), + sub_invokes: &[], + }, + }, + ]); + + // `require_auth_test` should return user address + let res: Address = fee_forwarder + .forward( + &token.address, + &fee_amount, + &max_fee_amount, + ¤t_ledger, + &target.address, + &fn_name, + &fn_args, + &user, + &relayer, + ) + .try_into_val(&e) + .unwrap(); + + assert_eq!(res, user); + + assert_eq!(token.balance(&user), initial_user_balance - fee_amount); + assert_eq!(token.balance(&fee_forwarder.address), initial_relayer_balance + fee_amount); +}