# 0xSlots Collectively Owned Slots Protocol # Overview 0xSlots is a property primitive for scarce onchain assets. A **slot** is a position that is always priced and always contestable. You set your own price, pay tax on it continuously, and anyone can buy it from you at that price at any time. * Too high → the tax gets expensive. * Too low → someone takes it. * Honest → you keep it. There is no fourth option. That is the whole design. The tax flows to a **recipient** — a creator, a community, a public good. Idle property becomes costly to hold, and positions drift toward whoever actually values them. ## What a slot is One contract that funds **one recipient** in **one currency** at **one tax rate**, with at most one **module** installed. A slot is immutable by default. Its tax, its recipient and its module can each be declared changeable at creation, and only then does it have a manager. Want a different configuration? Deploy another — slots are cheap and disposable by design. ## The slot is the plot A slot starts empty. It is a position and nothing more. What makes it valuable is what gets built on it. A **module** is the one way to extend a slot: it can define what holding grants — a sponsor placement, a name, an NFT — and set terms of tenancy, like a guaranteed minimum tenure. The slot is the plot; the module is the crop. Not a walled garden you lock people out of — a public garden you tend, where the market decides whether you are tending it well enough to keep it. ## Start here | | | |---|---| | [How a slot works](/concepts/slots) | The mechanism, end to end | | [Modules](/concepts/modules) | Giving a slot a purpose, and controlling who may hold it | | [Getting Started](/getting-started) | Install the SDK, create your first slot | # Getting Started ## Installation :::code-group ```bash [pnpm] pnpm add @0xslots/sdk @0xslots/contracts viem ``` ```bash [npm] npm install @0xslots/sdk @0xslots/contracts viem ``` ::: ## Create a client `SlotsClient` reads and writes slots directly over RPC. Addresses come from `@0xslots/contracts`, which only lists chains the protocol is deployed on — see [Deployments](/deployments). ```ts import { SlotsClient } from "@0xslots/sdk"; import { slotFactoryAddress } from "@0xslots/contracts"; import { createPublicClient, createWalletClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { anvil } from "viem/chains"; const publicClient = createPublicClient({ chain: anvil, transport: http() }); const walletClient = createWalletClient({ account: privateKeyToAccount("0x..."), chain: anvil, transport: http(), }); const client = new SlotsClient({ factoryAddress: slotFactoryAddress[anvil.id], // only createSlot and collectAll need it publicClient, walletClient, // omit for read-only use }); ``` ## Read a slot ```ts const state = await client.slotState("0x..."); // occupant, price, deposit, taxOwed, isInsolvent, secondsUntilLiquidation, // taxRateBps, recipient, module, settings, scopes, pending terms, … const cost = await client.quoteBuy("0x...", buyer, depositAmount); // what buy() charges ``` `slotState` is one `SlotLens.getSlotInfo` call, so every figure is from the same block. ## Create a slot ```ts import { zeroAddress } from "viem"; await client.createSlot({ currency: zeroAddress, // native ETH; or an ERC-20 address manager: zeroAddress, // nothing is mutable, so there is no manager mutableTax: false, mutableRecipient: false, mutableModule: false, taxTerms: { recipient: "0x...", rateBps: 100, // 1% of the price per 30 days minRunwaySeconds: 86_400, // a buyer must fund one day of tax }, // moduleTerms omitted: no module }); ``` To install a module, pass `moduleTerms: { module, settings }` — see [Modules](/concepts/modules). ## Write actions ```ts import { parseEther } from "viem"; // `account` is who gets seated — it need not be the signer. // ERC-20 approval is handled, and the quoted total is sent as `maxPayment`. await client.buy({ slot: "0x...", account: "0x...", selfAssessedPrice: parseEther("10"), depositAmount: parseEther("1"), }); await client.selfAssess(slot, parseEther("20")); await client.topUp(slot, parseEther("0.5")); await client.withdraw(slot, parseEther("0.1")); await client.release(slot); await client.collect(slot); // permissionless await client.liquidate(slot); // permissionless — no bounty, the slot itself is the reward ``` Call `simulateBuy(params)` before `buy` to surface a module's refusal with its own error — a mined revert carries no reason. ## React ```tsx import { useQuery } from "@tanstack/react-query"; import { useAccount } from "wagmi"; import { useSlotAction, useSlotsClient } from "@0xslots/sdk/react"; function SlotView({ address }: { address: `0x${string}` }) { const { address: me } = useAccount(); const client = useSlotsClient(); const { data: slot, isLoading } = useQuery({ queryKey: ["slot", address], queryFn: () => client.slotState(address), }); const { buy, busy } = useSlotAction({ onSuccess: (label, hash) => console.log(`${label}: ${hash}`), }); if (isLoading || !slot || !me) return null; return ( ); } ``` # How a slot works A slot is one contract holding one position. An **occupant** holds it, at a **price** they set themselves, paying tax to a **recipient**. Three rules: 1. **You set your own price**, publicly. 2. **You pay tax on that price**, per second, from a deposit. 3. **Anyone can buy it from you at that price**, at any time. Rule 2 punishes declaring too high. Rule 3 punishes declaring too low. ## The parts ```mermaid flowchart LR Occupant["Occupant
sets price, pays tax"] Slot["Slot
price · deposit · occupant"] Recipient["Recipient
receives tax"] Module["Module
may refuse a buy,
and reacts to what happens
"] Buyer["Anyone
can buy at the declared price"] Occupant -->|"deposit + self-assessed price"| Slot Buyer -->|"buy at declared price"| Slot Slot -->|"tax, continuously"| Recipient Slot -.->|"may this proceed? — then, this happened"| Module classDef core fill:#000,stroke:#000,color:#fff classDef opt fill:#fff,stroke:#999,stroke-dasharray:4 3,color:#333 class Slot core class Module opt ``` | Part | Required | What it does | |---|---|---| | **Occupant** | — | Holds the slot, sets the price, pays the tax | | **Recipient** | yes | Receives the tax, less any module fee | | **Module** | no | One contract that may refuse a buy or a reprice, and is told what happened. Never moves the price | | **Manager** | only if something is mutable | Queues changes to whatever was declared mutable | Solid lines move value. Dashed lines are optional. ## Price and deposit are different Confusing these is the most common mistake. * **Price** — what you say it is worth. A standing offer to anyone. Not held by the contract. * **Deposit** — real tokens you put in, which tax is drawn from. When it runs out you can be liquidated. `minRunwaySeconds` sets how much deposit a buy must bring: enough to cover that many seconds of tax at the declared price, rounded up. It stops someone taking a slot with a huge price and no means to pay for it. `selfAssess` and `withdraw` enforce the same floor against a sitting occupant. ## Lifecycle ```mermaid stateDiagram-v2 [*] --> Vacant: created Vacant --> Occupied: buy() Occupied --> Occupied: selfAssess() / topUp() / withdraw() Occupied --> Occupied: buy() — taken by someone else Occupied --> Occupied: acceptOffer() — the book reprices and seats a bidder Occupied --> Vacant: release() Occupied --> Vacant: liquidate() — deposit exhausted ``` Tax accrues per second, at `rateBps` basis points of the price per 30 days. Nothing runs on a timer — the contract settles whatever is owed at the start of every interaction, so the numbers are correct whenever anyone looks. `collect()` pays accrued tax out — the module's fee, if it takes one, then the recipient — and is permissionless. ## Liquidation When the deposit hits zero, anyone may call `liquidate()`. The slot goes vacant. There is **no bounty** — the reward is the slot: a vacant slot costs only the taker's own deposit, so whoever actually wants it can evict and buy it in one transaction. Tax the deposit could not cover is not forgiven. It is carried as debt against the occupant's address and repaid out of their next buy, buyout proceeds or top-up on that slot. No module can prevent liquidation, and none can stop an occupant leaving. Both paths tell the module what happened, on a fixed gas budget, and ignore it if it fails. The one exception is a module that declares `afterCallbacksMustSucceed`, whose `after` callbacks run uncapped and can fail the transaction. Whether a slot's module did that is readable from `scopes()` before you buy, and cannot change under a sitting occupant. ## Roles | Role | Set | Can | |---|---|---| | **Occupant** | by buying | Set price, top up, withdraw surplus, release, apply ripe terms early | | **Operator** | by the occupant | Reprice for the occupant — the grant lapses with the tenure | | **Recipient** | at creation | Receive tax. Nothing else | | **Manager** | at creation | Queue changes to what was declared mutable, accept the module's new fee or scopes, hand over the role | A slot is immutable by default. Its tax (rate and minimum runway), its recipient and its module are three separate mutability promises, fixed at creation. A manager is required exactly when at least one of them is true, and forbidden otherwise. ## Terms do not move under an occupant A manager's changes only ever **queue**. They wait `TERMS_DELAY` (one hour) and then land at the next `buy` — so the terms you bought into hold for as long as you hold the slot. The occupant may land ripe terms sooner with `applyTerms()`, and once the slot is vacant anyone may. The one change that takes effect at once is a module's **fee**, when the manager accepts it with `acceptFee`. It changes how collected tax is split between the module and the recipient, never what the occupant pays. Recipient and manager are different jobs on purpose — "receives money" and "has admin powers" should not be the same authority. Either can be a plain address or a contract that shares the role among several parties, such as a `SlotCollective`. ## Why all three rules, or none Remove any one and the other two stop working. A price with no tax is a bluff. A tax with no forced sale is just rent. Forced sale with no self-assessment needs someone else to set the price — which is the problem the whole design avoids. ## Next * [Modules](/concepts/modules) — how a slot gets a purpose, and when it can refuse a buy * [Slot reference](/reference/slot) — the full contract API # Modules A slot on its own is a position and nothing more. Holding it grants no rights and refuses no buyer, because there is nothing attached to decide either. A **module** is the one way to extend a slot — the sponsor creative that renders, the NFT that follows the occupant, the seven-day tenure nobody outbids you through. A slot installs **at most one**, as `ModuleTerms { module, settings }`, at creation or later through its manager. Leave it empty for a bare, instant-buy slot. ## The whole rule > `before` decides and may refuse. `after` records and cannot. Every other property of a module falls out of that one line: * **`before*` callbacks are `view`.** The slot staticcalls them without a gas cap — a `view` cannot write, so it cannot reenter. Their only power is to **revert**, which vetoes the action, and the module's own error bubbles up. * **`after*` callbacks are gas-capped and their revert is swallowed.** They run after the action has settled, on `MODULE_CALLBACK_GAS_LIMIT` (500,000), and a failure is logged as `ModuleCallFailed` and otherwise ignored. A broken module can never block a buy, and above all can never block a **liquidation**. * **Unless the module declares `afterCallbacksMustSucceed`.** Then its `after*` callbacks run uncapped and their revert propagates, so work that must land — a mint, a transfer — cannot be silently dropped. The price is that the slot is only as evictable as the module. There is deliberately no way to write during `before`. A module that wants to record something about a decision does it in the matching `after`. ## Validators and hooks Borrowing the vocabulary of ERC-7579, a module can be described by what its scopes let it do: * A module with **`before*` scopes acts as a validator.** It can refuse a buy or a self-assessment. View-only, uncapped. * A module with **`after*` scopes acts as a hook.** It records what happened. Gas-capped, and cannot block anything unless it declares `afterCallbacksMustSucceed`. One module can be both — `MinimumTenureModule` and AdLand are. These are labels, not interfaces: every module implements the same `ISlotModule`, and a slot still installs exactly one. ## The callbacks A slot calls only the ones its module declared. One context struct — `SlotContext` — is passed to all of them; fields that aren't meaningful for a callback are zero. | Callback | Acts as | Fires | |---|---|---| | `beforeBuy` | validator | someone tries to buy the slot | | `beforeSelfAssess` | validator | the occupant (or their operator) reprices | | `afterBuy` | hook | a buy went through | | `afterRelease` | hook | the occupant left | | `afterLiquidate` | hook | an insolvent occupant was removed | | `afterSettle` | hook | tax was taken from the deposit | | `onInstall` | hook | the module became this slot's module | | `onUninstall` | hook | the module is being replaced or removed | ```mermaid flowchart LR subgraph decide["validator — view, may veto"] BB["beforeBuy"] BA["beforeSelfAssess"] end subgraph record["hook — capped, swallowed"] AB["afterBuy"] AR["afterRelease"] AL["afterLiquidate"] AT["afterSettle"] end decide -->|revert = no| Veto["action reverts"] record -->|revert = ignored| Effect["creative clears, NFT moves,
ledger updates…"] classDef d fill:#000,stroke:#000,color:#fff class decide d ``` `onUninstall` is always capped and swallowed, `afterCallbacksMustSucceed` or not: a module that could refuse its own removal could never be replaced. There is no sell callback. A sale through the [OfferBook](/reference/book) is `selfAssess` then `buy`, so it runs `beforeSelfAssess` and `beforeBuy` like any other seating — one seating path, one set of checks. ## Scopes and fee A module says what it wants from a slot in two reads: * `scopes(settings)` — the callbacks it needs, as bits. * `fee(settings)` — an optional share of collected tax (`bps`, paid to `recipient`). Both take the slot's `settings`, so the answer can depend on them: AdLand asks for its `before` scopes only when the config sets a tenure window. The slot **copies** both when the module is installed and never re-reads them on a callback or a payout. The slot only calls the callbacks the copy lists — which matters for the `before` side: calling one on a contract that does not implement it would revert, and a reverting `before` vetoes every buy. A module may later declare something different. The slot keeps its copy until the manager accepts the new value, and the two follow different rules: * **`acceptFee(expected)`** applies at once. It only changes how collected tax is split between the module and the recipient, never what an occupant pays, and tax collected so far is paid out under the old fee first. A fee can be anything up to all of the rent, but **raising** one — or attaching a module that charges at all — needs a slot whose recipient is mutable. * **`acceptScopes(expected)`** queues: new scopes change what the module may do to an occupant, so they land at the next buy, and only on a slot whose module is mutable. A module **proposed** as a replacement is checked the same way. The slot keeps the scopes and fee it read at proposal in `pending()`, reads the module again when it lands, and drops it instead of installing it if either differs — so the delay cannot be used to raise a fee or add `afterCallbacksMustSucceed` after the manager looked. ## Settings `settings` are bytes stored **on the slot** and handed back in every callback as `ctx.moduleTerms.settings`: `abi.encode` of the fields the module's definition lists. It is what lets one deployment serve every configuration: a seven-day and a thirty-day minimum tenure point at the same `MinimumTenureModule`, each with its own window. Because it lives on the slot, a slot whose module is immutable has both halves frozen — which module, and how it is configured. A module keeping the setting in its own storage could rewrite a slot's rules while the slot went on reporting itself immutable. The slot asks `validateSettings(settings)` whenever a module is proposed or installed. A module that rejects its configuration is refused there, rather than installed and vetoing every buy afterwards. There is no size limit: large settings cost whoever builds and uses the slot, and they can see them first. ## Fail-closed, on purpose A validator that reverts — on purpose or because it is broken — makes the action **fail**. A rule about who may take your property should never be skippable because a contract had a bad day. ```mermaid sequenceDiagram participant B as Buyer participant S as Slot participant M as Module B->>S: buy(account, price, deposit, maxPayment) S->>S: settle tax, land ripe terms S->>M: beforeBuy(context) alt module reverts M--xS: revert — reason bubbles up S--xB: transaction fails, nothing moved else module returns M-->>S: ok S->>S: seat the buyer, refund the old occupant S->>M: afterBuy(context) — capped, swallowed end ``` The hook side is the mirror image: capped and swallowed, so a broken effect degrades to "the slot grants nothing" rather than "the slot is frozen." ## What no module can do A module decides and records. It **cannot** move funds, change the price, or redirect the buyer — and two exits never consult a validator: * **`liquidate()`** — an insolvent occupant can always be removed. Otherwise a module could keep someone in a slot they have stopped paying for. * **`release()`** — an occupant can always leave. Otherwise a module could trap you in a position you no longer want. Both still tell the module afterwards, and a `afterCallbacksMustSucceed` module can fail them — which is exactly why `afterCallbacksMustSucceed` is readable from the slot before anyone buys. Short of that, the worst a bad module can do is stop *new* people arriving. A module may only ever say **no**, never touch the price. Common ownership works because the tax and the forced sale pull against each other; let a module move the price and under-declaring becomes profitable, which inverts the whole mechanism. ## Many behaviours, one module A slot points at **exactly one** module, and the core makes **one** call into it per callback. To want several behaviours — a minimum tenure *and* a sponsor creative — you write one module that does both. One contract, one gas budget, one `msg.sender`: every callback costs a known maximum, and the slot is always the caller. AdLand does this by inheriting the `MinimumTenure` rule rather than standing beside a second module. ```mermaid flowchart LR Slot -->|one call per callback| M["Your module"] M --> B1["Minimum tenure"] M --> B2["Sponsor creative"] M --> B3["Fee"] ``` ## Changing a slot's module Only on a slot created with `mutableModule`, and only by its manager. The new `ModuleTerms` are validated when proposed, then wait out the terms delay and land at the next buy (or earlier, through `applyTerms`): 1. the outgoing module gets `onUninstall`; 2. the incoming module's scopes and fee are read afresh — one that no longer answers is dropped (`ModuleDropped`) rather than allowed to block the buy; 3. the incoming module gets `onInstall` — on a buy, once the buyer is seated and before `afterBuy`. Proposing `ModuleTerms` with a zero `module` removes the module. ## Describing a module A module is just an address; on its own it says nothing to a UI. It may *optionally* implement `IModuleMetadata`, whose `metadata()` returns a JSON document: a title, a description, and a JSON Schema for its settings — enough for a client to render a configuration form for a module nobody wrote a screen for. The slot never reads `metadata()`. It is self-reported and unbounded, and the moment the protocol trusted it a label would become an attack surface on the path liquidation depends on. A UI may say "this module *says* it takes a 7-day window"; authority is always the slot's copy of the scopes and fee, and the module's own `validateSettings`. To name the modules it knows, a client uses the catalogue in `@0xslots/contracts` — see [Deployments](/deployments#naming-a-module). ## Next * [How a slot works](/concepts/slots) — the position the module extends * [Module reference](/reference/modules) — `ISlotModule`, `SlotContext`, scopes and fee, the shipped modules * [Slot reference](/reference/slot) — the full contract API # SlotFactory Deploys slots as beacon proxies sharing one implementation. The factory itself is UUPS-upgradeable. A slot's address comes from CREATE2 over the chain id and the factory's slot count — not from its terms — so a slot is identified by its address. ```solidity function createSlot(SlotInit calldata init) external returns (address slot); event SlotCreated(address indexed slot, address indexed recipient, address indexed creator, address currency, address module); ``` One call, one struct. A new slot parameter goes into `SlotInit`, never into a second creation function. ## SlotInit ```solidity struct SlotInit { IERC20 currency; // address(0) = native ETH address manager; // required exactly when something is mutable; zero otherwise bool mutableTax; // rate and minimum runway bool mutableRecipient; bool mutableModule; TaxTerms taxTerms; ModuleTerms moduleTerms; } struct TaxTerms { address recipient; // receives the tax, less any module fee; never zero uint16 rateBps; // basis points of the price per 30 days, 1..10_000 uint32 minRunwaySeconds; // tax a buy must fund, in seconds; zero = no minimum } struct ModuleTerms { address module; // zero for none bytes settings; // its configuration; must be empty when module is } ``` `initialize` refuses, before anything is stored: * a `currency` that is neither zero nor a contract (`InvalidCurrency`); * a manager on a fully immutable slot, or none on a mutable one (`InvalidManager`); * a `rateBps` outside 1..10,000 (`InvalidTax`) or a zero recipient (`InvalidRecipient`); * `settings` without a module, or a module that rejects its settings, declares invalid scopes or fee, or cannot answer within its gas allowance (`InvalidModule`, `InvalidModuleFee`, `ModuleTooExpensive`, or the module's own error from `validateSettings`). If the module declares `onInstall`, it is called at creation. `minRunwaySeconds` guards against a buyer declaring a huge price with no means to pay: the deposit must cover that many seconds of tax at the declared price. It rounds **up** — a truncating client-side copy lands one unit short and reverts. It is capped at one year (`MAX_MIN_RUNWAY`; `InvalidRunway` above it), here and on every later proposal. viem encodes `SlotInit` **by component name**, and a key it does not recognise is written as a silent **zero** rather than an error. The `@0xslots/sdk` `createSlot` takes a typed `SlotInit` and checks it with `assertSlotInit` before sending. ## Collecting in bulk ```solidity function collectAll(address[] calldata slots) external returns (uint256[] memory collected); function collectFrom(address slot) external returns (uint256 amount); function isSlot(address) external view returns (bool); ``` `collectAll` calls `collect()` on each slot this factory created, isolating failures: a slot with nothing to collect, a reverting `afterCallbacksMustSucceed` module or an address the factory did not create leaves a zero rather than failing the batch. Nothing about it is privileged — `collect()` is permissionless and pays each slot's own recipient. Simulate it to learn the amounts. ## Admin ```solidity function transferAdmin(address next) external; // hand over the role function upgradeBeacon(address implementation) external; // move every slot at once function implementation() external view returns (address); event AdminTransferred(address indexed from, address indexed to); event BeaconUpgraded(address indexed implementation); ``` `upgradeBeacon` moves every slot to a new implementation in one call — they all share one beacon. Slot storage is ERC-7201-namespaced and append-only for that reason. # Slot The contract behind one position, deployed by the [SlotFactory](/reference/factory) as a beacon proxy. Every mutating call settles accrued tax before anything else happens, so the numbers are correct whenever anyone looks — nothing runs on a timer. `Slot` inherits OpenZeppelin's `Multicall`. ## Occupant operations ```solidity function buy( address account, // who gets SEATED — not necessarily msg.sender uint256 selfAssessedPrice, uint256 depositAmount, uint256 maxPayment // ceiling on the total charged; 0 disables it ) external payable; function selfAssess(uint256 newPrice) external; // occupant or operator function topUp(uint256 amount) external payable; // anyone may fund an occupied slot function withdraw(uint256 amount) external; // occupant only function release() external; // occupant only function setOperator(address operator, bool allowed) external; // occupant only ``` `buy` separates who **pays** (`msg.sender`) from who **holds** (`account`), so a contract can acquire a slot on someone's behalf. It charges `quoteBuy(account, depositAmount)`: the sitting occupant's price (zero when vacant), plus the deposit, plus any debt `account` owes this slot. Native slots require `msg.value` to equal it exactly. `maxPayment` bounds what a buy can cost. The sitting price is read at execution, not when you quoted it, so without a ceiling the occupant could raise it between your simulation and your inclusion. Pass `0` only to opt out on purpose. `selfAssess`, `withdraw` and `buy` all require the deposit to cover `minRunwaySeconds` of tax at the price in question. An operator may only `selfAssess`, and the grant lapses when the tenure ends. A consensual sale goes through the [OfferBook](/reference/book), which calls `selfAssess` then `buy` inside the occupant's own transaction. ## Permissionless operations ```solidity function collect() external; // pay out collected tax: module fee, then recipient function liquidate() external; // evict an occupant whose deposit is empty function claim(address account) external; // pay `account` a credited payout function applyTerms() external; // land ripe terms — occupant only while occupied ``` `liquidate()` has **no bounty**. The reward is the slot itself: a vacant slot costs only the taker's own deposit, so whoever wants it can evict and buy it in one `multicall`. A payout that cannot be pushed (a contract that rejects it, say) is credited instead of reverting; `claim` pulls it later. ## Manager operations Only by the manager, and only for what the slot was created mutable for. ```solidity function proposeTerms(TaxTerms calldata taxTerms, ModuleTerms calldata moduleTerms, uint16 mask) external; function cancelTerms(uint16 mask) external; function acceptFee(ModuleFee calldata expected) external; // applies now function acceptScopes(uint16 expected) external; // queues for the next buy function setManager(address next) external; // immediate, one step ``` `mask` names what to change: | Bit | Constant | Changes | Needs | |---|---|---|---| | `1` | `TERM_TAX_RATE` | `taxTerms.rateBps` | `mutableTax` | | `2` | `TERM_RECIPIENT` | `taxTerms.recipient` | `mutableRecipient` | | `4` | `TERM_MIN_RUNWAY` | `taxTerms.minRunwaySeconds` | `mutableTax` | | `8` | `TERM_MODULE` | the whole `moduleTerms` | `mutableModule` | | `16` | `TERM_SCOPES` | queued by `acceptScopes`, never proposed | `mutableModule` | Fields outside the mask are ignored. Proposals are validated now — a module is asked `validateSettings`, `scopes` and `fee` at proposal — and then **queue**: they ripen after `TERMS_DELAY` (one hour) and land at the next `buy`, or earlier through `applyTerms`. Tax terms and the module ripen on separate clocks, so one role's change never holds another's back. Proposing again overwrites the named terms, keeps the rest queued, and restarts the delay of the group it touched. `cancelTerms` drops whichever of `mask` is queued and leaves the rest. ## Modules ```solidity function module() external view returns (address); function moduleTerms() external view returns (ModuleTerms memory); function mutableModule() external view returns (bool); function scopes() external view returns (Scopes memory); // the slot's accepted copy, unpacked function fee() external view returns (ModuleFee memory); // the slot's accepted copy ``` When the module declares something different, the manager accepts it, passing the value they reviewed. Each reverts `NothingToAccept` if nothing would change. * `acceptFee(expected)` applies at once; tax collected so far is paid out under the old fee first. Reverts `FeeChanged` if the module now says something else; a rise needs `mutableRecipient`. * `acceptScopes(expected)` queues under `TERM_SCOPES` and lands at the next buy, only when the slot's module is mutable. Reverts `ScopesChanged` if the module now says something else, and `ModuleChangeQueued` while a new module is queued. To see whether there is anything to accept, ask [`SlotLens.moduleUpdate(slot)`](/reference/lens#moduleupdate). The SDK's `moduleUpdate(slot)` calls it. ## Reading state One getter per field (`occupant`, `price`, `terms`, `scopes`, `fee`, …), and the constants are public (`MAX_PRICE`, `TERMS_DELAY`, `MODULE_CALLBACK_GAS_LIMIT`, the `TERM_*` bits, …). For a whole slot in one call, or many slots, use [`SlotLens`](/reference/lens). Beyond the fields: ```solidity function quoteBuy(address account, uint256 depositAmount) external view returns (uint256); function minDepositForBuy(uint256 price) external view returns (uint256); // uses ripe queued terms function minDepositToHold(uint256 price) external view returns (uint256); // terms in force function hasRipeTerms() external view returns (bool); function pending() external view returns (Pending memory); // stored as-is function isOperator(address operator) external view returns (bool); function debtOf(address account) external view returns (uint256); function claimableOf(address account) external view returns (uint256); ``` Size a buy's deposit with `minDepositForBuy`: a buy applies ripe queued terms before its funding check, so the buyer funds the terms they are buying into. ## Events ```solidity event Initialized(address indexed currency, address indexed manager, bool mutableTax, bool mutableRecipient, bool mutableModule, TaxTerms taxTerms, ModuleTerms moduleTerms); event Bought(address indexed buyer, address indexed from, uint256 price, uint256 deposit, uint256 paid); event Released(address indexed occupant, uint256 refund); event Liquidated(address indexed by, address indexed occupant); event PriceSet(address indexed by, uint256 oldPrice, uint256 newPrice); event Deposited(address indexed by, uint256 amount, uint256 total); event Withdrawn(address indexed occupant, uint256 amount, uint256 left); event OperatorSet(address indexed operator, bool allowed, uint64 indexed tenureId); event Settled(uint256 owed, uint256 paid, uint256 depositLeft); event TaxPaid(address indexed payer, uint256 owed, uint256 paid); event TaxCollected(address indexed recipient, uint256 amount); event ModuleFeePaid(address indexed module, address indexed recipient, uint256 amount); event DebtRepaid(address indexed account, uint256 amount); event Credited(address indexed account, uint256 amount); // a push payment failed event Claimed(address indexed account, uint256 amount); // and was later pulled event TermsProposed(TaxTerms taxTerms, ModuleTerms moduleTerms, uint16 mask); event TermsCancelled(uint16 mask); event TermsApplied(TaxTerms taxTerms, ModuleTerms moduleTerms, uint16 scopes, ModuleFee fee, uint16 mask); event ManagerSet(address indexed previous, address indexed next); event FeeAccepted(ModuleFee fee); event ScopesAccepted(uint16 scopes); event ModuleCallFailed(address indexed module, bytes4 selector); // an effect callback reverted, swallowed event ModuleDropped(address indexed module); // a queued module could not be installed event ScopesDropped(address indexed module, uint16 scopes); // accepted scopes the module no longer declares ``` `OperatorSet` carries the `tenureId` it is scoped to: an approval dies with the tenure and there is no revocation event, so a log without it cannot be replayed into `isOperator`. `TaxPaid` is the one to reduce over for per-address accounting, and it fires whether or not the module's `afterSettle` succeeds. # OfferBook Standing bids on a slot, and the fill that settles them. Anyone may post "I will take that slot at this price"; the occupant reads the best one and accepts it. The book is **periphery**. The core knows nothing about it — deploy another and it competes. ## Why the book performs the fill A consensual sale is two calls the core already has, in one transaction: ```solidity slot.selfAssess(price) // the occupant's price, restated slot.buy(bidder, price, deposit, max) // the ordinary market path ``` `buy` already refunds the outgoing occupant their deposit plus the price. So there is one seating path, and the slot's module gets its say — on the reprice and on the seating — like on any other buy. ## Posting a bid ```solidity function offer(address slot, uint256 price, uint256 deposit, uint64 expiry) external returns (uint256 id); function cancel(address slot, uint256 id) external; // bidder only ``` One transaction, and no signature: `offer` is a call **from** the bidder, so posting is the consent. One offer per bidder per slot — posting again updates your standing one under the same id. Bids are **allowance-backed, not escrowed**. The book pulls the payment during the fill and spends it in the same transaction; if the funds are gone when the occupant accepts, the transfer reverts and the seller loses gas and nothing else. Approve the **book**, not the slot, for `price + deposit` plus any debt you owe that slot. `buy` charges `msg.sender`, and on a fill that is the book. ## Accepting a bid ```solidity function acceptOffer(address slot, uint256 id, uint256 minPrice) external; // occupant only ``` `selfAssess` is `onlyOccupantOrOperator`, so the occupant makes the book their operator first: ```solidity slot.setOperator(book, true); book.acceptOffer(slot, id, minPrice); ``` `minPrice` is the price the seller reviewed. A bidder edits an offer in place under the same id, so without it a bidder could lower their price between the seller reading the board and the fill landing. The operator grant is keyed by tenure on the slot's side, so it lapses by itself when the slot changes hands. It is a real power — repricing to dust would let anyone take the slot cheaply. Which is why **the book is not upgradeable** and has no admin or owner: the code an occupant approved is the code that runs. `acceptOffer` is the only function that touches a slot, it reprices only to a live offer's price, and it reverts unless `msg.sender` is the occupant. ### Errors worth handling | Error | Meaning | |---|---| | `NotOccupant` | only the sitting occupant may accept a bid on their slot | | `NotOperator` | the book has not been granted `setOperator` for this tenure | | `OfferNotLive` | cancelled, expired, filled, unfunded, or the bidder is the occupant | | `PriceBelowMinimum(price, minPrice)` | the offer was lowered after the seller read it | | `NativeSlotNotSupported` | native slots cannot be filled — see below | | `TopUpRequired(shortfall)` | accepting a higher bid raises the escrow floor above what the seller holds | `TopUpRequired` is the one that surprises people. Raising the declared price raises the minimum escrow with it, and `selfAssess` enforces that floor against the deposit **already in the slot** — the seller's, not the bidder's. Top up by the reported shortfall and retry; it comes straight back in the sale proceeds. **ERC-20 only.** The occupant sends the transaction, so there is no way to reach the bidder's ETH. ## Reading the board ```solidity function board(address slot) external view returns (Offer[] memory offers, bool[] memory live); function best(address slot) external view returns (bool found, uint256 id, Offer memory offer); function liveCount(address slot) external view returns (uint256); function offerCount(address slot) external view returns (uint256); function isLive(address slot, uint256 id) external view returns (bool); function isFundable(address slot, uint256 id) external view returns (bool); function offerOf(address slot, address bidder) external view returns (bool has, uint256 id, Offer memory offer); function offerAt(address slot, uint256 id) external view returns (Offer memory); function offers(address slot) external view returns (Offer[] memory); // The same reads over offer ids [start, start + count), clipped to the board. function boardPage(address slot, uint256 start, uint256 count) external view returns (Offer[] memory offers, bool[] memory live); function bestIn(address slot, uint256 start, uint256 count) external view returns (bool found, uint256 id, Offer memory offer); function liveCountIn(address slot, uint256 start, uint256 count) external view returns (uint256); ``` `board` returns the list **and** the contract's own per-entry liveness verdict in one call, which is why it is preferred over `offers` plus N `isLive` reads. The board only grows — cancelling sets a flag, nothing removes an entry — so a client should read it with `boardPage` in chunks (the SDK uses 200) and take the live count and the best bid from the same pages; a whole-board read can be pushed past an RPC's gas budget by enough dust bids. An offer is live only if its deposit meets the slot's entry floor at its price (`minDepositForBuy`) — a bid the slot would refuse to seat is never shown as fillable. `acceptOffer` never pulls more than the offer's `price + deposit` plus the bidder's debt; a quote above that reverts `QuoteAboveOffer`. Count `liveCount`, never `offerCount` — the latter counts every row ever posted, cancelled and filled included. Filter on `isLive`, never `isFundable`. `isFundable` asks only whether the bidder could still pay, and a **filled** offer goes on answering yes. ## Events ```solidity event Offered(address indexed slot, address indexed bidder, uint256 indexed id, uint256 price, uint256 deposit, uint64 expiry); event Cancelled(address indexed slot, address indexed bidder, uint256 indexed id); event Filled(address indexed slot, address indexed bidder, uint256 indexed id, address seller, uint256 price, uint256 deposit); ``` `Filled` is the seller's side of a sale. The slot emits `Bought` in the same transaction, so occupancy history is complete from the slot alone. The book is not indexed yet, so `Filled` is not queryable — see [Indexer](/indexer). # SlotLens Reads about slots that the slot has no room for. It asks each slot through the slot's own getters, so it never holds a copy of a slot's state, and every answer comes from one block. Periphery, like the book. A UUPS proxy like the factories, so a new read keeps the same address. Its admin can upgrade it and nothing else. ```solidity function getSlotInfo(address slot) external view returns (SlotInfo memory); function getSlotInfos(address[] calldata slots) external view returns (SlotInfo[] memory); function getSlotConstants(address slot) external view returns (SlotConstantsInfo memory); function moduleUpdate(address slot) external view returns (ModuleUpdate memory); ``` ## `getSlotInfo` / `getSlotInfos` One slot, or many, whole. `getSlotInfos` reverts if any address is not a slot. ```solidity struct SlotInfo { // governance IERC20 currency; address manager; bool mutableTax; bool mutableRecipient; bool mutableModule; Terms terms; // { TaxTerms taxTerms; ModuleTerms moduleTerms; } Scopes scopes; ModuleFee fee; // occupancy address occupant; uint256 price; uint256 deposit; uint64 occupiedSince; uint64 tenureId; uint64 lastSettled; // money, as of this block uint256 taxOwed; uint256 collectedTax; bool isVacant; bool isInsolvent; uint256 secondsUntilLiquidation; Pending pending; // { taxTerms, nextModule: { module, scopes, fee, settings }, mask, proposedAt, moduleProposedAt } bool hasRipeTerms; // whether `pending` lands at the next buy } ``` ## `getSlotConstants` Every constant the slot runs under (`MAX_PRICE`, `TERMS_DELAY`, `MODULE_CALLBACK_GAS_LIMIT`, the `TERM_*` bits, …). Read from the slot, so a beacon upgrade that changes one shows up here. ## `moduleUpdate` What the slot's module declares today beside the slot's copy: ```solidity struct ModuleUpdate { uint16 currentScopes; ModuleFee currentFee; // the slot's copy bool answered; // false: no module, a revert, or out of range uint16 declaredScopes; ModuleFee declaredFee; // what the module says now bool feeDiffers; // acceptFee would change it bool scopesDiffer; // acceptScopes would queue it } ``` It reads the module the way the slot would when it lands (fail-open, under a third of `MODULE_CALLBACK_GAS_LIMIT` per read) and applies the accept rules. It never reverts on a module that misbehaves. # Module reference The interfaces a module implements, and the modules the protocol ships. See [the concept](/concepts/modules) for the model; this is the surface. `ISlotModule` is required. `IModuleMetadata` is optional and client-only. ## `ISlotModule` ```solidity interface ISlotModule { function validateSettings(bytes calldata settings) external view; function scopes(bytes calldata settings) external view returns (uint16); function fee(bytes calldata settings) external view returns (ModuleFee memory); // decisions — view, revert to veto function beforeBuy(SlotContext calldata ctx) external view; function beforeSelfAssess(SlotContext calldata ctx) external view; // effects — capped and swallowed, unless `afterCallbacksMustSucceed` is declared function afterBuy(SlotContext calldata ctx) external; function afterRelease(SlotContext calldata ctx) external; function afterLiquidate(SlotContext calldata ctx) external; function afterSettle(SlotContext calldata ctx) external; function onInstall(SlotContext calldata ctx) external; function onUninstall(SlotContext calldata ctx) external; // never fatal } ``` A module implements every function; the slot calls only the callbacks its scopes declare. **`validateSettings`** reverts if `settings` is not a configuration the module accepts. The slot calls it when the module is proposed and when it is installed. A module that takes no configuration implements it as a no-op and so accepts anything, empty included. **`scopes`** must be non-zero and use only known bits. **`fee`** may be zero; a non-zero `bps` needs a `recipient`, may not exceed 10,000, and needs a slot whose recipient is mutable (`NotMutable` otherwise). Both are `view`, not `pure` — a module may answer from storage. Each read (`validateSettings`, `scopes`, `fee`) must answer within a third of `MODULE_CALLBACK_GAS_LIMIT`, which is what the slot allows when the module is installed; one that cannot is refused at proposal with `ModuleTooExpensive`. The effect callbacks are `external` and state-changing, so **anyone** can call them with a context they invented. Only the slot fills `ctx` in honestly. Either `require(msg.sender == ctx.slot)`, or read what you need from the slot itself (`occupant()`, `moduleTerms()`) — never key storage on an unauthenticated `ctx`. ## `SlotContext` One shape for every callback. Fields not meaningful for a given call are zero. ```solidity struct SlotContext { address slot; // the slot calling — only trustworthy when msg.sender == slot address caller; // who called the slot; not necessarily the occupant address account; // incoming occupant on a transition; current otherwise address occupant; // who holds it now; zero when vacant uint256 occupiedSince; // when the current occupancy began; zero when vacant uint256 taxRateBps; // basis points per 30 days uint256 currentPrice; uint256 newPrice; // proposed in `before`, just set in `after` uint256 depositAmount; uint256 taxOwed; // afterSettle only — tax that accrued uint256 taxPaid; // afterSettle only — what the deposit could cover ModuleTerms moduleTerms; // this slot's module address and settings } ``` `taxOwed > taxPaid` on `afterSettle` means the occupant has run dry. On `afterRelease` and `afterLiquidate` the context is built after the slot is vacated, so `occupant`, `occupiedSince` and `currentPrice` are zero and `account` is the occupant who left. `beforeBuy` runs after ripe queued terms have landed, so it judges the terms the buyer is seated under. ## `ModuleTerms` ```solidity struct ModuleTerms { address module; // zero for none bytes settings; // this slot's configuration for it; must be empty when module is } ``` ## `ModuleFee` and `Scopes` ```solidity struct ModuleFee { uint16 bps; // share of collected tax, 0..10_000 address recipient; // required when bps is non-zero } struct Scopes { // `scopes`, unpacked — what Slot.scopes() returns bool beforeBuy; bool beforeSelfAssess; bool afterBuy; bool afterRelease; bool afterLiquidate; bool afterSettle; bool afterCallbacksMustSucceed; // not a callback — a mode bool onInstall; bool onUninstall; } ``` | Bit | Scope | | Bit | Scope | |---|---|---|---|---| | `1 << 0` | `beforeBuy` | | `1 << 5` | `afterSettle` | | `1 << 1` | `beforeSelfAssess` | | `1 << 6` | `afterCallbacksMustSucceed` | | `1 << 2` | `afterBuy` | | `1 << 7` | `onInstall` | | `1 << 3` | `afterRelease` | | `1 << 8` | `onUninstall` | | `1 << 4` | `afterLiquidate` | | | | `afterCallbacksMustSucceed` changes how the effect callbacks run: uncapped, and their revert propagates — eviction included. `onUninstall` stays capped and swallowed regardless. Leave `afterCallbacksMustSucceed` off unless a swallowed write would be worse than a stuck slot; the NFT modules below set it because they hold real ERC-721 ownership. The slot's accepted copy is what counts: `Slot.scopes()` and `Slot.fee()`. How a manager accepts new ones is on the [Slot reference](/reference/slot#modules). ## `IModuleMetadata` Optional. Self-reported metadata for clients — **never** read by the slot. ```solidity interface IModuleMetadata { function metadata() external pure returns (string memory); } ``` `metadata()` returns one JSON document, built on-chain by `ModuleSchemaLib` from the module's own constants. `MinimumTenureModule` answers: ```json { "version": 1, "title": "Minimum tenure", "description": "Protects an occupant from being bought out for a fixed window after they take the slot.", "settings": { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Minimum tenure", "type": "object", "properties": { "window": { "type": "string", "pattern": "^[0-9]+$", "title": "Minimum tenure", "description": "How long an occupant is protected from being bought out.", "x-unit": "seconds", "x-minimum": "1", "x-maximum": "31536000", "x-semantic": "minimum-tenure" } }, "required": ["window"], "additionalProperties": false, "x-abi": [{ "name": "window", "type": "uint256" }] }, "errors": [ { "signature": "TenureNotConfigured()", "message": "Set a window above zero." }, { "signature": "TenureTooLong(uint256)", "message": "Too long. The most this module allows is {0}.", "x-unit": "seconds" } ] } ``` `settings` is a JSON Schema 2020-12 document a form library can take as is, plus these conventions: | Key | Meaning | |---|---| | `x-abi` | The values in ABI-encoding order, as viem `AbiParameter`s. `abi.encode` of them is the slot's `settings` | | `x-optional` | The slot may leave `settings` empty | | `x-semantic` | A behaviour a client may recognise, such as `"minimum-tenure"` | | `x-unit`, `x-minimum`, `x-maximum` | What a number counts, and its bounds | | `x-enum-labels` | Labels for an enumeration, in value order | | `x-format` | How a value is typed and read. `"bytes32-string"` is text packed into 32 bytes | `errors`, beside `settings`, lists how `validateSettings` may revert: the error's signature, a sentence to show, and the unit its arguments are in. `{0}`, `{1}` in the message are the error's arguments. A revert carries only a selector, so this is what lets a client name and word a refusal from a module it has never seen. Every value is a string, because JSON numbers lose precision above 2^53; bounds travel as `x-minimum` / `x-maximum` strings. They are advice — `validateSettings` is the authority. A module that does not implement `metadata()`, or answers with something that is not JSON, is simply undescribed; fall back to its scopes. ## `MinimumTenureModule` One deployment per chain; the window a slot enforces is its own `settings`, a number of seconds. ```solidity function tenureOf(bytes settings) external pure returns (uint256); // settings = abi.encode(uint256 seconds) function requiredDeposit(uint256 price, uint256 taxRateBps, uint256 window) external pure returns (uint256); function reentryAllowedAt(address slot, address account) external view returns (uint256); uint256 public constant MAX_TENURE = 365 days; uint256 public constant BUYOUT_PREMIUM_BPS = 100_000; // 10x ``` Scopes: `beforeBuy`, `beforeSelfAssess`, `afterRelease`, `afterLiquidate`. No fee. | | | |---|---| | **Entry is funded** | every buy must post enough deposit to cover a whole window, at the higher of the old and new price | | **No price cut** | inside the window, the occupant cannot lower their price | | **Buyouts cost a premium** | inside the window, a buyer must declare at least 10x the occupant's price | | **No free renewal** | an occupant who releases or is liquidated cannot retake that slot for one window | The funding check runs at entry only; `withdraw` consults the slot's own `minRunwaySeconds`, not the module. A slot that wants the window's escrow to bind for the whole term sets `minRunwaySeconds` to at least the window. | Error | Meaning | |---|---| | `TenureNotConfigured` | `settings` is zero — a window was never set | | `TenureTooLong(maxTenure)` | above `MAX_TENURE` | | `TenureUnderfunded(required)` | the deposit does not cover the window | | `PriceCutDuringTenure` | a `selfAssess` lowering the price inside the window | | `TenureNotElapsed(allowedAt)` | the leaver's re-entry bar has not expired | | `BuyoutBelowPremium(required)` | a mid-window buy declaring less than 10x | | `NotTheSlot` | an `after` callback called by something other than the slot | ## AdLand A sponsorship module: the occupant publishes a creative (`Published`), and it is cleared when the slot changes hands (`Cleared`). Its `settings` are `abi.encode(AdConfig)` — an optional minimum-tenure window, a moderation mode and a registry key — and empty settings configure none of them. It inherits the `MinimumTenure` rule, so one module covers both behaviours. Scopes: `afterBuy`, `afterRelease`, `afterLiquidate`, plus `beforeBuy` and `beforeSelfAssess` when the config sets a window, so a slot without one grants no veto. No fee. Its creatives are indexed — see [Indexer](/indexer#creatives). ## Slot-bound NFTs `SlotBoundNFTFactory` deploys `SlotBoundNFT` collections, which mint a token and its slot together, and `SlotBoundNFTWrapper`, which puts an existing ERC-721 under common ownership. In both, the token belongs to whoever occupies its slot: ownership moves in `afterBuy`, `afterRelease` and `afterLiquidate`, and both declare `afterCallbacksMustSucceed` so the move cannot be dropped. The wrapper also declares `beforeBuy`, to refuse buys on a slot it has retired. # SlotsClient The main entry point for reading and writing slots. It talks to the contracts over RPC — it does not query the [indexer](/indexer). [Source](https://github.com/nezz0746/0xSlots/blob/main/packages/sdk/src/slots/client.ts) ## Constructor ```ts import { SlotsClient } from "@0xslots/sdk"; const client = new SlotsClient({ publicClient, // viem PublicClient — every read walletClient, // viem WalletClient — every write; needs an account and a chain factoryAddress, // SlotFactory — only createSlot and the collectAll family offerBookAddress, // OfferBook — defaults to the one recorded for the wallet's chain lensAddress, // SlotLens — slotState, slotStates, moduleUpdate; same default }); ``` Every field is optional; a method that needs a missing one throws a `SlotsError` naming it. `createSlotsClient(config)` is the same thing as a function. ## Reads ```ts const state = await client.slotState(slot); // occupant, price, deposit, taxOwed, isVacant, isInsolvent, // secondsUntilLiquidation, currency, taxRateBps, minRunwaySeconds, recipient, // manager, mutableTax, mutableRecipient, mutableModule, module, settings, // fee, scopes, pending, occupiedSince, lastSettled, collectedTax, tenureId ``` `slotState` is one `SlotLens.getSlotInfo` call, and `slotStates(slots)` reads many in one call. Individual reads exist for the common fields (`occupant`, `price`, `deposit`, `taxOwed`, `module`, `scopes`, `fee`, `terms`, `tenureId`, …), plus: | Method | Returns | |---|---| | `quoteBuy(slot, account, deposit)` | what `buy` will charge, including `account`'s debt | | `minDepositForBuy(slot, price)` | the smallest deposit a buy accepts, under ripe queued terms | | `minDepositToHold(slot, price)` | the floor `selfAssess` and `withdraw` enforce | | `pending(slot)` | queued terms, with `appliesAt` and `applies` | | `hasRipeTerms(slot)` | whether the next buy lands them — the chain's clock, not yours | | `fee(slot)` / `scopes(slot)` | the module's fee and scopes, as the slot copied them | | `moduleUpdate(slot)` | `{ current, declared, feeDiffers, scopesDiffer }`, from the lens | | `debtOf(slot, account)` / `claimableOf(slot, account?)` | debt carried / credits to `claim` | | `isOperator(slot, operator)` | live, per tenure | ## Writes All return `Promise`. ERC-20 approval is handled automatically. ```ts await client.createSlot(init); // see SlotInit below await client.simulateCreateSlot(init); // the address it would get await client.buy({ slot, account, selfAssessedPrice, depositAmount /*, maxPayment */ }); await client.simulateBuy(params); // throws the module's own error on refusal await client.liquidateAndBuy(params); // ERC-20 slots, one multicall await client.selfAssess(slot, newPrice); await client.topUp(slot, amount); await client.withdraw(slot, amount); await client.manageTerms(slot, { newPrice, topUpAmount, withdrawAmount }); await client.setOperator(slot, operator, true); await client.release(slot); await client.collect(slot); // permissionless await client.collectAll(slots); // through the factory await client.simulateCollectAll(slots); // what each would pay out await client.liquidate(slot); // permissionless — no bounty await client.claim(slot, account); // pull a credited payout ``` `buy` takes an `account` — who becomes occupant; it need not be the signer. It sends the quote it just read as `maxPayment` unless you pass one, so the transaction pays what it was quoted or reverts. `maxPayment: 0n` disables the ceiling on purpose. `manageTerms` reprices and moves the deposit as one submission, in the only order that passes both funding checks: top up, reprice, withdraw. A native top-up goes first as its own transaction, because `multicall` is not payable. ### SlotInit ```ts import { zeroAddress } from "viem"; await client.createSlot({ currency: zeroAddress, // native ETH manager: "0x...", // required exactly when something is mutable mutableTax: true, mutableRecipient: false, mutableModule: true, taxTerms: { recipient: "0x...", rateBps: 100, minRunwaySeconds: 86_400 }, moduleTerms: { module, settings }, // omit for no module }); ``` viem encodes a struct **by component name**, so the SDK takes a typed shape and `assertSlotInit` refuses what the contract would refuse before any gas is spent. ### Manager operations ```ts await client.proposeTerms(slot, { taxRateBps: 200 }); await client.proposeTerms(slot, { recipient, minRunwaySeconds: 3_600 }); await client.proposeTerms(slot, { moduleTerms: { module, settings } }); await client.proposeTerms(slot, { moduleTerms: NO_MODULE }); // remove the module await client.cancelTerms(slot, TERMS.MODULE); // one term await client.cancelTerms(slot); // everything (ALL_TERMS) const update = await client.moduleUpdate(slot); if (update.feeDiffers) await client.acceptFee(slot, update.declared!.fee); // now if (update.scopesDiffer) await client.acceptScopes(slot, update.declared!.scopes); // next buy await client.setManager(slot, next); ``` Presence, not truthiness, decides what `proposeTerms` queues. Terms land at the next buy after the one-hour delay — see [How a slot works](/concepts/slots#terms-do-not-move-under-an-occupant). ## Modules ```ts import { minimumTenureModuleAddress } from "@0xslots/contracts"; import { toHex } from "viem"; const module = minimumTenureModuleAddress[chainId]; const settings = toHex(7n * 24n * 3600n, { size: 32 }); // abi.encode(uint256 window) await client.validateSettings(module, settings); // { ok: true } | { ok: false, reason } await client.readScopes(module, settings); // what the module asks, today await client.readFee(module, settings); await client.moduleMetadata(module); // parsed metadata() JSON, or null ``` `validateSettings` runs the same check the slot runs at proposal and resolves with the module's own reason rather than throwing, so a form can show it. Constants: `TERMS`, `ALL_TERMS`, `SCOPE_BITS`, `unpackScopes`, `NO_MODULE`, `NO_SETTINGS`, `MAX_PRICE`, `MAX_TAX_BPS`, `BASIS_POINTS`, `MONTH_SECONDS`, `TERMS_DELAY_SECONDS`. ## Selling through the OfferBook ```ts // as the bidder — posting moves nothing; approve the book first await client.approveOfferBook(slot, await client.offerCost(slot, me, price, deposit)); await client.postOffer({ slot, price, deposit, expiry }); // as the occupant — grant once per tenure, then accept await client.authorizeOfferBook(slot); const { best } = await client.offerBoard(slot); if (best) await client.acceptOffer(slot, best.id, best.price); ``` `offerBoard` returns only live offers, highest first, as the book itself judges them. See [OfferBook](/reference/book). ## Collections and collectives Two more clients ship beside `SlotsClient`: * **`CollectionsClient`** creates and mints slot-bound NFT collections (`createCollection`, `quoteMint`, `approveMint`, `mint`, `setBaseURI`). * **`CollectivesClient`** creates and governs `SlotCollective`s — a split recipient with role-gated management. `COLLECTIVE_ROLES` names the roles: `tax` proposes tax rates, `policy` proposes modules and accepts their updates (`proposeModule`, `acceptFee`, `acceptScopes`, `cancelModuleProposal`), `split` edits the payout. Holders are set at creation with `taxManagers`, `policyManagers` and `splitManagers`. # React Hooks Wagmi-wired hooks from `@0xslots/sdk/react`. Requires `wagmi`, `viem`, and `@tanstack/react-query` as peer dependencies. ## `useSlotsClient` Creates a memoized `SlotsClient` from wagmi's public and wallet clients. ```tsx import { useSlotsClient } from "@0xslots/sdk/react"; const client = useSlotsClient(); // connected chain const client = useSlotsClient({ chainId: 84532 }); // override the chain const client = useSlotsClient({ factoryAddress }); // needed for createSlot ``` The config is optional: `factoryAddress` (only `createSlot` and `collectAll` need it), `offerBookAddress` (defaults to the book recorded for the chain) and `chainId`. `useCollectivesClient({ factoryAddress?, chainId? })` does the same for `CollectivesClient`. *** ## `useSlotAction` Every write, wrapped in shared pending/receipt tracking. ```tsx import { useSlotAction } from "@0xslots/sdk/react"; const { createSlot, // factory buy, release, liquidate, liquidateAndBuy, // occupancy selfAssess, topUp, withdraw, manageTerms, setOperator, // holding collect, collectAll, claim, // money out proposeTerms, cancelTerms, acceptFee, acceptScopes, setManager, // manager postOffer, cancelOffer, authorizeOfferBook, acceptOffer, // OfferBook client, // the underlying SlotsClient exec, // run any write with the same tracking preflight, // run a read or signature, reporting failure the same way busy, // isPending || isConfirming isPending, // wallet interaction in progress isConfirming, // waiting for on-chain confirmation isSuccess, activeAction, // label of the current action } = useSlotAction({ onSuccess: (label, hash) => console.log(`${label}: ${hash}`), onError: (label, error) => console.error(`${label}: ${error}`), }); ``` Failures are reported through `onError` rather than thrown — a module's veto arrives with its own error name — and an action resolves to the hash, or `undefined` when it failed. `useSlotAction` also accepts the `useSlotsClient` config. `proposeTerms(slot, { taxRateBps?, recipient?, minRunwaySeconds?, moduleTerms? })` queues any mix of terms; `cancelTerms(slot, mask?)` retracts them, and its `activeAction` label names the term being cancelled when `mask` is a single `TERMS` bit. *** ## Reading a slot There is no read hook. Reads go through the client, which returns plain promises you can hand to whatever query library the app already uses. ```tsx import { useQuery } from "@tanstack/react-query"; import { useSlotsClient } from "@0xslots/sdk/react"; function useSlot(address: `0x${string}`) { const client = useSlotsClient(); return useQuery({ queryKey: ["slot", address], queryFn: () => client.slotState(address), }); } ``` `slotState` returns occupancy, price, deposit, tax owed, the terms, the module and the `scopes` this slot accepted from it, in one call. # Deployments The v1 protocol these docs describe is **not deployed on any public chain yet**. It runs locally on anvil. The Base, Base Sepolia and Ethereum Sepolia addresses this page used to list belong to a retired protocol and do not speak this API. ## Where addresses come from `DeployProtocol` writes one record per contract to `apps/contracts/deployments//.json`, with an `address`, the `startBlock` the indexer begins from, and a `version`. Only records carrying a `version` belong to this protocol: the retired protocol's scripts wrote the same filenames, so `@0xslots/contracts`, the indexer and the deploy script all ignore a record without one. `@0xslots/contracts` generates its address maps from those records, so a contract appears on exactly the chains it has been deployed to. `CHAINS`, `DEFAULT_CHAIN` and `isDeployedOn(chainId)` are derived from `slotFactoryAddress`, so the set of usable chains is exactly the set that has a `SlotFactory` recorded. Chains sort local, then testnets, then mainnets, so `DEFAULT_CHAIN` is never a mainnet by accident. Addresses are CREATE2 through the canonical deployer, salted by contract name and version, so the same code with the same admin and constructor arguments lands at the same address on every chain. ## Deploying to a chain Each chain the deploy tooling knows has a config file in `apps/contracts/deployments/config/` — today anvil, Base, Base Sepolia and Ethereum Sepolia. From the repo root: ```bash pnpm protocol deploy --dry --chain base-sepolia # simulate, send nothing pnpm protocol deploy --chain base-sepolia ``` ## Local anvil — 31337 `pnpm dev:local` at the repo root starts a chain, deploys the protocol with the same `DeployProtocol` script and runs an indexer against it. Local addresses move whenever a contract's code changes, so read them back rather than copying them: ```bash cat apps/contracts/deployments/31337/SlotFactory.json ``` `CHAINS` includes anvil only when `NODE_ENV === "development"`. Bundlers inline `NODE_ENV`, so a production build drops it at compile time. The address maps themselves are unconditional. ## Naming a module A slot stores its module as a bare address. `@0xslots/contracts` ships a small catalogue of the modules a client can name, per chain: ```ts import { findKnownModule, knownModules } from "@0xslots/contracts"; const known = findKnownModule(chainId, slot.module); // known?.name — "AdLand", "Minimum tenure", or undefined for a module this // client does not recognise ``` A module appears in `knownModules` on exactly the chains it is deployed to, because the entries are derived from the deployment records. An unknown address reads back as `undefined` rather than a guess — the honest answer for a module anyone can deploy. There is one `MinimumTenureModule` per chain. The window a slot enforces is its own `settings`, so a single deployment serves every duration. ## ABIs and addresses ```ts import { slotAbi, slotFactoryAbi, offerBookAbi, minimumTenureModuleAbi, adLandAbi, slotCollectiveAbi, slotCollectiveFactoryAbi, slotBoundNftAbi, slotBoundNftFactoryAbi, slotBoundNftWrapperAbi, } from "@0xslots/contracts"; import { slotFactoryAddress, offerBookAddress, minimumTenureModuleAddress, adLandAddress, slotCollectiveFactoryAddress, slotImplementationAddress, // the beacon implementation, not a slot deployBlockOf, // the lower bound for historical log queries } from "@0xslots/contracts"; const factory = slotFactoryAddress[chainId]; // Address | undefined ``` Use the recorded `SlotFactory` and nothing else. A slot created through a factory the indexer does not watch is **invisible to every indexed consumer** — and it fails silently, because the transaction itself succeeds. # Indexer Slot deployments, ownership transitions, tax and module events are indexed by [Ponder](https://ponder.sh) (`packages/ponder`) and served as GraphQL. `SlotsClient` itself reads the chain; the indexer is for lists and history. ## Endpoint | | | |---|---| | Hosted | `https://0xslots-production.up.railway.app/graphql` (`development` instance: `https://0xslots-dev.up.railway.app/graphql`) | | Local | `http://localhost:42069/graphql` (`pnpm dev:local`) | `@0xslots/sdk` exports them as `DEFAULT_API_URL`, `LOCAL_API_URL`, `API_URLS` and `apiUrlFor(env)`. The API is unauthenticated. **One deployment holds every chain**, so the chain is a `where: { chainId }` filter on the query, not a property of the endpoint. An unfiltered list returns every chain's rows interleaved — a plausible-looking result that is quietly wrong. Every chain-scoped row carries `chainId`; filter on it. ## Shape * Plural fields return `{ items, totalCount, pageInfo }`, not a bare list. * Pagination is `limit` with `offset`, or the `after` / `before` cursors from `pageInfo`. * Foreign keys are scalar columns; the joined row is the `*Ref` sibling — `module` is an address, `moduleRef` is the row. * There is no `block:` argument. Ponder has no time-travel query. ## Key entities | Entity | Key fields | |---|---| | `slot` | `id`, `chainId`, `recipient`, `currency`, `manager`, `occupant`, `isOccupied`, `price`, `deposit`, `taxRateBps`, `minRunwaySeconds`, `module`, `settings`, `moduleFeeBps`, `moduleFeeRecipient`, the `scope*` flags, `collectedTax`, `taxPaidTotal`, `totalCollected`, `moduleFeesTotal`, `tenureId`, `lastSettled`, `createdAt` | | `module` | `id`, `chainId`, `declaredKnown` and the `declared*` flags, `slotCount`, `failedCallCount` | | `account` | `id`, `type` (EOA/CONTRACT/DELEGATED/SPLIT), `slotCount`, `occupiedCount`, `totalHoldTime`, `taxPaidTotal` | | `accountChain` | `account`, `chainId`, `slotCount`, `occupiedCount`, `occupiedAsRecipient` | | `currency` | `id`, `chainId`, `name`, `symbol`, `decimals` | A slot points at **one** `module` (or none). Scopes are stored on **both** rows and they answer different questions: `slot.scope*` is the copy the slot accepted — what it obeys — while `module.declared*` is what the module declared when the indexer first saw it. Comparing them is how you find a module that changed its scopes after slots had committed to it. `slot.scopeAfterCallbacksMustSucceed` is the one worth surfacing: such a module's `after` callbacks run uncapped and can fail the slot, eviction included. `module.failedCallCount` counts `after` callbacks that reverted and were swallowed (`moduleCallFailedEvent`). A module accumulating those is quietly broken. `slot` also carries its queued terms: `pendingMask` and, per term, `pendingHasTaxRate` / `pendingTaxRateBps`, `pendingHasRecipient` / `pendingRecipient`, `pendingHasMinRunway` / `pendingMinRunwaySeconds`, `pendingHasModule` / `pendingModule` / `pendingModuleSettings`, `pendingHasScopes` / `pendingScopes`, and `pendingProposedAt`. Read the `pendingHas*` boolean, not the value column. The zero address is itself a real proposed value — `pendingModule` = zero with `pendingHasModule` = true is a deliberately queued "remove the module", not an empty slot. ### `account` vs `accountChain` `account` has no `chainId` and holds protocol-wide totals. `accountChain` holds the same counters per chain, and is what a per-chain view must read. `occupiedCount` counts slots the account **occupies**; `slotCount` counts slots where it is the **recipient**. The one that pairs with `slotCount` is `occupiedAsRecipient` — the only honest numerator for an occupancy percentage. ### Schema drift The indexer serves exactly one schema, and GraphQL rejects a document containing an unknown field — so a client querying a renamed column gets an error or an **empty list**, not a missing field. Deploy the indexer and its consumers together. ## Example queries ### List slots ```graphql { slots( where: { chainId: 31337 } orderBy: "createdAt" orderDirection: "desc" limit: 10 ) { items { id recipient occupant price deposit taxRateBps module currencyRef { symbol decimals } moduleRef { slotCount failedCallCount } } totalCount pageInfo { hasNextPage endCursor } } } ``` ### Slot activity ```graphql query GetSlotActivity($slot: String!) { boughtEvents(where: { slot: $slot }, orderBy: "timestamp", orderDirection: "desc") { items { buyer from price deposit paid tenure timestamp } } releasedEvents(where: { slot: $slot }, orderBy: "timestamp", orderDirection: "desc") { items { occupant refund timestamp } } liquidatedEvents(where: { slot: $slot }, orderBy: "timestamp", orderDirection: "desc") { items { by occupant heldFor timestamp } } } ``` Module money and failures have their own tables: `moduleFeePaidEvents` and `moduleCallFailedEvents`, both keyed by `slot` and `module`. **Sales are not indexed yet.** A consensual sale emits `Bought` on the slot (so occupancy history is complete) and `Filled` on the OfferBook — and the book is not in `ponder.config.ts`, so the seller's side of a sale is not queryable. ## Indexing status ```graphql { _meta { status } } ``` `status` is keyed by chain and carries the latest indexed block. An erroring indexer stops rather than serving stale rows, so it shows up as a failed request. ## Creatives One module is indexed: **AdLand**, whose creative is the entire content of a sponsor space and lives nowhere else. | Entity | Key fields | |---|---| | `creative` | `slot`, `chainId`, `module`, `uri`, `tenureId`, `publisher`, `clearedAt`, `publishCount`, `firstPublishedAt` | | `publishedEvent` | `id`, `slot`, `module`, `uri`, `tenureId`, `publisher`, `timestamp`, `tx` | | `clearedEvent` | `id`, `slot`, `module`, `fromTenure`, `toTenure`, `timestamp`, `tx` | | `adKey` | `key`, `chainId`, `module`, `slot`, `pendingSlot`, `pendingReadyAt`, `setCount` | Two events, and both matter. `Published` is somebody putting a creative up. `Cleared` is the module blanking one because the slot changed hands — emitted from `afterBuy`, `afterRelease` and `afterLiquidate`. Reading only the first leaves a creative in the index long after the chain has stopped serving it. A clear is **recorded, not applied**: the row keeps its `uri` and gains a `clearedAt`, mirroring the contract, which refuses to return a creative once `tenureId` has moved on.