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:

  1. React 19 is the floor. Both packages now peer react >=19.
  2. useRecords / <MarketVerdict> lost their apiUrl. 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.
  3. MarketResultsMarketVerdictCounts (venue-kit). The old name collided with the SDK’s own MarketResults, which is a different shape.
  4. ReceiptSourceRow.respondedstatus === 'responded'. A boolean could not say “hasn’t run yet”, so an unsettled market rendered every source as failed.
  5. ProphecyCheckoutThemeProphecyTheme (connect-react). Same shape, correct name.

Still true from 2.x: scope is required on <Leaderboard> and <MiniLeaderboard> — pass scope={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.