Caller Contract
A starting point, not a product. This contract is a reference implementation. It is unaudited, and Franklin Templeton does not maintain it on your behalf — once you copy it, it is yours to review, test and keep current.
Two limitations it leaves to you, worth addressing before it handles real value:
- Access control is a single owner. Every function that moves funds is
onlyOwner. - It carries no reentrancy guard of its own. It relies on the pool's.
The pool is operated separately, and its fees, authorizations, minimums and pause state can change without notice — which is why these pages ask you to quote immediately before you trade.
We recommend an independent audit before it handles client funds.
It compiles against OpenZeppelin upgradeable v5. It does not compile against v4, because
__Ownable_init there takes no argument — worth knowing before dropping it into an existing project.
Deploy it behind a proxy, and initialize in the same transaction
This is UUPS-upgradeable, so deploying produces two addresses: an implementation and a proxy.
Send us the proxy address, not the implementation. Deploying produces both, and your deployer prints the implementation first — it is also the one the block explorer asks you to verify, so it is the one most often sent to us by mistake. If the implementation is authorized instead, every call is refused as an unauthorized trader even though the authorization looks correct on our side, and neither of us can tell from the address alone.
We need the proxy address for the trader authorization and for the fund-token registration.
One call tells the two apart. Call owner() on the address you are about to send:
owner() returns | What it is | What to do |
|---|---|---|
| your wallet | an initialized proxy | this is the address to hand over |
| the zero address | the implementation, or a proxy nobody initialized | call owner() on the other address; if both are zero, initialize the proxy now |
| someone else's address | a proxy somebody else claimed | do not use it — see below |
An uninitialized proxy has no owner, so whoever calls initialize first becomes the owner.
Initializing in the same transaction as the deployment removes the gap entirely:
- Hardhat:
upgrades.deployProxy(Factory, [owner], { kind: "uups" }) - Foundry:
Upgrades.deployUUPSProxy
If you deploy the proxy yourself instead, pass the encoded initialize call as the proxy's constructor
data rather than sending it as a second transaction.
What to keep
Four things are why it works at all. Change everything else, keeping what to settle in mind.
- The contract holds the tokens and approves the pool —
_approve, then_swap. The pool pulls from whoever called it, so the tokens must be in the contract before you swap. - The contract grants its own destination override —
_rundoes this. An owner wallet granting it instead writes a record the swap never reads. allowedDestination, your own list of permitted recipients — checked before the contract grants anything to the pool. Without it, anyone who can reach your swap can send output to any address the pool happens to accept.- Ownership cannot be dropped, and cannot move in one step. Every function that can move this
contract's balances is
onlyOwner, so an owner that cannot send transactions strands everything it holds, with no way back.renounceOwnershipis blocked, andtransferOwnershiponly nominates — nothing changes until the new owner callsacceptOwnership. Keep both if you keep the single-owner model: the addresses that strand a contract this way are ordinary mistakes, not exotic ones — the proxy's own address, its implementation, or a multi-signature wallet that was never deployed on this network. Cancel an unaccepted nomination withtransferOwnership(address(0)).
And one thing that makes it easier to debug: withdraw passes the token's own error back to you.
A permissioned fund token refuses transfers to unregistered holders and says so by name, so it is worth
keeping that message rather than collapsing it into a generic failure.
What to settle
The entry-point signatures, so they match how your own system calls them.
The authorization model. Every function that moves funds is onlyOwner. Where a single party
controls the contract, that is a workable production answer — put the owner key behind a multisig, and
have the contract audited.
One detail to know before you widen access to the swap entry points, because it is specific to how
this template is wired rather than to Solidity generally: pool and fromToken are arguments, not
stored addresses. _swap approves the pool it is given to spend the token it is given, before the
real pool validates anything. So a caller who can reach an entry point can name a contract of their own
as pool and have your contract approve it. If you open these functions up, store the pool address at
initialization instead of accepting it per call, or check it against an allowlist the way
allowedDestination already checks recipients. Note also that minAmountOut is the caller's to choose
on executeSwapWithFloor, and that a floor of 0 re-quotes inside the transaction and cannot bind —
see Execute a swap.
Beyond that, changes to the authorization model are yours to design and yours to have reviewed. We do not maintain this contract, so please treat an independent audit as part of taking it to production rather than as an optional step.
If you upgrade it later
The contract holds token balances, so its storage layout is append-only. Add new variables at the end; never reorder or remove the ones already there. Send no value with the upgrade call — it is forwarded to the function your calldata names, and nothing here is payable.
The ownership guards live in the implementation, not the proxy, so an upgrade replaces them. A new
implementation that drops the blocked renounceOwnership or the two-step transfer reopens the way to
strand the contract, and standard upgrade tooling checks storage layout rather than whether those
guards survived. Confirm they are still there in whatever you deploy.
The contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {
Ownable2StepUpgradeable
} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol";
interface ISwapPool {
function quoteNet(
address fromToken,
address toToken,
uint256 amount,
address client
) external view returns (uint256 net, uint256 fee);
function swap(
address fromToken,
address toToken,
uint256 amount,
address destination,
uint256 minAmountOut
) external;
function setDestinationOverrideApproval(
address fromToken,
address toToken,
uint256 amount,
address destination,
uint256 deadline
) external;
}
/// @title BenjiSwapCallerDestination
/// @notice A minimal contract-to-contract caller for the BenjiSwap swap pool, able to deliver swap
/// output to a third party as well as to itself. UUPS-upgradeable.
/// @dev A starting point, not production-hardened. See "Templates, not a product".
///
/// The pool keys destination override approvals on `msg.sender`, so in a contract-to-contract
/// flow this contract must grant the approval itself. An owner wallet calling the pool directly
/// writes a record the swap never reads.
///
/// The destination allowlist is what makes granting override capability safe at all: without it,
/// this contract could direct output to any address the pool accepts. An upgrade can remove the
/// allowlist, so the proxy owner is a trust assumption for destination safety in a way an
/// immutable contract would not be.
///
/// Upgrade discipline: this contract holds token balances and its storage layout is append-only.
/// Add new variables at the end; never reorder or remove existing slots.
///
/// This contract has no reentrancy guard of its own and relies on the pool's. That is defensible
/// only while it holds no per-user state. If you add per-user accounting, add a guard.
/// Ownership moves in two steps: `transferOwnership` only nominates, and nothing changes until
/// the nominee calls `acceptOwnership`. That is deliberate — every function that can move this
/// contract's balances is `onlyOwner`, so an owner that cannot send transactions strands them
/// permanently. `renounceOwnership` is blocked for the same reason.
contract BenjiSwapCallerDestination is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable {
/// @notice Destinations this contract may deliver swap output to. Self-delivery is always allowed.
mapping(address => bool) public allowedDestination;
event DestinationAllowed(address indexed destination, bool allowed);
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address initialOwner) external initializer {
__UUPSUpgradeable_init();
__Ownable_init(initialOwner);
}
/// @notice Add or remove a permitted third-party destination.
function setAllowedDestination(address destination, bool allowed) external onlyOwner {
require(destination != address(0), "ZERO_DESTINATION");
allowedDestination[destination] = allowed;
emit DestinationAllowed(destination, allowed);
}
/// @notice Swap and keep the output in this contract.
/// @dev No effective floor: the fallback quote is read in the same transaction as the swap, so it
/// cannot bind. Use `executeSwapWithFloor` with a floor you quoted yourself for anything of
/// value.
/// @param pool Swap pool address.
/// @param fromToken Input token, which must already be held by this contract.
/// @param toToken Output token.
/// @param amount Input amount, in `fromToken` base units.
function executeSwap(
address pool,
address fromToken,
address toToken,
uint256 amount
) external onlyOwner {
_run(pool, fromToken, toToken, amount, address(this), 0);
}
/// @notice Swap and deliver the output to `destination`, granting the required override approval in
/// the same transaction.
/// @dev `destination` must be allow-listed here and authorized on the pair on the pool. Re-grants
/// unconditionally, replacing any standing approval on the same route. No effective floor —
/// prefer `executeSwapWithFloor`.
function executeSwapToDestination(
address pool,
address fromToken,
address toToken,
uint256 amount,
address destination
) external onlyOwner {
_run(pool, fromToken, toToken, amount, destination, 0);
}
/// @notice As above, with an explicit `minAmountOut`. Pass the `net` you quoted off-chain.
/// @dev Passing `0` re-quotes inside this transaction and yields a floor that cannot bind.
/// Pass `address(0)` for `destination` to deliver to this contract.
function executeSwapWithFloor(
address pool,
address fromToken,
address toToken,
uint256 amount,
uint256 minAmountOut,
address destination
) external onlyOwner {
_run(
pool,
fromToken,
toToken,
amount,
destination == address(0) ? address(this) : destination,
minAmountOut
);
}
/// @notice Recover any ERC-20 held by this contract, whether leftover input or swap output.
/// @dev Passes the token's own revert reason back. A permissioned fund token refuses transfers to
/// unregistered holders and says so by name; collapsing that into a generic failure throws
/// away the only useful information you get.
function withdraw(address token, address to, uint256 amount) external onlyOwner {
(bool ok, bytes memory data) = token.call(
abi.encodeWithSignature("transfer(address,uint256)", to, amount)
);
if (!ok) {
if (data.length > 0) {
assembly ("memory-safe") {
revert(add(data, 0x20), mload(data))
}
}
revert("TRANSFER_FAIL");
}
require(data.length == 0 || abi.decode(data, (bool)), "TRANSFER_FAIL");
}
function _run(
address pool,
address fromToken,
address toToken,
uint256 amount,
address destination,
uint256 minAmountOut
) private {
if (destination != address(this)) {
require(allowedDestination[destination], "DESTINATION_NOT_ALLOWED");
// Granted and consumed in this transaction: the approval cannot be raced by a second
// swap, and it is left drained and past its deadline the moment the block closes.
ISwapPool(pool).setDestinationOverrideApproval(
fromToken,
toToken,
amount,
destination,
block.number
);
}
_swap(pool, fromToken, toToken, amount, destination, minAmountOut);
}
function _swap(
address pool,
address fromToken,
address toToken,
uint256 amount,
address destination,
uint256 minAmountOut
) private {
_approve(fromToken, pool, amount);
if (minAmountOut == 0) {
(uint256 net, ) = ISwapPool(pool).quoteNet(fromToken, toToken, amount, address(this));
require(net > 0, "ZERO_NET");
minAmountOut = net;
}
ISwapPool(pool).swap(fromToken, toToken, amount, destination, minAmountOut);
}
/// @dev Low-level approve, tolerant of tokens that return no data.
function _approve(address token, address spender, uint256 amount) private {
(bool ok, bytes memory data) = token.call(
abi.encodeWithSignature("approve(address,uint256)", spender, amount)
);
require(ok && (data.length == 0 || abi.decode(data, (bool))), "APPROVE_FAIL");
}
function _authorizeUpgrade(address newImplementation) internal view override onlyOwner {
require(newImplementation != address(0), "ZERO_ADDRESS");
require(newImplementation.code.length > 0, "NOT_A_CONTRACT");
}
/// @dev Disabled. This contract holds balances and every function that can move them, `withdraw`
/// and `_authorizeUpgrade` included, is `onlyOwner`. Renouncing would leave them with no
/// recovery path, and it cannot be undone. To hand the contract over, nominate the new owner
/// with `transferOwnership` and have it call `acceptOwnership`; to cancel an unaccepted
/// nomination, call `transferOwnership(address(0))`.
function renounceOwnership() public pure override {
revert("RENOUNCE_DISABLED");
}
}