HOT

API

Create tokens on Realm programmatically

Overview

Token creation on Realm follows a strict order: build the create-token transaction and precompute its hash, submit the token metadata (name, image, socials) to the Realm API keyed by that precomputed hash, and finally broadcast the signed transaction on-chain.

Order matters. The metadata submission and the on-chain broadcast must be performed one after the other with no delay between them.

Tip: uploading an image file is the slowest part of the metadata call. To launch instantly, omit the image — pass an already-pinned imageUrl (IPFS) in Step 4, or skip it entirely and attach the image within 10 minutes via PATCH /api/tokens/image (Step 6).

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 — see Networks and the runnable scripts.

Networks

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

Robinhood testnet

chainId
46630
API base
https://realm-rh-dev.vercel.app
RPC
https://rpc.testnet.chain.robinhood.com
RealmFactoryUniV4Direct
0xF0399F67e359c08A18816E466364797b7fBc4df4

Robinhood mainnet

chainId
4663
API base
https://realm.trade
RPC
https://rpc.mainnet.chain.robinhood.com
RealmFactoryUniV4Direct
0xC763b1795DaBe2D2336EbA214d16969EBc5db27F

Step 1: Authenticate

All API calls require a JWT Bearer token. Obtain one by signing a message with your wallet. Tokens expire after 30 days.

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

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

// Response:
{ "token": "eyJhbGci..." }

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

Step 2: Build the Create Token Call

Tokens are created by RealmFactoryUniV4Direct. It seeds the circulating supply into one Uniswap 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. See The Direct Factory and Struct Reference below.

Factory addresses are listed under Networks. All contracts are verified on Blockscout — fetch ABIs from there, or use the inline ABIs in the runnable scripts.

The Direct Factory (Uniswap V4)

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.

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.

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  | paid with                      | quoteAmount | minQuoteOut | msg.value
--------------|--------------------------------|-------------|-------------|-------------------
native (0x0)  | msg.value                      | 0           | 0           | amount to spend
ERC20         | the quote, pulled by the factory | raw amount  | 0           | 0
ERC20         | native, converted in-launch    | 0           | conversion floor | ETH to convert

In the third mode the factory spends the WHOLE proceeds of the conversion on the buy, so minQuoteOut (raw quote decimals) 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. In the quote-funded mode, approve the factory for quoteAmount first. 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:

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). Pass address(0) for referral for now (see below).

setup — DirectTokenSetup

struct FeeShare {
    address account;
    uint256 shares;               // bps, > 0; array sums to exactly 10000
    bool    directFeesEnabled;    // at most ONE entry may be true
}
namerequired
Token name. Non-empty.
symbolrequired
Token symbol. Non-empty, ≤ 96 bytes on-chain.
saltrequired
Any bytes32, e.g. random. See the Salt section.
feeSharesrequired
Trading-fee recipients. Non-zero, unique accounts; every shares > 0; the array must sum 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.
lpFeeBpsrequired
The pools' LP fee: 100 (1%) or 50 (0.5%), else InvalidLpFeeBps.

taxAllocationConfigs — TaxConfigsWithDirectAllocation

Struct layout in The Direct Factory.

buyTaxBps / sellTaxBps
Long-term static tax per direction. Capped by the total-fee rule lpFeeBps + tax ≤ 500: up to 400 bps with a 1% LP fee, 450 bps with 0.5%. 0 disables static tax.
taxDurationSeconds
Static-tax window length. 0 disables (then both bps must be 0). Max ~120 years.
startTaxFromLaunch
Window anchor for both the static tax and the decay leg. A direct token is live from launch, so both anchors start the window at creation.
buyTaxDecayStartBps / sellTaxDecayStartBps
Optional linear launch-tax decay — a higher rate at the anchor decaying linearly down to the static rate (to 0 if untaxed). If set, must be strictly greater than the direction's static rate; combined (buy + sell) ≤ 2000 bps. 0 = no decay for that direction.
taxDecayDuration
Decay window length. 0 disables (then both decay-start bps must be 0). Max 20 minutes. If a static tax is also set, taxDurationSeconds ≥ taxDecayDuration.
—
Static tax and decay are independent — set either, both, or neither. The effective rate a trade pays per direction is max(decay, static).
earningsAllocation
Splits earnings (swap tax + the creator's LP-fee share) 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
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 two arrays must be the same length. 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 and must each be non-zero and sum to exactly 10000. Assets must be distinct (InvalidDividendAssetSet), the self-token sentinel is only legal 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 its 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[] for Uniswap V4 (starting from native) and 0x03 Uniswap V3's packed path from WETH. 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. Read a holder's accrual at the current block with previewDividend(holder, assetIndex) (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 about 5,500 more gas on every transfer of the token, so the set is a real cost to holders, not just to the deployer.

devBuy.recipients — SupplyShare[]

struct SupplyShare {
    address account;
    uint256 shares;   // bps, > 0; array sums to exactly 10000
}
recipients
Splits the dev-buy tokens across accounts; the amount bought is set by the funding (see Funding the dev buy). Pass [] when not buying.

antiSniperConfigs — AntiSniperConfigs

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
}
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 — CreatorVault[]

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
}
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 — address

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.

import { toHex } from "viem";

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

Step 3: Precompute the Transaction Hash

Encode the createToken call, build an EIP-1559 transaction (chainId, nonce, gas, fee fields, data, value), 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 will assign once you broadcast the transaction in Step 5.

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

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

POST /api/tokens/create
Authorization: Bearer <jwt_token>
Content-Type: multipart/form-data
txHashrequired
0x-prefixed, 66-character hex string (precomputed in Step 3)
namerequired
Token name, max 96 characters
symbolrequired
Max 96 characters
chainIdrequired
46630 on the testnet API, 4663 on the mainnet API (see Networks)
description
Max 250 characters
socials
JSON array of up to 5 http(s) URL strings (e.g. ["https://x.com/foo","https://t.me/bar"]). Icons are derived from each URL; invalid entries are dropped.
image
JPEG, PNG, GIF, or WebP, max 5MB. Optional — uploading a file is the slow path. Omit it and either pass imageUrl instead, or set the image later (see Step 6).
imageUrl
An already-pinned IPFS reference — ipfs://<cid> or an /ipfs/<cid> gateway URL. IPFS only; other URLs are rejected. Skips the upload entirely. If both image and imageUrl are sent, the uploaded file wins.

Response:

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

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.

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

Step 6 (optional): Set the Image Later

To launch as fast as possible — e.g. reacting to breaking news — 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)
}

// Response:
{ "success": true, "txHash": "0x...", "imageUrl": "https://...mypinata.cloud/ipfs/<cid>" }
txHash
0x-prefixed, 66-char hex. Preferred identifier. Provide this or tokenAddress.
tokenAddress
0x-prefixed token address. Alternative to txHash.
imageUrlrequired
IPFS reference only — ipfs://<cid> or an /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 (set at creation or by a prior call) returns 409 and cannot be changed.

Validation Rules

Metadata (API) limits are stricter than the on-chain ones; the factory enforces the rest. Per-field constraints are in Struct Reference; the ones most likely to revert:

name
Metadata: 1-96 chars. On-chain: non-empty.
symbol
Metadata: 1-96 chars. On-chain: non-empty, ≤ 96 bytes.
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.
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.

Key Events

After token creation, the factory emits:

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

Parse TokenCreated from the transaction receipt to get the created token address. launchpad is address(0) (there is no curve to trade against) and graduator is the contract that owns the token's pools.

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 is a raw file upload, which the API pins 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 (see Step 6).

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

create-token-v4.mjs — download

// 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()}`);

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 and we'll help you integrate it.