Verifying OTP On-Chain with Phala Phat Functions

The blockchain is immutable and not internet-aware, as everything must be provable and guaranteed to remain the same. This makes asynchronous operations impossible. Blockchain oracles and off-chain data are critical in the blockchain ecosystem because they enable smart contracts to interact with the real world and utilize datasets such as weather forecasting, financial trading, bots, and messaging services. This bridges the gap between Web2 and Web3, paving the way for mass adoption.

Securing online transactions and user data is very important in today's digital landscape. One traditional method of safeguarding user data is by implementing two-factor authentication, often time-based, using OTPs (one-time passwords) to verify access to specific data.

Traditional OTPs do not seamlessly integrate with the decentralized and trustless nature of blockchain systems. However, with Phala Network's Phat functions, it is possible to make your smart contracts internet-aware.

Phat functions in the Phala Network are programs that enable you to trigger arbitrary off-chain computations from your smart contract. These computations can include making API requests, writing to databases, and performing client-side computations.

In this article, we will explore how to leverage Phala's Phat functions and XMTP messaging to implement on-chain OTP verification. We will demonstrate how to empower your smart contracts with off-chain computations.

Tutorial Prerequisites

Before following along with this tutorial, you should have the following prerequisites:

  • Knowledge of how to write a smart contract.

  • Phala CLI tool installed on your computer.

  • Node.js version 18 or later.

  • Foundry, which we will use for the smart contracts.

You can find the code for this tutorial in this GitHub repository.

Let's begin by setting up our project.

Setting Up the Project

  1. Create a new project using a mono-repo template. You can use any mono-repo template or this one.

  2. Inside the packages/ directory, create three folders (you can name them differently):

    • /contracts - for our OTP contract.

    • /api - for our OTP server.

    • /core - for our Phat function.

  3. Navigate to the packages/core directory and run the following commands to initialize your Phat function project and install the necessary dependencies:

cd packages/core

pnpm init
pnpm i @phala/fn @phala/ethers @phala/pink-env @phala/sdk dotenv
npx tsc --init

Navigate to the packages/api directory and run the following commands to initialize your OTP server project and install the necessary dependencies:

cd packages/api

pnpm init
pnpm install -D typescript @types/cors cors @types/express express ethers dotenv @xmtp/xmtp-js@xmtp/xmtp-js
npx tsc --init

Navigate to the packages/contracts directory and initialize your smart contract project:

cd packages/contracts

forge init  --no-commit --force
forge install foundry-rs/forge-std openzeppelin/openzeppelin-contracts --no-commit

Update your foundry.toml configuration with the following settings:

...
optimizer_runs = 200
solc_version = "0.8.17"

remappings = [
    "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/"
]

Building an OTP Server

Our goal is to generate an OTP code that can be verified on-chain. To achieve this, we will leverage several different APIs. However, we need a custom API to keep the messaging service decentralized and to move the parameter encoding/decoding from phat functions to the server. The OTP server will generate and deliver one-time passwords to the recipient's wallet address using xmtp protocol.

Simple Express.js API

Create a new file called index.ts inside the APIs src/ directory and add the following code:

/// api/src/index.ts

import express from "express";
import cors from "cors";
import { config } from "dotenv";

config();

const app = express();
const port = process.env.PORT || 3000;
const env = (process.env.ENV as "local" | "dev" | "production" | undefined) || "dev";
const apiKey = process.env.API_KEY;

app.use(express.json());
app.use(cors());

function encodeError(error: string): string {
    return "todo";
}

app.get("/", async (req, res) => {
    const key = req.query.key as string | undefined;

    if (!key || key !== apiKey) {
        return res.status(401).json({ error: encodeError("Unauthorized") });
    }

    res.json({
        payload: "",
    });
});

app.listen(port, async () => {
    console.log(`Server is running on port ${port}`);
});

Run your app with the command ts-node src/index.ts. You should see the text Server is running on port 3000 logged in to your terminal. We are using a simple API key authentication for the route, but keep in mind that this is not ideal for production. You would want to use more robust authentication methods in a production environment.

Writing the OTP Generator

Next, let's write our OTP generator function. For simplicity, we'll generate a random 6-digit OTP, but you can modify this to include additional characters or generate longer OTPs:

// api/src/index.ts

function generateOTP(): string {
    // Generate a random 6-digit OTP
    return Math.floor(100000 + Math.random() * 900000).toString();
}

Web3 Messaging with XMTP

To send the OTP to the expected recipient address, we will use XMTP chat. Ensure you have @xmtp/xmtp-js installed, and then add the following code to your index.ts:

/// api/src/index.ts

import { Client } from "@xmtp/xmtp-js";
import { Wallet, utils } from "ethers";
import { defaultAbiCoder, isAddress } from "ethers/lib/utils";

const privKey = env === "production" ? process.env.PRIV_KEY : process.env.LOCAL_PRIV_KEY;
const wallet = privKey ? new Wallet(privKey) : Wallet.createRandom();

let xmtp: Client;

app.get("/", async (req, res) => {
    // ...

    const payload = req.query.payload as string | undefined;

    if (!payload) {
        return res.status(400).json({ error: encodeError("Invalid Payload") });
    }

    const [recipient, sender] = defaultAbiCoder.decode(["address", "address"], payload);

    if (!isAddress(recipient) || !isAddress(sender)) {
        return res.status(400).json({ error: encodeError("Address or Recipient invalid") });
    }

    const canMessage = await xmtp.canMessage(recipient);

    if (!canMessage) {
        return res.status(403).json({ error: encodeError("Unable to message this recipient") });
    }

    const conversation = await xmtp.conversations.newConversation(recipient);

    const otp = generateOTP();
    const message = getMessage(otp, sender);

    await conversation.send(message);

    // ...
});

app.listen(port, async () => {
    xmtp = await Client.create(wallet, { env });
    console.log(`Server is running on port ${port}`);
});

In the code above, we generate the OTP and send it to the recipient's web3 inbox using XMTP. We extract the recipient's and sender's addresses from the payload received from the Phala function. The XMTP chat facilitates secure OTP delivery to the recipient's wallet address.

Now, let's add the getMessage and formatAddress functions:

const formatAddress = (address: string) => {
    return address.startsWith("0x") && address.length === 42
        ? address.substring(0, 6) + "..." + address.substring(address.length - 4)
        : address;
};

const getMessage = (otp: number, sender: string) => {
    return `your ${formatAddress(
        sender || "UNKNOWN_CONTRACT"
    )} verification code is: ${otp}. Don't share this code with anyone; confirm that you initiated it from ${formatAddress(
        sender || "UNKNOWN_CONTRACT"
    )}!`;
};

These functions format the sender's address and create the OTP message for delivery.

Handling Requests

We receive the raw bytes of the data and perform the decoding and encoding on the API side, rather than in the Phat functions. This approach enhances expressiveness and reduces the work needed in Phat functions.

Handling Responses

While we send the OTP to the recipient's web3 inbox, we also need to return the OTP on-chain. This is crucial because we want to ensure that only the user who received the OTP can reproduce the hash sent on-chain. This is similar to zero-knowledge proofs, where the recipient can prove that they know the OTP without revealing the actual OTP itself. Only the recipient can recreate the original hash from the OTP.

Add the following code to your index.ts:

/// api/src/index.ts

import crypto from "crypto";
import { arrayify, randomBytes } from "ethers/lib/utils";

app.get("/", async (req, res) => {
    const otpHash = utils.solidityKeccak256(["string"], [crypto.createHash("sha256").update(otp).digest("hex")]);

    // Generate a valid signature to be used for verifying the OTP hash on-chain
    const signature = await wallet.signMessage(arrayify(otpHash));

    res.json({
        payload: utils.solidityPack(["bytes32", "uint256", "address", "bytes"], [otpHash, 0, recipient, signature]),
    });
});

In the above code, we generate a SHA-256 hash of the OTP and use Keccak256 to ensure it is a 32-byte length array. We then sign the generated OTP hash using the API's private key. This signature allows us to prove on-chain that the OTP was generated by our server.

We also return the payload with all encoding, so the Phat functions don't need to perform additional encoding.

To complete the encodeError function:

function encodeError(error: string): string {
    return utils.solidityPack(
        ["bytes32", "uint256", "address", "bytes"],
        [randomBytes(32), 1, constants.AddressZero, arrayify(utils.solidityKeccak256(["string"], [error]))]
    );
}

The encodeError function encodes errors in a format that matches the payload. This allows us to distinguish between errors and successful responses in our smart contract. We use a SUCCESS code of 0 for success and 1 for failure to differentiate between an error and a payload.

With this, our OTP server is complete. You can find the full code in the GitHub repository.

Creating Our Consumer Contract

The next component to materialize is the OTP consumer smart contract. This contract will be deployed on-chain and send requests to an attestor when invoked. An attestor is specifically tied to your Phala workflow, which you will deploy later in this tutorial.

Create a new file in your Foundry project for the OTP contract:

/// contracts/src/OTP.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import "./PhatRollupAnchor.sol";

interface IOTP {
    event ResponseReceived(address reqId, bytes32 otpHash, uint256 code);
    event ErrorReceived(address reqId, bytes32 otpHash, uint256 code);

    function getOTP() external;

    function verifyOTP(bytes32 _otpHash, address recipient) external view returns (bool);
}

contract OTP is IOTP, PhatRollupAnchor {
    using ECDSA for bytes32;
    address public immutable _api;

    constructor(address api) {
        _api = api;
        _grantRole(PhatRollupAnchor.ATTESTOR_ROLE, api);
    }

    // Other contract functions and variables
}

In the code above, we define a simple contract that takes the wallet address used in the API to sign the OTP hash.

To complete the contract, add two files to your src directory: PhatRollupAnchor.sol and MetaTransaction.sol. These files are essential for t.

Now, let's add the getOTP and verifyOTP methods:

uint256 constant MAX_OTP_VALIDITY = 5 minutes;

mapping(address => OTPRecord) public otpRecords;

function getOTP() public {
        _pushMessage(abi.encode(msg.sender, address(this)));
    }

function verifyOTP(bytes32 _otpHash, address recipient) public view returns (bool) {
        OTPRecord memory otp = otpRecords[recipient];
        require(otp.otpHash == _otpHash, "OTP: Invalid otp.");
        require(block.timestamp <= otp.timestamp + MAX_OTP_VALIDITY, "OTP: validity expired.");
        return true;
    }

function _setOtp(bytes32 _otp, address recipient, uint256 code) internal {
        otpRecords[recipient] = OTPRecord(_otp, block.timestamp);
        emit ResponseReceived(recipient, _otp, code);
    }
  • MAX_OTP_VALIDITY: This constant defines the duration for which an OTP will be valid.

  • getOTP(): This function is not gasless, as it prompts the contract to request a new OTP. Typically, you would track requests with a unique request ID for each request. However, in our case, we treat the user's address as a request ID, ensuring only one OTP request per address. If you request multiple OTPs simultaneously, the latter request will override the former.

  • verifyOTP(): This function allows users to verify whether they possess the correct OTP. Users can reproduce the hash on-chain to prove their knowledge of the OTP.

  • _setOTP(): This internal function is called by the PhatRollupAnchor whenever we receive a response from our server.

To handle message reception in your contract, add the following code:

// contracts/src/OTP.sol

function _onMessageReceived(bytes calldata action) internal override {
    bytes32 otpHash = bytes32(action[0:32]);
    uint256 code = uint256(bytes32(action[32:64]));
    address recipient = address(bytes20(action[64:84]));
    bytes memory signature = action[84:];

    address signer = otpHash.toEthSignedMessageHash().recover(signature);
    if (signer != _api) {
        revert InvalidSigner(signer, _api);
    }
    if (code == uint256(STATUSCODE.SUCCESS) && recipient != address(0)) {
        _setOtp(otpHash, recipient, code);
    } else {
        emit ErrorReceived(recipient, otpHash, code);
    }
}

Here we override the _onMessageReceived. Remember we are tightly packing the payload from the server so we need to manually deconstruct it.

  • A bytes32: Is a 64bit length array, so we take the first 32 bytes as the OTPhash

  • A unit256: Is also a 64bit length integer represented in hexadecimal, so we take the next 32 bytes as the SUCCESS CODE

  • An address: Is made up of 40 chars, excluding 0x, which is 40 bits or 20 bytes, so we take the next 20 bytes and cast it bytes20 then to address

  • The rest is the server signature.

Using the solidity erecover We can guarantee that the API was the one who signed the OTPhash with the provided signature. Add the following to complete the contract:

enum STATUSCODE {
    SUCCESS,
    FAILURE
}

struct OTPRecord {
    bytes32 otpHash;
    uint256 timestamp;
}

error InvalidSigner(address, address);

bool initialized = false;

function setAttestor(address attester) public {
        require(!initialized, "OTP: already initialized.");
        _grantRole(PhatRollupAnchor.ATTESTOR_ROLE, attester);
        initialized = true;
    }

With this, your OTP contract is complete. You can find the full code in the GitHub repository.

Deploy Scripts

Foundry deploy scripts allow you to execute all your deployment logic, which is then replayed on a live blockchain in the specified order. They are great for multistep deployments that may require other contracts to have been deployed or certain variables to have been initialized.

In this section, we'll create a simple deploy script to deploy our consumer contract. Create a new file called OTP.s.sol inside the scripts/ directory and add the following code:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import "forge-std/Script.sol";
import "../src/OTP.sol";

contract DeployOTP is Script {
    function setUp() public {}

    function run() public {
        vm.startBroadcast();
        OTP otp = new OTP(msg.sender);
        vm.stopBroadcast();
    }
}

Deploying Our Contract

Before deploying your contract, ensure you have the following environment variables set up in your .env file:

  • MUMBAI_RPC_URL= (your Mumbai testnet RPC URL)

  • POLYGON_RPC_URL= (your Polygon mainnet RPC URL)

  • PRIVATE_KEY= (Your private key for deploying, use the same one in your API)

  • ETHERSCAN_API_KEY= (your PolygonScan API key)

Once your environment variables are set, you can deploy the contract by running the following command:

forge script script/OTP.s.sol:DeployOTP --rpc-url ${MUMBAI_RPC_URL}  --private-key ${PRIVATE_KEY} --broadcast --verify --etherscan-api-key ${ETHERSCAN_API_KEY} --legacy -vv

Alternatively, if you've cloned this GitHub repo, you can simply run:

pnpm deploy-mumbai

This script will deploy your OTP contract to the specified network. Make sure to replace ${MUMBAI_RPC_URL}, ${PRIVATE_KEY}, and ${ETHERSCAN_API_KEY} with your actual values.

Phat Contracts

Phat contracts are programs that run on Phala Network's off-chain Workers and can interface with smart contracts on any blockchain. They serve as a bridge between off-chain and on-chain worlds, enabling various capabilities such as internet connectivity, off-chain computation, and verifiable computation for smart contracts. Phat contracts are a crucial component in the Phala Network ecosystem, providing the following benefits:

  1. Internet Connectivity: Phat contracts can access the internet, allowing them to fetch data from external sources, interact with web APIs, and bring real-world information to the blockchain.

  2. Off-Chain Computation: They enable complex computations to be performed off-chain, reducing the burden on the blockchain network and improving scalability.

  3. Verifiable Compute: Phat contracts provide a mechanism to verify that off-chain computations were performed correctly and honestly, ensuring trust and security in smart contract interactions.

  4. Versatility: Phat contracts can be used for a wide range of applications, including decentralized servers, machine learning, privacy-preserving applications, composable infrastructures, identity providers, and connecting to traditional banking APIs from within smart contracts.

Creating a Phat Bricks Profile

To work with Phala Network and Phat contracts, you need a Polkadot account. You can create one using the Polkadot.js Wallet Extension.

Follow these steps to create your Bricks Profile account on the Phala PoC5 Testnet or Phala Mainnet:

  1. Visit the Phala PoC5 Testnet or Phala Mainnet website.

  2. Create your Bricks Profile account by following the provided instructions. You may refer to this YouTube video for a quick guide on setting up your account from scratch.

  3. Once your Bricks Profile account is created, you will have access to an overview of your account information.

  4. Set your POLKADOT_WALLET_SURI environment variable to the mnemonic phrase generated when creating your new Polkadot account inside your .env file. For example:

  5. Also not note your bricks profile ID, as we will use it later

POLKADOT_WALLET_SURI="test test test test test test test test test test test test"

After set up, your bricks profile should look like this:

Writing Our Phat Function

Now, let's delve into writing our Phat function. Below is what our Phat function looks like:

import "@phala/pink-env"

type HexString = `0x${string}`

export default function main(req: HexString, key: string) {
    // Replace with your production server URL before live deployment
    const otp_api_endpoint = `http://localhost:3001/?key=${key}&payload=${req}`

    let headers = {
        "Content-Type": "application/json",
        "User-Agent": "phat-contract",
    }

    const res = pink.httpRequest({
        url: otp_api_endpoint,
        method: "GET",
        headers,
        returnTextBody: true,
    })

    const body = JSON.parse(res.body.toString()) as {payload?: string; error?: string}
    if (res.statusCode == 200) {
        return body.payload
    }
    return body.error
}

A few key points to note about this Phat function:

  1. Import Statement: We use import "@phala/pink-env" to enable TypeScript in our Phat function. It's crucial to only import Phala-specific libraries in your Phat function code.

  2. Function Parameters: The main function takes two parameters:

    • req: This parameter represents the payload we receive from the consumer contract. It is of type HexString, which is a hex string.

    • key: This parameter is the API key that will be stored on Phala as the settings. Phala Network provides a secure enclave, ensuring the settings are kept secure.

  3. API Endpoint: We construct the API endpoint for our OTP server, otp_api_endpointby combining the provided key and req parameters. However, in a production environment, you should replace the URL with your production server URL.

  4. HTTP Request: We use the pink library to make a non-asynchronous HTTP request to our OTP server. It's important to note that you should not await the response. Phala functions work with the concept of a message queue, meaning that Phala will repeatedly attempt to trigger an HTTP request until it is successfully processed or dropped from the queue.

  5. Response Handling: We extract the payload or error from the response and return it as appropriate. If the HTTP request returns a 200 status code, we return the payload; otherwise, we return the error.

  6. Gas Accounts: The gas account, in this case, is an Ethereum wallet attached to your bricks profile. It helps relay responses to your consumer contract via metaTransaction. Ensure that the gas account is properly funded before sending a message to the function, as it plays a crucial role in the communication flow.

Deploying Our Phat Function

Now, let's deploy our Phat function, so it will be used in conjunction with our OTP consumer contract. We've provided deploy scripts to simplify this process, which you can find in the GitHub repository.

Also, to deploy we need to set up our packages/core directory with hardhat. Refer to the official hardhat documentation or follow the example in this GitHub repo.

Here's how you can deploy your Phat function:

  1. Create Deploy Script: Add the provided deploy script to your project. Save it as core/scripts/deploy-test-function.ts.

  2. Set Environment Variables: Ensure you have the following environment variables set in your .env file:

    • PHAT_BRICKS_TESTNET_CONTRACT_ID: Your Phat Bricks Profile ID.

    • POLKADOT_WALLET_SURI: Your Polkadot.js wallet mnemonic.

    • MUMBAI_CONSUMER_CONTRACT_ADDRESS: The address of your Mumbai OTP consumer contract.

    • DEPLOYER_PRIVATE_KEY: Use the same private key as your contracts.

    • OTP_API_KEY: Your OTP server API key.

    • MUMBAI_RPC_URL: Mumbai testnet RPC URL.

    • MUMBAI_LENSAPI_ORACLE_ENDPOINT: The LensAPI oracle endpoint.

  3. Run Deployment Script: Execute the following command to run the deployment script:

hardhat run --network mumbai ./scripts/deploy-test-function.ts

This command will deploy your Phat function to the Mumbai testnet, and your workflow will look like this (don’t worry if it’s not loading. it will show up once you send your first request):

Funding the Gas Account

To ensure that your Phat function can successfully communicate with your OTP consumer contract and receive callbacks, you must fund the gas ether account associated with your Phat bricks profile. Follow these steps to set the attestor on your consumer contract and fund your gas account:

  1. Get the Attestor Address: Copy the address of the attestor when you deploy your Phat function. You can find this address in your deployment transaction details

  2. Set the Attestor in the Consumer Contract: Call the setAttestor function in your OTP consumer contract, passing the attestor's address as the argument. This function is typically available in your consumer contract and should be called to specify the attestor's address. You can use a blockchain explorer like PolygonScan to initiate this transaction.

  3. Fund the Gas Ether Account: Send funds (Matic tokens) to the gas account.

If you don’t fund your gas account, Phala will continue to execute your function until the message queue is processed. Hence, this step is crucial to enable the complete workflow. Once you've funded the gas account, you're ready to interact with your deployed Phat function and OTP consumer contract. You can find your gas ether account on your Phat Bricks profile.

Interacting with Our Function On-Chain

Now that we have successfully deployed our OTP consumer contract and Phat function, let's explore how to interact with them on the blockchain.

Request an OTP

Request an OTP by calling the getOTP function from your OTP consumer contract. The recipient's address will identify the recipient of the OTP and will be msg.sender

import ethers from "ethers"

import JSON_INTERFACE from "../../contracts/out/OTP.sol/OTP.json"

const provider = new ethers.providers.JsonRpcProvider(process.env.MUMBAI_RPC_URL);
const wallet = new ethers.Wallet(pocess.env.DEPLOYER_PRIVATE_KEY, provider);
const otpContract = new ethers.Contract(pocess.env.MUMBAI_CONSUMER_CONTRACT_ADDRESS, JSON_INTERFACE.abi, wallet);
const tx = await otpContract.getOTP();
await tx.wait();

This code initiates a transaction to request an OTP for the specified recipient. Make sure you have sufficient gas and Matic in your wallet to cover the transaction fees because getOTP is a write function. You will receive an OTP in your xmtp chat like this:

If you inspect your Consumer contract on Polygonscan, you will see two transactions: getOTP, MetaTransactionRollup

Verifying an OTP

To verify an OTP on-chain, you can follow these steps:

Call the verifyOTP function from your OTP consumer contract, passing the OTP and recipient's address as arguments. Replace otpHash and recipientAddress with the actual OTP hash and recipient's address.

import {Wallet, constants, utils} from "ethers"

const otp = <the otp you recieved>

const otpHash = utils.solidityKeccak256(["string"], [crypto.createHash("sha256").update(otp).digest("hex")])

const isValid = await otpContract.verifyOTP(otpHash, recipientAddress);

If the OTP is valid, you should receive a boolean else it will revert with either with OTP: Invalid or OTP: Expired

Conclusion

In this tutorial, we've explored the possibilities offered by Phala Network and Phat Contracts for enhancing the functionality of smart contracts. We've learned how to implement on-chain OTP verification using Phala's off-chain capabilities and the XMTP messaging protocol.

By combining the power of Phala Network's off-chain capabilities, decentralized messaging, and Phat contracts, you can create secure and efficient applications that bridge the gap between the blockchain and the real world. This opens up a world of possibilities for decentralized finance, identity verification, and beyond.

Subscribe to Anyaogu
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.