Ir al contenido principal
Time to read: 1 min

Relay Server & Gateway Integration

This page covers building the Node.js Express server that acts as the bridge between the Africa's Talking USSD gateway and the InclusiveDeFi contract on RSK Testnet. It also covers registering your USSD callback with Africa's Talking and exposing your local server to the internet using ngrok.

How the Relay Server Works

The relay server is a stateless HTTP server with a single POST /ussd endpoint. Every time a user presses a key on their feature phone, Africa's Talking sends an HTTP POST to this endpoint. The server reads the accumulated session input from the text field, determines what the user wants, calls the appropriate contract function, and returns a CON (continue) or END (terminate) response string.

There is no database, no session store, and no authentication layer in this proof of concept. The entire session state is encoded in the text field of the incoming request.

Creating the Relay Server

Create index.ts in your project root:

import express, { Request, Response } from 'express';
import { ethers } from 'ethers';
import * as dotenv from 'dotenv';

dotenv.config();

const app = express();
app.use(express.urlencoded({ extended: false }));

/* -------------------------------------------------------------------------- */
/* PROVIDER + RELAYER WALLET */
/* -------------------------------------------------------------------------- */

const provider = new ethers.JsonRpcProvider(process.env.RSK_TESTNET_RPC);
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY as string, provider);

const CONTRACT_ADDRESS = "YOUR_DEPLOYED_CONTRACT_ADDRESS";

const ABI = [
"function getBalance(address user) view returns (uint256)",
"function transfer(address to, uint256 amount)",
"function applyForLoan()"
];

const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, wallet);

/* -------------------------------------------------------------------------- */
/* SESSION GUARD (PREVENTS DUPLICATE TXs) */
/* -------------------------------------------------------------------------- */

const processedSessions = new Set<string>();

// Auto-cleanup sessions after 5 minutes to prevent memory leak
function addSession(sessionId: string) {
processedSessions.add(sessionId);
setTimeout(() => processedSessions.delete(sessionId), 5 * 60 * 1000);
}

/* -------------------------------------------------------------------------- */
/* DEBUG LOGGER */
/* -------------------------------------------------------------------------- */

app.use((req, _res, next) => {
console.log("USSD HIT");
console.log("BODY:", req.body);
next();
});

/* -------------------------------------------------------------------------- */
/* USSD ROUTE */
/* -------------------------------------------------------------------------- */

app.post('/ussd', async (req: Request, res: Response) => {
const { text, phoneNumber, sessionId } = req.body;
const input = text ? text.split('*') : [''];
let response = "";

// Helper to send response
const send = (msg: string) => {
res.header('Content-Type', 'text/plain');
res.send(msg);
};

try {

/* ------------------------------ MAIN MENU ------------------------------ */
if (text === "") {
response = `CON Rootstock DeFi (${phoneNumber})
1. My Balance
2. Send Money (P2P)
3. Request Micro-Loan`;
return send(response);
}

/* ---------------------------- BALANCE CHECK ---------------------------- */
else if (input[0] === "1") {
try {
const bal = await contract.getBalance(wallet.address);
response = `END Your Balance: ${ethers.formatEther(bal)} tRBTC`;
} catch (err) {
console.error("Balance fetch error:", err);
response = "END Failed to fetch balance. Try again.";
}
return send(response);
}

/* ------------------------------ SEND MONEY ----------------------------- */
else if (input[0] === "2") {

// Step 1: Ask for recipient address
if (!input[1]) {
response = "CON Enter Recipient Address:";
return send(response);
}

// Step 2: Ask for amount
else if (!input[2]) {
response = "CON Enter Amount (in tRBTC):";
return send(response);
}

// Step 3: Execute transfer
else {
const sessionKey = `transfer_${sessionId}`;

// Prevent duplicate transfer
if (processedSessions.has(sessionKey)) {
response = "END Transfer already submitted. Please wait for confirmation.";
return send(response);
}

addSession(sessionKey);

try {
const to = input[1].trim();
const amount = ethers.parseEther(input[2].trim());

console.log(`Sending ${input[2]} tRBTC to ${to}...`);

// Submit tx — DO NOT await tx.wait() (too slow for USSD timeout)
const tx = await contract.transfer(to, amount);

// Confirm in background
tx.wait()
.then(() => console.log(`Transfer confirmed: ${tx.hash}`))
.catch((err: any) => console.error(`Transfer failed on-chain: ${err.reason || err.message}`));

response = `END Transfer Submitted
To: ${to.substring(0, 10)}...
Tx: ${tx.hash.substring(0, 12)}...
Funds will arrive shortly.`;

} catch (err: any) {
processedSessions.delete(`transfer_${sessionId}`);
console.error("Transfer error:", err);

if (err.reason?.includes("Insufficient balance")) {
response = "END Insufficient balance for transfer.";
} else if (err.reason) {
response = `END Transfer failed: ${err.reason}`;
} else {
response = "END Transfer failed. Check address and balance.";
}
}

return send(response);
}
}

/* ---------------------------- REQUEST LOAN ----------------------------- */
else if (input[0] === "3") {
const sessionKey = `loan_${sessionId}`;

// Prevent duplicate loan tx
if (processedSessions.has(sessionKey)) {
response = "END Loan already submitted. Please wait for confirmation.";
return send(response);
}

addSession(sessionKey);

try {
console.log("Applying for loan...");

// Submit tx — DO NOT await tx.wait() (too slow for USSD timeout)
const tx = await contract.applyForLoan();

// Confirm in background
tx.wait()
.then(() => console.log(`Loan confirmed: ${tx.hash}`))
.catch((err: any) => console.error(`Loan failed on-chain: ${err.reason || err.message}`));

response = `END Loan Requested
Tx: ${tx.hash.substring(0, 12)}...
0.01 tRBTC credited shortly.`;

} catch (err: any) {
processedSessions.delete(sessionKey);
console.error("Loan error:", err);

if (err.reason?.includes("Existing loan active")) {
response = "END You already have an active loan.";
} else if (err.reason) {
response = `END Loan failed: ${err.reason}`;
} else {
response = "END Loan request failed. Try again.";
}
}

return send(response);
}
else {
response = "END Invalid choice. Please try again.";
return send(response);
}

} catch (error) {
console.error("USSD FATAL ERROR:", error);
response = "END Something went wrong. Please try again.";
return send(response);
}
});

/* -------------------------------------------------------------------------- */
/* SERVER */
/* -------------------------------------------------------------------------- */

app.listen(3000, () => {
console.log("RSK-USSD Bridge running on port 3000");
});

Understanding the USSD State Machine

The text field from Africa's Talking accumulates all user inputs for the session, joined by *. The relay server splits on * and uses array index to determine the current menu depth:

text valueinput arrayStateAction
""[""]EntryShow main menu
"1"["1"]Balance selectedCall getBalance(), end session
"2"["2"]Transfer selectedPrompt for recipient
"2*0xABC"["2", "0xABC"]Recipient enteredPrompt for amount
"2*0xABC*0.005"["2", "0xABC", "0.005"]Amount enteredExecute transfer, end session
"3"["3"]Loan selectedCall applyForLoan(), end session

This table driven pattern is the canonical way to implement multi-depth USSD menus without any server side session storage.

Running the Server

Start the relay server with:

npm run start-bridge

You should see:

The server is now listening on http://localhost:3000/ussd. Africa's Talking requires a publicly reachable HTTPS URL to send USSD callbacks. During local development, use ngrok to tunnel your local port to the internet.

Exposing the Server with ngrok

Install ngrok from ngrok.com and run:

ngrok http 3000

ngrok will output a forwarding URL like:

Forwarding  https://abc123.ngrok-free.app → http://localhost:3000

Copy the https:// URL you will register this as your USSD callback in the next step. Note that on the ngrok free tier, this URL changes every time you restart ngrok.

Registering with Africa's Talking

Creating a Sandbox Account

  1. Sign up at Africa's Talking and navigate to the Sandbox environment from the top navigation bar.
  2. In the sandbox, create a new application or use the default one provided.

Setting Up the USSD Channel

  1. In your Africa's Talking dashboard, go to USSD in the left sidebar.
  2. Click Create Channel.
  3. Set a shortcode — for sandbox use any value like *384#.
  4. Set the Callback URL to your ngrok HTTPS URL with the path:
    https://abc123.ngrok-free.app/ussd
  5. Set the HTTP Method to POST.
  6. Save the channel.

Configuring the Simulator

Africa's Talking provides a built-in USSD Simulator in the sandbox dashboard. You do not need a physical phone to test.

To use it:

  1. Go to USSD → Simulator in your dashboard.
  2. Enter a phone number (any format, e.g., +2348012345678).
  3. Dial your shortcode (e.g., *384#).
  4. The simulator will fire HTTP POSTs to your callback URL and display the USSD responses in real time.
nota

Keep your ngrok tunnel and relay server running in separate terminal windows while testing. ngrok's web interface at http://localhost:4040 lets you inspect every incoming HTTP request and response in real time use this to debug USSD payloads.

What Each Transaction Looks Like On-Chain

When the relay server calls a write function (transfer or applyForLoan), ethers.js:

  1. Encodes the function call using the ABI.
  2. Estimates gas using the RSK node's eth_estimateGas RPC method.
  3. Signs the transaction with the relayer wallet's private key.
  4. Broadcasts it via eth_sendRawTransaction to the RSK public node.
  5. Polls for a receipt using eth_getTransactionReceipt until the block is confirmed.

The await tx.wait() call blocks until the transaction is included in a block. RSK's average block time on testnet is approximately 30 seconds. This means write operations will have a noticeable delay before the USSD session returns. Africa's Talking's default session timeout is 180 seconds, which is sufficient for most cases, but monitor this carefully under real network conditions.

Next Steps

With the relay server running and connected to the Africa's Talking gateway, proceed to Demo & Testing to validate the full USSD flow end-to-end, run curl-based tests, and verify transactions on the RSK Testnet Explorer.

Última actualización en por jayzalani