THERM
Integrate

Integrate

viem code for protocols buying bulk therms and for reading a user's therm balance.

This page shows how to integrate with Therm using viem (opens in a new tab). The first half is for protocols that want to buy therms in bulk and sponsor gas for their users. The second half is for any app that wants to read a user's therm balance.

Contracts are not deployed yet. Addresses below are marked TBD and will be published on the Security page at deployment. The interfaces are stable.

Setup#

Define Robinhood Chain once and reuse it. Read the chain parameters from your environment so they can be confirmed and changed without code edits.

typescript
// chains.ts
import { defineChain } from "viem";

export const robinhoodChain = defineChain({
  id: Number(process.env.RH_CHAIN_ID),
  name: "Robinhood Chain",
  nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
  rpcUrls: { default: { http: [process.env.RH_RPC_URL!] } },
  blockExplorers: {
    default: { name: "Explorer", url: process.env.RH_EXPLORER_URL! },
  },
});
typescript
// therm.ts — addresses and minimal ABIs
import { parseAbi, type Address } from "viem";

export const THERM = {
  pool: "0x0000000000000000000000000000000000000000" as Address, // TBD
  credit: "0x0000000000000000000000000000000000000000" as Address, // TBD
  paymaster: "0x0000000000000000000000000000000000000000" as Address, // TBD
  usdg: "0x0000000000000000000000000000000000000000" as Address, // USDG on Robinhood Chain
};

export const poolAbi = parseAbi([
  "function quote(uint256 usdgIn) view returns (uint256 therms, uint256 rateGweiPerTherm, uint256 premium)",
  "function coverageRatio() view returns (uint256)", // 1e18 = 1.0x
  "function buyPass(uint256 usdgIn, uint256 minTherms, address recipient) returns (uint256 passId, uint256 therms)",
  "event PassPurchased(uint256 indexed passId, address indexed buyer, address indexed recipient, uint256 usdgIn, uint256 premium, uint256 therms, uint256 rateGweiPerTherm)",
]);

export const paymasterAbi = parseAbi([
  "function createPolicy(address[] targets, uint256 perUserHourlyCap) returns (bytes32 policyId)",
  "function fundPolicy(bytes32 policyId, uint256 therms)",
  "function policyBalance(bytes32 policyId) view returns (uint256)",
  "function setPolicyPaused(bytes32 policyId, bool paused)",
  "event Sponsored(address indexed account, bytes32 indexed policyId, uint256 gasUsed, uint256 thermsBurned)",
]);

export const erc20Abi = parseAbi([
  "function balanceOf(address) view returns (uint256)",
  "function decimals() view returns (uint8)",
  "function approve(address spender, uint256 amount) returns (bool)",
  "function allowance(address owner, address spender) view returns (uint256)",
  "event Transfer(address indexed from, address indexed to, uint256 value)",
]);

Buying therms in bulk#

A bulk purchase is an ordinary pass. The quote curve applies to everyone equally, so there is no special endpoint. What matters for a large buyer is slippage protection and not moving coverage against yourself.

typescript
import { createPublicClient, createWalletClient, http, parseUnits, formatUnits } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { robinhoodChain } from "./chains";
import { THERM, poolAbi, erc20Abi } from "./therm";

const account = privateKeyToAccount(process.env.TREASURY_KEY as `0x${string}`);
const publicClient = createPublicClient({ chain: robinhoodChain, transport: http() });
const walletClient = createWalletClient({ account, chain: robinhoodChain, transport: http() });

export async function buyTherms(usdgAmount: string, slippageBps = 50n) {
  const usdgIn = parseUnits(usdgAmount, 6); // USDG has 6 decimals

  // 1. Quote, and refuse to buy on a stretched pool.
  const coverage = await publicClient.readContract({
    address: THERM.pool, abi: poolAbi, functionName: "coverageRatio",
  });
  if (coverage < parseUnits("2", 18)) {
    throw new Error(`Coverage ${formatUnits(coverage, 18)}x is below target; the curve is adding a surcharge.`);
  }

  const [therms, rate] = await publicClient.readContract({
    address: THERM.pool, abi: poolAbi, functionName: "quote", args: [usdgIn],
  });
  const minTherms = (therms * (10_000n - slippageBps)) / 10_000n;

  // 2. Approve USDG if needed.
  const allowance = await publicClient.readContract({
    address: THERM.usdg, abi: erc20Abi, functionName: "allowance", args: [account.address, THERM.pool],
  });
  if (allowance < usdgIn) {
    const approveHash = await walletClient.writeContract({
      address: THERM.usdg, abi: erc20Abi, functionName: "approve", args: [THERM.pool, usdgIn],
    });
    await publicClient.waitForTransactionReceipt({ hash: approveHash });
  }

  // 3. Buy. Reverts if fewer than minTherms would be minted.
  const hash = await walletClient.writeContract({
    address: THERM.pool, abi: poolAbi, functionName: "buyPass",
    args: [usdgIn, minTherms, account.address],
  });
  const receipt = await publicClient.waitForTransactionReceipt({ hash });

  console.log(`Locked ${formatUnits(therms, 18)} therms at ${rate} gwei/therm in ${receipt.transactionHash}`);
  return receipt;
}

For very large purchases, split the order into tranches across blocks and re-check coverageRatio between them. The curve is evaluated on coverage before each pass, so one large pass pays the pre-pass price for all of it, but the next buyer, which may be you, pays the post-pass price.

Sponsoring your users#

To pay gas for your own users, create a sponsorship policy that names the contracts you are willing to pay for and a per-user hourly cap, then fund it with therms.

typescript
import { encodeFunctionData, parseUnits } from "viem";
import { THERM, paymasterAbi, erc20Abi } from "./therm";

// Create a policy: your router and your vault, at most 20 therms per user per hour.
const { result: policyId, request } = await publicClient.simulateContract({
  account,
  address: THERM.paymaster,
  abi: paymasterAbi,
  functionName: "createPolicy",
  args: [["0xYourRouter", "0xYourVault"], parseUnits("20", 18)],
});
await walletClient.writeContract(request);

// Move therms into the policy. Approve the paymaster to pull them first.
const amount = parseUnits("50000", 18);
await walletClient.writeContract({
  address: THERM.credit, abi: erc20Abi, functionName: "approve", args: [THERM.paymaster, amount],
});
await walletClient.writeContract({
  address: THERM.paymaster, abi: paymasterAbi, functionName: "fundPolicy", args: [policyId, amount],
});

Your frontend then passes the policy id in paymasterAndData when it builds user operations. Most account-abstraction SDKs accept a paymaster address and an opaque data field; the Therm paymaster expects abi.encode(bytes32 policyId) as that field. Users sign as normal and never need ETH.

Watch Sponsored events filtered by your policyId to track spend per user, and top the policy up before policyBalance runs low. setPolicyPaused stops sponsorship instantly if you need to.

Reading a user's therm balance#

Any app can show a user their therm balance with a single read. Display it in therms, not in dollars; its value depends on the future base fee.

typescript
import { createPublicClient, http, formatUnits, type Address } from "viem";
import { robinhoodChain } from "./chains";
import { THERM, erc20Abi } from "./therm";

const client = createPublicClient({ chain: robinhoodChain, transport: http() });

export async function getThermBalance(user: Address) {
  const raw = await client.readContract({
    address: THERM.credit, abi: erc20Abi, functionName: "balanceOf", args: [user],
  });
  const therms = Number(formatUnits(raw, 18));
  return {
    therms,
    gas: therms * 100_000,
    swapsCovered: Math.floor((therms * 100_000) / 320_000), // ~320k gas per stock-token swap
  };
}

To keep it live, watch Transfer events to or from the user. Burns by the paymaster appear as transfers to the zero address.

typescript
const unwatch = client.watchContractEvent({
  address: THERM.credit,
  abi: erc20Abi,
  eventName: "Transfer",
  args: { from: user },
  onLogs: () => refreshBalance(),
});