# 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.