---
name: realm-token-launch
description: How to create a token on Realm programmatically — authentication, contract calls, metadata API, and end-to-end flow.
user_invocable: true
---

# Creating a Token on Realm

This document describes the complete flow for creating a token on the Realm launchpad programmatically (outside the frontend UI).

## Overview

Token creation follows a strict order:

1. **Build & precompute** — build the `createToken` transaction and precompute its hash (sign it offline).
2. **Submit metadata** — POST the token metadata (name, image, socials) to the Realm API, keyed by that precomputed hash.
3. **Broadcast** — broadcast the signed transaction on-chain.

**Order matters.** Steps 2 and 3 must be performed one after the other with no delay between them. The metadata is keyed by the transaction hash, which you know before broadcasting because the signed transaction's hash is deterministic.

> **Tip:** uploading an image is the slowest part of the metadata call. To launch instantly, omit the image — pass an already-pinned `imageUrl` (IPFS) in step 2, or skip it entirely and attach it within 10 minutes via `PATCH /api/tokens/image` (see the last section).

**Venue:** launch through `RealmFactoryUniV4Direct` — tokens trade on Uniswap V4 from the first block.

**Test first:** every step works end to end on Robinhood testnet against the testnet API. The [runnable scripts](#runnable-scripts) default to testnet.

### Networks

Each network has its own API: the testnet API only accepts `chainId` 46630, the mainnet API only 4663. Use the API base as the host for every endpoint below.

| | Robinhood testnet | Robinhood mainnet |
|---|---|---|
| `chainId` | 46630 | 4663 |
| API base | `https://realm-rh-dev.vercel.app` | `https://realm.trade` |
| RPC | `https://rpc.testnet.chain.robinhood.com` | `https://rpc.mainnet.chain.robinhood.com` |
| `RealmFactoryUniV4Direct` | `0xF0399F67e359c08A18816E466364797b7fBc4df4` | `0xC763b1795DaBe2D2336EbA214d16969EBc5db27F` |
| All contracts | [deployments.robinhood.testnet.md](https://github.com/RealmLaunchpad/realm-contracts/blob/main/deployments.robinhood.testnet.md) | [deployments.robinhood.mainnet.md](https://github.com/RealmLaunchpad/realm-contracts/blob/main/deployments.robinhood.mainnet.md) |

---

## Step 1: Authenticate

All API calls require a JWT Bearer token. Obtain one by signing a message with your wallet.

### Request

```
POST /api/auth/wallet
Content-Type: application/json

{
  "address": "0xYourWalletAddress",
  "signature": "<signature>",
  "message": "Sign in to Realm\nTimestamp: <unix_ms>"
}
```

The message must contain `Timestamp: <unix_ms>` where the timestamp is within the last 5 minutes.

### Response

```json
{ "token": "eyJhbGci..." }
```

Use this token as `Authorization: Bearer <token>` in all subsequent API calls. Tokens expire after 30 days.

---

## Step 2: Build the Create Token Call (on-chain)

### The Factory

Tokens are created by `RealmFactoryUniV4Direct`. It seeds the circulating supply into one V4 pool
per pair (native and/or any whitelisted ERC20 quote, up to 3) as single-sided liquidity, opening at a
fixed 1.125 ETH-equivalent market cap priced at launch from each quote's own pool. The creator's LP
fee share is live from the first swap. Every knob (fee split, tax, anti-sniper, creator vaults, dev
buy) is a struct argument; disabled features are passed as zeroed structs or empty arrays.

### Contract Addresses

Factory addresses are in the [Networks](#networks) table. All contracts are verified on Blockscout — fetch ABIs from there, or use the inline ABIs in the [runnable scripts](#runnable-scripts).

### RealmFactoryUniV4Direct

There is no bonding curve: the circulating supply is seeded into one Uniswap V4 pool per pair as
single-sided liquidity, and the token trades from the first block.

```solidity
function createToken(
    DirectTokenSetup                setup,
    DirectPair[]                    pairs,
    TaxConfigsWithDirectAllocation  taxAllocationConfigs,
    AntiSniperConfigs               antiSniperConfigs,
    CreatorVault[]                  creatorVaults,
    DevBuy                          devBuy,
    address                         referral
) payable returns (address token);

struct DirectTokenSetup {
    string     name;
    string     symbol;
    bytes32    salt;              // any value; namespaced by msg.sender (see Salt)
    FeeShare[] feeShares;         // shares sum to 10000
    bool       renounceOwnership; // true: deployed ownerless; false: owner = msg.sender
    uint16     lpFeeBps;          // 100 (1%) or 50 (0.5%), else InvalidLpFeeBps
}

struct DirectPair {
    address quote;        // address(0) for the chain's native coin, else a whitelisted ERC20
    uint16  weightBps;    // this pool's share of the circulating supply
}

struct DevBuy {
    uint8         pairIndex;    // which pool the buy runs on
    PoolKey[]     route;        // MUST be empty — the factory derives the route from the whitelist
    uint256       minQuoteOut;  // floor on the in-launch conversion; 0 to disable
    uint256       quoteAmount;  // raw quote the factory pulls from the caller
    SupplyShare[] recipients;   // who receives the bought tokens; shares sum to 10000
}

struct TaxConfigsWithDirectAllocation {
    uint16 buyTaxBps;
    uint16 sellTaxBps;
    uint32 taxDurationSeconds;
    bool   startTaxFromLaunch;
    uint16 buyTaxDecayStartBps;
    uint16 sellTaxDecayStartBps;
    uint32 taxDecayDuration;
    EarningsAllocationMultiConfig earningsAllocation;
    bytes[] quoteRoutes;   // positional to `pairs`: each quote's native->quote V4 route, needed
                           // only where a dividends leg must leave that quote; "0x" otherwise,
                           // trailing empties may be omitted. InvalidQuoteRoutes if malformed.
}

struct EarningsAllocationMultiConfig {
    uint16    burnBps;
    uint16    dividendsBps;
    uint16    liquidityBps;
    address[] dividendTokens;
    uint16[]  dividendWeightsBps;
    bytes[]   dividendRoutes;
}
```

`AntiSniperConfigs`, `CreatorVault`, `FeeShare` and `SupplyShare`, and the tax and
earnings-allocation fields, are documented under [Struct reference](#struct-reference).

**Pairs.** At most `MAX_PAIRS()` (3) entries, quotes distinct, every `weightBps > 0` and the set
summing to exactly `10000` — otherwise `InvalidPairs`. A quote that is not on
`RealmAssetsWhitelist` reverts with `QuoteNotSupported`, and one whose decimals the factory cannot
handle with `UnsupportedDecimals`.

**Funding the dev buy.** Three mutually exclusive modes, all on `pairs[devBuy.pairIndex]`:

| Pool's quote | How it is paid | `quoteAmount` | `minQuoteOut` | `msg.value` |
|---|---|---|---|---|
| native (`address(0)`) | `msg.value` | `0` | `0` | the amount to spend |
| ERC20 | the quote itself, pulled by the factory | the raw amount (approve the factory first) | `0` | `0` |
| ERC20 | native, converted in-launch | `0` | floor on the conversion, raw quote decimals | the ETH to convert |

In the third mode the factory spends the WHOLE proceeds of the conversion on the buy, so
`minQuoteOut` is a floor on what the buy receives, not a refundable slippage cap. It converts
through the quote's own whitelist price pool(s) and reverts with `DevBuyRouteUnavailable` when that
quote has no V4 route back to native. Leave `recipients` empty for no dev buy; a mismatch between
the funding and the mode reverts with `InvalidDevBuy`.

**Where a pool opens.** Fixed and factory-controlled — there is no launch price in the calldata:

```solidity
function LAUNCH_MARKET_CAP_X18() view returns (uint256);   // 1.125e18: the opening market cap of
                                                           // EVERY pair, native-denominated
function previewLaunchTick(address quote)
    view returns (int24 tick, uint256 priceX18, uint256 marketCapX18);
```

`previewLaunchTick` prices `LAUNCH_MARKET_CAP_X18` against `quote` at the whitelist's LIVE rate and
returns where that pool would open: `priceX18` and `marketCapX18` are in WHOLE quote units scaled by
1e18. It reverts (`QuoteNotSupported`) under exactly the conditions `createToken` would, so treat a
revert as "this pair cannot be launched", not as a transient read failure.

A native pair is priced 1:1 and always opens at exactly 1.125 ETH; an ERC20 pair opens at whatever
1.125 ETH is worth in that quote at its live whitelist rate.

### Struct reference

Total supply is always `1_000_000_000e18`. All bps values are basis points (`10000` = 100%). To disable a feature, pass a zeroed struct (tax, anti-sniper) or an empty array (creator vaults, dev-buy recipients). `DirectTokenSetup`, `TaxConfigsWithDirectAllocation`, `EarningsAllocationMultiConfig` and `DevBuy` are laid out above.

```solidity
struct FeeShare {
    address account;
    uint256 shares;               // bps, > 0; array sums to exactly 10000
    bool    directFeesEnabled;    // at most ONE entry may be true
}

struct SupplyShare {
    address account;
    uint256 shares;              // bps, > 0; array sums to exactly 10000
}

struct AntiSniperConfigs {
    uint16    maxBuyPerTxBps;          // 10..300 (0.1%..3% of supply)
    uint16    maxWalletBps;            // 10..300, and >= maxBuyPerTxBps
    uint40    protectionWindowSeconds; // 0 disables; else 60..86400 (1min..24h)
    address[] whitelist;               // <= 20 addresses; bypass caps in the window
}

struct CreatorVault {
    address owner;
    uint256 supplyBps;       // non-zero multiple of 500 (5%); sum across vaults <= 3000 (30%)
    uint256 cliffSeconds;    // pure lock-up before vesting
    uint256 vestingSeconds;  // linear vesting after the cliff
}
```

Field notes:

- **feeShares** — non-zero, unique accounts; every `shares > 0`; the array sums to exactly `10000`. At most one entry may set `directFeesEnabled` (fees forwarded on each accrual instead of pull-claimed).
- **renounceOwnership** — `true` deploys the token ownerless; `false` makes the sender its owner.
- **lpFeeBps** — the pools' LP fee: `100` (1%) or `50` (0.5%), else `InvalidLpFeeBps`.
- **taxConfigs** — static tax and launch-tax decay are independent; set either, both, or neither. The effective rate a trade pays per direction is `max(decay, static)`. Static tax is capped by `lpFeeBps + tax <= 500`: up to 400 bps with a 1% LP fee, 450 bps with 0.5%. A direct token is live from launch, so `startTaxFromLaunch` starts the window at creation either way. A configured decay start must be strictly greater than the direction's static rate; combined (buy + sell) decay start `<= 2000` bps.
- **earningsAllocation** — splits earnings (swap tax + the creator's share of LP fees) between the fee receivers and the on-chain buckets. The three bps must sum to `<= 10000`; the fee receivers take the remainder, so all-zero means 100% to them. No static tax is needed (the LP-fee share is a permanent stream), but an ERC20 quote whose dividend buffer cannot be routed back to native reverts with `MissingQuoteRoute(quote)` — supply it in `quoteRoutes`. All three buckets accrue on each earnings accrual and are settled out-of-band by `processBurn()` / `processLiquidity()` / `processDividends()` on the token.
- **dividendTokens / dividendWeightsBps / dividendRoutes** — the 1 to 3 assets holders are paid in, chosen at creation and **permanent** (clones cannot be patched). Each asset is an independent stream with its own weight, and the arrays are positional to each other. `address(0)` = the chain's native currency, `address(type(uint160).max)` = the token itself, any other address = an ERC20 the token buys on each distribution. Weights are shares *of the dividends slice*: each must be non-zero and they must sum to exactly `10000`. Assets must be distinct (`InvalidDividendAssetSet`), the self-token sentinel is legal only as the sole asset (`SelfTokenDividendMustBeSole`), and naming any asset while `dividendsBps == 0` reverts `DividendAssetWithoutShare`. Every third-party ERC20 in the set needs a route, registered at creation with `RealmSwapper`. There is no whitelist and no review: you name the pools your token converts through, as `dividendRoutes[i]`, and the registry checks only the route's shape, reverting `MalformedRoute` if it does not end at the asset. `0x02` alone selects the asset's WETH pair on Uniswap V2; a leading `0x04` carries an abi-encoded `Hop[]` (`{ currency, fee, tickSpacing, hooks }`) for Uniswap V4, starting from native; a leading `0x03` carries Uniswap V3's packed path from WETH. Native and the self-token take an empty route (`0x`); an empty route for an asset the token has to buy reverts the creation with `MissingDividendRoute(asset)` unless the registry already holds an admin route for it. Quote liquidity yourself before choosing: only a registry admin can repoint a route afterwards.
- **dividends, once live** — holders earn continuously in proportion to `balance x time`; there are no rounds, snapshots or minimum-balance rules, and one stream per configured asset. `previewDividend(holder, assetIndex)` reads what a holder is owed at the current block (the one-argument form answers for asset 0); `dividendAssetCount()` and `dividendAssets(i)` enumerate the set and its per-asset stream state. Payouts are pushed per asset by a keeper calling `processDividends(assetIndex, fund, amount, minOut, holders)` (the two-argument `(minOut, holders)` form services asset 0; direct V4 tokens also take `(assetIndex, quote, amount, minOut, holders)` to convert an ERC20 quote's buffer), and any holder a batch skips can pull every asset at once with `claimDividends()`. Each extra asset costs roughly 5,500 more gas on every transfer of the token, so the size of the set is a permanent cost to holders, not just to the deployer.
- **devBuy.recipients** — splits the dev-buy tokens across accounts; the *amount* bought is set by the funding (see **Funding the dev buy** above). Pass `[]` when not buying.
- **antiSniperConfigs** — opt-in via a non-zero `protectionWindowSeconds`. To disable, pass all zeros / empty array (sentinel: if the window is 0, every other field must be 0/empty).
- **creatorVaults** — optional vesting vaults that lock part of the supply at deploy. Empty array = none. Max 5 vaults; the sum of `supplyBps` `<= 3000` (30%).
- **referral** — reserved for future relayer payouts. Nothing is wired to it on-chain yet — a non-zero value only emits `TokenReferral(token, referral)`, with no storage or payout. **Pass `address(0)` for now.**

### Salt

The factory deploys through CREATE2, but the token address has no required pattern: any `bytes32` salt works, so pick a random one. The factory namespaces it by the deployer — the effective CREATE2 salt is `keccak256(abi.encodePacked(msg.sender, salt))` — so a salt seen in the mempool can't be front-run into the same address by another sender.

```javascript
import { toHex } from "viem";

const salt = toHex(crypto.getRandomValues(new Uint8Array(32)));
```

### Validation Rules

All errors are 4-byte custom errors. The ones most likely to revert:

- **name** — non-empty (metadata API caps it at 96 chars).
- **symbol** — non-empty, `<= 96` bytes on-chain (metadata API caps it at 96 chars).
- **feeShares / devBuy.recipients** — every share `> 0`; unique non-zero accounts; must sum to exactly `10000`.
- **buyTaxBps / sellTaxBps** — `lpFeeBps + tax <= 500`: `<= 400` at a 1% LP fee, `<= 450` at 0.5%.
- **taxDurationSeconds** — `0` to disable, else up to ~120 years; if non-zero, a buy or sell tax must be set. No fee-receiver or ownership constraints at any duration.
- **taxDecayDuration** — `0` to disable, else `<= 20 min`; combined decay start `<= 2000` bps.
- **lpFeeBps** — must be `100` or `50`.
- **antiSniper** (when window enabled) — `maxBuyPerTxBps` 10-300; `maxWalletBps` 10-300 and `>= maxBuyPerTxBps`; window 60-86400; whitelist `<= 20`.
- **creatorVaults** — `supplyBps` a multiple of 500, sum `<= 3000`; `<= 5` vaults.

---

## Step 3: Precompute the Transaction Hash

Encode the `createToken` call, build an EIP-1559 transaction, sign it offline, and take the keccak256 of the signed RLP payload. That digest is the txHash you submit to the API in Step 4 — and the same hash the network assigns once you broadcast in Step 5.

```javascript
import { keccak256, encodeFunctionData } from "viem";

// setup, pairs, taxAllocationConfigs, antiSniperConfigs, devBuy and value built as in Step 2; salt from the Salt section.
const data = encodeFunctionData({
  abi: factoryAbi,
  functionName: "createToken",
  args: [setup, pairs, taxAllocationConfigs, antiSniperConfigs, creatorVaults, devBuy, referral],
});

const fees = await publicClient.estimateFeesPerGas();
const tx = {
  type: "eip1559",
  chainId,
  nonce: await publicClient.getTransactionCount({ address: account.address }),
  to: factoryAddress,
  data,
  value, // 0n unless the dev buy is paid in native (see Funding the dev buy)
  gas: await publicClient.estimateGas({ account, to: factoryAddress, data, value }),
  maxFeePerGas: fees.maxFeePerGas,
  maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
};

const signedTx = await walletClient.signTransaction(tx);
const txHash = keccak256(signedTx);
```

---

## Step 4: Submit Metadata to API (off-chain)

Submit token metadata to the API **before** broadcasting the transaction, using the precomputed txHash from Step 3.

### Request

```
POST /api/tokens/create
Authorization: Bearer <jwt_token>
Content-Type: multipart/form-data

Fields:
  txHash     (required) — 0x-prefixed, 66-character hex string (precomputed in Step 3)
  name       (required) — token name, max 96 characters
  symbol     (required) — token symbol, max 96 chars
  chainId    (required) — 46630 on the testnet API, 4663 on the mainnet API (see Networks)
  description (optional) — max 250 characters
  socials    (optional) — JSON array of up to 5 http(s) URL strings, e.g.
                          ["https://x.com/foo","https://t.me/bar"]. The icon is derived
                          from each URL; invalid entries are dropped server-side.
  image      (optional) — JPEG, PNG, GIF, or WebP, max 5MB. Uploading a file is the slow path.
  imageUrl   (optional) — an already-pinned IPFS reference (ipfs://<cid> or an /ipfs/<cid>
                          gateway URL). IPFS only. Skips the upload. If both image and imageUrl
                          are sent, the uploaded file wins.
```

### Response (success)

```json
{
  "success": true,
  "txHash": "0x...",
  "imageUrl": "https://..."
}
```

### Error Responses

| Status | Meaning                                                 |
| ------ | ------------------------------------------------------- |
| 400    | Validation error (missing fields, invalid format, etc.) |
| 401    | Unauthorized (missing or invalid JWT)                   |
| 409    | Metadata already exists for this txHash                 |
| 500    | Server error                                            |

---

## Step 5: Broadcast the Transaction

As soon as the metadata POST returns successfully, broadcast the signed transaction from Step 3. **Steps 4 and 5 must be performed back-to-back with no delay between them.**

```javascript
const hash = await publicClient.sendRawTransaction({ serializedTransaction: signedTx });
// hash === txHash submitted in Step 4
```

---

## Runnable Scripts

A self-contained Node script (viem only, ABI inlined) that runs Steps 1-5 end to end and prints the token page. It defaults to Robinhood testnet: install viem, set a funded key, run, then edit the config block at the top (name, symbol, socials, image, dev buy) and switch `NETWORK` to `"mainnet"` when ready.

```
npm i viem
PRIVATE_KEY=0x... node create-token-v4.mjs
```

Download: https://realm.trade/developers/create-token-v4.mjs — inlined below.

```javascript
// Create a token on Realm through RealmFactoryUniV4Direct (Uniswap V4, no bonding curve).
// Setup: `npm i viem`, then `PRIVATE_KEY=0x... node create-token-v4.mjs`.
// Runs on Robinhood testnet by default; set NETWORK = "mainnet" once it works there.
import {
  createPublicClient, createWalletClient, http, parseAbi, parseEventLogs,
  encodeFunctionData, keccak256, toHex, zeroAddress,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { readFile } from "node:fs/promises";
import { basename } from "node:path";

const NETWORK = "testnet";
const NETWORKS = {
  mainnet: {
    chainId: 4663,
    rpc: "https://rpc.mainnet.chain.robinhood.com",
    api: "https://realm.trade",
    factory: "0xC763b1795DaBe2D2336EbA214d16969EBc5db27F",
  },
  testnet: {
    chainId: 46630,
    rpc: "https://rpc.testnet.chain.robinhood.com",
    api: "https://realm-rh-dev.vercel.app",
    factory: "0xF0399F67e359c08A18816E466364797b7fBc4df4",
  },
};
const net = NETWORKS[NETWORK];

// ---- Token config: tweak here ----
const NAME = "My Token";
const SYMBOL = "MTK";
const DESCRIPTION = "Launched through the Realm API";
const SOCIALS = ["https://x.com/yourproject"];
const IMAGE_FILE = ""; // optional local image (jpeg/png/gif/webp, <= 5MB), uploaded with the metadata
const IMAGE_URL = ""; // or an already-pinned IPFS url (ipfs://<cid>), faster than uploading
const DEV_BUY_ETH = 0n; // wei of ETH to buy at launch on the native pool; 0n = no dev buy

const factoryAbi = parseAbi([
  "struct FeeShare { address account; uint256 shares; bool directFeesEnabled; }",
  "struct DirectTokenSetup { string name; string symbol; bytes32 salt; FeeShare[] feeShares; bool renounceOwnership; uint16 lpFeeBps; }",
  "struct DirectPair { address quote; uint16 weightBps; }",
  "struct EarningsAllocationMultiConfig { uint16 burnBps; uint16 dividendsBps; uint16 liquidityBps; address[] dividendTokens; uint16[] dividendWeightsBps; bytes[] dividendRoutes; }",
  "struct TaxConfigsWithDirectAllocation { uint16 buyTaxBps; uint16 sellTaxBps; uint32 taxDurationSeconds; bool startTaxFromLaunch; uint16 buyTaxDecayStartBps; uint16 sellTaxDecayStartBps; uint32 taxDecayDuration; EarningsAllocationMultiConfig earningsAllocation; bytes[] quoteRoutes; }",
  "struct AntiSniperConfigs { uint16 maxBuyPerTxBps; uint16 maxWalletBps; uint40 protectionWindowSeconds; address[] whitelist; }",
  "struct CreatorVault { address owner; uint256 supplyBps; uint256 cliffSeconds; uint256 vestingSeconds; }",
  "struct PoolKey { address currency0; address currency1; uint24 fee; int24 tickSpacing; address hooks; }",
  "struct SupplyShare { address account; uint256 shares; }",
  "struct DevBuy { uint8 pairIndex; PoolKey[] route; uint256 minQuoteOut; uint256 quoteAmount; SupplyShare[] recipients; }",
  "function createToken(DirectTokenSetup setup, DirectPair[] pairs, TaxConfigsWithDirectAllocation taxAllocationConfigs, AntiSniperConfigs antiSniperConfigs, CreatorVault[] creatorVaults, DevBuy devBuy, address referral) payable returns (address)",
  "event TokenCreated(address indexed token, string name, string symbol, address tokenOwner, address launchpad, address graduator, address feeHandler)",
]);

const chain = {
  id: net.chainId,
  name: `Robinhood ${NETWORK}`,
  nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
  rpcUrls: { default: { http: [net.rpc] } },
};
const account = privateKeyToAccount(process.env.PRIVATE_KEY);
const publicClient = createPublicClient({ chain, transport: http() });
const walletClient = createWalletClient({ account, chain, transport: http() });

// Single native (ETH) pool, 1% LP fee, no tax, no anti-sniper, no vaults.
const createArgs = (salt) => [
  {
    name: NAME,
    symbol: SYMBOL,
    salt,
    feeShares: [{ account: account.address, shares: 10000n, directFeesEnabled: false }],
    renounceOwnership: false,
    lpFeeBps: 100,
  },
  [{ quote: zeroAddress, weightBps: 10000 }],
  {
    buyTaxBps: 0, sellTaxBps: 0, taxDurationSeconds: 0, startTaxFromLaunch: false,
    buyTaxDecayStartBps: 0, sellTaxDecayStartBps: 0, taxDecayDuration: 0,
    earningsAllocation: {
      burnBps: 0, dividendsBps: 0, liquidityBps: 0,
      dividendTokens: [], dividendWeightsBps: [], dividendRoutes: [],
    },
    quoteRoutes: [],
  },
  { maxBuyPerTxBps: 0, maxWalletBps: 0, protectionWindowSeconds: 0, whitelist: [] },
  [],
  {
    pairIndex: 0, route: [], minQuoteOut: 0n, quoteAmount: 0n,
    recipients: DEV_BUY_ETH > 0n ? [{ account: account.address, shares: 10000n }] : [],
  },
  zeroAddress,
];

// 1. Authenticate
const message = `Sign in to Realm\nTimestamp: ${Date.now()}`;
const authRes = await fetch(`${net.api}/api/auth/wallet`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ address: account.address, signature: await walletClient.signMessage({ message }), message }),
});
const { token: jwt } = await authRes.json();
if (!jwt) throw new Error(`auth failed: ${authRes.status}`);

// 2. Any salt works; the factory namespaces it by sender (CREATE2 salt = keccak256(deployer ++ salt))
const salt = toHex(crypto.getRandomValues(new Uint8Array(32)));

// 3. Sign offline and precompute the txHash
const data = encodeFunctionData({ abi: factoryAbi, functionName: "createToken", args: createArgs(salt) });
const fees = await publicClient.estimateFeesPerGas();
const signedTx = await walletClient.signTransaction({
  type: "eip1559",
  chainId: chain.id,
  nonce: await publicClient.getTransactionCount({ address: account.address }),
  to: net.factory,
  data,
  value: DEV_BUY_ETH,
  gas: await publicClient.estimateGas({ account, to: net.factory, data, value: DEV_BUY_ETH }),
  maxFeePerGas: fees.maxFeePerGas,
  maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
});
const txHash = keccak256(signedTx);

// 4. Submit metadata, keyed by the txHash...
const form = new FormData();
form.append("txHash", txHash);
form.append("name", NAME);
form.append("symbol", SYMBOL);
form.append("chainId", String(chain.id));
form.append("description", DESCRIPTION);
form.append("socials", JSON.stringify(SOCIALS));
if (IMAGE_FILE) {
  const type = `image/${IMAGE_FILE.split(".").pop().toLowerCase().replace("jpg", "jpeg")}`;
  form.append("image", new Blob([await readFile(IMAGE_FILE)], { type }), basename(IMAGE_FILE));
}
if (IMAGE_URL) form.append("imageUrl", IMAGE_URL);
const metaRes = await fetch(`${net.api}/api/tokens/create`, {
  method: "POST",
  headers: { Authorization: `Bearer ${jwt}` },
  body: form,
});
if (!metaRes.ok) throw new Error(`metadata POST failed: ${metaRes.status} ${await metaRes.text()}`);

// 5. ...and broadcast immediately after
await publicClient.sendRawTransaction({ serializedTransaction: signedTx });
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
const [created] = parseEventLogs({ abi: factoryAbi, eventName: "TokenCreated", logs: receipt.logs });
console.log(`${receipt.status}: ${net.api}/token/${created.args.token.toLowerCase()}`);
```

---

## Set the Image Later (optional)

To launch as fast as possible, create the token with metadata only (no `image` / `imageUrl` in Step 4) and attach the image within **10 minutes** of the on-chain creation. The window is measured from the token's on-chain creation timestamp, so the token must already be indexed (a few seconds after broadcast); call again if it returns `409`.

```
PATCH /api/tokens/image
Authorization: Bearer <jwt_token>
Content-Type: application/json

{
  "txHash": "0x...",            // or "tokenAddress": "0x..."
  "imageUrl": "ipfs://<cid>"    // IPFS only (ipfs:// or /ipfs/<cid> gateway URL)
}
```

Only the token's original creator (the authenticated wallet that submitted the metadata) may set the image. Returns `403` after the 10-minute window closes. The image can only be set once — a token that already has an image returns `409`.

---

## ABIs

All Realm contracts are verified on Blockscout. To get the ABI for any contract:

1. Get the contract address from the [Networks](#networks) table or the deployment files.
2. Look it up on the chain's Blockscout explorer.
3. Use the "Contract" tab → "Read/Write Contract" or the explorer API.

Key contracts and their roles:

- **`RealmFactoryUniV4Direct`** — `createToken`, `previewLaunchTick`.

## Key Events

After token creation, the factory emits:

```solidity
event TokenCreated(
    address indexed token,
    string name,
    string symbol,
    address tokenOwner,
    address launchpad,
    address graduator,
    address feeHandler
);
```

`launchpad` is `address(0)` (there is no curve to trade against) and `graduator` is the contract that owns the token's pools. The factory also emits `LpFeeBpsSet(token, lpFeeBps)`, and a non-zero `referral` emits `TokenReferral(token, referral)`. Read the token address from `TokenCreated` in the receipt.

## Notes

- Token creation is free (no ETH cost beyond gas) — unless you buy supply on deploy (`value > 0`, or a pulled `quoteAmount` of an ERC20 quote).
- The `image` field on the metadata API is a raw file upload; the API pins it to IPFS via Pinata. To skip the upload, pass an already-pinned `imageUrl` (IPFS only) instead, or attach the image after creation via `PATCH /api/tokens/image`.

## Bonding-Curve Launches (Uniswap V2)

Realm also supports launching on a bonding curve that graduates to Uniswap V2, through a separate factory with its own interface. It is not covered here — if you want to launch that way, reach out on Telegram (https://t.me/tradeonrealm) and we'll help you integrate it.
