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-module

poo init names the contract and its handle after the folder, then builds and tests the project.

CommandWhat it does
forge testPublishes your module to a real registry on a throwaway chain
poo previewDraws the page your manifest asks for, and says if the registry accepts it
poo devStarts a local chain with POO.MEME and a token that installs your module
poo checkRefuses imports outside the published surface
poo publishChecks a deployed factory; with --broadcast, publishes it
my-module/
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 dependencies

Toolchain

poo init writes the compiler settings POO.MEME itself builds with:

foundry.toml
[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 = true

Imports

remappings.txt
@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 check refuses 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.

HookCallWhenIf It Fails
GategateEach buy by a non-exempt address, before tax (static call); never a liquidity removal or a skimThe buy reverts
TracktrackEach leg outside an Operate runSkipped
ReceiveonReceiveAfter your tax row is paid, naming the asset it was paid inSkipped; funds already arrived
Operate on selloperateOnSellIn a run, just before a taxed sell or inside process(); may move the tokenSkipped
Operate on buyoperateOnBuyIn a run, inside a taxed buy, after the gate has let it through; may move the tokenSkipped

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.

SideArmed byDelivered
Operate on sellEvery taxed sell, the token’s own tax sale, and your requestRun() from outside a transfer — you must declare this side to request oneAt the head of the next taxed sell, or inside process()
Operate on buyEvery taxed buy. A liquidity removal or a skim is not a buy and arms nothingInside 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 own openedAt and 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.

MemberRule
nameNot empty
summaryOne line, 1–120 bytes
descriptionUp to 1,000 bytes: your disclosure
handleYour module family’s handle
requiresWhat your module accepts and needs (below)
seedsWhat a creator pays your instance at creation
receiveGas … operateGasEach hook’s exact gas; zero without it, and one operateGas for both sides of a run
lifecycle, readsLaunch modules only: statuses, and the figures POO.MEME shows for the launch
probeConfig, configThe probe’s config, and the fields a creator fills in
sections, events, errorsYour panel, your lists, and a sentence per error

Requirements

MemberMeaningRefused As
quoteAny, or one quote kindQuoteKindMismatch
uniqueOne seat per token for your handleDuplicateTokenModule
minSupplyBpsLeast share of supplySupplyShareBelowMinimum
minTotalBpsLeast buy + sell tax for your rowMinTotalBpsNotMet
taxAssetThe asset your tax row is paid in: None (no row), Quote or TokenTaxShareNotAccepted
acceptsSupplyTakes a supply shareSupplyShareNotAccepted

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.

TypeIn configIn An Action Input
address, bool, string, bytes, bytes32, uint8 to uint256 in steps of 8YesYes
A list of any of those but bytes, such as address[]NoYes
bytes[], bare uint, signed integers, tuples, fixed or nested arraysNoNo

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, defaultValue and dependsValue are abi.encode of 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.
  • dependsOn indexes another field: this one is drawn only while that field equals dependsValue. An empty dependsValue draws it always.
  • A blank optional field 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 DeclaresDrawn AsEncodes
uintN[] with optionsA checkbox groupThe indexes checked
Any other listOne item per line; consecutive list inputs of one action share one editor of parallel columnsThe array
uintN with optionsA one-of choiceThe option’s index
boolA checkbox when optional or given a defaultValue, otherwise a choice left open until picked; two options name false and truefalse or true
addressAn address box; on the token page, with quick fills for the wallet, the token, its pair and its modulesThe address
bytes32, bytesA hex boxThe bytes
stringA line, or a box when multilineThe text
uintN in TimestampA date and time, in UTCUnix seconds
uintN in DurationDays, hours, minutes and secondsSeconds
uintN in BpsA percentage in steps of 0.01; a slider when min and max are setBps
uintN in TokenAmount or QuoteAmountAn amount in that asset, accepting 10k and 2.5mBase units
uintN in PriceThe whole tokens one whole quote token buysToken base units per quote base unit × 10^18
uintN in Raw with assetOfAn amount in that ERC-20Base units
uintN in Raw named multipleWadA multiple, entered plainly and suffixed ×The multiple × 10^18
Any other uintNA whole numberThe number
  • Price is the ratio tokensOut = quoteIn * price / 1e18. It carries 18 + token decimals − quote decimals places, and a finer figure is refused, never rounded.
  • maxOf and assetOf belong only on Raw, TokenAmount or QuoteAmount, and presetBps also on Bps (FieldAmountRuleOnNonAmount).
  • maxOf is selector(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.
  • assetOf is selector() -> address, the ERC-20 the amount is in, and must answer nonzero (FieldAssetUnanswered). Zero means the unit’s own asset.
  • presetBps are 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.
  • valueOf makes 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).
  • multiline is for string only (FieldMultilineOnNonText).
  • A token module whose config has exactly one address field named owner, in Unit.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.Raw number named multipleWad, or whose name ends in MultipleWad, is a multiple of something you already name — "how many times the opening". The creator writes 20 and your module reads 20e18, so declare min and max at that same WAD scale; the create page draws them back as and 1,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 other Raw number 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.

  • items is the drawing order and names every element exactly once (ItemIndexOutOfRange, ItemRepeated, ItemUnreferenced). A Gap item holds an empty cell, with index 0.
  • columns is 0–4, where 0 takes the page’s own grid (ColumnsOutOfRange). An item’s span is at most the columns, or 4 without them; 0 takes the kind’s default (SpanOutOfRange).
  • role says where you offer the section: Body in your panel, in your order; Progress, Terms or Trade to those parts of the page. A Trade section docks beside the market.
  • visibleWhen, on a section, view or action, is a Condition: a read answering a word, where nonzero shows and zero hides, asked as selector() or, with withViewer, selector(address viewer). Its label says 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 State is 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:

WidgetselectorsDrawn 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 zeroA 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. Rows and Series are asked at index 0 and at type(uint256).max.
  • A view in Unit.AssetAmount adds one last selector answering the ERC-20 it counts in: nonzero (FieldAssetUnanswered) and answering decimals() (AssetNotAnErc20).
  • A Figure may 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. The Unit.AssetAmount pointer stays last, so a figure that wants both declares [value, multiple, asset], while [value, asset] is a figure with no multiple.
  • tones run beside labels: Neutral, Positive, Negative or Warning; empty is Neutral throughout.

poo init writes a section like this one:

src/MyModuleFactory.sol
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.

  • name is the called function’s exact name and inputs its exact parameters, in order. The registry rebuilds the selector from them (ActionSelectorMismatch) and refuses an empty name (ActionNameEmpty).
  • preview is zero, or a view over exactly the action’s inputs returning uint256 in previewUnit; without one, previewUnit is Raw (PreviewUnitWithoutPreview). When every input is static the registry asks it with every input zero, so answer zero rather than revert.
  • minOutInput and deadlineInput name an input as its index + 1; 0 names none. The minimum is a required uint256 in TokenAmount or QuoteAmount, on an action whose preview answers in that same unit. The deadline is a required uint256 or uint64 in Timestamp. Neither is valueOf, and they are never the same input (the MinOut… and Deadline… 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.
  • flow says which way value moves for the presser: WalletPays, WalletReceives or None. The profile page gathers WalletReceives actions under Available now. isExit marks how a wallet leaves a launch. Neither authorizes anything.
  • isPayable sends a QuoteAmount input 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 any WalletPays action with a QuoteAmount input. A payable action is WalletPays (PayableActionNotWalletPays) and has no target (PayableActionWithTarget).
  • amount is zero or selector(address viewer) -> uint256, a figure drawn beside the button in amountUnit.
  • formValues is zero or selector(address viewer) -> bytes[], one ABI-encoded value per input (FormValuesLengthMismatch), so an edit form opens on what is live.
  • target is zero or selector() -> address: the transaction goes to that contract, and name and inputs are its signature. Every other read stays on your module.
  • Do not declare transferOwnership or renounceOwnership: the page draws them for the owner of an instance answering owner().

Seeds

A seed is quote the token factory collects from the creator and hands your instance at creation.

  • Declare each in seeds as a Quote seed; a Token seed is refused (TokenSeedUnsupported). Name its amount exactly once (SeedAmountAmbiguous): amountField indexes a uint256 config field (SeedFieldOutOfRange, SeedFieldNotAnAmount), or is NO_SEED_FIELD with a fixed amount. Only the first 32 config fields can name a seed amount — the registry tracks which are spoken for in a 32-bit mask, so an index of 32 or more is SeedFieldOutOfRange however 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 before onInstalled.
  • onInstalled runs 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 from IPooToken(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, flagArg and textArg are parameter indexes or NO_EVENT_ARG (EventArgOutOfRange). textArg is a non-indexed string (EventTextArgInvalid).
  • amountUnit is Raw when there is no amount (EventShapeMismatch). The same topic0 under the same role twice is EventRepeated.
roleUsed ForMust Carry (EventShapeMismatch)
WalletInflowA launch’s contributions on the profile pageA wallet and an amount in QuoteAmount
ListAdd, ListRemoveA Membership listA wallet
CustomAny list you build on itWhat the list needs

A ListDecl is a list the indexer builds from a declared event, paged and searchable by wallet (ListEventOutOfRange, ListRoleMismatch):

shapeBuilt FromDrawn As
WalletTotalsOne event with a wallet and an amountA total per wallet, largest first, with the sum
MembershipA ListAdd event, and optionally a ListRemove oneThe wallets added and not since removed, newest first
LogOne event with a wallet or a keyEvery entry, newest first
SnapshotOne event with a wallet, re-emitted whole on every changeThe 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.
A tax row
Declare a taxAsset of Quote or Token. The creator sets your share, for the token’s life. A Quote row arrives after each tax sale; a Token row 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 call claimTax() on the token to collect it sooner.
A creation seed
Declare it in seeds and receive it in onInstalled(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 initialize check 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.