Skip to content

Full client reference ​

This page provides a TypeScript reference helper for reading from TABLETS, preparing transactions and validating receipts and events with viem.

This reference code comes from the TABLETS repository and imports the repository-internal @tablets/abi and @tablets/chain-config packages. External integrations must replace those imports with the ABI and deployment configuration for the current Robinhood Chain deployment.

ts
import {
  createPublicClient,
  createWalletClient,
  custom,
  decodeErrorResult,
  defineChain,
  encodeAbiParameters,
  encodeFunctionData,
  getAddress,
  http,
  keccak256,
  parseEventLogs,
  type Address,
  type Hash,
  type Hex,
  type TransactionReceipt,
} from "viem";

import { clayTokenAbi, tabletsAbi } from "@tablets/abi";
import { tabletsProductionDeployment } from "@tablets/chain-config";

const candidateDeployment = tabletsProductionDeployment as unknown as {
  readonly status: string;
  readonly chain: typeof tabletsProductionDeployment.chain;
  readonly contracts: {
    readonly protocol: Address | null;
    readonly protocolDeploymentBlock: number | null;
    readonly protocolCodeHash: Hex | null;
    readonly implementation: Address | null;
    readonly implementationCodeHash: Hex | null;
    readonly token: Address | null;
    readonly tokenCodeHash: Hex | null;
  };
  readonly token: {
    readonly name: string;
    readonly symbol: string;
    readonly decimals: number;
  } | null;
};
const protocol = candidateDeployment.contracts.protocol;
const protocolDeploymentBlock =
  candidateDeployment.contracts.protocolDeploymentBlock;
const protocolCodeHash = candidateDeployment.contracts.protocolCodeHash;
const implementation = candidateDeployment.contracts.implementation;
const implementationCodeHash =
  candidateDeployment.contracts.implementationCodeHash;
const tokenAddress = candidateDeployment.contracts.token;
const tokenCodeHash = candidateDeployment.contracts.tokenCodeHash;
const tokenMetadata = candidateDeployment.token;
if (
  candidateDeployment.status !== "active" ||
  protocol === null ||
  protocolDeploymentBlock === null ||
  protocolCodeHash === null ||
  implementation === null ||
  implementationCodeHash === null ||
  tokenAddress === null ||
  tokenCodeHash === null ||
  tokenMetadata === null
) {
  throw new Error("Deployment metadata is incomplete or inactive");
}
export const deployment = {
  ...candidateDeployment,
  status: "active" as const,
  contracts: {
    protocol,
    protocolDeploymentBlock,
    protocolCodeHash,
    implementation,
    implementationCodeHash,
    token: tokenAddress,
    tokenCodeHash,
  },
  token: tokenMetadata,
};
export const TABLETS_ADDRESS = getAddress(deployment.contracts.protocol);
export const CLAY_ADDRESS = getAddress(deployment.contracts.token);
export const DEPLOYMENT_BLOCK = BigInt(
  deployment.contracts.protocolDeploymentBlock,
);
export const MAX_UINT256 = (1n << 256n) - 1n;
export const confirmationPolicy = Object.freeze({
  confirmationDepth: 12n,
  receiptConfirmations: 1,
});

const rpcUrl = deployment.chain.publicRpcUrls.at(0);
if (!rpcUrl) throw new Error("No public RPC URL is configured");

export const robinhoodMainnet = defineChain({
  id: deployment.chain.id,
  name: deployment.chain.name,
  nativeCurrency: deployment.chain.nativeCurrency,
  rpcUrls: { default: { http: [rpcUrl] } },
  blockExplorers: {
    default: {
      name: "Robinhood Chain Explorer",
      url: deployment.chain.explorerUrl,
    },
  },
});

export const publicClient = createPublicClient({
  chain: robinhoodMainnet,
  transport: http(rpcUrl),
});

type InjectedProvider = Parameters<typeof custom>[0];
const injected = (window as Window & { ethereum?: InjectedProvider }).ethereum;
if (!injected) throw new Error("No injected EIP-1193 wallet was found");

export const walletClient = createWalletClient({
  chain: robinhoodMainnet,
  transport: custom(injected),
});

export type BlockPin = {
  readonly number: bigint;
  readonly hash: Hash;
  readonly timestamp: bigint;
};

export type ExactWrite = {
  readonly account: Address;
  readonly to: Address;
  readonly input: Hex;
};

export function invariant(
  condition: unknown,
  message: string,
): asserts condition {
  if (!condition) throw new Error(message);
}

export function only<T>(values: readonly T[], label: string): T {
  invariant(values.length === 1, `Expected exactly one ${label}`);
  return values[0] as T;
}

export async function getActiveAccount(): Promise<Address> {
  const accounts = await walletClient.requestAddresses();
  const account = accounts[0];
  invariant(account, "Connect one wallet account");
  invariant(
    (await walletClient.getChainId()) === deployment.chain.id,
    `Select chain ID ${deployment.chain.id}`,
  );
  return getAddress(account);
}

export async function pinLatestBlock(): Promise<BlockPin> {
  const block = await publicClient.getBlock({ blockTag: "latest" });
  invariant(block.hash, "Latest block has no hash");
  return { number: block.number, hash: block.hash, timestamp: block.timestamp };
}

export async function pinConfirmedBlock(
  confirmationDepth = confirmationPolicy.confirmationDepth,
): Promise<BlockPin> {
  const latest = await publicClient.getBlock({ blockTag: "latest" });
  invariant(
    latest.number >= confirmationDepth,
    "Chain is too short for the requested confirmation depth",
  );
  const block = await publicClient.getBlock({
    blockNumber: latest.number - confirmationDepth,
  });
  invariant(block.hash, "Confirmed block has no hash");
  return { number: block.number, hash: block.hash, timestamp: block.timestamp };
}

export async function assertPin(pin: BlockPin): Promise<void> {
  const block = await publicClient.getBlock({ blockNumber: pin.number });
  invariant(
    block.hash?.toLowerCase() === pin.hash.toLowerCase() &&
      block.timestamp === pin.timestamp,
    "Pinned block identity changed; refresh state and rebuild the transaction",
  );
}

export async function verifyDeployment(): Promise<number> {
  const [chainId, proxyCode, tokenCode, linkedToken, decimals] =
    await Promise.all([
      publicClient.getChainId(),
      publicClient.getBytecode({ address: TABLETS_ADDRESS }),
      publicClient.getBytecode({ address: CLAY_ADDRESS }),
      publicClient.readContract({
        address: TABLETS_ADDRESS,
        abi: tabletsAbi,
        functionName: "clayToken",
      }),
      publicClient.readContract({
        address: CLAY_ADDRESS,
        abi: clayTokenAbi,
        functionName: "decimals",
      }),
    ]);

  invariant(chainId === deployment.chain.id, "Unexpected public-client chain");
  invariant(proxyCode && proxyCode !== "0x", "TABLETS proxy has no code");
  invariant(tokenCode && tokenCode !== "0x", "CLAY token has no code");
  invariant(
    keccak256(proxyCode).toLowerCase() ===
      deployment.contracts.protocolCodeHash.toLowerCase(),
    "TABLETS proxy runtime identity mismatch",
  );
  invariant(
    keccak256(tokenCode).toLowerCase() ===
      deployment.contracts.tokenCodeHash.toLowerCase(),
    "CLAY runtime identity mismatch",
  );
  invariant(
    getAddress(linkedToken) === CLAY_ADDRESS,
    "TABLETS is linked to a different CLAY token",
  );
  invariant(decimals === deployment.token.decimals, "Unexpected CLAY decimals");
  return decimals;
}

export async function waitForExactTransaction(
  hash: Hash,
  expected: ExactWrite,
): Promise<TransactionReceipt> {
  const receipt = await publicClient.waitForTransactionReceipt({
    hash,
    confirmations: confirmationPolicy.receiptConfirmations,
  });
  invariant(receipt.status === "success", "Transaction reverted");
  invariant(
    receipt.to && getAddress(receipt.to) === expected.to,
    "Receipt destination mismatch",
  );

  const transaction = await publicClient.getTransaction({ hash });
  invariant(
    getAddress(transaction.from) === expected.account,
    "Sender mismatch",
  );
  invariant(
    transaction.to && getAddress(transaction.to) === expected.to,
    "Transaction destination mismatch",
  );
  invariant(transaction.value === 0n, "Unexpected native value");
  invariant(
    transaction.input.toLowerCase() === expected.input.toLowerCase(),
    "Transaction input mismatch",
  );
  return receipt;
}

export function contentHash(
  imageURI: string,
  title: string,
  description: string,
): Hex {
  return keccak256(
    encodeAbiParameters(
      [{ type: "string" }, { type: "string" }, { type: "string" }],
      [imageURI, title, description],
    ),
  );
}

function revertData(reason: unknown): Hex | null {
  if (typeof reason !== "object" || reason === null) return null;
  const direct = (reason as { data?: unknown }).data;
  if (typeof direct === "string" && /^0x[0-9a-f]+$/iu.test(direct)) {
    return direct as Hex;
  }
  const cause = (reason as { cause?: unknown }).cause;
  if (typeof cause === "object" && cause !== null) {
    const nested = (cause as { data?: unknown }).data;
    if (typeof nested === "string" && /^0x[0-9a-f]+$/iu.test(nested)) {
      return nested as Hex;
    }
  }
  return null;
}

export function explainTabletsError(reason: unknown): string {
  const data = revertData(reason);
  if (!data) return "The TABLETS call failed before a transaction was sent.";
  try {
    const decoded = decodeErrorResult({ abi: tabletsAbi, data });
    switch (decoded.errorName) {
      case "BidBelowRequiredTotal":
        return "The supplied total no longer leads. Read current state and rebuild the transaction.";
      case "BidIncreaseBelowRequiredDelta":
        return "The supplied increase no longer leads. Read current state and rebuild the transaction.";
      case "SubmissionAuctionEnded":
        return "Entries are closed for this auction.";
      case "AuctionNotEnded":
        return "The auction has not reached its cutoff.";
      case "AuctionAlreadyFinalized":
        return "The auction is already finalized.";
      case "NotSubmissionOwner":
        return "Only the Entry author can increase it.";
      case "RefundNotAvailable":
        return "This refund is not currently available.";
      case "WinningContentHashMismatch":
        return "The supplied winning content does not match the stored commitment.";
      default:
        return `TABLETS rejected the call with ${decoded.errorName}.`;
    }
  } catch {
    return "TABLETS rejected the call with an unrecognized custom error.";
  }
}

function assertValidJsString(value: string, label: string): void {
  for (let index = 0; index < value.length; index += 1) {
    const codeUnit = value.charCodeAt(index);
    if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
      const next = value.charCodeAt(index + 1);
      invariant(
        next >= 0xdc00 && next <= 0xdfff,
        `${label} contains an unpaired UTF-16 surrogate`,
      );
      index += 1;
    } else {
      invariant(
        codeUnit < 0xdc00 || codeUnit > 0xdfff,
        `${label} contains an unpaired UTF-16 surrogate`,
      );
    }
  }
}

function assertUtf8Limit(value: string, maximum: number, label: string): void {
  assertValidJsString(value, label);
  invariant(
    new TextEncoder().encode(value).byteLength <= maximum,
    `${label} exceeds ${maximum} UTF-8 bytes`,
  );
}

export type EntryContent = {
  readonly imageURI: string;
  readonly title: string;
  readonly description: string;
};

export function validateEntryContent(content: EntryContent): void {
  assertUtf8Limit(content.imageURI, 256, "imageURI");
  assertUtf8Limit(content.title, 160, "title");
  assertUtf8Limit(content.description, 2_048, "description");
  invariant(
    content.imageURI.startsWith("ipfs://") &&
      new TextEncoder().encode(content.imageURI).byteLength > 7,
    "imageURI must be a non-empty canonical ipfs:// URI",
  );
}

export async function readTokenFunds(
  account: Address,
  amount: bigint,
  pin: BlockPin,
): Promise<{ readonly balance: bigint; readonly allowance: bigint }> {
  const [balance, allowance] = await Promise.all([
    publicClient.readContract({
      address: CLAY_ADDRESS,
      abi: clayTokenAbi,
      functionName: "balanceOf",
      args: [account],
      blockNumber: pin.number,
    }),
    publicClient.readContract({
      address: CLAY_ADDRESS,
      abi: clayTokenAbi,
      functionName: "allowance",
      args: [account, TABLETS_ADDRESS],
      blockNumber: pin.number,
    }),
  ]);
  invariant(balance >= amount, "Insufficient CLAY balance");
  return { balance, allowance };
}

export async function ensureBoundedAllowance(
  account: Address,
  amount: bigint,
): Promise<Hash | null> {
  if (amount === 0n) return null;
  const pin = await pinLatestBlock();
  const funds = await readTokenFunds(account, amount, pin);
  if (funds.allowance >= amount) return null;

  const { request } = await publicClient.simulateContract({
    address: CLAY_ADDRESS,
    abi: clayTokenAbi,
    functionName: "approve",
    args: [TABLETS_ADDRESS, amount],
    account,
    blockNumber: pin.number,
  });
  await assertPin(pin);

  const input = encodeFunctionData({
    abi: clayTokenAbi,
    functionName: "approve",
    args: [TABLETS_ADDRESS, amount],
  });
  const hash = await walletClient.writeContract(request); // exactly one send
  const receipt = await waitForExactTransaction(hash, {
    account,
    to: CLAY_ADDRESS,
    input,
  });
  const event = only(
    parseEventLogs({
      abi: clayTokenAbi,
      eventName: "Approval",
      logs: receipt.logs.filter(
        (log) => getAddress(log.address) === CLAY_ADDRESS,
      ),
      strict: true,
    }),
    "Approval event",
  );
  invariant(
    getAddress(event.args.owner) === account &&
      getAddress(event.args.spender) === TABLETS_ADDRESS &&
      event.args.value === amount,
    "Approval event mismatch",
  );
  const allowance = await publicClient.readContract({
    address: CLAY_ADDRESS,
    abi: clayTokenAbi,
    functionName: "allowance",
    args: [account, TABLETS_ADDRESS],
    blockNumber: receipt.blockNumber,
  });
  invariant(allowance === amount, "Bounded approval was not recorded exactly");
  return hash;
}

// Verify deployment metadata and retain the configured CLAY decimals.
export const clayDecimals = await verifyDeployment();