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);
+}