Skip to content
montra

For builders

Build on Montra

Montra pools are standard Uniswap v4 pools with the Montra hook attached: they trade through Uniswap's Universal Router or any other v4 router, a quote matches its trade exactly, and Montra's lens and its own simple router make reading, quoting and trading a pool one call each.

Registry
get(name) · hooks() · isKnownHook(hook)
Names every other address and lists every hook.
Lens
tokenView(token)
A pool's key, terms, price, fees and progress in one read.
Router
quoteExactIn · swapExactIn
LaunchRouter, the site's simple single-pool router: a quote and a trade, one code path.
Any v4 router
Universal Router · V4Quoter
Uniswap's own, multi-hop included, with the key the hook gives.
Hook
poolKey(poolId) · poolConfig(poolId)
A pool's key and its terms.

No bonding curve, no migration

A Montra token has no bonding curve, and nothing migrates. There is no second pool, no liquidity to move and no new address: the Uniswap v4 pool a token launches in is its market for life, and its poolId and PoolKey never change. A graduated token trades as it did before.

Graduation is still there, as a line in the pool: 16.8× the opening price on the menu's rows, about $58.7k of market cap from a $3.5k opening. Passing it moves nothing and changes no fee. It is there so a terminal's usual columns work on Montra as they do anywhere else.

Column

New
Which poolsLess than half way to the line
API stagenew
On chaingraduationProgress under 5,000, PoolConfig.stage 0
About to graduate
Which poolsHalf way or more, not yet graduated
API stagegraduating
On chaingraduationProgress of 5,000 or more, PoolConfig.stage 0 or 1
Graduated
Which poolsPast the line and confirmed, for good
API stagegraduated
On chainPoolConfig.stage 2, 3 or 4

Half way is the cut Montra's own board uses. progressBps runs from 0 to 10,000, so a terminal can draw the column wherever it likes.

  • A column. GET /api/tokens?stage=graduating&sort=progress lists the pools closest to the line first. stage takes new, graduating or graduated, and sort=graduated puts the latest graduates first.
  • A progress bar. graduation.progressBps on a card, or LaunchLens.graduationProgress(address hook, bytes32 poolId) view returns (uint256) on chain: the quote the seed has collected over the quote it holds at the line, the figure a bonding curve's bar shows.
  • The moment it graduates. The hook's Graduated event, or graduation.graduatedAt in the API. There is no migration transaction to watch for: the first swap of a later transaction confirms the line, and nobody has to call anything.
  • Past the line, not yet confirmed. graduationProgress reads 10,000 at stage Crossed (1). The next transaction's swap graduates the pool, whichever way it trades, so a pool at Crossed is almost always about to move: keep it at the top of About to graduate until Graduated. A sale can graduate a pool and leave the price back under the line. Only a sale in the transaction that crossed the line steps the pool back, before any read can see it.
  • A pool with no line. graduationTicks of zero in PoolRegistered means graduated from birth, with no Graduated event. The API already counts it as graduated.

Find and trust a pool

Find tokens from the factory's TokenLaunched events or from the public API. Accept a pool only if its hook is one the factory announced (ModuleSetPublished, emitted once the factory has checked the hook), which the registry mirrors (hooks(), isKnownHook(hook)): filter by it rather than taking the first pool you find for a token. An indexer pins the factory's proxy, whose address never moves; an app pins the registry and reads every other address from it by name. The addresses themselves are under Contracts.

Trust a token by its code, never by its name: a launch is an EIP-1167 clone of a token master the factory published, with a supply. Anyone can clone a master, but only the factory it names can initialize one, so a clone with a supply is a launch.

MONTRA, the launchpad's own token, is the token the registry names under montra (get("montra"), the name's ASCII right-padded to 32 bytes), and nothing else is, whatever its name or symbol. Until the registry names one, there is no MONTRA.

An indexer starts at the factory proxy's deployment block: no launch comes before it, and the creator's first buy is logged inside the launch's own transaction, before its TokenLaunched.

The pool key (PoolKey, from hook.poolKey(bytes32 poolId))Plain text
PoolKey({
  address currency0:   the lower address (native HYPE = 0x0000…0000),
  address currency1:   the higher address,
  uint24  fee:         0x800000,     // dynamic: the LP fee lives in the pool's slot0
  int24   tickSpacing: 200,          // the row's; every row of the menu's
  address hooks:       <launch hook> // one the factory announced; this one's low 14 bits are 0x38EC
})

The registry's keys

get(bytes32 key) returns (address) takes the key's ASCII right-padded to 32 bytes (viem's stringToHex(key, { size: 32 }), cast's --format-bytes32-string), and answers zero for a key not set. Every change is announced by AddressSet(key, previous, current); hooks are not keys but a list (hooks(), isKnownHook(hook), HookAdded), from which none is ever removed.

Key

factory
What it namesThe factory's proxy: launches, rows and module sets. Once deployed, its address never moves.
SetAt deployment
router
What it namesLaunchRouter: a quote and an exact-input swap on one pool.
SetAt deployment
lens
What it namesLaunchLens: stateless reads, replaceable, so read it here.
SetAt deployment
quoteRegistry
What it namesThe quote assets a launch can pair with.
SetAt deployment
protocolVault
What it namesProtocolFeeVault: the protocol's share and the launch fees.
SetAt deployment
feeMarket
What it namesFeeMarket: the sales of the protocol's fees.
SetAt deployment
routeFiller
What it namesRouteFiller: fills a sale through pools.
SetAt deployment
buybackFiller
What it namesBuybackFiller: the buyback's only taker.
SetAt deployment
poolManager
What it namesUniswap v4's PoolManager, where every pool lives.
SetAt deployment
custodian
What it namesWhere the venue keeps every pool's balances, which holder rewards leave out: on v4 the PoolManager itself.
SetAt deployment
universalRouter
What it namesUniswap's Universal Router, for exact-output and multi-hop trades through a launch pool. The registry's own address here, or under permit2 or quoter, means withdrawn: none.
SetBy the governor; zero while unset, which is not an error
permit2
What it namesCanonical Permit2, which that router pulls every ERC-20 input with: a sale's token, and the quote of a buy in an ERC-20. Each token needs two approvals: the token's to Permit2, then Permit2's to the router, with an expiry.
SetWith universalRouter; zero while unset
quoter
What it namesUniswap's V4Quoter, for quoting a swap by either side: quoteExactIn quotes exact input alone.
SetBy the governor; zero while unset, which is not an error
gatewayRouter
What it namesThe chain's token bridge router, which Montra's API asks whether a listed quote is the bridge's canonical token. Nothing on chain calls it.
SetBy the governor; zero while unset

The launchpad's own token's key is set by the governor's last call in the batch that launches it, and reads zero until then.

The ABIs

Every Montra contract's ABI, and Uniswap's PoolManager's, is published from the contracts' build: /docs/abis.json, an object of from, the build they came from, and contracts, each contract's ABI by its name; /docs/abis/<Contract>.json, one contract's as a plain array, ready for viem or ethers; and /docs/abis/events.json, every event once, to decode any log an indexer reads. Each contract change refreshes them (npm run pull:abis in the site's repo), and every function, event, error, argument and field these pages name is checked against them.

FileWhat it is
LaunchpadRegistry.jsonLaunchpadRegistry: the address book
LaunchFactory.jsonLaunchFactory: launches and module sets (the proxy's ABI)
LaunchToken.jsonLaunchToken: every launch token
LaunchHook.jsonLaunchHook: the pools' hook
LaunchLens.jsonLaunchLens: the read views
LaunchRouter.jsonLaunchRouter: quotes and exact-input swaps
QuoteRegistry.jsonQuoteRegistry: the quote assets
FeeSplitter.jsonFeeSplitter: a split's clone
ProtocolFeeVault.jsonProtocolFeeVault: the protocol's share
FeeMarket.jsonFeeMarket: the fee market's sales
RouteFiller.jsonRouteFiller: filling a sale through pools
BuybackFiller.jsonBuybackFiller: the buyback's filler
TokenBurner.jsonTokenBurner: the buyback's burner
PoolManager.jsonPoolManager: Uniswap v4's, for Swap, Initialize and ModifyLiquidity, and the errors a refusal arrives in, the V4Quoter's wrapper among them

The Universal Router, the V4Quoter and Permit2 are Uniswap's, and their ABIs are not published here. Take each from Uniswap's own repository (Uniswap/universal-router, Uniswap/v4-periphery, Uniswap/permit2) at the version the address the registry names is running, or from the chain's explorer where that address is verified. The calls these pages use are written out under Trade through the Universal Router.

A script or an AI tool reads the docs' machine files as plain files: these ABIs, /llms.txt, /llms-full.txt and each page's Markdown at /docs/<page>.md.

Fetching an ABIShell
curl -s https://montramarkets.xyz/docs/abis/LaunchRouter.json

Read, quote and trade

  1. An app hard-codes the registry and reads the rest from it. Names are bytes32 strings. In these samples client is a viem public client, wallet a wallet client for account, and registryAbi, lensAbi and routerAbi the registry's, the lens's and the router's ABIs.
    TypeScript
    // get(bytes32 key) view returns (address)
    import { stringToHex, zeroAddress } from "viem";
    
    const registry = "0xF1E2FA75422F889e565F3142bdbf60B28dce2aa7";
    const name = (s: string) => stringToHex(s, { size: 32 });
    const lens = await client.readContract({
      address: registry, abi: registryAbi, functionName: "get", args: [name("lens")],
    });
    const router = await client.readContract({
      address: registry, abi: registryAbi, functionName: "get", args: [name("router")],
    });
  2. One call returns the pool key, its terms (config), the price, what the next trade pays and the pool's progress: a PoolView of 50 words, field by field under Contract reference. Check the hook against the registry. An address that is not a launch token makes it revert with no data, so there is no error to decode.
    TypeScript
    // tokenView(address token) view returns (PoolView)
    const view = await client.readContract({
      address: lens, abi: lensAbi, functionName: "tokenView", args: [token],
    });
    view.key;                     // the pool key to trade with
    view.config.quoteIsCurrency0; // which side the quote asset is
    view.sqrtPriceX96;            // the price now
    view.effectiveTotalFee;       // what the next sale pays all in, in pips
    view.buyEffectiveTotalFee;    // what a buy pays all in now: more inside an anti-snipe window
    await client.readContract({
      address: registry, abi: registryAbi, functionName: "isKnownHook", args: [view.key.hooks],
    }); // true
  3. Buying the token spends the quote asset, so a buy swaps from the quote's side. quoteExactIn returns the output after every fee, as any call returns: it runs the swap inside the PoolManager and undoes it, so it changes nothing, but it is not marked view, so make it an eth_call (viem's simulateContract). On a Montra pool its quote is exactly what the trade then pays out. A swap the pool or the hook refuses makes the quote revert UnexpectedRevert, carrying the refusal. The amounts are uint128.
    TypeScript
    // quoteExactIn(PoolKey key, bool zeroForOne, uint128 amountIn) returns (uint256)
    const zeroForOne = view.config.quoteIsCurrency0; // a buy; a sell is the opposite
    const { result: amountOut } = await client.simulateContract({
      address: router, abi: routerAbi, functionName: "quoteExactIn",
      args: [view.key, zeroForOne, amountIn],
    });
  4. How a refusal arrives depends on the call, so take each layer only if it is there. quoteExactIn reverts UnexpectedRevert(bytes reason), selector 0xc47bc67c, with the refusal as its reason. The V4Quoter does the same in UnexpectedRevertBytes(bytes revertData), selector 0x6190b2b0. swapExactIn adds no layer: the refusal arrives as it is, as it does through the deployed Universal Router, and another router may wrap it in an error of its own. Under that, a pool manager's own refusal is the error itself. A hook's arrives wrapped, as ERC-7751 has it, in WrappedError(address target, bytes4 selector, bytes reason, bytes details), selector 0x90bfb865: target is the hook, selector the callback that refused, reason the hook's own error and details the selector of HookCallFailed(), 0xa9e35b2f. An exact-output buy for more than the pool can deliver reverts SafeCastOverflow(), selector 0x93dafdf1. UnexpectedRevert is in LaunchRouter.json, the hook's errors in LaunchHook.json, and the rest in PoolManager.json.
    TypeScript
    // quoteExactIn:   UnexpectedRevert(bytes reason)
    // V4Quoter:       UnexpectedRevertBytes(bytes revertData)
    // swapExactIn:    no layer
    //   WrappedError(address target, bytes4 selector, bytes reason, bytes details)
    //     PartialFillRejected()
    import { decodeErrorResult } from "viem";
    
    // revertData: the bytes the call reverted with
    const abi = [...routerAbi, ...poolManagerAbi, ...hookAbi];
    let refusal = decodeErrorResult({ abi, data: revertData });
    if (refusal.errorName === "UnexpectedRevert" || refusal.errorName === "UnexpectedRevertBytes") {
      refusal = decodeErrorResult({ abi, data: refusal.args[0] });
    }
    if (refusal.errorName === "WrappedError") {
      refusal = decodeErrorResult({ abi, data: refusal.args[2] });
    }
    refusal.errorName; // "PartialFillRejected"
  5. One exact-input swap with a minimum and a deadline. A native input is sent as value; an ERC-20 input needs an approval for the router first. Reverts Expired, ZeroAddress, InvalidRecipient (the router itself as recipient), IncorrectValue or TooLittleReceived, or with the pool's or the hook's refusal, read as above; emits Swapped.
    TypeScript
    // swapExactIn((PoolKey key, bool zeroForOne, uint128 amountIn, uint128 minAmountOut, address recipient, uint256 deadline) s)
    //   payable returns (uint256 amountOut)
    const input = zeroForOne ? view.key.currency0 : view.key.currency1;
    await wallet.writeContract({
      address: router, abi: routerAbi, functionName: "swapExactIn",
      args: [{
        key: view.key, zeroForOne, amountIn,
        minAmountOut: amountOut * 99n / 100n,   // 1% slippage
        recipient: account.address,
        deadline: BigInt(Math.floor(Date.now() / 1000) + 300),
      }],
      value: input === zeroAddress ? amountIn : 0n,
    });

Trade through the Universal Router

LaunchRouter makes one exact-input swap on one pool. For exact output, or a route through several pools, use the Universal Router the registry names under universalRouter, and the V4Quoter under quoter to quote it. A trade is one execute call: commands has a byte per command and inputs an entry per command. A V4_SWAP command's input is abi.encode(bytes actions, bytes[] params), with a byte per action in actions and an entry per action in params.

Kind

Command
NameV4_SWAP
Byte0x10
InputIts actions and their params.
Command
NameSWEEP
Byte0x04
Input(address token, address recipient, uint256 amountMinimum). Pays out all the router holds of token, the zero address being the native currency, to recipient, address 1 being the caller.
Action
NameSWAP_EXACT_IN_SINGLE
Byte0x06
InputExactInputSingleParams: one pool, exact input.
Action
NameSWAP_EXACT_IN
Byte0x07
InputExactInputParams: a route, exact input.
Action
NameSWAP_EXACT_OUT_SINGLE
Byte0x08
InputExactOutputSingleParams: one pool, exact output.
Action
NameSWAP_EXACT_OUT
Byte0x09
InputExactOutputParams: a route, exact output.
Action
NameSETTLE_ALL
Byte0x0c
Input(address currency, uint256 maxAmount). Pays what the swap owes, from the caller: through Permit2, or out of the call's value.
Action
NameTAKE_ALL
Byte0x0f
Input(address currency, uint256 minAmount). Delivers what the swap made, to the caller.
  • The structs depend on the router's build. The samples below are what the deployed router requires: a uint256 minHopPriceX36 before hookData in a one-pool swap, and a uint256[] minHopPriceX36 after path in a route. The structs in many older samples have neither, and builds of the Universal Router differ, so check the router the registry names before you encode.
  • How to check. execute takes bytes, so no ABI shows these structs, but a build's ABI lists its errors: InvalidHopPriceLength is there on a build that takes the field. Confirm it in the router's source.
  • The wrong shape does not fail cleanly. Sent to the deployed router, the older struct reverts with no data where currency0 is an ERC-20. Where currency0 is native it goes through, so a test on a native-quoted pool proves nothing.
  • What the field does. It is the least a hop may pay, as amountOut × 1e36 / amountIn, and a hop under it reverts V4TooLittleReceivedPerHopSingle or, in a route, V4TooLittleReceivedPerHop; on exact output, V4TooMuchRequestedPerHopSingle or V4TooMuchRequestedPerHop. Zero, or an empty list, turns it off; a list has one entry per pool, or the swap reverts InvalidHopPriceLength. The samples turn it off: amountOutMinimum already holds the whole trade.
  1. In these samples universalRouter, permit2 and quoter are the registry's addresses, read as under Read, quote and trade, and view, zeroForOne and amountIn are as they are there. trade sends one V4_SWAP. With a native input it adds a SWEEP, so nothing the swap did not spend stays behind.
    TypeScript
    // execute(bytes commands, bytes[] inputs, uint256 deadline) payable
    import { encodeAbiParameters, pad, parseAbi, parseAbiParameters, zeroAddress } from "viem";
    
    const poolKey = "(address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks) poolKey";
    const pathKeys = "(address intermediateCurrency, uint24 fee, int24 tickSpacing, address hooks, bytes hookData)[] path";
    
    // the swap actions' params, as the deployed router takes them
    const exactInSingle = parseAbiParameters(`(${poolKey}, bool zeroForOne, uint128 amountIn, uint128 amountOutMinimum, uint256 minHopPriceX36, bytes hookData)`);
    const exactIn = parseAbiParameters(`(address currencyIn, ${pathKeys}, uint256[] minHopPriceX36, uint128 amountIn, uint128 amountOutMinimum)`);
    const exactOutSingle = parseAbiParameters(`(${poolKey}, bool zeroForOne, uint128 amountOut, uint128 amountInMaximum, uint256 minHopPriceX36, bytes hookData)`);
    const exactOut = parseAbiParameters(`(address currencyOut, ${pathKeys}, uint256[] minHopPriceX36, uint128 amountOut, uint128 amountInMaximum)`);
    
    // SETTLE_ALL's and TAKE_ALL's params
    const currencyAmount = parseAbiParameters("address, uint256");
    
    // one V4_SWAP command's input: its actions, a byte each, and their params
    const v4Swap = (actions, params) => encodeAbiParameters(parseAbiParameters("bytes, bytes[]"), [actions, params]);
    
    // a SWEEP of the native currency to the caller, which the router reads from address 1
    const sweep = encodeAbiParameters(parseAbiParameters("address, address, uint256"), [zeroAddress, pad("0x01", { size: 20 }), 0n]);
    
    const universalRouterAbi = parseAbi(["function execute(bytes commands, bytes[] inputs, uint256 deadline) payable"]);
    const deadline = BigInt(Math.floor(Date.now() / 1000) + 300);
    
    const trade = (swap, input, value) => wallet.writeContract({
      address: universalRouter, abi: universalRouterAbi, functionName: "execute",
      args: input === zeroAddress ? ["0x1004", [swap, sweep], deadline] : ["0x10", [swap], deadline],
      value: input === zeroAddress ? value : 0n,
    });
  2. Every ERC-20 the router spends goes through Permit2: the launch token on a sale, and the quote on a buy in an ERC-20. Each token needs two approvals: the token's own approve to Permit2, then Permit2's approve to the router, with an amount and an expiry. Read the second with allowance and renew it in time: an expired one reverts AllowanceExpired, and one too small InsufficientAllowance. An expiry of zero lasts only the block. A native input needs neither.
    TypeScript
    // approve(address token, address spender, uint160 amount, uint48 expiration)
    import { erc20Abi, maxUint256 } from "viem";
    
    const permit2Abi = parseAbi([
      "function approve(address token, address spender, uint160 amount, uint48 expiration)",
      "function allowance(address user, address token, address spender) view returns (uint160 amount, uint48 expiration, uint48 nonce)",
    ]);
    const input = zeroForOne ? view.key.currency0 : view.key.currency1;
    
    // once a token
    await wallet.writeContract({
      address: input, abi: erc20Abi, functionName: "approve", args: [permit2, maxUint256],
    });
    
    // again when it runs out or expires: an hour here
    await wallet.writeContract({
      address: permit2, abi: permit2Abi, functionName: "approve",
      args: [input, universalRouter, amountIn, Math.floor(Date.now() / 1000) + 3600],
    });
  3. The V4Quoter's structs have no minHopPriceX36. It quotes either side and returns the amount and a gas estimate. It is not marked view, so make it an eth_call. What it answers is what the swap then does, and it reverts as under Read a refusal.
    TypeScript
    // quoteExactInputSingle(params) returns (uint256 amountOut, uint256 gasEstimate)
    const quoterAbi = parseAbi([
      `function quoteExactInputSingle((${poolKey}, bool zeroForOne, uint128 exactAmount, bytes hookData) params) returns (uint256 amountOut, uint256 gasEstimate)`,
      `function quoteExactOutputSingle((${poolKey}, bool zeroForOne, uint128 exactAmount, bytes hookData) params) returns (uint256 amountIn, uint256 gasEstimate)`,
      `function quoteExactInput((address exactCurrency, ${pathKeys}, uint128 exactAmount) params) returns (uint256 amountOut, uint256 gasEstimate)`,
      `function quoteExactOutput((address exactCurrency, ${pathKeys}, uint128 exactAmount) params) returns (uint256 amountIn, uint256 gasEstimate)`,
    ]);
    const { result: [quoted] } = await client.simulateContract({
      address: quoter, abi: quoterAbi, functionName: "quoteExactInputSingle",
      args: [{ poolKey: view.key, zeroForOne, exactAmount: amountIn, hookData: "0x" }],
    });
  4. A buy or a sale, as zeroForOne says. SETTLE_ALL pays the input and TAKE_ALL delivers the output, each with its limit. The caller is both the payer and who receives.
    TypeScript
    const input = zeroForOne ? view.key.currency0 : view.key.currency1;
    const output = zeroForOne ? view.key.currency1 : view.key.currency0;
    const minimum = quoted * 99n / 100n; // 1% slippage
    
    const swap = v4Swap("0x060c0f", [ // SWAP_EXACT_IN_SINGLE, SETTLE_ALL, TAKE_ALL
      encodeAbiParameters(exactInSingle, [{
        poolKey: view.key, zeroForOne, amountIn, amountOutMinimum: minimum, minHopPriceX36: 0n, hookData: "0x",
      }]),
      encodeAbiParameters(currencyAmount, [input, amountIn]),
      encodeAbiParameters(currencyAmount, [output, minimum]),
    ]);
    await trade(swap, input, amountIn);
  5. path lists the pools in order, each entry naming the currency its hop ends in. This one buys a token quoted in an ERC-20 with native HYPE: through a plain pool into quote, then through the launch pool into token. A sale runs the same pools the other way: the launch pool's entry first, ending in quote, then the plain pool's, ending in the zero address. The hook's fee is charged once, on the launch pool's hop.
    TypeScript
    const path = [
      { intermediateCurrency: quote, fee: plain.fee, tickSpacing: plain.tickSpacing, hooks: plain.hooks, hookData: "0x" },
      { intermediateCurrency: token, fee: view.key.fee, tickSpacing: view.key.tickSpacing, hooks: view.key.hooks, hookData: "0x" },
    ];
    const { result: [routeQuote] } = await client.simulateContract({
      address: quoter, abi: quoterAbi, functionName: "quoteExactInput",
      args: [{ exactCurrency: zeroAddress, path, exactAmount: amountIn }],
    });
    const minimum = routeQuote * 99n / 100n; // 1% slippage
    
    const swap = v4Swap("0x070c0f", [ // SWAP_EXACT_IN, SETTLE_ALL, TAKE_ALL
      encodeAbiParameters(exactIn, [{ currencyIn: zeroAddress, path, minHopPriceX36: [], amountIn, amountOutMinimum: minimum }]),
      encodeAbiParameters(currencyAmount, [zeroAddress, amountIn]),
      encodeAbiParameters(currencyAmount, [token, minimum]),
    ]);
    await trade(swap, zeroAddress, amountIn);
  6. You name what you receive and cap what you pay. A native input sends the whole cap as the call's value and pays only what the swap costs. Without a SWEEP, the surplus stays in the router, and any caller's SWEEP takes it. An ERC-20 input is pulled for exactly what the swap costs, so it leaves nothing. A route uses SWAP_EXACT_OUT (0x09) and ExactOutputParams, whose path names each hop by the currency it starts from.
    TypeScript
    const input = zeroForOne ? view.key.currency0 : view.key.currency1;
    const output = zeroForOne ? view.key.currency1 : view.key.currency0;
    const { result: [cost] } = await client.simulateContract({
      address: quoter, abi: quoterAbi, functionName: "quoteExactOutputSingle",
      args: [{ poolKey: view.key, zeroForOne, exactAmount: wanted, hookData: "0x" }],
    });
    const maximum = cost * 102n / 100n; // 2% slippage
    
    const swap = v4Swap("0x080c0f", [ // SWAP_EXACT_OUT_SINGLE, SETTLE_ALL, TAKE_ALL
      encodeAbiParameters(exactOutSingle, [{
        poolKey: view.key, zeroForOne, amountOut: wanted, amountInMaximum: maximum, minHopPriceX36: 0n, hookData: "0x",
      }]),
      encodeAbiParameters(currencyAmount, [input, maximum]),
      encodeAbiParameters(currencyAmount, [output, wanted]),
    ]);
    await trade(swap, input, maximum); // a native input sends the cap, and the SWEEP returns the rest

A trade through this router can also revert V4TooLittleReceived, under its minimum; V4TooMuchRequested, over its cap; or TransactionDeadlinePassed. A native input whose value does not cover the swap reverts with no data.

Launch a token

One call to the factory makes the token, its pool, its locked liquidity and the first buy. Whoever calls launch is the creator. A product that sends launches can earn a share of the fees they go on to pay: Builder programme. In these samples factory is the registry's factory, read as above, factoryAbi is LaunchFactory.json, and zeroAddress, zeroHash and parseEventLogs are viem's.

  1. A token carries its image in two places, and both are fixed at launch, so set both. metadataURI names a document, the usual place wallets and explorers look; profile.logo keeps the image on the token itself, in the form terminals read through getTokenInfo(). The site shows the document's image, and the token's logo where there is no document or it names none. These two routes are what the site itself uses, and a script calls them the same way, with no key: the image first, as its raw bytes (PNG, JPEG, WebP or GIF, 2 MB at most, 20 an hour per connection), then a document naming it (name, symbol, description, image, and any of website, twitter, telegram, discord; 30 an hour). Upload shortly before you launch: anything no launch has named after about an hour may be cleared, and uploading the same file again gives the same URI, so it is safe to repeat. Once the launch is seen, both are pinned to IPFS. Or host your own: any ipfs:// or https:// URI to a JSON document with those fields works, naming an ipfs:// or https:// image.
    TypeScript
    import { readFileSync } from "node:fs";
    
    const api = "https://api.montramarkets.xyz";
    const posted = async (r) => {
      const body = await r.json();
      if (!r.ok) throw new Error(body.error ?? r.statusText);
      return body;
    };
    
    const image = await fetch(`${api}/api/metadata/image`, {
      method: "POST",
      headers: { "content-type": "image/png" },
      body: readFileSync("logo.png"),
    }).then(posted);
    
    const metadata = await fetch(`${api}/api/metadata`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        name: "Example",
        symbol: "EXMPL",
        description: "What it is.",
        image: image.uri,
        website: "https://example.com",
      }),
    }).then(posted);
  2. A launch names a row of the factory's menu by configId. Ids count from 0, configCount() says how many there are and getConfig(id) returns a row: one tier for one quote asset, launchable while enabled. Rows are added and switched off, so read them rather than hard-coding an id. The row picks the hook: getModuleSet(row.moduleSetId).hook, not the newest of the registry's hooks(). That list holds every hook ever published and never loses one, so a pool's hook is checked against it, and a launch's is chosen by its row.
    TypeScript
    // ids run from 0 to count - 1: read each, and offer those with row.enabled
    const count = await client.readContract({ address: factory, abi: factoryAbi, functionName: "configCount" });
    const row = await client.readContract({ address: factory, abi: factoryAbi, functionName: "getConfig", args: [configId] });
    
    const { hook } = await client.readContract({
      address: factory, abi: factoryAbi, functionName: "getModuleSet", args: [row.moduleSetId],
    });
  3. LaunchParams has twelve fields, in the order below; each is in the structs. The launch fee is native and separate: on a native-quoted row value is the fee plus the first buy, and on an ERC-20 row it is the fee alone, with the first buy pulled from the sender, who approves the factory first or passes a permit. firstBuyAmount is the whole first buy, its fee included, and firstBuyMinOut the fewest tokens you accept for it, 0 for any: it lands in the launch's own transaction, so nothing can trade ahead of it. There is no pool to quote yet: to show what it will buy, LaunchLens.seedPreview(factory, configId, supply) gives the pool it opens on.
    TypeScript
    const params = {
      name: "Example",
      symbol: "EXMPL",
      metadataURI: metadata.uri,                // the document, with the image in it
      profile: {
        logo: image.uri,                        // the same image, kept on the token
        description: "What it is.",
        socials: { telegram: "", twitter: "", discord: "", website: "", extra: "" },
      },
      configId,
      supply: 0n,                               // the row's own
      salt: "0x…",                              // any 32 bytes of your own
      feeRecipients: [],                        // the creator's own wallet
      feeShares: [],
      firstBuyAmount,
      firstBuyMinOut,
      permit: { deadline: 0n, v: 0, r: zeroHash, s: zeroHash }, // none
    };
    
    const launchFee = await client.readContract({ address: factory, abi: factoryAbi, functionName: "launchFee" });
    const value = row.quote === zeroAddress ? launchFee + firstBuyAmount : launchFee;
  4. TokenLaunched describes the launch in full. Its firstBuyIn is firstBuyAmount, the swap's fee included, its firstBuyOut the tokens the creator received after that fee, and the launch fee is in neither. A product's builder code goes on as dataSuffix: Attributing a launch.
    TypeScript
    await client.simulateContract({ address: factory, abi: factoryAbi, functionName: "launch", args: [params], account, value });
    const hash = await wallet.writeContract({ address: factory, abi: factoryAbi, functionName: "launch", args: [params], value });
    const receipt = await client.waitForTransactionReceipt({ hash });
    
    const [launched] = parseEventLogs({ abi: factoryAbi, eventName: "TokenLaunched", logs: receipt.logs });
    const { token, poolId, firstBuyIn, firstBuyOut } = launched.args;

A token's page is https://montramarkets.xyz/token/<address>, the address in lowercase; any other spelling redirects there. A wallet's is /wallet/<address>, and an approved builder's /builders/<code>.

Which fee to show

LaunchLens.feeBreakdown(hook, poolId).total
Show this as the pool's fee. In pips (1,000,000 is 100%), the whole fee a sale pays, and a buy outside an anti-snipe window, the LP fee included once it is on; the same read gives its split.
LaunchLens.poolView(hook, poolId).buyFee
What a buy pays now, the hook's own currentBuyFeeRate(poolId): the total, or inside the pool's anti-snipe window the higher rate falling to it. Show a buyer its price: a buy pays 1 / (1 − buyEffectiveTotalFee / 1e6) times the pool's price, before its move. Montra's site asks before a buy above 10%, which only a window can charge. Showing a window: The anti-snipe fee.
LaunchToken.taxBps()
The total in basis points, rounded up, from the token alone, for a scanner that reads only tokens (sellTaxRate() says the same; buyTaxRate() says what a buy pays now, more than the total inside an anti-snipe window; taxRatePips() gives the total in exact pips).
lp, protocol, creator
Where the total goes: the pool's liquidity providers, Montra, and the creator side.
effectiveTotal
The hook's fee and the LP fee compounded, a hair under total once the LP fee is on: what a trade's amounts work out to, for exact arithmetic.
LaunchRouter.quoteExactIn
What one trade of a given size delivers after every fee and the price's move: the trade's own figure.
coreProtocolFeeZeroForOne, coreProtocolFeeOneForZero (poolView)
Uniswap's own fee, on top and zero unless Uniswap switches it on: show it beside the total when it is not zero.

Never show Swap.fee or slot0's LP fee, which read zero until the LP line while every trade pays the total, nor the key's 0x800000, which is only the dynamic-fee flag. There is no transfer tax.

The rules a pool keeps

No tier of the menu has a whitelist, per-wallet logic or a trading window: a pool trades from its first block, and the hook sees the router as the sender, never the trader. The fee is netted into the swap's deltas, so standard routers and quoters handle it, and quote exactly what the trade pays.

Partial fills
Refused: a swap must fill the whole amount it names, whichever currency it names. One stopped part-way by its price limit, or run out of liquidity, reverts PartialFillRejected, so no swap can set a price without trading. Set no price limit (the extremes, as V4Router does) and express slippage as a minimum output; a price limit is fill-or-revert, never fill-up-to.
The opening
A sale may take the price back to the pool's opening and no further: one that would leave it past the opening reverts PastOpening. No ordinary sale meets it: every swap rounds in the pool's favour, so the tokens outstanding never carry the price back past the opening.
Donations
Refused (DonationsLocked), so nothing pays a pool's liquidity outside a trade. A position that others' trades fill pays no hook fee on what it sells or buys that way; the trades that fill it pay theirs.
Liquidity
Anyone may add; removals have no hook callback and always work. Positions earn the LP fee (Experimental) from the LP line on.
The fee now
poolConfig(poolId).totalFeePips is the advertised total, which only ever falls; the hook takes it less the LP fee in force, and the pool charges that LP fee from slot0. Model the next trade with LaunchLens.feeBreakdown(hook, poolId), not slot0 alone: the LP line switches on inside the beforeSwap of the first trade after the hold, so that trade already pays the LP fee while slot0 still reads zero.
Terms per pool
Read every pool's terms from the chain (poolConfig(poolId), feeBreakdown(hook, poolId)) and never hard-code the tiers: a pool keeps the row it launched with, a total its recipient lowered and a protocol share the owner lowered, and a row off the menu is the governor's to publish. The menu is what launches on mainnet are offered.
hookData
No tier of the menu reads it. Send empty bytes.
Anti-snipe fee
A pool's terms may carry one (poolConfig(poolId).earlyBuyFeeUntil not zero). From the launch second a buy pays earlyBuyFeeStartPips (at most 99%), falling in a straight line to the total at earlyBuyFeeUntil (at most 30 minutes after launch), the drop rounded down; a sale never pays it, and neither does the factory's own first buy in the launch transaction. Read it as currentBuyFeeRate(poolId) at the latest block's time, never the wall clock's: it only falls, so a figure read earlier errs high. The hook takes it in the swap's deltas like the rest of its fee, so v4's Swap never shows it: what a buyer paid is the Swap's quote amount plus FeeAccrued's fee. The formula is under Contract reference; how to read, track and show it, under The anti-snipe fee.
Other routers
Uniswap's Universal Router (V4_SWAP), V4Router and V4Quoter trade and quote a Montra pool as any v4 pool, multi-hop included, the hook's fee charged once where the route crosses its pool. The quoter's answer is exactly what the router then trades (the contracts' parity suite holds the two together), as is LaunchRouter.quoteExactIn's on a Montra pool. The registry names their addresses under universalRouter, permit2 and quoter, and contracts in /api/config repeats them, each null until it is set. An exact-output quote needs the V4Quoter: quoteExactIn quotes exact input alone. How to encode a trade is under Trade through the Universal Router.
Gas
Through LaunchRouter about 140-250k for a buy and 140-210k for a sell, by what the trade does: crossing or confirming a line, settling holder rewards mid-stream, moving an ERC-20 quote (measured over 60 trades); a buy inside an anti-snipe window about 3k more. A plain test router on a local chain takes 164k and 173k (the contracts' gas snapshot). Estimate each trade and add a margin.
Exact-input buybeforeSwap

The quote is the amount you name. The hook takes its fee off it first; the pool swaps the rest and charges its LP fee on that.

fee = amountIn × h / 1e6
Exact-output buyafterSwap

You name the tokens. The quote is worked out by the swap; the hook adds its fee to what you pay.

fee = quoteIn × h / (1e6 − h)
Exact-input sellafterSwap

You name the tokens. The quote is what the pool pays out; the hook's fee comes off it.

fee = quoteOut × h / 1e6
Exact-output sellbeforeSwap

You name the quote you want. The pool pays out that plus the hook's fee, which the hook keeps.

fee = amountOut × h / (1e6 − h)
Figure 1. Where the hook takes its fee in each of the four shapes a swap can take; h is the hook fee in pips (the swap's rate less the LP fee in force: the total, or a buy's anti-snipe rate inside its window). Floors in every case.

The fee is always in the quote asset and returned as a hook delta (beforeSwapReturnDelta when the quote is the specified currency, afterSwapReturnDelta when it is not), so the caller's delta already includes it. Exact-output swaps gross the fee up, so the rate is the same in both directions. Every shape fills the whole amount it names or reverts (PartialFillRejected).

Hook callbackBit
beforeInitialize1<<13
afterInitialize1<<12
beforeAddLiquidity1<<11
beforeSwap1<<7
afterSwap1<<6
beforeDonate1<<5
beforeSwapReturnDelta1<<3
afterSwapReturnDelta1<<2

v4 reads a hook's permissions from the low 14 bits of its address; this hook's are 0x38EC: nothing on removing liquidity and nothing after a donation. A later hook may carry more, so trust a hook because the factory announced it, not by its bits.

For integrators

Terminals, wallets and apps can build on the pools without asking anyone: start with Build on Montra, Contract reference and Public API.

A product that brings launches earns a share of their fees: the Builder programme.

To talk to the team: @MontraXYZ on X.

For AI tools

Every page as Markdown in llms.txt, and all of it in one file at llms-full.txt.