OtomateDOCS
Open app

DEVELOPERS

Build on Otomate

Every Otomate launch is a plain ERC-20 token and its own Uniswap v4 pool. The pool's hook-owned liquidity is the launch curve, so the token trades from its first block through standard routers, on Ink (chain ID 57073) and Robinhood Chain (chain ID 4663).

This guide is for bots, trading terminals, wallets, data providers, AI agents and anyone who wants to run their own launchpad on Otomate.

You want to…UseStart with
Follow launches and trades as they happenThe real-time feed (SSE or WebSocket)Real-time feed
Read a token, its market, trades, candles, holdersThe public API v1API v1
Quote and trade from your walletThe SDK, or the API's swap transactionTrade launch pools
Create tokens from codeThe SDK's createToken, or the factory directlyCreate tokens
Earn a fee on the trades you sendBuilder codesBuilder codes
Index every launch yourselfContract events and the indexing checklistData providers
Let an AI agent do itThe MCP server and llms.txtAI agents
Run your own launchpadThe starter launchpadYour own launchpad

The fastest way in: follow launches and trade in 30 lines.

Two ways to build

Level 1, open to everyone. Use the SDK, the API and the feed, and add your builder code to the trades you send. You need no account and no approval.

Level 2, partners. Partners get higher limits and closer support. Otomate admits partners on an allow-list at first. A bond in OTO may be used later. Otomate decides the terms.

Free to start

Every read, the feed and the API work without a key. Limits apply per client.

AccessHowReads per minuteFeed connections
No keyNothing to do3005
Free keyA wallet signs a message (API keys); counted per client address60010
PartnerOtomate's list: terminals, aggregators, data providers6,000100
OTO lock (later)OTO the wallet locks: 100,000 / 1,000,000 OTO3,000 / 15,00050 / 200
Pay per requestx402: a premium request answers 402 with its price; pay it in USDC on Base and send it againOne request per payment—

Otomate sets each tier's numbers. Locking OTO only raises your limits; it shares no revenue. The RateLimit-Policy header and GET /api/v1/{chain}/keys/me show yours.

Chains and contracts

One API host serves both chains: https://api.otomate.fun, with the chain in the path. Every answer names its chain.

InkRobinhood Chain
Chain ID570734663
APIhttps://api.otomate.fun/api/v1/ink/…https://api.otomate.fun/api/v1/robinhood/…
Fallback origin (its chain only)https://otomate-launcher-api-production.up.railway.apphttps://otomate-launcher-api-rh4663-production.up.railway.app
Uniswap v4 PoolManager0x360E68faCcca8cA495c1B759Fd9EEe466db9FB320x8366a39CC670B4001A1121B8F6A443A643e40951
Universal Router0x112908daC86e20e7241B0927479Ea3Bf935d1fa00x8876789976dEcBfCbBbe364623C63652db8C0904
V4 Quoter0x3972C00f7ed4885e145823eb7C655375d275A1C50x8Dc178eFB8111BB0973Dd9d722ebeFF267c98F94
StateView0x76Fd297e2D437cd7f76d50F01AfE6160f86e99900xF3334192D15450CdD385c8B70e03f9A6bD9E673b
Permit20x000000000022D473030F116dDEE9F6B43aC78BA30x000000000022D473030F116dDEE9F6B43aC78BA3
WETH0x42000000000000000000000000000000000000060x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73

Pairs

Each launch pairs its token with one ERC-20, its pair. A pair is open once the chain's launch config enables it: the config's quotes() lists its pairs, quoteConfig(pair) answers whether one is enabled and its presets, and QuoteConfigured and QuoteDisabled log each change. Otomate's site, SDK (otomatePairs(chain)) and MCP server name the pairs below; any other ERC-20 the config enables works the same way. All of these have 18 decimals.

ChainPairWhat it isAddress
InkWETH, the defaultWrapped Ether; pay and receive ETH through the router0x4200000000000000000000000000000000000006
InkOTOOtomate's token0x0e04Ca25B5cd15047946d89E21cBcDBaE43A323B
InkwAAPLxApple xStock, in an ERC-4626 wrapper0x943BF64D566c32A2Bcd41AC92FB63C111cC9De8f
InkwNVDAxNVIDIA xStock, in an ERC-4626 wrapper0xa8ddb5Cd96b5222AFe198316E9A57CAA642850D5
InkwSPYxS&P 500 ETF xStock, in an ERC-4626 wrapper0xE7E553Cd128F0011777323A0b44a7b96EA1CB540
InkwTSLAxTesla xStock, in an ERC-4626 wrapper0xc3FdBe3A68EE5dE461D30415a8165cf9Aefe1171
InkWIREDA 404 token (ERC-20 and ERC-721), not a stock0xC7d264d906B488ef6660005af5450c979F58006c
Robinhood ChainWETH, the defaultWrapped Ether; pay and receive ETH through the router0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73
Robinhood ChainOTOOtomate's token0xAAed1a6A716Bb12c6037e8B170aB6dA29f28fD04
Robinhood ChainNVDANVIDIA Stock Token (ERC-8056)0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC
Robinhood ChainAAPLApple Stock Token (ERC-8056)0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9
Robinhood ChainSPCXSpaceX Stock Token (ERC-8056)0x4a0E65A3EcceC6dBe60AE065F2e7bb85Fae35eEa
Robinhood ChainSPYS&P 500 ETF Stock Token (ERC-8056)0x117cc2133c37B721F49dE2A7a74833232B3B4C0C
Robinhood ChainQQQNasdaq-100 ETF Stock Token (ERC-8056)0xD5f3879160bc7c32ebb4dC785F8a4F505888de68
Robinhood ChainTSLATesla Stock Token (ERC-8056)0x322F0929c4625eD5bAd873c95208D54E1c003b2d

The issuers of the stock tokens (the xStock wrappers and the Stock Tokens) can pause them or blocklist addresses, which would stop trading on the pools of that pair.

WIRED's issuer can upgrade its contract at any time. Each whole WIRED a wallet receives mints an NFT of its collection and each whole WIRED it gives up burns one (a wallet that goes under a whole WIRED, a sell or a payment included, loses one of its WIRED NFTs), with the gas paid by the transaction's sender, and a transfer of more than 64 whole WIRED at once to or from a wallet that has not called setSkipNFT(true) reverts: split a larger sell, or opt the wallet out first.

Launch contracts

Deployed on Ink, Robinhood Chain, Base and HyperEVM (read on each chain on 2026-10-11).

Each contract has the same address on every one of these chains:

ContractInk, Robinhood Chain, Base and HyperEVM
Router (trades)0x173b1e9eCC06d53b239754602177F1707BFE76A1
Factory0xd8661806D749a6360348a0D4f94f8db36B43420e
Config0x6Ab344Dcf639E99DB656cC8DeA76A9b5262ee545
Hook0xF8b5e7A4F3cEb6ba9F9a619611360750A7D728EC
Distributor0x021851048acE99Bc0fc3F064Ad699a335de9d35c
Lens0x9f9Ef622f5dafDFd69C54CAb1bcCe4E8E71B3f2b
NFT vault0x5Ad95E0CEA3210f1F1E391f3A77405cBed128C9E
NFT gate0x5CD814311dD91B5fF7f4712Ae6c8A1BFb8319D70
Forge0xdb82153b0A0C9D31Bc4D0F85fc0F40E6753b0D5e
Collection beacon0x8A6a2BCbA7Ea5371d2eba795b67f207D0fd2f49a
Royalty list (OpenSea)0xE83248a0Af0fb083E0091c1E867b5b81accF45f9
Token deployer0x208518704cC6b27c34A3ECd78A9ecf20c4959aE6
Upgrade timelock0xd14837BF79D73A4C73270889f8D822Bf7Fc77A5a
Otomate's Safe0xfA0ffF20DB7A2939cc402e395c1916Ba42f5E850
Global NFT sinkInk 0x31E1c790C933acb9F8F2f9C1dE2FFd1F2834aB60, Robinhood Chain 0xe4973A293570C054AdCbb6769501D249Af67Aed5 (none on Base and HyperEVM)

Each chain's runtime code hashes are in the SDK (LAUNCH_POOL_DEPLOYMENT.live) and in the OpenAPI document (info.x-otomate-contracts). Each chain opens to new launches on its own date: the config's creationEnabled() tells.

The other chains of the hook's table are not deployed yet: BNB Smart Chain (56), Arbitrum One (42161), OP Mainnet (10), Unichain (130), Ethereum (1).

New launches use the config's current hook; earlier pools keep their hook. Follow the config's LaunchHookSet event to learn a new hook version.

Versioning and stability

  • The API, the feed schema (otomate.launch-pool-feed.v1) and the SDK follow one rule: within a version, fields may be added; none is removed, renamed or changes type. A breaking change ships as a new version (/api/v2, …feed.v2) next to the old one.
  • Amounts are integers as decimal strings (raw), often with their exact decimal value (decimal). Never floats.
  • Every record says whether it is mined (in the latest block) or final (its block is finalized).
  • The machine-readable reference is the OpenAPI document: openapi.v1.json, also served at /api/v1/openapi.json.

Your own launchpad

The starter launchpad is a Next.js site built on the SDK: Explore, token pages with a live chart and trades, Create, a buy and sell ticket and creator profiles. Brand, colors and your builder code live in one configuration file. Copy it, set your name and builder address, deploy it anywhere, and earn your builder fee from the first trade. It is a template: Otomate hosts nothing for you.

Quickstart

Two complete programs that follow new launches in real time and buy each new launch paired with WETH, paying in ETH. Both run as they are; read Trade launch pools before risking funds.

Follow launches and trade in 30 lines

TypeScript (quickstart.ts), with the SDK and viem. Node 18 or later.

npm install @otomate/sdk viem
PRIVATE_KEY=0x… npx tsx quickstart.ts
import { createOtomate, otomateViemChain } from '@otomate/sdk';
import { createWalletClient, http, type Address } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const walletClient = createWalletClient({ account, chain: otomateViemChain('ink'), transport: http() });
// Your builder code earns you a fee on top of each trade you send (0 here; at most 200 bps).
const otomate = createOtomate({ chain: 'ink', walletClient, builder: null, apiKey: process.env.OTOMATE_API_KEY });

otomate.feed({ topics: ['launch', 'trade'], cursor: process.env.CURSOR })
  .on('launch', async e => {
    console.log(`new launch ${e.data.symbol} ${e.token} by ${e.data.creator}`);
    if (e.data.quote !== otomate.chain.weth) return;                       // WETH pairs: we pay in ETH
    // The launch protection charges buys in the first seconds: wait it out.
    const until = (e.data.launchedAt + e.data.surcharge.duration + 1) * 1000;
    await new Promise(r => setTimeout(r, Math.max(0, until - Date.now())));
    const r = await otomate.buy(e.token as Address, 10n ** 15n);            // 0.001 ETH, 1 % slippage
    console.log(`bought ${r.tokenAmount} for ${r.quoteAmount} wei (fee ${r.quoteFee}, burned ${r.burn}) ${r.hash}`);
  })
  .on('trade', e => console.log(`${e.data.side} ${e.token} ${e.data.quoteRaw} wei, progress ${e.data.progressBps / 100} %`))
  .on('reorg', e => console.log(`reorg: drop what you stored above block ${e.afterBlock}`))
  .on('final', e => console.log(`final up to block ${e.block}`))
  .on('error', e => console.error(e.message))
  .start();

What it does:

  • feed() opens the real-time feed. If the connection drops, the SDK reconnects from the last event it received: you never miss or repeat an event. Store feed.cursor to resume after a restart (CURSOR).
  • buy() quotes on the chain's V4 Quoter, checks your allowances (an approval for exactly the amount when an ERC-20 is short), sends the swap through Otomate's router with a 1 % minimum (the Universal Router with Permit2 where the router is not deployed), and returns the hook's record of the trade: what you paid, received, the fee and the tokens burned.

Python (quickstart.py), with web3.py only. The API quotes on chain and builds the router call; your script signs it.

pip install web3
PRIVATE_KEY=0x… python quickstart.py
import json, os, time, urllib.request
from web3 import Web3

API = os.environ.get("OTOMATE_API", "https://api.otomate.fun") + "/api/v1/ink"
w3 = Web3(Web3.HTTPProvider(os.environ.get("RPC_URL", "https://rpc-qnd.inkonchain.com")))
me = w3.eth.account.from_key(os.environ["PRIVATE_KEY"])

def send(built):  # sign and send one transaction the API built, wait for its receipt
    tx = {"to": Web3.to_checksum_address(built["to"]), "data": built["data"], "value": int(built["value"]), "from": me.address, "chainId": 57073,
          "nonce": w3.eth.get_transaction_count(me.address), "maxFeePerGas": w3.eth.gas_price * 2, "maxPriorityFeePerGas": w3.eth.max_priority_fee}
    tx["gas"] = int(built["gas"]) if built.get("gas") else w3.eth.estimate_gas(tx) * 13 // 10  # the API's limit, else the estimate + 30 %
    return w3.eth.wait_for_transaction_receipt(w3.eth.send_raw_transaction(me.sign_transaction(tx).raw_transaction))

def buy(token, wei):  # the API quotes on chain and builds the router call (ETH in on WETH pairs)
    body = json.dumps({"token": token, "side": "buy", "amountIn": str(wei), "account": me.address, "nativeEth": True}).encode()
    built = json.load(urllib.request.urlopen(urllib.request.Request(API + "/swap-transaction", body, {"content-type": "application/json"})))
    for approval in built["approvals"]: send(approval)
    return built["quote"]["amountOut"]["decimal"], send(built["transaction"])

with urllib.request.urlopen(API + "/feed?topics=launch") as stream:  # server-sent events, one JSON per data line
    for line in stream:
        if not line.startswith(b"data: "): continue
        e = json.loads(line[6:])
        if e["type"] == "reorg": print("reorg: drop what you stored above block", e["afterBlock"])
        if e["type"] != "launch" or e["data"]["quote"] != "0x4200000000000000000000000000000000000006": continue
        print("new launch", e["data"]["symbol"], e["token"])
        time.sleep(max(0, e["data"]["launchedAt"] + e["data"]["surcharge"]["duration"] + 1 - time.time()))  # launch protection
        out, receipt = buy(e["token"], 10**15)
        print("bought", out, "tokens, tx", receipt["transactionHash"].hex())

On Robinhood Chain, use chain: 'robinhood' (TypeScript), or the Robinhood API origin with /api/v1/robinhood, chain ID 4663 and the chain's WETH (Python). The routers differ between the chains; the SDK and the API handle it.

Before you trade

  • Launch protection. Buys in the first seconds after a launch pay an extra quote fee that decreases over time (surcharge in the launch event). Both programs wait it out.
  • Fees. Each pool's rates are in its launch event and token sheet (fees.buyBps, fees.sellBps). The quote you get already includes them.
  • Slippage. The SDK sends a 1 % minimum by default (slippageBps). A swap that would end below the minimum reverts.
  • Reorgs. Events arrive at the mined block. If the chain reorganizes, a reorg marker tells you which events to drop; final markers tell you what can no longer change.

Real-time feed

The feed pushes every new launch, trade, 50 % mint gate and graduation of one chain as soon as its block is mined. It is public, free to start, and resumable: a client that reconnects with its last cursor gets every event it missed, once.

TransportURLUse it from
Server-sent eventsGET /api/v1/{chain}/feedAny HTTP client, browsers (EventSource), the SDK
WebSocketGET /api/v1/{chain}/feed/wsWebSocket clients; JSON text frames
PagesGET /api/v1/{chain}/feed/eventsPolling, or catching up before you connect

{chain} is ink or robinhood, on that chain's API origin (Chains and contracts). Schema: otomate.launch-pool-feed.v1.

Choose what you receive

QueryValuesDefault
topicsComma-separated: launch, trade, graduation, mint_gateAll
tokensComma-separated token addresses, at most 100All tokens
cursorAn event's id: resume after it. A browser's EventSource may send Last-Event-ID instead.—
fromBlockStart at this blockThe newest event

Without cursor or fromBlock, the feed starts with what happens next. Markers (ready, final, reorg) always come, whatever the filter.

curl -N "https://api.otomate.fun/api/v1/ink/feed?topics=launch,graduation"

Events

Every event has the same envelope; data depends on its type.

FieldMeaning
v1
typelaunch, trade, graduation or mint_gate
idThe cursor. Store the last one you processed; send it back to resume. Treat it as an opaque string.
chainId57073 or 4663
blocknumber (decimal string), hash, timestamp (Unix seconds)
transactionHash, logIndexWhere the event happened
statusmined (the latest block) or final (its block is finalized)
confirmedfalse only in the first seconds, while a trader's smart-account wallet may still be corrected
token, poolIdThe launch
typedata
launchcreator, quote (the pair), hook, collection (or null), name, symbol, metadataURI, launchedAt, supplyRaw, thresholdRaw (quote needed to graduate), minimumTradeRaw, mintGateBps, poolKey, quoteIsCurrency0, launchTick, mintGateTick, graduationTick, initialSqrtPriceX96, fees (quoteFeeBps, shareBps, burnBps, creatorBurnBps, and the rates after graduation marketQuoteFeeBps, marketBurnBps), feeRate (buyBps, sellBps, creatorBps, nftBps, protocolBps, burnBps), surcharge (startBps, duration, halvings), initialBuy (or null)
tradeside (buy or sell), trader (the attributed wallet), traderBasis, router, tokenRaw, quoteRaw (the trader's gross amounts), quoteFeeRaw, surchargeRaw, burnRaw, sqrtPriceX96, tick and progressBps after the trade, sequence (per pool, +1 each trade), builder (the trade's builder code: address, null for a tag alone, feeRaw in the pair on top of quoteFeeRaw, feeBps applied, tag; null without one)
graduation, mint_gateat (Unix seconds), l2Block, sqrtPriceX96

Fields may be added within v1; none will be removed or renamed.

Markers

MarkerMeaningWhat to do
readySent first: resumeFrom, confirmedBlock, finalizedBlock, your filterNothing
reorg{afterBlock}: the chain reorganizedDrop every event you stored with a block above afterBlock. The replacements follow.
final{block}: every event at or below it is finalSettle anything you kept provisional
error{code} just before the server closes the streamReconnect with your cursor (the SDK does it)

Markers carry an id too, so Last-Event-ID always points to where you are.

A mirror that stays exact:

  1. On each event, store it with its block number, and store its id as your cursor.
  2. On reorg, delete what you stored above afterBlock.
  3. On final, mark what is at or below block as final.
  4. After any disconnection, reconnect with your cursor.

If your cursor names a block that changed while you were away, the feed first sends a reorg marker down to the last block it had told you was final, then replays from there.

WebSocket

Same query, same JSON (each frame is one event or marker, with its type). The server pings every 15 seconds; a client that stops answering is disconnected. To send an API key from Node, use a client that sets headers (the ws package); browsers send none.

const ws = new WebSocket('wss://api.otomate.fun/api/v1/ink/feed/ws?topics=trade&tokens=0x…');
ws.onmessage = m => console.log(JSON.parse(m.data));

Pages

GET /api/v1/{chain}/feed/events?cursor=…&limit=500 answers up to 500 events after the cursor, and next, the cursor to continue from. complete: true means you reached the newest event. A page can start with reorg ({afterBlock}) when your cursor's block changed.

Limits and slow clients

  • Connections and connection attempts are limited per client and tier (Free to start); a refused connection answers 429 or 503 with Retry-After.
  • A client that reads slower than events arrive is disconnected once its buffer fills; it reconnects with its cursor and loses nothing.
  • A far cursor replays at a steady pace, then switches to live events.

API v1

Everything a front end, a bot or a data pipeline needs about Otomate's launches, in plain JSON. https://api.otomate.fun/api/v1/{chain} serves both chains (Chains and contracts). Each chain's fallback origin serves only its own chain: a request there for the other chain answers 421 with the right origin in origin. The full reference is the OpenAPI document: openapi.v1.json.

Endpoints

Method and pathAnswers
GET /tokens?sort=&quote=&collection=&q=&limit=&cursor=Launches. sort: new, graduating (closest to graduation), graduated, trending (24 h volume), gainers (24 h change). q: name, symbol or the start of an address.
GET /tokens/{token}One launch in one read: identity (name, symbol, metadata and profile, logo, banner, creator, moderation), market (price in the pair and in USD, market cap, FDV, liquidity, holders, 5 min / 1 h / 24 h volume, buys, sells and change), curve (progress, graduated, mint gate), fees (the rates that apply now, and both the curve's and the after-graduation pair), collection
GET /tokens/{token}/trades?limit=&cursor=Trades, newest first, with the attributed wallet and the trade's builder code (builder, or null)
GET /tokens/{token}/candles?interval=&currency=&limit=&to=OHLCV. interval: 1s, 1m, 5m, 15m, 1h, 4h, 1d. currency: quote or usd
GET /tokens/{token}/holdersThe 50 largest holders and the holder count
GET /creators/{address}A creator's launches, how many graduated, and their creator earnings
GET /wallets/{address}A wallet's positions (balance, value, cost basis, profit and loss) and what it can expect: creator earnings, builder fees (claimable.builder, per currency and hook)
GET /quote?token=&side=&amountIn=&builder=&builderFeeBps=&builderTag=A quote from the chain's V4 Quoter, with every fee: quote fee, launch surcharge, burn, builder fee (builder.feeBps is the rate applied), price impact
POST /swap-transactionA swap ready to sign through Otomate's router (or the chain's Universal Router with Permit2), and the approvals to send first
GET /feed, /feed/ws, /feed/eventsThe real-time feed
GET /tokenlist.jsonThe chain's launches as a token list (tokenlists.org)
GET /statusThe indexer's latest and finalized blocks and the feed's health

Creating tokens uses POST /metadata and POST /create-transaction: see Create tokens.

curl "https://api.otomate.fun/api/v1/ink/tokens?sort=trending&limit=5"
curl "https://api.otomate.fun/api/v1/ink/quote?token=0x…&side=buy&amountIn=10000000000000000"

Conventions

Amounts{"raw": "1500000000000000000", "decimal": "1.5"}: raw in the smallest unit, decimal exact. Never floats.
PricesDecimal strings, the token's price in its pair (and in USD when known)
Timetimestamp: Unix seconds of the block
Finalitystatus: mined or final
PagesSend cursor; read next (null on the last page)
AddressesLower case in answers; any case in requests
RatesBasis points
ModerationLists show only launches the moderation shows. A hidden launch's sheet answers moderation: "hidden" with no name, symbol or metadata.

Swap transactions

curl -X POST "https://api.otomate.fun/api/v1/ink/swap-transaction" \
  -H 'content-type: application/json' \
  -d '{"token":"0x…","side":"buy","amountIn":"10000000000000000","account":"0xYou","nativeEth":true,"slippageBps":100}'

Add builder, builderFeeBps and builderTag to carry your builder code (a tag alone needs no builder).

The swap goes through Otomate's router where it serves the pool (route: "router"), else the chain's Universal Router with Permit2 (route: "permit2"). Send "route": "permit2" to always take the Universal Router, or "route": "router" to refuse anything else (400, field: "route").

The answer holds quote, minimumOut, deadline, route, approvals (send them first, in order; each for exactly the amount in: on the router route, the input token to the router; on the Permit2 route, the token to Permit2, then Permit2 to the Universal Router; empty for ETH in) and transaction (to, data, value, and gas: the gas limit to send it with, the node's estimate plus 30 %; it is left out while an approval comes first, or when the node gives no estimate: then estimate the swap yourself, after the approvals, and add 30 %), all from and to account. Nothing is signed or stored. Check that to is Otomate's router (0x173b1e9eCC06d53b239754602177F1707BFE76A1) or the chain's Universal Router before you sign.

Errors

Errors answer a closed code: {"error": "API_QUERY", "field": "…"}.

StatusCodesMeaning
400API_QUERY, FEED_QUERY, FEED_CURSOR_CHAINA malformed request; field names the culprit when known
401FEED_KEYAn unknown, revoked or malformed key
404API_NOT_FOUNDNo such launch
421API_CHAINThe other chain: use the API in origin
422QUOTE_REVERTEDThe chain would refuse the trade; reason is the contract error (PartialFillUnsupported, PriceBelowLaunch, TradeBelowMinimum, …)
429API_RATE_LIMITED, FEED_RATE_LIMITED, FEED_CLIENT_LIMITOver your tier's budget; wait Retry-After seconds
503API_UNAVAILABLE, FEED_UNAVAILABLE, FEED_FULLTemporarily unavailable; retry after Retry-After

API keys

A key raises your limits and is free. A wallet signs a message, the API answers a key bound to that wallet. The API stores nothing: the key carries its wallet and a signature, and every key of one wallet shares its limits.

const api = 'https://api.otomate.fun/api/v1/ink';   // a key works on both chains
const { message } = await (await fetch(`${api}/keys/challenge?address=${account.address}`)).json();
const signature = await account.signMessage({ message });   // Sign-In with Ethereum (EIP-4361); moves no funds
const { key, tier } = await (await fetch(`${api}/keys`, {
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message, signature }),
})).json();
// Then: Authorization: Bearer <key>
  • The key is shown once: keep it secret. Lost or leaked, sign again for a new one; Otomate can revoke a wallet's keys.
  • Accounts whose own key signs (EOAs, EIP-7702 accounts) are accepted; contract wallets that sign through ERC-1271 are not, yet.
  • GET /api/v1/{chain}/keys/me with the key answers its tier and limits.
  • Free keys are counted per client address: several free keys from one address share one free budget.

Limits

Limits are per client: your key's wallet, or your address without a key. Each answer carries RateLimit-Policy: <requests>;w=60.

BudgetCounts
Reads per minuteEach /api/v1 request; a quote or a swap transaction counts ten (they read the chain)
Feed connectionsOpen SSE and WebSocket connections
Feed connections per minuteNew connections
Feed pages per minute/feed/events requests

The numbers per tier are in Free to start. Otomate sets each tier's numbers; the free tier is meant to let a bot start.

Pay per request (x402)

Where enabled, a request above your budget answers 402 Payment Required instead of 429, with its price and terms in the PAYMENT-REQUIRED header (x402 version 2): USDC on Base (eip155:8453), paid to the Otomate treasury. Your x402 client signs the USDC transfer and sends the request again with PAYMENT-SIGNATURE; x402 client libraries do this for you. The payment is settled only when the answer succeeds (PAYMENT-RESPONSE carries the transaction): a refused request costs nothing. One payment pays one request. Locking OTO (later) and x402 only raise limits.

Stability

Within v1, fields may be added; none is removed, renamed or changes type. Breaking changes ship as /api/v2 next to v1. Fee rates are data: read them from each pool, never hard-code them.

Trade launch pools

A launch pool is a Uniswap v4 pool with LP fee 0 and Otomate's hook. You trade it through Otomate's router (one approval of the input token to it, then one transaction; no Permit2) or through each chain's Universal Router with Permit2, with empty hookData (or your builder code), and you quote it with the chain's V4 Quoter. Both run the same PoolManager swap: same price, same fees. There is no witness, signature or allow-list. Three ways, from the simplest:

WayYou doBest for
SDKotomate.buy(token, amount) / otomate.sell(…) / otomate.buyExactOut(token, tokens)TypeScript bots and fronts
APIPOST /api/v1/{chain}/swap-transaction, then signAny language
DirectCall Otomate's router, or encode the Universal Router call yourself (below)Routers, aggregators, existing bots

The SDK and the API take Otomate's router by default and fall back to the Universal Router with Permit2 where the router is not deployed or does not serve the pool. Every approval they prepare is for exactly the amount of the trade, never unlimited.

Pairs. A launch pairs its token with WETH or with another ERC-20 the config enables, such as OTO or a stock token (Ink's xStock wrappers, Robinhood Chain's Stock Tokens): the pairs per chain are in Chains and contracts. Some of those tokens (the stock tokens, for example) let their issuer freeze balances or pause transfers; the issuer's rules then apply to the pair side of every trade in that pool, and a pause would stop trading on the pools of that pair.

How a swap is charged

Each pool stores its rates once, in its registration snapshot (LaunchRegistered, the launch event, the token sheet): a quote fee q and a burn b, in basis points, for the curve, and a second pair (marketQuoteFeeBps, marketBurnBps) that applies from the swap after graduation on. b is the part of the creator's share the creator chose to burn, 0 when they chose none. A builder code adds its rate r on top (0 without one). Every swap has one leg in the pair (the quote) and one in the token:

SwapTaken on the leg you specifyTaken on the other leg
Buy, exact in (quote Q in)quote fee floor(Q·q / 10⁴), builder fee floor(Q·r / 10⁴)burn floor(T·b / (10⁴ − q − r)) of the tokens out T
Buy, exact out (tokens X out)burn ceil(X·b / (10⁴ − q − r − b))quote fee ceil(A·q / (10⁴ − q − r)), builder fee ceil(A·r / (10⁴ − q − r)) on the quote in A
Sell, exact in (tokens T in)burn floor(T·b / (10⁴ − q))quote fee floor(Qo·q / 10⁴), builder fee floor(Qo·r / 10⁴) on the quote out Qo
Sell, exact out (quote Q out)quote fee ceil(Q·q / (10⁴ − q − r)), builder fee ceil(Q·r / (10⁴ − q − r))burn ceil(A·b / (10⁴ − q − b)) on the tokens in A

The token leg is grossed up so that the total cost is q + r + b of the trade's value, to rounding. The burned tokens leave the supply. The quote fee is shared between the creator, NFT holders, the protocol, OTO burns and global NFT holders, as each pool's snapshot says: read the shares from the snapshot or the token sheet, never from these pages.

Launch protection. On buys during the first duration seconds (at most 3,600), q includes a surcharge that decreases with the time elapsed since the launch: startBps >> floor(elapsed × halvings / duration), or, when halvings is 0, floor(startBps × (duration − elapsed) / duration) (a straight line down to 0). It is capped so that q + b stays at most 99 %; a builder rate is then lowered to fit (the surcharge never is). startBps, halvings and duration are in each pool's snapshot: read them, and quote right before you trade. The creator's own buy in the creation transaction is exempt; anyone else pays it. As the surcharge falls between your gas estimate and the block, a swap in the first seconds can use more gas than estimated: send swaps with the estimate plus 30 % (the SDK does, and the API's transaction.gas, when it is there, is that limit; only the gas used is paid).

What the hook refuses (the swap reverts):

ErrorWhenWhat to do
PartialFillUnsupportedThe swap cannot be filled entirely: a sell larger than the quote the pool holds, or a swap that would stop at your price limitSell less; use the router's default limits
PriceBelowLaunchA sell would end below the launch priceSell less
TradeBelowMinimumUnder the pair's minimum trade (0 on today's presets)Trade more

Graduation does not move liquidity: the same pool keeps trading.

Quote

The V4 Quoter simulates the hook, so its answer is what executes, both fee legs included. Use quoteExactInputSingle with the pool key, zeroForOne and empty hookData (or your builder code).

The pool key: currency0 and currency1 are the token and its pair in address order, fee is 0, tickSpacing is the pool's (60 on today's presets), hooks is the pool's hook. A buy is zeroForOne when the pair is currency0.

The SDK can also quote without a node round trip: quoteLocal(snapshot, slot0, side, amount, now, {graduated, builder}) runs the v4 swap and the hook's fees exactly, both rate pairs, the surcharge and a builder code included (checked to the wei against the final hook on 220 swaps, in TypeScript and Go). It is exact while the pool holds only the hook's liquidity; after graduation, pass graduated: true and any outside positions you know.

Route: Otomate's router

LaunchPoolRouter at 0x173b1e9eCC06d53b239754602177F1707BFE76A1, the same address on Ink, Robinhood Chain, Base and HyperEVM (each chain's runtime code hash is in the SDK, LAUNCH_POOL_DEPLOYMENT.live). It has no owner, no storage and keeps no funds. It trades the pools of its hook only; any other pool reverts InvalidPool. You pass the pool's key as it is; the router finds the token's side.

CallvalueApproval first
buy(key, amountIn, minTokensOut, recipient, deadline, hookData), paying ETH on a WETH pairamountInnone
buy(…), paying the pair's ERC-20 (WETH, OTO, …)0the pair token to the router, amountIn
buyExactOut(key, tokensOut, maxAmountIn, recipient, deadline, hookData), paying ETHmaxAmountIn (the rest comes back)none
buyExactOut(…), paying the ERC-200the pair token to the router, maxAmountIn
sell(key, tokensIn, minAmountOut, recipient, deadline, hookData, nativeOut)0the launch token to the router, tokensIn
  • The payer is always the sender; the output goes to recipient. The router refuses as recipient address(0) and every contract of the trade (itself, the PoolManager, the hook, the factory, the wrapped native token, the token and its pair): InvalidRecipient.
  • nativeOut: true pays a sell out in ETH (WHYPE pools on HyperEVM: HYPE). Native in and out work on wrapped-native pairs only.
  • Amounts are raw units, above 0 and below 2¹²⁷. The deadline is a unix time; its own second is valid.
  • hookData goes to the hook unchanged: 0x, or your builder code. Never put the router's address as the builder: it cannot claim.
  • The router uses the allowance to the unit, so an approval of exactly the trade's amount is enough; an exact-out buy leaves what it did not spend of maxAmountIn.
  • After the hook's SwapFee, it emits Bought(payer, recipient, token, quoteIn, tokensOut, quoteRefund) or Sold(payer, recipient, token, tokensIn, quoteOut). On an exact-out buy paid in ETH, quoteIn is what was sent and quoteRefund what came back.
  • A call from inside another PoolManager unlock reverts (AlreadyUnlocked), as with the Universal Router; two router calls in one transaction work.
  • Its errors: Expired, InvalidPool, InvalidAmount, InvalidRecipient, InvalidNativeValue, NotWrappedNative, TooLittleReceived(amountOut, minAmountOut), TooMuchRequested(amountIn, maxAmountIn), Reentrancy, Unauthorized, NativeTransferFailed. The hook's errors arrive wrapped by the PoolManager.
import { encodeRouterBuy, launchPool, launchPoolRouter, quoteOnchain } from '@otomate/sdk';

const pool = launchPool('ink', token, weth, hook);
const router = launchPoolRouter('ink')!;                     // { address, codeHash, hook }
const out = await quoteOnchain(publicClient, pool, 'buy', amountIn);
const tx = encodeRouterBuy({ pool, pin: router, amountIn, minTokensOut: out * 99n / 100n, recipient: account.address,
  deadline: BigInt(Math.floor(Date.now() / 1000) + 120), hookData: '0x', nativeEth: true });
await walletClient.sendTransaction(tx);                      // { to: router, data, value }

readLaunchPoolRouterCode(publicClient, router) reads the code at the router's address and answers ok only when it is the pinned runtime: the SDK checks it before every router trade.

Route: the Universal Router with Permit2

InkRobinhood Chain
Router0x112908daC86e20e7241B0927479Ea3Bf935d1fa00x8876789976dEcBfCbBbe364623C63652db8C0904
v4 single-swap tuple(PoolKey, zeroForOne, amountIn, amountOutMinimum, hookData)(PoolKey, zeroForOne, amountIn, amountOutMinimum, minHopPriceX36, hookData) with minHopPriceX36 = 0
Commands0x10 (V4_SWAP); 0x0b10 (WRAP_ETH, V4_SWAP) to pay ETH; 0x100c (V4_SWAP, UNWRAP_WETH) to receive ETHSame
Actions0x060c0f (SWAP_EXACT_IN_SINGLE, SETTLE_ALL, TAKE_ALL)Same

ERC-20 inputs go through Permit2: approve the token to Permit2, then give the router a Permit2 allowance; the SDK and the API prepare both for exactly the amount in. A launch token's own permit (EIP-2612) takes 65-byte signatures only: EOAs, EIP-7702 EOAs, and ERC-1271 wallets whose signature is 65 bytes. Other smart wallets (multi-owner Safes, passkey wallets) use the approval or Permit2. ETH works on WETH pairs: the router wraps your ETH, or unwraps the WETH it receives. A swap costs about 163,000 gas on Ink and 173,000 on Robinhood Chain.

The SDK's encoding functions, if you send transactions yourself (swap({…, route: 'permit2'}) takes this route too):

import { encodeSwap, launchPool, quoteOnchain } from '@otomate/sdk';

const pool = launchPool('ink', token, weth, hook);          // key from token, pair and hook (tick spacing 60)
const out = await quoteOnchain(publicClient, pool, 'buy', amountIn);
const tx = encodeSwap({ pool, side: 'buy', amountIn, minimumOut: out * 99n / 100n,
  deadline: BigInt(Math.floor(Date.now() / 1000) + 120), recipient: account.address, nativeEth: true });
await walletClient.sendTransaction(tx);                      // { to: router, data, value }

With an ERC-20 input, the router pays the pool from the sender and sends the output to the sender: the recipient is always the sender.

Read the result

The hook emits one SwapFee per swap, right after the PoolManager's Swap:

SwapFee(bytes32 indexed poolId, address indexed sender, bool isBuy, uint256 quoteAmount, uint256 tokenAmount,
        uint256 quoteFee, uint256 surcharge, uint256 burn, uint160 sqrtPriceX96After, int24 tickAfter, uint32 seq)

quoteAmount and tokenAmount are the trader's: paid and received (a builder fee included). quoteFee is the pool's, surcharge included, never the builder fee. seq counts the pool's swaps from 1 without gaps. A swap with a builder code also emits BuilderFee(poolId, builder, tag, fee, feeBps, seq) right after. A trade through Otomate's router then emits its Bought or Sold. The SDK's readSwapFee(receipt, pool) decodes them; swap() and buyExactOut() return them, with the route taken.

Slippage, deadlines, MEV

  • Set amountOutMinimum from a fresh quote; the SDK and the API use 1 % by default.
  • Set a deadline of a minute or two.
  • Inside the launch protection window, a buy's price moves fast with time: quote again just before sending.

Create tokens

Anyone can create a token, a bot included: call the factory and pay its creation fee in ETH. One transaction deploys the token, its pool with the whole supply, an NFT collection if you ask for one, and your optional first buy. Otomate moderates what its own site and API show, not who may create.

In one call

import { createOtomate } from '@otomate/sdk';
import { readFile } from 'node:fs/promises';

const otomate = createOtomate({ chain: 'ink', walletClient });   // the SDK carries the launch contracts, as deployed
const created = await otomate.createToken(
  { name: 'Moon Cat', symbol: 'MCAT', description: 'A cat on the moon.', image: await readFile('logo.png'),
    banner: await readFile('banner.png'), links: { website: 'https://mooncat.example', x: 'https://x.com/mooncat' } },
  { quote: otomate.chain.weth, creatorBps: 60, creatorBurnBps: 20, initialBuy: 10n ** 16n },
);
console.log(created.token, created.poolId, created.hash);

createToken uploads the metadata to Otomate's point of upload, asks the API to build the factory call, checks that it goes to the official factory and that every field is your choice (name, symbol, pair, shares, your address as payout and token recipient, initial buy, metadata, the creation fee read on chain, the ETH sent), then signs with your wallet (the approval first, for an ERC-20 pair) and waits for the receipt.

The choices

ParameterMeaning
quoteThe pair: WETH or another ERC-20 the config enables, such as OTO or a stock token (the list per chain is in Chains and contracts; otomatePairs(chain) in the SDK). On WETH pairs you may pay in ETH. A pair the config does not enable is refused (CREATE_QUOTE_DISABLED from the API).
creatorBpsYour share of the trading fee, within the config's bounds
creatorBurnBpsThe part of your share burned in token at each trade instead of paid to you (at most creatorBps)
withCollectionAn NFT collection now, or never: it cannot be added later
artworkWith a collection: 'deferred', the default and the only value while automatic artwork is off (Collection art)
initialBuyYour first buy, in the same transaction, without the launch protection. It is bounded by the pair's preset amounts (minInitialBuy and maxInitialBuy of quoteConfig(pair) on the config; a maxInitialBuy of 0 means no cap): the cap is an amount in the pair, set per pair at about 10 % of the supply at the launch price, not a percentage the contract computes
saltPicks the token address (predictToken); random by default

The creation fee depends only on withCollection: read creationFee(withCollection) on the factory, or creationFee in the API's answer. Besides that fee, the factory takes your initial buy and the hook's QUOTE_DUST of the pair, which starts the pool, even without a buy:

PairdustCovered(pair) false or reverting (today's factory)dustCovered(pair) true (factory upgrade, not deployed yet)
WETH, paid in ETHmsg.value = fee + initial buy + QUOTE_DUSTmsg.value = fee + initial buy (fee + initial buy + QUOTE_DUST is still accepted)
Another ERC-20msg.value = fee; allowance of initial buy + QUOTE_DUSTmsg.value = fee; allowance of the initial buy, none without a buy

The SDK reads dustCovered on the factory and applies the right column (readDustCovered, creatorPayment); a revert reads as false.

Collection art

A collection's art direction is the artworkCommitment of the factory call (artwork in the SDK, artworkCommitment in the API's create-transaction body). It is set at creation:

SDK artworkartworkCommitmentWhat happens
'deferred' (the default)0x0000…0000After the launch, the creator picks the collection's design in Otomate's Studio: three designs to choose from, then one confirmation from the creator payout wallet. The NFT mint opens once the design is chosen.

Automatic artwork is off for now, so a collection is created deferred only: any other artworkCommitment would open the mint with no art ever made. The API answers it with 409 CREATE_ARTWORK_AUTOMATIC_DISABLED (field artworkCommitment), and the SDK and the MCP server refuse it before any upload. They build no finalizeArtworkAutomatic() call either. The SDK exports LAUNCH_POOL_DEFERRED_ARTWORK_DIRECTION.

A launch that never graduates

A collection's NFT share waits in the NFT vault until its first reveal, and a reveal needs graduation. If the pool has not graduated neverGraduatedDelay() after its creation while no NFT of the collection exists (30 days by default), or neverGraduatedMintedDelay() once NFTs exist (365 days by default), anyone may send that waiting share to Otomate's treasury with sweepNeverGraduated(collection, asset) on the vault. Graduation closes it for good. Read the delays on the vault, never assume them: neverGraduatedSweep(collection) answers when it opens, whether it is open and which rule applies (the SDK's readNeverGraduatedSweep, readNeverGraduatedDelays and sweepNeverGraduatedCall).

Metadata

The token's metadata is a JSON file on IPFS; its URI is stored at creation and never changes. Its format, otomate.token-metadata.v1:

{
  "schema": "otomate.token-metadata.v1",
  "name": "Moon Cat",
  "symbol": "MCAT",
  "description": "A cat on the moon.",
  "image": "ipfs://bafkrei…",
  "banner": "ipfs://bafkrei…",
  "external_url": "https://mooncat.example",
  "website": "https://mooncat.example",
  "twitter": "https://x.com/mooncat",
  "telegram": "https://t.me/mooncat",
  "discord": "https://discord.gg/…"
}

name and symbol must equal the ones you pass to the factory. The symbol is 1 to 16 letters and digits. The logo is square; the banner is 1600 × 600 (8:3). Links are public https URLs.

Two ways to publish it:

Otomate's point of uploadYour own IPFS
HowPOST /api/v1/{chain}/metadata (the SDK's uploadMetadata)Pin the JSON and images yourself
ImagesBytes as data:image/…;base64, (PNG, JPEG, WebP, GIF; 4 MiB each), or an https URL the API downloadsYours, as ipfs:// raw CIDs
What you getmetadataURI, projectCommitment, the drawn logo (512 px) and banner, and for a collection its hidden URIYour URI
ModerationBefore pinning; a refused upload answers 422 METADATA_CONTENT_REVIEWAfter launch, on display
Limits20 uploads per hour per client; unused uploads are unpinned after 24 hours—

No banner? Otomate generates one after the launch from the token's name and logo.

Build the call yourself

The API builds it: POST /api/v1/{chain}/create-transaction with your address, the metadata URI and your choices. It simulates the call and answers transaction (to, data, value, gas), predictedToken and minTokensOut. For an ERC-20 pair whose allowance is short, it answers approval first and no transaction: send the approval, wait for it, and ask again with the same salt (the token address stays the same).

Or build it locally from the ABI with the SDK's buildCreateTransactionLocal: it reads the fee, simulates the creation and sets the initial buy's minimum.

Gas limit. gas is null on Ink and Robinhood Chain: your wallet estimates. On HyperEVM a creation is sent with a gas limit of exactly 3,000,000, the size of its small blocks (a worst-case creation uses about 2.64 M; a wallet's usual margin over its estimate would not fit). The SDK, the MCP server and otomate.fun set it for you; if you sign by hand, use the gas the API answers and add no margin.

Before signing, check that to is the official factory. Its address is the same on every chain and is listed in Chains and contracts; the SDK and the MCP server check it for you.

After the launch

  • Same address on both chains: the same creator, salt, name, symbol and pair give the same token address on Ink and Robinhood Chain, as long as both chains create with the same hook and their configs give the pair the same token supply (predictToken tells you before you sign).
  • tokenInfo(token) on the factory answers in one read: metadata URI, profile URI, creator, profile authority, pair, pool, hook, collection, creation time.
  • Profile. The description, banner and links can be updated through the token's profile: upload a profile (kind: "profile", the SDK's uploadProfile), then the profile authority calls setProfileURI(token, uri). The authority starts as the creator and moves in two steps (proposeProfileAuthority, then acceptProfileAuthority). The name, symbol and logo never change.
  • Creator earnings are paid by the distributor to your payout address when anyone calls distribute(poolIds), the creator included: there is nothing to claim. The payout address moves in two steps (propose, then accept).

Builder codes

A builder code lets the front end, bot or terminal that sends a trade earn a fee on it. You add your code to the swaps you send; the trader pays your fee on top of the pool's fees, in the pool's pair; the hook keeps it for you; you claim it whenever you want. This is Hyperliquid's model.

Add your code

The code is the swap's hookData, exactly 96 bytes: abi.encode(address builder, uint256 feeBps, bytes32 tag).

RuleValue
Highest fee200 bps (2 %) of the trade
Paid byThe trader, on top of the pool's fees, in the pool's pair, on buys and sells
The pool's own fee and burnExactly what they are without a code
Inside the launch protection windowYour rate is lowered so that the total stays at most 99 %; the protection itself is not. BuilderFee shows the rate applied
A code with feeBps 0Attribution only: the trade is credited to you, with no fee
tagAny 32 bytes you choose (a referral, a partner id, a campaign). The hook only copies it into BuilderFee; nothing checks it
A tag alonebuilder = address(0), feeBps 0 and your tag: no fee, no account, the tag is logged
A malformed hookDataThe swap reverts (InvalidHookData): another length, feeBps above 200, a fee without a builder, or the hook or the PoolManager as builder
Through Otomate's router or the Universal RouterThe same: both pass the hookData to the hook unchanged
Otomate's router as builderNever: it cannot claim. The SDK and the API refuse it
// SDK: every swap this object sends carries your code.
const otomate = createOtomate({ chain: 'ink', walletClient, builder: { address: '0xYourBuilder', feeBps: 50, tag: '0x…32 bytes' } });

// Or by hand: the same bytes in the router's v4 swap.
import { builderHookData } from '@otomate/sdk';
const hookData = builderHookData({ address: '0xYourBuilder', feeBps: 50 });   // tag: 32 zero bytes
const tagOnly = builderHookData({ tag: '0x…32 bytes' });                        // no fee, no builder

The API takes it too: builder, builderFeeBps and builderTag on /quote and /swap-transaction. A quote shows your fee apart (fees.builder) and the rate applied (builder.feeBps, with requestedFeeBps when the protection window lowered it).

See what you earned

  • Each swap with a code emits BuilderFee(poolId, builder, tag, fee, feeBps, seq) right after its SwapFee (same pool, same seq). builder and tag are indexed.
  • builderFees(builder, currency) on the hook answers what you can claim in one currency (WETH, OTO, any enabled pair).
  • GET /api/v1/{chain}/wallets/{builder} lists it under claimable.builder (per currency and hook), and each trade of /tokens/{token}/trades and of the live feed carries its builder.
  • On otomate.fun, the builder dashboard shows earnings per pair and a claim button.

Claim

Call claimBuilderFees(currencies, recipient) on the hook from your builder address. It sends what you earned in each listed currency to recipient, less one raw unit that stays in your account (so your next fee costs less gas). Nothing expires.

import { claimBuilderFeesCall } from '@otomate/sdk';
await walletClient.sendTransaction(claimBuilderFeesCall(hook, [weth, oto], recipient));

A builder that is a contract

A builder that cannot call the hook itself (a contract without that function) is paid by claimBuilderFeesFor(builder, currencies): anyone may send it, and it always pays the builder itself, with the same one-unit rule. Such a contract must be able to hold and move ERC-20s, and must not let anyone sweep its balance.

A builder that can call may refuse these claims on its account: setClaimForBlocked(true) (and false to accept them again). A refused claim reverts ClaimForBlocked(); claimForBlocked(builder) reads the setting.

import { claimBuilderFeesForCall, readClaimForBlocked, setClaimForBlockedCall } from '@otomate/sdk';
await walletClient.sendTransaction(setClaimForBlockedCall(hook, true));    // sent by the builder
await readClaimForBlocked(publicClient, hook, builder);                     // true

Builder code in the starter launchpad

The starter launchpad puts your code on every trade it sends: set your address, rate and tag in its configuration file. See Your own launchpad.

Data providers

This page is for data providers and indexers: screeners, aggregators, analytics, wallets and terminals that list Otomate launches from their own pipelines. Everything here is read from the chain. Otomate's API and feed are optional shortcuts (Otomate's own data).

Launch model for indexers

An Otomate launch is two things:

  • A plain ERC-20 token, 18 decimals, with no owner, no pause and no mint after creation.
  • Its own Uniswap v4 pool, whose hook is Otomate's launch pool hook. At creation the hook puts the whole supply into the pool, in two positions it owns. Those positions are the bonding curve and the market beyond it.

There is no separate curve contract and no migration. The token trades in the same pool from its first block, before and after graduation.

PropertyValue
ChainsInk (57073) and Robinhood Chain (4663)
AMMUniswap v4, the chain's standard PoolManager
Pools per launchOne: currencies are the token and its pair in address order, fee 0, the snapshot's tick spacing (60 on today's presets), hooks = the pool's hook
PairWETH, or another ERC-20 the config enables, such as OTO or a stock token (the pairs per chain). Follow the config's QuoteConfigured and QuoteDisabled events. No native ETH pool: routers wrap ETH on WETH pairs.
LP fee0. Every fee is the hook's.
Liquidity at creationThe whole supply, in two hook-owned positions (bonding and market)
GraduationA one-way latch in the hook. The pool stays the same.
QuotesThe chain's V4 Quoter equals execution, with the swap's own hookData (empty, or a builder code)

Network and contracts for indexers

Ink (57073). Explorer: explorer.inkonchain.com.

Robinhood Chain (4663). Explorer: robinhoodchain.blockscout.com.

Otomate's launch contracts have the same address on both chains. They are published here at deployment, with their ABIs. Check the signatures on this page against those ABIs.

ContractKindAddress
Launch pool hookPlain contract, not a proxyTo be published
FactoryUpgradeable proxy (ERC-1967)To be published
ConfigUpgradeable proxy (ERC-1967)To be published
DistributorUpgradeable proxy (ERC-1967)To be published
Lens (batched views)Upgradeable proxy (ERC-1967)To be published
Token deployerPlain contract, not a proxyTo be published

Track hook versions and factory versions separately.

  • Hooks. The config names the hook for new launches and emits LaunchHookSet(address indexed hook, uint64 indexed revision). A pool keeps the hook it was created with for its whole life; a newer hook serves only later launches. Index every hook the config ever named (config.isLaunchHook(hook)). The hook of one pool is LaunchCreated.hook or factory.hookOf(poolId). A hook is not a proxy: pin its address and its runtime code hash, per chain (the runtime differs per chain).
  • Factory and other proxies. The factory, config, distributor and lens keep their proxy address. A new implementation emits Upgraded(address indexed implementation) on the proxy. Follow it. Otomate's rule for these contracts: an existing event never changes its fields; a changed field set gets a new event name.

Discover launches

One creation is one transaction. Anyone can send it, a bot or another app included. Its logs come in this order:

  1. Token deployer. First the token's mint, Transfer from the zero address to the hook. Then TokenDeployed(address indexed token, address indexed creator, address indexed recipient, uint256 supply). The recipient is the hook.
  2. PoolManager Initialize, then two ModifyLiquidity from the hook (its two positions). Any rounding dust is burned (Transfer from the hook to the zero address).
  3. Hook LaunchRegistered(bytes32 indexed poolId, address indexed token, address indexed quote, PoolSnapshot snapshot, address creatorPayout, uint160 initialSqrtPriceX96). This registers the pool, with its full snapshot and its first price.
  4. Optional initial buy by the creator: PoolManager Swap, hook SwapFee with seq 1, factory InitialBuy(bytes32 indexed poolId, address indexed recipient, uint256 quoteIn, uint256 tokensOut). It carries no builder code, so it has no BuilderFee.
  5. Factory LaunchCreated, last:
event LaunchCreated(bytes32 indexed poolId, address indexed token, address indexed creator, address hook, address quote,
    address collection, uint64 quoteRevision, uint64 globalRevision, bytes32 projectCommitment, string name, string symbol,
    string metadataURI, uint256 creationFee, bytes32 artworkCommitment);

With an NFT collection, the collection's own logs and the Forge's SourceRegistered come between steps 3 and 4. Transfers of the pair (the creator's payment, the hook's small pre-funding and the unused rest coming back) also appear in the transaction; they are not trades. LaunchRegistered and LaunchCreated are in the same transaction. Key the pool on LaunchRegistered: it comes before any trade of the pool. Take the name, symbol, creator, metadata URI and collection from LaunchCreated.

  • creator is the address that called create.
  • collection is the zero address for a token without an NFT collection.
  • creationFee is the fee paid in the chain's native currency.
  • artworkCommitment is the collection's first artwork direction. It is zero when the creator deferred the direction (the collection's ArtworkFinalized gives it later), and for a token without a collection.

The snapshot, as the hook declares it:

struct FeeSnapshot { uint16 quoteFeeBps; uint16[5] shareBps; uint16 burnBps; uint16 creatorBurnBps; uint16 marketQuoteFeeBps;
    uint16 marketBurnBps; }
struct SurchargeSnapshot { uint16 startBps; uint16 duration; uint8 halvings; }
struct PoolSnapshot { address token; address quote; bool quoteIsCurrency0; int24 tickSpacing; int24 launchTick;
    int24 mintGateTick; int24 graduationTick; uint128 bondingLiquidity; uint128 marketLiquidity; uint256 threshold;
    uint256 minimumTrade; uint16 mintGateBps; uint40 launchedAt; FeeSnapshot fees; SurchargeSnapshot surcharge; }

One read per token. factory.tokenInfo(token) answers metadataURI, profileURI, creator, profileAuthority, proposedProfileAuthority, quote, poolId, hook, collection and createdAt. An unknown token answers poolId 0; it does not revert. hook.poolIdOf(token) and hook.snapshot(poolId) give the pool and its snapshot.

Without running an indexer: the API's GET /api/v1/{chain}/tokens?sort=new lists launches, and the real-time feed pushes each one as a launch event at its mined block.

Price from the pool

Read the price from sqrtPriceX96, in the pool's real currency order:

  • currency0 is the lower of the two addresses.
  • sqrtPriceX96² / 2¹⁹² is the price of currency0 in currency1, in raw units.
  • When the token is currency0, that is the token's price in the pair. When the token is currency1, take its inverse.
  • Then adjust decimals: the token has 18; the pair has its own (decimals(); 18 for WETH). One whole token costs raw × 10^(18 − pairDecimals) whole pair units.

snapshot.quoteIsCurrency0 says which order a pool uses. Take the price after each trade from SwapFee.sqrtPriceX96After (equal to the PoolManager Swap event's price), and the current price from StateView.getSlot0(poolId). The pool's first price is in the PoolManager's Initialize event and in LaunchRegistered.initialSqrtPriceX96. It is sqrtAt(launchTick) when the token is currency0, and one unit below it when the token is currency1.

TypeScript, exact with bigints:

const Q192 = 1n << 192n;

/** One whole token in whole pair units, as an exact fraction. */
export function tokenPrice(sqrtPriceX96: bigint, token: string, quote: string, quoteDecimals: number) {
  const tokenIsCurrency0 = BigInt(token) < BigInt(quote);
  const square = sqrtPriceX96 * sqrtPriceX96;                          // currency1 per currency0, × 2^192
  const num = (tokenIsCurrency0 ? square : Q192) * 10n ** 18n;          // the token has 18 decimals
  const den = (tokenIsCurrency0 ? Q192 : square) * 10n ** BigInt(quoteDecimals);
  return { num, den };
}

/** A fraction as a decimal string, rounded down. */
export function toDecimal({ num, den }: { num: bigint; den: bigint }, digits = 18) {
  const s = ((num * 10n ** BigInt(digits)) / den).toString().padStart(digits + 1, '0');
  return `${s.slice(0, -digits)}.${s.slice(-digits)}`.replace(/\.?0+$/, '');
}

Python, exact with fractions:

from fractions import Fraction

def token_price(sqrt_price_x96: int, token: str, quote: str, quote_decimals: int) -> Fraction:
    """One whole token in whole pair units."""
    token_is_currency0 = int(token, 16) < int(quote, 16)
    ratio = Fraction(sqrt_price_x96 * sqrt_price_x96, 1 << 192)  # currency1 raw per currency0 raw
    raw = ratio if token_is_currency0 else 1 / ratio                # pair raw per token raw
    return raw * Fraction(10 ** 18, 10 ** quote_decimals)          # the token has 18 decimals

Both give the same value for a pool and its mirror order: at tick −207,960 (token as currency0, WETH pair) and at tick 207,960 (token as currency1), one token costs 0.000000000930815279 WETH.

Do not derive the price from a trade's amounts. quoteAmount / tokenAmount is what the trader paid per token, fees on both legs included. Use it for an average execution price, not for the pool price.

Otomate's API computes the market cap from the circulating supply and the FDV from the initial supply, both times the price (Supply and burns). For USD, multiply by your USD price of the pair. On a stock pair, price the pair token itself, not one share of the stock: an xStock wrapper's share is worth convertToAssets of its xStock, and a Robinhood Chain Stock Token keeps its raw balances through dividends and splits while its uiMultiplier() moves (Chainlink's feed of each Stock Token on Robinhood Chain prices the raw token, multiplier included).

Trades from SwapFee

The hook emits one SwapFee per swap. It is a complete trade record:

event SwapFee(bytes32 indexed poolId, address indexed sender, bool isBuy, uint256 quoteAmount, uint256 tokenAmount,
    uint256 quoteFee, uint256 surcharge, uint256 burn, uint160 sqrtPriceX96After, int24 tickAfter, uint32 seq);
FieldMeaning
poolIdThe pool (topic 1)
senderThe PoolManager's swap caller: the router, not the trader
isBuytrue: pair in, token out. false: token in, pair out
quoteAmountThe trader's pair amount, as their balance change. Buy: paid, quote fee and builder fee included. Sell: received, after the quote fee and the builder fee.
tokenAmountThe trader's token amount. Buy: received, after the burn. Sell: paid, burn included.
quoteFeeThe pool's quote fee taken, in the pair, launch surcharge included. It never includes the builder fee.
surchargeThe launch surcharge part of quoteFee
burnTokens taken from this swap, to be burned
sqrtPriceX96After, tickAfterThe pool's price and tick after the swap
seqThe pool's swap number: 1 for its first swap, then +1 on every swap, with no gaps

Builder codes. A swap whose hookData is not empty carries a builder code. The hook then emits one BuilderFee right after its SwapFee, with the same poolId and seq:

event BuilderFee(bytes32 indexed poolId, address indexed builder, bytes32 indexed tag, uint256 fee, uint16 feeBps, uint32 seq);
FieldMeaning
builderThe builder's account (topic 2). The zero address for a tag alone, with fee and feeBps 0
tag32 bytes chosen by whoever built the swap (topic 3). The hook only copies it; nothing checks it
feeThe builder fee, in the pair, kept by the hook for the builder. It is inside quoteAmount, never inside quoteFee
feeBpsThe rate applied: the code's rate, lowered inside the launch surcharge window when needed (Fee rates in the snapshot). 0 is attribution only
seqThe seq of the swap's SwapFee

Order of the logs. For one swap:

  1. The PoolManager's Swap.
  2. The PoolManager's ERC-6909 Transfer logs that mint the hook's claims: the quote fee together with any builder fee, then the burn (each only when it is not zero).
  3. The hook's SwapFee. It is the hook's first log after the Swap.
  4. The hook's BuilderFee, when the swap carries a builder code.
  5. MintGateOpened, then Graduated, when that swap latches them.

Rules for indexers:

  • Several swaps of one pool in one transaction are separate trades. Each has its own SwapFee and seq. Do not merge them; do not drop them as ambiguous.
  • A gap in seq means a missed log. Expect (last + 1) mod 2³²: after 4,294,967,295 comes 0, then 1. hook.swapSeq(poolId) answers the last one.
  • The Swap amounts are the AMM leg only, before the hook's fees. A buy pays Swap pair amount + quoteFee + builder fee and receives Swap tokens − burn. A sell pays Swap tokens + burn and receives Swap pair amount − quoteFee − builder fee. The builder fee is BuilderFee.fee, 0 without a code. SwapFee already gives the trader's side; you never need the PoolManager's Swap firehose.
  • Zero amounts happen. A sell of a few wei can return 0 of the pair. Accept it.
  • Volume in the pair is the sum of quoteAmount. Otomate's API counts it that way.

Who traded. sender is the router. Attribute the trade to a wallet in this order, and never with tx.from alone:

  1. The token flow. On a buy, follow the token's Transfer out of the PoolManager after the Swap: the trader is the wallet that ends up with the tokens. On a sell, follow the Transfer into the PoolManager before it: the trader is the wallet the tokens came from, wherever the pair goes. Look through routers, Permit2, aggregator executors, the hook and addresses that pass the tokens on (net zero in the transaction), up to 8 hops, following the largest transfer at each one: an interface fee paid in the token before the rest never names the fee's recipient. The flow amount is the Swap amount minus the burn on buys, plus the burn on sells. This holds whatever the path: a smart account or a router's caller buying for another wallet credits that wallet.
  2. ERC-4337, only when the flow names no wallet (the tokens are burned, stay in a router or a protocol contract, or are still being passed on after 8 hops). When a UserOperationEvent follows the trade in the transaction, the trader is its sender, never the bundler. Read the flow inside that operation's logs: a bundle's operations never mix. EntryPoints v0.6 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789, v0.7 0x0000000071727De22E5E9d8BAf0edAc6f37da032 and v0.8 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108, the same on both chains.
  3. A router's payer, only when the flow names no wallet. Otomate's router (0x173b1e9eCC06d53b239754602177F1707BFE76A1, the same on every chain) emits Bought(address indexed payer, address indexed recipient, address indexed token, uint256 quoteIn, uint256 tokensOut, uint256 quoteRefund) or Sold(address indexed payer, address indexed recipient, address indexed token, uint256 tokensIn, uint256 quoteOut) after the hook's SwapFee. The payer is the router's caller (a wallet or a contract); tokens never pass through the router.
  4. Fallback: without any transfer of the trade's tokens, the Swap sender (for example an arbitrage contract), unless it is a router.

Otomate's API and feed give the result as trader, with traderBasis (user-operation, router, caller, token-flow, event or unknown); on launch pools, user-operation means the token flow named no wallet. They also give each trade's builder code as builder: address (null for a tag alone), the fee (fee in the API, feeRaw in the feed), feeBps and tag. It is null for a trade without a code.

Liquidity of a launch pool

The hook creates two positions in its own pool (salts 1 and 2) and owns them. Their ticks are in the snapshot, already in the pool's orientation:

PositionToken is currency0Token is currency1Holds at creation
Bonding[launchTick, graduationTick][graduationTick, launchTick]The bonding supply
Market[graduationTick, maxUsableTick][minUsableTick, graduationTick]The market supply
  • Amounts at a price. Use the snapshot's bondingLiquidity and marketLiquidity with the standard v4 amount formulas (SqrtPriceMath.getAmount0Delta and getAmount1Delta, rounded down) over each range. The SDK's rangeAmounts(launchPoolRanges(snapshot), sqrtPriceX96) does it.
  • The hook never removes its liquidity. It has no function that does. Its positions change only through swaps.
  • Outside liquidity is refused during bonding (LiquidityLockedDuringBonding). After graduation it is allowed only on the token-expensive side of the launch price: tickLower ≥ launchTick when the token is currency0, tickUpper ≤ launchTick when it is currency1 (LiquidityBelowLaunchPrice otherwise). Donations are always refused. Outside positions appear as PoolManager ModifyLiquidity from another sender.
  • LP fee is 0. An outside LP earns no fee: every fee is the hook's. Outside liquidity is depth for traders, not income.
  • TVL. The PoolManager holds the tokens of every v4 pool on the chain, so its balances are not one pool's TVL. Compute from positions. Otomate's API reports the pair and tokens in the hook's own positions at the current price (market.liquidity); it does not count outside positions.
  • Other pools. Anyone can open another pool for a launch token elsewhere (v2, v3, another v4 hook). Only the pool registered by LaunchRegistered is the launch pool. Progress and graduation read only that pool.

Progress and graduation

Progress is the pair held by the bonding position, over the threshold:

  • progressBps = min(10,000, bondingQuote × 10,000 / threshold), with threshold from the snapshot.
  • On chain: hook.bondingQuote(poolId) and hook.bondingProgressBps(poolId).
  • Off chain, from any sqrtPriceX96 (P), with L = bondingLiquidity, rounded down:
    • token is currency0: bondingQuote = getAmount1Delta(sqrtAt(launchTick), min(P, sqrtAt(graduationTick)), L), or 0 when P ≤ sqrtAt(launchTick);
    • token is currency1: bondingQuote = getAmount0Delta(max(P, sqrtAt(graduationTick)), sqrtAt(launchTick), L), or 0 when P ≥ sqrtAt(launchTick).

Two one-way latches in the hook:

MilestoneWhereEvent
Mint gatemintGateBps of the threshold (mintGateTick); 5,000 on today's presetsMintGateOpened
GraduationThe threshold (graduationTick)Graduated
event MintGateOpened(bytes32 indexed poolId, uint40 at, uint64 l2Block, uint160 sqrtPriceX96, int24 tick, uint32 seq,
    uint256 feesOrdinary, uint256 feesSurcharge, uint128 liquidity);
event Graduated(bytes32 indexed poolId, uint40 at, uint64 l2Block, uint160 sqrtPriceX96, int24 tick, uint32 seq,
    uint256 feesOrdinary, uint256 feesSurcharge, uint128 liquidity);
  • The first swap that ends at or beyond a milestone latches it. Nothing unlatches it. Graduation also latches the mint gate: when one swap latches both, MintGateOpened comes first, then Graduated, with the same values.
  • sqrtPriceX96, tick and seq are those of the latching swap (its SwapFee has the same seq). feesOrdinary and feesSurcharge are the pool's cumulative quote fees after that swap, as hook.feesAccrued(poolId) answers them. liquidity is the pool's active liquidity after it.
  • The protocol flag is authoritative: the Graduated event, or hook.graduated(poolId). Never recompute graduation from the price after the fact. A buy and a sell in one transaction can latch it and bring the price back; the price can also fall after graduation. After graduation, bondingProgressBps answers 10,000 whatever the price.
  • l2Block is the chain's own block: block.number on Ink, the Arbitrum L2 block on Robinhood Chain (the log's block number on both).
  • Graduation does not move liquidity. No new pool, no new pair, no LP tokens. The same pool keeps trading; the market position takes over above the graduation price.
  • Graduation switches the rates from the next swap on: see Fee rates in the snapshot.

Fee rates in the snapshot

Each pool's rates are numbers in its snapshot (LaunchRegistered, or hook.snapshot(poolId)). Read them per pool; show them as they are.

Snapshot fieldMeaning
fees.quoteFeeBpsThe curve's quote fee, taken in the pair on every swap until graduation. It is the sum of the five shares.
fees.shareBps[5]The shares of the quote fee, in this order: creator, NFT holders, treasury, OTO burn, global NFT holders. After graduation they keep their proportions.
fees.burnBpsThe curve's token burn, taken in the token on every swap until graduation, and burned
fees.creatorBurnBpsThe creator's burn choice, the part of burnBps the creator chose to burn instead of receiving
fees.marketQuoteFeeBps, fees.marketBurnBpsThe quote fee and the token burn after graduation. Each is at most its curve rate.
surcharge.startBps, duration, halvingsThe launch surcharge on buys, from launchedAt
  • Which rates apply. The curve's pair (quoteFeeBps, burnBps) applies to every swap up to and including the swap that graduates the pool. From the next swap on, the pair after graduation (marketQuoteFeeBps, marketBurnBps) applies. Below, q and b are the pair that applies to a swap.
  • What a trader pays. q + b of the trade's value, on buys and sells, to rounding. The token leg is grossed up so that this holds whichever leg is specified. A builder fee comes on top.
  • The token-leg burn. On a buy, the burn is taken from the tokens out. On a sell, from the tokens in. SwapFee.burn gives the amount.
  • The launch surcharge applies to buys during the first duration seconds after launchedAt (duration is at most 3,600). With elapsed the seconds since launchedAt, it is startBps >> floor(elapsed × halvings / duration), or, when halvings is 0, floor(startBps × (duration − elapsed) / duration) (a straight line down to 0). It is 0 once elapsed ≥ duration. It is capped at 9,900 − q − b, so that the swap's total stays at most 99 %, then added to q for that buy. The creator's initial buy in the creation transaction pays none. SwapFee.surcharge is its part of quoteFee; hook.surchargeBps(poolId, sender) answers the rate now.
  • Builder fee. A swap may carry a builder code. The trader pays the builder fee on top of the pool's fees, in the pair, on buys and sells, at the code's rate on the same pair amount as the quote fee: at most 200 bps (MAX_BUILDER_FEE_BPS). The pool's own fee and burn are the same as without a code. Inside the surcharge window, the hook lowers the builder rate when needed so that the swap's total stays at most 99 %; it never lowers the surcharge. BuilderFee.feeBps is the rate applied. A code with builder = the zero address and feeBps 0 is a tag alone. The tag is declarative: the hook only copies it.
  • LP fee is 0. The PoolManager's Swap.fee is Uniswap's own fee field; Otomate's rates are not in it.
  • Where fees go. Quote fees accrue in the hook as PoolManager claims. Anyone calls distributor.distribute(poolIds), which calls the hook's release (also open to anyone). The hook sends what each share is owed to the distributor, burns the pending tokens and emits Released(bytes32 indexed poolId, uint256[5] amounts, uint256 burned, uint256 releasedOrdinary, uint256 releasedSurcharge). amounts follow the shares' order; the treasury amount includes the surcharge. releasedOrdinary and releasedSurcharge are the pool's fees released so far, cumulative. When the PoolManager refuses the transfer, the hook emits ReleaseFailed(bytes32 indexed poolId, address indexed quote, uint256 amount) and the amounts stay owed for the next release. The distributor then pays each share (Accounted, Paid, PaymentFailed).
  • Builder fees stay in the hook, in each builder's account per currency (hook.builderFees(builder, currency)), apart from the pool's shares. distribute does not move them. The builder claims them: BuilderFeesClaimed(address indexed builder, address indexed currency, address indexed recipient, uint256 amount). ClaimForBlockedSet(address indexed builder, bool blocked) records whether a builder refuses claims that others send for it (claimBuilderFeesFor).

The API's token sheet gives the same rates as fees: buyBps, sellBps, quoteFeeBps and burnBps for the rates that apply now, phase (curve or graduated), both pairs (curve and afterGraduation), shares, and surcharge (with its currentBps and activeUntil).

Metadata and token profile

Base metadata. LaunchCreated.metadataURI, also in factory.tokenInfo(token).metadataURI. Launches prepared through Otomate's point of upload use ipfs:// raw CIDs. The factory only bounds a URI's length (128 bytes, MAX_URI_BYTES), so handle other schemes as you do for any launchpad. The format, otomate.token-metadata.v1:

{
  "schema": "otomate.token-metadata.v1",
  "name": "Moon Cat",
  "symbol": "MCAT",
  "description": "A cat on the moon.",
  "image": "ipfs://bafkrei…",
  "banner": "ipfs://bafkrei…",
  "external_url": "https://mooncat.example",
  "website": "https://mooncat.example",
  "twitter": "https://x.com/mooncat",
  "telegram": "https://t.me/mooncat",
  "discord": "https://discord.gg/…"
}
  • name, symbol and image are always present; the other fields are optional. Ignore fields you do not know (an otomate object may follow).
  • name and symbol equal the token's on-chain name() and symbol(). On chain, a name has 1 to 64 bytes and a symbol 1 to 31 bytes (MAX_NAME_BYTES, MAX_SYMBOL_BYTES).
  • Through the point of upload, the logo is square (at most 512 px, PNG) and the banner is 1600 × 600.

Editable profile. The token's profile authority (the creator at first) may publish a profile: factory.setProfileURI(token, uri) emits TokenProfileUpdated(bytes32 indexed poolId, address indexed token, string uri). The format, otomate.token-profile.v1: description, banner, external_url, website, twitter, telegram, discord, farcaster. The latest profile gives the description, banner and links; an empty URI clears it. The authority moves in two steps: ProfileAuthorityProposed(bytes32 indexed poolId, address indexed token, address indexed proposed, address current) (a proposed zero address cancels a pending proposal), then ProfileAuthorityChanged(bytes32 indexed poolId, address indexed token, address indexed next, address previous).

Name, symbol and logo never change. They come from the token and the base metadata. A profile cannot replace them.

Moderation. Otomate moderates what its own site and API show. The API marks each launch visible, pending or hidden; a hidden launch has null name and metadata there and is left out of the token list. It still exists on chain and trades. Providers apply their own policy.

Supply and burns

  • Minted once. The whole supply is minted at creation, to the hook (TokenDeployed.supply, and the token's Transfer from the zero address). The token cannot mint again.
  • Burns are Transfer to the zero address, and lower totalSupply():
    • the hook's release of swap burns (from = the hook), when anyone calls distribute or the hook's release;
    • the Forge, which burns tokens for sealed NFT mints (from = the Forge);
    • rounding dust at creation (from = the hook);
    • any holder calling burn.
  • Pending burn. Tokens taken by swaps and not yet burned wait as the hook's claims in the PoolManager: hook.pendingBurn(poolId). Nothing but a burn can take them out.
  • Circulating supply = initial supply − burned − pending burn, that is totalSupply() − pendingBurn(poolId). Otomate's API uses this figure for market cap; FDV uses the initial supply.

Indexing checklist

  1. Follow the config. Read LaunchHookSet from the config's deployment block. Keep the list of every hook it named.
  2. Subscribe to the hooks' logs only. One eth_getLogs filter on the hook addresses gives every pool (LaunchRegistered), trade (SwapFee, and BuilderFee for builder codes), latch (MintGateOpened, Graduated), release and builder claim. Add the factory for LaunchCreated, TokenProfileUpdated and the profile authority events.
  3. Pair nothing with the PoolManager. SwapFee is a complete trade; a BuilderFee joins it by poolId and seq. The PoolManager's Swap is only an optional cross-check.
  4. Read the snapshot once per pool, from LaunchRegistered or hook.snapshot(poolId).
  5. Check seq per pool. A gap means a missed log: read that range again.
  6. Keep several swaps of one pool in one transaction as separate trades.
  7. Order by block, then log index.
  8. Handle reorgs. Store block hashes; when a stored hash no longer matches, drop what you hold above the last common block and read again. Otomate's feed sends reorg markers for this.
  9. Mark finality. Keep records above the chain's finalized block as provisional. Otomate's feed sends final markers.
  10. Take graduation from the protocol (Graduated, graduated(poolId)), never from the price.
  11. Attribute traders with the rules in Trades from SwapFee. Never tx.from alone.
  12. Follow Upgraded on the factory, config, distributor and lens proxies. Pin each hook's address and code hash per chain.
  13. Exclude technical holders. Leave the PoolManager, every hook, the zero address and 0x000000000000000000000000000000000000dEaD out of holder counts. Otomate also leaves out the factory, the Forge, the distributor and the NFT vault.
  14. Count burns on the launch token only. On Robinhood Chain, WETH logs an unwrap as a Transfer to the zero address: that is WETH, not a launch burn.
  15. Keep amounts as integers: raw units, 18 decimals for tokens, the pair's own decimals for the pair. No floats.
  16. Find NFT collections only through LaunchCreated.collection or factory.collectionOf(poolId), never through beacon events.

History: earlier Otomate tokens

  • Earlier Otomate tokens are a different family. They use bonding curve contracts (launchers 035 to 105), a fee witness, and after graduation Uniswap v4 pools on the graduated pool hook 0x7353B76Ce326A3401162eDB1CD802f88e5EB60Cc on Ink, plus relay pools on 0x92fdd38CdAc2345A689B2e0382047BF458152888. Robinhood Chain has its own contracts for that family. Index them with the v1 guide: Build with Otomate and Trade Otomate tokens from a bot.
  • Launches described on this page start at the deployment of the launch contracts. There is no earlier history to backfill: start at the config's deployment block, published with the addresses.
  • A token belongs to one family. Tell them apart by the creating contract: LaunchRegistered on a hook the config named means this family.

Otomate's own data

You can skip running an indexer, or check yours against Otomate's. Every surface is free to start: no key is needed, and a key signed by a wallet raises the limits (Free to start).

SurfaceWhereNotes
Public API v1/api/v1/{chain}/… on each chain's API origin (Chains and contracts)Launch lists, token sheet, trades, candles, holders, creators, wallets, quotes. API v1
OpenAPI/api/v1/openapi.json, and openapi.v1.jsonThe machine-readable reference
Real-time feed/api/v1/{chain}/feed (SSE), /feed/ws (WebSocket), /feed/events (pages)Topics launch, trade, graduation, mint_gate; resumable by cursor; reorg and final markers. Real-time feed
Token list/api/v1/{chain}/tokenlist.jsontokenlists.org schema. Visible launches only, tagged bonding or graduated, with quote, hook, tickSpacing and launchedAt in extensions
Status/api/v1/{chain}/statusThe indexer's latest and finalized blocks, and the feed's health

Amounts are integers as decimal strings (raw), often with their exact decimal value. Every record says whether it is mined or final.

Coming from pump.fun

If you built on pump.fun, directly or through PumpPortal, most of what you know maps one to one. The main difference: an Otomate launch never migrates. Its bonding curve is a position in its own Uniswap v4 pool, and graduation happens in that same pool.

Otomate runs on Ink (chain ID 57073) and Robinhood Chain (chain ID 4663), both EVM chains. Addresses are 0x…, amounts are integers in each token's smallest unit, and you sign with an EVM wallet.

pump.fun concepts on Otomate

On pump.fun or PumpPortalOn OtomateNotes
Bonding curveThe launch pool's bonding position: hook-owned Uniswap v4 liquidity from the launch price to the graduation priceTrade it with any v4 router; quote it with the chain's V4 Quoter
Bonding curve progressprogressBps in the API and the feed, bondingProgressBps(poolId) on the hookThe pair held by the bonding position over the pool's threshold
Graduation, then migration to a PumpSwap pool (Raydium in earlier years)A one-way latch in the same pool (Graduated event)No migration, no new pool, no LP tokens. The market position continues above the graduation price.
subscribeMigrationFeed topic graduation—
Creator fees, collected from pump.fun's programsThe creator share of each pool's quote fee, paid by the distributor to the creator's payout addressAnyone calls distribute(poolIds): there is nothing to claim. A creator may also choose to burn part of the share in token at each trade.
Metadata upload to IPFS (pump.fun's former upload endpoint, or your own pinning as PumpPortal now documents)Otomate's point of upload: POST /api/v1/{chain}/metadataJSON body; images as data:image/…;base64, or an https URL; optional banner. Answers the metadataURI. Or pin otomate.token-metadata.v1 yourself.
tokenMetadata {name, symbol, uri}name, symbol, metadataURI in the factory callAll three appear in the factory's LaunchCreated event
action: "create"The SDK's createToken, POST /api/v1/{chain}/create-transaction, or the factory directlyAnyone can create, a bot included. The creation fee is paid in ETH.
Local Transaction API (POST https://pumpportal.fun/api/trade-local)POST /api/v1/{chain}/swap-transaction, or the SDK's buy and sellAnswers the approvals and the transaction, unsigned. You sign.
Lightning Transaction API (sends for you)No equivalentOtomate never holds your keys
subscribeNewTokenFeed topic launch—
subscribeTokenTrade with keysFeed topic trade with tokens=0x…,0x…At most 100 tokens per connection
subscribeAccountTradeNo wallet filter on the feedFilter trade events on trader, or read GET /api/v1/{chain}/wallets/{address}
wss://pumpportal.fun/api/dataGET /api/v1/{chain}/feed (SSE) or /api/v1/{chain}/feed/ws (WebSocket)Filters go in the URL. Resumable by cursor, with reorg and final markers.
Mint addressToken address (ERC-20, 18 decimals)Known before creation: predictToken. The same creator, salt, name, symbol and pair give the same address on both chains when both create with the same hook and give the pair the same token supply.
Mint keypairA salt (32 bytes)No extra signer
SOLWETH, or another pair the config enables (OTO, a stock token: Chains and contracts)ETH works on WETH pairs: the router wraps and unwraps it
denominatedInSol, amountamountIn, always the inputExact input: the pair amount on a buy, the token amount on a sell
amount: "100%" on a sellRead balanceOf and pass it—
slippage (percent)slippageBps (basis points)100 bps = 1 %
priorityFeeNoneOrdinary L2 gas, set by your wallet
pool: pump, pump-amm, raydium, auto…NoneOne pool per token, the launch pool, before and after graduation
Dev buy (amount with create)initialBuy, in the creation transactionExempt from the launch surcharge that applies to other buys in the first seconds
One chainTwo chains, one API origin eachPaths /api/v1/ink/… and /api/v1/robinhood/… (Chains and contracts)

Rates are numbers in each pool's snapshot: a quote fee in the pair on every buy and sell, and a burn in the token when the creator chose one. Read them per pool (How a swap is charged).

Create and buy side by side

pump.fun through PumpPortal's Local Transaction API (Solana, simplified):

const mint = Keypair.generate();
const uri = await pinMetadata({ name, symbol, description, image });          // your own IPFS pinning
const res = await fetch('https://pumpportal.fun/api/trade-local', {
  method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ publicKey: wallet.publicKey.toBase58(), action: 'create', tokenMetadata: { name, symbol, uri },
    mint: mint.publicKey.toBase58(), denominatedInSol: 'true', amount: 0.1, slippage: 10, priorityFee: 0.0005, pool: 'pump' }),
});
// Deserialize the answer, sign it with the mint keypair and your wallet, send it.
// A later buy: the same endpoint with action 'buy', the mint, an amount, slippage and a priority fee.

Otomate with the SDK (@otomate/sdk, TypeScript and viem):

import { createOtomate } from '@otomate/sdk';
import { readFile } from 'node:fs/promises';

const otomate = createOtomate({ chain: 'ink', walletClient });   // the SDK carries the launch contracts, as deployed
const created = await otomate.createToken(
  { name: 'Moon Cat', symbol: 'MCAT', description: 'A cat on the moon.', image: await readFile('logo.png') },
  { quote: otomate.chain.weth, creatorBps: 60, initialBuy: 10n ** 17n },         // dev buy: 0.1 ETH, same transaction
);
await otomate.buy(created.token, 5n * 10n ** 16n);                             // 0.05 ETH on a WETH pair
await otomate.sell(created.token, created.tokensOut / 2n);                    // half of the dev buy

createToken uploads the metadata, asks the API to build the factory call, checks that it goes to the official factory, signs and waits for the receipt. buy and sell quote with the V4 Quoter, send the approvals they need (each for exactly the amount) and trade through Otomate's router, then return the hook's SwapFee record. creatorBps must lie within the config's bounds (Create tokens).

Otomate from any language, the PumpPortal trade-local way:

curl -s -X POST "https://api.otomate.fun/api/v1/ink/swap-transaction" \
  -H 'content-type: application/json' \
  -d '{"token":"0x…","side":"buy","amountIn":"50000000000000000","account":"0xYourWallet","slippageBps":100,"nativeEth":true}'

The answer holds approvals (send them first, in order; empty for ETH in), transaction (to, data, value) and the quote with every fee. Sign and send them from account.

Stream launches and trades like PumpPortal

PumpPortal subscribes by message on one WebSocket:

const ws = new WebSocket('wss://pumpportal.fun/api/data');
ws.onopen = () => {
  ws.send(JSON.stringify({ method: 'subscribeNewToken' }));
  ws.send(JSON.stringify({ method: 'subscribeTokenTrade', keys: [mint] }));
};

Otomate's feed takes its filter in the URL, and resumes where you left off:

const feed = otomate.feed({ topics: ['launch', 'trade', 'graduation'] })
  .on('launch', e => console.log('new', e.token, e.data.name, e.data.symbol))
  .on('trade', e => console.log(e.data.side, e.token, e.data.quoteRaw, e.data.trader))
  .on('graduation', e => console.log('graduated', e.token))
  .on('reorg', m => dropAbove(BigInt(m.afterBlock)))                          // drop what you stored above this block
  .start();
// Store feed.cursor; pass { cursor } to otomate.feed() to resume after a restart.

The same feed without the SDK: wss://api.otomate.fun/api/v1/ink/feed/ws?topics=launch,trade. Every event says whether its block is mined or final (Real-time feed).

What works differently

  • No migration. Graduation is a latch in the same pool. There are no LP tokens to burn, and the hook has no function that removes its liquidity.
  • Fees in the pair, a burn if the creator chose one. Each swap pays a quote fee in the pair; when the creator chose to burn part of their share, a burn in the token too, and the burned tokens leave the supply. The V4 Quoter's answer includes everything.
  • Launch surcharge. Buys in the first seconds pay an extra quote fee that decreases quickly; the creator's initial buy pays none.
  • Builder codes. A front end, bot or terminal can earn a fee on the trades it sends (Builder codes).
  • Reorgs and finality. Every API record and feed event is mined or final; the feed tells you when to drop events after a reorg.
  • Moderation. Otomate's site and API hide launches its moderation refuses. The chain does not: a hidden launch still trades.

AI agents

An AI agent can do everything on Otomate a bot can: find launches, read a token, quote, trade, create tokens, earn builder fees, and pay for premium API requests. It can also prepare a trade that its user signs on otomate.fun with their own wallet. Three tools make it easy.

ToolWhat it gives the agent
The MCP serverReady-made tools for Claude Code, Cursor and any MCP client
llms.txt and llms-full.txtThese documents as plain text, to read in one request
The prompt templateA starting prompt to build a launchpad front end in one go

The MCP server

otomate-mcp reads and builds; it never signs and never holds a key. The user signs every transaction in their own wallet.

ToolDoes
list_launchesLists launches (new, graduating, graduated, trending, gainers), with filters and search
get_token, get_trades, get_candles, get_holdersReads one launch
get_creator, get_walletA creator's track record; a wallet's positions and what it can claim
follow_launchesReads the real-time feed from a cursor (call it again with the cursor it returns)
quoteQuotes a buy or a sell, with every fee
build_swapBuilds a swap ready to sign (Ink or Robinhood Chain), with your builder code if set
upload_metadata, build_createUploads a token's metadata and builds its creation, ready to sign
builder_codeEncodes a builder code (address, rate, tag) and builds its claims and the claim-for opt-out
collection_artworkReads whether a collection's art is final (the creator chooses it on the Otomate site)
prepare_tradePrepares a trade the user signs on otomate.fun: a signing link and a summary (Ink or Robinhood Chain, v2 launches and OTO)
trade_statusFollows a trade: by its transaction hash, or by wallet and token

Before it returns a swap, build_swap checks what the API built. It rebuilds the call locally and compares it byte for byte; its to must be Otomate's router (whose code it reads on chain) or the chain's Universal Router. It also compares the API's quote with the chain's own quote of the same trade: the V4 Quoter at the latest block, through OTOMATE_RPC_URL_<CHAIN> or else the chain's public RPC.

  • An API quote more than 1 % above the chain's is refused with QUOTE_ABOVE_ONCHAIN, which gives both amounts and the gap. Nothing is returned to sign: build the swap again for a fresh quote.
  • If the chain's quote cannot be read (the node fails, or the Quoter reverts), the swap is refused with RPC_ERROR, never built on the API's quote alone.
  • The minimum out sent is the stricter of the API's and the one from the chain's quote. When it is the chain's (onchainQuote.minimumOutFrom is onchain), quote.amountOut and quote.executionPrice are the chain's too: show those with minimumOut. An API quote more than 1 % below the chain's also adds a warning.

Configuration, all optional: OTOMATE_CHAIN (ink by default), OTOMATE_API_KEY, OTOMATE_BUILDER, OTOMATE_BUILDER_FEE_BPS and OTOMATE_BUILDER_TAG, OTOMATE_RPC_URL_INK, OTOMATE_RPC_URL_ROBINHOOD, and OTOMATE_SITE_URL (the site its signing links open; https://www.otomate.fun by default).

Once the package is published:

claude mcp add otomate -- npx -y @otomate/mcp-server
{ "mcpServers": { "otomate": { "command": "npx", "args": ["-y", "@otomate/mcp-server"], "env": { "OTOMATE_CHAIN": "ink" } } } }

(the second block goes in Cursor's .cursor/mcp.json).

Trade from your assistant

With prepare_trade, any assistant connected to the MCP server (Claude, ChatGPT, Grok…) prepares a trade that its user signs on otomate.fun, in their own wallet. The assistant never signs and Otomate holds no key.

  1. The assistant calls prepare_trade with the token, the side, the exact amount in, what pays (pay: ETH, the pair token, or for OTO a stablecoin), the slippage and, if it knows it, the user's wallet.
  2. It shows the user the summary and the signingUrl, a link to https://www.otomate.fun/sign.
  3. The user opens the link, connects their wallet as on the site, reviews the trade and signs the exact approval, if one is needed, then the swap.
  4. The page shows the transaction hash while it confirms, then the outcome; the assistant follows the trade with trade_status, by that hash, the wallet and the trade it prepared.

The link carries the intent only: the chain, the token, the side, the amount, the payment, the slippage and the expected wallet. It never carries a transaction. The page builds the trade itself with the site's own ticket: a fresh quote from the chain, the exact approval, the deadline, the minimum and the checks of the pool on chain. A link changed on its way can only change what the page shows the user before they sign, and the page refuses a link with a field it does not use, a field given twice or a value out of its format.

The page also flags what is unusual: a slippage above 1 %, a wallet other than the one the link names (no ticket until the user switches accounts or chooses to trade with the wallet connected; nothing is ever sent from another wallet), and a launch named like $OTO, ETH or a stablecoin, look-alike spellings included (Otomate's own $OTO is marked as such); and it stops on a token Otomate does not list or hides. On OTO's ticket, a price impact above 15 % waits for the user to accept it on the page before anything is signed. The page never opens inside another site's frame. prepare_trade gives no link for a token the page stops on, nor for a trade the chain refuses; its estimate is the chain's own quote, and the page quotes again before anything is signed. prepare_trade also gives OTO's price impact, measured on its pools. trade_status says a trade is done only when the chain proves it (the wallet sent it through a known router, and the token asked about moved against ETH or a known token, on the side and for the amount prepared), never without the wallet and that token, and it says what was traded: a smart account's operation that failed inside its bundle is said to have failed. Signing links cover the v2 launches and OTO on Ink and Robinhood Chain.

Documents for models

Build a launchpad in one prompt

Give this to your coding assistant, with your values filled in (launchpad-prompt.md):

Build a token launchpad web app on Otomate with the @otomate/sdk package (docs: https://www.otomate.fun/llms-full.txt).
Brand: <name>, colors <primary> / <background>, logo <file>.
Chains: Ink (and Robinhood Chain).
Builder code: <my address>, <fee bps, at most 200> on every trade the app sends.
Pages: Explore (new, graduating, graduated, trending, gainers; live new launches from the feed), a token page (price chart from
candles, live trades, holders, buy and sell ticket), Create (name, symbol, description, links, logo, banner, pair, my share,
first buy; createToken), a creator page. Connect the user's injected wallet; never handle private keys.
Show fee rates as numbers from the API. Start from the starter launchpad if you can.

The starter launchpad already does all of this: copy it and change one configuration file.

Agents that pay their own way

  • x402. Where enabled, an API request above the budget answers 402 Payment Required with its price and terms (USDC on Base). An agent with an x402 client pays and retries automatically, without an account or a key; it pays only for answers that succeed.
  • Approvals without a separate transaction. Agents trade launch tokens through Otomate's router with one approval for exactly the trade, which permit (EIP-2612: the holder signs an allowance off chain, anyone sends it) can replace, or through the Universal Router with Permit2. permit takes 65-byte signatures only: EOAs, EIP-7702 EOAs, and ERC-1271 wallets whose signature is 65 bytes. Other smart wallets (multi-owner Safes, passkey wallets) use an approval or Permit2. Launch tokens do not implement EIP-3009. The SDK has signPermit and permitCall.
  • Wallets. An agent should trade from a wallet its user controls and limits, with a spending cap; never paste a private key into a prompt. Without a wallet of its own, an assistant gives its user a signing link (prepare_trade): the user signs in their own wallet.

Good practice for agents

  • Quote right before trading, and pass a minimum (slippageBps).
  • Wait out the launch protection on new launches (see Before you trade).
  • Check that a creation's to is the official factory and a swap's to Otomate's router or the chain's Universal Router before signing. The MCP server's build_swap checks it, with the router's code on chain and the API's quote against the chain's (see The MCP server).
  • Read rates from each pool; never assume them.

Status and security

Status

otomate.fun/status shows, for each chain, how far the indexer is (latest and finalized blocks and their age) and whether the real-time feed is serving. Machines read the same from GET /api/v1/{chain}/status.

Report a vulnerability

Open a ticket on the Otomate Discord, the same contact as in /.well-known/security.txt. Please include the chain, the contract or endpoint, and the steps to reproduce. Do not post the details in public channels, and do not test on mainnet with other people's funds.

Otomate never sends a direct message first, and support never asks for a seed phrase, a private key or a signature. Anyone who does so while claiming to be Otomate support is a scammer.

Audit

The audit report of the launch pool contracts is published here once the audit is complete.

What the contracts do

These are facts of the code, for anyone assessing a launch. The addresses are in Chains and contracts.

What nobody can modify, Otomate included

  • The token cannot be upgraded. It is a plain ERC-20 with 18 decimals, burns and permit (EIP-2612). permit takes 65-byte signatures only: an ECDSA signature of the owner (an EOA, or an EIP-7702 EOA), or, when the owner is a contract wallet, its ERC-1271 approval of the same 65 bytes. Other smart wallets (multi-owner Safes, passkey wallets) use an approval or Permit2. The token does not implement EIP-3009. It has no owner, no pause, no blocklist and no mint after creation.
  • The whole supply goes into the pool at creation: nothing is held back.
  • The hook cannot be upgraded. It owns the pool's two positions and has no function that removes their liquidity. It has no recovery or admin withdrawal function: fees leave it only through release (to the distributor; anyone may call it) and through builder claims.
  • Each pool's rates (the curve's rates, the rates after graduation and the launch surcharge) are set at creation for this pool: the hook stores them in the pool's snapshot and has no function that writes them afterwards, and no other contract or key can reach them. The launch surcharge applies to buys only, for at most 3,600 seconds after creation, and decreases over that time.
  • Builder fees are chosen by the front end or bot that sends a trade, not by Otomate: at most 2 %, paid by the trader on top of the pool's fees. The hook keeps them for that builder; only the builder claims them, or anyone pays them to the builder itself when the builder allows it. See Builder codes.
  • Creating is open to anyone. The creation fee is paid in the chain's native currency, the exact amount is written in the call, and the factory sends it to Otomate's treasury in the same transaction.

What Otomate's Safe sets, within bounds

Otomate's Safe (2 of 4 signatures) sets these values directly; each change emits an event that anyone can watch.

  • Launch settings, for new pools only (config): the pairs and their presets (supply, curve, the minimum and maximum initial buy as amounts in the pair), the fee rows a new pool copies into its snapshot, the creation fees (at most 0.05 ETH, 5 HYPE on HyperEVM, or 0.2 BNB on BNB Smart Chain), the hook new pools use, and the roles (guardian, operator, treasury, NFT manager). A pool already created keeps its snapshot.
  • Automatic art delay: 1 to 30 days (automaticArtworkDelay() on the config; 3 days by default).
  • Never-graduated delays (NFT vault): 7 to 365 days while no NFT of the collection exists, 90 to 1,825 days once one does (30 and 365 days by default).
  • Payout paths (distributor): the routes, sinks and per-asset caps of OTO burns, royalty burns and global forwarding, never a rate. The Safe can stop those three; it cannot stop distribute or the creator share.
  • Forge: a pause of new sealed mints and of openings; trading is not affected.
  • No trading pause. Nothing in Otomate's contracts can stop trading on a pool, before or after graduation: the hook has no pause and no owner. The Safe can only stop new launches (setCreationEnabled). A pair's own issuer is another matter: the issuers of the stock pairs (Chains and contracts) can pause their tokens or blocklist addresses, which would stop trading on the pools of that pair.

What can receive new code, through the timelock

  • Upgradeable parts: the proxies (config, factory, distributor, NFT vault, gate, Forge, lens) and the collection template (one beacon serves every collection). They receive new code only through Otomate's upgrade timelock, which the Safe operates; each upgrade emits events that anyone can watch. A template upgrade applies to every collection at once; a distributor upgrade applies to how the fees released from then on are delivered. No upgrade reaches the hook or the token: an existing pool's rates, liquidity and graduation stay as they are.
  • The timelock's delay is 0 at launch; Otomate plans to raise it to 24 to 48 hours once the system is stable. Only the timelock itself can raise it (a scheduled operation, visible on chain).

Collections

  • Rare NFT draws. When NFTs are revealed, Otomate's operator provides the draw, with a commitment published before it. The commitment proves the attribution was not altered afterwards; it is not independent randomness.
  • A collection's art. The creator chooses it after the launch: three designs, then one confirmation from the creator payout wallet. The NFT mint opens once it is chosen. Automatic art is not available for now.
  • A launch that never graduates. Its collection's waiting NFT share can be sent to Otomate's treasury by anyone, once the pool has not graduated neverGraduatedDelay() after its creation while no NFT of the collection exists, or neverGraduatedMintedDelay() once one does. Read both, and neverGraduatedSweep(collection), on the NFT vault. Graduation closes this for good.
  • Creator earnings are delivered by the distributor; the amounts owed are public in the hook.

Stability of the API

See Versioning and stability.

Build with Otomate

Launch a token, buy it on its bonding curve, and sell it from your own wallet. Start with the shared TypeScript setup below, then follow the action you want to implement.

Start here

I want to…TutorialResult
Launch a tokenLaunching a tokenA confirmed transaction and the new token and curve addresses.
Buy a tokenBuying a tokenA quote, any required approval, and a confirmed purchase.
Sell a tokenSelling a tokenAn exact token allowance and a confirmed sale.
Buy during creationLaunch and buy in one transactionCreation and an initial purchase in the same transaction.
Discover tokensDiscover tokens on-chainFactory events and verified project records.
Trade from a botTrade Otomate tokens from a botCurve and graduated-pool trades with tested samples.
Integrate collectionsCollections, rewards, and indexingCollection lifecycle, claims and events.

These examples target Launcher105 on Ink (chain ID 57073) and Robinhood Chain (chain ID 4663). Use the contract addresses and integration ABI files below.

Addresses and ABI downloads

Use the proxy addresses below for calls and transactions. These addresses were read from Otomate's public launch configuration and their proxy/implementation code hashes were checked on Ink at block 56,408,226 on 20 September 2026, and on Robinhood Chain at block 78,455,837 on 2 October 2026. This verifies deployment identity; it is not a live transaction test of every example.

ContractRobinhood Chain address
Project factory0x28e47B1cDd5Ef181731Ff9fC18e517baEe54CBc6
Token launch contract0x7eB0458CA2292919c7a53D881fB9f0Aa96e479a9

Download the deployment identity record for Ink or Robinhood Chain. Resolve each project's token and curve from the factory; there is no single curve address for all tokens. The ABI files below are the same on both chains.

ContractJSON ABITypeScript ABI
Curve: quote, buy, sell and trade eventsJSONTypeScript
Factory: project reads and creation eventsJSONTypeScript
Creation: commitment, predictions and launch tuplesJSONTypeScript
Token launch contract: platform quote and launchJSONTypeScript
Launch configurationsJSONTypeScript
Quote assets and presetsJSONTypeScript
Market readiness and pool discoveryJSONTypeScript
Companion rewards and claimsJSONTypeScript

These are integration ABI subsets. They include launch-time fee inputs and fee reads needed to quote transactions, and transaction events. Token and curve addresses are resolved per project with factory.project(projectId).

Set up your integration

The examples use TypeScript and viem. Use the same actions with a browser wallet or a server signer for a bot. In your own integration project, install viem:

npm install viem

Download the TypeScript ABI files above into an abis/ directory in your integration project. Copy this setup and the action examples you need into your own module. No kit or Otomate repository checkout is required.

import {
  createPublicClient, createWalletClient, custom, http, erc20Abi,
  encodeAbiParameters, encodeFunctionData, getAbiItem, keccak256,
  stringToHex, toHex, parseUnits, parseEventLogs,
  type Address, type Hex, type EIP1193Provider,
  type ContractFunctionArgs, type WalletClient, type Account, parseAbi,
  BaseError, ContractFunctionRevertedError,
} from 'viem';
import { ink } from 'viem/chains';
import { LaunchConfigurationAbi105 as configurationAbi } from './abis/LaunchConfiguration';
import { LocalStockQuoteRegistryAbi105 as registryAbi } from './abis/LocalStockQuoteRegistry';
import { LauncherMarketCoordinator035Abi105 as marketAbi } from './abis/LauncherMarketCoordinator035';
import { LauncherProjectFactory037Abi105 as factoryAbi }
  from './abis/LauncherProjectFactory037';
import { ILauncherProjectFactory105Abi105 as creationAbi }
  from './abis/ILauncherProjectFactory105';
import { LauncherProjectEntitlement105Abi105 as entitlementAbi }
  from './abis/LauncherProjectEntitlement105';

const curveAbi = parseAbi([
  "function buyNativeWithFeePolicy103(uint256 minimum, uint256 deadline, address recipient, bytes32 expected, uint16 maximumOrdinaryBps) payable returns ((uint256 grossQuoteUsed, uint256 grossQuoteRefund, uint256 principalIn, uint256 feeAmount, uint256 tokensOut))",
  "function buyWithFeePolicy103(uint256 gross, uint256 minimum, uint256 deadline, address recipient, bytes32 expected, uint16 maximumOrdinaryBps) returns ((uint256 grossQuoteUsed, uint256 grossQuoteRefund, uint256 principalIn, uint256 feeAmount, uint256 tokensOut))",
  "function currentFeeWitness103() view returns (bytes32 hash, uint64 version, uint16 totalBps)",
  "function graduated() view returns (bool)",
  "function launchActive104() view returns (bool active)",
  "function quoteAsset() view returns (address)",
  "function quoteBuyFor(uint256 gross, address recipient) view returns ((uint256 grossQuoteUsed, uint256 grossQuoteRefund, uint256 principalIn, uint256 feeAmount, uint256 tokensOut))",
  "function quoteSell(uint256 amount) view returns ((uint256 tokensIn, uint256 grossQuoteOut, uint256 feeAmount, uint256 netQuoteOut))",
  "function sellNativeWithFeePolicy103(uint256 amount, uint256 minimum, uint256 deadline, address recipient, bytes32 expected, uint16 maximumOrdinaryBps) returns ((uint256 tokensIn, uint256 grossQuoteOut, uint256 feeAmount, uint256 netQuoteOut))",
  "function sellWithFeePolicy103(uint256 amount, uint256 minimum, uint256 deadline, address recipient, bytes32 expected, uint16 maximumOrdinaryBps) returns ((uint256 tokensIn, uint256 grossQuoteOut, uint256 feeAmount, uint256 netQuoteOut))",
  "function token() view returns (address)",
  "event TokensBought(address indexed payer, address indexed recipient, uint256 grossQuoteUsed, uint256 feeAmount, uint256 tokensOut, uint256 realQuoteReserve, uint256 tokenReserve)",
  "event TokensSold(address indexed seller, address indexed recipient, uint256 tokensIn, uint256 grossQuoteOut, uint256 feeAmount, uint256 netQuoteOut, uint256 realQuoteReserve, uint256 tokenReserve)"
]);

// reserve must atomically insert a unique key and commit before returning true.
// Keep failed/uncertain attempts until their transaction has been reconciled.
export type AttemptStore = {
  reserve(key: string, transaction: { to: Address; data: Hex; value: string }): Promise<boolean>;
  submitted(key: string, hash: Hex): Promise<void>;
  confirmed(key: string, hash: Hex): Promise<void>;
};
export type Context = {
  client: ReturnType<typeof createPublicClient<ReturnType<typeof http>, typeof ink>>;
  wallet: WalletClient;
  account: Address;
  signer: Account | Address;
  attempts: AttemptStore;
};
export async function connectOtomate(provider: EIP1193Provider, rpcUrl: string,
  attempts: AttemptStore): Promise<Context> {
  const client = createPublicClient({ chain: ink, transport: http(rpcUrl) });
  const wallet = createWalletClient({ chain: ink, transport: custom(provider) });
  await wallet.switchChain({ id: ink.id });
  const [account] = await wallet.requestAddresses();
  if (!account || await client.getChainId() !== ink.id) throw new Error(`Connect to ${ink.name}`);
  return { client, wallet, account, signer: account, attempts };
}
export async function connectBot(signer: Account, rpcUrl: string,
  attempts: AttemptStore): Promise<Context> {
  const client = createPublicClient({ chain: ink, transport: http(rpcUrl) });
  if (await client.getChainId() !== ink.id) throw new Error('Wrong RPC chain');
  const wallet = createWalletClient({ account: signer, chain: ink, transport: http(rpcUrl) });
  return { client, wallet, account: signer.address, signer, attempts };
}
type Transaction = { to: Address; data: Hex; value: bigint };

// A persistent attempt ID prevents this example from blindly sending twice.
// Keep the same ID when inspecting or recovering the same attempted action.
async function sendOnce(ctx: Context, attemptId: string, tx: Transaction) {
  const key = `otomate:${ink.id}:${ctx.account}:${attemptId}`;
  const [selected] = await ctx.wallet.getAddresses();
  if (selected?.toLowerCase() !== ctx.account.toLowerCase()
      || await ctx.wallet.getChainId() !== ink.id
      || await ctx.client.getChainId() !== ink.id) {
    throw new Error('Wallet or network changed');
  }
  await ctx.client.call({ ...tx, account: ctx.account });
  const estimate = await ctx.client.estimateGas({ ...tx, account: ctx.account });
  const gas = (estimate * 120n + 99n) / 100n;
  const block = await ctx.client.getBlock();
  if (gas > 10_000_000n || gas > block.gasLimit) {
    throw new Error('Transaction exceeds the normal gas budget');
  }
  if (!await ctx.attempts.reserve(key, {
    to: tx.to, data: tx.data, value: tx.value.toString(),
  })) throw new Error('Reconcile the existing attempt');
  const hash = await ctx.wallet.sendTransaction({ ...tx, account: ctx.signer, chain: ink, gas });
  await ctx.attempts.submitted(key, hash);
  const receipt = await ctx.client.waitForTransactionReceipt({ hash, confirmations: 2 });
  if (receipt.status !== 'success') throw new Error(`Transaction reverted: ${hash}`);
  const canonical = await ctx.client.getBlock({ blockNumber: receipt.blockNumber });
  if (canonical.hash !== receipt.blockHash) throw new Error('Receipt was reorganized');
  await ctx.attempts.confirmed(key, hash);
  return receipt;
}

async function approveExact(ctx: Context, token: Address, spender: Address,
  amount: bigint, attemptId: string) {
  const allowance = await ctx.client.readContract({ address: token,
    abi: erc20Abi, functionName: 'allowance', args: [ctx.account, spender] });
  if (allowance >= amount) return;
  // Supports admitted tokens that require clearing a nonzero allowance first.
  if (allowance > 0n) await sendOnce(ctx, `${attemptId}:reset`, {
    to: token, data: encodeFunctionData({ abi: erc20Abi,
      functionName: 'approve', args: [spender, 0n] }), value: 0n,
  });
  await sendOnce(ctx, `${attemptId}:approve`, {
    to: token, data: encodeFunctionData({ abi: erc20Abi,
      functionName: 'approve', args: [spender, amount] }), value: 0n,
  });
}

function minimumAfterSlippage(output: bigint, slippageBps: bigint) {
  if (slippageBps < 0n || slippageBps >= 10_000n) throw new Error('Invalid slippage');
  const minimum = output * (10_000n - slippageBps) / 10_000n;
  if (minimum <= 0n) throw new Error('Output is too small');
  return minimum;
}

Call connectOtomate from a user-initiated Connect button with the wallet's EIP-1193 provider, your RPC URL and your attempt store. If the chain is not configured in the wallet, add it through your wallet integration first. Each action below receives the returned context.

On Robinhood Chain, import robinhood from viem/chains and use it wherever this setup uses ink.

For a bot, call connectBot(signer, rpcUrl, attempts) with a viem Account backed by your server's signing system, then pass that context to launchToken, buyToken or sellToken. Keep signing credentials on the server. Serialize submissions per signing wallet to avoid nonce conflicts.

Implement AttemptStore using your durable storage: a unique key must reserve each action atomically before sending. Persist the action ID before a Telegram command is processed, including across retries. If sending or saving the hash fails, reconcile the wallet nonce and transaction before another send. Two confirmations are this example's threshold, not a universal finality guarantee.

Fill in your configuration

InputWhere it comes from
RPC URLYour configured provider for chain 57073 (Ink) or 4663 (Robinhood Chain).
Factory and entitlementThe proxy address table above for your chain.
Token and curvefactory.project(projectId) for the selected project.
Quote assetcurve.quoteAsset(); use its own ERC-20 decimals.
Native/WETH eligibilityThe deployment's verified wrapped-native asset; never assume any ERC-20 pair accepts native ETH.
Recipe ID, hash and presetRead available launch configurations.
Metadata and economicsThe metadata URIs you supply and the fee configuration accepted by the selected recipe.

Match runtime code, proxy implementations where applicable, chain and ABI to that registry before calling the examples. A matching method name alone does not establish compatibility. Token symbols are not identifiers.

Handle amounts and wallet changes

Use parseUnits('0.01', decimals) and bigint, never floating point. 100n basis points means 1% slippage. The examples calculate a five-minute deadline from the latest block's timestamp.

Display the input, estimated output, fees, refund if any, recipient and minimum before invoking a send. The functions below perform a fresh quote immediately before submission; an application should present that quote for approval and restart the review if the wallet, amount, recipient or network changes.

Generation105 retains some method names ending in 103. Use those exact selectors. The creation domain is OTOMATE_LAUNCHER_PROJECT_CREATE_105_V2; the old V1 payload is incompatible.

Launching a token

Create a token through the entitlement contract. It collects the platform payment and calls the factory to build the project. Use payAndCreate for creation alone, or payAndCreateAndBuy to include your initial purchase.

Flow: prepare metadata → select an approved recipe/preset → configure creator allocations → build the request → read the platform fee and admission state → encode → simulate → sign → extract ProjectCreated → read the new project.

Reuse the imports and submission helpers from Set up your integration. The example calls the contracts directly for both launch paths.

Prepare your launch inputs

FieldWhat to supply
factory, entitlementVerified addresses for the same admitted deployment.
recipeHashThe reviewed hash of the selected recipe. The function compares it with the factory.
intent.nonceA new 32-byte nonce for this project; persist it before preparation and reuse it for recovery.
intent.recipeApproved recipe ID as bigint.
intent.quoteApproved quote-asset contract. For a native-funded launch this is the configured wrapped-native asset, not the zero address.
intent.preset, intent.presetHashThe selected asset preset and its reviewed hash.
intent.mode0 for token-only, 1 for token with Companions; this is not the economics creationMode.
intent.name, intent.symbolYour token's name and symbol, matching the prepared metadata.
metadataURIYour token metadata URI, satisfying the selected contract’s URI requirements.
hiddenURI, finalDirectionCommitmentThe prepared sealed-art metadata and direction commitment required by the selected collection mode. Do not substitute arbitrary placeholders.
economicsThe complete creation fee configuration described below.
maximumPlatformFeeMaximum native platform payment you accept, in wei; not the trading fee.

Generate a nonce once with toHex(crypto.getRandomValues(new Uint8Array(32))). Persist it with the chosen recipe, name, symbol, metadata and fee settings before launching. The tutorial reads the current factory revision and sets a five-minute transaction deadline.

For example, the editable token identity can be name: 'Example Token' and symbol: 'EXAMPLE'. Recipe IDs, presets, hashes, metadata commitments and contract addresses must match the selected deployment and the metadata you supply, not an example copied from another project.

Read available launch configurations

Resolve the market and quote registry from the factory. Recipe IDs come from RecipeAdded events; they are not preset IDs. Scan from the factory's deployment block, or resume from your saved cursor, in bounded ranges. Use a confirmed toBlock and reconcile reorgs before persisting results.

export async function readLaunchConfigurations(ctx: Context, factory: Address,
  fromBlock: bigint, toBlock: bigint) {
  if (fromBlock < 0n || toBlock < fromBlock) throw new Error('Invalid block range');
  const infrastructure = await ctx.client.readContract({ address: factory,
    abi: factoryAbi, functionName: 'infrastructure', blockNumber: toBlock });
  const registry = await ctx.client.readContract({ address: infrastructure.market,
    abi: marketAbi, functionName: 'stockRegistry', blockNumber: toBlock });
  const recipes = new Map<bigint, Hex>();
  for (let from = fromBlock; from <= toBlock; from += 1_000n) {
    const end = from + 999n < toBlock ? from + 999n : toBlock;
    const logs = await ctx.client.getContractEvents({ address: factory,
      abi: factoryAbi, eventName: 'RecipeAdded', fromBlock: from, toBlock: end,
      strict: true });
    for (const log of logs) recipes.set(log.args.version, log.args.recipeHash);
  }
  const availableRecipes = [];
  for (const [id, expectedHash] of recipes) {
    const enabled = await ctx.client.readContract({ address: factory,
      abi: factoryAbi, functionName: 'recipeEnabled', args: [id], blockNumber: toBlock });
    if (!enabled) continue;
    const hash = await ctx.client.readContract({ address: factory,
      abi: factoryAbi, functionName: 'recipeHash', args: [id], blockNumber: toBlock });
    if (hash !== expectedHash) throw new Error('Recipe identity mismatch');
    const config = await ctx.client.readContract({ address: factory,
      abi: configurationAbi, functionName: 'recipeConfig103', args: [id], blockNumber: toBlock });
    availableRecipes.push({ id, hash, config });
  }
  const count = await ctx.client.readContract({ address: registry,
    abi: registryAbi, functionName: 'presetCount', blockNumber: toBlock });
  const block = await ctx.client.getBlock({ blockNumber: toBlock });
  const presets = [];
  for (let id = 1n; id <= count; id++) {
    const hash = await ctx.client.readContract({ address: registry,
      abi: registryAbi, functionName: 'configHash', args: [id], blockNumber: toBlock });
    // A contract revert marks an unavailable preset. RPC failures must propagate.
    try {
      const [asset, economics] = await ctx.client.readContract({ address: registry,
        abi: registryAbi, functionName: 'validateCreation',
        args: [id, hash, block.timestamp + 300n], blockNumber: toBlock });
      presets.push({ id, hash, quote: asset.quote, asset, economics });
    } catch (error) {
      if (error instanceof BaseError && error.walk(e => e instanceof ContractFunctionRevertedError)
        instanceof ContractFunctionRevertedError) continue;
      throw error;
    }
  }
  const wrappedNative = await ctx.client.readContract({ address: registry,
    abi: registryAbi, functionName: 'weth', blockNumber: toBlock });
  return { market: infrastructure.market, registry, wrappedNative, recipes: availableRecipes, presets };
}

Import BaseError and ContractFunctionRevertedError from viem alongside the setup imports. Reads use one block so the list is internally consistent. Refresh the selected configuration immediately before signing; final launch simulation checks whether the chosen recipe, preset and mode can be combined.

Select a returned recipe and preset explicitly. Set intent.recipe = recipe.id, recipeHash = recipe.hash, intent.preset = preset.id, intent.presetHash = preset.hash, and intent.quote = preset.quote. Pass recipe.config.tokenTrading to tokenOnlyEconomics below. Companion launches use companionTrading and companionRoyalty instead. The selected preset supplies curve economics; the recipe supplies the launch fee allocations. Keep those two configurations separate.

Choose and keep your launch salt

The creation ABI calls the user-supplied value intent.nonce: a nonzero bytes32. This is your launch's unique seed, not your wallet's transaction nonce. There is no separate salt field to add to payAndCreate or the factory request.

Generate it once and save it before metadata preparation:

export function createLaunchNonce(): Hex {
  let nonce: Hex;
  do { nonce = toHex(crypto.getRandomValues(new Uint8Array(32))); }
  while (nonce === `0x${'0'.repeat(64)}`);
  return nonce;
}

Put that value in request.intent.nonce. The entitlement derives the project ID from the component domain, chain ID, entitlement address, owner and nonce. The deployment contracts derive their own CREATE2 salts from that project identity and each component's role.

Keep the same nonce throughout preparation, signing and recovery. Changing it creates a different project identity and invalidates receiver predictions tied to the original ID. It is not a replacement for checking an uncertain transaction.

For receiver addresses, first read entitlementId(owner, nonce), then call factory.predictedReceivers105(projectId, creatorCash, paired) with the selected project mode. Bind the returned receiver addresses into the prepared fee configuration before computing the commitment. Do not treat the creator-only fee-settings helper below as a complete deployment-ready receiver configuration.

Configure launch fees

The economics tuple contains trading, royalty, creatorExtraBps, creatorExtraBuybackBps, baseCreatorBuybackBps and creationMode. Both policies contain totalBps, six allocationBps, three creatorWeights, six recipients and companions.

  • allocationBps are fee rates assigned to each destination; their sum is the policy's totalBps.
  • creatorWeights split the creator allocation between wallet, project buyback and Companion rewards. The weights sum to 10,000.
  • creatorExtraBps is the additional creator trading rate. 100 basis points is 1%.
  • Buyback settings split a creator allocation; they do not add that percentage to the whole trade.
  • creationMode distinguishes token-only (0), Companions (1) and token-only with collection attachment configured (2). It is separate from the lifecycle mode above.

Use the recipe's approved rates and receiver configuration. Do not fill every recipient slot with an arbitrary wallet or invent the protocol allocations. Build the token-only configuration from the approved recipe's trading rates:

export function tokenOnlyEconomics(owner: Address, approvedTrading: {
  totalBps: number;
  allocationBps: readonly [number, number, number, number, number, number];
}): Economics {
  const recipients = [owner, owner, owner, owner, owner, owner] as const;
  return {
    trading: { ...approvedTrading, creatorWeights: [10_000, 0, 0],
      recipients, companions: false },
    royalty: { totalBps: 0, allocationBps: [0, 0, 0, 0, 0, 0],
      creatorWeights: [10_000, 0, 0], recipients, companions: false },
    creatorExtraBps: 0, creatorExtraBuybackBps: 0,
    baseCreatorBuybackBps: 0, creationMode: 0,
  };
}

This example keeps the recipe's creator trading allocation and adds no extra fee or buyback. launchToken below replaces the receiver placeholders with the contract's predicted addresses before calculating the commitment. Companion launches require their selected recipe's corresponding trading and royalty configurations.

Submit the launch

The following code builds the actual ABI payload, pays the platform fee, optionally funds a first buy, and returns the created project. It uses the domain-prefixed factory payload required by Launcher105 V2.

type CreateArgs = ContractFunctionArgs<typeof creationAbi, 'nonpayable', 'createReserved105'>;
type CreateRequest = CreateArgs[0];
type Economics = CreateArgs[1];
type LaunchInput = {
  factory: Address; entitlement: Address; recipeHash: Hex;
  request: Omit<CreateRequest, 'factoryRevision' | 'deadline'>;
  economics: Economics; maximumPlatformFee: bigint; attemptId: string;
  initialBuy?: { amount: string; native: boolean; wrappedNative: Address; slippageBps: bigint };
};

export async function launchToken(ctx: Context, input: LaunchInput) {
  const { client, account } = ctx;
  const { factory, entitlement } = input;
  let economics = input.economics;
  const quoteAsset = input.request.intent.quote;
  const firstBuy = input.initialBuy;
  let amount = 0n;
  if (firstBuy) {
    if (firstBuy.native && quoteAsset.toLowerCase() !== firstBuy.wrappedNative.toLowerCase()) {
      throw new Error('Selected quote asset does not support native funding');
    }
    const decimals = firstBuy.native ? 18 : await client.readContract({
      address: quoteAsset, abi: erc20Abi, functionName: 'decimals',
    });
    amount = parseUnits(firstBuy.amount, decimals);
    if (amount <= 0n) throw new Error('Initial purchase must be positive');
    if (!firstBuy.native) {
      const balance = await client.readContract({ address: quoteAsset,
        abi: erc20Abi, functionName: 'balanceOf', args: [account] });
      if (balance < amount) throw new Error('Insufficient initial-buy balance');
      // Atomic launch uses ENTITLEMENT as spender, not the future curve.
      await approveExact(ctx, quoteAsset, entitlement, amount, input.attemptId);
    }
  }
  const block = await client.getBlock();
  const factoryRead = { address: factory, abi: factoryAbi, blockNumber: block.number } as const;
  const entitlementRead = { address: entitlement, abi: entitlementAbi, blockNumber: block.number } as const;
  const [fee, feeVersion, allowed, enabled, recipeHash, factoryAdmission, entitlementAdmission] = await Promise.all([
    client.readContract({ ...entitlementRead, functionName: 'fee' }),
    client.readContract({ ...entitlementRead, functionName: 'feeVersion' }),
    client.readContract({ ...entitlementRead, functionName: 'factoryAllowed', args: [factory] }),
    client.readContract({ ...factoryRead, functionName: 'recipeEnabled', args: [input.request.intent.recipe] }),
    client.readContract({ ...factoryRead, functionName: 'recipeHash', args: [input.request.intent.recipe] }),
    client.readContract({ ...factoryRead, functionName: 'requireAdmission' }),
    client.readContract({ ...entitlementRead, functionName: 'requireAdmission' }),
  ]);
  if (!allowed || !enabled || recipeHash.toLowerCase() !== input.recipeHash.toLowerCase()) {
    throw new Error('Factory or recipe is not admitted');
  }
  if (fee > input.maximumPlatformFee) throw new Error('Platform fee exceeds your limit');
  const deadline = block.timestamp + 300n;
  const request: CreateRequest = { ...input.request, factoryRevision: factoryAdmission[0], deadline };
  const projectId = await client.readContract({ ...entitlementRead,
    functionName: 'entitlementId', args: [account, request.intent.nonce] });
  const receivers = await client.readContract({ address: factory, abi: creationAbi,
    functionName: 'predictedReceivers105',
    args: [projectId, economics.trading.recipients[0], economics.creationMode !== 0],
    blockNumber: block.number });
  economics = { ...economics,
    trading: { ...economics.trading, recipients: receivers },
    royalty: { ...economics.royalty,
      recipients: [economics.royalty.recipients[0], receivers[1], receivers[2],
        receivers[3], receivers[4], receivers[5]] },
  };
  const commitment = await client.readContract({ address: factory, abi: creationAbi,
    functionName: 'commitmentFor105', args: [account, request.intent, economics],
    blockNumber: block.number });
  const create = getAbiItem({ abi: creationAbi, name: 'createReserved105' });
  const creationData = encodeAbiParameters(
    [{ type: 'bytes32' }, ...create.inputs],
    [keccak256(stringToHex('OTOMATE_LAUNCHER_PROJECT_CREATE_105_V2')), request, economics],
  );
  const feeQuote = {
    nonce: request.intent.nonce, commitment, factory, feeVersion,
    factoryRevision: factoryAdmission[0], entitlementRevision: entitlementAdmission[0],
    maxFee: input.maximumPlatformFee, deadline,
  };
  let data: Hex;
  if (firstBuy) {
    const quote = await client.readContract({ address: factory, abi: creationAbi,
      functionName: 'quoteInitialBuy105',
      args: [account, request.intent, economics, amount, deadline], blockNumber: block.number,
    });
    if (quote.grossQuoteUsed <= 0n) throw new Error('No executable initial-buy quote');
    const minimum = minimumAfterSlippage(quote.tokensOut, firstBuy.slippageBps);
    data = encodeFunctionData({ abi: entitlementAbi, functionName: 'payAndCreateAndBuy',
      args: [feeQuote, creationData, {
        quoteAsset, amountIn: amount,
        minimumTokensOut: minimum * amount / quote.grossQuoteUsed,
        deadline, nativePayment: firstBuy.native,
      }],
    });
  } else {
    data = encodeFunctionData({ abi: entitlementAbi, functionName: 'payAndCreate',
      args: [feeQuote, creationData] });
  }
  const receipt = await sendOnce(ctx, `${input.attemptId}:launch`, {
    to: entitlement, data, value: fee + (firstBuy?.native ? amount : 0n),
  });
  const events = parseEventLogs({ abi: factoryAbi, eventName: 'ProjectCreated',
    logs: receipt.logs.filter(log => log.address.toLowerCase() === factory.toLowerCase()),
  });
  const event = events.find(item => item.args.entitlementId === projectId
    && item.args.owner.toLowerCase() === account.toLowerCase()
    && item.args.commitment === commitment);
  if (!event) throw new Error(`Inspect launch receipt ${receipt.transactionHash}`);
  const project = await client.readContract({ address: factory, abi: factoryAbi,
    functionName: 'project', args: [projectId], blockNumber: receipt.blockNumber });
  if (project.token.toLowerCase() !== event.args.token.toLowerCase()) {
    throw new Error('Created token does not match the project record');
  }
  return { hash: receipt.transactionHash, projectId, token: project.token,
    curve: project.curve, project };
}

Call launchToken(ctx, input) after reviewing the inputs. With initialBuy omitted, it submits payAndCreate and attaches only the platform fee. The returned token and curve are the addresses to save and pass to the trading examples. Do not infer the new token address from the transaction's destination: that destination is the entitlement contract.

Use it from your app

Call this handler after the user finishes the creation form and reviews the prepared configuration. preparedLaunch contains the fields from the input table above, including the saved nonce and attempt ID. No transaction is sent while the form is being edited.

export async function launchFromYourApp(
  provider: EIP1193Provider, rpcUrl: string, attempts: AttemptStore, preparedLaunch: LaunchInput,
) {
  const ctx = await connectOtomate(provider, rpcUrl, attempts);
  const { hash, projectId, token, curve } = await launchToken(ctx, preparedLaunch);
  // Save these values in your project record and show the token to the user.
  return { hash, projectId, token, curve };
}

Launch and buy in one transaction

To purchase at creation, supply the same LaunchInput with:

export function withInitialEthBuy(input: LaunchInput, wrappedNative: Address): LaunchInput {
  return { ...input, initialBuy: {
    amount: '0.01', native: true, wrappedNative, slippageBps: 100n,
  } };
}

Pass the result to launchToken. It quotes the initial buy, calculates its bound and calls payAndCreateAndBuy with value = platformFee + 0.01 ETH. Use this only with the admitted wrapped-native quote asset.

For an ERC-20-funded first purchase, set native: false. The same function reads the quote token's decimals, approves the entitlement, and sends only the platform fee as native value. An approval may require its own transaction even though creation and the purchase are atomic.

Verify your new token

A successful receipt must include ProjectCreated from the expected factory, for the expected owner, project ID and commitment. Read factory.project(projectId) and retain the complete graph, especially token, curve, quote and any Companion addresses.

Check curve.launchActive104() before trying to trade. Token creation does not prove collection artwork is ready. For standard generated collections, full generation becomes eligible after graduation; mint/reveal readiness is tracked separately.

If the response is lost, recover the existing transaction from your wallet/journal and read the project for the same owner and nonce. Do not generate a second project nonce as an automatic retry.

Read markets and prepare trades

A token trades on its curve before graduation and on its pool afterwards. The buy and sell examples below target the curve. Reuse the imports and helpers from Set up your integration.

Buying a token

Flow: resolve the project → read the quote asset → convert the input to raw units → approve if spending ERC-20 → get a fresh quote and fee witness → simulate → submit → check TokensBought.

  • For an ETH-funded purchase, use buyNativeWithFeePolicy103 and attach ETH as value. This is available only for the verified wrapped-native pair.
  • For WETH or another admitted ERC-20, approve the curve, then call buyWithFeePolicy103. Native value is zero.
  • Always quote for the actual recipient. Launch protection can depend on that recipient.
export async function buyToken(ctx: Context, input: {
  curve: Address; recipient: Address; amount: string; native: boolean;
  wrappedNative: Address; slippageBps: bigint; attemptId: string;
}) {
  const { client } = ctx;
  const read = { address: input.curve, abi: curveAbi } as const;
  const quoteAsset = await client.readContract({ ...read, functionName: 'quoteAsset' });
  if (input.native && quoteAsset.toLowerCase() !== input.wrappedNative.toLowerCase()) {
    throw new Error('This pair does not accept native ETH');
  }
  const decimals = input.native ? 18 : await client.readContract({
    address: quoteAsset, abi: erc20Abi, functionName: 'decimals',
  });
  const amount = parseUnits(input.amount, decimals);
  if (amount <= 0n) throw new Error('Enter a positive amount');
  if (!input.native) {
    const balance = await client.readContract({ address: quoteAsset,
      abi: erc20Abi, functionName: 'balanceOf', args: [ctx.account] });
    if (balance < amount) throw new Error('Insufficient quote-asset balance');
    await approveExact(ctx, quoteAsset, input.curve, amount, input.attemptId);
  }
  // Quote AFTER approval: its transaction may have changed the current block.
  const block = await client.getBlock();
  const [graduated, active, quote, witness] = await Promise.all([
    client.readContract({ ...read, functionName: 'graduated', blockNumber: block.number }),
    client.readContract({ ...read, functionName: 'launchActive104', blockNumber: block.number }),
    client.readContract({ ...read, functionName: 'quoteBuyFor',
      args: [amount, input.recipient], blockNumber: block.number }),
    client.readContract({ ...read, functionName: 'currentFeeWitness103', blockNumber: block.number }),
  ]);
  if (graduated || !active) throw new Error('Curve trading is unavailable');
  if (quote.grossQuoteUsed <= 0n) throw new Error('No executable quote');
  const effectiveMinimum = minimumAfterSlippage(quote.tokensOut, input.slippageBps);
  // Raw 105 ABI minima are relative to the offered input, including partial fills.
  const minimum = effectiveMinimum * amount / quote.grossQuoteUsed;
  const deadline = block.timestamp + 300n;
  const [policyHash, , maximumOrdinaryBps] = witness;
  const data = input.native
    ? encodeFunctionData({ abi: curveAbi, functionName: 'buyNativeWithFeePolicy103',
        args: [minimum, deadline, input.recipient, policyHash, maximumOrdinaryBps] })
    : encodeFunctionData({ abi: curveAbi, functionName: 'buyWithFeePolicy103',
        args: [amount, minimum, deadline, input.recipient, policyHash, maximumOrdinaryBps] });
  const receipt = await sendOnce(ctx, `${input.attemptId}:buy`, {
    to: input.curve, data, value: input.native ? amount : 0n,
  });
  const fills = parseEventLogs({ abi: curveAbi, eventName: 'TokensBought',
    logs: receipt.logs.filter(log => log.address.toLowerCase() === input.curve.toLowerCase()),
  });
  if (fills.length !== 1 || fills[0].args.recipient.toLowerCase() !== input.recipient.toLowerCase()) {
    throw new Error(`Inspect purchase receipt ${receipt.transactionHash}`);
  }
  return { hash: receipt.transactionHash, quoted: quote, executed: fills[0].args };
}

Call buyToken with the connected context, the verified project's curve, your recipient, amount: '0.01', slippageBps: 100n and a persisted unique attempt ID. Set native: true to spend 0.01 ETH on a supported native pair, or native: false to spend 0.01 units of the curve's ERC-20 quote asset. wrappedNative must come from the verified deployment configuration.

Use it from your app

This click handler buys 0.01 ETH worth of tokens for the connected wallet. Supply your RPC URL, the selected token's curve and the configured wrapped-native address. Save attemptId with the user's purchase before calling it.

export async function buyFromYourApp(
  provider: EIP1193Provider, rpcUrl: string, attempts: AttemptStore,
  curve: Address, wrappedNative: Address, attemptId: string,
) {
  const ctx = await connectOtomate(provider, rpcUrl, attempts);
  const result = await buyToken(ctx, {
    curve,
    recipient: ctx.account,
    amount: '0.01',
    native: true,
    wrappedNative,
    slippageBps: 100n,
    attemptId,
  });
  // Use the hash for your explorer link and the fill for the success screen.
  return result;
}

The result contains the transaction hash and actual event amounts. A quoted output is not the executed output. If receipt verification fails after submission, inspect that hash; do not buy again to recover the result.

Selling a token

Flow: resolve the project token → read its decimals and balance → approve the curve to spend it → quote the sale → simulate → submit → check TokensSold.

The approval asset is now the project token, not the quote asset. sellWithFeePolicy103 returns the ERC-20 quote asset. For the supported wrapped-native pair, sellNativeWithFeePolicy103 returns native ETH. Neither sell call attaches ETH as trade input; keep ETH for gas.

export async function sellToken(ctx: Context, input: {
  curve: Address; recipient: Address; amount: string; native: boolean;
  wrappedNative: Address; slippageBps: bigint; attemptId: string;
}) {
  const { client } = ctx;
  const read = { address: input.curve, abi: curveAbi } as const;
  const [token, quoteAsset] = await Promise.all([
    client.readContract({ ...read, functionName: 'token' }),
    client.readContract({ ...read, functionName: 'quoteAsset' }),
  ]);
  if (input.native && quoteAsset.toLowerCase() !== input.wrappedNative.toLowerCase()) {
    throw new Error('This pair cannot return native ETH');
  }
  const [decimals, balance] = await Promise.all([
    client.readContract({ address: token, abi: erc20Abi, functionName: 'decimals' }),
    client.readContract({ address: token, abi: erc20Abi,
      functionName: 'balanceOf', args: [ctx.account] }),
  ]);
  const amount = parseUnits(input.amount, decimals);
  if (amount <= 0n || amount > balance) throw new Error('Invalid token amount');
  await approveExact(ctx, token, input.curve, amount, input.attemptId);
  const block = await client.getBlock();
  const [graduated, active, quote, witness] = await Promise.all([
    client.readContract({ ...read, functionName: 'graduated', blockNumber: block.number }),
    client.readContract({ ...read, functionName: 'launchActive104', blockNumber: block.number }),
    client.readContract({ ...read, functionName: 'quoteSell', args: [amount], blockNumber: block.number }),
    client.readContract({ ...read, functionName: 'currentFeeWitness103', blockNumber: block.number }),
  ]);
  if (graduated || !active) throw new Error('Curve trading is unavailable');
  const minimum = minimumAfterSlippage(quote.netQuoteOut, input.slippageBps);
  const [policyHash, , maximumOrdinaryBps] = witness;
  const data = encodeFunctionData({ abi: curveAbi,
    functionName: input.native ? 'sellNativeWithFeePolicy103' : 'sellWithFeePolicy103',
    args: [amount, minimum, block.timestamp + 300n, input.recipient, policyHash, maximumOrdinaryBps],
  });
  const receipt = await sendOnce(ctx, `${input.attemptId}:sell`, {
    to: input.curve, data, value: 0n,
  });
  const fills = parseEventLogs({ abi: curveAbi, eventName: 'TokensSold',
    logs: receipt.logs.filter(log => log.address.toLowerCase() === input.curve.toLowerCase()),
  });
  if (fills.length !== 1 || fills[0].args.recipient.toLowerCase() !== input.recipient.toLowerCase()) {
    throw new Error(`Inspect sale receipt ${receipt.transactionHash}`);
  }
  return { hash: receipt.transactionHash, quoted: quote, executed: fills[0].args };
}

For example, amount: '1000' sells 1,000 project tokens after conversion using that token's decimals. A sell minimum is the minimum net quote asset received and uses absolute-output semantics; do not apply the buy normalization to it.

Getting a quote

Read the quote and currentFeeWitness103() at the same block as shown above. The witness supplies the hash and fee ceiling required by the protected trade call. A witness mismatch or failed simulation requires a fresh quote and another review.

Quote fieldMeaning
grossQuoteUsedBuy input that the quoted fill would consume.
grossQuoteRefundUnused buy input, including a purchase larger than the remaining curve allocation.
principalInBuy input credited to the curve after fees.
feeAmountFee in raw quote-asset units.
tokensOutProject tokens returned by a buy.
grossQuoteOutSell proceeds before fees.
netQuoteOutSell proceeds after fees; use this to calculate the sell minimum.

Partial buys near graduation

Generation105 can fill only the remaining curve allocation and refund unused input. For a raw ABI buy, execution enforces:

tokensOut >= ceil(minimum * actualGrossQuoteUsed / offeredGross)

When the quote is already partial, encode floor(effectiveMinimum * offeredGross / quotedGrossQuoteUsed), as in buyToken. Example: offer 10 units, quote uses 4 and returns 100 tokens. At 1% slippage the effective minimum is 99; the raw bound is floor(99 × 10 / 4) = 247. An execution using 4 units must return at least ceil(247 × 4 / 10) = 99 tokens.

The repository helper prepareCurveTrade105 already performs this normalization. Do not apply it again to that helper's minimumOutput. This page's direct ABI example does not use the helper.

Trading after graduation

Once graduated() is true, stop submitting curve trades. Resolve the coordinator's marketReady(token) and the project's admitted pool/router. Quote the pool route, obtain its current spender and prepare the pool transaction through that router. A curve approval is not a pool-router approval.

readyToGraduate() only indicates curve readiness. The existing Otomate operator completes the pool transition; an ordinary buyer should not send a graduation transaction before every trade. During the transition, wait until the market is ready.

Resolve the market from the factory and read the pool key from its graduator. These addresses are discovered on-chain, so no hardcoded pool address is required.

const graduatorAbi = parseAbi([
  'function poolKey(address curve) view returns ((address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks))',
]);
export async function readGraduatedPool(ctx: Context, factory: Address, projectId: Hex) {
  const blockNumber = await ctx.client.getBlockNumber();
  const infrastructure = await ctx.client.readContract({ address: factory,
    abi: factoryAbi, functionName: 'infrastructure', blockNumber });
  const project = await ctx.client.readContract({ address: factory,
    abi: factoryAbi, functionName: 'project', args: [projectId], blockNumber });
  const market = infrastructure.market;
  const ready = await ctx.client.readContract({ address: market,
    abi: marketAbi, functionName: 'marketReady', args: [project.token], blockNumber });
  if (!ready) throw new Error('Pool is not ready');
  const graduator = await ctx.client.readContract({ address: market,
    abi: marketAbi, functionName: 'graduator', blockNumber });
  const key = await ctx.client.readContract({ address: graduator,
    abi: graduatorAbi, functionName: 'poolKey', args: [project.curve], blockNumber });
  const currencies = [key.currency0.toLowerCase(), key.currency1.toLowerCase()];
  if (!currencies.includes(project.token.toLowerCase())
    || !currencies.includes(project.quote.toLowerCase())) throw new Error('Pool asset mismatch');
  const poolId = keccak256(encodeAbiParameters([
    { type: 'address' }, { type: 'address' }, { type: 'uint24' },
    { type: 'int24' }, { type: 'address' },
  ], [key.currency0, key.currency1, key.fee, key.tickSpacing, key.hooks]));
  return { token: project.token, quote: project.quote, curve: project.curve, market, key, poolId };
}

Pass this exact pool key to a compatible Uniswap v4 routing integration. Preserve its hook and tick spacing; do not reconstruct them from assumptions. The quote and execution must target the same pool and use the router's required allowance spender, minimum output and deadline. Simulate the exact route before sending: pool readiness alone does not prove a router is admitted by the hook.

This section identifies the graduated pool. To trade it, use the Otomate router or a relay pool, as in Trade graduated tokens. Do not reuse curve calls for pool trades.

Discover tokens on-chain

Read ProjectCreated events from the verified factory, starting at its deployment block. Each event supplies the project ID and token address. Resolve the curve and quote asset with factory.project(projectId) before offering a trade.

export async function discoverTokens(ctx: Context, factory: Address,
  fromBlock: bigint, toBlock: bigint) {
  if (toBlock < fromBlock || toBlock - fromBlock > 999n) {
    throw new Error('Read at most 1,000 blocks per request');
  }
  const events = await ctx.client.getContractEvents({
    address: factory, abi: factoryAbi, eventName: 'ProjectCreated',
    fromBlock, toBlock, strict: true,
  });
  const projects = [];
  for (const event of events) {
    const project = await ctx.client.readContract({
      address: factory, abi: factoryAbi, functionName: 'project',
      args: [event.args.entitlementId], blockNumber: toBlock,
    });
    projects.push({ projectId: event.args.entitlementId,
      token: project.token, curve: project.curve, quoteAsset: project.quote,
      transactionHash: event.transactionHash, blockHash: event.blockHash,
      logIndex: event.logIndex });
  }
  return projects;
}

Scan bounded ranges ending at your chosen confirmed block. Persist the next block to scan and each event's block hash, transaction hash and log index. Reduce the range when your RPC provider imposes a smaller log limit. Reconcile reorgs before counting events permanently. The example resolves projects sequentially to bound RPC concurrency.

For a Telegram bot, store this cursor and the project records in the bot's database. Subsequent commands can resolve a selected token's curve and use the buy/sell contract calls above.

Recovery and common errors

SituationNext step
Approval confirmed, buy or sell not sentFetch a new quote, then submit the trade; approval is not a swap.
LaunchInactive104Wait for the project to become active.
InvalidFeeWitnessRead a fresh quote and witness at one block, then review again.
Expired deadline or output below minimumRequote with the current market; keep the user's chosen slippage.
Unsupported native assetUse the ERC-20 path for the actual quote asset.
Receipt timeoutInspect the saved hash and wallet nonce before doing anything else.
Curve graduatedResolve a supported pool route.

Decode errors with the target deployment's ABI. Do not retry a transaction simply because the RPC request timed out. Preserve (chainId, account, target, calldata, value, nonce, hash) where available and reconcile replacements or reorgs.

Trade Otomate tokens from a bot

Otomate tokens on Ink trade in two phases. A new token trades on its bonding curve. When the curve fills, the token graduates to a Uniswap v4 pool.

Both phases check a fee witness: the token's active fee policy hash and a maximum fee. A generic router that sends a plain swap is refused. This page shows the three paths a bot can use, with samples tested on an Ink fork.

PhasePathCallsSample
Bonding curveThe token's curvebuyWithFeePolicy103, sellWithFeePolicy103 and their native versionscurve.ts
GraduatedOtomate routerbuy, sell and their native versionsrouter.ts
Graduated, with a relay poolUniswap Universal RouterV4_SWAP on the relay pool, empty hookDatarelay-pool.ts

All examples use TypeScript and viem on Ink (chain ID 57073). Read Tested samples to run them.

Bot addresses on Ink

These addresses were checked on Ink at block 58,001,041 on 9 October 2026. Each token has its own curve: read it from the catalogue or from factory.project(projectId).

A token's pair is WETH, $OTO or a supported xStock such as wNVDAx. Use the token's quoteAsset(); never infer the pair from its name.

Find new tokens

Use one of three sources. Name, symbol and pair are fixed once launched.

1. The public catalogue. No key is needed. Poll it a few times per minute at most and send a User-Agent that names your bot.

GET https://www.otomate.fun/api/launcher037/catalog?phase=CURVE&sort=newest&limit=30
ParameterValues
phaseCURVE (bonding) or POOL (graduated)
sortnewest, oldest, name, marketCap, bondingProgress (with phase=CURVE), recentBuys
pairall, weth, oto, stock (xStocks)
qName, symbol or token address, up to 80 characters
limit1 to 50, default 30
afterThe previous page's nextCursor; null means the last page

Each item in projects has token, curve, quote, quoteDecimals, entitlementId (the project ID), name, symbol, phase, status and creation (block, transaction hash, timestamp as decimal strings). name and symbol can be null.

A token created on otomate.fun appears within seconds as CONFIRMED_WAITING_FINALITY. A token created by a direct contract call appears once its block is final, as INDEXED: about 15 to 30 minutes later. CONFIRMATION_STALE means the creation may have been dropped: check the chain before trading.

2. The Telegram channel t.me/otomatefun_launches posts new launches and graduations on Ink and Robinhood Chain. Each token links to otomate.fun with network= and token= in the URL. It is meant for people; a bot should read the catalogue or the chain.

3. Factory events. The factory emits ProjectCreated(bytes32 indexed entitlementId, address indexed owner, address indexed token, bytes32 commitment, bytes32 finalConfigurationHash, uint256 recipe). Resolve the curve and pair with factory.project(entitlementId), as in Discover tokens on-chain. Graduation emits MarketGraduated(address indexed token, bytes32 indexed poolId, uint256 positionTokenId, uint128 liquidity) on the market coordinator.

Before trading, check the catalogue's curve and quote against the chain: curve.token() must be the token and curve.quoteAsset() its pair.

Tell the phase and the hook

CheckMeaning
curve.graduated() is falseTrade on the curve.
curve.readyToGraduate() is trueThe curve is full and the pool is being created. Buys revert with InsufficientLiquidity and sells with GraduationPending; wait.
market.marketReady(token) is trueTrade on the pool.
graduator.poolKey(curve).hooksThe pool's hook. It is set when the token is created.

Today every Otomate token on Ink uses the graduated pool hook 0x7353…60Cc. A swap on that pool needs the fee witness in hookData; a plain swap with empty hookData reverts with InvalidFeeWitness.

Read the market coordinator from factory.infrastructure().market and the graduator from market.graduator(); graduatedPoolKey in otomate.ts does both. This function from discover.ts returns the phase, the hook and the relay pool, if any:

export async function marketOf(bot: Bot, curve: Address) {
  const [token, quote, graduated, { market, key }] = await Promise.all([
    bot.client.readContract({ address: curve, abi: curveAbi, functionName: 'token' }),
    bot.client.readContract({ address: curve, abi: curveAbi, functionName: 'quoteAsset' }),
    bot.client.readContract({ address: curve, abi: curveAbi, functionName: 'graduated' }),
    graduatedPoolKey(bot, curve),
  ]);
  const poolReady = await bot.client.readContract({ address: market, abi: marketAbi,
    functionName: 'marketReady', args: [token] });
  // Hook 103 needs the fee witness in hookData; its graduated tokens may also have a relay pool.
  const relay = relayPoolKey(key);
  const relayPool = poolReady && isAddressEqual(key.hooks, INK.hook103) && await relayPoolExists(bot, relay)
    ? poolId(relay) : null;
  return { token, quote, graduated, poolReady, hook: key.hooks, poolId: poolId(key), relayPool };
}

Buy and sell on the curve

Flow: read the pair and the minimum trade → approve the curve (ERC-20 only) → read the quote and the fee witness at one block → set the minimum and the deadline → simulate → send → read TokensBought or TokensSold.

Read the fee witness

curve.currentFeeWitness103() returns (bytes32 hash, uint64 version, uint16 totalBps). Pass hash as expected and totalBps as maximumOrdinaryBps. Do not pass version.

The trade reverts with InvalidFeeWitness when expected is not the token's active fee policy hash, or when maximumOrdinaryBps is below the token's fee. Then read the witness and the quote again, at one block.

Approvals, minimums and deadline

  • ETH: buyNativeWithFeePolicy103 with the amount as value, and sellNativeWithFeePolicy103 to receive ETH. They work only when the pair is WETH (UnsupportedNativeAsset otherwise).
  • ERC-20: approve the curve for the exact amount: the pair asset to buy, the token to sell.
  • Buy minimum: tokens out. Near graduation the curve can use only part of your input and refund the rest to the sender, so scale the minimum to the offered amount as below.
  • Sell minimum: the net amount of the pair asset you receive.
  • Deadline: chain time, at most 30 minutes ahead (DeadlineTooFar). The samples use 2 minutes.
  • Recipient: quote for the real recipient. In the first seconds after a launch, launch protection can apply; quoteBuyFor includes it.

From curve.ts:

async function readAtOneBlock<T>(bot: Bot, curve: Address, quote: (blockNumber: bigint) => Promise<T>) {
  const block = await bot.client.getBlock();
  const at = { address: curve, abi: curveAbi, blockNumber: block.number } as const;
  const [graduated, active, witness, quoted] = await Promise.all([
    bot.client.readContract({ ...at, functionName: 'graduated' }),
    bot.client.readContract({ ...at, functionName: 'launchActive104' }),
    bot.client.readContract({ ...at, functionName: 'currentFeeWitness103' }),
    quote(block.number),
  ]);
  if (graduated) throw new Error('Graduated: trade the pool instead');
  if (!active) throw new Error('Curve trading is not active yet');
  const [policyHash, , maximumOrdinaryBps] = witness; // (hash, version, totalBps)
  return { quoted, policyHash, maximumOrdinaryBps, deadline: block.timestamp + 120n };
}
  const { quoted, policyHash, maximumOrdinaryBps, deadline } = await readAtOneBlock(bot, input.curve,
    blockNumber => bot.client.readContract({ address: input.curve, abi: curveAbi,
      functionName: 'quoteBuyFor', args: [input.amount, recipient], blockNumber }));
  // The buy minimum is scaled to the offered amount: near graduation only part of it may be used.
  const minimum = minimumOut(quoted.tokensOut, input.slippageBps) * input.amount / quoted.grossQuoteUsed;
  const data = input.native
    ? encodeFunctionData({ abi: curveAbi, functionName: 'buyNativeWithFeePolicy103',
        args: [minimum, deadline, recipient, policyHash, maximumOrdinaryBps] })
    : encodeFunctionData({ abi: curveAbi, functionName: 'buyWithFeePolicy103',
        args: [input.amount, minimum, deadline, recipient, policyHash, maximumOrdinaryBps] });

A sell works the same way with quoteSell(amount) and sellWithFeePolicy103(amount, minimum, deadline, recipient, expected, maximumOrdinaryBps); see sellOnCurve in the sample.

Minimum trade

Tokens paired with $OTO or an xStock have a minimum trade. Read it per token with curve.economics(): it is the fourth value, minimumTrade. On today's $OTO curves it is about 17.4 to 17.6 OTO. WETH pairs have no minimum (0).

  • A buy whose input is below the minimum reverts with BelowMinimum, including its quote. The one exception is a buy that exactly completes the curve.
  • A sell whose proceeds before fees are below the minimum reverts with BelowMinimum.
  • After graduation the same minimum applies on the pool. The hook reverts with InvalidFeeAmount, wrapped by the PoolManager.

Trade graduated tokens

Read a graduated pool's key with graduator.poolKey(curve): the token and its pair as currencies, fee 0 and tick spacing 60 today, and the graduated pool hook. That hook reads hookData = abi.encode(bytes32 policyHash, uint16 maximumOrdinaryBps, uint256 deadline), with the values of the curve's currentFeeWitness103(). Two paths fill it for you.

Through the Otomate router

The router trades graduated Otomate tokens on Ink whose pool uses the graduated pool hook above. It needs nothing off-chain: no API key and no signature. Each input below comes from the chain.

  • Exact input only. buy, sell, buyNative (ETH in, WETH pairs) and sellNative (ETH out, WETH pairs).
  • Approval: approve the router for the asset you pay with: the pair asset to buy, the token to sell.
  • Quote: call quoteBuyFor or quoteSell with eth_call; they are not view.
BuyParams fieldValue
launchTokenThe token.
paymentAssetThe pair asset (curve.quoteAsset()).
expectedPooltrue: the token trades on its pool.
amountInYour input.
minQuoteOutamountIn: paying with the pair asset needs no conversion.
minTokensOutThe quoted tokens minus your slippage. Must be above 0.
minRefundOut0.
deadlineChain time, at most 5 minutes ahead (InvalidDeadline).
recipientWho receives the tokens.
routeHash, refundRouteHash0x0…0, with empty route and refundRoute arrays.
expectedAdmissionHashrouter.admissionHash(token, true), read just before.
expectedRouteStaterouter.routeState([], []).
fee{ expectedBindingHash: hash, maximumOrdinaryBps: totalBps } from curve.currentFeeWitness103().

SellParams has no refund fields, and minBaseOut replaces minTokensOut. Set minQuoteOut and minBaseOut to the quoted amount minus your slippage. For a sell, expectedRouteState is routeRegistry.routeState([], executor), read from router.routeRegistry() and router.executor().

From router.ts:

export async function prepareRouterBuy(bot: Bot, input: Trade) {
  const { quote, fee, admission, deadline } = await prepare(bot, input);
  if (!input.native) await approveIfNeeded(bot, quote, ROUTER, input.amount); // The router pulls it.
  const recipient = bot.account.address;
  const [{ result }, routeState] = await Promise.all([
    bot.client.simulateContract({ address: ROUTER, abi: routerAbi, functionName: 'quoteBuyFor',
      args: [input.token, quote, true, input.amount, recipient, [], [], fee, deadline] }),
    bot.client.readContract({ address: ROUTER, abi: routerAbi, functionName: 'routeState', args: [[], []] }),
  ]);
  const minimum = minimumOut(result[1], input.slippageBps);
  const params = {
    launchToken: input.token, paymentAsset: quote, expectedPool: true, amountIn: input.amount,
    minQuoteOut: input.amount, minTokensOut: minimum, minRefundOut: 0n, deadline, recipient,
    routeHash: zeroHash, refundRouteHash: zeroHash, expectedAdmissionHash: admission,
    expectedRouteState: routeState, fee,
  } as const;
  const data = encodeFunctionData({ abi: routerAbi, functionName: input.native ? 'buyNative' : 'buy',
    args: [params, [], []] });
  return { tx: { to: ROUTER, data, value: input.native ? input.amount : 0n }, quoted: result[1], minimum };
}

Through a relay pool

A relay pool lets standard Uniswap v4 tooling trade a graduated token. Its key is the graduated pool's key with the relay pool hook instead. It holds no liquidity: each swap runs on the graduated pool, and the relay hook supplies the fee witness itself. Send empty hookData.

TokenPairRelay pool ID
VOYAGE 0x37987EbD22A7c41746fc57C7D6274355EABbBB6aWETH0x0e39184e21122c03d948231845c5b2c03bbe978cf86eae1392647a5828476627
IKT 0xbC9749aF41301eEe559DFE12048F1812A235b5a8WETH0x61173065c8bfc688e9dbf955580049ac754bf00dd726bbf5993b7bb092651ca8
Pepoto 0x8313b2ebCFFaDdaC888f423EdC250e419DE6947e$OTO0xc2ad90a3689fd40ef97338156b7e818c43b6b782b1f0417db261827e8eaabf57
DICKAPRIO 0x3b684866f432D3f0818e85c2b10DE1D836D16A4B$OTO0x8e7445fd351f2418225991db0e00c52728b0bf24a3b7f06976c934c069d586e4

Check other tokens with StateView: a relay pool exists when getSlot0(relayPoolId) returns a non-zero price. Compute relayPoolId from the graduated pool's key with the relay pool hook.

  • Swap: Universal Router execute with command V4_SWAP (0x10) and actions SWAP_EXACT_IN_SINGLE (0x06), SETTLE_ALL (0x0c), TAKE_ALL (0x0f). Exact output also works; the samples use exact input.
  • Approvals: the input token approves Permit2, then Permit2.approve(token, universalRouter, amount, expiration).
  • Quote: quoteExactInputSingle on the v4 Quoter with empty hookData, through eth_call.
  • No price limit: the relay pool ignores sqrtPriceLimitX96. Your amountOutMinimum is the only price guard: always set it from a fresh quote.

From relay-pool.ts:

export function relayPoolKey(graduated: PoolKey): PoolKey {
  return { ...graduated, hooks: INK.relayHook };
}
export function encodeExactInSingle(key: PoolKey, tokenIn: Address, amountIn: bigint, minimumOut: bigint): Hex {
  const zeroForOne = isAddressEqual(tokenIn, key.currency0);
  const tokenOut = zeroForOne ? key.currency1 : key.currency0;
  const actions = encodePacked(['uint8', 'uint8', 'uint8'], [SWAP_EXACT_IN_SINGLE, SETTLE_ALL, TAKE_ALL]);
  const params = [
    encodeAbiParameters(
      parseAbiParameters('((address,address,uint24,int24,address),bool,uint128,uint128,bytes)'),
      [[[key.currency0, key.currency1, key.fee, key.tickSpacing, key.hooks],
        zeroForOne, amountIn, minimumOut, '0x']]), // Empty hookData: the relay hook needs none.
    encodeAbiParameters(parseAbiParameters('address, uint256'), [tokenIn, amountIn]),
    encodeAbiParameters(parseAbiParameters('address, uint256'), [tokenOut, minimumOut]),
  ];
  return encodeAbiParameters(parseAbiParameters('bytes, bytes[]'), [actions, params]);
}

The Universal Router on Ink takes this five-field ExactInputSingleParams tuple: pool key, direction, amount in, minimum out and hookData.

Coming next

These are planned, without a date:

  • A new pool hook for tokens created after it ships, with optional hookData: a plain Uniswap v4 swap would work on those pools. A token keeps the hook chosen at its creation: always read graduator.poolKey(curve).hooks.
  • An optional witness on the curve.

Bot error codes

Decode reverts with the ABIs in the samples. Pool reverts arrive wrapped in the PoolManager's WrappedError(address target, bytes4 selector, bytes reason, bytes details) (0x90bfb865), once per hook; the v4 Quoter wraps them again in UnexpectedRevertBytes(bytes) (0x6190b2b0).

ErrorSelectorWhereWhat to do
InvalidFeeWitness0xe71501a9Curve, router, graduated poolRead the witness and the quote again at one block. On a graduated pool, use the router or a relay pool.
BelowMinimum0x860b82a9CurveTrade at least economics().minimumTrade.
InvalidFeeAmount0x52338c80Graduated pool, wrappedBelow the minimum trade on an $OTO pool.
SlippageExceeded0x8199f5f3CurveThe price moved. Quote again.
BalanceInvariantFailed0xe7a163ccRouterOutput below your minimum: the price moved. Quote again.
V4TooLittleReceived0x8b063d73Universal RouterOutput below amountOutMinimum. Quote again.
Expired / DeadlineTooFar0x203d82d8 / 0x48f0fae6CurveDeadline in the past, or more than 30 minutes ahead.
InvalidDeadline0x769d11e4RouterDeadline in the past, or more than 5 minutes ahead.
TransactionDeadlinePassed0x5bf6f916Universal RouterThe deadline passed before inclusion.
LaunchInactive1040x4b1837d9CurveThe token is not trading yet.
GraduationPending0x4ed36f02CurveThe curve is full. Wait for marketReady(token).
AlreadyGraduated0xe6a0d45fCurveTrade the pool.
InsufficientLiquidity0xbb55fd27CurveThe amount is too large or too small for the curve, or the curve is full.
UnsupportedNativeAsset0x35f7ca6cCurveNative ETH on a non-WETH pair. Use the ERC-20 call.
InvalidPhase0x9a36fd9cRouterexpectedPool does not match the token's phase.
InvalidConfiguration0xc52a9bd3Router, curveRead admissionHash again. If it persists, the market is unavailable.
InvalidRoute0x84e505d2RouterWrong route state or route hash. From a quote: the pool refused the swap, for example below the minimum trade.
InvalidAmount0x2c5211c6RouterA zero amount or minimum, or a refund minimum on a pool trade.
Paused0x9e87fac8Curve, router, poolTrading on this market is paused. Retry later.
RoutingUnavailable0xc3e2a9fbRouterThe router is not available. Retry later, or use a relay pool.
PartialFill0xd964f528Relay pool, wrappedThe graduated pool could not fill the exact amount. Use a smaller amount.
AllowanceExpired / InsufficientAllowance0xd81b2f2e / 0xf96fb071Permit2Renew the Permit2 approval for the Universal Router.

Do not resend a transaction because an RPC call timed out. Check the saved hash and the wallet nonce first.

Gas and speed tips

  • Budget gas. On an Ink fork, a curve buy or sell used about 0.8 M gas, a relay pool swap 0.85–0.9 M and a router trade 1.45–1.65 M. Router trades on Ink show the same range. Ink gas is cheap, but set your limits for these sizes.
  • Add 20 % to estimateGas. Pool trades run nested swaps; a router sale sent with the bare estimate ran out of gas on our fork.
  • Simulate first. eth_call the exact transaction before you send it: a revert on chain still costs gas.
  • Approve once per size. Keep exact or bounded approvals for the curve, the router or Permit2, and reuse them while they cover the trade.
  • Read at one block, send at once. Batch the quote and the witness reads, then send; use short deadlines.
  • One nonce queue per wallet. Serialize sends from each signing wallet.
  • Watch blocks, not the catalogue, when speed matters: the factory event arrives with the block. The catalogue can follow up to about 30 minutes later for tokens not created on otomate.fun.

Tested samples

These files were run against a local Ink fork on 9 October 2026: curve buy and sell with ETH, WETH and $OTO; the minimum trade and a witness mismatch; relay pool buy and sell on a WETH pool and a buy on an $OTO pool; router buy and sell with ETH, WETH and $OTO; and the slippage and minimum-trade errors above. xStock pairs were read, not traded.

FileContents
otomate.tsAddresses, curve ABI, connect, send, approvals, minimums
discover.tsCatalogue, ProjectCreated events, phase and hook
curve.tsCurve buy and sell, native or ERC-20
router.tsGraduated tokens through the Otomate router
relay-pool.tsGraduated tokens through a relay pool and the Universal Router
package.jsonviem and Node settings

Download them into one folder and run npm install. Node 22.18 or later runs the .ts files directly. Try your bot on a local fork first:

anvil --fork-url https://rpc-gel.inkonchain.com --chain-id 57073

Keep signing keys on your server, never in code or logs.

Collections, rewards, and indexing

Display collection progress

Read collection availability separately from market graduation. A confirmed token launch or graduated curve does not establish that the collection is ready to reveal.

Use the collection's mint, opening and reveal state and its ERC-721 transfers to update your interface. A collection whose implementation mints during bonding opens its sealed mint once its final configuration is set, then while the curve's real quote reserve is at least half the graduation threshold; its first NFT latches the mint open, and it stays open after graduation. Other collections mint only after graduation. The contract's mint gate decides: read it rather than recomputing it. Reveal requires graduation and inventory activation; show a pending state until the opening and artwork assignment are confirmed.

NFTs minted before graduation carry the bonding Relic guarantee, counted by token ID in mint order: one NFT per group of 20 (a last partial group of r NFTs with probability r/20), up to 9 per collection, is guaranteed at least a Relic, whenever it is revealed. The collection's inventory activation evidence binds the seed commitment and the number of NFTs minted before graduation; the seed is published once every NFT that could hold a guarantee is revealed, 7 days after activation at the latest. Each collection's proof is on its Provably fair page (/provably-fair?token=<token>, with &network=robinhood on Robinhood Chain).

Minting burns the required project tokens. Opening does not repeat that burn. Read the selected collection's current requirements and simulate the exact call before requesting a signature.

Integrate reward claims

Rewards106 accounts for each incoming supported asset separately. Do not convert or aggregate raw balances across assets. The contract's WETH identity anchor is not an instruction to convert every reward into WETH.

Claims use claimAsset106(asset, tokenIds, holder). Token IDs must be sorted, unique, owned by the holder, and limited to 100 per transaction. Revalidate ownership and claim state before submission. Claim each asset independently.

Resolve the reward contract from the factory project, then read each asset's claimable amount and submit one asset at a time:

import { LauncherWeightedRewards106Abi105 as rewardsAbi } from './abis/LauncherWeightedRewards106';
const nftAbi = parseAbi(['function ownerOf(uint256 tokenId) view returns (address)']);
export async function claimRewards(ctx: Context, factory: Address, projectId: Hex,
  asset: Address, tokenIds: bigint[], attemptId: string) {
  const ids = [...new Set(tokenIds)].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
  if (!ids.length || ids.length > 100 || ids.some(id => id < 0n)) throw new Error('Invalid NFT IDs');
  const blockNumber = await ctx.client.getBlockNumber();
  const project = await ctx.client.readContract({ address: factory, abi: factoryAbi,
    functionName: 'project', args: [projectId], blockNumber });
  if (project.rewards === '0x0000000000000000000000000000000000000000') throw new Error('No rewards contract');
  const read = { address: project.rewards, abi: rewardsAbi, blockNumber } as const;
  const companion = await ctx.client.readContract({ ...read, functionName: 'companion' });
  if (companion.toLowerCase() !== project.companion.toLowerCase()) throw new Error('Collection mismatch');
  const assets = await ctx.client.readContract({ ...read, functionName: 'assets106' });
  if (!assets.some(a => a.toLowerCase() === asset.toLowerCase())) throw new Error('Unsupported reward asset');
  let pending = 0n;
  for (const id of ids) {
    const owner = await ctx.client.readContract({ address: companion, abi: nftAbi,
      functionName: 'ownerOf', args: [id], blockNumber });
    if (owner.toLowerCase() !== ctx.account.toLowerCase()) throw new Error('NFT ownership changed');
    pending += await ctx.client.readContract({ ...read, functionName: 'pendingAsset106', args: [asset, id] });
  }
  if (pending === 0n) throw new Error('No claimable rewards');
  const receipt = await sendOnce(ctx, attemptId, { to: project.rewards, value: 0n,
    data: encodeFunctionData({ abi: rewardsAbi, functionName: 'claimAsset106',
      args: [asset, ids, ctx.account] }) });
  const claims = parseEventLogs({ abi: rewardsAbi, eventName: 'AssetClaimed106',
    logs: receipt.logs.filter(log => log.address.toLowerCase() === project.rewards.toLowerCase()), strict: true });
  return { hash: receipt.transactionHash, claims };
}

Obtain NFT IDs from ERC-721 Transfer events for the project's collection and recheck ownerOf before a claim, as above. Split larger inventories into batches of at most 100 IDs with distinct persisted action IDs. This example targets Rewards106; use the deployment's matching ABI for older collections.

Unclaimed rewards follow the NFT. A UI must not attribute a transferable NFT's pending rewards permanently to a previous wallet. Pagination totals describe their page, not necessarily all NFTs owned by the user.

Historical047 WETH rewards retain their original claimTokens path. Reading a new reward contract does not replace historical claims.

Update your app from events

Use the ABI matching each project generation. Useful event families include:

EventWhat to record
ProjectCreatedEntitlement ID, owner, token, commitment, configuration hash, and recipe.
TokensBoughtActual gross quote used, fee, token output, and reserve update.
TokensSoldToken input, gross quote, fee, net output, and reserve update.
TradeFeePolicy103The execution's fee-policy witness.
MarketGraduatedToken, pool ID, LP position ID, and liquidity.
Collection ERC-721 transfersMint, current ownership, transfers, and burns for inventory.
Reward claim eventsAsset, holder, NFT IDs, and settled amount according to the matching ABI.

Persist observations under (chainId, blockHash, transactionHash, logIndex). Track canonicality and finality separately, remove orphaned observations after a reorganization, and avoid applying the same canonical event twice.

Index bounded block ranges and resume from a durable cursor. Limit RPC concurrency, honor rate limits, and distinguish delayed indexing from an empty balance. Event indexing is a discovery mechanism; verify the current contract state before preparing a transaction.

Index actual trade fills

Read curve events for each discovered project. Index actual fills instead of treating the requested amount as the executed amount, especially near graduation.

export async function readTradeFills(ctx: Context, curve: Address,
  fromBlock: bigint, toBlock: bigint) {
  if (fromBlock < 0n || toBlock < fromBlock || toBlock - fromBlock >= 1_000n) {
    throw new Error('Use a range of at most 1000 blocks');
  }
  const [buys, sells] = await Promise.all([
    ctx.client.getContractEvents({ address: curve, abi: curveAbi,
      eventName: 'TokensBought', fromBlock, toBlock, strict: true }),
    ctx.client.getContractEvents({ address: curve, abi: curveAbi,
      eventName: 'TokensSold', fromBlock, toBlock, strict: true }),
  ]);
  return [...buys, ...sells].sort((a, b) =>
    a.blockNumber < b.blockNumber ? -1 : a.blockNumber > b.blockNumber ? 1 : a.logIndex - b.logIndex);
}

Process successive confirmed ranges with a durable cursor. A Telegram buy alert can use TokensBought.args.recipient, tokensOut, grossQuoteUsed, and feeAmount; a sell alert uses TokensSold.args.seller, tokensIn, and netQuoteOut. Format each amount using its own token decimals. These curve events cover pre-graduation trades only; pool trades require the pool manager's swap events filtered by the resolved pool ID.

Test your integration

Before a release, cover wrong-network and changed-wallet requests, stale quotes, insufficient raw balance, approval versus execution, partial final buys, graduation transition, pending and replaced transactions, paginated NFT ownership, and per-asset reward claims.

Local fixtures and source-matched ABIs establish only the behavior they exercised. Publish an audit or deployment claim only with its matching report, scope, and deployed contract identity.

Back to top