Appearance
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();