Skip to content

Show a revenue token

Tell a revenue token apart, then show its balance, income, drops, streams and when they end, its page and its mark.

On this page

A wallet or an explorer that knows a token is a revenue token can show more than its balance: the holder's income in each asset, its drops, and each stream with when it ends. This walkthrough builds that view for any token and wallet.

The steps

  1. Ask the tokenizer: tokenizations(token) has a nonzero way only for a token it made. Show anything else as a plain ERC-20.
  2. Read the holder's side: balanceOf, earns, claimable(account, i) for each asset, and the drop box's claimable(dropId, account) for each drop.
  3. Read the token's side: streamSeconds() for the release period, and streamsOf(token) on the tokenizer for its streams.
  4. Turn each stream's end into words. AT_BLOCK counts the parent chain's blocks, about 12 seconds each, so a date comes from the contracts' clock, never from eth_blockNumber. REVOCABLE means someone else can move the stream away: for a Pons door, Pons, after three days' notice. With a time, a move is pending from then.
  5. Link the token's page and draw its mark, from your own copy of it.

In code

With client.ts and abis.ts:

import { erc20Abi, formatUnits, zeroAddress, type Address } from "viem";
import { client, parentBlock, TOKENIZER } from "./client";
import { dropBoxAbi, tokenAbi, tokenizerAbi } from "./abis";

const STREAM = ["Rules ledger", "launched family", "its own market", "pools.trade fee NFT", "Uniswap v4 positions", "Uniswap v3 positions", "outside door"];
const day = (seconds: bigint) => new Date(Number(seconds) * 1000).toDateString();

export async function showRevenueToken(token: Address, account: Address) {
  // Only the tokenizer's record says a token is one of its revenue tokens.
  const record = await client.readContract({ address: TOKENIZER, abi: tokenizerAbi, functionName: "tokenizations", args: [token] });
  if (record.way === 0) return null;

  const t = { address: token, abi: tokenAbi } as const;
  const [symbol, balance, earns, period, assetCount, streams, box, clock] = await Promise.all([
    client.readContract({ ...t, functionName: "symbol" }),
    client.readContract({ ...t, functionName: "balanceOf", args: [account] }),
    client.readContract({ ...t, functionName: "earns", args: [account] }),
    client.readContract({ ...t, functionName: "streamSeconds" }),
    client.readContract({ ...t, functionName: "assetCount" }),
    client.readContract({ address: TOKENIZER, abi: tokenizerAbi, functionName: "streamsOf", args: [token] }),
    client.readContract({ ...t, functionName: "dropBox" }),
    parentBlock(),
  ]);

  // Income to claim, per asset, each in its own decimals.
  const income: string[] = [];
  for (let i = 0n; i < assetCount; i++) {
    const { currency } = await client.readContract({ ...t, functionName: "assetAt", args: [i] });
    const [name, decimals] =
      currency === zeroAddress
        ? (["ETH", 18] as const)
        : await Promise.all([
            client.readContract({ address: currency, abi: erc20Abi, functionName: "symbol" }),
            client.readContract({ address: currency, abi: erc20Abi, functionName: "decimals" }),
          ]);
    const due = await client.readContract({ ...t, functionName: "claimable", args: [account, i] });
    income.push(`${formatUnits(due, decimals)} ${name}`);
  }

  // Drops to claim from the drop box.
  const dropCount = await client.readContract({ address: box, abi: dropBoxAbi, functionName: "dropCount" });
  const drops: bigint[] = [];
  for (let id = 0n; id < dropCount; id++) {
    if ((await client.readContract({ address: box, abi: dropBoxAbi, functionName: "claimable", args: [id, account] })) > 0n) drops.push(id);
  }

  // Each stream and how it ends. A parent-chain block is about 12 seconds.
  const now = BigInt(Math.floor(Date.now() / 1000));
  const ends = (endKind: number, end: bigint) =>
    endKind === 0 ? "forever"
    : endKind === 1 ? `around ${day(now + (end - clock) * 12n)} (parent block ${end})`
    : endKind === 2 ? `on ${day(end)}`
    : endKind === 3 ? (end === 0n ? "can be moved away" : `moves away from ${day(end)}`)
    : "ended";

  return {
    symbol,
    balance: formatUnits(balance, 18),
    earning: earns,
    releasePeriodDays: period / 86_400,
    income,
    drops,
    streams: streams.map((s) => `${STREAM[s.kind]}: ${ends(s.endKind, s.end)}`),
    page: `https://hookr.fun/revenue/${token}`,
    mark: `https://hookr.fun/api/revenue-tokens/${token}/mark`, // fetch it on your server and serve a copy
  };
}

For rHOOKR and the leg collector, it gives a balance of 50,000,000 that earns, a release period of 1 day, the collector's ETH income to claim, and three streams: the pools.trade fee NFT, the Rules ledger and its own market, each forever. For a plain ERC-20 it gives null.

What to keep in mind

  • A Long door reads REVOCABLE, with no time, while its pool could still graduate, which would end the stream, or when the pool's other currency is an ERC-20 whose issuer could block it.
  • An outside door other than the Pons and Long doors is unverified: show what it calls itself, label(), as its own claim.
  • claimable grows every second while income is released, so refresh it rather than add to it.
  • The mark at https://hookr.fun/api/revenue-tokens/<address>/mark is served for hookr.fun's own pages. Fetch it on your server and serve your copy.