> **Can't find what you're looking for?** Use `search_docs` on the docs MCP server at `https://docs.modularium.art/api/mcp` to find what you need.

# Self-serve contracts

Everything the app does, you can also do by calling the contracts directly. This page covers the contracts behind the self-serve features. They're written in [Lithe](https://lithe-lang.org/): the contracts are in `packages/contracts/src` and the modules they're built from in `packages/sdk`.

| Contract | Purpose | Address |
| --- | --- | --- |
| `ModulariumFactory` | Deploys collections | `NEXT_PUBLIC_FACTORY_ADDRESS` |
| `ModulariumERC721` / `ModulariumERC1155` | Your collection | Per collection |
| `SimpleDrop` | Paid and free public mints | `NEXT_PUBLIC_DROP_ADDRESS` |
| `RecipeDrop` | Mints with requirements (recipes) | `NEXT_PUBLIC_RECIPE_DROP_ADDRESS` |
| `SimpleMarketplace` | Listings and offers | `NEXT_PUBLIC_MARKETPLACE_ADDRESS` |
| `ModulariumSlugRegistry` | Collection URL slugs | `NEXT_PUBLIC_SLUG_REGISTRY_ADDRESS` |
| `ModulariumProfile` | User profiles | `NEXT_PUBLIC_PROFILE_ADDRESS` |
| `ModulariumForwarder` | One-transaction batches | `0x09D92dBD6A182814911B040F9053989033Be5546` |

Addresses differ by network. The env var names are the web app's settings for them (see `apps/web/.env.example`). The forwarder is CREATE2-deployed and has the same address on every chain.

## Batching with the forwarder

`ModulariumForwarder.multicall(Call[] calls)` runs `{ target, data, value }` calls in order and appends `msg.sender` to each call's calldata (ERC-2771). Every contract on this page trusts the forwarder, so each call runs as you. If one call reverts, the whole batch reverts with that call's revert data. `msg.value` must equal the sum of the `value` fields. The calls run after `multicall` has checked the value, so it returns nothing: read results from events or later calls.

Collections also accept a forwarded `setApprovalForAll`, so an approval can share a transaction with the call that needs it, such as listing a token or redeeming a recipe. Transfers still use the direct caller.

## Factory

```solidity
function createERC721(bytes contractMetadata, address royaltyReceiver, uint96 royaltyBps, bool royaltyCemented) returns (address)
function createERC1155(bytes contractMetadata, address royaltyReceiver, uint96 royaltyBps, bool royaltyCemented) returns (address)
function createGenerativeERC721(bytes contractMetadata, address royaltyReceiver, uint96 royaltyBps, bool royaltyCemented, GenerativeParams params) returns (address)
function predictCollection(CollectionKind kind, address owner) view returns (address) // 0 = ERC721, 1 = ERC1155, 2 = ERC721Generative
function generativeMintFee() view returns (uint256) // per-token fee every generative mint pays
event CollectionCreated(address collection, address owner, CollectionKind kind)
```

* The caller becomes the collection `owner` and gets Creator, Minter and Editor.
* `contractMetadata` is a [KV document](/developers/metadata) that includes `name`.
* Royalties are in basis points, where `10000` is 100%.
* Collections are CREATE3-deployed per owner and nonce, so `predictCollection` gives the exact address of your next collection. That lets one forwarded batch create a collection and configure it, for example by claiming its slug.
* The indexer only picks up collections announced by `CollectionCreated`.
* Generative collections ([generative drops](/create/generative)) take `GenerativeParams { string generativeURI; uint256 previewAspectRatio; uint256 maxSupply; bytes defaultTokenMetadata }`. They have no `create`: `mintWithFee(address to, uint256 qty)` mints new tokens and requires `msg.value == mintFee() * qty`. SimpleDrop adds the fee to the drop price and forwards it. Platform renderers post each token's rendered document with `setGenerativeMetadata(tokenId, blockHash, document)`, or many at once with `setGenerativeMetadataBatch`.

## Collection

### Creating and minting

```solidity
// ERC-721
function create(bytes metadata, bool cemented, address royaltyReceiver, uint96 royaltyBps, bool royaltyCemented, address mintTo) returns (uint256 id)
function createBatch(bytes[] metadatas, bool cemented, address royaltyReceiver, uint96 royaltyBps, bool royaltyCemented, address mintTo) returns (uint256 first, uint256 last)
function mint(address to, uint256 amount)          // Minter

// ERC-1155
function create(bytes metadata, uint256 maxSupply, bool cemented, address royaltyReceiver, uint96 royaltyBps, bool royaltyCemented, address mintTo, uint256 mintAmount) returns (uint256 id)
function mint(address to, uint256 id, uint256 amount) // Minter
```

* `create` needs the Creator role. Token IDs start at 1.
* `royaltyBps = 0` means the token inherits the collection default.
* Pass `mintTo = address(0)` to create a token without minting it.
* **ERC-721 pools.** Tokens created without `mintTo` form a pool. `mint(to, amount)` hands out tokens from that pool, at random by default. Call `setSequentialMint(true)` before the first pool mint to hand them out in order instead. `availableTokens()` returns how many are left in the pool.
* **ERC-1155 supply.** `maxSupply = 0` means unlimited. Otherwise `mint` reverts once total supply would go over it.
* **Closing creation.** The owner's `closeCreation()` stops new token ids for good: `create` and `createBatch` (and a generative `mintWithFee`) then revert with `TokenCreationClosed`, while created tokens still mint. `creationClosed()` reads it, and the collection logs `CreationClosed()`.

### Roles and ownership

```solidity
function grantCreator(address) / revokeCreator(address)   // owner
function grantMinter(address)  / revokeMinter(address)    // owner
function grantEditor(address)  / revokeEditor(address)    // owner
function renounceRole(ModulariumRole role)                // 0 = CREATOR, 1 = MINTER, 2 = EDITOR
function hasRole(ModulariumRole role, address account) view returns (bool)
```

Ownership uses two-step transfer (`transferOwnership` then `acceptOwnership`).

### Royalties, metadata and freezing

| Function | Access |
| --- | --- |
| `setDefaultRoyalty(receiver, bps)`, `setTokenRoyalty(id, receiver, bps)` | Owner, until cemented |
| `cementDefaultRoyalty()`, `cementTokenRoyalty(id)` | Owner, irreversible |
| `setContractMetadataRaw(doc)`, `cementContractMetadata()` | Owner |
| `setTokenMetadataRaw(id, doc)`, `cementTokenMetadata(id)` | Editor |
| `freeze(owner, id, duration)` (ERC-1155: `freeze(owner, id, amount, duration)`) | Token owner or approved operator. Returns a record ID. |
| `thaw(recordIds)` | Anyone, once the freeze has expired |
| `renounceUpgrades()` | Owner, irreversible |

Frozen tokens can't be transferred or burned until they're thawed.

## SimpleDrop

```solidity
function createDrop(DropParams params) returns (uint256 dropId)
function mint(uint256 dropId, uint256 qty, address recipient, bytes32[] proof) payable
function cancelDrop(uint256 dropId)              // drop creator
function mintReserves(uint256 dropId, uint256 qty, address recipient) // drop creator
```

`DropParams`: `recipient` (who gets paid), `token` (`{ tokenAddress, tokenId }`), `maxAllowed`, `maxPerWallet`, `maxPerBlock`, `maxPerToken`, `startDate`, `endDate` (`0` means no end), `price`, `merkleRoot`, `reserves`, `fee`, `feeRecipient`.

Self-serve rules:

* You must be the `owner()` of the token contract.
* `fee` must be `0`, so the platform fee applies (currently 250 basis points, 2.5%).
* The drop contract needs the Minter role on your collection.

When minting:

* `msg.value` must equal `qty × price` exactly, including `0` for free drops. With tiered pricing each unit costs the tier it falls in.
* The caller must be the transaction's signer (`tx.origin`), not a contract. EIP-7702 wallets are fine.
* `maxPerWallet`, `maxPerBlock`, `maxPerToken` and the allowlist count the caller, not `recipient`.
* With a `merkleRoot`, the leaf is `keccak256(bytes.concat(keccak256(abi.encode(caller, uint256(1)))))`, the OpenZeppelin `StandardMerkleTree` format for `[address, uint256]`.
* `maxAllowed` and `reserves` are per drop. `reserves` editions are held back from the public mint, and the creator mints them with `mintReserves`.
* For ERC721 drops `token.tokenId` is stored as `0`.

## RecipeDrop

```solidity
function createDrop(DropParams params, HookParams[] hooks) returns (uint256 dropId)
function mint(uint256 dropId, uint8 recipeIndex, StepInput[] recipeSteps, address recipient) payable
function cancelDrop(uint256 dropId)
```

Instructions, token selections and step inputs are Lithe variants, which cross the ABI as `(uint8 tag, bytes payload)`: `payload` is `abi.encode` of the case's payload alone, and empty for a case without one. A drop has one or more `recipes`, each an `Instruction[]`. A mint picks one recipe and passes one `recipeSteps[i]` per instruction:

| Instruction (`tag`) | `payload` | Step input (`tag`, `payload`) |
| --- | --- | --- |
| `Burn` (0) | `abi.encode(Ingredient)` | `Ingredients` (0), `abi.encode(IngredientInput[])` |
| `Freeze` (1) | `abi.encode((Ingredient, uint48 duration))` | `Ingredients` (0), `abi.encode(IngredientInput[])` |
| `Hold` (2) | `abi.encode(Ingredient)` | `Ingredients` (0), `abi.encode(IngredientInput[])` |
| `Pay` (4) | `abi.encode(uint256 amount, address recipient)` | `NoInput` (2), empty |

An `Ingredient` is `{ (address tokenAddress, TokenIds ids)[] tokens, uint256 amount }`: acceptable tokens plus a total `amount`. `ids` is a variant too: `TokenId` (0, `abi.encode(uint256)`), `Range` (1, `abi.encode(uint256 start, uint256 end)`), `Set` (2, `abi.encode(uint256[])`), `Attributes` (3, `abi.encode((string traitType, string value)[])`, matching `attributes[]` trait values) or `Any` (4, empty). An `IngredientInput` is `{ (address tokenAddress, uint256 tokenId, uint8 tokenType) token, uint256 amount }`. The step inputs must add up to exactly `amount`.

Ingredients are taken from the caller, so the caller must approve the RecipeDrop contract (`setApprovalForAll`) on each input collection that gets burned or frozen. `msg.value` must equal the sum of all `Pay` amounts. The platform fee is taken out of each pay step.

Self-serve rules (callers that aren't allow-listed):

* Every product must be in a collection you own.
* Only `Burn`, `Freeze`, `Hold` and `Pay` are allowed. `Transfer`, `MerkleProof`, `View`, `Call` and hooks are reserved for allow-listed operators.
* Use ERC-1155 products, as the create flow does. For an ERC-721 product that isn't recipe-aware, the fallback `mint(to, tokenId)` is read by Modularium ERC-721 collections as `mint(to, amount)`.

## Marketplace

```solidity
function makeSellOrder(MakeOrderParams p)                     // { token, qty, price, recipient, validFrom, validUntil }
function makeBuyOrder(MakeOrderParams p) payable              // an offer; escrows qty × price
function makeCollectionBuyOrder(MakeCollectionOrderParams p) payable // ERC-721 only; any token id fills it
function takeSellOrder(TakeOrderParams p) payable             // { orderId, qty, recipient }
function takeBuyOrder(TakeOrderParams p)
function takeCollectionBuyOrder(TakeCollectionOrderParams p) // { orderId, tokenId, recipient }
function bulkTakeSellOrders(BulkTakeOrderParams p) payable
function cancelOrder(uint256 orderId)                         // maker
```

* The minimum price is 1 gwei. ERC-721 orders have `qty = 1`.
* Sellers must approve the marketplace for the collection.
* Each fill pays the platform fee first (currently 2.5%, readable with `getPlatformFee()`), then the ERC-2981 royalty, capped at 90% of the price (`getMaxRoyalty()`, in basis points), then the seller. A royalty with a zero receiver stays with the seller.
* On offers (`takeBuyOrder` / `takeCollectionBuyOrder`) the taker receives the payment and the token goes to the offer's `recipient` (or its maker), so the take `recipient` is ignored and `TakeOrder` / `TakeCollectionOrder` log the taker as `recipient`.

## Slug registry

```solidity
function setSlug(address collection, string slug) // collection owner(); "" clears
function slugOf(address collection) view returns (string)
function resolveSlug(string slug) view returns (address)
function isBlocked(string slug) view returns (bool)
```

Slugs are 3–255 characters of `a-z 0-9 _ -` and are unique. The registry admin can block or reassign slugs.

## Profile

```solidity
function setUsername(string) / setName(string) / setBio(string) / setProfilePic(string) / setBannerPic(string)
function setLinks(Link[] links)    // { linkType, url }
function clearProfile()
function profileOf(address) view returns (Profile)
function resolveUsername(string) view returns (address)
```

Each setter writes only its own field. To change several fields in one transaction, use `multicall`. Usernames are 3–30 characters of `a-z 0-9 _ -` and are unique. Other fields are capped in bytes (name 256, bio 4096, picture and link URLs 2048, link types 32) and `links` at 10; longer values revert with `FieldTooLong` / `TooManyLinks`.
