Documentation

Simulating Chainlink Oracle Arbitrage with PancakeSwap on BuildBear

This guide walks through detecting and executing arbitrage opportunities using Chainlink price feeds and PancakeSwap AMM pricing inside a BuildBear Sandbox.

Arbitrage in DeFi refers to exploiting price differences between markets. In this case, we detect profitable conditions where PancakeSwap's AMM spot price diverges from Chainlink's oracle price.

Arbitrage happens when the price of a token on the AMM (PancakeSwap) differs significantly from the fair market value reported by Chainlink oracles. Traders can profit by buying the cheaper asset and selling the more expensive one. In our BuildBear Sandbox, we simulate these real-world DeFi conditions safely, without using real funds.

BuildBear enables developers to simulate these conditions using Chainlink data feeds and real liquidity pools without real capital exposure.

How to Use

After creating a BuildBear Sandbox

Select the Price Feed

  • Install the plugin and choose a supported price feed, such as ETH/USD, directly from the BuildBear interface. Plugin Installation data feed selection

In this tutorial we are referring to ETH/WBNB Price Feed on BSC Mainnet Chain

Create a Foundry Project

forge init
forge install smartcontractkit/foundry-chainlink-toolkit --no-commit

Setting up foundry.toml file

  • Replace SANDBOX-ID with actual BuildBear Sandbox ID
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
 
remappings = ["@chainlink/=lib/foundry-chainlink-toolkit/src"]
 
[rpc_endpoints]
buildbear = "https://rpc.dev.buildbear.io/SANDBOX-ID"

Configure your .env File

MNEMONIC=""

Main Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
 
import {AggregatorV3Interface} from "@chainlink/interfaces/feeds/AggregatorV3Interface.sol";
import {IPancakePair} from "./interfaces/IPancakePair.sol";
 
interface IPancakeRouter {
    function swapExactTokensForTokens(
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external returns (uint256[] memory amounts);
}
 
interface IERC20 {
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
    function transfer(address recipient, uint256 amount) external returns (bool);
    function approve(address spender, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}
 
contract PancakeReactiveSwapper {
    AggregatorV3Interface public chainlinkFeed;
    IPancakePair public pancakePair;
    IPancakeRouter public pancakeRouter;
    address public tokenIn;
    address public tokenOut;
    address public owner;
 
    uint256 public thresholdBps = 100; // 1%
 
    event ArbitrageOpportunity(int256 percentDiff);
    event SwapExecuted(uint256 amountIn, uint256 amountOut);
 
    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }
 
    constructor(address _feed, address _pair, address _router, address _tokenIn, address _tokenOut) {
        owner = msg.sender;
        chainlinkFeed = AggregatorV3Interface(_feed);
        pancakePair = IPancakePair(_pair);
        pancakeRouter = IPancakeRouter(_router);
        tokenIn = _tokenIn;
        tokenOut = _tokenOut;
    }
 
    function fundContract(uint256 amountIn, uint256 amountOut) external onlyOwner {
        IERC20(tokenIn).transferFrom(owner, address(this), amountIn);
        IERC20(tokenOut).transferFrom(owner, address(this), amountOut);
    }
 
    function withdrawTokens() external onlyOwner {
        uint256 tokenInBalance = IERC20(tokenIn).balanceOf(address(this));
        uint256 tokenOutBalance = IERC20(tokenOut).balanceOf(address(this));
 
        if (tokenInBalance > 0) IERC20(tokenIn).transfer(owner, tokenInBalance);
        if (tokenOutBalance > 0) IERC20(tokenOut).transfer(owner, tokenOutBalance);
    }
 
    function getChainlinkPrice() public view returns (uint256) {
        (, int256 answer,,,) = chainlinkFeed.latestRoundData();
        uint8 decimals = chainlinkFeed.decimals();
        return uint256(answer) * 1e18 / (10 ** decimals);
    }
 
    function getPancakeSpotPrice() public view returns (uint256) {
        (uint112 r0, uint112 r1,) = pancakePair.getReserves();
        address t0 = pancakePair.token0();
        address t1 = pancakePair.token1();
        return (t0 == tokenIn) ? (uint256(r1) * 1e18) / uint256(r0) : (uint256(r0) * 1e18) / uint256(r1);
    }
 
    function getPriceDifferencePercent() public view returns (int256) {
        uint256 oracle = getChainlinkPrice();
        uint256 amm = getPancakeSpotPrice();
        if (oracle > amm) return int256((oracle - amm) * 1e4 / oracle);
        else return -int256((amm - oracle) * 1e4 / oracle);
    }
 
    function executeBidirectionalArbitrageIfProfitable(uint256 amount, uint256 minProfitBps) external onlyOwner {
        int256 diff = getPriceDifferencePercent();
        address[] memory path = new address[](2);
        uint256 minAmountOut;
        uint256[] memory amounts;
 
        if (diff >= int256(minProfitBps)) {
            path[0] = tokenIn;
            path[1] = tokenOut;
            minAmountOut = (amount * getPancakeSpotPrice()) / 1e18;
            minAmountOut = (minAmountOut * 97) / 100;
            amounts = pancakeRouter.swapExactTokensForTokens(amount, minAmountOut, path, address(this), block.timestamp + 300);
        } else if (-diff >= int256(minProfitBps)) {
            path[0] = tokenOut;
            path[1] = tokenIn;
            minAmountOut = (amount * 1e18) / getPancakeSpotPrice();
            minAmountOut = (minAmountOut * 97) / 100;
            amounts = pancakeRouter.swapExactTokensForTokens(amount, minAmountOut, path, address(this), block.timestamp + 300);
        } else {
            revert("No profitable arbitrage opportunity in either direction");
        }
 
        emit ArbitrageOpportunity(diff);
        emit SwapExecuted(amounts[0], amounts[1]);
    }
}

Here is a brief explanation of what each of the functions do:

Explanations

Constructor

Initializes the contract with:

  • Chainlink price feed address
  • PancakeSwap liquidity pair address
  • PancakeSwap Router address
  • Token addresses for the swap
  • Assigns the deployer as the owner

fundContract

  • Allows the owner to fund the contract with specified amounts of tokenIn and tokenOut.
  • Tokens are transferred from the owner's wallet into the contract.

withdrawTokens

  • Lets the owner withdraw any remaining balances of tokenIn and tokenOut from the contract.

getChainlinkPrice

  • Fetches the latest price of the token pair from the Chainlink oracle feed.
  • Adjusts the price to 18 decimals for consistency.

getPancakeSpotPrice

  • Retrieves the current AMM spot price directly from the PancakeSwap liquidity pool.
  • Calculates based on reserve balances of tokenIn and tokenOut.

getPriceDifferencePercent

  • Calculates the difference between Chainlink oracle price and PancakeSwap spot price.
  • Returns the difference as a percentage (in Basis Points, BPS).

executeBidirectionalArbitrageIfProfitable

  • Checks if the price difference exceeds the minimum profit threshold.
  • Executes a swap either from tokenIn -> tokenOut or tokenOut -> tokenIn, depending on the arbitrage direction.
  • Emits events after successful swaps.

Test File (Testing Price Changes and Arbitrage Execution)

This Foundry test script demonstrates how price manipulation and reserve imbalance can create arbitrage opportunities.

In real-world DeFi markets, differences often arise between prices quoted by liquidity pools (such as PancakeSwap reserves) and independent Chainlink oracle feeds for the same token pair.
These discrepancies present arbitrage opportunities.

In this test, we synthetically create such a scenario by manipulating AMM reserves while still referencing live Chainlink oracle prices.
The script closely mirrors real-world behavior, ensuring that when deployed, it accurately reflects how arbitrage detection and execution would perform on a live mainnet fork.

Why the Test Works

  • In decentralized finance, AMM prices shift based on liquidity ratios.
  • Chainlink oracles provide independent, slower-updating "fair market" prices.
  • After manipulating AMM reserves, an imbalance is created where:
    • The PancakeSwap price diverges from the Chainlink price.
  • The PancakeReactiveSwapper detects this divergence.
  • If the price gap crosses a profit threshold (e.g., 1%), it triggers a profitable swap.
  • The system thus tests both detection and execution of arbitrage strategies under safe, simulated conditions.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
 
import "forge-std/Test.sol";
import {PancakeReactiveSwapper} from "../src/PancakeReactiveSwapper.sol";
 
interface IERC20 {
    function approve(address spender, uint256 amount) external returns (bool);
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}
 
interface IPancakeRouter {
    function swapExactTokensForTokens(
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external returns (uint256[] memory amounts);
}
 
contract PancakeReactiveSwapperTest is Test {
    PancakeReactiveSwapper public swapper;
    address private owner;
    address private manipulator;
 
    address constant feed = 0xD5c40f5144848Bd4EF08a9605d860e727b991513;
    address constant pair = 0x74E4716E431f45807DCF19f284c7aA99F18a4fbc;
    address constant router = 0x10ED43C718714eb63d5aA57B78B54704E256024E;
    address constant tokenIn = 0x2170Ed0880ac9A755fd29B2688956BD959F933F8;
    address constant tokenOut = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c;
 
    function setUp() public {
        string memory mnemonic = vm.envString("MNEMONIC");
        (owner,) = deriveRememberKey(mnemonic, 0);
 
        manipulator = makeAddr("manipulator");
        vm.label(manipulator, "Manipulator");
 
        vm.startPrank(owner);
        swapper = new PancakeReactiveSwapper(feed, pair, router, tokenIn, tokenOut);
 
        IERC20(tokenIn).approve(address(swapper), type(uint256).max);
        IERC20(tokenOut).approve(address(swapper), type(uint256).max);
        IERC20(tokenIn).approve(router, type(uint256).max);
        IERC20(tokenOut).approve(router, type(uint256).max);
 
        swapper.fundContract(5 ether, 5 ether);
        vm.stopPrank();
    }
 
    function testArbitrageAfterAMMPriceManipulation() public {
        manipulatePriceViaSwap();
 
        uint256 oraclePrice = swapper.getChainlinkPrice();
        uint256 ammPrice = swapper.getPancakeSpotPrice();
        int256 diff = swapper.getPriceDifferencePercent();
 
        emit log_named_uint("Oracle Price", oraclePrice);
        emit log_named_uint("Pancake Price", ammPrice);
        emit log_named_int("Price Difference (BPS)", diff);
 
        vm.startPrank(owner);
        try swapper.executeBidirectionalArbitrageIfProfitable(1 ether, 100) {
            emit log("Swap executed");
            swapper.withdrawTokens();
        } catch {
            emit log("No arbitrage opportunity");
        }
        vm.stopPrank();
    }
 
    function manipulatePriceViaSwap() internal {
        deal(tokenOut, manipulator, 2 ether);
        address[] memory path = new address[](2);
 
        vm.startPrank(manipulator);
        IERC20(tokenOut).approve(router, 2 ether);
 
        path[0] = tokenOut;
        path[1] = tokenIn;
 
        IPancakeRouter(router).swapExactTokensForTokens(
            2 ether,
            0,
            path,
            manipulator,
            block.timestamp + 300
        );
 
        vm.stopPrank();
    }
}

Explanation

setUp

  • Prepares the test environment:
    • Deploys the PancakeReactiveSwapper contract.
    • Approves both tokens for unlimited usage (router and swapper).
    • Funds the swapper with initial balances of both tokens.
    • Sets up two roles: owner and manipulator for simulating price changes.

testArbitrageAfterAMMPriceManipulation

  • Simulates a price manipulation on PancakeSwap using a secondary actor (manipulator).
  • Reads oracle price, AMM price, and calculates price difference.
  • Attempts arbitrage execution:
    • If profitable, executes a token swap through the swapper.
    • If not profitable, skips swap with a revert message.
  • Logs prices and outcomes for verification.

manipulatePriceViaSwap

  • Simulates a real-world DeFi scenario by deliberately skewing the PancakeSwap liquidity pool:
    • Transfers a large amount of tokenOut (WBNB) into the liquidity pool.
    • This manipulation creates an artificial price gap between AMM and oracle.

Executing the Test Script

forge test --fork-url buildbear

Sample Outputs

Successful Arbitrage

[PASS] testArbitrageAfterAMMPriceManipulation() (gas: 412381)
Logs:
  Oracle Price: 1672738736919617
  Pancake Price: 1324000000000000000
  Price Difference (BPS): 2083674
  Swap executed

No Opportunity Detected

[PASS] testArbitrageAfterAMMPriceManipulation() (gas: 395289)
Logs:
  Oracle Price: 1672738736919617
  Pancake Price: 2941435311968307472
  Price Difference (BPS): -17574547
  No arbitrage opportunity

Deploying the Arbitrage Contract

The deployment contract is similar to the test contract, but without any liquidity manipulation.
It allows you to deploy the PancakeReactiveSwapper and monitor for real arbitrage opportunities on your BuildBear sandbox, leveraging live mainnet fork data.

Whenever the price divergence between the Chainlink oracle and PancakeSwap AMM becomes profitable based on your configured threshold, the contract can autonomously execute swaps to capture the arbitrage.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
 
import "forge-std/Script.sol";
import {PancakeReactiveSwapper} from "../src/PancakeReactiveSwapper.sol";
 
interface IERC20 {
    function approve(address spender, uint256 amount) external returns (bool);
    function transfer(address to, uint256 amount) external returns (bool);
}
 
contract DeployAndTestSwapper is Script {
    function run() external {
        string memory mnemonic = vm.envString("MNEMONIC");
        (address deployer, uint256 deployerPrivateKey) = deriveRememberKey(mnemonic, 0);
 
        vm.startBroadcast(deployerPrivateKey);
 
        address feed = 0xD5c40f5144848Bd4EF08a9605d860e727b991513;
        address pair = 0x74E4716E431f45807DCF19f284c7aA99F18a4fbc;
        address router = 0x10ED43C718714eb63d5aA57B78B54704E256024E;
        address tokenIn = 0x2170Ed0880ac9A755fd29B2688956BD959F933F8;
        address tokenOut = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c;
 
        PancakeReactiveSwapper swapper = new PancakeReactiveSwapper(feed, pair, router, tokenIn, tokenOut);
        console.log("Swapper deployed at:", address(swapper));
 
        IERC20(tokenIn).approve(address(swapper), type(uint256).max);
        IERC20(tokenOut).approve(address(swapper), type(uint256).max);
        IERC20(tokenIn).approve(router, type(uint256).max);
        IERC20(tokenOut).approve(router, type(uint256).max);
 
        uint256 fundAmountIn = 5 ether;
        uint256 fundAmountOut = 5 ether;
        swapper.fundContract(fundAmountIn, fundAmountOut);
 
        tryExecuteBidirectionalSwap(swapper, 1 ether, 100); // 1% profit threshold
 
        vm.stopBroadcast();
    }
 
    function tryExecuteBidirectionalSwap(PancakeReactiveSwapper swapper, uint256 amountIn, uint256 minProfitBps)
        internal
    {
        try swapper.executeBidirectionalArbitrageIfProfitable(amountIn, minProfitBps) {
            console.log("Arbitrage swap executed");
            swapper.withdrawTokens();
        } catch {
            console.log("No profitable arbitrage opportunity");
        }
    }
}

To deploy the script execute the following command:

forge script script/ScriptName.s.sol --rpc-url buildbear --broadcast

You will need to create a cron job or any other automated script to call the reactive swapper. It's because the opportunities to have a profitable arbitrage are rare and may or may not work manually.

Setting Up Cron Job to Call the Arbitrage function

This script will execute the call to executeBidirectionalArbitrageIfProfitable every 30 secs to check for price changes and arbitrage opportunities and execute the swap if it is profitable

// reactive-arbitrage-bot.js
 
const { ethers } = require('ethers');
const cron = require('node-cron');
 
// ——— Configuration (hard-coded) ———
const RPC_URL             = 'BuildBear Sandbox URL HERE';             // 👈 replace with your Sandbox URL
const PRIVATE_KEY         = '0xYOUR_PRIVATE_KEY_HERE';                // 👈 replace with your key
const SWAPPER_ADDRESS     = '0xYourDeployedSwapperAddressHere';       // 👈 replace with your deployed PancakeReactiveSwapper
 
const AMOUNT_IN           = ethers.utils.parseUnits('10', 18);      // tokenIn amount
const MIN_PROFIT_BPS      = 100;                                     // 1% threshold
 
// ABI for just the one function we need
const SWAPPER_ABI = [
"function executeBidirectionalArbitrageIfProfitable(uint256 amount, uint256 minProfitBps) external"
];
 
const provider = new ethers.providers.JsonRpcProvider(RPC_URL);
const wallet   = new ethers.Wallet(PRIVATE_KEY, provider);
const swapper  = new ethers.Contract(SWAPPER_ADDRESS, SWAPPER_ABI, wallet);
 
async function attemptArb() {
    const ts = new Date().toISOString();
    console.log(`${ts}  Checking arbitrage… `);
    try {
        const tx = await swapper.executeBidirectionalArbitrageIfProfitable(
            AMOUNT_IN,
            MIN_PROFIT_BPS
        );
        console.log(`tx sent ${tx.hash}\n`);
        const receipt = await tx.wait();
        console.log(`${ts}  ✅ Swap executed in block ${receipt.blockNumber}`);
    } catch (err) {
        const msg = err.error?.message || err.message;
        if (msg.includes("No profitable arbitrage")) {
            console.log("❌ No arbitrage opportunity");
        } else {
            console.error(`❗ Error: ${msg}`);
        }
    }
}
 
console.log("🔔 ReactiveSwapper bot started, polling every 30 seconds");
cron.schedule("*/30 * * * * *", attemptArb);

Once you run this script, the cron job will run every 30 secs checking for profitable arbitrage opportunities and execute a swap whenever there is one

🔔 ReactiveSwapper bot started, polling every 30 seconds
2025-04-30T14:23:17.582Z  Checking arbitrage… 
tx sent 0x9f8e3b2c5a1d4e6f7b8c9d0a1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f

2025-04-30T14:23:32.764Z  ✅ Swap executed in block 22766010

2025-04-30T14:23:47.201Z  Checking arbitrage… 
❌ No arbitrage opportunity

2025-04-30T14:24:07.418Z  Checking arbitrage… 
❌ No arbitrage opportunity

Summary

  • Live Oracle Integration
    Leverages production-grade Chainlink price feeds alongside PancakeSwap AMM pools for real-time price data.

  • Fully Sandboxed
    Executes the entire arbitrage lifecycle in BuildBear’s safe environment—no real capital at risk.

  • End-to-End Automation
    Covers opportunity detection, simulation, and on-chain execution of arbitrage trades with a single reactive contract.

  • Built with Chainlink & Foundry
    Utilizes the Chainlink Foundry toolkit for data feeds and the Foundry test framework for robust, forked-mainnet testing.

  • MEV & DeFi Blueprint
    Provides a clear path for architecting reactive DeFi bots and MEV strategies in a controlled sandbox.