Building a USSD-Based DeFi Application on Rootstock

A USSD-based decentralized finance system on Rootstock enables users with legacy GSM phones to access blockchain-powered financial services without needing smartphones or internet connectivity. By integrating USSD gateways with an off-chain Node.js backend and connecting to Rootstock smart contracts via JSON-RPC endpoints, the system supports peer-to-peer transactions and micro-loans directly from feature phones. This setup includes secure relay flows between USSD, backend servers, and the blockchain, along with architectural patterns and Solidity examples to demonstrate feature-phone DeFi interactions. Such an approach expands financial inclusion, allowing unbanked and underbanked populations to participate in decentralized finance using basic mobile phones.
What is Rootstock?
Rootstock is a smart contract platform that runs on top of Bitcoin. Its main goal is to combine Bitcoin’s security with the flexibility of Ethereum-style smart contracts. Essentially, it makes Bitcoin programmable by allowing developers to create decentralized applications (dApps), decentralized finance (DeFi) platforms, and tokenized assets all secured by Bitcoin’s blockchain.
Rootstock achieves this by using Bitcoin as the base layer for security while providing its own ecosystem for smart contracts, transactions, and tokens like RBTC, which mirrors BTC. This allows developers and users to enjoy Bitcoin’s trustworthiness while accessing modern blockchain functionalities.
Key Features of Rootstock
Bitcoin Security via Merged Mining
Rootstock leverages Bitcoin’s immense mining power through merged mining, allowing Bitcoin miners to secure both the Bitcoin and Rootstock networks simultaneously. This means Rootstock inherits the same high level of security as Bitcoin without additional energy consumption. For users and developers, this provides a trusted and secure environment for deploying smart contracts and handling financial transactions.Ethereum-Compatible Smart Contracts (EVM-Based)
Rootstock supports the Ethereum Virtual Machine (EVM), which allows developers to write smart contracts in Solidity, the same language used on Ethereum. This compatibility enables easy migration of existing Ethereum dApps to Rootstock, giving developers access to Bitcoin’s security while leveraging Ethereum’s rich tooling and developer ecosystem.Two-Way Peg and RBTC
The two-way peg mechanism allows BTC to be locked on the Bitcoin blockchain and represented as RBTC on Rootstock, maintaining a 1:1 value ratio. RBTC acts like Bitcoin on Rootstock but can be used in smart contracts, enabling BTC holders to participate in DeFi applications like lending, borrowing, and trading without leaving the Bitcoin ecosystem.High Throughput and Faster Transactions
With a block time of around 10 seconds, Rootstock confirms transactions much faster than Bitcoin, which averages 10 minutes per block. This speed is essential for decentralized applications, micropayments, and real-time financial operations, making the network more practical for everyday use.Low Transaction Fees
Rootstock’s transaction fees are considerably lower than Ethereum’s and even Bitcoin’s standard transaction fees. This cost-efficiency makes Rootstock ideal for microtransactions, remittances, and other financial applications where high fees would otherwise be prohibitive.Support for Decentralized Finance (DeFi)
By combining smart contract capabilities with BTC interoperability, Rootstock enables a wide range of DeFi applications:Lending and borrowing platforms
Stablecoins backed by assets
Decentralized exchanges (DEXs)
Tokenized assets and NFTs
This feature expands Bitcoin’s utility beyond simple transfers into programmable finance.
Interoperability with Other Blockchains
Rootstock is designed to work alongside Bitcoin and other blockchains, allowing cross-chain asset transfers and communication. This opens the door to multi-chain DeFi strategies, token swaps, and hybrid applications that can benefit from the strengths of multiple networks.Governance and Protocol Upgrades
Rootstock uses a decentralized federation and governance model to manage protocol upgrades and peg operations securely. This ensures transparency, community involvement, and long-term stability of the network while minimizing central points of failure.
Implementation: Smart Contracts, Backend, and Frontend Integration
This comprehensive tutorial will guide you through building a production-ready USSD-first decentralized finance application on Rootstock, featuring wallet registry, P2P transfers, and micro-loans with a complete backend and frontend.
The system architecture consists of a USSD Gateway that routes requests to a Node.js backend (Express), which then communicates with Rootstock blockchain via JSON-RPC. Smart contracts handle on-chain logic including user registry, transfers, loan liquidity and repayment. The backend manages sessions, validation, hashing, and contract calls while a frontend admin panel shows balances, transactions, and permits simulation.
Prerequisites
Before starting, ensure you have:
Node.js 18+
npm
Basic Solidity and Express knowledge
Africa's Talking account (or similar USSD gateway) for live testing
Windows PowerShell for commands shown
Step 1: Project Setup
Initialize the Workspace
Create your project directory and initialize npm:
mkdir ussd-rsk-defi && cd ussd-rsk-defi
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox dotenv ethers @openzeppelin/contracts solidity-coverage hardhat-gas-reporter
Create Hardhat Project
Initialize Hardhat and choose "Create an empty hardhat.config.js":
npx hardhat
Configure Root Package.json
Create your root-level package.json:
{
"name": "ussd-rsk-defi",
"version": "1.0.0",
"description": "USSD-Based Decentralized Finance System on Rootstock",
"main": "index.js",
"scripts": {
"compile": "hardhat compile",
"test": "hardhat test",
"test:coverage": "hardhat coverage",
"deploy:local": "hardhat run scripts/deploy.js --network localhost",
"deploy:testnet": "hardhat run scripts/deploy.js --network rskTestnet",
"deploy:mainnet": "hardhat run scripts/deploy.js --network rskMainnet",
"node": "hardhat node",
"clean": "hardhat clean"
},
"devDependencies": {
"@nomicfoundation/hardhat-toolbox": "^4.0.0",
"@openzeppelin/contracts": "^5.0.1",
"dotenv": "^16.3.1",
"ethers": "^6.9.0",
"hardhat": "^2.19.4",
"hardhat-gas-reporter": "^1.0.9",
"solidity-coverage": "^0.8.5"
}
}
Configure Hardhat
Create hardhat.config.js:
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
module.exports = {
solidity: {
compilers: [
{ version: "0.8.19", settings: { optimizer: { enabled: true, runs: 200 }, viaIR: true } },
{ version: "0.8.20", settings: { optimizer: { enabled: true, runs: 200 }, viaIR: true } }
]
},
networks: {
localhost: { url: "http://127.0.0.1:8545" },
hardhat: { chainId: 31337 },
rskTestnet: {
url: process.env.RSK_TESTNET_RPC || "https://public-node.testnet.rsk.co",
chainId: 31,
gasPrice: 60000000,
accounts: process.env.DEPLOYER_PRIVATE_KEY ? [process.env.DEPLOYER_PRIVATE_KEY] : [],
timeout: 60000
},
rskMainnet: {
url: process.env.RSK_MAINNET_RPC || "https://public-node.rsk.co",
chainId: 30,
gasPrice: 60000000,
accounts: process.env.DEPLOYER_PRIVATE_KEY ? [process.env.DEPLOYER_PRIVATE_KEY] : [],
timeout: 60000
}
},
etherscan: {
apiKey: { rskTestnet: "not-needed", rskMainnet: "not-needed" },
customChains: [
{ network: "rskTestnet", chainId: 31, urls: { apiURL: "https://blockscout.com/rsk/testnet/api", browserURL: "https://explorer.testnet.rsk.co" } },
{ network: "rskMainnet", chainId: 30, urls: { apiURL: "https://blockscout.com/rsk/mainnet/api", browserURL: "https://explorer.rsk.co" } }
]
},
gasReporter: {
enabled: process.env.REPORT_GAS === "true",
currency: "USD",
gasPrice: 60
},
paths: { sources: "./contracts", tests: "./test", cache: "./cache", artifacts: "./artifacts" },
mocha: { timeout: 40000 }
};
Step 2: Smart Contract Development
Create WalletRegistry Contract
Create contracts/WalletRegistry.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// Importing OpenZeppelin contracts for ownership control and reentrancy protection
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract WalletRegistry is Ownable, ReentrancyGuard {
// Mapping phone hash => wallet address
mapping(bytes32 => address) private phoneToWallet;
// Mapping wallet address => phone hash
mapping(address => bytes32) private walletToPhone;
// Mapping phone hash => hashed PIN
mapping(bytes32 => bytes32) private phoneToPinHash;
// Track if a phone has been registered
mapping(bytes32 => bool) private isRegistered;
// Track authorized backend addresses that can perform actions
mapping(address => bool) public authorizedBackends;
uint256 public totalUsers;
// Events to log important actions
event WalletRegistered(bytes32 indexed phoneHash, address indexed wallet, uint256 timestamp);
event WalletUpdated(bytes32 indexed phoneHash, address indexed oldWallet, address indexed newWallet);
event PinUpdated(bytes32 indexed phoneHash, uint256 timestamp);
event BackendAuthorized(address indexed backend);
event BackendRevoked(address indexed backend);
// Custom errors for gas-efficient error handling
error NotAuthorized();
error PhoneAlreadyRegistered();
error WalletAlreadyRegistered();
error InvalidWalletAddress();
error PhoneNotRegistered();
error InvalidPinHash();
// Modifier to restrict functions to authorized backends or contract owner
modifier onlyAuthorized() {
if (!authorizedBackends[msg.sender] && msg.sender != owner()) revert NotAuthorized();
_;
}
// Constructor sets the deployer as the initial owner and authorizes them as a backend
constructor() Ownable(msg.sender) {
authorizedBackends[msg.sender] = true;
emit BackendAuthorized(msg.sender);
}
function registerWallet(bytes32 phoneHash, address wallet, bytes32 pinHash)
external onlyAuthorized nonReentrant
{
if (isRegistered[phoneHash]) revert PhoneAlreadyRegistered();
if (wallet == address(0)) revert InvalidWalletAddress();
if (walletToPhone[wallet] != bytes32(0)) revert WalletAlreadyRegistered();
if (pinHash == bytes32(0)) revert InvalidPinHash();
phoneToWallet[phoneHash] = wallet;
walletToPhone[wallet] = phoneHash;
phoneToPinHash[phoneHash] = pinHash;
isRegistered[phoneHash] = true;
totalUsers++;
emit WalletRegistered(phoneHash, wallet, block.timestamp);
}
// Get wallet address linked to a phone
function getWallet(bytes32 phoneHash) external view returns (address) {
return phoneToWallet[phoneHash];
}
// Get phone hash linked to a wallet
function getPhoneHash(address wallet) external view returns (bytes32) {
return walletToPhone[wallet];
}
// Check if a phone is registered
function checkRegistration(bytes32 phoneHash) external view returns (bool) {
return isRegistered[phoneHash];
}
// Verify if the provided PIN hash matches the stored hash
function verifyPin(bytes32 phoneHash, bytes32 pinHash) external view returns (bool) {
if (!isRegistered[phoneHash]) return false;
return phoneToPinHash[phoneHash] == pinHash;
}
function updatePin(bytes32 phoneHash, bytes32 newPinHash) external onlyAuthorized nonReentrant {
if (!isRegistered[phoneHash]) revert PhoneNotRegistered();
if (newPinHash == bytes32(0)) revert InvalidPinHash();
phoneToPinHash[phoneHash] = newPinHash;
emit PinUpdated(phoneHash, block.timestamp);
}
function updateWallet(bytes32 phoneHash, address newWallet) external onlyAuthorized nonReentrant {
if (!isRegistered[phoneHash]) revert PhoneNotRegistered();
if (newWallet == address(0)) revert InvalidWalletAddress();
if (walletToPhone[newWallet] != bytes32(0)) revert WalletAlreadyRegistered();
address oldWallet = phoneToWallet[phoneHash];
walletToPhone[oldWallet] = bytes32(0);
phoneToWallet[phoneHash] = newWallet;
walletToPhone[newWallet] = phoneHash;
emit WalletUpdated(phoneHash, oldWallet, newWallet);
}
// Authorize a new backend to manage registrations
function addAuthorizedBackend(address backend) external onlyOwner {
authorizedBackends[backend] = true;
emit BackendAuthorized(backend);
}
// Revoke backend authorization
function revokeAuthorizedBackend(address backend) external onlyOwner {
authorizedBackends[backend] = false;
emit BackendRevoked(backend);
}
}
Mappings for Data Storage
phoneToWallet: Maps hashed phone numbers to wallet addresses.walletToPhone: Maps wallet addresses back to phone hashes.phoneToPinHash: Stores hashed PINs for phone numbers.isRegistered: Tracks registration status of phone numbers.authorizedBackends: Manages backend addresses allowed to perform operations.
Access Control
Ownableensures that only the contract owner can manage backends.onlyAuthorizedmodifier allows either the owner or authorized backend addresses to execute sensitive functions.
Wallet Registration
registerWalletlinks a phone hash, wallet address, and PIN hash.Ensures each phone and wallet is registered only once.
Emits
WalletRegisteredevent.
PIN Management
verifyPinchecks if a given PIN hash matches the stored hash.updatePinallows authorized backends to update the PIN for a phone hash.Emits
PinUpdatedevent on changes.
Wallet Update
updateWalletenables changing a wallet linked to a phone hash.Ensures the new wallet is not already registered.
Emits
WalletUpdatedevent.
Backend Authorization Management
addAuthorizedBackendandrevokeAuthorizedBackendallow the owner to manage backend permissions.Emits
BackendAuthorizedandBackendRevokedevents.
Security Measures
ReentrancyGuardprotects functions that modify state from reentrancy attacks.Custom error messages save gas compared to string-based
requiremessages.Validations prevent invalid wallet addresses or empty PINs.
Events for Transparency
- Tracks wallet registrations, updates, PIN changes, and backend authorization changes for off-chain monitoring.
Create P2PTransfer Contract
Create contracts/P2PTransfer.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// Interface to interact with the WalletRegistry contract
interface IWalletRegistry {
function checkRegistration(bytes32 phoneHash) external view returns (bool);
function getWallet(bytes32 phoneHash) external view returns (address);
}
// P2PTransfer contract enables peer-to-peer transfers between registered wallets
contract P2PTransfer {
IWalletRegistry public walletRegistry;
uint256 public transferFeeBps = 50;
// Structure to store details of each transfer
struct TransferRecord {
bytes32 fromPhoneHash; // Sender's phone hash
bytes32 toPhoneHash; // Recipient's phone hash
uint256 amount; // Amount sent after fees
uint256 fee; // Fee taken by contract
uint256 timestamp; // When the transfer occurred
bytes32 txHash; // Unique hash identifying the transfer
}
// Mapping of phoneHash => array of transfer records
mapping(bytes32 => TransferRecord[]) private history;
// Event emitted after a successful transfer
event TransferExecuted(
bytes32 indexed fromHash,
bytes32 indexed toHash,
uint256 amount,
uint256 fee,
bytes32 txHash,
uint256 timestamp
);
constructor(address registry) {
walletRegistry = IWalletRegistry(registry);
}
function setFeeBps(uint256 newFeeBps) external {
require(newFeeBps <= 1000, "Fee too high"); // Max 10%
transferFeeBps = newFeeBps;
}
function transfer(bytes32 fromPhoneHash, bytes32 toPhoneHash) external payable {
// Check both sender and recipient are registered
require(walletRegistry.checkRegistration(fromPhoneHash), "Sender not registered");
require(walletRegistry.checkRegistration(toPhoneHash), "Recipient not registered");
require(msg.value > 0, "No value"); // Ensure some ETH is sent
// Calculate fee and final amount to send
uint256 fee = (msg.value * transferFeeBps) / 10000;
uint256 sendAmount = msg.value - fee;
// Get recipient wallet address from registry and send funds
address toWallet = walletRegistry.getWallet(toPhoneHash);
(bool ok, ) = toWallet.call{ value: sendAmount }("");
require(ok, "Send failed");
// Create a unique transaction hash and record the transfer
bytes32 txHash = keccak256(abi.encodePacked(block.number, fromPhoneHash, toPhoneHash, msg.value));
TransferRecord memory rec = TransferRecord({
fromPhoneHash: fromPhoneHash,
toPhoneHash: toPhoneHash,
amount: sendAmount,
fee: fee,
timestamp: block.timestamp,
txHash: txHash
});
// Store transfer in history for both sender and recipient
history[fromPhoneHash].push(rec);
history[toPhoneHash].push(rec);
// Emit event for frontend or off-chain tracking
emit TransferExecuted(fromPhoneHash, toPhoneHash, sendAmount, fee, txHash, block.timestamp);
}
function getHistory(bytes32 phoneHash, uint256 offset, uint256 limit) external view returns (TransferRecord[] memory) {
TransferRecord[] storage arr = history[phoneHash];
if (offset >= arr.length) return new TransferRecord ; // Return empty if offset too high
uint256 end = offset + limit;
if (end > arr.length) end = arr.length;
uint256 n = end - offset;
TransferRecord[] memory page = new TransferRecord[](n);
for (uint256 i = 0; i < n; i++) {
page[i] = arr[offset + i];
}
return page;
}
receive() external payable {}
}
WalletRegistry Integration
The contract uses the
IWalletRegistryinterface to verify if the sender and recipient are registered.Ensures transfers only occur between verified users.
Transfer Fee
transferFeeBpsrepresents the fee in basis points (default 50 = 0.5%).Fee can be updated via
setFeeBps(max 1000 = 10%).
Transfer Execution
transferfunction performs the ETH transfer from sender to recipient.Calculates fee and subtracts it from the transferred amount.
Sends ETH directly using
callto avoid fixed gas stipend issues.
Transaction History
TransferRecordstruct stores details: sender, recipient, amount, fee, timestamp, and a unique transaction hash.historymapping stores transactions for each phone hash.getHistoryfunction allows paginated retrieval of transfer records.
Events
TransferExecutedevent logs every successful transfer, including sender/recipient hashes, amount, fee, transaction hash, and timestamp.
Security and Validations
Checks that both sender and recipient are registered.
Validates
msg.value > 0to prevent empty transfers.Uses a unique
txHash(keccak256) for each transaction.Transfers revert if the ETH send fails.
ETH Reception
receive()function allows the contract to accept ETH deposits directly.
Create MicroLoan Contract
Create contracts/MicroLoan.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// Interface to interact with the WalletRegistry contract
interface IWalletRegistry {
function checkRegistration(bytes32 phoneHash) external view returns (bool);
function getWallet(bytes32 phoneHash) external view returns (address);
}
contract MicroLoan {
IWalletRegistry public walletRegistry;
// Structure to store loan details for each user
struct Loan {
uint256 loanId; // Unique ID for each loan
uint256 principal; // Principal loan amount
uint256 collateral; // Collateral provided
uint256 totalDue; // Total repayment including interest
uint256 repaidAmount; // Amount already repaid
uint256 remainingDue; // Remaining amount to repay
uint256 dueDate; // Timestamp when loan is due
uint8 status; // 0 = no loan, 1 = active, 2 = repaid
}
// Mapping of user phone hash to their active loan
mapping(bytes32 => Loan) public loans;
// Incremental ID for new loans
uint256 public nextLoanId;
// Annual interest rate in basis points (default 12% per year)
uint256 public annualRateBps = 1200;
// Events for off-chain tracking or frontend integration
event LoanRequested(
bytes32 indexed phoneHash,
uint256 principal,
uint256 collateral,
uint256 totalDue,
uint256 dueDate,
uint256 loanId
);
event LoanRepaid(
bytes32 indexed phoneHash,
uint256 amount,
uint256 remainingDue,
uint256 loanId
);
event LiquidityAdded(address indexed provider, uint256 amount);
event LiquidityWithdrawn(address indexed provider, uint256 amount);
constructor(address registry) {
walletRegistry = IWalletRegistry(registry);
}
function availableLiquidity() public view returns (uint256) {
return address(this).balance;
}
function calculateLoanQuote(uint256 principalWei, uint256 durationDays) public view returns (uint256 totalDueWei) {
uint256 interest = (principalWei * annualRateBps * durationDays) / (365 * 10000);
totalDueWei = principalWei + interest;
}
function checkEligibility(bytes32 phoneHash) external view returns (bool eligible) {
eligible = walletRegistry.checkRegistration(phoneHash) && loans[phoneHash].status != 1;
}
function requestLoan(bytes32 phoneHash, uint256 principalWei, uint256 durationDays) external payable {
require(walletRegistry.checkRegistration(phoneHash), "Not registered");
require(loans[phoneHash].status != 1, "Existing loan");
require(principalWei > 0, "Zero principal");
require(durationDays > 0, "Zero duration");
require(availableLiquidity() >= principalWei, "Insufficient liquidity");
uint256 collateralWei = msg.value;
uint256 totalDue = calculateLoanQuote(principalWei, durationDays);
require(collateralWei >= principalWei / 10, "Collateral too low");
address wallet = walletRegistry.getWallet(phoneHash);
(bool ok, ) = wallet.call{ value: principalWei }("");
require(ok, "Disburse failed");
// Record the new loan
loans[phoneHash] = Loan({
loanId: ++nextLoanId,
principal: principalWei,
collateral: collateralWei,
totalDue: totalDue,
repaidAmount: 0,
remainingDue: totalDue,
dueDate: block.timestamp + durationDays * 1 days,
status: 1
});
emit LoanRequested(phoneHash, principalWei, collateralWei, totalDue, block.timestamp + durationDays * 1 days, nextLoanId);
}
function repayLoan(bytes32 phoneHash) external payable {
Loan storage L = loans[phoneHash];
require(L.status == 1, "No active loan");
require(msg.value > 0, "Zero repayment");
if (msg.value >= L.remainingDue) {
// Full repayment
uint256 excess = msg.value - L.remainingDue;
L.repaidAmount += L.remainingDue;
L.remainingDue = 0;
L.status = 2;
// Refund excess if any
if (excess > 0) {
(bool okRefund, ) = msg.sender.call{ value: excess }("");
require(okRefund, "Refund failed");
}
// Return collateral to user
(bool okColl, ) = msg.sender.call{ value: L.collateral }("");
require(okColl, "Collateral refund failed");
} else {
// Partial repayment
L.repaidAmount += msg.value;
L.remainingDue -= msg.value;
}
emit LoanRepaid(phoneHash, msg.value, L.remainingDue, L.loanId);
}
function addLiquidity() external payable {
require(msg.value > 0, "Zero");
emit LiquidityAdded(msg.sender, msg.value);
}
function withdrawLiquidity(uint256 amountWei) external {
require(amountWei <= availableLiquidity(), "Exceeds");
(bool ok, ) = msg.sender.call{ value: amountWei }("");
require(ok, "Withdraw failed");
emit LiquidityWithdrawn(msg.sender, amountWei);
}
// Fallback function to accept ETH
receive() external payable {}
}
WalletRegistry Integration
Only registered users can request loans.
Uses
IWalletRegistryto verify registration and get the user’s wallet address.
Loan Structure
Each loan is represented by the Loan struct:
loanId: unique identifierprincipal: borrowed amountcollateral: ETH deposited as securitytotalDue: principal + interestrepaidAmount&remainingDue: track repaymentdueDate: timestamp for loan maturitystatus: 1 = active, 2 = repaid
Loan Eligibility & Request
Checks that user has no active loan.
Requires non-zero principal and duration.
Loan is disbursed to the user’s wallet, collateral is locked.
Minimum collateral = 10% of principal.
Total due calculated using annual interest rate in basis points (annualRateBps).
Loan Repayment
Users can repay partially or fully.
Collateral is returned upon full repayment.
Overpayments are refunded.
Loan status updated to repaid after full repayment.
Liquidity Management
External users can add ETH to provide liquidity.
Liquidity can be withdrawn up to the contract’s balance.
Events track all additions and withdrawals.
Events
LoanRequested: logs new loans.LoanRepaid: logs repayments and remaining dues.LiquidityAdded/LiquidityWithdrawn: track funds in the pool.
Security & Validations
Ensures only registered users request loans.
Validates sufficient collateral and available liquidity.
Uses
callfor ETH transfers to prevent gas issues.Protects against zero principal, zero duration, and over-withdrawals.
ETH Reception
receive()allows the contract to accept ETH directly.
Step 3: Deployment Scripts
Create Main Deployment Script
Create scripts/deploy.js:
// Import Hardhat runtime environment and Node.js modules
const hre = require("hardhat");
const fs = require("fs");
const path = require("path");
async function main() {
// Get the first signer (deployer) from Hardhat
const [deployer] = await hre.ethers.getSigners();
const balance = await hre.ethers.provider.getBalance(deployer.address);
// Display header and deployment info
console.log("\n╔════════════════════════════════════════════════════════╗");
console.log("║ USSD DeFi on Rootstock - Contract Deployment ║");
console.log("╚════════════════════════════════════════════════════════╝\n");
console.log("Deployment Configuration:");
console.log("──────────────────────────────────────────────────");
console.log(` Network: ${hre.network.name}`);
console.log(` Deployer: ${deployer.address}`);
console.log(` Balance: ${hre.ethers.formatEther(balance)} RBTC`);
console.log("──────────────────────────────────────────────────\n");
// Ensure deployer has funds
if (balance === 0n) {
throw new Error("Deployer has no RBTC. Fund account first.");
}
// Deploy WalletRegistry contract
console.log("📦 Deploying WalletRegistry...");
const WalletRegistry = await hre.ethers.getContractFactory("WalletRegistry");
const walletRegistry = await WalletRegistry.deploy();
await walletRegistry.waitForDeployment();
const walletRegistryAddress = await walletRegistry.getAddress();
// Deploy P2PTransfer contract with WalletRegistry address
console.log("📦 Deploying P2PTransfer...");
const P2PTransfer = await hre.ethers.getContractFactory("P2PTransfer");
const p2pTransfer = await P2PTransfer.deploy(walletRegistryAddress);
await p2pTransfer.waitForDeployment();
const p2pAddress = await p2pTransfer.getAddress();
// Deploy MicroLoan contract with WalletRegistry address
console.log("📦 Deploying MicroLoan...");
const MicroLoan = await hre.ethers.getContractFactory("MicroLoan");
const microLoan = await MicroLoan.deploy(walletRegistryAddress);
await microLoan.waitForDeployment();
const mlAddress = await microLoan.getAddress();
// Authorize the P2PTransfer and MicroLoan contracts in WalletRegistry
console.log("🔐 Authorizing backends...");
await (await walletRegistry.addAuthorizedBackend(p2pAddress)).wait();
await (await walletRegistry.addAuthorizedBackend(mlAddress)).wait();
// Save deployment info to JSON for backend/frontend use
const deploymentsDir = path.join(__dirname, "..", "deployments");
if (!fs.existsSync(deploymentsDir)) fs.mkdirSync(deploymentsDir, { recursive: true });
const latest = {
network: hre.network.name,
chainId: hre.network.config.chainId,
deployer: deployer.address,
timestamp: new Date().toISOString(),
contracts: {
WalletRegistry: walletRegistryAddress,
P2PTransfer: p2pAddress,
MicroLoan: mlAddress
}
};
fs.writeFileSync(path.join(deploymentsDir, `${hre.network.name}-latest.json`), JSON.stringify(latest, null, 2));
// Display deployed addresses
console.log("\n✅ Deployed:");
console.log(` WalletRegistry: ${walletRegistryAddress}`);
console.log(` P2PTransfer: ${p2pAddress}`);
console.log(` MicroLoan: ${mlAddress}`);
console.log("\n➡ Update backend .env with these addresses.");
}
// Run the deployment script and handle errors
main().catch((e) => {
console.error("\n❌ Deployment failed:", e);
process.exit(1);
});
This script automates the deployment of three smart contracts WalletRegistry, P2PTransfer, and MicroLoan on an Rootstock network using Hardhat. It begins by connecting to the deployer’s account and checking their RBTC balance to ensure sufficient funds for deployment. It then deploys the WalletRegistry contract first, followed by the P2PTransfer and MicroLoan contracts, passing the WalletRegistry address to each for proper integration. After deployment, it authorizes the deployed P2P and MicroLoan contracts as backend contracts in the WalletRegistry. Finally, it saves the deployed contract addresses, network info, and timestamp into a JSON file in the deployments directory for reference and backend configuration. The script includes console logs for clarity, displaying deployer info, deployment progress, and final addresses.
Create Liquidity Script
Create scripts/add-liquidity.js:
const hre = require("hardhat");
const fs = require("fs");
const path = require("path");
async function main() {
const dep = JSON.parse(
fs.readFileSync(
path.join(__dirname, "..", "deployments", `${hre.network.name}-latest.json`),
"utf8"
)
);
// Get the deployed MicroLoan contract address
const addr = dep.contracts.MicroLoan;
// Get contract factory and attach to the deployed address
const MicroLoan = await hre.ethers.getContractFactory("MicroLoan");
const microLoan = MicroLoan.attach(addr);
const amount = hre.ethers.parseEther("0.1");
const tx = await microLoan.addLiquidity({ value: amount });
await tx.wait();
// Fetch and display the updated available liquidity
const liq = await microLoan.availableLiquidity();
console.log(`New liquidity: ${hre.ethers.formatEther(liq)} RBTC`);
}
// Execute the script and catch any errors
main().catch((e) => {
console.error(e);
process.exit(1);
});
This Hardhat script is designed to add liquidity to an already deployed MicroLoan contract on the Rootstock network. It first reads the latest deployment metadata JSON file to get the MicroLoan contract address for the current network. Using Hardhat’s ethers library, it attaches to the deployed contract instance without redeploying it. The script then sends a specified amount of RBTC (0.1 in this case) to the addLiquidity function of the contract, thereby increasing the liquidity pool available for microloans. After the transaction is confirmed, it queries the availableLiquidity function to display the updated RBTC balance in the contract.
Step 4: Backend Implementation
Setup Backend Directory
Create the backend directory structure:
mkdir backend
cd backend
npm init -y
Configure Backend Package.json
Create backend/package.json:
{
"name": "ussd-rsk-defi-backend",
"version": "1.0.0",
"description": "USSD DeFi Backend Server for Rootstock Integration",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"ethers": "^6.9.0",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
},
"engines": { "node": ">=18.0.0" }
}
Install dependencies:
npm install
Create Environment Configuration
Create backend/.env:
PORT=3001
NODE_ENV=development
RSK_TESTNET_RPC=https://public-node.testnet.rsk.co
BACKEND_WALLET_PRIVATE_KEY=0x<YOUR_FUNDED_PRIVATE_KEY>
WALLET_REGISTRY_ADDRESS=
P2P_TRANSFER_ADDRESS=
MICRO_LOAN_ADDRESS=
AT_API_KEY=placeholder
AT_USERNAME=sandbox
SESSION_SECRET=dev_session_secret
PHONE_HASH_SALT=dev_phone_salt
INTERNAL_API_KEY=dev_internal_key
ALLOWED_ORIGINS=http://localhost:5173
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
This .env file holds backend config: server port, environment, Rootstock testnet RPC, and wallet private key. It includes placeholders for deployed contract addresses and settings for API keys, session, phone hashing, CORS, and rate limiting.
Create Main Server File
Create backend/src/index.js:
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const bodyParser = require('body-parser');
const ussdRoutes = require('./routes/ussd');
const apiRoutes = require('./routes/api');
const { initializeBlockchain } = require('./services/blockchain');
const app = express();
const PORT = process.env.PORT || 3001;
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || '*', methods: ['GET', 'POST'] }));
const limiter = rateLimit({ windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS || 900000), max: Number(process.env.RATE_LIMIT_MAX_REQUESTS || 100) });
app.use('/api/', limiter);
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString(), version: '1.0.0' });
});
app.use('/ussd', ussdRoutes);
app.use('/api', apiRoutes);
app.use((req, res) => { res.status(404).json({ error: 'Not found' }); });
app.use((err, req, res, next) => { console.error('Server error:', err); res.status(500).json({ error: 'Internal server error' }); });
async function startServer() {
await initializeBlockchain();
app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });
}
startServer();
module.exports = app;
Create USSD Routes
Create backend/src/routes/ussd.js:
const express = require('express');
const router = express.Router();
const { handleUSSDRequest } = require('../handlers/menuHandler');
const { validateUSSDRequest } = require('../middleware/validation');
router.post('/', validateUSSDRequest, async (req, res) => {
const { sessionId, phoneNumber, serviceCode, text, networkCode } = req.body;
try {
const response = await handleUSSDRequest({ sessionId, phoneNumber, serviceCode, text: text || '', networkCode });
res.set('Content-Type', 'text/plain');
res.send(response);
} catch (error) {
res.set('Content-Type', 'text/plain');
res.send('END An error occurred. Please try again later.');
}
});
router.post('/notification', async (req, res) => { res.status(200).send('OK'); });
module.exports = router;
Create API Routes
Create backend/src/routes/api.js:
const express = require('express');
const router = express.Router();
const { validateAPIRequest } = require('../middleware/validation');
const blockchainService = require('../services/blockchain');
const { hashPhoneNumber } = require('../utils/crypto');
router.get('/status', async (req, res) => {
try {
const provider = blockchainService.getProvider();
const gp = await provider.getGasPrice();
res.json({
status: 'operational',
timestamp: new Date().toISOString(),
blockchain: { connected: !!provider, gasPrice: `${Number(gp) / 1e9} gwei` },
sessions: { total: 0, active: 0, expired: 0 }
});
} catch {
res.json({ status: 'degraded' });
}
});
router.get('/balance/:phoneNumber', validateAPIRequest, async (req, res) => {
try {
const hash = hashPhoneNumber(req.params.phoneNumber);
const balance = await blockchainService.getBalance(hash);
const wallet = await blockchainService.getWalletAddress(hash);
res.json({ phoneNumber: req.params.phoneNumber, wallet, balance, unit: 'RBTC' });
} catch (e) {
res.status(400).json({ error: 'Unable to fetch balance' });
}
});
router.get('/transactions/:phoneNumber', validateAPIRequest, async (req, res) => {
try {
const hash = hashPhoneNumber(req.params.phoneNumber);
const limit = Number(req.query.limit || 10);
const records = await blockchainService.getHistory(hash, 0, limit);
res.json({ phoneNumber: req.params.phoneNumber, transactions: records, count: records.length });
} catch (e) {
res.status(400).json({ error: 'Unable to fetch transactions' });
}
});
module.exports = router;
Create Validation Middleware
Create backend/src/middleware/validation.js:
function validateUSSDRequest(req, res, next) {
const { sessionId, phoneNumber, serviceCode } = req.body;
if (!sessionId) return res.status(400).send('END Invalid request: Missing session ID');
if (!phoneNumber) return res.status(400).send('END Invalid request: Missing phone number');
if (!serviceCode) return res.status(400).send('END Invalid request: Missing service code');
const phoneRegex = /^\+?\d{10,15}$/;
if (!phoneRegex.test(phoneNumber.replace(/\s/g, ''))) return res.status(400).send('END Invalid phone number format');
const sessionRegex = /^[a-zA-Z0-9\-_]+$/;
if (!sessionRegex.test(sessionId)) return res.status(400).send('END Invalid session');
if (req.body.text) {
const textRegex = /^[0-9*#\+\.]*$/;
if (!textRegex.test(req.body.text)) return res.status(400).send('END Invalid input');
if (req.body.text.length > 182) return res.status(400).send('END Input too long');
}
next();
}
function validateAPIRequest(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) return res.status(401).json({ error: 'API key required' });
const validApiKey = process.env.INTERNAL_API_KEY;
if (apiKey !== validApiKey) return res.status(403).json({ error: 'Invalid API key' });
next();
}
module.exports = { validateUSSDRequest, validateAPIRequest };
Create Blockchain Service
Create backend/src/services/blockchain.js:
const { ethers } = require('ethers');
const { hashPin, hashPhoneNumber } = require('../utils/crypto');
const WALLET_REGISTRY_ABI = require('../abis/WalletRegistry.json');
const P2P_TRANSFER_ABI = require('../abis/P2PTransfer.json');
const MICRO_LOAN_ABI = require('../abis/MicroLoan.json');
let provider, signer, walletRegistry, p2pTransfer, microLoan;
async function initializeBlockchain() {
const rpcUrl = process.env.RSK_TESTNET_RPC || 'https://public-node.testnet.rsk.co';
provider = new ethers.JsonRpcProvider(rpcUrl);
if (!process.env.BACKEND_WALLET_PRIVATE_KEY) throw new Error('BACKEND_WALLET_PRIVATE_KEY not configured');
signer = new ethers.Wallet(process.env.BACKEND_WALLET_PRIVATE_KEY, provider);
if (process.env.WALLET_REGISTRY_ADDRESS) {
walletRegistry = new ethers.Contract(process.env.WALLET_REGISTRY_ADDRESS, WALLET_REGISTRY_ABI, signer);
}
if (process.env.P2P_TRANSFER_ADDRESS) {
p2pTransfer = new ethers.Contract(process.env.P2P_TRANSFER_ADDRESS, P2P_TRANSFER_ABI, signer);
}
if (process.env.MICRO_LOAN_ADDRESS) {
microLoan = new ethers.Contract(process.env.MICRO_LOAN_ADDRESS, MICRO_LOAN_ABI, signer);
}
}
async function checkRegistration(phoneHash) {
if (!walletRegistry) return false;
return await walletRegistry.checkRegistration(phoneHash);
}
async function getWalletAddress(phoneHash) {
if (!walletRegistry) throw new Error('WalletRegistry not initialized');
return await walletRegistry.getWallet(phoneHash);
}
async function getBalance(phoneHash) {
const walletAddress = await getWalletAddress(phoneHash);
if (walletAddress === ethers.ZeroAddress) return '0';
const balance = await provider.getBalance(walletAddress);
return ethers.formatEther(balance);
}
async function verifyPin(phoneHash, pin) {
const pinHash = hashPin(phoneHash, pin);
return await walletRegistry.verifyPin(phoneHash, pinHash);
}
async function calculateTransferFee(amount) {
if (!p2pTransfer) return amount * 0.005;
const feeBps = await p2pTransfer.transferFeeBps();
return amount * Number(feeBps) / 10000;
}
async function executeTransfer(fromPhoneHash, toPhoneHash, amount) {
const fee = await calculateTransferFee(amount);
const totalWei = ethers.parseEther((amount + fee).toString());
const tx = await p2pTransfer.transfer(fromPhoneHash, toPhoneHash, { value: totalWei });
const receipt = await tx.wait();
return receipt.hash;
}
async function requestLoan(phoneHash, principal, durationDays, collateral) {
const tx = await microLoan.requestLoan(phoneHash, ethers.parseEther(principal.toString()), durationDays, { value: ethers.parseEther(collateral.toString()) });
const receipt = await tx.wait();
return receipt.hash;
}
async function repayLoan(phoneHash, amount) {
const tx = await microLoan.repayLoan(phoneHash, { value: ethers.parseEther(amount.toString()) });
const receipt = await tx.wait();
return receipt.hash;
}
async function getHistory(phoneHash, offset, limit) {
const records = await p2pTransfer.getHistory(phoneHash, offset, limit);
return records;
}
function getProvider() { return provider; }
function getSigner() { return signer; }
module.exports = {
initializeBlockchain,
checkRegistration,
getWalletAddress,
getBalance,
verifyPin,
calculateTransferFee,
executeTransfer,
requestLoan,
repayLoan,
getHistory,
getProvider,
getSigner
};
Create Wallet Service
Create backend/src/services/wallet.js:
const { ethers } = require('ethers');
const { hashPin } = require('../utils/crypto');
const blockchainService = require('./blockchain');
const WALLET_REGISTRY_ABI = require('../abis/WalletRegistry.json');
const walletStore = new Map();
async function createAndRegisterWallet(phoneHash, pin) {
const wallet = ethers.Wallet.createRandom();
walletStore.set(phoneHash, { address: wallet.address, encryptedKey: wallet.privateKey, createdAt: Date.now() });
const pinHash = hashPin(phoneHash, pin);
const signer = blockchainService.getSigner();
const walletRegistryAddress = process.env.WALLET_REGISTRY_ADDRESS;
if (!walletRegistryAddress) throw new Error('WalletRegistry address not configured');
const walletRegistry = new ethers.Contract(walletRegistryAddress, WALLET_REGISTRY_ABI, signer);
const tx = await walletRegistry.registerWallet(phoneHash, wallet.address, pinHash);
const receipt = await tx.wait();
return { address: wallet.address, txHash: receipt.hash };
}
module.exports = { createAndRegisterWallet };
Create Session Service
Create backend/src/services/session.js:
const sessions = new Map();
const TTL_MS = 10 * 60 * 1000;
function getSession(id) {
const s = sessions.get(id);
if (!s) return null;
if (Date.now() - s.updatedAt > TTL_MS) { sessions.delete(id); return null; }
s.updatedAt = Date.now();
return s;
}
function createSession(id, phoneNumber) {
const s = { id, phoneNumber, state: 'main', data: {}, createdAt: Date.now(), updatedAt: Date.now() };
sessions.set(id, s);
return s;
}
module.exports = { getSession, createSession };
Create Crypto Utilities
Create backend/src/utils/crypto.js:
const { ethers } = require('ethers');
function hashPhoneNumber(phoneNumber) {
const salt = process.env.PHONE_HASH_SALT || '';
return ethers.keccak256(ethers.toUtf8Bytes(`${phoneNumber}${salt}`));
}
function hashPin(phoneHash, pin) {
return ethers.keccak256(ethers.concat([ethers.getBytes(phoneHash), ethers.toUtf8Bytes(pin)]));
}
module.exports = { hashPhoneNumber, hashPin };
Create Formatter Utilities
Create backend/src/utils/formatter.js:
function formatBalance(b) {
const n = typeof b === 'string' ? parseFloat(b) : b;
return Number(n).toFixed(6);
}
module.exports = { formatBalance };
Create Menu Handler
Create backend/src/handlers/menuHandler.js:
const sessionManager = require('../services/session');
const blockchainService = require('../services/blockchain');
const walletService = require('../services/wallet');
const { formatBalance } = require('../utils/formatter');
const { hashPhoneNumber } = require('../utils/crypto');
const MENU_STATES = {
MAIN: 'main',
CHECK_BALANCE: 'check_balance',
SEND_MONEY: 'send_money',
SEND_RECIPIENT: 'send_recipient',
SEND_AMOUNT: 'send_amount',
SEND_CONFIRM: 'send_confirm',
SEND_PIN: 'send_pin',
REQUEST_LOAN: 'request_loan',
LOAN_AMOUNT: 'loan_amount',
LOAN_DURATION: 'loan_duration',
LOAN_CONFIRM: 'loan_confirm',
LOAN_PIN: 'loan_pin',
REPAY_LOAN: 'repay_loan',
REPAY_CONFIRM: 'repay_confirm',
REPAY_PIN: 'repay_pin',
TRANSACTION_HISTORY: 'transaction_history',
REGISTER: 'register',
REGISTER_PIN: 'register_pin',
REGISTER_CONFIRM_PIN: 'register_confirm_pin'
};
async function handleUSSDRequest({ sessionId, phoneNumber, text }) {
let session = sessionManager.getSession(sessionId);
if (!session) session = sessionManager.createSession(sessionId, phoneNumber);
const inputs = text ? text.split('*') : [];
const phoneHash = hashPhoneNumber(phoneNumber);
const isRegistered = await blockchainService.checkRegistration(phoneHash);
if (text === '') return showMainMenu(isRegistered);
return await processMenuSelection(session, inputs, phoneNumber, isRegistered);
}
function showMainMenu(isRegistered) {
if (isRegistered) {
return `CON Welcome to Rootstock DeFi\n\n1. Check Balance\n2. Send Money\n3. Request Loan\n4. Repay Loan\n5. Transaction History\n6. My Account`;
} else {
return `CON Welcome to Rootstock DeFi\n\nYou are not registered.\n\n1. Register Now\n0. Exit`;
}
}
async function processMenuSelection(session, inputs, phoneNumber, isRegistered) {
const phoneHash = hashPhoneNumber(phoneNumber);
const firstInput = inputs[0];
if (!isRegistered) return await handleRegistration(session, inputs, phoneNumber, phoneHash);
switch (firstInput) {
case '1': return await handleCheckBalance(phoneHash);
case '2': return await handleSendMoney(session, inputs, phoneNumber, phoneHash);
case '3': return await handleRequestLoan(session, inputs, phoneHash);
case '4': return await handleRepayLoan(session, inputs, phoneHash);
case '5': return await handleTransactionHistory(phoneHash);
case '6': return `END Feature coming soon.`;
default: return 'END Invalid selection. Please try again.';
}
}
async function handleCheckBalance(phoneHash) {
const balance = await blockchainService.getBalance(phoneHash);
const formatted = formatBalance(balance);
return `END Your Balance:\n${formatted} RBTC`;
}
async function handleSendMoney(session, inputs, phoneNumber, phoneHash) {
const step = inputs.length;
switch (step) {
case 1:
session.state = MENU_STATES.SEND_RECIPIENT;
return `CON Send Money\n\nEnter recipient phone number:\n(e.g., +254712345678)`;
case 2: {
const recipientPhone = inputs[1];
if (!recipientPhone.match(/^\+?\d{10,15}$/)) return 'END Invalid phone number format.';
const recipientHash = hashPhoneNumber(recipientPhone);
const recipientRegistered = await blockchainService.checkRegistration(recipientHash);
if (!recipientRegistered) return 'END Recipient is not registered on Rootstock DeFi.';
session.data.recipientPhone = recipientPhone;
session.data.recipientHash = recipientHash;
session.state = MENU_STATES.SEND_AMOUNT;
return `CON Enter amount to send (RBTC):\n(e.g., 0.001)\n\nYour balance: ${formatBalance(await blockchainService.getBalance(phoneHash))} RBTC`;
}
case 3: {
const amount = parseFloat(inputs[2]);
if (isNaN(amount) || amount <= 0) return 'END Invalid amount.';
const balance = await blockchainService.getBalance(phoneHash);
const fee = await blockchainService.calculateTransferFee(amount);
const totalRequired = amount + fee;
if (totalRequired > parseFloat(balance)) {
return `END Insufficient balance.\nRequired: ${totalRequired.toFixed(6)} RBTC\nAvailable: ${formatBalance(balance)} RBTC`;
}
session.data.amount = amount;
session.data.fee = fee;
session.state = MENU_STATES.SEND_CONFIRM;
return `CON Confirm Transfer:\nTo: ${session.data.recipientPhone}\nAmount: ${amount} RBTC\nFee: ${fee.toFixed(6)} RBTC\nTotal: ${totalRequired.toFixed(6)} RBTC\n\n1. Confirm\n2. Cancel`;
}
case 4:
if (inputs[3] !== '1') return 'END Transaction cancelled.';
session.state = MENU_STATES.SEND_PIN;
return 'CON Enter your 4-digit PIN:';
case 5: {
const pin = inputs[4];
if (!pin.match(/^\d{4}$/)) return 'END Invalid PIN format.';
const pinValid = await blockchainService.verifyPin(phoneHash, pin);
if (!pinValid) return 'END Incorrect PIN. Transaction cancelled.';
const txHash = await blockchainService.executeTransfer(phoneHash, session.data.recipientHash, session.data.amount);
return `END Transfer Successful!\n\nAmount: ${session.data.amount} RBTC\nTo: ${session.data.recipientPhone}\nFee: ${session.data.fee.toFixed(6)} RBTC\n\nTx: ${txHash.substring(0, 10)}...`;
}
default: return 'END Session error. Please start over.';
}
}
async function handleRequestLoan(session, inputs, phoneHash) {
const step = inputs.length;
switch (step) {
case 1:
session.state = MENU_STATES.LOAN_AMOUNT;
return `CON Request Loan\n\nEnter amount (RBTC):`;
case 2:
session.data.loanAmount = parseFloat(inputs[1]);
if (isNaN(session.data.loanAmount) || session.data.loanAmount <= 0) return 'END Invalid amount.';
session.state = MENU_STATES.LOAN_DURATION;
return `CON Enter duration (days):`;
case 3:
session.data.loanDays = parseInt(inputs[2]);
if (isNaN(session.data.loanDays) || session.data.loanDays <= 0) return 'END Invalid duration.';
session.state = MENU_STATES.LOAN_CONFIRM;
return `CON Confirm Loan:\nAmount: ${session.data.loanAmount} RBTC\nDuration: ${session.data.loanDays} days\nCollateral: ${(session.data.loanAmount/10).toFixed(6)} RBTC\n\n1. Confirm\n2. Cancel`;
case 4:
if (inputs[3] !== '1') return 'END Loan cancelled.';
session.state = MENU_STATES.LOAN_PIN;
return 'CON Enter your 4-digit PIN:';
case 5: {
const pin = inputs[4];
if (!pin.match(/^\d{4}$/)) return 'END Invalid PIN format.';
const pinValid = await blockchainService.verifyPin(phoneHash, pin);
if (!pinValid) return 'END Incorrect PIN. Loan cancelled.';
const txHash = await blockchainService.requestLoan(
phoneHash,
session.data.loanAmount,
session.data.loanDays,
session.data.loanAmount / 10
);
return `END Loan Approved!\n\nAmount: ${session.data.loanAmount} RBTC\nDuration: ${session.data.loanDays} days\nCollateral: ${(session.data.loanAmount/10).toFixed(6)} RBTC\n\nTx: ${txHash.substring(0, 10)}...`;
}
default: return 'END Invalid input.';
}
}
async function handleRepayLoan(session, inputs, phoneHash) {
const step = inputs.length;
switch (step) {
case 1:
session.state = MENU_STATES.REPAY_CONFIRM;
return `CON Enter amount to repay (RBTC):`;
case 2:
session.data.repayAmount = parseFloat(inputs[1]);
if (isNaN(session.data.repayAmount) || session.data.repayAmount <= 0) return 'END Invalid amount.';
session.state = MENU_STATES.REPAY_PIN;
return 'CON Enter your 4-digit PIN:';
case 3: {
const pin = inputs[2];
if (!pin.match(/^\d{4}$/)) return 'END Invalid PIN format.';
const pinValid = await blockchainService.verifyPin(phoneHash, pin);
if (!pinValid) return 'END Incorrect PIN. Repayment cancelled.';
const txHash = await blockchainService.repayLoan(phoneHash, session.data.repayAmount);
return `END Repayment Successful!\n\nAmount: ${session.data.repayAmount} RBTC\n\nTx: ${txHash.substring(0, 10)}...`;
}
default: return 'END Invalid input.';
}
}
async function handleTransactionHistory(phoneHash) {
const recs = await blockchainService.getHistory(phoneHash, 0, 5);
if (!recs || recs.length === 0) return 'END No transactions found.';
const lines = recs.map((r) => `To: ${r.toPhoneHash.substring(0,8)} Amt: ${Number(r.amount)/1e18} Fee: ${Number(r.fee)/1e18}`);
return `END Recent Transfers:\n${lines.join('\n')}`;
}
async function handleRegistration(session, inputs, phoneNumber, phoneHash) {
const step = inputs.length;
switch (step) {
case 1:
if (inputs[0] === '0') return 'END Thank you for using Rootstock DeFi. Goodbye!';
if (inputs[0] !== '1') return 'END Invalid selection.';
session.state = MENU_STATES.REGISTER_PIN;
return `CON Registration\n\nCreate a 4-digit PIN:`;
case 2: {
const pin = inputs[1];
if (!pin.match(/^\d{4}$/)) return 'END PIN must be exactly 4 digits.';
session.data.pin = pin;
session.state = MENU_STATES.REGISTER_CONFIRM_PIN;
return 'CON Confirm your 4-digit PIN:';
}
case 3: {
const confirmPin = inputs[2];
if (confirmPin !== session.data.pin) return 'END PINs do not match.';
const result = await walletService.createAndRegisterWallet(phoneHash, session.data.pin);
return `END Registration Successful!\n\nWallet: ${result.address.substring(0, 10)}...\n\nDial *384*123# to access your account.`;
}
default: return 'END Invalid input.';
}
}
module.exports = { handleUSSDRequest, MENU_STATES };
Create ABIs Directory
After compiling contracts, create the ABIs directory:
mkdir backend/src/abis
Copy the compiled ABIs from artifacts/contracts/*/*.json to backend/src/abis/ for WalletRegistry.json, P2PTransfer.json, and MicroLoan.json.
Step 5: Frontend Implementation
Initialize React Frontend
Create a React app using Vite:
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm install axios
Configure Frontend Package.json
The frontend/package.json should look like:
{
"name": "ussd-rsk-defi-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.8",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
Create Frontend Application
Create frontend/src/App.jsx:
import { useState } from 'react';
import axios from 'axios';
const API = 'http://localhost:3001';
export default function App() {
const [phone, setPhone] = useState('+254712345678');
const [sessionId, setSessionId] = useState('s1');
const [text, setText] = useState('');
const [ussdResponse, setUssdResponse] = useState('');
const [status, setStatus] = useState(null);
const [balance, setBalance] = useState(null);
const [transactions, setTransactions] = useState([]);
const callUSSD = async () => {
const params = new URLSearchParams();
params.append('sessionId', sessionId);
params.append('phoneNumber', phone);
params.append('serviceCode', '*384*123#');
params.append('text', text);
const { data } = await axios.post(`${API}/ussd`, params);
setUssdResponse(data);
};
const loadStatus = async () => {
const { data } = await axios.get(`${API}/api/status`);
setStatus(data);
};
const loadBalance = async () => {
const { data } = await axios.get(`${API}/api/balance/${encodeURIComponent(phone)}`, { headers: { 'x-api-key': 'dev_internal_key' } });
setBalance(data);
};
const loadTransactions = async () => {
const { data } = await axios.get(`${API}/api/transactions/${encodeURIComponent(phone)}?limit=5`, { headers: { 'x-api-key': 'dev_internal_key' } });
setTransactions(data.transactions || []);
};
return (
<div style={{ padding: 20, fontFamily: 'sans-serif' }}>
<h2>USSD Rootstock DeFi Admin & Simulator</h2>
<div style={{ display: 'flex', gap: 20 }}>
<div>
<h3>USSD Simulator</h3>
<div>
<label>Phone Number:</label>
<input value={phone} onChange={e => setPhone(e.target.value)} style={{ marginLeft: 8 }} />
</div>
<div>
<label>Session ID:</label>
<input value={sessionId} onChange={e => setSessionId(e.target.value)} style={{ marginLeft: 8 }} />
</div>
<div>
<label>Text:</label>
<input value={text} onChange={e => setText(e.target.value)} style={{ marginLeft: 8, width: 300 }} placeholder="e.g. 1, or 2*+2547...*0.005*1*1234" />
</div>
<button onClick={callUSSD} style={{ marginTop: 10 }}>Send</button>
<pre style={{ background: '#f5f5f5', padding: 10, marginTop: 10 }}>{ussdResponse}</pre>
</div>
<div>
<h3>Status</h3>
<button onClick={loadStatus}>Load Status</button>
<pre>{status ? JSON.stringify(status, null, 2) : ''}</pre>
<h3>Balance</h3>
<button onClick={loadBalance}>Load Balance</button>
<pre>{balance ? JSON.stringify(balance, null, 2) : ''}</pre>
<h3>Transactions</h3>
<button onClick={loadTransactions}>Load Transactions</button>
<pre>{JSON.stringify(transactions, null, 2)}</pre>
</div>
</div>
</div>
);
}
Step 6: Local Development Workflow
Start Local Blockchain
Start a local Hardhat node:
npm run node
Compile Contracts
Compile your smart contracts:
npm run compile
Deploy to Localhost
Deploy contracts to the local network:
npm run deploy:local
Fund Loan Pool
Add liquidity to the MicroLoan contract:
npx hardhat run scripts/add-liquidity.js --network localhost
Configure Backend for Localhost
Update your backend/.env for localhost:
RSK_TESTNET_RPC=http://127.0.0.1:8545
BACKEND_WALLET_PRIVATE_KEY=<HARDHAT_ACCOUNT_0_PRIVATE_KEY>
WALLET_REGISTRY_ADDRESS=<DEPLOYED_ADDRESS>
P2P_TRANSFER_ADDRESS=<DEPLOYED_ADDRESS>
MICRO_LOAN_ADDRESS=<DEPLOYED_ADDRESS>
Copy ABIs
Copy compiled ABIs to the backend:
cp artifacts/contracts/WalletRegistry.sol/WalletRegistry.json backend/src/abis/
cp artifacts/contracts/P2PTransfer.sol/P2PTransfer.json backend/src/abis/
cp artifacts/contracts/MicroLoan.sol/MicroLoan.json backend/src/abis/
Start Backend Server
Navigate to the backend directory and start the development server:
cd backend
npm run dev
Start Frontend
In a new terminal, navigate to the frontend directory and start the development server:
cd frontend
npm run dev
Open your browser to the displayed URL (typically http://localhost:5173).
Step 7: USSD Flow Testing
Test Registration Flow
Test user registration with these sequential requests:
Initial menu:
text=''Select register:
text='1'Enter PIN:
text='1*1234'Confirm PIN:
text='1*1234*1234'
Test Balance Check
After registration, check balance:
texttext='1'
Test Loan Request
Request a loan using this sequence:
Select loan option:
text='3'Enter amount:
text='3*0.01'Enter duration:
text='3*0.01*7'Confirm:
text='3*0.01*7*1'Enter PIN:
text='3*0.01*7*1*1234'
Test Money Transfer
Register a recipient first, then test transfer:
Select send money:
text='2'Enter recipient:
text='2*+254700000000'Enter amount:
text='2*+254700000000*0.005'Confirm:
text='2*+254700000000*0.005*1'Enter PIN:
text='2*+254700000000*0.005*1*1234'
PowerShell Testing Example
Use PowerShell to test the USSD endpoint:
Invoke-WebRequest -UseBasicParsing -Uri http://localhost:3001/ussd `
-Method Post -Body @{ sessionId='u1'; phoneNumber='+254712345678'; serviceCode='*384*123#'; text='' } `
-ContentType 'application/x-www-form-urlencoded'
Step 8: USSD Gateway Integration
Configure Africa's Talking
Africa's Talking USSD expects your endpoint to respond with plain text prefixed by CON to continue the session or END to terminate. Configure the gateway to send POST requests to your backend's /ussd route.
Expose Local Backend
For development, use ngrok to expose your local backend:
ngrok http 3001
Set the gateway callback URL to the ngrok address provided.
Step 9: Rootstock Testnet Deployment
Fund Deployer Account
Obtain RBTC from a Rootstock testnet faucet to fund your deployer account.
Configure Environment Variables
Set your environment variables:
setx DEPLOYER_PRIVATE_KEY "0x<FUNDED_PRIVATE_KEY>"
setx RSK_TESTNET_RPC "https://public-node.testnet.rsk.co"
Restart your terminal to load the new environment variables.
Deploy contracts to Rootstock testnet:
npm run deploy:testnet
Update Backend Configuration
Update your backend/.env with the testnet addresses and restart the backend:
RSK_TESTNET_RPC=https://public-node.testnet.rsk.co
WALLET_REGISTRY_ADDRESS=<TESTNET_DEPLOYED_ADDRESS>
P2P_TRANSFER_ADDRESS=<TESTNET_DEPLOYED_ADDRESS>
MICRO_LOAN_ADDRESS=<TESTNET_DEPLOYED_ADDRESS>
Verify Deployment
Check the backend status:
curl http://localhost:3001/api/status
For reference use this GitHub Repo : https://github.com/Ishita1302/Rootstock_USSD.git
Step 10: Security Best Practices
Always hash phone numbers and PINs never store plaintext values. Use the authorized backend mechanism in WalletRegistry to restrict who can register users. Validate all USSD and API inputs with strict regex patterns. Store private keys in environment variables or key management systems, never in logs or code.
Step 11: Troubleshooting
If you encounter "Invalid input" errors, verify that validation regex allows + and . characters in text fields. For "Not initialized" errors, confirm all contract addresses are properly set in backend/.env. When facing insufficient funds on testnet, ensure your deployer address has adequate RBTC before running deployment scripts. If ABIs are missing, run npm run compile and manually copy the JSON files from the artifacts directory.
This complete tutorial provides you with a production-ready USSD-first DeFi application on Rootstock with smart contracts, backend integration, and a frontend simulator.
Conclusion
The USSD-based DeFi system on Rootstock demonstrates a practical approach to bridging the gap between traditional feature phones and modern blockchain financial services. By leveraging USSD technology, users without smartphones or reliable internet access can participate in decentralized finance, enabling P2P transfers, microloans, and secure wallet management. This approach addresses financial inclusion, offering a secure, low-barrier entry point to the benefits of blockchain-based finance for underserved populations.
The architecture of the system, combining a USSD gateway, Node.js backend, and Rootstock smart contracts, ensures smooth and reliable interactions between users and the blockchain. The WalletRegistry, P2PTransfer, and MicroLoan contracts together create a cohesive ecosystem, handling registration, transaction history, microloan disbursements, and repayments. The integration of authorized backends and secure session management guarantees that transactions are executed safely, while users retain full control over their wallets and loan data.
Overall, this solution exemplifies how innovative blockchain applications can extend financial services to populations previously excluded due to technological limitations. By enabling feature phone users to interact with Rootstock-based DeFi applications, the project not only promotes financial empowerment but also paves the way for broader adoption of decentralized finance in emerging markets. As blockchain ecosystems continue to evolve, such inclusive solutions will be crucial in ensuring that technological advancements benefit all segments of society.
Happy Building!🚀
For additional information, explore the official website and discover more about the project. Be sure to join our growing community to stay updated and engage with fellow users. If you have any questions or need support, the community channels are always open to help!
Telegram: @rskofficialcommunity
Documentation: https://dev.rootstock.io





