@boostxyz/tbi-sdk
v0.4.0
Published
TypeScript SDK for Boost time-based incentives
Readme
@boostxyz/tbi-sdk
Framework-agnostic TypeScript SDK for Boost time-based incentives. It wraps the public /v1/ TBI API, returns SDK-native types (bigint, viem Address/Hex, Date | null), and includes viem-friendly claim helpers.
V1 does not ship React hooks, Vue/Svelte adapters, or UI components. Use the methods here with your framework's data-fetching layer of choice.
Documentation
- Quickstart — build your first integration: discover a campaign, read a user's rewards, and claim, in one short walkthrough. Start here.
- Concepts — how time-based incentives work, plus campaign lifecycle, modes, APY fields, merkle claims, and forwarder deposits. Read this to understand the domain.
- API reference — every method, type, and field, generated from source. Regenerate after changing
src/withpnpm --filter @boostxyz/tbi-sdk docs. To browse everything — these guides plus the full reference — as one searchable HTML site, runpnpm run docs:openinsidepackages/sdk(written to the gitignoredbuild/docs/).
This README is a cookbook of the common flows — install, discover campaigns, and claim. For a guided first integration, follow the Quickstart.
Install
pnpm add @boostxyz/tbi-sdk viemnpm install @boostxyz/tbi-sdk viemviem is a peer dependency because claim helpers accept viem WalletClient instances and exported types use viem Address/Hex.
Create a Client
import { createTbiClient } from "@boostxyz/tbi-sdk";
const tbi = createTbiClient();Use a non-production API or custom headers when needed:
const tbi = createTbiClient({
baseUrl: "https://staging-api.example.com",
refId: "partner_demo",
retry: { attempts: 2, baseDelayMs: 100 },
});Boost assigns partner refId values manually. When configured, the SDK sends it on every API request using the X-Boost-Ref-Id header so Boost can attribute SDK/API/Forwarder usage. You can override it per call when needed:
await tbi.campaigns.active(undefined, { refId: "partner_demo_experiment" });Does This Vault Have a Boost Campaign?
const { data } = await tbi.campaigns.active({
target: { chainId: 8453, address: vaultAddress },
});
const campaign = data[0];target.chainId is the event chain where the vault, pool, or token position is tracked. campaign.id.chainId is the reward chain where users claim.
Campaign Discovery
const campaigns = await tbi.campaigns.list({
chainId: 480,
status: ["active", "ended"],
userAddress,
claimable: true,
limit: 20,
offset: 0,
});Single-campaign and stats-only reads:
const campaign = await tbi.campaigns.get({ chainId: 480, campaignIndex: 1 });
const stats = await tbi.campaigns.stats({ chainId: 480, campaignIndex: 1 });Some campaigns are omitted from discovery (campaigns.list, campaigns.active) by design. Direct campaigns.get / campaigns.stats, plus user reward and claim methods, still return them when you have the campaign id.
APY Fields
boostApyBps and protocolApyBps are separate basis-point fields:
boostApyBps: APY paid by the Boost TBI campaign.protocolApyBps: APY from the underlying vault, pool, or protocol.null: unavailable or not meaningful for the campaign state.
Do not add them together unless your UI explicitly wants to present a combined estimate.
function formatBps(value: bigint | null) {
return value === null ? "N/A" : `${Number(value) / 100}%`;
}
console.log(formatBps(campaign.boostApyBps));
console.log(formatBps(campaign.protocolApyBps));Forwarder-Required Campaigns
Discovery methods exclude forwarder-required campaigns by default. These campaigns only reward positions that entered through the Boost Forwarder contract.
Opt in when your integration will route deposits through the Forwarder:
const { data } = await tbi.campaigns.active({
target: { chainId: 8453, address: vaultAddress },
includeForwarderRequired: true,
});
const forwarderOnly = data.filter((campaign) => campaign.requiresForwarderDeposit);Direct campaigns.get(id) returns the campaign regardless of this flag.
Build the ordered transaction list for a supported forwarder target:
const campaign = forwarderOnly[0];
if (!campaign) throw new Error("No forwarder campaign");
const deposit = await tbi.forwarder.buildDeposit({
target: campaign.target,
amount: 1_000_000n,
sender: userAddress,
});
for (const tx of deposit.transactions) {
await walletClient.sendTransaction({
account: userAddress,
chain: walletClient.chain,
to: tx.to,
data: tx.data,
value: tx.value,
});
}buildDeposit currently supports direct same-chain deposits into registered Forwarder targets. It infers the accepted token from the server registry and includes an ERC-20 approval transaction only when the user's allowance is insufficient.
Multi-asset targets (e.g. Lido Earn) accept more than one input asset. Each target lists its directly depositable assets in acceptedTokens, with native ETH represented by the zero address. Pass one of them as inputToken; when omitted, the deposit uses the target's default acceptedToken:
const deposit = await tbi.forwarder.buildDeposit({
target: campaign.target,
amount: 1_000_000_000_000_000_000n,
sender: userAddress,
inputToken: "0x0000000000000000000000000000000000000000", // native ETH
});List supported targets when you need to preflight UI state:
const targets = await tbi.forwarder.targets();
const supported = targets.find(
(target) =>
target.chainId === campaign.target.chainId &&
target.targetAddress === campaign.target.address,
);User Rewards and Claim Proofs
const rewards = await tbi.rewards.forUser(userAddress, {
chainId: 480,
status: ["active", "finalized"],
});
const claimable = rewards.data.filter((reward) => reward.claimable > 0n);Fetch a claim proof:
const proof = await tbi.claims.get(
{ chainId: 480, campaignIndex: 1 },
userAddress,
);
if (proof.claimable > 0n) {
console.log(proof.cumulativeAmount, proof.proof);
}Batch claim statuses:
const statuses = await tbi.claims.statuses(userAddress, [
{ chainId: 480, campaignIndex: 1 },
{ chainId: 480, campaignIndex: 2 },
]);Claim with viem
Short full claim flow:
const proof = await tbi.claims.get(id, address);
if (walletClient.chain?.id !== id.chainId) throw new Error("Wrong chain");
if (proof.claimable === 0n) throw new Error("Nothing to claim");
const { hash } = await tbi.claim({ walletClient, id, address, proof });tbi.claim() fetches the proof for you if proof is omitted:
const { hash } = await tbi.claim({ walletClient, id, address });Dry-run before signing:
const result = await tbi.claim.simulate({ walletClient, id, address });
if (!result.willSucceed) {
console.error(result.revertReason);
}Raw Calldata
Use encodeClaim when your stack submits transactions outside viem, such as ethers, Gnosis Safe, or account abstraction.
const proof = await tbi.claims.get(id, address);
const { to, data, value } = tbi.encodeClaim({ id: proof.id, proof });Ethers:
await signer.sendTransaction({ to, data, value });Gnosis Safe:
await safeSdk.createTransaction({
transactions: [{ to, data, value: value.toString() }],
});Account abstraction:
await smartAccount.sendUserOperation({
calls: [{ to, data, value }],
});encodeClaim passes proof.cumulativeAmount verbatim. The TBI Manager contract subtracts the amount already claimed on-chain.
Claim Multiple Campaigns
Claim specific campaign IDs on the same reward chain:
const result = await tbi.claimAll({
walletClient,
address,
ids: [
{ chainId: 480, campaignIndex: 1 },
{ chainId: 480, campaignIndex: 2 },
],
});
console.log(result.hash, result.claimed);Or let the SDK fetch claimable rewards for one reward chain:
const result = await tbi.claimAll({
walletClient,
address,
chainId: 480,
});claimAll filters zero-claimable proofs, rejects mixed reward chains, and submits one Multicall3 transaction. V1 is all-or-nothing: if one subcall reverts, the whole transaction reverts.
Raw Multicall3 calldata:
const proofs = await Promise.all(ids.map((id) => tbi.claims.get(id, address)));
const { to, data, value } = tbi.encodeClaimAll({ proofs });Referrals
Campaigns can fund one or more referral pools — each in its own token, not necessarily the reward token — that pay referrers a share of what their referred users earn. A referral "code" is just the referrer's wallet address — build links that carry it; there is nothing to mint or register.
Bind a referred user (the referee) before their first deposit. The referee signs an EIP-712 message with their wallet; the first accepted bind wins. A bind is global — one per referee wallet, applying to every campaign — and earns the referrer a share of each referral campaign the referee first deposits into after binding:
const bind = await tbi.referrals.bind({
walletClient, // the referee's wallet, on any network
referrer: referrerAddress, // from your referral link
});
console.log(bind.created); // false when the same bind already existedBind rejections are TbiApiErrors with machine-readable codes: INVALID_SIGNATURE, SELF_REFERRAL, WALLET_NOT_LINKED, REFERRER_NOT_LINKED, CYCLIC_REFERRAL, and ALREADY_BOUND.
Track a referrer's earnings:
const stats = await tbi.referrals.stats(referrerAddress);
for (const entry of stats) {
// One entry per referral pool of each campaign (entry.poolIndex), paid in
// entry.rewardToken. "estimated" while the campaign is live (entry.amount is
// the projected share of the pool); "final" once the post-finalize payout is
// settled. entry.amount is null only from servers that predate the live projection.
console.log(entry.poolIndex, entry.status, entry.refereeCount, entry.amount, entry.referralPool);
// entry.claimTxHash / entry.claimedAt are set once the payout is claimed on-chain.
}Claim after the campaign finalizes and the pool's referral root is published. Referral payouts go through the TBI Manager's claimReferral, which pays out of the pool's own ReferralDistributor contract — the reward-claim methods tbi.claim and tbi.claimAll do not apply — and each is one-shot per pool, claimable only during the distributor's on-chain claim window. Pass poolIndex to claim from a pool other than the first:
const { hash } = await tbi.referrals.claim({
walletClient,
id: { chainId: 480, campaignIndex: 1 },
poolIndex: 0, // from the stats entry; defaults to 0
referrer: referrerAddress,
});Dry-run first, or encode raw calldata for non-viem stacks:
const result = await tbi.referrals.claim.simulate({ walletClient, id, referrer });
const proof = await tbi.referrals.proof(id, referrer, { poolIndex: 0 }); // TbiNotFoundError until published
const { to, data, value } = tbi.referrals.encodeClaim({ proof });Signing the bind outside viem? Build the EIP-712 payload with referralBindTypedData, sign it with the referee's EOA (smart-wallet EIP-1271 signatures are not supported), and submit the signed fields with tbi.referrals.submitBind.
See Concepts — Referrals for the full rules: global first-touch binding, no retroactive attribution, estimated vs. final earnings, and the claim window.
wagmi + TanStack Query
import { useQuery } from "@tanstack/react-query";
import { type Address, type CampaignId, createTbiClient } from "@boostxyz/tbi-sdk";
import { useAccount, useWalletClient } from "wagmi";
const tbi = createTbiClient();
export function useBoostCampaignForVault(chainId: number, vault: Address) {
return useQuery({
queryKey: ["tbi", "campaigns", "active", chainId, vault],
queryFn: () => tbi.campaigns.active({ target: { chainId, address: vault } }),
});
}
export function ClaimButton({ id }: { id: CampaignId }) {
const { address } = useAccount();
const { data: wallet } = useWalletClient({ chainId: id.chainId });
const disabled = !wallet || !address || wallet.chain?.id !== id.chainId;
return (
<button
disabled={disabled}
onClick={() => tbi.claim({ walletClient: wallet!, id, address: address! })}
>
Claim
</button>
);
}Footguns
Bigint Persistence
SDK responses contain bigint. In-memory caches like TanStack Query and SWR handle that fine, but JSON.stringify(1n) throws. If you persist cache data to localStorage or IndexedDB, add a serializer.
export function stringifyWithBigint(value: unknown) {
return JSON.stringify(value, (_, entry) =>
typeof entry === "bigint" ? `${entry.toString()}n` : entry,
);
}
export function parseWithBigint(value: string) {
return JSON.parse(value, (_, entry) => {
if (typeof entry === "string" && /^\d+n$/.test(entry)) {
return BigInt(entry.slice(0, -1));
}
return entry;
});
}Wallet Client Availability
walletClient can be missing until the wallet is connected and on the requested chain. Gate claim calls on all of these:
const canClaim =
!!walletClient &&
!!address &&
walletClient.chain?.id === campaign.id.chainId &&
proof.claimable > 0n;Errors
API errors are typed:
TbiValidationError: invalid query/path/body params (INVALID_PARAMS).TbiNotFoundError: campaign or proof not found.TbiApiError: other API errors, including referral bind rejections — checkerror.code.TbiNetworkError: fetch or JSON parsing failures.TbiClaimError: SDK-side claim validation failures.
Wallet write failures are propagated from the wallet client so integrations can surface wallet-specific rejection or revert messages.
