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… | Use | Start with |
|---|---|---|
| Follow launches and trades as they happen | The real-time feed (SSE or WebSocket) | Real-time feed |
| Read a token, its market, trades, candles, holders | The public API v1 | API v1 |
| Quote and trade from your wallet | The SDK, or the API's swap transaction | Trade launch pools |
| Create tokens from code | The SDK's createToken, or the factory directly | Create tokens |
| Earn a fee on the trades you send | Builder codes | Builder codes |
| Index every launch yourself | Contract events and the indexing checklist | Data providers |
| Let an AI agent do it | The MCP server and llms.txt | AI agents |
| Run your own launchpad | The starter launchpad | Your own launchpad |
The fastest way in: follow launches and trade in 30 lines.
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.
Every read, the feed and the API work without a key. Limits apply per client.
| Access | How | Reads per minute | Feed connections |
|---|---|---|---|
| No key | Nothing to do | 300 | 5 |
| Free key | A wallet signs a message (API keys); counted per client address | 600 | 10 |
| Partner | Otomate's list: terminals, aggregators, data providers | 6,000 | 100 |
| OTO lock (later) | OTO the wallet locks: 100,000 / 1,000,000 OTO | 3,000 / 15,000 | 50 / 200 |
| Pay per request | x402: a premium request answers 402 with its price; pay it in USDC on Base and send it again | One 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.
One API host serves both chains: https://api.otomate.fun, with the chain in the path. Every answer names its chain.
| Ink | Robinhood Chain | |
|---|---|---|
| Chain ID | 57073 | 4663 |
| API | https://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.app | https://otomate-launcher-api-rh4663-production.up.railway.app |
| Uniswap v4 PoolManager | 0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32 | 0x8366a39CC670B4001A1121B8F6A443A643e40951 |
| Universal Router | 0x112908daC86e20e7241B0927479Ea3Bf935d1fa0 | 0x8876789976dEcBfCbBbe364623C63652db8C0904 |
| V4 Quoter | 0x3972C00f7ed4885e145823eb7C655375d275A1C5 | 0x8Dc178eFB8111BB0973Dd9d722ebeFF267c98F94 |
| StateView | 0x76Fd297e2D437cd7f76d50F01AfE6160f86e9990 | 0xF3334192D15450CdD385c8B70e03f9A6bD9E673b |
| Permit2 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
| WETH | 0x4200000000000000000000000000000000000006 | 0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73 |
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.
| Chain | Pair | What it is | Address |
|---|---|---|---|
| Ink | WETH, the default | Wrapped Ether; pay and receive ETH through the router | 0x4200000000000000000000000000000000000006 |
| Ink | OTO | Otomate's token | 0x0e04Ca25B5cd15047946d89E21cBcDBaE43A323B |
| Ink | wAAPLx | Apple xStock, in an ERC-4626 wrapper | 0x943BF64D566c32A2Bcd41AC92FB63C111cC9De8f |
| Ink | wNVDAx | NVIDIA xStock, in an ERC-4626 wrapper | 0xa8ddb5Cd96b5222AFe198316E9A57CAA642850D5 |
| Ink | wSPYx | S&P 500 ETF xStock, in an ERC-4626 wrapper | 0xE7E553Cd128F0011777323A0b44a7b96EA1CB540 |
| Ink | wTSLAx | Tesla xStock, in an ERC-4626 wrapper | 0xc3FdBe3A68EE5dE461D30415a8165cf9Aefe1171 |
| Ink | WIRED | A 404 token (ERC-20 and ERC-721), not a stock | 0xC7d264d906B488ef6660005af5450c979F58006c |
| Robinhood Chain | WETH, the default | Wrapped Ether; pay and receive ETH through the router | 0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73 |
| Robinhood Chain | OTO | Otomate's token | 0xAAed1a6A716Bb12c6037e8B170aB6dA29f28fD04 |
| Robinhood Chain | NVDA | NVIDIA Stock Token (ERC-8056) | 0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC |
| Robinhood Chain | AAPL | Apple Stock Token (ERC-8056) | 0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9 |
| Robinhood Chain | SPCX | SpaceX Stock Token (ERC-8056) | 0x4a0E65A3EcceC6dBe60AE065F2e7bb85Fae35eEa |
| Robinhood Chain | SPY | S&P 500 ETF Stock Token (ERC-8056) | 0x117cc2133c37B721F49dE2A7a74833232B3B4C0C |
| Robinhood Chain | QQQ | Nasdaq-100 ETF Stock Token (ERC-8056) | 0xD5f3879160bc7c32ebb4dC785F8a4F505888de68 |
| Robinhood Chain | TSLA | Tesla 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.
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:
| Contract | Ink, Robinhood Chain, Base and HyperEVM |
|---|---|
| Router (trades) | 0x173b1e9eCC06d53b239754602177F1707BFE76A1 |
| Factory | 0xd8661806D749a6360348a0D4f94f8db36B43420e |
| Config | 0x6Ab344Dcf639E99DB656cC8DeA76A9b5262ee545 |
| Hook | 0xF8b5e7A4F3cEb6ba9F9a619611360750A7D728EC |
| Distributor | 0x021851048acE99Bc0fc3F064Ad699a335de9d35c |
| Lens | 0x9f9Ef622f5dafDFd69C54CAb1bcCe4E8E71B3f2b |
| NFT vault | 0x5Ad95E0CEA3210f1F1E391f3A77405cBed128C9E |
| NFT gate | 0x5CD814311dD91B5fF7f4712Ae6c8A1BFb8319D70 |
| Forge | 0xdb82153b0A0C9D31Bc4D0F85fc0F40E6753b0D5e |
| Collection beacon | 0x8A6a2BCbA7Ea5371d2eba795b67f207D0fd2f49a |
| Royalty list (OpenSea) | 0xE83248a0Af0fb083E0091c1E867b5b81accF45f9 |
| Token deployer | 0x208518704cC6b27c34A3ECd78A9ecf20c4959aE6 |
| Upgrade timelock | 0xd14837BF79D73A4C73270889f8D822Bf7Fc77A5a |
| Otomate's Safe | 0xfA0ffF20DB7A2939cc402e395c1916Ba42f5E850 |
| Global NFT sink | Ink 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.
- 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) orfinal(its block is finalized). - The machine-readable reference is the OpenAPI document: openapi.v1.json, also served at
/api/v1/openapi.json.
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.
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.
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. Storefeed.cursorto 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.
- Launch protection. Buys in the first seconds after a launch pay an extra quote fee that decreases over time (
surchargein 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
reorgmarker tells you which events to drop;finalmarkers tell you what can no longer change.
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.
| Transport | URL | Use it from |
|---|---|---|
| Server-sent events | GET /api/v1/{chain}/feed | Any HTTP client, browsers (EventSource), the SDK |
| WebSocket | GET /api/v1/{chain}/feed/ws | WebSocket clients; JSON text frames |
| Pages | GET /api/v1/{chain}/feed/events | Polling, 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.
| Query | Values | Default |
|---|---|---|
topics | Comma-separated: launch, trade, graduation, mint_gate | All |
tokens | Comma-separated token addresses, at most 100 | All tokens |
cursor | An event's id: resume after it. A browser's EventSource may send Last-Event-ID instead. | — |
fromBlock | Start at this block | The 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"
Every event has the same envelope; data depends on its type.
| Field | Meaning |
|---|---|
v | 1 |
type | launch, trade, graduation or mint_gate |
id | The cursor. Store the last one you processed; send it back to resume. Treat it as an opaque string. |
chainId | 57073 or 4663 |
block | number (decimal string), hash, timestamp (Unix seconds) |
transactionHash, logIndex | Where the event happened |
status | mined (the latest block) or final (its block is finalized) |
confirmed | false only in the first seconds, while a trader's smart-account wallet may still be corrected |
token, poolId | The launch |
type | data |
|---|---|
launch | creator, 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) |
trade | side (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_gate | at (Unix seconds), l2Block, sqrtPriceX96 |
Fields may be added within v1; none will be removed or renamed.
| Marker | Meaning | What to do |
|---|---|---|
ready | Sent first: resumeFrom, confirmedBlock, finalizedBlock, your filter | Nothing |
reorg | {afterBlock}: the chain reorganized | Drop every event you stored with a block above afterBlock. The replacements follow. |
final | {block}: every event at or below it is final | Settle anything you kept provisional |
error | {code} just before the server closes the stream | Reconnect 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:
- On each event, store it with its block number, and store its
idas your cursor. - On
reorg, delete what you stored aboveafterBlock. - On
final, mark what is at or belowblockas final. - 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.
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));
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.
- Connections and connection attempts are limited per client and tier (Free to start); a refused connection answers
429or503withRetry-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.
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.
| Method and path | Answers |
|---|---|
GET /tokens?sort="e=&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=¤cy=&limit=&to= | OHLCV. interval: 1s, 1m, 5m, 15m, 1h, 4h, 1d. currency: quote or usd |
GET /tokens/{token}/holders | The 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-transaction | A 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/events | The real-time feed |
GET /tokenlist.json | The chain's launches as a token list (tokenlists.org) |
GET /status | The 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"
| Amounts | {"raw": "1500000000000000000", "decimal": "1.5"}: raw in the smallest unit, decimal exact. Never floats. |
| Prices | Decimal strings, the token's price in its pair (and in USD when known) |
| Time | timestamp: Unix seconds of the block |
| Finality | status: mined or final |
| Pages | Send cursor; read next (null on the last page) |
| Addresses | Lower case in answers; any case in requests |
| Rates | Basis points |
| Moderation | Lists show only launches the moderation shows. A hidden launch's sheet answers moderation: "hidden" with no name, symbol or metadata. |
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 answer a closed code: {"error": "API_QUERY", "field": "…"}.
| Status | Codes | Meaning |
|---|---|---|
| 400 | API_QUERY, FEED_QUERY, FEED_CURSOR_CHAIN | A malformed request; field names the culprit when known |
| 401 | FEED_KEY | An unknown, revoked or malformed key |
| 404 | API_NOT_FOUND | No such launch |
| 421 | API_CHAIN | The other chain: use the API in origin |
| 422 | QUOTE_REVERTED | The chain would refuse the trade; reason is the contract error (PartialFillUnsupported, PriceBelowLaunch, TradeBelowMinimum, …) |
| 429 | API_RATE_LIMITED, FEED_RATE_LIMITED, FEED_CLIENT_LIMIT | Over your tier's budget; wait Retry-After seconds |
| 503 | API_UNAVAILABLE, FEED_UNAVAILABLE, FEED_FULL | Temporarily unavailable; retry after Retry-After |
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/mewith 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 are per client: your key's wallet, or your address without a key. Each answer carries RateLimit-Policy: <requests>;w=60.
| Budget | Counts |
|---|---|
| Reads per minute | Each /api/v1 request; a quote or a swap transaction counts ten (they read the chain) |
| Feed connections | Open SSE and WebSocket connections |
| Feed connections per minute | New 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.
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.
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.
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:
| Way | You do | Best for |
|---|---|---|
| SDK | otomate.buy(token, amount) / otomate.sell(…) / otomate.buyExactOut(token, tokens) | TypeScript bots and fronts |
| API | POST /api/v1/{chain}/swap-transaction, then sign | Any language |
| Direct | Call 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.
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:
| Swap | Taken on the leg you specify | Taken 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):
| Error | When | What to do |
|---|---|---|
PartialFillUnsupported | The swap cannot be filled entirely: a sell larger than the quote the pool holds, or a swap that would stop at your price limit | Sell less; use the router's default limits |
PriceBelowLaunch | A sell would end below the launch price | Sell less |
TradeBelowMinimum | Under the pair's minimum trade (0 on today's presets) | Trade more |
Graduation does not move liquidity: the same pool keeps trading.
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.
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.
| Call | value | Approval first |
|---|---|---|
buy(key, amountIn, minTokensOut, recipient, deadline, hookData), paying ETH on a WETH pair | amountIn | none |
buy(…), paying the pair's ERC-20 (WETH, OTO, …) | 0 | the pair token to the router, amountIn |
buyExactOut(key, tokensOut, maxAmountIn, recipient, deadline, hookData), paying ETH | maxAmountIn (the rest comes back) | none |
buyExactOut(…), paying the ERC-20 | 0 | the pair token to the router, maxAmountIn |
sell(key, tokensIn, minAmountOut, recipient, deadline, hookData, nativeOut) | 0 | the 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: truepays 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.
hookDatagoes 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 emitsBought(payer, recipient, token, quoteIn, tokensOut, quoteRefund)orSold(payer, recipient, token, tokensIn, quoteOut). On an exact-out buy paid in ETH,quoteInis what was sent andquoteRefundwhat 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.
| Ink | Robinhood Chain | |
|---|---|---|
| Router | 0x112908daC86e20e7241B0927479Ea3Bf935d1fa0 | 0x8876789976dEcBfCbBbe364623C63652db8C0904 |
| v4 single-swap tuple | (PoolKey, zeroForOne, amountIn, amountOutMinimum, hookData) | (PoolKey, zeroForOne, amountIn, amountOutMinimum, minHopPriceX36, hookData) with minHopPriceX36 = 0 |
| Commands | 0x10 (V4_SWAP); 0x0b10 (WRAP_ETH, V4_SWAP) to pay ETH; 0x100c (V4_SWAP, UNWRAP_WETH) to receive ETH | Same |
| Actions | 0x060c0f (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.
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.
- Set
amountOutMinimumfrom 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.
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.
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.
| Parameter | Meaning |
|---|---|
quote | The 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). |
creatorBps | Your share of the trading fee, within the config's bounds |
creatorBurnBps | The part of your share burned in token at each trade instead of paid to you (at most creatorBps) |
withCollection | An NFT collection now, or never: it cannot be added later |
artwork | With a collection: 'deferred', the default and the only value while automatic artwork is off (Collection art) |
initialBuy | Your 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 |
salt | Picks 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:
| Pair | dustCovered(pair) false or reverting (today's factory) | dustCovered(pair) true (factory upgrade, not deployed yet) |
|---|---|---|
| WETH, paid in ETH | msg.value = fee + initial buy + QUOTE_DUST | msg.value = fee + initial buy (fee + initial buy + QUOTE_DUST is still accepted) |
| Another ERC-20 | msg.value = fee; allowance of initial buy + QUOTE_DUST | msg.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.
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 artwork | artworkCommitment | What happens |
|---|---|---|
'deferred' (the default) | 0x0000…0000 | After 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 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).
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 upload | Your own IPFS | |
|---|---|---|
| How | POST /api/v1/{chain}/metadata (the SDK's uploadMetadata) | Pin the JSON and images yourself |
| Images | Bytes as data:image/…;base64, (PNG, JPEG, WebP, GIF; 4 MiB each), or an https URL the API downloads | Yours, as ipfs:// raw CIDs |
| What you get | metadataURI, projectCommitment, the drawn logo (512 px) and banner, and for a collection its hidden URI | Your URI |
| Moderation | Before pinning; a refused upload answers 422 METADATA_CONTENT_REVIEW | After launch, on display |
| Limits | 20 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.
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.
- 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 (
predictTokentells 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'suploadProfile), then the profile authority callssetProfileURI(token, uri). The authority starts as the creator and moves in two steps (proposeProfileAuthority, thenacceptProfileAuthority). 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).
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.
The code is the swap's hookData, exactly 96 bytes: abi.encode(address builder, uint256 feeBps, bytes32 tag).
| Rule | Value |
|---|---|
| Highest fee | 200 bps (2 %) of the trade |
| Paid by | The trader, on top of the pool's fees, in the pool's pair, on buys and sells |
| The pool's own fee and burn | Exactly what they are without a code |
| Inside the launch protection window | Your 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 0 | Attribution only: the trade is credited to you, with no fee |
tag | Any 32 bytes you choose (a referral, a partner id, a campaign). The hook only copies it into BuilderFee; nothing checks it |
| A tag alone | builder = address(0), feeBps 0 and your tag: no fee, no account, the tag is logged |
| A malformed hookData | The 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 Router | The same: both pass the hookData to the hook unchanged |
| Otomate's router as builder | Never: 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).
- Each swap with a code emits
BuilderFee(poolId, builder, tag, fee, feeBps, seq)right after itsSwapFee(same pool, sameseq).builderandtagare 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 underclaimable.builder(per currency and hook), and each trade of/tokens/{token}/tradesand of the live feed carries itsbuilder.- On otomate.fun, the builder dashboard shows earnings per pair and a claim button.
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 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
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.
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).
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.
| Property | Value |
|---|---|
| Chains | Ink (57073) and Robinhood Chain (4663) |
| AMM | Uniswap v4, the chain's standard PoolManager |
| Pools per launch | One: 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 |
| Pair | WETH, 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 fee | 0. Every fee is the hook's. |
| Liquidity at creation | The whole supply, in two hook-owned positions (bonding and market) |
| Graduation | A one-way latch in the hook. The pool stays the same. |
| Quotes | The chain's V4 Quoter equals execution, with the swap's own hookData (empty, or a builder code) |
Ink (57073). Explorer: explorer.inkonchain.com.
| Contract | Address |
|---|---|
| Uniswap v4 PoolManager | 0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32 |
| Universal Router | 0x112908daC86e20e7241B0927479Ea3Bf935d1fa0 |
| V4 Quoter | 0x3972C00f7ed4885e145823eb7C655375d275A1C5 |
| StateView | 0x76Fd297e2D437cd7f76d50F01AfE6160f86e9990 |
| Permit2 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
| WETH | 0x4200000000000000000000000000000000000006 |
Robinhood Chain (4663). Explorer: robinhoodchain.blockscout.com.
| Contract | Address |
|---|---|
| Uniswap v4 PoolManager | 0x8366a39CC670B4001A1121B8F6A443A643e40951 |
| Universal Router (hop-price tuple) | 0x8876789976dEcBfCbBbe364623C63652db8C0904 |
| V4 Quoter | 0x8Dc178eFB8111BB0973Dd9d722ebeFF267c98F94 |
| StateView | 0xF3334192D15450CdD385c8B70e03f9A6bD9E673b |
| Permit2 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
| WETH | 0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73 |
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.
| Contract | Kind | Address |
|---|---|---|
| Launch pool hook | Plain contract, not a proxy | To be published |
| Factory | Upgradeable proxy (ERC-1967) | To be published |
| Config | Upgradeable proxy (ERC-1967) | To be published |
| Distributor | Upgradeable proxy (ERC-1967) | To be published |
| Lens (batched views) | Upgradeable proxy (ERC-1967) | To be published |
| Token deployer | Plain contract, not a proxy | To 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 isLaunchCreated.hookorfactory.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.
One creation is one transaction. Anyone can send it, a bot or another app included. Its logs come in this order:
- Token deployer. First the token's mint,
Transferfrom the zero address to the hook. ThenTokenDeployed(address indexed token, address indexed creator, address indexed recipient, uint256 supply). The recipient is the hook. - PoolManager
Initialize, then twoModifyLiquidityfrom the hook (its two positions). Any rounding dust is burned (Transferfrom the hook to the zero address). - 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. - Optional initial buy by the creator: PoolManager
Swap, hookSwapFeewithseq1, factoryInitialBuy(bytes32 indexed poolId, address indexed recipient, uint256 quoteIn, uint256 tokensOut). It carries no builder code, so it has noBuilderFee. - 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.
creatoris the address that calledcreate.collectionis the zero address for a token without an NFT collection.creationFeeis the fee paid in the chain's native currency.artworkCommitmentis the collection's first artwork direction. It is zero when the creator deferred the direction (the collection'sArtworkFinalizedgives 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.
Read the price from sqrtPriceX96, in the pool's real currency order:
currency0is the lower of the two addresses.sqrtPriceX96² / 2¹⁹²is the price ofcurrency0incurrency1, in raw units.- When the token is
currency0, that is the token's price in the pair. When the token iscurrency1, take its inverse. - Then adjust decimals: the token has 18; the pair has its own (
decimals(); 18 for WETH). One whole token costsraw × 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).
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);
| Field | Meaning |
|---|---|
poolId | The pool (topic 1) |
sender | The PoolManager's swap caller: the router, not the trader |
isBuy | true: pair in, token out. false: token in, pair out |
quoteAmount | The 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. |
tokenAmount | The trader's token amount. Buy: received, after the burn. Sell: paid, burn included. |
quoteFee | The pool's quote fee taken, in the pair, launch surcharge included. It never includes the builder fee. |
surcharge | The launch surcharge part of quoteFee |
burn | Tokens taken from this swap, to be burned |
sqrtPriceX96After, tickAfter | The pool's price and tick after the swap |
seq | The 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);
| Field | Meaning |
|---|---|
builder | The builder's account (topic 2). The zero address for a tag alone, with fee and feeBps 0 |
tag | 32 bytes chosen by whoever built the swap (topic 3). The hook only copies it; nothing checks it |
fee | The builder fee, in the pair, kept by the hook for the builder. It is inside quoteAmount, never inside quoteFee |
feeBps | The rate applied: the code's rate, lowered inside the launch surcharge window when needed (Fee rates in the snapshot). 0 is attribution only |
seq | The seq of the swap's SwapFee |
Order of the logs. For one swap:
- The PoolManager's
Swap. - The PoolManager's ERC-6909
Transferlogs that mint the hook's claims: the quote fee together with any builder fee, then the burn (each only when it is not zero). - The hook's
SwapFee. It is the hook's first log after theSwap. - The hook's
BuilderFee, when the swap carries a builder code. MintGateOpened, thenGraduated, when that swap latches them.
Rules for indexers:
- Several swaps of one pool in one transaction are separate trades. Each has its own
SwapFeeandseq. Do not merge them; do not drop them as ambiguous. - A gap in
seqmeans 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
Swapamounts are the AMM leg only, before the hook's fees. A buy paysSwappair amount +quoteFee+ builder fee and receivesSwaptokens −burn. A sell paysSwaptokens +burnand receivesSwappair amount −quoteFee− builder fee. The builder fee isBuilderFee.fee, 0 without a code.SwapFeealready gives the trader's side; you never need the PoolManager'sSwapfirehose. - 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:
- The token flow. On a buy, follow the token's
Transferout of the PoolManager after theSwap: the trader is the wallet that ends up with the tokens. On a sell, follow theTransferinto 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 theSwapamount 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. - 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
UserOperationEventfollows the trade in the transaction, the trader is itssender, never the bundler. Read the flow inside that operation's logs: a bundle's operations never mix. EntryPoints v0.60x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789, v0.70x0000000071727De22E5E9d8BAf0edAc6f37da032and v0.80x4337084D9E255Ff0702461CF8895CE9E3b5Ff108, the same on both chains. - A router's payer, only when the flow names no wallet. Otomate's router (
0x173b1e9eCC06d53b239754602177F1707BFE76A1, the same on every chain) emitsBought(address indexed payer, address indexed recipient, address indexed token, uint256 quoteIn, uint256 tokensOut, uint256 quoteRefund)orSold(address indexed payer, address indexed recipient, address indexed token, uint256 tokensIn, uint256 quoteOut)after the hook'sSwapFee. The payer is the router's caller (a wallet or a contract); tokens never pass through the router. - Fallback: without any transfer of the trade's tokens, the
Swapsender (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.
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:
| Position | Token is currency0 | Token is currency1 | Holds 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
bondingLiquidityandmarketLiquiditywith the standard v4 amount formulas (SqrtPriceMath.getAmount0DeltaandgetAmount1Delta, rounded down) over each range. The SDK'srangeAmounts(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 ≥ launchTickwhen the token iscurrency0,tickUpper ≤ launchTickwhen it iscurrency1(LiquidityBelowLaunchPriceotherwise). Donations are always refused. Outside positions appear as PoolManagerModifyLiquidityfrom 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
LaunchRegisteredis the launch pool. Progress and graduation read only that pool.
Progress is the pair held by the bonding position, over the threshold:
progressBps = min(10,000, bondingQuote × 10,000 / threshold), withthresholdfrom the snapshot.- On chain:
hook.bondingQuote(poolId)andhook.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).
- token is
Two one-way latches in the hook:
| Milestone | Where | Event |
|---|---|---|
| Mint gate | mintGateBps of the threshold (mintGateTick); 5,000 on today's presets | MintGateOpened |
| Graduation | The 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,
MintGateOpenedcomes first, thenGraduated, with the same values. sqrtPriceX96,tickandseqare those of the latching swap (itsSwapFeehas the sameseq).feesOrdinaryandfeesSurchargeare the pool's cumulative quote fees after that swap, ashook.feesAccrued(poolId)answers them.liquidityis the pool's active liquidity after it.- The protocol flag is authoritative: the
Graduatedevent, orhook.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,bondingProgressBpsanswers 10,000 whatever the price. l2Blockis the chain's own block:block.numberon 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.
Each pool's rates are numbers in its snapshot (LaunchRegistered, or hook.snapshot(poolId)). Read them per pool; show them as they are.
| Snapshot field | Meaning |
|---|---|
fees.quoteFeeBps | The 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.burnBps | The curve's token burn, taken in the token on every swap until graduation, and burned |
fees.creatorBurnBps | The creator's burn choice, the part of burnBps the creator chose to burn instead of receiving |
fees.marketQuoteFeeBps, fees.marketBurnBps | The quote fee and the token burn after graduation. Each is at most its curve rate. |
surcharge.startBps, duration, halvings | The 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,qandbare the pair that applies to a swap. - What a trader pays.
q + bof 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.burngives the amount. - The launch surcharge applies to buys during the first
durationseconds afterlaunchedAt(durationis at most 3,600). Withelapsedthe seconds sincelaunchedAt, it isstartBps >> floor(elapsed × halvings / duration), or, whenhalvingsis 0,floor(startBps × (duration − elapsed) / duration)(a straight line down to 0). It is 0 onceelapsed ≥ duration. It is capped at9,900 − q − b, so that the swap's total stays at most 99 %, then added toqfor that buy. The creator's initial buy in the creation transaction pays none.SwapFee.surchargeis its part ofquoteFee;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.feeBpsis the rate applied. A code withbuilder= the zero address andfeeBps0 is a tag alone. The tag is declarative: the hook only copies it. - LP fee is 0. The PoolManager's
Swap.feeis 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'srelease(also open to anyone). The hook sends what each share is owed to the distributor, burns the pending tokens and emitsReleased(bytes32 indexed poolId, uint256[5] amounts, uint256 burned, uint256 releasedOrdinary, uint256 releasedSurcharge).amountsfollow the shares' order; the treasury amount includes the surcharge.releasedOrdinaryandreleasedSurchargeare the pool's fees released so far, cumulative. When the PoolManager refuses the transfer, the hook emitsReleaseFailed(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.distributedoes 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).
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,symbolandimageare always present; the other fields are optional. Ignore fields you do not know (anotomateobject may follow).nameandsymbolequal the token's on-chainname()andsymbol(). 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.
- Minted once. The whole supply is minted at creation, to the hook (
TokenDeployed.supply, and the token'sTransferfrom the zero address). The token cannot mint again. - Burns are
Transferto the zero address, and lowertotalSupply():- the hook's release of swap burns (
from= the hook), when anyone callsdistributeor the hook'srelease; - the Forge, which burns tokens for sealed NFT mints (
from= the Forge); - rounding dust at creation (
from= the hook); - any holder calling
burn.
- the hook's release of swap burns (
- 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.
- Follow the config. Read
LaunchHookSetfrom the config's deployment block. Keep the list of every hook it named. - Subscribe to the hooks' logs only. One
eth_getLogsfilter on the hook addresses gives every pool (LaunchRegistered), trade (SwapFee, andBuilderFeefor builder codes), latch (MintGateOpened,Graduated), release and builder claim. Add the factory forLaunchCreated,TokenProfileUpdatedand the profile authority events. - Pair nothing with the PoolManager.
SwapFeeis a complete trade; aBuilderFeejoins it bypoolIdandseq. The PoolManager'sSwapis only an optional cross-check. - Read the snapshot once per pool, from
LaunchRegisteredorhook.snapshot(poolId). - Check
seqper pool. A gap means a missed log: read that range again. - Keep several swaps of one pool in one transaction as separate trades.
- Order by block, then log index.
- 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
reorgmarkers for this. - Mark finality. Keep records above the chain's
finalizedblock as provisional. Otomate's feed sendsfinalmarkers. - Take graduation from the protocol (
Graduated,graduated(poolId)), never from the price. - Attribute traders with the rules in Trades from SwapFee. Never
tx.fromalone. - Follow
Upgradedon the factory, config, distributor and lens proxies. Pin each hook's address and code hash per chain. - Exclude technical holders. Leave the PoolManager, every hook, the zero address and
0x000000000000000000000000000000000000dEaDout of holder counts. Otomate also leaves out the factory, the Forge, the distributor and the NFT vault. - Count burns on the launch token only. On Robinhood Chain, WETH logs an unwrap as a
Transferto the zero address: that is WETH, not a launch burn. - Keep amounts as integers: raw units, 18 decimals for tokens, the pair's own decimals for the pair. No floats.
- Find NFT collections only through
LaunchCreated.collectionorfactory.collectionOf(poolId), never through beacon events.
- 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
0x7353B76Ce326A3401162eDB1CD802f88e5EB60Ccon Ink, plus relay pools on0x92fdd38CdAc2345A689B2e0382047BF458152888. 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:
LaunchRegisteredon a hook the config named means this family.
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).
| Surface | Where | Notes |
|---|---|---|
| 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.json | The 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.json | tokenlists.org schema. Visible launches only, tagged bonding or graduated, with quote, hook, tickSpacing and launchedAt in extensions |
| Status | /api/v1/{chain}/status | The 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.
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.
| On pump.fun or PumpPortal | On Otomate | Notes |
|---|---|---|
| Bonding curve | The launch pool's bonding position: hook-owned Uniswap v4 liquidity from the launch price to the graduation price | Trade it with any v4 router; quote it with the chain's V4 Quoter |
| Bonding curve progress | progressBps in the API and the feed, bondingProgressBps(poolId) on the hook | The 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. |
subscribeMigration | Feed topic graduation | — |
| Creator fees, collected from pump.fun's programs | The creator share of each pool's quote fee, paid by the distributor to the creator's payout address | Anyone 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}/metadata | JSON 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 call | All three appear in the factory's LaunchCreated event |
action: "create" | The SDK's createToken, POST /api/v1/{chain}/create-transaction, or the factory directly | Anyone 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 sell | Answers the approvals and the transaction, unsigned. You sign. |
| Lightning Transaction API (sends for you) | No equivalent | Otomate never holds your keys |
subscribeNewToken | Feed topic launch | — |
subscribeTokenTrade with keys | Feed topic trade with tokens=0x…,0x… | At most 100 tokens per connection |
subscribeAccountTrade | No wallet filter on the feed | Filter trade events on trader, or read GET /api/v1/{chain}/wallets/{address} |
wss://pumpportal.fun/api/data | GET /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 address | Token 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 keypair | A salt (32 bytes) | No extra signer |
| SOL | WETH, 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, amount | amountIn, always the input | Exact input: the pair amount on a buy, the token amount on a sell |
amount: "100%" on a sell | Read balanceOf and pass it | — |
slippage (percent) | slippageBps (basis points) | 100 bps = 1 % |
priorityFee | None | Ordinary L2 gas, set by your wallet |
pool: pump, pump-amm, raydium, auto… | None | One pool per token, the launch pool, before and after graduation |
Dev buy (amount with create) | initialBuy, in the creation transaction | Exempt from the launch surcharge that applies to other buys in the first seconds |
| One chain | Two chains, one API origin each | Paths /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).
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.
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).
- 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
minedorfinal; 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.
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.
| Tool | What it gives the agent |
|---|---|
| The MCP server | Ready-made tools for Claude Code, Cursor and any MCP client |
llms.txt and llms-full.txt | These documents as plain text, to read in one request |
| The prompt template | A starting prompt to build a launchpad front end in one go |
otomate-mcp reads and builds; it never signs and never holds a key. The user signs every transaction in their own wallet.
| Tool | Does |
|---|---|
list_launches | Lists launches (new, graduating, graduated, trending, gainers), with filters and search |
get_token, get_trades, get_candles, get_holders | Reads one launch |
get_creator, get_wallet | A creator's track record; a wallet's positions and what it can claim |
follow_launches | Reads the real-time feed from a cursor (call it again with the cursor it returns) |
quote | Quotes a buy or a sell, with every fee |
build_swap | Builds a swap ready to sign (Ink or Robinhood Chain), with your builder code if set |
upload_metadata, build_create | Uploads a token's metadata and builds its creation, ready to sign |
builder_code | Encodes a builder code (address, rate, tag) and builds its claims and the claim-for opt-out |
collection_artwork | Reads whether a collection's art is final (the creator chooses it on the Otomate site) |
prepare_trade | Prepares a trade the user signs on otomate.fun: a signing link and a summary (Ink or Robinhood Chain, v2 launches and OTO) |
trade_status | Follows 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.minimumOutFromisonchain),quote.amountOutandquote.executionPriceare the chain's too: show those withminimumOut. An API quote more than 1 % below the chain's also adds awarning.
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).
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.
- The assistant calls
prepare_tradewith 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. - It shows the user the
summaryand thesigningUrl, a link tohttps://www.otomate.fun/sign. - 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.
- 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.
- /llms.txt: a short index of these documents, with links.
- /llms-full.txt: every developer page in one text file, with the API reference summary, types and errors.
- /docs/kit/openapi.v1.json: the API's exact schema.
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.
- x402. Where enabled, an API request above the budget answers
402 Payment Requiredwith 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.permittakes 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 hassignPermitandpermitCall. - 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.
- 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
tois the official factory and a swap'stoOtomate's router or the chain's Universal Router before signing. The MCP server'sbuild_swapchecks 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.
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.
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.
The audit report of the launch pool contracts is published here once the audit is complete.
These are facts of the code, for anyone assessing a launch. The addresses are in Chains and contracts.
- The token cannot be upgraded. It is a plain ERC-20 with 18 decimals, burns and
permit(EIP-2612).permittakes 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.
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
distributeor 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.
- 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).
- 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, orneverGraduatedMintedDelay()once one does. Read both, andneverGraduatedSweep(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.
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.
| I want to… | Tutorial | Result |
|---|---|---|
| Launch a token | Launching a token | A confirmed transaction and the new token and curve addresses. |
| Buy a token | Buying a token | A quote, any required approval, and a confirmed purchase. |
| Sell a token | Selling a token | An exact token allowance and a confirmed sale. |
| Buy during creation | Launch and buy in one transaction | Creation and an initial purchase in the same transaction. |
| Discover tokens | Discover tokens on-chain | Factory events and verified project records. |
| Trade from a bot | Trade Otomate tokens from a bot | Curve and graduated-pool trades with tested samples. |
| Integrate collections | Collections, rewards, and indexing | Collection 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.
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.
| Contract | Ink address |
|---|---|
| Project factory | 0x9F317a4307E7496dfb839CfE8aa063a94c99aECF |
| Token launch contract | 0xd63c58450E5b5E8DbB681641e84449aA25730919 |
| Contract | Robinhood Chain address |
|---|---|
| Project factory | 0x28e47B1cDd5Ef181731Ff9fC18e517baEe54CBc6 |
| Token launch contract | 0x7eB0458CA2292919c7a53D881fB9f0Aa96e479a9 |
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.
| Contract | JSON ABI | TypeScript ABI |
|---|---|---|
| Curve: quote, buy, sell and trade events | JSON | TypeScript |
| Factory: project reads and creation events | JSON | TypeScript |
| Creation: commitment, predictions and launch tuples | JSON | TypeScript |
| Token launch contract: platform quote and launch | JSON | TypeScript |
| Launch configurations | JSON | TypeScript |
| Quote assets and presets | JSON | TypeScript |
| Market readiness and pool discovery | JSON | TypeScript |
| Companion rewards and claims | JSON | TypeScript |
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).
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.
| Input | Where it comes from |
|---|---|
| RPC URL | Your configured provider for chain 57073 (Ink) or 4663 (Robinhood Chain). |
| Factory and entitlement | The proxy address table above for your chain. |
| Token and curve | factory.project(projectId) for the selected project. |
| Quote asset | curve.quoteAsset(); use its own ERC-20 decimals. |
| Native/WETH eligibility | The deployment's verified wrapped-native asset; never assume any ERC-20 pair accepts native ETH. |
| Recipe ID, hash and preset | Read available launch configurations. |
| Metadata and economics | The 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.
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.
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.
| Field | What to supply |
|---|---|
factory, entitlement | Verified addresses for the same admitted deployment. |
recipeHash | The reviewed hash of the selected recipe. The function compares it with the factory. |
intent.nonce | A new 32-byte nonce for this project; persist it before preparation and reuse it for recovery. |
intent.recipe | Approved recipe ID as bigint. |
intent.quote | Approved quote-asset contract. For a native-funded launch this is the configured wrapped-native asset, not the zero address. |
intent.preset, intent.presetHash | The selected asset preset and its reviewed hash. |
intent.mode | 0 for token-only, 1 for token with Companions; this is not the economics creationMode. |
intent.name, intent.symbol | Your token's name and symbol, matching the prepared metadata. |
metadataURI | Your token metadata URI, satisfying the selected contract’s URI requirements. |
hiddenURI, finalDirectionCommitment | The prepared sealed-art metadata and direction commitment required by the selected collection mode. Do not substitute arbitrary placeholders. |
economics | The complete creation fee configuration described below. |
maximumPlatformFee | Maximum 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.
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.
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.
The economics tuple contains trading, royalty, creatorExtraBps, creatorExtraBuybackBps, baseCreatorBuybackBps and creationMode. Both policies contain totalBps, six allocationBps, three creatorWeights, six recipients and companions.
allocationBpsare fee rates assigned to each destination; their sum is the policy'stotalBps.creatorWeightssplit the creator allocation between wallet, project buyback and Companion rewards. The weights sum to 10,000.creatorExtraBpsis the additional creator trading rate.100basis points is 1%.- Buyback settings split a creator allocation; they do not add that percentage to the whole trade.
creationModedistinguishes 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.
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.
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 };
}
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.
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.
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.
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
buyNativeWithFeePolicy103and attach ETH asvalue. This is available only for the verified wrapped-native pair. - For WETH or another admitted ERC-20, approve the curve, then call
buyWithFeePolicy103. Nativevalueis 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.
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.
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.
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 field | Meaning |
|---|---|
grossQuoteUsed | Buy input that the quoted fill would consume. |
grossQuoteRefund | Unused buy input, including a purchase larger than the remaining curve allocation. |
principalIn | Buy input credited to the curve after fees. |
feeAmount | Fee in raw quote-asset units. |
tokensOut | Project tokens returned by a buy. |
grossQuoteOut | Sell proceeds before fees. |
netQuoteOut | Sell proceeds after fees; use this to calculate the sell minimum. |
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.
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.
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.
| Situation | Next step |
|---|---|
| Approval confirmed, buy or sell not sent | Fetch a new quote, then submit the trade; approval is not a swap. |
LaunchInactive104 | Wait for the project to become active. |
InvalidFeeWitness | Read a fresh quote and witness at one block, then review again. |
| Expired deadline or output below minimum | Requote with the current market; keep the user's chosen slippage. |
| Unsupported native asset | Use the ERC-20 path for the actual quote asset. |
| Receipt timeout | Inspect the saved hash and wallet nonce before doing anything else. |
| Curve graduated | Resolve 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.
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.
| Phase | Path | Calls | Sample |
|---|---|---|---|
| Bonding curve | The token's curve | buyWithFeePolicy103, sellWithFeePolicy103 and their native versions | curve.ts |
| Graduated | Otomate router | buy, sell and their native versions | router.ts |
| Graduated, with a relay pool | Uniswap Universal Router | V4_SWAP on the relay pool, empty hookData | relay-pool.ts |
All examples use TypeScript and viem on Ink (chain ID 57073). Read Tested samples to run them.
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).
| Contract | Address |
|---|---|
| Project factory | 0x9F317a4307E7496dfb839CfE8aa063a94c99aECF |
| Market coordinator | 0xeFf5778110A9f6EC8a51835cA338710e5dAEa50B |
| Otomate router | 0x8Fb92e7a4a0680272eE012C0F8301Ce5eE84c9B0 |
| Graduated pool hook | 0x7353B76Ce326A3401162eDB1CD802f88e5EB60Cc |
| Relay pool hook | 0x92fdd38CdAc2345A689B2e0382047BF458152888 |
| Uniswap v4 PoolManager | 0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32 |
| Uniswap Universal Router | 0x112908daC86e20e7241B0927479Ea3Bf935d1fa0 |
| Permit2 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
| Uniswap v4 Quoter | 0x3972C00f7ed4885e145823eb7C655375d275A1C5 |
| Uniswap v4 StateView | 0x76Fd297e2D437cd7f76d50F01AfE6160f86e9990 |
| WETH | 0x4200000000000000000000000000000000000006 |
| $OTO | 0x0e04Ca25B5cd15047946d89E21cBcDBaE43A323B |
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.
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
| Parameter | Values |
|---|---|
phase | CURVE (bonding) or POOL (graduated) |
sort | newest, oldest, name, marketCap, bondingProgress (with phase=CURVE), recentBuys |
pair | all, weth, oto, stock (xStocks) |
q | Name, symbol or token address, up to 80 characters |
limit | 1 to 50, default 30 |
after | The 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.
| Check | Meaning |
|---|---|
curve.graduated() is false | Trade on the curve. |
curve.readyToGraduate() is true | The curve is full and the pool is being created. Buys revert with InsufficientLiquidity and sells with GraduationPending; wait. |
market.marketReady(token) is true | Trade on the pool. |
graduator.poolKey(curve).hooks | The 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 };
}
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.
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.
- ETH:
buyNativeWithFeePolicy103with the amount asvalue, andsellNativeWithFeePolicy103to receive ETH. They work only when the pair is WETH (UnsupportedNativeAssetotherwise). - 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;
quoteBuyForincludes 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.
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.
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.
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) andsellNative(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
quoteBuyFororquoteSellwitheth_call; they are notview.
BuyParams field | Value |
|---|---|
launchToken | The token. |
paymentAsset | The pair asset (curve.quoteAsset()). |
expectedPool | true: the token trades on its pool. |
amountIn | Your input. |
minQuoteOut | amountIn: paying with the pair asset needs no conversion. |
minTokensOut | The quoted tokens minus your slippage. Must be above 0. |
minRefundOut | 0. |
deadline | Chain time, at most 5 minutes ahead (InvalidDeadline). |
recipient | Who receives the tokens. |
routeHash, refundRouteHash | 0x0…0, with empty route and refundRoute arrays. |
expectedAdmissionHash | router.admissionHash(token, true), read just before. |
expectedRouteState | router.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 };
}
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.
| Token | Pair | Relay pool ID |
|---|---|---|
VOYAGE 0x37987EbD22A7c41746fc57C7D6274355EABbBB6a | WETH | 0x0e39184e21122c03d948231845c5b2c03bbe978cf86eae1392647a5828476627 |
IKT 0xbC9749aF41301eEe559DFE12048F1812A235b5a8 | WETH | 0x61173065c8bfc688e9dbf955580049ac754bf00dd726bbf5993b7bb092651ca8 |
Pepoto 0x8313b2ebCFFaDdaC888f423EdC250e419DE6947e | $OTO | 0xc2ad90a3689fd40ef97338156b7e818c43b6b782b1f0417db261827e8eaabf57 |
DICKAPRIO 0x3b684866f432D3f0818e85c2b10DE1D836D16A4B | $OTO | 0x8e7445fd351f2418225991db0e00c52728b0bf24a3b7f06976c934c069d586e4 |
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
executewith commandV4_SWAP(0x10) and actionsSWAP_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:
quoteExactInputSingleon the v4 Quoter with emptyhookData, througheth_call. - No price limit: the relay pool ignores
sqrtPriceLimitX96. YouramountOutMinimumis 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.
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 readgraduator.poolKey(curve).hooks. - An optional witness on the curve.
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).
| Error | Selector | Where | What to do |
|---|---|---|---|
InvalidFeeWitness | 0xe71501a9 | Curve, router, graduated pool | Read the witness and the quote again at one block. On a graduated pool, use the router or a relay pool. |
BelowMinimum | 0x860b82a9 | Curve | Trade at least economics().minimumTrade. |
InvalidFeeAmount | 0x52338c80 | Graduated pool, wrapped | Below the minimum trade on an $OTO pool. |
SlippageExceeded | 0x8199f5f3 | Curve | The price moved. Quote again. |
BalanceInvariantFailed | 0xe7a163cc | Router | Output below your minimum: the price moved. Quote again. |
V4TooLittleReceived | 0x8b063d73 | Universal Router | Output below amountOutMinimum. Quote again. |
Expired / DeadlineTooFar | 0x203d82d8 / 0x48f0fae6 | Curve | Deadline in the past, or more than 30 minutes ahead. |
InvalidDeadline | 0x769d11e4 | Router | Deadline in the past, or more than 5 minutes ahead. |
TransactionDeadlinePassed | 0x5bf6f916 | Universal Router | The deadline passed before inclusion. |
LaunchInactive104 | 0x4b1837d9 | Curve | The token is not trading yet. |
GraduationPending | 0x4ed36f02 | Curve | The curve is full. Wait for marketReady(token). |
AlreadyGraduated | 0xe6a0d45f | Curve | Trade the pool. |
InsufficientLiquidity | 0xbb55fd27 | Curve | The amount is too large or too small for the curve, or the curve is full. |
UnsupportedNativeAsset | 0x35f7ca6c | Curve | Native ETH on a non-WETH pair. Use the ERC-20 call. |
InvalidPhase | 0x9a36fd9c | Router | expectedPool does not match the token's phase. |
InvalidConfiguration | 0xc52a9bd3 | Router, curve | Read admissionHash again. If it persists, the market is unavailable. |
InvalidRoute | 0x84e505d2 | Router | Wrong route state or route hash. From a quote: the pool refused the swap, for example below the minimum trade. |
InvalidAmount | 0x2c5211c6 | Router | A zero amount or minimum, or a refund minimum on a pool trade. |
Paused | 0x9e87fac8 | Curve, router, pool | Trading on this market is paused. Retry later. |
RoutingUnavailable | 0xc3e2a9fb | Router | The router is not available. Retry later, or use a relay pool. |
PartialFill | 0xd964f528 | Relay pool, wrapped | The graduated pool could not fill the exact amount. Use a smaller amount. |
AllowanceExpired / InsufficientAllowance | 0xd81b2f2e / 0xf96fb071 | Permit2 | Renew 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.
- 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_callthe 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.
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.
| File | Contents |
|---|---|
| otomate.ts | Addresses, curve ABI, connect, send, approvals, minimums |
| discover.ts | Catalogue, ProjectCreated events, phase and hook |
| curve.ts | Curve buy and sell, native or ERC-20 |
| router.ts | Graduated tokens through the Otomate router |
| relay-pool.ts | Graduated tokens through a relay pool and the Universal Router |
| package.json | viem 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.
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.
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.
Use the ABI matching each project generation. Useful event families include:
| Event | What to record |
|---|---|
ProjectCreated | Entitlement ID, owner, token, commitment, configuration hash, and recipe. |
TokensBought | Actual gross quote used, fee, token output, and reserve update. |
TokensSold | Token input, gross quote, fee, net output, and reserve update. |
TradeFeePolicy103 | The execution's fee-policy witness. |
MarketGraduated | Token, pool ID, LP position ID, and liquidity. |
| Collection ERC-721 transfers | Mint, current ownership, transfers, and burns for inventory. |
| Reward claim events | Asset, 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.
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.
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.