websitekit

websitekit#

The issuance and settlement layer for tokenized page inventory.

A publisher registers regions of a rendered page as discrete, transferable positions. Investors acquire those positions at issue and trade them in a permissionless secondary market. A holder can then delegate write access on a position without surrendering it — which is the primitive a rental market for advertising demand plugs into.

That last sentence is the thesis. Conventional ad inventory is rented by the impression and settled by an intermediary that owns the measurement, the auction and the ledger. websitekit unbundles it: the publisher sells the position once, the market prices it continuously, and the holder retains a rentable asset. The SDK is the first layer of that stack.

pnpm create websitekit my-site
cd my-site && pnpm install && pnpm dev

No wallet, no credentials, no API key. The page reads a live board off Robinhood Chain testnet and renders.


The three-party model#

partyroleprimitivestatus
PublisherDefines inventory, sets floors, freezes terms of issue. Receives issuance proceeds and a fixed cut of every resale.createSite, registerSlots, setFloorships
HolderAcquires a position at issue or by displacing an incumbent. Holds a transferable ERC-721 with an income right and a write right.buy, buyFor, ERC-721 transferships
TenantWrites content into a position they do not own, under a grant from the holder.setEditor, setEditorWithSig, editprimitive ships; marketplace does not

The publisher and holder legs are complete and live. The tenant leg has its on-chain primitive — delegated editing — but no discovery, pricing or settlement layer above it. Demand routing, bidding and measurement are companion products and are not built. Nothing in this document claims otherwise.


The stack#

  ┌─────────────────────────────────────────────┐
  │  demand      advertisers, campaigns          │   companion products — not built
  │  exchange    rental auction, measurement     │   companion products — not built
  ├─────────────────────────────────────────────┤
  │  tenancy     delegated write access          │   @websitekit/sdk       ships
  │  secondary   take · decay · payout           │   SlotSite           ships
  │  issuance    register · floor · claim        │   SlotSite           ships
  │  settlement  pull payments, per-site funds   │   SlotSite           ships
  │  rendering   hash-verified content, SSR      │   @websitekit/react     ships
  └─────────────────────────────────────────────┘

Each layer is usable without the ones above it. A publisher can issue inventory and be paid today with no rental market in existence; a holder who never rents their position still earns on resale. The upper layers add demand, they are not a prerequisite for the lower ones to settle.


1. Inventory definition#

A slot is identified by a dotted key, hashed client-side:

tokenId = uint256(keccak256("hero.headline"))

The contract only ever sees a bytes32, so it never learns the shape of a page. That is what lets one audited implementation serve every publisher without a per-site schema.

Keys are permanent. Renaming one does not rename a slot — it addresses a different, unregistered slot and orphans whatever the old key held, along with whoever paid for it. Add and retire keys freely before issue; treat them as immutable afterwards.

Keys are lowercase alphanumeric segments joined by ., _ or -, enforced by the SDK rather than the contract, so that Hero.Headline and hero.headline — which hash to entirely different slots and are indistinguishable in a screenshot — cannot both end up in one config.

Inventory is closed by default. An unregistered key cannot be bought. Without that, anyone reading a publisher's repository could acquire hero.headline at floor the day before launch.

Which regions to issue#

Issue the periphery first. A position left open in the middle of editorial content — a missing listing row, an empty card — degrades the page and reads as a fault rather than as available inventory. An open announcement strip, nav link or footer link reads as an offer.

<Slot id="announce.bar" fallback="This strip is for sale — 0.00005 ETH, and every reader sees it first." />

registerSlots is in the freely-mutable set, so inventory can be expanded at any time. Starting narrow costs nothing and lets the market price a low-risk position before the publisher commits their headline.


2. Primary issuance#

An unclaimed position is acquired at its floor — the publisher-set reserve. Proceeds split three ways, and the publisher takes nearly all of it:

at issueon resale
buyer pays1.00× floortakeBps × effective floor
displaced holder receivespayoutBps × effective floor
protocol takesprotocolBps × effective floorprotocolBps × effective floor
publisher receivesthe remainder — 0.95× floor at default termstakeBps − payoutBps − protocolBps

The publisher's resale cut is a frozen parameter, not a negotiated rate: it is the spread between what a displacing buyer pays and what the incumbent is owed. At the four reference configurations it ranges from 0.15× to 0.75× of the floor per resale.

Floors are mutable within a rate limit — ±20% per change, 24 hours apart — so a publisher can track demand without being able to strand an incumbent with a sudden repricing.

Settlement is pull, never push. Proceeds accrue to pendingWithdrawals and are claimed with withdraw(). withdrawFor(account) is permissionless and always pays the address that is owed rather than the caller, so funds cannot be stranded by a party that never returns.


3. The secondary market#

Four rules. Everything else is a consequence.

ClaimAn unissued position costs its floor.
TakeDisplacing the incumbent costs takeBps × the effective floor — 1.4× by default.
PayoutThe displaced holder receives payoutBps × the effective floor — 1.15× by default, and never less than the floor itself.
DecayAn untouched position amortises toward its floor at decayBps per week.

Price discovery#

There is no oracle and no external price feed. Discovery is the ratchet: each take writes a new lastPrice at takeBps above the last, so contested inventory climbs geometrically as buyers displace one another. Decay is the counterweight, walking an untouched position back toward its floor so that stale inventory becomes acquirable again rather than being permanently priced at its last peak.

The consequence worth internalising: the price tracks trading activity, not traffic. A position amortises at the same rate whether one visitor or a million saw the page, because only a transaction moves the price. Measurement is precisely what the exchange layer is for, and it does not exist yet.

Decay is iterative, not closed-form. price × decayᶰ is a different number from what the chain charges, because integer division truncates once per period. Any client-side quote must use computeTakePrice from @websitekit/sdk, which is byte-identical to the Solidity.

Position economics#

A displaced holder always recovers at least the floor. That is the invariant, it holds for every input, and it is enforced by payoutBps >= 10_000 in the implementation's bytecode.

It is not a guaranteed profit. The payout is a multiple of the current effective floor, not of what the holder paid, and decay separates the two. At the default 0.9/week:

displacedrecovers
within 1 week of acquisition~1.035× cost — gain
after 2 weeks~0.93× — loss
after 4 weeks~0.75× — loss
ever, at any pointat least the floor

A holder who acquired at floor is always made whole, since decay cannot push the effective floor below it. A holder who paid a displacement premium and then held carries the amortisation, and can be underwater. That carry is the mechanism that prevents indefinite squatting on high-value inventory, and it is not risk-free capital.

An earlier revision of this document asserted that displacement was always a profit event. It is not, and the invariant suite found it: 33 of 73 simulated takes credited the displaced holder less than they paid.

One boundary condition: at floors small enough that floor × payoutBps truncates — a 1 wei floor — the margin rounds away entirely and the payout is exactly principal. Do not issue at dust floors.


4. Tenancy — separating ownership from control#

This is the layer the demand side will attach to, and its primitive already ships.

buildSetEditor(site, 'hero.headline', tenantAddress)      // holder signs and sends
buildSetEditorWithSig(site, key, tenant, deadline, sig)   // holder signs, anyone relays

A grant confers the right to edit a position — to write the content that renders — without transferring the position itself. The holder keeps the asset, the income right, and the ability to revoke. That separation is what makes a rental market possible at all: an advertiser needs to place creative, not to custody an NFT.

The signed variant matters more than it looks. It is verified through ERC-1271, so a smart account works, and it lets a tenant relay a grant the holder signed offline — the mechanic a hosted booking flow needs in order to settle a rental without the holder sending a transaction.

Grant lifetime#

A grant expires on its own. There is no clearing logic anywhere, which means there is no clearing logic to forget on a code path added later:

  • a transfer or marketplace sale moves owner away from the grantor → dead
  • a take moves the position's take count past the grant's stamp → dead
  • a holder who is displaced and then re-acquires does not resurrect the old grant — the take count has moved, so re-granting is a deliberate second signature

The EIP-712 domain binds the clone's address. Without that, a signature scoped to one site would replay against every other site cloned from the same bytecode, which is every site on the chain.

The design consequence for the exchange layer: because a grant dies on displacement, a tenancy is not a duration guarantee. A rental sold as "30 days of this position" is underwritten by the holder, not by the contract, and the exchange layer has to price or insure that risk rather than assume it away. The primitive is deliberately the minimal one; the guarantee is a product decision for the layer above.


5. Terms of issue#

createSite freezes the economics permanently. There is no setter, no admin key and no timelock, because there is nothing to time-lock.

takeBpsFrozen. ≤ 30,000 (3×), and must exceed payoutBps + protocolBps.
payoutBpsFrozen. ≥ 10,000, so a displaced holder always recovers at least the floor. Not necessarily their cost — see above.
decayBpsFrozen. 1–10,000. 10,000 disables decay.
maxDecayWeeksFrozen. ≤ 52.
cooldownSecsFrozen. ≤ 7 days.
per-slot floorMutable, ±20% per change, 24h apart.
treasury, pause, metadata, royalty, slot registrationFreely mutable.
protocolBpsSet in the implementation. A clone cannot strip it.

This is what makes a position underwritable. An investor evaluating inventory reads the terms once and knows the issuer cannot dilute them afterwards — no rate change, no unilateral repricing, no governance action. That is the entire reason it is rational to take a position in a market whose operator is a stranger.

The cost is symmetrical: a bad parameter cannot be fixed. Getting it wrong means issuing a fresh site and abandoning everyone who bought into the old one. The clamps are enforced in bytecode, so a configuration that farms takers — payoutBps: 0 — cannot be deployed at all.


Architecture#

SlotFactory  ──createSite()──▶  a clone of SlotSite
                                 its own ERC-721 collection
                                 its own funds, its own inventory
                                 tokenId = uint256(keccak256("hero.headline"))
                                      │
                     writes (wallet)  │  reads (one getSlots call)
                                      ▼
                          @websitekit/react in the publisher's app
                          hash-verifies bytes before render
                                      │  fetch by hash
                                      ▼
                          content-addressed storage

Every site is its own contract. Not a row in a shared registry — an EIP-1167 clone with its own address, its own ERC-721 collection and its own funds. A defect in one site's treasury cannot reach another's, because they are different contracts. This is also what makes the publisher's position custodially clean: nobody else's inventory shares a balance with theirs.

A whole page is one RPC call. getSlots(bytes32[]) returns every position at once. No indexer, no database and no socket in the read path. Those exist as an optional hosted tier for publishers who outgrow the default; they are not a prerequisite for rendering.


Settlement of content#

The chain stores a 32-byte hash. The bytes live off-chain:

object      = [schemeVersion:u8][kind:u8][payload…]
contentHash = sha256(object)

payload is opaque. Structure it however the application needs — the framework hashes bytes and never learns the vocabulary, which is what stops a framework-wide content schema from accreting fields it can never remove.

sha256 rather than keccak, so the hash is an IPFS CIDv1 raw address. A client holding nothing but the on-chain hash can construct a gateway URL. No storage adapter, no mapping table, no backend on the read path.

Three operational facts#

Reads are backend-free; writes are not. Something has to place the bytes somewhere retrievable by hash. A hash committed on-chain whose bytes were never uploaded is a permanently blank position that looks exactly like an SDK defect. Three tiers: a public pinning gateway (adequate for text), the publisher's own upload route, or a hosted service.

The scaffold ships the middle tier — app/api/content/[cid]/route.ts serves files from content/, so pnpm dev renders real content with no credentials. Swap contentUrl in websitekit.config.ts when there is somewhere durable to put bytes.

Resolve content server-side where possible. <Slot> fetches on the client by default, so the first paint is fallback and the real content appears after hydration — a visible flash, and a crawler that only ever sees the placeholder. On a page whose premise is that the content belongs to somebody, serving the placeholder to indexers is the wrong output. The scaffold resolves and verifies in the Server Component and passes <SlotProvider initialContent>.

Availability is not guaranteed by the chain. A position can report as held and priced while its content 404s. <Slot> degrades to fallback on any failure and never renders unverified bytes — including images, which go through fetch-and-verify rather than <img src={gateway}>, because a browser painting unchecked bytes defeats the verification entirely.

Payloads are capped at 1 MiB, enforced at encode time so it fails before signing.


The fallback prop#

<Slot id="hero.headline" as="h1" className="text-6xl" fallback="Ship faster." />

fallback renders whenever there is no verified content — unissued, never edited, gateway down, hash mismatch, or written by a newer scheme version than the client knows. At launch that is most of the board.

This is what makes a page look finished before any inventory has sold, and it is the difference between a product and a grid of empty boxes. Write real copy into it. A site whose storage has gone entirely dark should still look like the thing it is pretending to be.

For open inventory, quote the price in the fallback. An open position that advertises its own floor converts; one that renders blank reads as breakage.


Reference deployments#

Robinhood Chain testnet (46630). One chain at v1, deliberately — every chain needs its own implementation deploy, its own audit sign-off and its own address, and multi-chain is a support surface rather than a feature.

Both contracts are verified. Because every site is a canonical EIP-1167 clone, Blockscout auto-detects the proxy — verifying the implementation once gives every site cloned from it a readable contract page, permanently.

Mainnet is gated on an audit. Nothing here has been audited.


Reference inventory configurations#

Four boards, live on testnet, cloned from the same implementation. They differ in the only two dimensions that vary between publishers: what is carved into inventory, and the terms frozen at issue. Their amortisation windows span 4 to 52 weeks — the full range the contract permits.

The Weekly Dispatch — a newsletter archive#

take 1.4× · payout 1.15× · decay 0.95/week over 26 weeks

announce.bar  nav.link.1
masthead.title  masthead.tagline  sponsor.primary
issue.latest.sponsor  issue.prev.sponsor
recommended.1  recommended.2  recommended.3   footer.credit  footer.link.1

Sponsorship is already the revenue model; this makes the position tradable. The slowest amortisation of the four, because archive inventory retains value long after publication — a holder of sponsor.primary keeps most of their position for months.

DevConf Autumn — a conference site#

take 2× · payout 1.2× · decay 0.9/week over 4 weeks

announce.bar  nav.link.1
sponsor.headline
sponsor.gold.1  sponsor.gold.2  sponsor.gold.3
sponsor.silver.1  sponsor.silver.2   booth.1  booth.2   schedule.note  footer.link.1

Sponsor tiers are an auction conducted over email; this runs it on-chain. The steepest displacement premium of the four — , so the publisher retains the widest spread on every contested upgrade — and a 4-week window, because dated inventory has no use for a year-long amortisation tail.

Remote Roles — a job board#

take 1.3× · payout 1.1× · decay 0.85/week over 8 weeks

nav.link.1
banner.top
featured.1 … featured.5
category.design.sponsor  category.eng.sponsor   footer.link.1  footer.link.2

Listings churn, and stale featured inventory is worse than empty inventory. The lowest displacement premium — friction suppresses turnover, and turnover is the objective — and the fastest amortisation: unrefreshed inventory returns to floor within two months and reopens.

Vaultline — a DeFi protocol#

take 1.6× · payout 1.2× · decay 0.9/week over 52 weeks

announce.bar  nav.link.1
hero.headline  hero.sub  hero.cta
integration.1 … integration.4
ecosystem.1  ecosystem.2  ecosystem.3   audit.note  footer.link.1  footer.link.2

The configuration whose off-chain analogue is already a market: ecosystem placement, launch-partner rows and integration listings are bought and sold today through business development, at business-development latency. This runs the same trade at market latency. The full 52-week window, because ecosystem positions are long-dated.

Note what is deliberately not inventory: the TVL and volume figures. A protocol that sold its own metrics would be selling the right to misstate them. Issue attention; never issue facts.

Exported as EXAMPLE_SITES from @websitekit/sdk and rendered as live pages by apps/websitekit-site, with per-position revenue computed through computeSplit from each board's on-chain terms.


Operational notes#

  • Positions are ERC-721 and marketplace-listable. A position sold on a marketplace for 5 ETH remains takeable at its on-chain price of 0.4 the same minute. That spread is permanently arbitrageable and the arbitrage is the mechanism functioning as designed — but a buyer arriving from a marketplace will not know it. Disclose it in tokenURI metadata.
  • A transfer moves owner and nothing else. lastPrice and lastPurchaseTs survive untouched, so a wash trade cannot launder a cost basis downward or reset the amortisation clock.
  • Liquidity requires contested attention. Inventory in front of a few hundred visitors sits at floor indefinitely, and a market with no displacement reads as broken rather than as calm. This is the single largest determinant of whether a deployment works, and no amount of contract correctness substitutes for it. It is also the specific problem the exchange layer exists to solve, by routing external demand into inventory that would otherwise be idle.

Reference#

  • SDK reference — every exported function, and the ordering that matters
  • Design spec — why each decision was made, and what was rejected
  • Status / handoff — what is built, what is live, what is left