Build, Test and Deploy: A smart contract for handling ERC20 and native token payments with whitelisting capabilities on RSK Test Network.


Hello smart contract maxis, welcome back to yet another smart contract tutorial. This guide will walk you through creating a robust payment handler smart contract using Foundry that supports both ERC20 and native token RBTC, or any payments with whitelisting functionality on the RSK Testnet. Foundry is a modern, fast Ethereum development toolkit written in Rust that's gaining popularity for its efficiency and powerful features. We will build an entire smart contract from scratch, then we will test it, and after writing comprehensive test suites, we will deploy it on the RSK test network. So let’s get started, waiitttt, before moving ahead, let me give you a brief intro of the Rootstock network.
Introduction to Rootstock (RSK)
Rootstock (RSK) is a smart contract platform built on top of the Bitcoin blockchain, combining Bitcoin’s security with Ethereum-compatible smart contract functionality. It enables decentralised applications, DeFi protocols, and tokenisation while leveraging Bitcoin’s robust Proof-of-Work (PoW) security through merge-mining.
Well, now you know what the RSK network is, now let’s jump into development and start building our contract.
Prerequisites
Basic Knowledge of Solidity: Familiarity with smart contract development.
Node.js and npm: Ensure you have Node.js and npm installed on your machine.
Foundry: Install Foundry by following the instructions
Rootstock Wallet: Set up a wallet compatible with the RSK network (e.g., MetaMask).
Testnet RSK Funds: Acquire some Testnet RSK funds from the RSK faucet.
Set Up Your Development Environment
For smart contract development, we are going to use Foundry - a blazing-fast smart contract development tool. Go ahead and create a new Foundry project in a fresh new directory. (Make sure you installed Foundry in your system using the above link.)
Let’s create a fresh new directory and name it Payment_contract, and open this directory in VS Code, then open your VS Code terminal and run the following command to initialise our Payment_contract foundry project.
forge init
Then go ahead and delete counter.sol
from src
directory, counter.t.sol
from the test
directory and counter.s.sol
from script
directory. We will build our files from scratch. Now we need to install the OpenZeppelin libraries.
What is the OpenZeppelin Library?
OpenZeppelin Contracts is a widely used library of secure, battle-tested smart contracts for Ethereum and other EVM-compatible blockchains. It provides developers with standardised implementations of common blockchain functionality like ERC20 and ERC721 tokens, access control mechanisms, and governance tools, all thoroughly audited and maintained by blockchain security experts. By offering these reusable components, OpenZeppelin allows developers to build on established security patterns rather than writing everything from scratch, significantly reducing the risk of vulnerabilities that could lead to costly hacks or exploits.
Here are the docs if you want to explore more - https://docs.openzeppelin.com/contracts/5.x/
GitHub - https://github.com/OpenZeppelin/openzeppelin-contracts
Now let’s go ahead and install OpenZeppelin’s contracts library in our project. To install, run the following command.
forge install OpenZeppelin/openzeppelin-contracts
Amazing, you just installed the OpenZeppelin library. We need to do one important task before moving ahead and building our smart contract, and that is we have to set up our remappings.
What is this? Well, in Foundry, "remappings" are a configuration feature that simplifies import paths in your Solidity code. They provide shortcuts for referencing external libraries and contracts, making your import statements cleaner and more maintainable. After all, we're gonna do it like a professional smart contract developer. So follow along with me. To set our remappings, run the following command.
forge remappings >> remappings.txt
Writing Payment Contract
Now open your src
directory and create a new file named Payment.sol
and paste the following code in this file. I have explained the contract code in the comments.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// warning ⚠️ - don't use this code for production deployments
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/// @title Payment System Smart Contract
/// @notice A secure and flexible payment system that supports both ERC20 tokens and native payments
/// @dev Implements reentrancy protection and uses OpenZeppelin's SafeERC20 for secure token transfers
contract Payment is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
/// @notice Mapping to track whitelisted tokens
/// @dev Maps token address to boolean indicating if it's whitelisted
mapping(address => bool) public whiteListedTokens;
/// @notice Emitted when a token is registered or unregistered
/// @param token The address of the token being registered
/// @param enabled Whether the token is being enabled or disabled
event TokenRegistered(address indexed token, bool enabled);
/// @notice Emitted when a payment is processed
/// @param token The address of the token used for payment (address(0) for ETH)
/// @param from The address of the payer
/// @param amount The amount of tokens/ETH paid
/// @param userId The unique identifier of the user making the payment
/// @param productId The unique identifier of the product being purchased
event PaymentEvent(address indexed token, address indexed from, uint256 amount, string userId, string productId);
/// @notice Emitted when payments are claimed by the owner
/// @param token The address of the token being claimed (address(0) for ETH)
/// @param amount The amount of tokens/ETH claimed
event PaymentsClaimed(address indexed token, uint256 amount);
/// @notice Error thrown when attempting to use a non-whitelisted token
error TokenNotWhiteListed();
/// @notice Error thrown when attempting to make a payment with zero amount
error ZeroAmount();
/// @notice Error thrown when attempting to claim more than the contract balance
error InSufficientBalance();
/// @notice Error thrown when a transfer operation fails
error TransferFailed();
/// @notice Constructor initializes the contract with the deployer as owner
constructor() Ownable(msg.sender) {}
/// @notice Registers or unregisters a single token
/// @dev Only callable by the contract owner
/// @param token The address of the token to register
/// @param enabled Whether to enable or disable the token
function registerToken(address token, bool enabled) external onlyOwner {
whiteListedTokens[token] = enabled;
emit TokenRegistered(token, enabled);
}
/// @notice Processes an ERC20 token payment
/// @dev Implements reentrancy protection and uses SafeERC20 for transfers
/// @param token The address of the ERC20 token to use for payment
/// @param amount The amount of tokens to pay
/// @param userId The unique identifier of the user making the payment
/// @param productId The unique identifier of the product being purchased
function payERC20(address token, uint256 amount, string calldata userId, string calldata productId)
external
nonReentrant
{
if (!whiteListedTokens[token]) revert TokenNotWhiteListed();
if (amount == 0) revert ZeroAmount();
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
emit PaymentEvent(token, msg.sender, amount, userId, productId);
}
/// @notice Processes a native ETH payment
/// @dev Implements reentrancy protection
/// @param userId The unique identifier of the user making the payment
/// @param productId The unique identifier of the product being purchased
function payNative(string calldata userId, string calldata productId) external payable nonReentrant {
if (!whiteListedTokens[address(0)]) revert TokenNotWhiteListed();
if (msg.value == 0) revert ZeroAmount();
emit PaymentEvent(address(0), msg.sender, msg.value, userId, productId);
}
/// @notice Claims collected payments (tokens or ETH)
/// @dev Only callable by the contract owner, implements reentrancy protection
/// @param token The address of the token to claim (address(0) for ETH)
/// @param amount The amount of tokens/ETH to claim
function claimPayments(address token, uint256 amount) external onlyOwner nonReentrant {
if (token == address(0)) {
if (amount > address(this).balance) revert InSufficientBalance();
(bool success,) = owner().call{value: amount}("");
if (!success) revert TransferFailed();
} else {
uint256 contractBalance = IERC20(token).balanceOf(address(this));
if (amount > contractBalance) revert InSufficientBalance();
IERC20(token).safeTransfer(owner(), amount);
}
emit PaymentsClaimed(token, amount);
}
/// @notice Allows the contract to receive ETH
receive() external payable {}
/// @notice Gets the contract's balance for a specific token
/// @param token The address of the token to check (address(0) for ETH)
/// @return balance The current balance of the specified token
function getContractBalance(address token) external view returns (uint256 balance) {
if (token == address(0)) {
return address(this).balance;
} else {
return IERC20(token).balanceOf(address(this));
}
}
/// @notice Registers or unregisters multiple tokens in a single transaction
/// @dev Only callable by the contract owner
/// @param tokens Array of token addresses to register
/// @param values Array of boolean values indicating whether to enable or disable each token
function batchRegisterTokens(address[] calldata tokens, bool[] calldata values) external onlyOwner {
uint256 length = tokens.length;
require(length == values.length, "length mismatched");
for (uint256 i = 0; i < length; ++i) {
whiteListedTokens[tokens[i]] = values[i];
emit TokenRegistered(tokens[i], values[i]);
}
}
}
Great! Now let’s compile our contract and check if there are errors. RUN the following command to compile our payment contract.
forge build
You will see that your contracts are successfully compiled.
Now, let’s test our contract and check that it is behaving as we expect. Let’s go ahead and create a new directory in test
folder named mock
, and within it, create a new file MockERC20.sol
. Go to outside mock
folder and create a new file in test
folder and name it PaymentTest.t.sol
.
We're going to create a mock ERC20 contract for testing purposes. In MockERC20.sol
, paste the following code.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
This is just an ERC20 smart contract we need in order to test our main Payment smart contract. Well, now go back to your PaymentTest.t.sol
file and paste the following code.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {Payment} from "../src/Payment.sol";
import {MockERC20} from "./mock/MockERC20.sol";
contract PaymentTest is Test {
Payment public paymentContract;
MockERC20 public mockToken;
MockERC20 public mockToken2;
address owner = address(1);
address user = address(2);
uint256 initialBalance = 1000 ether;
event PaymentEvent(address indexed token, address indexed from, uint256 amount, string userId, string productId);
event TokenRegistered(address indexed token, bool enabled);
event PaymentsClaimed(address indexed token, uint256 amount);
function setUp() public {
// Set up the owner with ETH
vm.deal(owner, initialBalance);
vm.deal(user, initialBalance);
// Deploy contracts
vm.prank(owner);
paymentContract = new Payment();
// Deploy mock ERC20 tokens
mockToken = new MockERC20("Mock Token", "MTK");
mockToken2 = new MockERC20("Mock Token 2", "MTK2");
// Mint tokens to the user
mockToken.mint(user, initialBalance);
mockToken2.mint(user, initialBalance);
}
function testRegisterToken() public {
vm.expectEmit(true, false, false, false);
emit TokenRegistered(address(mockToken), true);
// Register the token as the owner
vm.prank(owner);
paymentContract.registerToken(address(mockToken), true);
// Check if the token is whitelisted
bool isWhitelisted = paymentContract.whiteListedTokens(address(mockToken));
assertTrue(isWhitelisted, "Token should be whitelisted");
}
function testBatchRegisterTokens() public {
address[] memory tokens = new address[](2);
tokens[0] = address(mockToken);
tokens[1] = address(mockToken2);
bool[] memory values = new bool[](tokens.length);
for (uint256 i = 0; i < tokens.length; i++) {
values[i] = true;
}
// Register the tokens as the owner
vm.prank(owner);
paymentContract.batchRegisterTokens(tokens, values);
// Check if the tokens are whitelisted
bool isToken1Whitelisted = paymentContract.whiteListedTokens(address(mockToken));
bool isToken2Whitelisted = paymentContract.whiteListedTokens(address(mockToken2));
assertTrue(isToken1Whitelisted, "Token1 should be whitelisted");
assertTrue(isToken2Whitelisted, "Token2 should be whitelisted");
}
function testRegisterTokenNotOwner() public {
// Try to register the token as a non-owner
vm.prank(user);
vm.expectRevert();
paymentContract.registerToken(address(mockToken), true);
}
function testPayErc20() public {
// Register the token
vm.prank(owner);
paymentContract.registerToken(address(mockToken), true);
// Approve the payment contract to spend user's tokens
vm.prank(user);
mockToken.approve(address(paymentContract), 100 ether);
// Expect the PaymentEvent to be emitted
vm.expectEmit(true, true, true, true);
emit PaymentEvent(address(mockToken), user, 100 ether, "user123", "product456");
// Make the payment
vm.prank(user);
paymentContract.payERC20(address(mockToken), 100 ether, "user123", "product456");
// Check balances
assertEq(mockToken.balanceOf(address(paymentContract)), 100 ether, "Contract should have received tokens");
assertEq(mockToken.balanceOf(user), initialBalance - 100 ether, "User's balance should have decreased");
}
function testPayErc20TokenNotWhitelisted() public {
// Approve the payment contract to spend user's tokens
vm.prank(user);
mockToken.approve(address(paymentContract), 100 ether);
// Try to make payment with non-whitelisted token
vm.prank(user);
vm.expectRevert();
paymentContract.payERC20(address(mockToken), 100 ether, "user123", "product456");
}
function testPayErc20ZeroAmount() public {
// Register the token
vm.prank(owner);
paymentContract.registerToken(address(mockToken), true);
// Try to make payment with zero amount
vm.prank(user);
vm.expectRevert();
paymentContract.payERC20(address(mockToken), 0, "user123", "product456");
}
function testPayNative() public {
// Register native token (address(0))
vm.prank(owner);
paymentContract.registerToken(address(0), true);
// Expect the PaymentEvent to be emitted
vm.expectEmit(true, true, true, true);
emit PaymentEvent(address(0), user, 100 ether, "user123", "product456");
// Make the payment
vm.prank(user);
paymentContract.payNative{value: 100 ether}("user123", "product456");
// Check balances
assertEq(address(paymentContract).balance, 100 ether, "Contract should have received ETH");
}
function testPayNativeTokenNotWhitelisted() public {
// Try to make payment with non-whitelisted native token
vm.prank(user);
vm.expectRevert();
paymentContract.payNative{value: 100 ether}("user123", "product456");
}
function testPayNativeZeroAmount() public {
// Register native token
vm.prank(owner);
paymentContract.registerToken(address(0), true);
// Try to make payment with zero amount
vm.prank(user);
vm.expectRevert();
paymentContract.payNative{value: 0}("user123", "product456");
}
function testClaimPaymentsErc20() public {
// Register the token and make a payment
vm.startPrank(owner);
paymentContract.registerToken(address(mockToken), true);
vm.stopPrank();
vm.startPrank(user);
mockToken.approve(address(paymentContract), 100 ether);
paymentContract.payERC20(address(mockToken), 100 ether, "user123", "product456");
vm.stopPrank();
// Check initial balances
assertEq(mockToken.balanceOf(address(paymentContract)), 100 ether, "Contract should have tokens");
assertEq(mockToken.balanceOf(owner), 0, "Owner should have no tokens initially");
// Expect PaymentsClaimed event
vm.expectEmit(true, true, false, true);
emit PaymentsClaimed(address(mockToken), 100 ether);
// Claim payments
vm.prank(owner);
paymentContract.claimPayments(address(mockToken), 100 ether);
// Check final balances
assertEq(mockToken.balanceOf(address(paymentContract)), 0, "Contract should have no tokens left");
assertEq(mockToken.balanceOf(owner), 100 ether, "Owner should have received tokens");
}
function testClaimPaymentsNative() public {
// Register native token and make a payment
vm.prank(owner);
paymentContract.registerToken(address(0), true);
vm.prank(user);
paymentContract.payNative{value: 100 ether}("user123", "product456");
// Check initial balances
assertEq(address(paymentContract).balance, 100 ether, "Contract should have ETH");
uint256 ownerInitialBalance = address(owner).balance;
// Expect PaymentsClaimed event
vm.expectEmit(true, true, false, true);
emit PaymentsClaimed(address(0), 100 ether);
// Claim payments
vm.prank(owner);
paymentContract.claimPayments(address(0), 100 ether);
// Check final balances
assertEq(address(paymentContract).balance, 0, "Contract should have no ETH left");
assertEq(address(owner).balance, ownerInitialBalance + 100 ether, "Owner should have received ETH");
}
function testClaimPaymentsNotOwner() public {
// Register the token and make a payment
vm.prank(owner);
paymentContract.registerToken(address(mockToken), true);
vm.prank(user);
mockToken.approve(address(paymentContract), 100 ether);
vm.prank(user);
paymentContract.payERC20(address(mockToken), 100 ether, "user123", "product456");
// Try to claim payments as non-owner
vm.prank(user);
vm.expectRevert();
paymentContract.claimPayments(address(mockToken), 100 ether);
}
function testClaimPaymentsInsufficientBalance() public {
// Register the token and make a payment
vm.prank(owner);
paymentContract.registerToken(address(mockToken), true);
vm.prank(user);
mockToken.approve(address(paymentContract), 100 ether);
vm.prank(user);
paymentContract.payERC20(address(mockToken), 100 ether, "user123", "product456");
// Try to claim more than available
vm.prank(owner);
vm.expectRevert();
paymentContract.claimPayments(address(mockToken), 200 ether);
}
function testGetContractBalance() public {
// Register the token and make a payment
vm.prank(owner);
paymentContract.registerToken(address(mockToken), true);
vm.prank(user);
mockToken.approve(address(paymentContract), 100 ether);
vm.prank(user);
paymentContract.payERC20(address(mockToken), 100 ether, "user123", "product456");
// Check the balance using the getter
uint256 balance = paymentContract.getContractBalance(address(mockToken));
assertEq(balance, 100 ether, "Contract balance should match");
}
}
And now run the following command to check whether our tests pass.
forge test
You’d see something like this in your terminal with all green passes!
Ohhoooo! All your 14 test cases passed. Now we need to write a deployment script to deploy our contract on the RSK network. Let’s go ahead and create a new file DeployPayment.s.sol
, inside your script
directory and paste the following script written in Solidity.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console} from "forge-std/Script.sol";
import {Payment} from "../src/Payment.sol";
contract DeployPayment is Script {
function run() external {
vm.startBroadcast();
Payment payment = new Payment();
payment.registerToken(address(0), true);
vm.stopBroadcast();
console.log("Payment deployed at", address(payment));
}
}
Great! So far… we have built a Payment smart contract, tested it, and written a deploy script, and now we are almost ready to deploy our contract on the RSK network, but before going ahead, we need to do some configuration in our foundry.toml
file. for that, open your toml file and paste the following configuration in it.
Your foundry.toml file should look like this.
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc="0.8.20"
evm_version = "london"
[rpc_endpoints]
rsk_testnet="https://public-node.testnet.rsk.co"
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
Here we set our Solidity compiler version, EVM version, and RPC endpoints of the RSK test network. Here you can set API keys and some remappings as well, in fact, all configurations.
All set! Now we are finally ready to deploy your contract on the Rootstock network. Ensure you have an RSK wallet set up and funded with the RSK tokens for transaction fees.
To add the RSK network to your wallet, follow the link: https://dev.rootstock.io/dev-tools/wallets/metamask/
To get Rootstock faucets, follow the link: https://faucet.rootstock.io/
Make sure you set up your foundry wallet - import a private key into an encrypted keystore.
To set up. Follow this link - https://book.getfoundry.sh/reference/cast/cast-wallet-import
We are avoiding here .env
file to store private keys to follow best security practices.
To deploy our contract, go ahead and open your terminal (Make sure you are in the Payment_contract directory) and run the following command.
forge script script/DeployPayment.s.sol:DeployPayment --rpc-url rsk_testnet --account YOUR_KEYSTORE_ACCOUNT_NAME --sender YOUR_WALLET_PUBLIC_KEY --broadcast --legacy
It will ask you for the password you set up during the setup of your keystore wallet. Enter the password and wait for a couple of moments, and BOOOOM! You will see in your terminal that your contract is deployed on the RSK test network.
You can see all information related to your deployment in your terminal, like contract address, block, gas used and so on…
Our job ain’t finished yet, we have to check that our contract is indeed deployed on the RSK network. From your terminal, copy your deployed contract address and open your browser and head over to https://explorer.testnet.rootstock.io/ and paste your copied contract address in the search bar, and you can see all the information related to your deployment here on this block explorer.
Congratulations on successfully building, testing, and deploying this great masterpiece on the Rootstock test network.
If you're stuck anywhere, go back to the GitHub repo (link below) and follow the code.
Github Repo - https://github.com/panditdhamdhere/Payment_contracts
Also, don't forget to drop your technical questions and seek help while building on RSK in Rootstock TG and Discord communities. (links below)
Discord: http://discord.gg/rootstock
Telegram: @rskofficialcommunity
Docs: https://dev.rootstock.io/
I hope you have now built it and successfully deployed your contract on the RSK test network. With that, I am wrapping this up and will see you in another exciting smart contract development tutorial.
Subscribe to my newsletter
Read articles from Pandit Dhamdhere directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Pandit Dhamdhere
Pandit Dhamdhere
01101101 01100001 01100100 01100101 00100000 01111001 01101111 01110101 00100000 01101100 01101111 01101111 01101011