Skip to main content

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() returnsWhat it isWhat to do
your walletan initialized proxythis is the address to hand over
the zero addressthe implementation, or a proxy nobody initializedcall owner() on the other address; if both are zero, initialize the proxy now
someone else's addressa proxy somebody else claimeddo 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.

  1. 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.
  2. The contract grants its own destination override — _run does this. An owner wallet granting it instead writes a record the swap never reads.
  3. 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.
  4. 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. renounceOwnership is blocked, and transferOwnership only nominates — nothing changes until the new owner calls acceptOwnership. 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 with transferOwnership(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");
}
}