SDK reference#
Two packages. @websitekit/sdk is framework-agnostic and does the real work; @websitekit/react is a
thin layer over it.
Between them they cover five layers of the stack — issuance, secondary market, tenancy, settlement
and rendering. The demand-side layers above tenancy (rental auction, measurement, campaign routing)
are companion products and have no surface here. Where a function exists specifically to support
that future layer it is called out below; setEditorWithSig and buyFor are the two that matter.
The ordering that matters#
Most of this API is unsurprising. One part is not, and getting it wrong is how a buyer ends up signing terms they were never shown.
// 1. Read the quote, the encumbrance, and the CHAIN's clock — at one pinned block.
const context = await readBuyContext(client, site, 'hero.headline');
// 2. Show the user exactly that.
// context.slot.charged is what they pay.
// 3. Build from the SAME context. Never re-read here.
await walletClient.writeContract(buildBuyFrom(site, context));
Three separate failures this prevents:
A torn read. Reading the quote at block N and encumbranceHash at block N+1 means the buyer
confirms a price that never coexisted with the terms they agreed to. expectedTerms then guards a
state that never existed.
A wall-clock deadline. buy compares its deadline against block.timestamp, not your system
clock. Those are close and are not the same number. A user whose clock is ten minutes slow gets
DeadlineExpired on every purchase with nothing in the UI able to explain it. readBuyContext
returns the chain's clock and buildBuyFrom uses it.
A re-quote at submit time. The whole point of expectedTerms is to reject a change made between
quote and execution. Re-reading it just before sending picks up that very change and waves it
through. buildBuyFrom takes the context you displayed; there is no code path that fetches a fresh
one.
@websitekit/sdk#
Pricing#
Byte-identical to Pricing.sol, fuzz-tested against it across 3,024 vectors spanning twelve
admissible configurations.
computeTakePrice(lastPrice, basePrice, elapsedWeeks, decayBps, takeBps, maxDecayWeeks) | { price, effectiveFloor } |
computeSplit(effectiveFloor, price, isUnclaimed, payoutBps, protocolBps) | { charged, payout, protocolCut, siteCut } |
computeBuyBreakdown(lastPrice, basePrice, elapsedWeeks, economics, isUnclaimed) | both of the above, composed in the order the contract composes them |
computeElapsedWeeks(nowTs, lastPurchaseTs) | whole weeks, floored |
Everything is bigint. Never number, never a decimal library: those carry their own rounding
rules, which are not guaranteed to match Solidity's integer truncation op-for-op, and any mismatch
makes a buy transaction revert.
PricingOverflowError is thrown wherever Solidity's checked arithmetic would revert. BigInt has
no word size and uint256 does — without those guards the SDK would happily quote a price the chain
can never charge.
Content#
encodeContent(kind, payload) / encodeText / encodeLink / encodeImage | { bytes, hash, cid } |
readContent(bytes, expectedHash) | verify then decode — the only function a renderer should call |
decodeContent(bytes) | splits the header; does not verify |
contentHashToCid(hash) / cidToContentHash(cid) | the hash is the address |
readContent returns a discriminated result rather than throwing. hash-mismatch means the bytes
are not what the chain committed to and must never render. unknown-version means the slot was
written by a newer SDK than yours — a different problem, and the only one an upgrade fixes.
Keys#
slotKey(key) | keccak256(utf8(key)) |
slotTokenId(key) | the ERC-721 token id |
slotKeys(keys) | batch; reports all invalid keys at once and rejects duplicates |
assertValidSlotKey(key) | validate without hashing |
slotKeys rejects duplicates because registerSlots reverts on the second occurrence — after the
first has been written. Catching it locally turns a half-applied transaction into a config error.
Reads#
readSlots(client, site, keys) | the whole board in one call, keyed by your strings |
readSlot(client, site, key) | single-slot convenience |
readSiteTerms(client, site, blockNumber?) | one call, not nine |
readBuyContext(client, site, key) | quote + terms + chain clock, pinned |
readCanEdit(client, site, key, account) | owner or live delegate |
readEncumbrance(client, site, key) | for hand-assembled buys |
readPendingWithdrawal(client, site, account) |
readSlots returns owner: null rather than the zero address, contentHash: null for a slot never
edited, and charged — what this buyer pays, which is the floor on a claim and the take price on
a take. Getting that branch wrong quotes every first purchase 1.4× too high.
Writes#
All builders return a viem writeContract request. The SDK never owns a wallet, picks a nonce, or
decides when to submit.
buildBuyFrom(site, context, opts?) | prefer this |
buildBuy(opts) | when assembling by hand |
buildEdit(site, key, contentHash) | |
buildSetEditor(site, key, editor) | zero address revokes |
buildSetEditorWithSig(site, key, editor, deadline, signature) | relay a grant the owner signed |
editorGrantTypedData(opts) | the EIP-712 payload to sign |
buildRegisterSlots(site, { key: floor }) | site owner only |
buildSetFloor(site, key, floor) | ±20%, 24h apart |
buildWithdrawFor(site, account) | permissionless; pays the owed address |
buildCreateSite(opts) | one transaction, board included |
recipient on a buy switches buy → buyFor: the payer pays and the recipient owns. That is what
makes sponsored and gasless purchases possible, and it exists because hardcoding msg.sender
already killed one implementation in a sibling project.
Change credits the payer, not the recipient. A relayer forwarding a user's funds has to be able to forward the remainder back.
buildCreateSite duplicates the contract's clamps deliberately — the only place in the SDK that
does. The contract is the authority and reverts, but a revert costs a deploy transaction and gives
you an error selector; this gives you a sentence.
Config#
export default defineSite({
address: '0x…',
chain: robinhoodTestnet,
slots: {
'hero.headline': { kind: 'text', floor: '0.05' },
'hero.image': { kind: 'image', floor: '0.04' },
'nav.cta': { kind: 'link', floor: '0.03' },
},
});
Floors are ether strings. This is the one number typed by hand, and a string keeps 0.002 out
of a JS float. Everything validates at import time, so a typo'd key stops a build rather than
producing a slot that quietly never resolves.
@websitekit/react#
<SlotProvider config={config} client={publicClient} initialSlots={serverSlots}>
<Slot id="hero.headline" as="h1" fallback="Ship faster." />
</SlotProvider>
Always import it as Slot, never as a bare default — slot is crowded frontend vocabulary (Web
Components have a native <slot>; React has a "slots" composition pattern) and a named import is
how a reader tells which one is meant.
<SlotProvider> | owns the single page-wide read; polls on focus by default |
<Slot> | renders content or fallback |
<BuyDialog> | unstyled default over useBuy |
useSlot(key) | { definition, state, content, showFallback } |
useBuy(key) | prepare() → show → buildRequest() |
useWebsitekit() | the raw context |
The provider owns the read because "a page with twelve slots is one RPC call" only holds if it does. Twelve components each fetching their own slot would quietly reintroduce the indexer this design exists without.
<Slot id="…"> is not forwarded to the DOM — it is an on-chain identity, not an HTML id, and
spreading it would collide with your page's own id namespace. It is emitted as data-slot.
Payload conventions for the built-in kinds — a client convention, not part of the wire format:
| kind | payload |
|---|---|
text | UTF-8 |
link | JSON { href, label } |
image | the raw encoded image |
video | JSON { src } |
Doing something else is fine: use useSlot() and render it yourself.
Tenancy — delegated editing#
This is the primitive the rental layer attaches to. A grant confers the right to edit a
position — to write the content that renders — without transferring the position itself. The holder
retains the asset, the income right, and revocation. An advertiser needs to place creative, not to
custody an NFT, and this is the split that makes that possible.
Immediate uses: an agency managing a client's position, a scheduler rotating creative, or a product editing on behalf of a holder who has no wallet.
buildSetEditor(site, 'hero.headline', agencyAddress) // owner signs and sends
buildSetEditorWithSig(site, key, editor, deadline, sig) // owner signs, anyone relays
A grant expires on its own. There is no clearing logic anywhere, which means no clearing logic to forget on a path added later:
- a transfer or marketplace sale moves
owneraway from the grantor → dead - a take moves the slot's take count past the grant's stamp → dead
- an owner who is taken and then buys the slot back does not resurrect the old grant — the take count moved, so re-granting is a deliberate second signature
The signed variant checks against the current owner, so a signature from someone since taken out of the slot is dead on arrival. Verification goes through ERC-1271, so smart accounts work — which matters, because a walletless user ends up holding exactly one of those.
Why the signed variant is load-bearing for the rental layer. A booking flow has to settle a
tenancy without the holder sending a transaction — the holder signs an offline authorisation, the
exchange relays it, and gas is somebody else's problem. setEditorWithSig plus buyFor (payer and
recipient are separate parties) are the two functions that let a layer above transact on a user's
behalf without custody. Everything else in this SDK assumes the wallet holder is the sender.
And the constraint that layer inherits: a grant dies on displacement, so a tenancy is not a duration guarantee. "Thirty days on this position" is underwritten by the holder, not by the contract. Price or insure that risk above; do not assume it away.
The EIP-712 domain binds the clone's address. Without that, a signature scoped to one site would replay on every other site cloned from the same bytecode, which is every site on the chain.