> **Can't find what you're looking for?** Use `search_docs` on the docs MCP server at `https://docs-sepolia.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. Their source is in `packages/contracts/src` and `packages/sdk/contracts`.

| Contract | Purpose | Address |
| --- | --- | --- |
| `ModulariumFactoryUpgradeable` | Deploys collections | `NEXT_PUBLIC_FACTORY_ADDRESS` |
| `ModulariumERC721Upgradeable` / `ModulariumERC1155Upgradeable` | Your collection | Per collection |
| `SimpleDropUpgradeable` | Paid and free public mints | `NEXT_PUBLIC_DROP_ADDRESS` |
| `RecipeDropUpgradeable` | Mints with requirements (recipes) | `NEXT_PUBLIC_RECIPE_DROP_ADDRESS` |
| `SimpleMarketplaceUpgradeable` | Listings and offers | `NEXT_PUBLIC_MARKETPLACE_ADDRESS` |
| `ModulariumSlugRegistry` | Collection URL slugs | `NEXT_PUBLIC_SLUG_REGISTRY_ADDRESS` |
| `ModulariumProfile` | User profiles | `0x87b50A8f59bfC553d899Acee6b4a02cfb4204B09` |
| `ModulariumForwarder` | One-transaction batches | `0x879684b76ca16EB53D865C5F7868e4082D6D6507` |

Addresses differ by network. The env var names are the web app's settings for them (see `apps/web/.env.example`). The profile and forwarder contracts are CREATE2-deployed and have 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. `msg.value` must equal the sum of the `value` fields.

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, string symbol, address royaltyReceiver, uint96 royaltyBps, bool royaltyCemented) returns (address)
function createERC1155(bytes contractMetadata, address royaltyReceiver, uint96 royaltyBps, bool royaltyCemented) returns (address)
function predictCollection(CollectionKind kind, address owner) view returns (address) // 0 = ERC721, 1 = ERC1155
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 CREATE2-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`.

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

### 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.
* The caller must be a wallet, not a contract.
* With a `merkleRoot`, the leaf is `keccak256(bytes.concat(keccak256(abi.encode(recipient, uint256(1)))))`, the OpenZeppelin `StandardMerkleTree` format for `[address, uint256]`.
* `reserves` editions are held back from the public mint, and the creator mints them with `mintReserves`.

## RecipeDrop

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

A drop has one or more `recipes`, and each recipe is a list of `Instruction { InstructionType, bytes data }`. A mint picks one recipe and passes one `recipeSteps[i]` per instruction:

| Instruction | `data` | Step input |
| --- | --- | --- |
| `Burn` (0) | `BurnInstruction { Ingredient }` | `IngredientInput[]` |
| `Freeze` (1) | `FreezeInstruction { Ingredient, uint48 freezeDuration }` | `IngredientInput[]` |
| `Hold` (2) | `HoldInstruction { Ingredient }` | `IngredientInput[]` |
| `Pay` (4) | `PayInstruction { amount, recipient }` | empty |

An `Ingredient` lists acceptable `{ tokenAddress, optType, data }` options plus a total `amount`. `optType` is one of `TokenId`, `Range`, `Set`, `Attributes` (match `attributes[]` trait values) or `Any`. 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
function takeSellOrder(TakeOrderParams p) payable             // { orderId, qty, recipient }
function takeBuyOrder(TakeOrderParams p)
function takeCollectionBuyOrder(...)
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, then the seller.

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