Building Modular Contracts on Fuel Without transferFrom
Wait… what’s transferFrom
again?
On Ethereum, ERC20 tokens come with a neat little trick: transferFrom
.
It lets contracts pull tokens from a user’s wallet—as long as the user has approved them first. That’s how DEX routers, lending markets, and tons of DeFi apps work. You click “approve,” and then the protocol can grab funds directly when you want to swap, lend, or do some other action.
It’s a powerful debit pattern.
But here’s the twist: Fuel’s SRC20 tokens don’t have transferFrom
.
That means if you’re used to building like it’s Ethereum, you need a new playbook. Sure, you could create wrappers to emulate it—but that fragments assets and makes life harder for users. Nobody wants ten wrapped versions of the same token.
So… how do we build without transferFrom
?
Two Flavors of Fund Movement
When protocols need to move tokens on behalf of a user, it usually falls into one of two buckets:
-
Direct user actions — The user calls a contract themselves. (Think: depositing into Aave.)
-
Relayed actions — Someone else executes an “intent” on the user’s behalf. (Think: Cowswap or 1inch Fusion.)
This post is about case 1: direct user actions.
Scripts on Fuel: Powerful, but…
Fuel has something cool called scripts. These let wallets chain together contract calls and transfers in a single transaction.
Sounds perfect, right? Well, the UTXO model throws a curveball.
Because UTXOs are defined upfront and only resolved at the end of the transaction, you run into problems.
Example:
-
You want to swap tokens on Mira.
-
Then, immediately deposit them into Swaylend, same transaction.
On Ethereum, no problem. On Fuel, the swap only produces new UTXOs at the end of the transaction, so you can’t pass the swapped tokens directly into the deposit call.
In short: plain scripting doesn’t cut it.
Borrowing Ideas: Aave + Uniswap
This is where we look at the old giants for inspiration:
-
Aave has handy parameters like
onBehalfOf
andreceiver
. They’re not flashy, but they let one account deposit or borrow on behalf of another. Super useful for modularity. -
Uniswap avoids
transferFrom
entirely. Pools don’t pull funds—they just check their current balance against an internally calculated balance. The difference tells them how much was deposited. Simple, elegant, and modular.
Put those ideas together, and we’ve got a model for Fuel.
A Simple Deposit Contract
Here’s the core building block: a deposit contract with two essential features:
-
Delegated withdrawals (others can withdraw on your behalf).
-
Receiver parameter (others can deposit on your behalf).
abi Depositor {
fn allow(asset: b256, spender: Identity, amount: u64);
fn deposit(asset: b256, receiver: Identity) -> u64;
fn withdraw(
asset: b256,
amount: u64,
onBehalfOf: Identity,
receiver: Identity,
) -> u64;
}
Note: The
deposit
function has no amount
parameter.
Just like Uniswap pools, we rely on internal accounting of the funds received: the contract checks its balance before and after the transaction, and the difference is credited as the deposit. This design avoids reliance on transferFrom
and makes the system more modular.
I created a very simplistic example implementation in our GitHub, the repository is called fuel-depositor
.
Building a periphery: The Router
Now let’s put the pieces together. The router is where the magic happens. It allows:
-
Executing an action before depositing funds (
call_and_deposit
) -
Pulling funds (like
transferFrom
) and using them in a call (withdraw_and_call
)
Here’s what it looks like:
impl Router for Contract {
#[storage(read, write), payable]
fn call_and_deposit(
call_data: Bytes,
call_target: ContractId,
deposit_asset: AssetId,
deposit_target: ContractId,
deposit_receiver: Identity,
) -> u64 {
// Step 1: Execute the generic call (e.g. swap, bridge)
abi(GenericCaller, call_target.into()).action {
asset_id: msg_asset_id().into(),
coins: msg_amount(),
}(call_data);
// Step 2: Check how many tokens we got
let deposit_amount = this_balance(deposit_asset);
// Step 3: Forward them to the depositor (optional)
transfer(
Identity::ContractId(deposit_target),
deposit_asset,
deposit_amount,
);
// Step 4: Deposit on behalf of the receiver
abi(Depositor, deposit_target.into())
.deposit(deposit_asset.bits(), deposit_receiver)
}
#[storage(read, write)]
fn withdraw_and_call(
call_data: Bytes,
call_target: ContractId,
withdraw_asset: AssetId,
withdraw_target: ContractId,
withdraw_amount: u64,
) {
// Step 1: Withdraw funds to this contract
// (requires prior `allow` from user)
abi(Depositor, withdraw_target.into())
.withdraw(
withdraw_asset.bits(),
withdraw_amount,
msg_sender().unwrap(),
Identity::ContractId(ContractId::this()),
);
// Step 2: Execute the generic call
// Withdrawn funds are forwarded directly
abi(GenericCaller, call_target.into()).action {
asset_id: withdraw_asset.into(),
coins: withdraw_amount,
}(call_data);
}
}
Mirroring transferFrom
The withdraw_and_call
function is particularly powerful because it recreates the debit pattern of transferFrom
:
-
Users first call
allow
on the deposit contract, granting permission. -
The router then pulls funds on demand using
withdraw
. -
Those funds can immediately be forwarded into any generic action.
-
This reflects a custom debit feature on the protocol level instead of the asset level.
This mirrors the exact flow of ERC20 transferFrom
, but in a Fuel-native, modular way.
General Utility
The beauty of this design is that it’s protocol-agnostic. Any system that needs to pull or redirect funds can use it:
-
Staking protocols — pull stake from a user into a pool.
-
Lending protocols — let a router supply liquidity on a user’s behalf.
-
DEXs — pull tokens straight into a swap or multi-hop trade.
Because both push-style (call_and_deposit
) and pull-style (withdraw_and_call
) flows are supported, the router covers the full range of DeFi interactions.
Takeaways
-
transferFrom
isn’t the only way to enable debit flows—Uniswap and Aave have shown modular alternatives. -
Fuel’s UTXO model prevents naive chaining of actions, but modularity solves the problem.
-
With
call_and_deposit
andwithdraw_and_call
, we recreate the expressive power oftransferFrom
without wrappers or liquidity fragmentation. -
The
deposit
function doesn’t need anamount
parameter—funds are credited automatically based on balance differences. -
The design is universal: staking, lending, DEXs, bridges—anything that needs delegated fund movement can plug into this pattern.
-
Note that
deposit
andwithdraw
are just placeholders, these could be protocol functions that credit and debit, just likestake
,add_liquidity
,borrow
orunstake
.
In short: no
transferFrom
, no problem. With modular contracts, we can still build the same rich, composable DeFi systems on Fuel—just cleaner.