Build

Build a module

A module is a contract POO.MEME's PooSplitter pays. Build one that answers a small interface, test it entirely on your own machine with the poo command, register it, and any creator can name it in their launch — no allow-list, no admin approval, only checks the directory runs on chain.

What you build

A module is any contract a launch names as one of its splitter's legs. PooLauncher calls your factory's create once, at launch, and from then on your module receives its own share of the fees straight from the splitter's own collect() — the launch's own pair asset (the chain's native coin, or any Pons-approved ERC-20 the creator paired against), in basis points. Nothing about the standard limits what your module does with that share, or what else it does at all: staking, buybacks, a treasury, a game. The standard governs only how you're paid and how your panel is drawn, never your own logic — and anyone may call the actions your manifest exposes, so add your own access control if an action needs it.

Declare receive() if you want your own code to run the moment your share arrives; you don't have to. A leg with no receive(), or one that reverts, still gets paid — collect() forces the transfer through regardless, so your module can never block a whole launch's collection just by refusing it (see Money flow). Without a receive(), though, nothing of yours runs on arrival: read address(this).balance instead of reacting to the transfer itself.

npm install -g @poomeme/sdk, then poo init my-module scaffolds a Foundry project: src/MyModule.sol (what the splitter pays), src/MyModuleFactory.sol (its manifest and how instances are made — with new, see How the factory makes an instance), a test that stands the real PooLauncher, PooModuleDirectory and a Pons stand-in up in memory, and script/Publish.s.sol. Nothing reaches a chain until you choose to — forge test runs entirely offline.

  • poo check — compiles your project, refuses any import outside IPooModuleFactory, ModuleManifestTypes and ModuleManifestLib, and runs the same manifest rules the directory runs on chain, so a refusal shows up by name before you ever spend gas on it.
  • poo preview — builds your factory, deploys the devkit, reads manifest() back and draws your panel exactly as POO.MEME would: every section, its grid, each view's widget and unit, every action's inputs, every event's tone — then registers it with the devkit's own directory and names the error if the directory refuses it.
  • poo abi — prints your module's and your factory's compiled ABI.

The factory

Every module is made by a factory — a separate contract you deploy once that builds a fresh instance per token:

IPooModuleFactory
interface IPooModuleFactory is IERC165 {
    error NotLauncher();

    function manifest() external view returns (ModuleManifest memory);
    function developer() external view returns (address);
    function launcher() external view returns (address);
    function create(ModuleContext calldata context, bytes calldata config) external returns (address module);
}

struct ModuleContext {
    address token;
    address splitter;
    address curve;
    address creator;
    uint16 bps;
    address quote;
    uint8 quoteDecimals;
}

create runs once per token, inside the launch's own transaction, called by PooLauncher itself: context names the token, the splitter that will pay this leg, the bonding curve, the creator, this leg's own bps share, and the launch's own pair asset — quote (the zero address for the chain's native coin, else the Pons-approved ERC-20 the launch is paired against) and its quoteDecimals, the one scale a QuoteAmount config field or action input is read and encoded at; config is whatever bytes the creator supplied for this leg — decode it yourself, and revert if it isn't what you expect. developer() is the address PooModuleDirectory requires as msg.sender when you register this factory (Publish). launcher() is the one PooLauncher your factory serves: take it as a constructor argument and keep it immutable.

create must revert NotLauncher() for every caller but launcher(), and that check must be its very first statement — before a nonReentrant guard, before decoding config, before any state write. The directory registers a factory only after calling create itself — read-only, with an empty context and empty config, capped at 50,000 gas — and seeing exactly the 4-byte NotLauncher() error come back. A create that succeeds for it, reverts with anything else, or writes state before refusing is refused LauncherNotEnforced. poo init scaffolds the check as create's first line; keep it there. That one call is what registering proves, and no more — see What the check doesn't prove.

isInstallable(factory) is the check a launch makes of every factory it names: your factory must be registered in the launcher's own PooModuleDirectory (Publish), its deployed code must still be the code it registered with, and its launcher() must still be that launcher. Registering is open to anyone, with no allow-list, no fee and no admin who could refuse yours — only the directory's own on-chain checks.

How the factory makes an instance

create has to return the address of a contract, and how it gets one is yours to choose; the directory checks none of it. There are two ways.

A clone with immutable arguments is the pattern POO.MEME's own modules use. The factory's constructor deploys your module once, as an implementation, and create makes every instance with OpenZeppelin's Clones.cloneWithImmutableArgs(implementation, abi.encode(...)), appending that instance's terms (the token, the splitter, the pair asset, whatever your logic needs) to the clone's code. The module reads them back from its own code with extcodecopy. Vesting Vault from 1.1.0, Diamond Vault from 1.2.0, Staking from 1.3.0 and Buyback & Burn are built this way. What you get:

  • A far cheaper create. A clone is a few dozen bytes plus its arguments instead of the whole module's code. Measured on the first-party modules, create fell from about 0.8M, 1.8M and 1.1M gas to about 114k, 107k and 93k, which is room under the launch's cap (Gas and the launch transaction).
  • One contract to verify. A clone has no source of its own, so verifying the implementation verifies the code every instance runs, and no instance needs a submission of its own. Submit the implementation like any contract you deploy; the first contract your factory's constructor creates sits at the factory's first CREATE address, which is implementation() in the first-party factories.

And what you write for it:

  • Move the constructor's checks into create. A clone's constructor never runs, and the implementation's runs once, before any token exists. Validate config and the context in create and revert with your own custom errors, keeping NotLauncher() as its first statement (The factory).
  • Keep the implementation inert. It holds no instance's terms, so make every path that reads them revert when it runs at the implementation's own address; the first-party modules revert NotAClone. Terms passed as arguments are fixed for the instance's life; anything that changes lives in the clone's own storage.
  • Expect a little more gas per call. Every call into a clone is first a delegatecall to the implementation, which measured about 2.6k to 2.7k gas more per call on the first-party modules. If your module meters its own gas, as the sync() refund in Money flow does, measure on the clone path: the first-party refund's fixed overhead went from 32,000 to 35,000 gas for that reason.
  • A receive() behind a clone needs more than the 2,300-gas stipend. It runs behind that same delegatecall, so a transfer or send carrying only the 2,300-gas stipend into a clone whose implementation is cold reverts and the sender keeps the coin. The splitter is not affected: it pays each leg with a 50,000-gas call.

new works too, and it is what the poo init scaffold writes: create runs your module's constructor with the arguments you derive from context and config. Every instance is then its own contract, with its own creation code and its constructor arguments baked in, so an explorer sees one unverified contract per token and each needs a submission of its own, with that instance's constructor arguments. Each create also pays the whole module's code deposit, which counts against the launch's cap. POO.MEME's earlier module versions were built this way, and their instances are not verified on explorers for that reason.

The manifest

manifest() is the one read that tells a token page what to draw. It's plain data — Solidity structs and enums returned straight off the chain, so the page a holder sees always matches the factory that's actually installed.

FieldIs
name, summary, descriptionYour module's own words: a name (≤48 bytes), a one-line summary (1–120 bytes), and a longer disclosure (≤1,000 bytes).
handleYour module family's own label across versions — lowercase letters, digits and -, 1 to 32 characters, never starting or ending with one. It claims nothing: nothing stops a second factory from using the same handle.
versionFree text, up to 16 bytes — 1.0.0, 2, whatever you use.
configThe Field[] your factory's own create(config) decodes — the form a launch's own creator fills in for your leg.
sectionsYour whole panel, in your own order: up to 4 sections, each with its own views, actions, lists and a grid of up to 4 columns.
eventsThe events your runtime emits, so the indexer can build rows and lists from them.
errorsA reader sentence for each of your own custom errors.

A config field's own type is a plain scalar — address, bool, bytes32, string, bytes, or any uintN — never an array; an action's own input may additionally be an array of one of those. A whole manifest is capped at 16,384 bytes ABI-encoded, at most 4 sections, at most 8 fields in any one list, at most 16 events and 24 errors — ModuleManifestCheckLib enforces every one of these on chain, the moment you register, and poo check runs the identical rules first, so a refusal shows up by name before you ever spend gas on it.

ModuleManifestLib (part of the published surface) builds the common shapes for you — .base(...), .figure(...), .call(...), .when(...), .items(...), .logged(...) — so a straightforward module never hand-writes a struct literal.

Money flow

Fees reach your module through the token's own PooSplitter, never directly from a trade. On collect(), POO.MEME's own 0.1% of the token's trade volume and the caller's own gas refund (capped at 2% of what's collected) both come off the top; what's left splits across every leg — module and payout alike — by its own bps share, with the remainder to the creator's payout address. A splitter carries at most 8 legs in all, and every leg's bps together can never exceed 10,000 — checks PooLauncher makes before a launch is ever created.

On the chain's native coin, every leg is paid with a forced send: a plain call first and, if that's refused, the coin delivered through a throwaway contract's own selfdestruct, which no receive() or fallback() can block. Your module holds its share whether or not you wrote a receive() for it — write one only if you want your own code to run when it arrives.

A call of your own that does keeper work for everyone, such as banking fees that have arrived, can pay its caller's gas back out of what it banks, the way collect() does. POO.MEME's own Diamond Vault from 1.1.0 and Staking from 1.2.0 do this on their external sync(): before banking, it pays the caller the gas it spent, priced at the lower of the transaction's gas price and twice the block's base fee, up to 2% of the fees it banks, and emits SyncRefunded(caller, amount). On a native-coin pair that is paid in the coin; on an ERC-20 pair it goes through the launch's own refund route, as ETH from the swap or as the pair asset if the swap fails, and a route that cannot price it pays nothing. A sync() with nothing to bank pays nothing, the module's other calls (lock, unlock, claim, stake, unstake) pay no refund, and earlier versions of both modules pay none.

Gas and the launch transaction

Your factory's create runs inside the same transaction that creates the token, buys the creator's own opening amount if any, and calls every other leg's own factory in turn — all of it under Robinhood Chain's own per-transaction cap, 16,777,216 gas. By the time create runs, PooLauncher has already checked the token's own predicted address against POO.MEME's 0x8888 suffix and pinned Pons's live economics against what the creator read — your factory is never called for a request that fails either. A heavy create doesn't cost only your own leg: it eats into the budget the token, the curve and every other module share, and a launch that doesn't fit reverts whole, for every leg named in it, not only yours. Keep create to what it needs to deploy your instance and read its own config (a clone makes that cheap, How the factory makes an instance); do the real work later, in the calls your module's own actions expose.

What you control, and what you don't

You control your module's own logic end to end: its storage, whether it has an owner, what its actions do, and everything its manifest tells a page to draw.

You don't control your leg's own bps share, or whether a given launch names you at all — the creator decides both, per token, at launch time, and you can't attach yourself to a token after the fact. A splitter with no named owner keeps the legs it launched with for good. One with an owner can change later: that owner alone, never you, may add you to a token you weren't in, drop you from one you were, or re-share what every leg gets, each change taking effect three days after it's proposed, through the same registration check a launch itself runs — see Changing where fees go. And you can't refuse what a collect() sends you — see Money flow.