Skip to main content

Command Palette

Search for a command to run...

Building a USSD-Based DeFi Application on Rootstock

Published
32 min read
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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  1. 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.

  2. 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

    • Ownable ensures that only the contract owner can manage backends.

    • onlyAuthorized modifier allows either the owner or authorized backend addresses to execute sensitive functions.

  • Wallet Registration

    • registerWallet links a phone hash, wallet address, and PIN hash.

    • Ensures each phone and wallet is registered only once.

    • Emits WalletRegistered event.

  • PIN Management

    • verifyPin checks if a given PIN hash matches the stored hash.

    • updatePin allows authorized backends to update the PIN for a phone hash.

    • Emits PinUpdated event on changes.

  • Wallet Update

    • updateWallet enables changing a wallet linked to a phone hash.

    • Ensures the new wallet is not already registered.

    • Emits WalletUpdated event.

  • Backend Authorization Management

    • addAuthorizedBackend and revokeAuthorizedBackend allow the owner to manage backend permissions.

    • Emits BackendAuthorized and BackendRevoked events.

  • Security Measures

    • ReentrancyGuard protects functions that modify state from reentrancy attacks.

    • Custom error messages save gas compared to string-based require messages.

    • 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 IWalletRegistry interface to verify if the sender and recipient are registered.

    • Ensures transfers only occur between verified users.

  • Transfer Fee

    • transferFeeBps represents the fee in basis points (default 50 = 0.5%).

    • Fee can be updated via setFeeBps (max 1000 = 10%).

  • Transfer Execution

    • transfer function performs the ETH transfer from sender to recipient.

    • Calculates fee and subtracts it from the transferred amount.

    • Sends ETH directly using call to avoid fixed gas stipend issues.

  • Transaction History

    • TransferRecord struct stores details: sender, recipient, amount, fee, timestamp, and a unique transaction hash.

    • history mapping stores transactions for each phone hash.

    • getHistory function allows paginated retrieval of transfer records.

  • Events

    • TransferExecuted event 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 > 0 to 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 IWalletRegistry to verify registration and get the user’s wallet address.

  • Loan Structure

    • Each loan is represented by the Loan struct:

      • loanId: unique identifier

      • principal: borrowed amount

      • collateral: ETH deposited as security

      • totalDue: principal + interest

      • repaidAmount & remainingDue: track repayment

      • dueDate: timestamp for loan maturity

      • status: 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 call for 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:​

  1. Initial menu: text=''

  2. Select register: text='1'

  3. Enter PIN: text='1*1234'

  4. Confirm PIN: text='1*1234*1234'

Test Balance Check

After registration, check balance:​

texttext='1'

Test Loan Request

Request a loan using this sequence:

  1. Select loan option: text='3'

  2. Enter amount: text='3*0.01'

  3. Enter duration: text='3*0.01*7'

  4. Confirm: text='3*0.01*7*1'

  5. Enter PIN: text='3*0.01*7*1*1234'

Test Money Transfer

Register a recipient first, then test transfer:

  1. Select send money: text='2'

  2. Enter recipient: text='2*+254700000000'

  3. Enter amount: text='2*+254700000000*0.005'

  4. Confirm: text='2*+254700000000*0.005*1'

  5. 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!