websitekit

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 buybuyFor: 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:

kindpayload
textUTF-8
linkJSON { href, label }
imagethe raw encoded image
videoJSON { 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 owner away 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.