What's new
New components, hooks and helpers for venue builders — and the traps each one exists to close. Current as of venue-kit 3.10.0, connect-react 2.15.0, connect-sdk 1.54.0, connect-wire 1.57.0.
What a venue can use today that it could not last week, and — more usefully — what each one stops you getting wrong. Most of these exist because a venue built the same thing by hand and lost a rule doing it.
Versions: venue-kit@3.10.0 · connect-react@2.15.0 · connect-sdk@1.54.0 · connect-wire@1.57.0.
Community: rooms, profiles, counts (September 2026)
The comment and take layer grew the reads a real venue needs. The whole model — takes vs comments, which component goes on which screen — is on Comments & feeds; this is what changed.
Rooms you can join — useRoomMembership + useRoomBoard (react 2.12)
const { member, members, refused, join, leave } = useRoomMembership() // defaults to 'self'
member is null while unknown and when the read failed — never a guess a button acts on.
refused is only the private-room answer (“this room is not open to join”); any other failure is
error. Join and leave work only for your own venue’s room, which is why 'self' is the default.
useRoomBoard(room) is the room’s ranked members; a private board shown to an outsider is notFound,
not an empty board.
Membership is an invitation, not a posting gate. The server also lets in anyone who follows the
room’s subject, so do not hide a composer because member === false. <RoomFeed> shows a Join card
and leaves the take prompt alone.
A person’s takes — useAuthorTakes + takeReceipt (react 2.13)
const { posts, results, hasMore, loadMore } = useAuthorTakes(wallet) // takes only, no replies
const receipt = takeReceipt(post, results) // open | void | right (oddsBeatenBps) | wrong | null
The profile page’s default tab. Any wallet the person owns gives the same list. results is how each
resolved market ended, as the chain recorded it; an unresolved market is absent. A wrong call carries
no oddsBeatenBps. CLOB markets have no results yet, so a CLOB take reads open even after it
resolved — don’t render that as a confident “still open”. Private-room posts stay hidden from readers
who can’t read the room.
Take counts for a card grid — useTakeCounts (react 2.14)
const { countOf } = useTakeCounts(cards.map((c) => c.id))
const c = countOf(market.id) // { posts, takes, bySide } — or null
One request for up to 100 markets (the sdk splits more). countOf is null while loading or after
a failed read — print nothing, never “0 takes” for a market you haven’t heard about. The numbers are
the same for every reader: private-room posts are left out, because a public card must not reveal
that a private room is discussing a market.
The take prompt asks once per wallet, market and side (react 2.15)
<TakePrompt> / useTakePrompt used to reopen on every buy. Now a wallet is asked once per market
and side; Skip or a posted take counts as answered, and buying the other side asks again. A silent
1-tap send never opens it.
The ranked feed refuses parameters it doesn’t take
GET /v1/social/posts/feed answers 400 unsupported-param for any query parameter not in its
contract. ?author= used to return 200 with somebody else’s posts; a person’s posts are
GET /v1/social/profile/:wallet/posts (useAuthorTakes). The sdk and react send only declared
parameters, so this only bites a hand-built URL.
Upgrading to venue-kit 3.x + connect-react 2.x — the migration, in full:
- React 19 is the floor. Both packages now peer
react >=19.useRecords/<MarketVerdict>lost theirapiUrl. They read the client from<ConnectProvider>, which is what carries the network and the API key — the hardcoded default meant a testnet venue read mainnet, and sent no key. Without a<ConnectProvider>above them they now render nothing rather than throwing; they are decoration and must not take a page down.MarketResults→MarketVerdictCounts(venue-kit). The old name collided with the SDK’s ownMarketResults, which is a different shape.ReceiptSourceRow.responded→status === 'responded'. A boolean could not say “hasn’t run yet”, so an unsettled market rendered every source as failed.ProphecyCheckoutTheme→ProphecyTheme(connect-react). Same shape, correct name.Still true from 2.x:
scopeis required on<Leaderboard>and<MiniLeaderboard>— passscope={null}for the global board. See The leaderboard for why that was worth breaking.
Components
<UserStatus> — the signed-in header corner
<ProphecyProvider onSignOut={() => privy.logout()} /* …wallet, auth, reader */>
<UserStatus onSignIn={login}>
<NotificationBell />
</UserStatus>
</ProphecyProvider>
Badge, balance with claim, a link to the viewer’s own record, and sign-out. Sign-in stays yours — the library has no Privy dependency and cannot sign anyone in, so you pass the callbacks.
Pass your wallet provider’s sign-out to <ProphecyProvider onSignOut> (connect-react 2.18.0).
Every logout() then ends the Privy session too. Without it the wallet stays signed in, and a reload
signs the user straight back in. <UserStatus onSignOut> is still called: use it for venue-side work
such as navigation.
It closes three things a hand-rolled corner gets wrong: signed-out is a real state rather than
null (so the header does not shift twice while the session resolves), sign-out clears both the
Connect session and your wallet, and the claim path defaults to the one that already avoids
double-spending a sponsored redeem.
<MarketVerdict marketId> — how a settled market actually went
"154 right · 44 wrong", or the honest alternative. undecided is not a rounding bucket: a
voided market has no winner, so every holder’s verdict is null — mainnet has one with 1,988 of them.
Folding those into “wrong” tells 1,988 people they called it wrong when the source simply never
answered. This renders three states, and a market nobody held renders nothing (0 right · 0 wrong reads as a market everybody lost).
<RecordChip record> + useRecords(wallets, facet)
Someone’s record at one node, batched so one request annotates a whole thread. Reads through the
<ConnectProvider> client, so it follows your venue’s network and key; with no provider above it,
it renders nothing. Three rules it makes
unavoidable: a rank never appears without its population (#1 of 17,650 and #1 of 8 look
identical otherwise), unranked is a state rather than last place, and edge is a phrase, never a
portable number.
nodeFacetOf(market)
Which node to read a record at — the market’s, never your venue’s. Measured across one venue’s
markets, the same 100 wallets: asking org:spacex found 4 ranked against a population of 8; asking
the markets’ actual node, topic:stocks, found 62 against 1,896. Same page, same people, 4% vs 62%,
decided entirely by which axis you ask.
The leaderboard now makes you say what it ranks
scope is required, qualified renders as the denominator, and the row leads with edge while
the win count sits beside it as the receipt.
An omitted scope used to produce a global board under a venue’s own heading — one shipped that way
for weeks, ranking platform-wide traders under a venue whose markets held 793 trades in total.
scope={null} still gives you the global board; it is now a choice a reviewer can see rather than a
default nobody typed. And a hit rate in the loud position teaches the wrong ranking rule: call a
hundred things already priced at 95¢ and finish the month at 95% having told nobody anything.
Hooks
useProfiles(wallets)
One request for a whole thread. Identity is referenced rather than embedded, so a post row carries a
wallet and you hydrate — and doing that per row is sixty requests for sixty comments. Deduped,
lowercased, keyed on the set so an inline posts.map(p => p.author) does not refetch forever.
<ClientOnly>
The Privy/SSR wrapper, with the reasoning attached. PrivyProvider 500s under SSR, so the wallet
stack cannot be server-rendered — but your market data should be, for SEO and the OG image.
Wrap too much and you lose both; too little and you 500. Everything from Privy down goes inside.
Notification reads now reach every mount
Marking a row read on an activity list used to leave the nav bell — a different mount — showing the
old count for up to 30 seconds. Nothing to change on your side; useNotifications now syncs.
Money and formatting
Collateral maths — wholeUnits, wholeUnitsUp, unitsToWei, moneyLabel, formatAmount
Rounding a balance is a money decision, so it lives in one place. What a wallet has rounds down (a balance of 24.9 reading 25 is a ceiling the wallet cannot cover, and the send fails on-chain); what has been spent rounds up.
moneyLabel returns null when the collateral’s decimals are unknown, rather than assuming 18.
If you have a ?? 18 anywhere, that is the bug it prevents — a 6-decimal payout rendered at 18
turns 207.075 into 0.000000000207075.
Price thresholds read as Above / Below
Binary crypto-price markets used to show their raw bounds — ">= 1900.4101" — on the buttons. They
now carry displayLabel, and label is untouched, so render displayLabel ?? label on a tap target
and keep label wherever the exact figure matters.
Contract additions
PREPARE_ERROR_CLASS
{ retryable, ours } for every market-creation failure reason. ours is what lets you stop
blaming the creator for your venue’s exhausted quota or for Connect’s own unfunded wallet.
retryable means this same request could succeed shortly — so both daily caps are false, since
telling someone to try again against a limit that resets tomorrow is not true.
CHANNEL_DEFAULT_MODE / effectiveMode(prefs, channel, kind)
A missing preference row is not “off”. Defaulting one to off shows a user their notifications switched off while they are still receiving them. In-app defaults to realtime; every external channel is opt-in.
wallets.rank takes a facet
Per-node ranks without a cast. A faceted rank is not a slice of the global one — edge terms are signed, so a subset board can place a wallet higher.
Safety changes you get for free
The sell gate no longer fails open. A custom adapter that never implemented isSellable used to
skip the check entirely, and one whose check failed was read as permission — so an RPC blip walked
a user into a sell the market reverts, paid for in gas. Connect now answers for itself when your
adapter cannot, and refuses when neither can. Your adapter still wins whenever it answers.
Every leaderboard states its population, including the global one.
/v1/markets/counts?by= rejects an axis it cannot group on instead of quietly answering as
category. If you were passing by=country or by=series you were getting category counts under
another heading; you will now get a 400 naming the axes that exist.