Create a game integrated payment system with ERC-20 tokens using Ethereum
Published on 21 September 2024 | By Richard Hao
Welcome readers!
We're going to a go through the architecture and code for a simple payment system
based on any EVM supported blockchain, which can be integrated into your game or
applications server side.
This method is relatively gas cheap as we only process deposits and withdrawals on chain, while handling real balances internally.
Some assumptions to make are that:
Your server/application is the authority of the payments.
Your server has authentication and authorization controls.
A brief overview
We require a few components for this system.
A PaymentHandler smart contract
Web3 RPC Provider which supports your chosen chain(s) such as Infura or Alchemy
Your game/application server which has Websocket support
PaymentHandler Smart Contract
Let's go through the simple contract first. Do make variations and changes to support your specific requirements.
For development and deployment of Solidity contracts, I recommend using the Foundry suite.
solidity
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;import "@openzeppelin/contracts/token/ERC20/IERC20.sol";import "./Roles.sol";contract PaymentHandler is CustomRoles { // Token address used for user payments. IERC20 public token; // Paused status in case you need to prevent deposits and/or withdrawals. // Note: Separated deposit and withdrawals pauses may also present a better user experience. bool private paused; // Events which our server will be requiring to update internal state. event Deposit(address indexed user, uint256 amount); event Withdraw(address indexed user, uint256 amount); // Setup the payment token address, as well as the server address constructor(address _tokenAddress, address ownerAddress) CustomRoles(ownerAddress) { token = IERC20(_tokenAddress); paused = true; } // Requires the user's approval before hand. // Note: With ERC-20 Permit tokens, you may be able to take the users signature and perform the transfer server side! function deposit(uint256 _amount) external { require(!paused, "Contract is paused"); token.transferFrom(msg.sender, address(this), _amount); emit Deposit(msg.sender, _amount); } // Only the server can withdraw funds. This function should be called from your server. // Note: If you want to withdraw any profitable amounts, create a separate function and roles to handle as such. function withdraw(uint256 _amount, address _userAddress) public onlyRole(SERVER_ROLE) { require(!paused, "Contract is paused"); require(_amount > 0, "Amount must be greater than 0"); require(_userAddress != address(0), "Invalid user address"); require(token.balanceOf(address(this)) >= _amount, "Insufficient contract balance"); token.transfer(_userAddress, _amount); emit Withdraw(_userAddress, _amount); } function pause() public onlyRole(OWNER_ROLE) { paused = true; } function unpause() public onlyRole(OWNER_ROLE) { paused = false; }}
And that's it! The contract is minimal and serves to interface with users
better. Our server will listen for the Deposit and Withdraw events, and
perform the corresponding database actions accordingly.
After you deploy the contract with the parameters you want, make sure to keep the contract address handy!
For this example, we are going to go through a basic server implementation using Nodejs and Typescript.
The Ethereum dev community has fantastic support within the Javascript ecosystem, however the principles should be applicable to any stack.
As a rough outline, these are the steps in your server:
Establish a Websocket connection with your provider. Regular Http also works, but polling can eat up credits.
Setup Event listeners for the Deposit and Withdraw events from your provider.
Record the transactions in your database and update user credits/balance.
Additionally, for user withdrawals you will need to:
Have an endpoint for user withdrawals, this will require some form of auth.
A server wallet with gas, furthermore it is possible to implement signatures to allow the user to perform the withdrawals too.
Handle withdrawal failures. (try in single request, otherwise handle in event handler) upon further failures, scenario is more complex
We will setup a simple a Express server with Nodejs first.
Ensure your database connection is established before doing any updating of course.
We specify the token to capture incase you decide to support multiple tokens.
Let's dive into the specific functions.
The first function getLastBlockChecked is basic but required to track the last time our server synchronized with the chain.
As we are essentially indexing our own contract events, we need to know the last block that was processed.
typescript
// This is some basic code interfacing with MongoDB and Typescript.// For simplicity sake, we will just work with one network.// Extend this to store chain information (separate network id, token address etc.) if you plan on deploying across multiple networks.export async function getLastBlockChecked(token: TokenType) { // Get the last block number from the database const lastBlockChecked = await db .collection<TokenBlockData>("lastBlockChecked") .findOne({ token: token, }); if (lastBlockChecked) { return lastBlockChecked.blockNumber; } else { // find the last block number from the blockchain const oldLastBlockChecked = await db .collection<TokenBlockData>("lastBlockChecked") .findOne({}); return oldLastBlockChecked ? oldLastBlockChecked.blockNumber : 0; }}
Here is the core functionality of handling events using ethers.js and Websockets.
typescript
export async function listenForEvents(token: TokenType, retryCount = 0) { console.log("listening for events..."); // Create new WebsocketProvider. // this is from ethers v5 using Polygon. const provider = new ethers.providers.InfuraWebSocketProvider( "matic", process.env.INFURA_API_KEY ); const contractAddress = TOKEN_DEPOSIT_CONTRACTS[token]; // https://github.com/ethers-io/ethers.js/issues/1053 // The keepAlive function should be implemented yourself. // It is crucial for a long running application as websocket connections will most likely be closed from the provider's end from inactivity, // And thus we need to reconnect and sync any missed transaction. keepAlive({ provider: provider, onDisconnect: async () => { const delay = Math.min(initialDelay * 2 ** retryCount, maxDelay); console.log("Disconnected..."); setTimeout(async () => { let blockNumber = await getLastBlockChecked(token); console.log("checking latest blocks..."); await checkMissedBlocks(token, blockNumber); console.log("listening for transactions..."); listenForEvents(token, retryCount + 1); }, delay); }, }); // Create a contract instance using the address of our deployed PaymentHandler.sol, // the ABI from building in Hardhat/Foundry // WebsocketProvider, alternatively a regular Http Provider also works however listening to events can use up your provider credits easily. const contract = new ethers.Contract( contractAddress, PaymentHandler.abi, provider ); // Get all deposit events from the contract since the last recorded block in the database in case we missed some events due to downtime contract.on("Deposit", async (address, amount, ...params) => { // Process the event here console.log("Event:", address, amount, params); const event = params[params.length - 1]; let txHash = event.transactionHash; console.log("txHash", txHash); // Note: In newer versions of Ethers.js we can just use Javascript's native BigInt type as well. let amountInU256 = ethers.BigNumber.from(amount); try { await updateBalanceWithTransaction( address, amountInU256.toString(), txHash, event.blockNumber, token ); } catch (e) { console.log(e); } }); // Withdraw events contract.on("Withdraw", async (address, amount, ...params) => { // Process the event here console.log("Event:", address, amount, params); // On withdraw events, we check if it was a successful withdraw and update the database accordingly const event = params[params.length - 1]; let txHash = event.transactionHash; console.log("txHash", txHash); let amountInU256 = ethers.BigNumber.from(amount); try { await updateBalanceWithTransactionWithdraw( address, amountInU256.toString(), txHash, event.blockNumber, token ); } catch (e) { console.log(e); } });}
Essentially we have a recursive keepAlive function for our event listening behaviour.
Within the core of the function, we setup the event listeners based on the deployed PaymentHandler.sol contract.
On an event, we process the transaction into our database.
The following is an example for how to handle a deposit using MongoDB.
It is imperative that for whatever database you are using, ensure the reads and writes are atomic. For MongoDB we are using transactions to ensure atomicity.
typescript
export async function updateBalanceWithTransaction( address: string, amount: string, txHash: string, blockNumber: number, token: TokenType) { await client.connect(); // Ensure you handle your session correctly. // Most scenarios can use mongodb's helper of "withTransaction" const session = client.startSession(); const database = client.db(dbEnv); session.startTransaction(); let lower_addr = address.toLowerCase(); let amountInU256 = amount; try { const info: DepositInfo = { txHash: txHash, amount: amountInU256, address: lower_addr, blockNumber: blockNumber, token: token, type: "deposit", }; const accounts = database.collection<AccountInfo>("accounts"); const transactions = database.collection<ServerTransaction>("transactions"); // Fetch the account balance const account = await accounts.findOne( { address: lower_addr }, { session } ); if (!account) { // Save new account address + balance let newAcc: AccountInfo = { address: lower_addr, //initialize to 0 balance: { USDT: "0" }, }; newAcc.balance[token] = amountInU256; await accounts.insertOne(newAcc, { session }); await transactions.insertOne(info, { session }); } else { //check if the txHash is already in the depositHistory let res = await transactions .find({ txHash: txHash }, { session }) .toArray(); if (res.length > 0) { throw new Error("Transaction already processed/exists in db"); } // Check if balance exists first for new tokens // if not, just use 0 and we set it below if (!account.balance[token]) { account.balance[token] = "0"; } // Always work with BigNumber/BigInt let newBalance = ethers.BigNumber.from(account.balance[token]).add( amountInU256 ); const update = "balance." + token; await accounts.updateOne( { address: lower_addr }, { $set: { [update]: newBalance.toString() }, }, { session } ); await transactions.insertOne(info, { session }); //update last block checked const lastBlockChecked = await database .collection<TokenBlockData>("lastBlockChecked") // find for the token .findOne( { token: token, }, { session } ); if (lastBlockChecked) { await database.collection<TokenBlockData>("lastBlockChecked").updateOne( { token: token, }, { $set: { blockNumber: blockNumber } }, { upsert: true, session } ); } else { await database .collection<TokenBlockData>("lastBlockChecked") .insertOne({ blockNumber: blockNumber, token: token }, { session }); } } console.log("Committing deposit transaction..."); await session.commitTransaction(); } catch (error) { console.error("Transaction failed, aborting...", error); await session.abortTransaction(); throw error; } finally { await session.endSession(); await client.close(); }}
In this handler we are performing the following:
Check if this account/address already exists, if not add it to the accounts collection/table.
Increment the account balance using BigNumber/BigInt calculation
As web3 numbers are generally all U256, we need to store it as a string as most databases don't support 256 bit integers natively.
Record the transaction hash in the database
Record the blocknumber
At this point, you could customize any behaviour on deposit according to your game or application. Just be sure to handle them in an atomic matter.
For Withdrawing
Since it's server initiated, check the transaction hash of the Withdraw event against one recorded in your database.
If it hasn't been processed already, set the withdrawal to success.
If your transaction is stuck, you'll need an authoritative method to replace the transaction and cancel it.
This functionality is out of scope for this reading, but ideally you set gas limits which don't take long to process.
You might be wondering where the checkMissedBlocks function is at, well here it is!
This function doesn't check missed withdrawals, but you can simply do the same log querying on the Withdraw event, and I encourage you to try it yourself!
typescript
export async function checkMissedBlocks(token: TokenType, fromBlock?: number) { const contractAddress = TOKEN_DEPOSIT_CONTRACTS[token]; const contract = new ethers.Contract( contractAddress, EscrowABI.abi, infuraProvider ); // Query the provider for the Deposit logs // Note that we should query the block which 'had' already been processed. i.e "fromBlock" is the most recently checked block. // This is incase of some failure previously mid processing, which allows to recover from a block inclusively. // The function should be safe as the transaction hash is always checked before the balance updates. const depositLogs = await contract.queryFilter( contract.filters.Deposit(null, null), fromBlock ); // Process all deposit events for (let i = 0; i < depositLogs.length; i++) { const log = depositLogs[i]; let txHash = log.transactionHash; let amount = log.args![1].toString(); let address = log.args![0]; // Process the event here if (log) { console.log("Event:", log); console.log("txHash", log.transactionHash); // Args are the parameters we supplied to the contract Event in Solidity. console.log("addr:", log.args![0]); console.log("amount:", log.args![1].toString()); // Add these to the database try { await updateBalanceWithTransaction( address.toLowerCase(), amount, txHash, log.blockNumber, token ); } catch (e) { // Note: Consider actually throwing the error as we don't really want to proceed if one of these fails. console.log(e); } } }}
Withdrawing
Withdrawals are somewhat similar in behaviour. It is necessary to implement some form of authentication before calling this function.
The function should do the following:
Check the account/address exists.
Check the amount the user wishes to withdraw is valid and greater than their balance.
Create a pending Withdraw item and store it DB.
Deduct the user's balance before the transaction is even successful.
Important! Commit the transaction at this point.
Send the withdraw transaction
If you wish to retrieve the success status, you may wait for the transaction confirmations in this function.
Alternatively, use an event listeners to do so.
I would recommend some signature verification using nonces + random data generated by your server to authenticate the incoming address.
typescript
export async function handleWithdrawRequestsWithTransactions(address: string,amount: string,token: TokenType) { let userAddress = address.toLowerCase(); await client.connect(); const _session = client.startSession(); const database = client.db(dbEnv); //await _session.withTransaction(async (session) => {}); const accounts = database.collection<AccountInfo>("accounts"); const transactions = database.collection<ServerTransaction>("transactions"); let tx = null; let withdrawId = null; try { await _session.withTransaction(async (session) => { // 1. Check user account and balance const acc = await accounts.findOne({ address: userAddress }, { session }); if (!acc) throw new Error("Account not found"); const userBalance = ethers.BigNumber.from(acc.balance[token] || "0"); if (userBalance.lt(amount)) throw new Error("Insufficient balance"); // 2. Check for existing pending withdrawals const pendingWithdrawal = await transactions.findOne( { address: userAddress, token: token, status: "pending", type: "withdraw", }, { session } ); if (pendingWithdrawal) throw new Error("Pending withdrawal already exists"); // 3. Generate withdrawal ID and create withdrawal record withdrawId = nanoid(14); const withdrawalInfo: WithdrawInfo = { address: userAddress, amount: amount, token: token, status: "pending", withdrawId: withdrawId, txHash: "", type: "withdraw", createdAt: new Date().toISOString(), processedAt: undefined, }; await transactions.insertOne(withdrawalInfo, { session }); // 4. Update user balance const newBalance = userBalance.sub(amount); const update = "balance." + token; await accounts.updateOne( { address: userAddress }, { $set: { [update]: newBalance.toString() } }, { session } ); }); // 5. Initiate on-chain transaction (outside of database transaction) let serverSigner = new ethers.Wallet( process.env.SERVER_PRIVATE_KEY!, infuraProvider ); const { depositAddress } = TokenAddressMap[token]; let BetNFTContract = new ethers.Contract( depositAddress, DepositContract.abi, serverSigner ); const gasPrices = await getPolygonGas(); let txOptions = { maxFeePerGas: gasPrices.maxFeePerGas, maxPriorityFeePerGas: gasPrices.maxFeePerGas, }; console.log("txOptions", txOptions); console.log("Sending transaction..."); tx = await BetNFTContract.withdraw(amount, userAddress, txOptions); // 6. Update transaction record with transaction hash await transactions.updateOne( { withdrawId: withdrawId }, { $set: { txHash: tx.hash, status: "pending", processedAt: new Date(), }, } ); // 7. Wait for transaction confirmation (optional, depending on your requirements) await tx.wait(5); // Wait for 1 confirmation // 8. Update transaction status to completed await transactions.updateOne( { withdrawId: withdrawId }, { $set: { status: "success" } } ); return { success: true, txHash: tx.hash }; } catch (error) { console.error("Withdrawal failed:", error); // If the error occurred during the database transaction, it will automatically roll back // If it occurred during the blockchain transaction, we need to handle it if (withdrawId) { await transactions.updateOne( { withdrawId: withdrawId }, { $set: { status: "failed" } } ); } throw error; } finally { await client.close(); }}// Call this function every time before a contract callasync function getPolygonGas() { try { const { data } = await axios({ method: "get", url: gasStationURL, }); let maxFeePerGas = ethers.utils.parseUnits( Math.ceil(data.fast.maxFee) + "", "gwei" ); let maxPriorityFeePerGas = ethers.utils.parseUnits( Math.ceil(data.fast.maxPriorityFee) + "", "gwei" ); return { maxFeePerGas, maxPriorityFeePerGas, }; } catch (e) { let errMessage = "Failed to get gas price from polygon gas station" + e; throw new Error(errMessage); }}const gasStationURL = "https://gasstation.polygon.technology/v2";
And that is it! Most of the functionality you will have to customize to suit your application requirements, but hope this gives you a general overview of the implementation of a basic payment system using ERC-20 tokens on Ethereum and similar networks!
Some closing statements:
It is crucial to use atomic operations on your database anytime payment/balance related manipulations are involved. It is also a good idea to record all internal transfers if you have those.
This implementation is very minimalistic, low blockchain interactivity and should be considered centralized as business logic is settled internally and not on chain.
However, to save transaction time and costs it can prove to be a better user experience to handle many things internally. If you wish to add more on chain transactions, the PaymentHandler contract can certainly be extended to do so.
With the prevalence of L2's more operations could be moved onto a quick network.
Don't worry if this takes some time to implement, as there are several parts involved.
Feel free to send us a message on Telegram if you have any questions!