If you want maximum arbitrage performance, you need to swap tokens between exchanges in a single transaction. Or maybe you just want to save gas on certain swaps you perform regularly. Or maybe you have your own custom use case for swapping between decentralized exchanges. And of course maybe you are just here for the learning aspect.
Whatever your reason may be, MultiSwap is a great way to combine knowledge into one contract. Let's try to do a MultiSwap that looks like this:
So how can we achieve this?

Do it manually first!
First we want to try all the trades manually. And since we are in the testing phase, we will do everything on a testnet which has deployed contracts for each protocol we want to use. In our case this happens to be only on Ropsten.
- If a similar token to what you want to trade doesn't exist on the testnet, simply deploy one yourself via Remix.
- If a token pool on a DEX doesn't exist yet on the testnet, create it yourself.
Ropsten is retired. The manual swaps and addresses below record the original experiment. A new integration requires a compatible deployment of each protocol, suitable test liquidity and matching interfaces; changing the chain name alone is insufficient.
1. Banchor: ETH -> BNT
So first go to the Banchor app and swap your funds on the Ropsten network from ETH to BNT. After swapping click on the Etherscan transaction.

You will easily find the function name and passed parameters in the Etherscan transaction. Keep this in mind and also the documentation always helps.


2. SushiSwap: BNT -> INJ
Then go to the SushiSwap app and swap your tokens from BNT to INJ.
Again note down the function name and parameters from the Etherscan transaction. SushiSwap is based on Uniswap 2, so you will also find more detailed explanations how it works in my previous blog post here.
3. Uniswap: INJ -> DAI
And lastly go to the Uniswap app and swap your tokens from INJ to DAI.
Again note down the function name and parameters from the Etherscan transaction. You will also find more detailed explanations how Uniswap 3 works in my previous blog post here.

Now lets use Solidity.

With the previous information, creating the trade logic is straight-forward. You first trade on Bancor, use the received funds to trade on SushiSwap and again use the received funds to trade on Uniswap.
1. Trading on Bancor
IBancorNetwork private constant bancorNetwork = IBancorNetwork(0xb3fa5DcF7506D146485856439eb5e401E0796B5D);
address private constant BANCOR_ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
address private constant BANCOR_ETHBNT_POOL = 0x1aCE5DD13Ba14CA42695A905526f2ec366720b13;
address private constant BNT = 0xF35cCfbcE1228014F66809EDaFCDB836BFE388f5;
function _tradeOnBancor(uint256 amountIn, uint256 amountOutMin) private returns (uint256) {
return bancorNetwork.convertByPath{value: amountIn}(_getPathForBancor(), amountIn, amountOutMin, address(0), address(0), 0);
}
function _getPathForBancor() private pure returns (address[] memory) {
address[] memory path = new address[](3);
path[0] = BANCOR_ETH_ADDRESS;
path[1] = BANCOR_ETHBNT_POOL;
path[2] = BNT;
return path;
}Our function to trade on Banchor is basically self-explanatory. We obtained the addresses for the path and bancor network from our example transaction.
2. Trading on Sushi
IUniswapV2Router02 private constant sushiRouter = IUniswapV2Router02(0x1b02dA8Cb0d097eB8D57A175b88c7D8b47997506);
address private constant INJ = 0x9108Ab1bb7D054a3C1Cd62329668536f925397e5;
function _tradeOnSushi(uint256 amountIn, uint256 amountOutMin, uint256 deadline) private returns (uint256) {
address recipient = address(this);
uint256[] memory amounts = sushiRouter.swapExactTokensForTokens(
amountIn,
amountOutMin,
_getPathForSushiSwap(),
recipient,
deadline
);
return amounts[amounts.length - 1];
}
function _getPathForSushiSwap() private pure returns (address[] memory) {
address[] memory path = new address[](2);
path[0] = BNT;
path[1] = INJ;
return path;
}Then we can use swapExactTokensForTokens to swap BNT to INJ. The path simply consists of the tokens. We received the router address from our example transaction.
3. Trading on Uniswap
ISwapRouter private constant uniswapRouter = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564);
address private constant DAI = 0xaD6D458402F60fD3Bd25163575031ACDce07538D;
function _tradeOnUniswap(uint256 amountIn, uint256 amountOutMin, uint256 deadline) private returns (uint256) {
address tokenIn = INJ;
address tokenOut = DAI;
uint24 fee = 3000;
address recipient = msg.sender;
uint160 sqrtPriceLimitX96 = 0;
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams(
tokenIn,
tokenOut,
fee,
recipient,
deadline,
amountIn,
amountOutMin,
sqrtPriceLimitX96
);
return uniswapRouter.exactInputSingle(params);
}4. Bringing it all together
We need to approve the SushiSwap contract to use our BNT and the Uniswap contract to use our INJ. It's more gas-efficient to do this only once on the deployment, so put it in your constructor:
The safeApprove constructor below belongs to the original OpenZeppelin 3.4 example. Current OpenZeppelin 5 uses different approval helpers. Unlimited approval is a trust decision about the exact spender, not merely a gas optimization.
constructor() {
IERC20(BNT).safeApprove(address(sushiRouter), type(uint256).max);
IERC20(INJ).safeApprove(address(uniswapRouter), type(uint256).max);
}Now we have everything we need. Let's create a multiSwap function.
function multiSwap(
uint256 deadline,
uint256 amountOutMinBancor,
uint256 amountOutMinSushiSwap,
uint256 amountOutMinUniswap
) external payable returns (uint256) {
require(msg.value > 0 && block.timestamp <= deadline, "Invalid input or expired");
uint256 bntBought = _tradeOnBancor(msg.value, amountOutMinBancor);
uint256 injBought = _tradeOnSushi(bntBought, amountOutMinSushiSwap, deadline);
return _tradeOnUniswap(injBought, amountOutMinUniswap, deadline);
}
The minimum outputs and deadline must come from a deliberate caller policy. A final output bound constrains the completed route, but setting intermediate limits to one is not a universal recommendation. A very distant deadline also extends the time during which an old quote can be executed. An off-chain simulation is only an estimate at its selected state.
// The old public multiSwapPreview implementation is withdrawn.
// It was an executable swap entry point with all minimum outputs set to one.
// Use a read-only RPC simulation of the intended call in the frontend,
// with explicit sender/value/state, and still enforce execution bounds.
eth_call can simulate a state-changing function and discard its changes; that does not make the function view or stop someone sending a real transaction. In ethers v6, the contract method’s staticCall helper performs that RPC simulation. Do not confuse it with the EVM’s state-change-prohibiting STATICCALL opcode.
// ethers v6, for the corrected multiSwap signature above.
const estimate = await myContract.multiSwap.staticCall(
deadline, minBancor, minSushi, minUniswap, { value: ethAmount }
);
// Example arithmetic only: choose slippage policy for your application.
const fourPercentLower = estimate * 9600n / 10000n;
Now we only need one transaction for the whole swap!

The linked repository records the historical Ropsten experiment. A new chain or protocol version needs verified addresses, matching ABIs, token behavior, price bounds and tests of the whole route; it is not an address-only migration.





Join the conversation
Comments are hosted by Disqus and load only when you choose to enable them.