Developers
Build a module
Write, test and preview a module on your own machine, against the real contracts. If this page and the Solidity disagree, the Solidity wins.
Two kinds
A launch module gets a token’s market started. A token module acts on a live token’s transfers and trades. How many a token carries is under Modules. A contract is never both.
Token modules are open to anyone, and this page is about writing one. POO.MEME publishes the launch modules: today the registry takes publishLaunch from its owner alone. That is a switch the owner holds open or closed, not something the contract fixes — ask the registry’s launchPublishingOpen() for where it stands on the chain you are on.
So your module is a token module:
- Factory
IPooTokenModuleFactory- Instance
IPooTokenModule- Hooks
- Gate, Track, Receive, Operate on sell, Operate on buy
- Installer
IPooTokenModuleInstaller- Publish
publishTokenModule
Set up
You need Node 22+ and Foundry. Install the poo CLI from npm, then start a module:
npm install -g @poomeme/sdk
poo init my-modulepoo init names the contract and its handle after the folder, then builds and tests the project.
| Command | What it does |
|---|---|
| forge test | Publishes your module to a real registry on a throwaway chain |
| poo preview | Draws the page your manifest asks for, and says if the registry accepts it |
| poo dev | Starts a local chain with POO.MEME and a token that installs your module |
| poo check | Refuses imports outside the published surface |
| poo publish | Checks a deployed factory; with --broadcast, publishes it |
src/MyModule.sol the contract a token installs
src/MyModuleFactory.sol its manifest, its probe, how instances are made
test/MyModule.t.sol POO.MEME in memory, publishing your module
script/Poo.s.sol the same as a script: poo dev broadcasts it, poo preview runs it in memory
lib/poo-sdk/ the published surface, test kit and dependenciesToolchain
poo init writes the compiler settings POO.MEME itself builds with:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
test = "test"
solc_version = "0.8.36"
auto_detect_solc = false
evm_version = "cancun"
optimizer = true
optimizer_runs = 200
via_ir = trueImports
@openzeppelin/=lib/poo-sdk/lib/openzeppelin-contracts/
@uniswap/v2-core/=lib/poo-sdk/lib/v2-core/contracts/
@uniswap/v2-periphery/=lib/poo-sdk/lib/v2-periphery/contracts/
forge-std/=lib/poo-sdk/lib/forge-std/src/
@standard/=lib/poo-sdk/src/standard/
@launch-module/=lib/poo-sdk/src/launch-module/
@token-module/=lib/poo-sdk/src/token-module/
@modules/=src/
@poo/devkit/=lib/poo-sdk/devkit/
@registry/=lib/poo-sdk/src/registry/
@token/=lib/poo-sdk/src/token/- Sources may import the first block: POO.MEME’s interfaces and types, OpenZeppelin and the Uniswap V2 interfaces.
- Tests and scripts may also import the second block, to stand POO.MEME up.
poo checkrefuses a source that imports a file outside the published surface, however the path is spelled. Build against interfaces, never against POO.MEME’s own contracts.
Token modules
Your ERC-165 answers are your hooks. The registry reads them from your probe and pins them; the token calls only those. A leg below is one transfer: a buy, a sell or a wallet transfer. Signatures and bit values are in the Reference.
| Hook | Call | When | If It Fails |
|---|---|---|---|
| Gate | gate | Each buy by a non-exempt address, before tax (static call); never a liquidity removal or a skim | The buy reverts |
| Track | track | Each leg outside an Operate run | Skipped |
| Receive | onReceive | After your tax row is paid, naming the asset it was paid in | Skipped; funds already arrived |
| Operate on sell | operateOnSell | In a run, just before a taxed sell or inside process(); may move the token | Skipped |
| Operate on buy | operateOnBuy | In a run, inside a taxed buy, after the gate has let it through; may move the token | Skipped |
Declare either side of Operate, or both: they are two bits over one run, and one operateGas serves whichever you declare. Declaring a side without that gas is refused, and declaring the gas without a side is refused too. Every other call gets exactly the gas your manifest declares for it.
Rules for every hook
- Accept calls from the token only. The probe’s token is the registry’s stand-in, which makes the publishing calls, so publishing still passes.
- Return nothing.
- In Gate, Track and Receive the token is locked: moving it reverts
Locked(). A run is the only hook that may move it. - A skipped call is never retried. Work from balances, not from counted callbacks: if you pay holders, pay on the balance still held.
- Publishing calls each hook once; Gate must allow
gate(publisher, 1). A hook declared with zero gas is refused.
Exemption
Your instance is exempt with its token: legs with it skip Gate and the token’s tax. That is the only exemption there is, it is decided when the token is created, and you cannot give it to anything else — there is no hook and no call for it. Say in your description whether your instance opens a route around the tax.
Operate runs
A run is one mechanism with two sides, armed on one trade and delivered on the next of its kind.
| Side | Armed by | Delivered |
|---|---|---|
| Operate on sell | Every taxed sell, the token’s own tax sale, and your requestRun() from outside a transfer — you must declare this side to request one | At the head of the next taxed sell, or inside process() |
| Operate on buy | Every taxed buy. A liquidity removal or a skim is not a buy and arms nothing | Inside the next taxed buy, after its gate has let it through |
An exempt trade, a wallet transfer and a leg the token is already running arm nothing on either side.
The two sides are not symmetrical, and an author will assume they are. process() drains the sell side alone — a buy run has no buyer to hand it outside a buy, so there is no way to reach the buy side except by buying. requestRun() therefore arms the sell side only, and a module that declares just the buy side is refused with BuyRunNotRequestable() rather than quietly given nothing: every taxed buy already arms you, so a request would add nothing there. If your module must act without a trade, the sell side is the only one that can.
One transaction can reach you twice on either side: a trade delivers what is armed and then arms you again, so a second trade of that kind in the same transaction runs you a second time, at a price that has barely moved. Cap what one transaction can take out, not only what one run can.
A leg the token is itself running has your module on one side of it, which makes it an exempt leg: it skips every gate and the token’s own tax, and POO.MEME’s share still accrues on it at whatever rate the token took.
A leg inside your run that your module is on neither side of is not exempt at all, and an author will expect it to be. It is classified like any other leg: a buy or a sell there pays the token’s whole table and a buy meets every Gate, exactly as it would outside a run. It also has a gas floor of its own, checked at that leg as usual — so a run that moves someone else’s tokens has to leave the gas behind for it, and a run that starves there is undone and skipped like any other.
Each run is told where it runs and the trader and amount that triggered it: context sets RUN_ARMED_BY_ROUND, RUN_ARMED_BY_REQUEST or both on the sell side, RUN_IN_PROCESS inside process(), where the trader and amount are zero, and RUN_ON_BUY — alone — on the buy side.
The trade that delivers a run — the sell on one side, the buy on the other — completes after you, at the price you left. So:
- Cap how much one run moves, as a share of live reserves.
- Trade round trips inside one run, so the price ends where it started.
- Set a floor on every swap from the reserves it faces.
- Size a supply release from a price you recorded over several blocks — the lowest of the last few closes, not the pool’s spot price, which the trade in front of you set.
- Cap a release per transaction as well as per run, so no one trade can take out more than a fixed slice of what you hold.
- Describe what a run does and how far it can move the price.
The token enforces the outcome, ahead of a sell and inside process(): an Operate run that leaves less quote in the pool, a lower price with more quote, or unsynced tokens is undone, and the sell goes on. An undone run is not armed again for that round. Adding tokens and syncing passes, and so does a round trip that sells back what it bought. The token gives the guard 20,000 gas beyond your declared cap, and charges it to the sell floor.
The launch module beside you
Your token module shares its token with exactly one launch module, and that shapes what you can rely on:
- The token mints the launch’s whole share of the supply straight to it. It holds any money the launch raises, pays the refunds, creates and seeds the token’s pool, and schedules the opening. POO.MEME does none of it.
scheduleOpen(openAt)is the launch module’s alone, once, up to 72 hours ahead and only once the pool holds liquidity;open()is anyone’s from the scheduled time. Launched is the token’s ownopenedAtand no module reports it.- No buy or sell reaches you before then, because a trade on an unopened token is refused; wallet transfers happen from creation onward and reach Track. A run requested while the token was still closed is delivered inside
open(). - It is untrusted like everything else, and what publishing cannot check nobody checks. Whether it locks the liquidity it adds, and for how long, is in its own description.
The manifest
manifest() is your module’s whole interface: the token page draws exactly what it declares. The registry pins its bytes, so build it pure or from immutables.
| Member | Rule |
|---|---|
| name | Not empty |
| summary | One line, 1–120 bytes |
| description | Up to 1,000 bytes: your disclosure |
| handle | Your module family’s handle |
| requires | What your module accepts and needs (below) |
| seeds | What a creator pays your instance at creation |
| receiveGas … operateGas | Each hook’s exact gas; zero without it, and one operateGas for both sides of a run |
| lifecycle, reads | Launch modules only: statuses, and the figures POO.MEME shows for the launch |
| probeConfig, config | The probe’s config, and the fields a creator fills in |
| sections, events, errors | Your panel, your lists, and a sentence per error |
Requirements
| Member | Meaning | Refused As |
|---|---|---|
| quote | Any, or one quote kind | QuoteKindMismatch |
| unique | One seat per token for your handle | DuplicateTokenModule |
| minSupplyBps | Least share of supply | SupplyShareBelowMinimum |
| minTotalBps | Least buy + sell tax for your row | MinTotalBpsNotMet |
| taxAsset | The asset your tax row is paid in: None (no row), Quote or Token | TaxShareNotAccepted |
| acceptsSupply | Takes a supply share | SupplyShareNotAccepted |
A minimum needs its share: publishing refuses minTotalBps with a taxAsset of None, and minSupplyBps without acceptsSupply.
Config and fields
config is your fields as one ABI tuple, in order: abi.encode(MyConfig({...})), read back with abi.decode(config, (MyConfig)). probeConfig is encoded the same way and must fit the declaration (ProbeConfigInvalid): empty for an empty config, opening with the word 0x20 when any field is dynamic, otherwise exactly 32 bytes per field.
| Type | In config | In An Action Input |
|---|---|---|
address, bool, string, bytes, bytes32, uint8 to uint256 in steps of 8 | Yes | Yes |
A list of any of those but bytes, such as address[] | No | Yes |
bytes[], bare uint, signed integers, tuples, fixed or nested arrays | No | No |
Any other type is UnsupportedFieldType; a name used twice in one list is FieldNameRepeated. A structure create needs travels as one bytes field your code decodes.
min,max,defaultValueanddependsValueareabi.encodeof one value of the field’s type. The registry does not read them and the page enforces them, so repeat every bound and cross-field rule on chain.dependsOnindexes another field: this one is drawn only while that field equalsdependsValue. An emptydependsValuedraws it always.- A blank
optionalfield and a hidden field encode zero. The create page puts required fields first and folds optional ones into one group; the encoding keeps your order.
| The Field Declares | Drawn As | Encodes |
|---|---|---|
uintN[] with options | A checkbox group | The indexes checked |
| Any other list | One item per line; consecutive list inputs of one action share one editor of parallel columns | The array |
uintN with options | A one-of choice | The option’s index |
| bool | A checkbox when optional or given a defaultValue, otherwise a choice left open until picked; two options name false and true | false or true |
| address | An address box; on the token page, with quick fills for the wallet, the token, its pair and its modules | The address |
bytes32, bytes | A hex box | The bytes |
| string | A line, or a box when multiline | The text |
uintN in Timestamp | A date and time, in UTC | Unix seconds |
uintN in Duration | Days, hours, minutes and seconds | Seconds |
uintN in Bps | A percentage in steps of 0.01; a slider when min and max are set | Bps |
uintN in TokenAmount or QuoteAmount | An amount in that asset, accepting 10k and 2.5m | Base units |
uintN in Price | The whole tokens one whole quote token buys | Token base units per quote base unit × 10^18 |
uintN in Raw with assetOf | An amount in that ERC-20 | Base units |
uintN in Raw named multipleWad | A multiple, entered plainly and suffixed × | The multiple × 10^18 |
Any other uintN | A whole number | The number |
Priceis the ratiotokensOut = quoteIn * price / 1e18. It carries 18 + token decimals − quote decimals places, and a finer figure is refused, never rounded.maxOfandassetOfbelong only onRaw,TokenAmountorQuoteAmount, andpresetBpsalso onBps(FieldAmountRuleOnNonAmount).maxOfisselector(address viewer) -> uint256, the most this wallet may enter now. An action’s amount is capped at the smaller of it and what the wallet holds.assetOfisselector() -> address, the ERC-20 the amount is in, and must answer nonzero (FieldAssetUnanswered). Zero means the unit’s own asset.presetBpsare quick fills as bps of that cap, strictly increasing within 1–10,000 (FieldPresetsInvalid). An action amount without them gets 25 %, 50 %, 75 % and Max.valueOfmakes an action input your module’s own answer,selector() ->the input’s type, read live and drawn as a fact. Action inputs only (FieldValueOfOnConfig), static types only (FieldValueOfDynamic).multilineis forstringonly (FieldMultilineOnNonText).- A token module whose config has exactly one
addressfield namedowner, inUnit.Address, is drawn as an owner choice: Creator, Another address or None. Treat zero as no owner, with every owner-only setting final. - A
Unit.Rawnumber namedmultipleWad, or whose name ends inMultipleWad, is a multiple of something you already name — "how many times the opening". The creator writes20and your module reads20e18, so declareminandmaxat that same WAD scale; the create page draws them back as5×and1,000×and refuses anything outside. On the create page it also names the market cap a target lands on beside the box —$50,000 (20×)— from the opening pool the chosen launch module answers with. Any otherRawnumber keeps the plain whole-number box.
Sections and views
A Section is a titled list of items. Each Item names one of the section’s own views, actions or lists.
itemsis the drawing order and names every element exactly once (ItemIndexOutOfRange,ItemRepeated,ItemUnreferenced). AGapitem holds an empty cell, with index 0.columnsis 0–4, where 0 takes the page’s own grid (ColumnsOutOfRange). An item’sspanis at most the columns, or 4 without them; 0 takes the kind’s default (SpanOutOfRange).rolesays where you offer the section:Bodyin your panel, in your order;Progress,TermsorTradeto those parts of the page. ATradesection docks beside the market.visibleWhen, on a section, view or action, is aCondition: a read answering a word, where nonzero shows and zero hides, asked asselector()or, withwithViewer,selector(address viewer). Itslabelsays when the item shows, 1–80 bytes, and is empty exactly when the selector is zero (ConditionLabelInvalid). A viewer condition waits for a connected wallet. A condition is a hint; your code stays the authority.- A view that answers nothing is not drawn: a zero, an empty string, the zero address or an empty list. A
Stateis the exception, since its zero is a word. A section with nothing left to draw disappears.
A View is one typed read. Its widget sets how many selectors it takes (ViewShapeInvalid) and what the registry asks on your probe:
| Widget | selectors | Drawn As |
|---|---|---|
| Figure | [value], or [value, multiple] | A labelled figure in unit, with multiple drawn beside it in parentheses |
| Progress | [current, marker, max]; marker and max may be zero | A bar; zero max is uncapped, and labels[0] names the marker |
| Countdown | [timestamp] | The moment, and how far off while ahead |
| State | [index] | labels[index] in tones[index]; needs labels (StateWithoutLabels), never in Text or Address (StateUnitInvalid) |
| Address | [address] | A short address with copy and explorer links |
| Text | [string] | Plain text, never markup or links |
| Rows | [count, at], with at(uint256 index) | A paged list; at answers every index, zero past the end |
| Series | [sample, start, end], with sample(uint256 x) | A curve over your own domain; sample answers everywhere |
- Every read the registry asks must return at least one 32-byte word, or it is
SelectorNotAnswered.RowsandSeriesare asked at index 0 and attype(uint256).max. - A view in
Unit.AssetAmountadds one last selector answering the ERC-20 it counts in: nonzero (FieldAssetUnanswered) and answeringdecimals()(AssetNotAnErc20). - A
Figuremay declare one optional second selector, probed exactly as its first and refused when it is zero or unanswered. It draws in parentheses after the figure as a plain multiple —$12.5k (25×)— never in the view's own unit. TheUnit.AssetAmountpointer stays last, so a figure that wants both declares[value, multiple, asset], while[value, asset]is a figure with no multiple. tonesrun besidelabels:Neutral,Positive,NegativeorWarning; empty is Neutral throughout.
poo init writes a section like this one:
function _streakSection() private pure returns (Section memory s) {
s.title = "Streak";
s.views = new View[](2);
s.views[0] = _figure("Minimum balance", MyModule.threshold.selector, false, Unit.TokenAmount);
s.views[1] = _figure("Your streak", MyModule.streakOf.selector, true, Unit.Duration);
s.lists = new ListDecl[](0);
s.actions = new Action[](1);
s.actions[0] = _forgetAction();
s.columns = 2;
s.items = new Item[](3);
s.items[0] = Item({kind: ItemKind.View, span: 0, index: 0});
s.items[1] = Item({kind: ItemKind.View, span: 0, index: 1});
s.items[2] = Item({kind: ItemKind.Action, span: 0, index: 0});
}Actions
An Action is a transaction the page offers: a call when inputs is empty, a form otherwise. The page shows name in sentence case, with description beneath.
nameis the called function’s exact name andinputsits exact parameters, in order. The registry rebuilds the selector from them (ActionSelectorMismatch) and refuses an empty name (ActionNameEmpty).previewis zero, or a view over exactly the action’s inputs returninguint256inpreviewUnit; without one,previewUnitisRaw(PreviewUnitWithoutPreview). When every input is static the registry asks it with every input zero, so answer zero rather than revert.minOutInputanddeadlineInputname an input as its index + 1; 0 names none. The minimum is a requireduint256inTokenAmountorQuoteAmount, on an action whose preview answers in that same unit. The deadline is a requireduint256oruint64inTimestamp. Neither isvalueOf, and they are never the same input (theMinOut…andDeadline…errors).- The page fills both: the minimum from your preview and the reader’s slippage, the deadline from the chain’s clock. Your code enforces them: revert below the minimum and past the deadline. The registry only proves they are declared usably.
flowsays which way value moves for the presser:WalletPays,WalletReceivesorNone. The profile page gathersWalletReceivesactions under Available now.isExitmarks how a wallet leaves a launch. Neither authorizes anything.isPayablesends aQuoteAmountinput as the call’s value on a native market. On an ERC-20 market the page asks for an approval of that amount to your module first, as it does for anyWalletPaysaction with aQuoteAmountinput. A payable action isWalletPays(PayableActionNotWalletPays) and has no target (PayableActionWithTarget).amountis zero orselector(address viewer) -> uint256, a figure drawn beside the button inamountUnit.formValuesis zero orselector(address viewer) -> bytes[], one ABI-encoded value per input (FormValuesLengthMismatch), so an edit form opens on what is live.targetis zero orselector() -> address: the transaction goes to that contract, andnameandinputsare its signature. Every other read stays on your module.- Do not declare
transferOwnershiporrenounceOwnership: the page draws them for the owner of an instance answeringowner().
Seeds
A seed is quote the token factory collects from the creator and hands your instance at creation.
- Declare each in
seedsas aQuoteseed; aTokenseed is refused (TokenSeedUnsupported). Name its amount exactly once (SeedAmountAmbiguous):amountFieldindexes auint256configfield (SeedFieldOutOfRange,SeedFieldNotAnAmount), or isNO_SEED_FIELDwith a fixedamount. Only the first 32configfields can name a seed amount — the registry tracks which are spoken for in a 32-bit mask, so an index of 32 or more isSeedFieldOutOfRangehowever long your config is. - The registry pins the fixed amounts and the seed fields. The factory pays their sum from your pinned manifest and the creator’s config, so your instance never asks for its seed and never gets more than the form showed.
- On a native market the seed arrives as
msg.value; the value above the creation fee must match exactly (SeedMismatch). On an ERC-20 market the factory moves it from the creator beforeonInstalled. onInstalledruns on every instance with an installer interface, seeded or not: token modules in table order, the launch module last, with no gas cap. A revert undoes the creation. Accept it only fromIPooToken(token).factory(), once.
Events and lists
There is no required event. POO.MEME’s indexer listens at each instance for exactly the events your manifest declares, and a list authorizes nothing.
An EventDecl gives signature in readable form, parameter names and indexed included, and topic0, its selector; they must match (EventSignatureMismatch).
walletArg,amountArg,keyArg,flagArgandtextArgare parameter indexes orNO_EVENT_ARG(EventArgOutOfRange).textArgis a non-indexedstring(EventTextArgInvalid).amountUnitisRawwhen there is no amount (EventShapeMismatch). The sametopic0under the sameroletwice isEventRepeated.
role | Used For | Must Carry (EventShapeMismatch) |
|---|---|---|
| WalletInflow | A launch’s contributions on the profile page | A wallet and an amount in QuoteAmount |
ListAdd, ListRemove | A Membership list | A wallet |
| Custom | Any list you build on it | What the list needs |
A ListDecl is a list the indexer builds from a declared event, paged and searchable by wallet (ListEventOutOfRange, ListRoleMismatch):
shape | Built From | Drawn As |
|---|---|---|
| WalletTotals | One event with a wallet and an amount | A total per wallet, largest first, with the sum |
| Membership | A ListAdd event, and optionally a ListRemove one | The wallets added and not since removed, newest first |
| Log | One event with a wallet or a key | Every entry, newest first |
| Snapshot | One event with a wallet, re-emitted whole on every change | The rows of your latest write |
role: ShareTable marks payees each taking a share of a whole; the token page draws it under Allocations, as parts of that whole.
Errors
errors pairs each of your custom error selectors with a sentence of 1–200 bytes (ErrorSelectorInvalid, ErrorRepeated, ErrorTextInvalid). When a call to your instance reverts with one that no POO.MEME contract declares, the page shows your sentence. Explain every error a reader can meet.
Gas caps
- A cap is the exact gas that hook gets, on every call, for the token’s life.
- Each hook has its own ceiling, and publishing refuses a cap above it: Gate 30,000, Track 550,000, Receive 265,000, Operate 1,000,000 — one ceiling over both sides of a run.
gasCeiling(hook)on the registry answers each one. - A tax payee contract’s own
receiveGas()is held to the Receive ceiling: at most 265,000. - Declaring Gate costs the token a flat 15,000 gas beyond your cap — once, however many gates a token carries — on its buy, nested and exempt-pair floors. That is what it reserves to tell a real buy from a liquidity removal or a skim, which never reach a gate, and it is charged whether or not yours is called.
- Publishing only proves the hook fits on the probe. Measure your worst case on a real token.
- Your caps become part of the token’s gas floors, and a floor is headroom rather than a bill: the token compares the floor for that leg against the gas the transaction arrived with, refuses the leg if it is short, and never spends the floor on the check. A trader pays for what your hooks actually use. But every trade that can reach you has to carry your whole cap, so a cap you never spend still raises the limit every one of those transactions must be sent with.
- The floors exist because a failed call is skipped. Without them, a leg entered on thin gas would quietly drop your hook and succeed anyway; the floor turns that into a refusal the trader can see, and it is why a wallet cannot size one of these transactions from usage.
- Too large: tokens with your module can fail to be created. Too small a
gateGas: every buy of those tokens reverts.
How you get paid
- A tax row
- Declare a
taxAssetofQuoteorToken. The creator sets your share, for the token’s life. AQuoterow arrives after each tax sale; aTokenrow arrives inside each taxed trade.onReceive(asset, buyAmount, sellAmount)tells you. A quote payment that fails is kept and added to your next one, or your module can callclaimTax()on the token to collect it sooner. - A creation seed
- Declare it in
seedsand receive it inonInstalled(creator).
There is no cut of a launch’s raise. Whatever a launch module pays out of its raise is its own code, stated in its description.
Your security obligations
Nothing checks these for you. Answer them before you publish — your description and your code are the whole account a reader gets.
- Does
initializecheck every setting on chain? - Can an instance ever run other code than the probe’s?
- Does the description name every owner power and how it ends?
- Can tokens sent to your instance be forwarded?
- Is value safe when a Track, Receive or Operate call is skipped?
Any owner power to withdraw, seize, pause, censor, rescue, expire claims or refunds, or make arbitrary calls must be in your description, in plain words.
