← Go back

Offline Payments System

I tried creating an offline payments system in Rust inspired by Bitcoin's Lightning Network.

Lightning Network is a Bitcoin L2 where users can do off-chain transactions and later settle it on-chain.

In this, two parties open a payment channel by locking funds in a multi-signature address. They transact off-chain by exchanging signed balance updates, broadcasting only the final state to the blockchain.

This enables instant, low-cost transactions without waiting for on-chain confirmations.

      sequenceDiagram
        participant On-chain Funding Tx
        participant Payment@{ "type" : "queue" }
        participant On-chain Closing Tx
        On-chain Funding Tx-->Payment: Open Channel
        Payment-->On-chain Closing Tx: Close Channel
    

You can read more about Lightning Network here.

I made something similar to how Lightning Network works in Rust. You can go through the repository here. It still lacks a proper frontend, but you can try out the backend and the core contract logic.

There are three main directories in the repo:

The core logic of the app lies in contract directory.

The main thing used in this is alloy crate. It helps you interact with blockchain in Rust.

/server/src/contracts has all of the code required to interact with the smart contracts.

When users register to the platform, a new custodial wallet is created and its private key is stored securely. Users need to first add some money as collateral for security, which wouldn't be shown as balance. Users can then add money to their balance.

      sequenceDiagram
        participant User
        participant Server
        participant Blockchain
        User->>Server: Register & Deposit Collateral
        activate Server
        Server->>Blockchain: Create Wallet & Deposit Collateral Tx
        deactivate Server
        Server->>User: Registration Successful
        User->>Server: Add Funds to Balance
        activate Server
        Server->>Blockchain: Deposit Funds Tx
        deactivate Server
        Server->>User: Balance Updated
        User->>Server: Initiate Payment to Another User
        activate Server
        Server->>Blockchain: Create Payment Channel Tx
        deactivate Server
        Server->>User: Payment Channel Established
        User->>Server: Close Payment Channel
        activate Server
        Server->>Blockchain: Settle Final Balance Tx
        deactivate Server
    

The users perform transactions by using the ERC-20 token $OFFPAY. Users perform transactions off-chain and start a settlement period for 1 day or so. The payment has to be settle during that time by sending the final amount on-chain. If not, the amount will be slashed from their collateral.

The main function responsible for settlement of payments is call_settle_payment.

use alloy::primitives::{Address, U256};
use chrono::{Duration, Utc};
use eyre::{Ok, Result};

use crate::{
    contracts::init::{init_contracts, init_provider_with_wallet},
    models::model::{Transaction, Wallet},
};

pub async fn call_settle_payment(
    transaction: &Transaction,
    wallet: &Wallet,
    to: Address,
    amount: U256,
) -> Result<()> {
    let (contract_address, provider) = init_provider_with_wallet(wallet).await?;

    let (contract, _) = init_contracts(&provider, contract_address).await?;

    let current_timestamp = U256::from(Utc::now().timestamp() as u64);

    let settle_time_period_in_seconds = U256::from(Duration::days(1).num_seconds() as u64);

    let elapsed_time = current_timestamp.saturating_sub(transaction.timestamp);

    if elapsed_time <= settle_time_period_in_seconds {
        let settle_payment_tx = contract.settlePayment(to, amount).send().await?;
        let _receipt = settle_payment_tx.get_receipt().await?;
    } else {
        let settle_payment_after_settlement_time_tx = contract
            .settlePaymentAfterSettlementPeriod(to, amount)
            .send()
            .await?;
        let _receipt = settle_payment_after_settlement_time_tx
            .get_receipt()
            .await?;
    }

    Ok(())
}

The type of object which this function will accept is of this format.

{
    "wallet": {
        "public_key": "0x8b4754842db66d1e31d166eb96cadb700dc2f3a4",
        "private_key": "0xe3dd13d6098228249925315caf3abca462a6edf1696b8d61b379906ff1353926"
    },
    "transaction": {
        "id": "1",
        "from": "0x8b4754842db66d1e31d166eb96cadb700dc2f3a4",
        "to": "0x1f41edf961bf379e0c10c9e5c94361b769806898",
        "amount": "100000000000000000",
        "timestamp": "1753807570",
        "is_settled": false,
        "tx_hash": "1f41edf961bf379e0c10c9e5c94361b769806898"
    },
    "amount": "100000000000000000"
}

You can read more about the models in /server/src/models, which contain information about how the data object format is defined.

The contract implementation is rather simple compared to the backend.

There are only 2 contracts which handle the payment channels and settlements.

OffPayToken.sol is a simple ERC-20 token contract. The main logic is in OffPay.sol.

function settlePayment(address recipient, uint256 amount) external {
    User storage sender = users[msg.sender];
    User storage recip = users[recipient];
    require(sender.isRegistered, "Not registered");
    require(recip.isRegistered, "Recipient not registered");
    require(amount > 0, "Amount must be > 0");
    require(block.timestamp <= sender.last_txn_timestamp + SETTLEMENT_PERIOD, "Not within settlement period");
    require(sender.balance >= amount, "Insufficient balance");

    sender.balance -= amount;
    sender.last_txn_timestamp = block.timestamp;

    require(token.transfer(recipient, amount), "Token transfer failed");
    emit PaymentSettled(msg.sender, recipient, amount, false, block.timestamp);
}

function settlePaymentAfterSettlementPeriod(address recipient, uint256 amount) external {
    User storage sender = users[msg.sender];
    User storage recip = users[recipient];
    require(sender.isRegistered, "Not registered");
    require(recip.isRegistered, "Recipient not registered");
    require(amount > 0, "Amount must be > 0");
    require(block.timestamp > sender.last_txn_timestamp + SETTLEMENT_PERIOD, "Settlement period not yet over");
    require(sender.collateral - amount >= HEALTHY_COLLATERAL, "Collateral would fall below healthy threshold");
    sender.collateral -= amount;
    sender.last_txn_timestamp = block.timestamp;

    require(token.transfer(recipient, amount), "Token transfer failed");
    emit PaymentSettled(msg.sender, recipient, amount, true, block.timestamp);
}

The two functions settlePayment and settlePaymentAfterSettlementPeriod handle the settlement of payments within and after the settlement period respectively.

This is the basic structure of the offline payments system using smart contracts and ERC-20 tokens.

I have further plans of making this into a full-fledged protocol with a proper frontend, preferably a mobile app. There are many flaws in this approach I took, such as privacy and security, such as you cannot decide if the payment actually took place on-chain and many other such issues, which will be solved in a better and improved version of this in near future.