@flipperdotfamily/sdk
v0.2.0
Published
Headless TypeScript SDK for flipper.family coin flips (viem): flips, native ETH, token listing, deployments, odds and settlement helpers. Robinhood Chain.
Maintainers
Readme
@flipperdotfamily/sdk
The headless TypeScript SDK for flipper.family: coin flips on whitelisted tokens (the majors, Robinhood stock tokens and curated launchpad tokens; Uniswap v4 and v3 routes, native ETH via WETH), settled by verifiable randomness against a $FLIPPER house bankroll. Built on viem (≥ 2.21.56); framework-agnostic; no React in the default entry. Robinhood Chain (4663) is the launch chain and the default; local forks (31337) are supported too.
For a drop-in UI, use @flipperdotfamily/widget (web component) or its wrappers
(React, Vue, Svelte, Angular).
npm i @flipperdotfamily/sdk viem| Entry | What |
|---|---|
| @flipperdotfamily/sdk | viem client, deployments, API client, listing by venue, native ETH helpers, odds / reject / settlement helpers, token discovery, holder rewards, formatting |
| @flipperdotfamily/sdk/abis | typed as const ABIs (FlipperHouse, FlipperLens, HolderRewards, V4RouteAdapter, V3RouteAdapter, TreasuryVault, ERC20, WETH) |
| @flipperdotfamily/sdk/avatar | deterministic default profile pictures (an SVG string or a data URI); dependency-free, no viem |
| @flipperdotfamily/sdk/react | legacy: the older wagmi-based <FlipWidget />, kept for compatibility. New integrations should use @flipperdotfamily/react. |
Peer dependency: viem@^2.
Quick start (headless)
import { createFlipperClient, flipperChain, resolveDeployment, walletClientFromProvider } from "@flipperdotfamily/sdk";
import { createPublicClient, http, parseUnits } from "viem";
const deployment = await resolveDeployment({ chainId: 4663 }); // Robinhood Chain: live addresses, RPC, API
const chain = flipperChain(deployment);
const publicClient = createPublicClient({ chain, transport: http(deployment.rpcUrl) });
const [account] = await window.ethereum.request({ method: "eth_requestAccounts" });
const walletClient = walletClientFromProvider(window.ethereum, chain, account);
const flipper = createFlipperClient({ publicClient, walletClient, addresses: deployment.addresses });
const house = await flipper.house();
const { flipId, receipt } = await flipper.flip({ token: house.flipper, amount: parseUnits("100", 18), approve: "max" });
const settled = await flipper.waitForSettlement(flipId, { fromBlock: receipt.blockNumber });
console.log(settled.won ? "won" : "lost");Deployments and chains
resolveDeployment({ chainId?, rpcUrl?, apiUrl?, addresses?, deploymentUrl? })→FlipperDeployment{ chainId, liveChainId, name, rpcUrl, apiUrl, explorerUrl, nativeSymbol, blockTimeMs, addresses }.- Explicit
addresses(withhouseandlens) win and nothing is fetched. - Next come the default addresses for the chain (
FLIPPER_ADDRESSES: Robinhood Chain's ship with the SDK) or ones registered withregisterFlipperAddresses(chainId, …); nothing is fetched unless you passdeploymentUrl. - Otherwise the live manifest is fetched from
https://flipper.family/embed/deployment.json(DEFAULT_DEPLOYMENT_URL; passdeploymentUrl: nullto forbid fetching). It throwsDeploymentErrorfor a chain without a deployment. - A fetched manifest must match
CANONICAL_DEPLOYMENTS[chainId](flipper's house and lens, baked into the SDK), orresolveDeploymentthrowsDeploymentError. PassallowUnpinnedDeployment: trueonly for your own deployment of the contracts. Chains without an entry (31337, and every chain until mainnet launch) aren't pinned. - The manifest's
rpcUrl,apiUrlandexplorerUrlmust passsafeHttpUrl(https:, orhttp:on localhost); others are dropped, and the chain's public RPC is used.
- Explicit
CANONICAL_DEPLOYMENTS: flipper's{ house, lens }per production chain. Written intosrc/deployments.ts(and the full address set intoFLIPPER_ADDRESSESinsrc/addresses.ts) from the deployment manifest by the launch script'sreleasestep (contracts/script/pin-sdk.py), before the SDK and widget are published.safeHttpUrl(u)→uwhen it'shttps:(orhttp:on localhost / 127.0.0.1 / [::1]), else undefined.flipperChain(deployment)→ a viemChain(it also supplieswallet_addEthereumChainparams).parseChainId("robinhood" | "local" | "4663" | 4663),PUBLIC_RPC_URLS,DEFAULT_CHAIN_ID(4663, Robinhood Chain),LOCAL_CHAIN_ID(31337),WETH_ADDRESSES.deployment.addresses(FlipperAddresses):house,lens,flipper,rewards(the $FLIPPER token itself: holder rewards are built in),vault,router,weth,v4Adapter,v3Adapter,v3Bridge,partnerRegistry,houseModule,ponsVerifier,stockVerifier,auctionConverter,wethWrapperHook,principalLock,poolManager,multicall3.
Wallet helpers (EIP-1193)
walletClientFromProvider(provider, chain, account?)→ viemWalletClientover any EIP-1193 provider.switchWalletChain(provider, chain):wallet_switchEthereumChain, adding the chain first on 4902. The add always uses flipper's hard-codedPUBLIC_RPC_URLS[chain.id]and explorer, neverchain's RPC, so an overridden read RPC is never saved into the user's wallet. A chain without a hard-coded RPC isn't added: it throwsFlipperError, asking the user to add it themselves.isEip1193Provider(x),rpcErrorCode(err), typeEip1193Provider.
flipper API (token index)
import { createFlipperApi, listingTargetFromApi } from "@flipperdotfamily/sdk";
const api = createFlipperApi({ url: deployment.apiUrl!, partner: "acme" }); // partner → X-Flipper-Partner header (unauthenticated attribution)
const page = await api.tokens({ q: "tsla", limit: 40, offset: 0 }); // { tokens, sections, total, next }
const { token, pools } = await api.token("0x…"); // one token, every eligible poolOnly public, CORS-open reads are used, with no credentials. tokenSection(t) gives the picker group: listed
(flippable), eligible (listable) or unsupported. Reads are rate-limited per end-user IP (20/s): debounce searches.
Listing by venue (permissionless)
Anyone can list an eligible token. Each venue has its own route adapter:
| Venue | Adapter | Call |
|---|---|---|
| v4 | V4RouteAdapter | registerAndList(token, poolKey) |
| v3 | V3RouteAdapter (bridged into v4 by the V3BridgeHook) | registerAndListV3(token, v3Pool) |
const target = listingTargetFromApi(token); // from the API's best pool: { venue: "v4", token, key } | { venue: "v3", token, pool } | …
const check = await flipper.checkListing(target); // adapter check() then a dry run; never throws
if (check.ok) await flipper.list(target, { onSent: (hash) => console.log("sent", hash) });
else console.log(check.reason, check.code, check.routeCostBps);v4CheckReason(code)andv3CheckReason(code)map the adapters'check()codes. The v3 adapter adds 9 NOT_V3_POOL, 10 LISTED_ELSEWHERE and 11 IS_WETH.isV3Hop(poolKey, addresses)says whether a route hop is a Uniswap v3 pool (itshooksis thev3Bridge).- Addresses:
v4Adapter,v3Adapter,v3Bridge.
Native ETH
The house flips WETH. flipEth wraps and flips in one call:
const res = await flipper.flipEth({ amount: parseEther("0.01"), approve: "max", onStep: (s) => console.log(s.step) });
// steps: previewing → (batch-signing → batch-sent) | (wrapping → wrap-sent → approving → approve-sent → signing → flip-sent) → requested
await flipper.waitForSettlement(res.flipId, { fromBlock: res.receipt.blockNumber });
await flipper.unwrapWeth(winnings); // winnings arrive as WETH- Batching. When the wallet reports atomic batching (EIP-5792
wallet_getCapabilities), wrap + approve + flip go out as onewallet_sendCalls(one confirmation,res.batched === true). Otherwise they are sequential transactions.batch: "never"forces the sequential path. - WETH must be listed. If it isn't, the call throws a
FlipperErrorwithdetails.errorName === "WethNotListed"; list it withcheckListing/list(anyone can). - Helpers:
weth(),wrapEth(amount),unwrapWeth(amount),supportsAtomicBatch().
Core client
import { createFlipperClient, oddsShift, rejectReason, describeSettlement } from "@flipperdotfamily/sdk";
import { createPublicClient, createWalletClient, custom, http, parseUnits } from "viem";
const flipper = createFlipperClient({
publicClient, // any viem PublicClient (e.g. wagmi's usePublicClient())
walletClient, // optional; needed for flip / claim / listToken
addresses: { house, lens, rewards, v4Adapter },
});
const amount = parseUnits("100", 18);
const pv = await flipper.preview(token, amount); // eth_call (previewFlip isn't a view)
if (pv.code !== 0) console.log(rejectReason(pv.code, { symbol: "WETH" })?.message);
const { terms } = await flipper.house();
// The house sponsors route costs (LP/hook fees + price impact) out of its edge. The odds only move once the house's
// expected profit would drop below minHouseEdgeBps (2%): winChance = min(base, (1 − routeCost − minHouseEdge) / 2),
// i.e. from about an 8% route cost at the launch base of 45%. `terms` is the base now (see "Edge schedule").
const shift = oddsShift(pv, terms);
if (shift.shifted) console.log(shift.message); // "The odds for this flip are shifted 1.25% due to token fees"
const { amount: fullOdds } = await flipper.maxStake(token, balance, true); // largest stake at base odds
const { flipId, receipt } = await flipper.flip({ token, amount, onStep: (s) => console.log(s.step) });
const settled = await flipper.waitForSettlement(flipId, { fromBlock: receipt.blockNumber });
console.log(describeSettlement(settled, { symbol: "WETH", decimals: 18 }).headline);flip() does the following, in order:
- Takes a fresh preview and throws
FlipperError(kindrejected) when the house would reject. - Approves exactly
amountif the allowance is short, and waits for that approval. - Simulates and sends
flip(token, amount, minWinChanceBps = preview.winChanceBps, deadline = now + 5 min).msg.valueisrandomnessFeeToSend(token, fee): the per-token randomness fee exactly when the adapter's fee is flat (Dice, Pyth Entropy), or × 1.2 (paddedRandomnessFee) when it's gas-priced (Chainlink VRF-style, priced at tx.gasprice; the house refunds the excess). Show users the unpadded fee. - Returns the
flipIdfrom theFlipRequestedlog.
onStep reports previewing → approving → approve-sent → signing → flip-sent → requested.
Gas-price-aware fees. With a Chainlink VRF v2.5-style wrapper, the randomness fee is
gasPrice × (callbackGas + overhead) × premium + L1 cost, evaluated at the transaction's gas price. An eth_call
without a gas price therefore quotes ~0. To handle that:
- Reads: every fee-bearing read (
preview,previews,maxStake,randomnessFeeFor,house) is evaluated at an explicitgasPrice, with explicit gas and a balance-overridden sender (SIMULATION_ACCOUNT). - Gas pricing:
gasFees()is the quote: 5/3 × base fee + tip (never beloweth_gasPrice), with the tip forced to 0 on Arbitrum-family chains such as Robinhood Chain. Fee-bearing reads are evaluated at itsmaxFeePerGas. Writes are sent withtxFees(quote)(client.sendFees()), a cap of 1.2 × the quote = 2 × base, so a base fee that doubles before inclusion still clears. You pay the effective price, never the cap. - Displayed fee:
displayRandomnessFee(token)evaluates the fee atexpectedGasPrice()(base fee + tip). - Sent fee:
flip()sendsrandomnessFeeToSend(token, fee).randomnessFeeIsGasPriced(token)probes once per client, quotingrandomnessFeeForat two gas prices:- A flat fee (Dice, Pyth Entropy) is sent exactly. Padding it would buy nothing, and the house's refund of the
excess fails for a contract wallet without
receive(). - A gas-priced fee (Chainlink VRF) is sent × 1.2 (
paddedRandomnessFee) of the fee quoted atmaxFeePerGas, which covers any price up to the transaction's cap. The house charges attx.gaspriceand refunds the excess. - If the probe fails, the fee is padded, and the probe runs again next time.
- A flat fee (Dice, Pyth Entropy) is sent exactly. Padding it would buy nothing, and the house's refund of the
excess fails for a contract wallet without
Entropy-style providers don't price by gas: on Robinhood Chain, flips draw from Dice Protocol's DiceEntropy, whose fee
is a flat 0.000025 ETH per flip (about $0.07) whatever the gas price and callback budget. The same code handles both
kinds; the gas-priced path matters for VRF-style adapters such as the dev forks' stand-in.
Lifecycle
waitForSettlement(flipId)resolves once the flip leaves Pending. It returns aSettlementwith the status and theFlipSettled,PendingWinResolvedandFlipCancelledlogs.waitForResolution(flipId)follows aWinPendingflip until upkeep (or anyone,resolvePendingWin(flipId)) pays it.settlement.resolved/getFlipEvents().resolved/resolvePendingWin()report what the player received:tokenPaid(the winnings, statusWon) orflipperPaid(the $FLIPPER fallback, statusWonFallback), andflipperSpent, what the house spent buying the winnings. The rawPendingWinResolvedevent'sflipperPaidis that spend on aWonresolution, not a payment;normalizePendingWinResolution(raw, status)does the mapping.- Pending wins:
pendingWins(player),canResolvePendingWin(flipId),resolvePendingWin(flipId),resolveAt(flip). describeSettlement()returns player-facing copy for every status: Won, WonFallback, WinPending, Lost, LostInventory, Refunded, and safe-mode credits.- Deferred flips (drawdown circuit breaker, below):
waitForSettlement(flipId, { onDeferred })callsonDeferredonce when the flip's randomness arrived while the protocol was locked, and keeps waiting (without timing out) until it settles. WithoutonDeferred, the timeout throws aFlipperErrorwhosedetails.errorNameis"SettlementDeferred".settlement.deferred/getFlipEvents().deferredhold theSettlementDeferredlog, andsettlement.partner/getFlipEvents().partnerthe flip'sFlipPartnerterms.
| Method | Notes |
|---|---|
| house(), params(), randomnessFeeFor(token) | lens HouseView plus terms, the base terms now (show those, not params' launch values); the fee is per-token (FLIPPER flips are cheaper) |
| baseTerms(), edgeProgress() | the base win chance, $FLIPPER payout and Kelly multiplier now; the edge schedule's progress (see "Edge schedule") |
| preview(token, amount), previews(token, amounts) | eth_call with explicit gas |
| maxStake(token, hi, baseOdds?) | the largest stake the house accepts for this flip (with baseOdds: at the base win chance). Each flip's max is sized to its own edge (see "Max bet"), so it's found by previewing: FlipperLens.maxStake for $FLIPPER, a client-side preview search otherwise; with a partner, every preview carries the partner suffix |
| flip(opts), waitForSettlement, waitForResolution, getFlips(ids), getSettlement, getFlipEvents, findFlipIds(player, fromBlock) | |
| claimables(user, tokens, { eth? }), claim(token), claimableEth(user) | payments credited instead of pushed (safe mode, failed transfer). The list ends with native ETH (token: zeroAddress, native: true): a randomness-fee excess the house couldn't refund, e.g. to a contract wallet without receive(); claim(zeroAddress) withdraws it. { eth: false } leaves it out |
| flipLimits(), openFlips(player), minStake(token) | per-player limits: { maxOpenPerPlayer, minLiability } (see "Flip limits") |
| surplus(token) | lens.surplus: the house's balance of token (zeroAddress: ETH) less what it owes in it. Should read 0; negative means a claim could fail |
| v4Check(token, key), canRegisterAndList(token, key), registerAndList(token, key) | listing a Uniswap v4 pool through the V4RouteAdapter (whitelisted tokens and pools, or launchpad tokens a verifier vouches for) |
| discoverV4Tokens(opts?) | resumable PoolManager Initialize scan + adapter.check via multicall → deepest valid pool per token |
| holderRewards(user?), claimHolderRewards(), claimStakingRewards() | $FLIPPER holder rewards, built into the token: the stream (rate, periodFinish, totals) and user's claimable (wallet and staking), lifetime and estimated per-day earnings; token.claim() / vault.claimRewards() |
| harvest(), revenueRouter() | RevenueRouter.harvest() (anyone): streams the holders' share to the token. The API's upkeep worker runs it; useful in dev tools |
Errors: every write rethrows a FlipperError whose message is plain English. describeError(err) does the same for any viem error. It matches errors by name, so it works across viem copies.
Partners (ERC-8021)
Anyone can be a partner: a code registered in the PartnerRegistry is active at once, no approval needed. It earns a
share of each flip it brings (on losing flips, a cut of the house's expected profit: 20% in the default tier) and can
hand part of it back to its players as better odds.
const flipper = createFlipperClient({ publicClient, walletClient, addresses, partner: "acme" });
const pv = await flipper.preview(token, amount); // the partner's odds (the call carries the suffix, from the player)
await flipper.flip({ token, amount }); // the flip calldata ends in the suffix: attributed onchainpartneris a code of 1–32[a-z0-9_-](isPartnerCode). The client readsregistry.suffixOf(code)once, the first time it needs it (the registry fromaddresses.partnerRegistry, elsehouse.partnerRegistry()), and appends it as viem'sdataSuffixtoflip,flipEth(the batched flip call too) andpreview.previewsgoes through the lens and stays at base odds;maxStakepreviews with the suffix. No registry, or an invalid code: nothing is appended.partnerSuffix()→ the suffix (or undefined);partnerRegistry()→ its address (or null).- House:
flipPartner(flipId)→{ partnerId, shareBps }(0n: none),partnerAccrued(id),partnerAccruedTotal(),claimPartner(id)→{ receipt, amount }(anyone may call; it pays the partner's current payout address). - Registry:
partnerInfo(id)→{ id, code, controller, payout, discountBps, tier, status, allowSelf }(status 2 active, 3 suspended; 1, pending, is no longer used),partnerIdOfCode(code),partnerTierCutBps(tier),registerPartner({ code, payout, discountBps })→{ receipt, id }(active at once, in the default tier), and, for the partner's controller,updatePartner(id, { payout } | { discountBps } | { controller }). Tier cuts, re-tiering and suspension are the registry owner's (partnerRegistryAbi). - A player can't attribute their own flips (player = the partner's payout or controller) unless the owner allows it.
Drawdown circuit breaker
If the bankroll's NAV per unit (in $FLIPPER) falls below half its all-time high, the protocol locks until the unlocker reopens it.
locked();breaker()→{ locked, navUnits, navAth, lockMinTreasury, unlocker, pendingUnlocker };checkDrawdown()runs the check (anyone may).- While locked,
preview().codeisRejectCode.LOCKED(8) and flips, claims (house, partner, holder rewards on the token), listings, vault actions and harvests revert withProtocolLocked. Both read asPROTOCOL_LOCKED_MESSAGE: "Flips are paused while the treasury is protected. In-flight flips settle after it reopens." - In-flight flips aren't lost: randomness delivered while locked is recorded (
SettlementDeferred), and after the unlock anyone settles it, market-free:isDeferred(flipId),canSettleDeferred(flipId)(dry run:{ ok }or{ ok: false, reason, locked?, notDeferred? }),settleDeferred(flipId)→{ receipt, settlement? }. InsufficientGas(a transaction sent with too little gas for a gas-floored step) has its own message too.HouseParams.maxReservedBps: the cap on all pending liabilities, as a share of the treasury.- The unlocker's tools:
unlock(resetAth)(truere-bases the high to today's NAV), andresetNavAth(newAth)(0n= today's NAV; an out-of-range value revertsAthOutOfBounds, which has its own message).
Edge schedule
The base terms step down as the house's own net buybacks grow, and never step back up: 45% at 2× (2.05× on $FLIPPER) below 10 ETH, 47.5% at 2× on both at 350 ETH and above, linear in between and rounded in the house's favour. That is a 10% edge on token flips and 7.75% on $FLIPPER at launch, 5% on both at the end. Net buybacks are the real ETH the house's settlement swaps move into the $FLIPPER/ETH pool (+ lost token stakes sold, − wins bought); the edge keys on their ratcheted high, so only real losses paid into the house move it. Route costs, partner discounts and the 2% minimum edge still apply per flip, and a flip keeps the terms it was made at.
house().terms/baseTerms()→{ baseWinChanceBps, flipperPayoutBps, kellyBps, scheduled }: the house'scurrentBaseWinChanceBps(),currentFlipperPayoutBps()andcurrentKellyBps(). Field names matchHouseParams, sooddsShift(preview, terms)andpayoutBps(token, flipper, terms)take it directly. A house from before the schedule answers fromparams()(scheduled: false).edgeProgress()→{ netBuybackEth, buybackHigh, fromEth, toEth, winStartBps, winEndBps, payoutStartBps, payoutEndBps, baseWinChanceBps, flipperPayoutBps, progress, tokenEdgeBps, flipperEdgeBps }(progress0–1), or null when the house has no schedule.formatWinChance(bps)("45%", "46.25%") andformatMultiple(bps)("2.025×") never round above what the house gives;houseEdgeBps(winChanceBps, payoutBps)is the house's expected profit at those terms.
Max bet: each flip's max is sized to its own edge
The house caps a flip's liability at min(maxBetBps, k × f*) of the unreserved treasury, where f* is the flip's
Kelly fraction at its final odds and partner share, and k is the Kelly multiplier now: half Kelly at the bankroll's
NAV-per-unit high, sliding linearly to quarter Kelly at a 50% drawdown (where the breaker locks), so bets shrink as a
losing streak deepens. A flip with a thin edge (expensive routes, a big partner cut) gets a smaller max than a flip
with a fat one.
preview().maxLiabilityis that flip's own cap.house().maxLiabilityis only the 5% ceiling: don't size a "max" from it.maxStake()searches previews, so it's Kelly-aware (and partner-aware with apartner).- Above the cap, previews and flips reject with
RejectCode.BET_SIZE(7). HouseParams.kellyBpsis the maximum (0 on a house from before the cap);terms.kellyBpsis the multiplier in force.
Flip limits
The randomness adapter caps open requests globally, so the house keeps one player from holding that capacity with dust:
- Too many open flips: a player may have at most
maxOpenPerPlayerflips waiting for randomness (4 by default:DEFAULT_MAX_OPEN_PER_PLAYER). Beyond it, previews and flips reject withRejectCode.TOO_MANY_OPEN(9), which reads asTOO_MANY_OPEN_MESSAGE: "You have too many flips in progress. Wait for one to land."openFlips(player)counts them. Previews run from the connected account, so code 9 shows before the flip. - Minimum size: a flip's liability must reach
minLiability$FLIPPER (50,000 at launch). Below it, previews and flips reject withRejectCode.AMOUNT(2), which now reads asBELOW_MIN_SIZE_MESSAGE: "This flip is below the minimum size." (key: "BELOW_MIN"; a zero stake,rejectReason(2, { amount: 0n }), still reads "Enter an amount").minStake(token)gives the smallest stake that clears it: exact for $FLIPPER (the liability is 1.05 × the stake), found from a few previews for other tokens (a hair above the true minimum), 0n with no floor, null when the token can't be priced. Pass it torejectReason(2, { symbol, amount, minStake, decimals })to name it.estimateMinStake(amount, liability, minLiability)extrapolates from a preview you already have. flipLimits()→{ maxOpenPerPlayer, minLiability }. A house from before the limits reads as the default cap and no floor.
Team stake (PrincipalLock)
The team's 12.5% of vault shares, locked for good: the principal can never be withdrawn; only the value above it can, to an immutable dev address, where the stake's rewards are swept too.
teamStake()→{ lock, principal, value, withdrawableExcess, pendingVaultRewards, pendingHolderRewards, devAddress, pendingRequest: { shares, assets, readyAt } | null, requestableExcess }(frompendingWithdrawal), or null withoutaddresses.principalLock.withdrawableExcessis capped at the vault's free bankroll.requestableExcessis what a MAX request asks for: show it as the "MAX".sweepTeamRewards("all" | "vault" | "holder")→{ receipt, vaultRewards, holderRewards }(anyone may).- The dev address only (checked before sending, with a plain-English error otherwise):
requestTeamExcess(amount | "max", { cushionBps? })($FLIPPER; it also sweeps), thenwithdrawTeamExcess()→{ receipt, amount }after the vault's cooldown (pays the dev address and sweeps both reward sources), orcancelTeamExcess(). - MAX leaves a cushion.
"max"(ormaxUint256) requests the unqueued excess less 1% of the principal (TEAM_EXCESS_CUSHION_BPS;teamExcessMax(principal, withdrawableExcess, queued, cushionBps)), never negative. Requesting all of it often ends inPrincipalBreachat low volume, when the stake's value dips before the withdrawal completes.{ cushionBps: 0n }withmaxUint256sends the contract's own "everything". Nothing above the cushion: a plain-English error before anything is sent. - The lock's reverts (
ExceedsExcess,PrincipalBreach,AlreadyStaked, and the vault'sProtocolLocked, …) read as plain English.
Any Uniswap v4 token (V4RouteAdapter)
const flipper = createFlipperClient({ publicClient, walletClient, addresses: { house, lens, v4Adapter } });
const { tokens, progress } = await flipper.discoverV4Tokens({ store: indexedDbStore, maxChunks: 120 });
// call again until progress.done: each call scans ≤ maxChunks more 10k-block ranges (newest first) and resumes
// from the store; tokens are sorted by pool depth, each with `v4: { key, depthWei, quote }`
await flipper.registerAndList(tokens[0].address, tokens[0].v4!.key);The scan starts at the PoolManager's first Initialize (Robinhood Chain block 9,505; KNOWN_POOL_MANAGERS holds the
start blocks by chain id). On Robinhood Chain (~200k pools) you need a server index instead: pass pools (for example from
parseV4PoolIndex(await (await fetch(url)).json())) to discoverV4Tokens and it skips the scan. Pools are
pre-filtered locally before check():
- the other side of the pool must be native ETH or one of
quoteCurrencies() - the hook must be zero or
isHookAllowed
Other behaviour:
- Excluded tokens: quote currencies, stablecoins and WETH are never offered as tokens (
QUOTES_AND_STABLESper chain, plus the adapter'squoteCurrencies()). - Labels: pons v2 graduations, whose pools use the meme hook, get
kind: "pons". - Batching: checks run in sequential multicall groups, which keeps rate-limited RPCs (Robinhood: ~3 req/s) happy.
- Caching:
check()results are cached for 5 minutes, and ERC-20 metadata is cached per address. - Reason codes:
v4CheckReason(code)maps the adapter's reason codes to plain English. - JSON transport:
encodeDiscovered/decodeDiscoveredcarry results through JSON, for example a server route.
Default avatars (@flipperdotfamily/sdk/avatar)
Every address gets a default profile picture in the flipper.family icon's format: a flippered animal's silhouette on a deep-ocean gradient disc. There are 25 animals, including orca, narwhal, walrus, sea turtle, puffin, manta ray and plesiosaur. The dolphin is left out because it's the brand mark. The same address gives the same avatar everywhere, so a partner app, the mobile SDKs and flipper.family all show a user the same one. The subpath is dependency-free and separate from the main entry, so importing it adds nothing else to your bundle.
import { avatarDataUri, avatarSpec, avatarSvg } from "@flipperdotfamily/sdk/avatar";
avatarSvg("0x5aAe…eAed", { size: 32 }); // "<svg …>": inline it, or hand it to react-native-svg's <SvgXml>
avatarSvg(address, { size: 64, title: "alice.eth" }); // labelled image (role="img" + <title>); decorative by default
img.src = avatarDataUri(address, { size: 40 }); // "data:image/svg+xml,…" for <img>, CSS or a native image view
avatarSpec(address); // { animal, disc, tint, angle, highlight, seed, … }Case and whitespace don't matter. Checksummed and lowercase addresses give the same avatar. Any string works as the input, an ENS name for example.
sizesetswidthandheight. TheviewBoxis always the icon's0 0 64 64, and the rim never drops below 0.75px. The default is 64.idPrefixscopes the SVG's internal gradient ids. The default comes from the address, so two different avatars inlined on one page never collide. Pass your own prefix (React'suseId(), for example) when you inline the same address twice. A data URI doesn't need this, because its ids are private to the image.Combinations. Version 2 has 33,600: 25 animals × 14 discs × 6 tints per disc × 4 gradient angles × 4 highlight positions. Every tint pairs only with discs it has at least 4.5:1 contrast against.
Raw data. For native renderers,
AVATAR_SILHOUETTESholds each animal's path data (64 × 64 frame, evenodd) andAVATAR_DISCS/AVATAR_TINTShold the gradients.src/avatar/SOURCES.mdrecords where every shape came from (CC0 / public-domain PhyloPic silhouettes, or hand-built). Standalone icon files are inassets/avatars/.Porting. The algorithm is version 2 of
AVATAR_VERSION:- the seed is FNV-1a 32 over the UTF-8 bytes of
address.trim().toLowerCase(); - a SplitMix32 stream picks, in order, the animal, disc, tint slot, angle and highlight, each as
next() % size.
The test suite's golden vectors pin the output, and a port must reproduce them.
- the seed is FNV-1a 32 over the UTF-8 bytes of
The tables are append-only. An avatar is a set of indices into
AVATAR_SILHOUETTES,AVATAR_DISCS,AVATAR_TINTS,AVATAR_ANGLESandAVATAR_HIGHLIGHTS. Reordering, removing or editing an entry reshuffles every user's avatar. New entries go at the end, and are used only afterAVATAR_VERSIONandAVATAR_TABLE_SIZESare bumped, which is a deliberate, announced reshuffle.
Legacy: <FlipWidget /> (@flipperdotfamily/sdk/react)
Superseded by @flipperdotfamily/react, which wraps the framework-agnostic <flipper-widget> and takes any
EIP-1193 provider. This entry stays for existing wagmi integrations.
"use client";
import { FlipWidget } from "@flipperdotfamily/sdk/react";
// inside your existing <WagmiProvider> + <QueryClientProvider>, with Robinhood Chain (4663) in the wagmi config
<FlipWidget
token="0x…" // the token to flip (listed, or $FLIPPER)
addresses={{ house: "0x…", lens: "0x…" }} // or registerFlipperAddresses(4663, {...}) once
theme="dark" // "dark" | "light"
onSettled={(s) => console.log(s.won, s.status)}
onConnect={() => openYourConnectModal()} // called when a disconnected user presses the button
/>What the widget includes
- a mini CSS-3D coin that floats, spins while randomness is pending and lands on the drawn face
- an amount input with MAX
- win chance, payout multiple and potential payout, the randomness fee, and the odds-shift note with a "flip X or less" suggestion
- plain-English reject reasons
- the flip button with approve → flip states and network switching
- the result, including WinPending → resolved, and a claim link for safe-mode credits
Styling
- Styles are self-contained. There is one scoped stylesheet (
.flw/.flw-*), injected through React 19's<style href precedence>, so it is deduplicated and works with SSR. - The host doesn't need Tailwind or a CSS pipeline.
- Fonts use the same tokens as flipper.family (Satoshi for display, Manrope for UI, JetBrains Mono for numbers) when the host loads them, and fall back to the system stack otherwise.
ABIs
The files in src/abis/*.ts are generated from the Foundry artifacts and committed, so builds don't need Foundry.
cd contracts && forge build
pnpm --filter @flipperdotfamily/sdk gen:abis # FOUNDRY_OUT=/path/to/out to point elsewhere
pnpm --filter @flipperdotfamily/sdk build # tsup → dist (esm + d.ts)