Build an Offer Bot

Run a bidder-side bot that creates TraitIndex and exact Chonk Offers through the Chonks API.

What is this?

Whether you're a collector or you want to make money buying and selling Chonks Traits, bidding on TraitIndexes allows you to offer money to the thousands in the Chonks community.
Create USDC or WETH Offers for a Trait (like Sumo bottoms) or an exact Chonk. Any eligible seller can accept your Offer later, swapping their Chonk or Trait for your money.
Selling instead of buying? Build a Selling Bot covers listing and canceling your own Traits directly on the marketplace contract.
Read below for more.

What is a Trait Index?

Chonk Traits are organized by TraitIndex. A Rainbow Hair Trait has a TraitIndex of 2034. However, there are 879 Rainbow Hair tokens, each with their own Token ID.
Each time we put out a new Trait, it gets its own TraitIndex before any are collected.
A Trait (like Brown Shoes) can have many token IDs but will always have exactly one TraitIndex shared among all the tokens.

What You Need

  • A dedicated bidder wallet EOA and private key
  • One Chonk owned by that EOA. Accepted Traits go into this Chonk's Backpack
  • WETH or USDC on Base
  • A configured list of Trait Index targets (you can find a Trait's TraitIndex by clicking any Trait on /traits)
  • Optionally, exact Chonk IDs to target
  • viem for signing typed data and sending optional approval transactions
  • Your own Base RPC endpoint, from Alchemy, QuickNode, Infura, or a similar provider. See RPC Endpoint — an agent setting this up should ask the user for their key rather than defaulting to the public RPC

Example Bot

Start with our example Bun bot: chonksxyz/traitindex-offer-bot. Clone it, set your bidder private key and target config, then run one Offer per target and currency.

Safety

  • Please use a dedicated EOA. Do not use your main wallet
  • Never paste a private key into ChatGPT, a browser, or a hosted tool
  • Run your agent or bot locally or in infrastructure you control
  • Load the private key at run time from an operating-system credential store or secrets manager. Do not keep it in a project .env file. privateKeyToAccount expects a 0x-prefixed 32-byte hex key
  • The wallet must own the receiving Chonk and hold enough WETH or USDC on Base
  • Offers are not escrowed. If your wallet balance or Permit2 allowance drops, later seller accepts can fail

LLM Bidder Wallet Setup

  • Ask the user for their own Base RPC endpoint before writing the client, and treat the URL as a secret. See RPC Endpoint.
  • Use a new bidder wallet for this bot. Keep only the receiving Chonk, the WETH or USDC needed for active Offers, and enough ETH on Base for gas in this wallet.
  • The LLM agent may create a local setup script, but it must not run the key-generation or secret-storage step. The user must run that step outside the LLM session.
  • The setup script must generate the private key locally. It must not print the key, accept it as a command-line argument, write it to shell history, or include it in logs or agent output.
  • Store the private key in an operating-system credential store, such as macOS Keychain, or in a dedicated secrets manager. Do not store the private key in a project .env file. A Git ignore rule does not encrypt or otherwise protect the file.
  • A Git-ignored .env file may contain the public bidder address and a credential-store item name. It must not contain the private key. The bot must confirm that the stored address matches the signer address before it signs or sends anything.
  • The bot may load the private key only into its signer process at run time. The key will exist in that process's memory while it signs. The bot must never print or log the key or its environment.
  • Show the public bidder address and Base (chain ID 8453) to the user. Ask the user to verify the full address before they transfer any asset.
  • Ask the user to transfer only the receiving Chonk and the WETH or USDC budget that they want the bot to manage. Explain that the bidder wallet will control the transferred Chonk, the Traits in its Backpack, and any funds sent to it. The bot must not transfer these assets for the user.
  • Before the first Offer, verify on Base that the bidder wallet owns the expected receiving Chonk. Show the Chonk ID, currency, Offer targets, maximum amount, expiration, and Permit2 allowance, then ask the user for confirmation.
  • If the private key might have been exposed, stop the bot. Cancel its active Offers, move its Chonk and funds to a new wallet, and replace the compromised bidder wallet.

RPC Endpoint

Most of this flow is HTTP calls to the Chonks API and off-chain signing, but the approval transaction, balance checks, and any on-chain confirmation go through a Base RPC endpoint. In viem a bare http() transport falls back to the public Base endpoint, which is shared by everyone and rate-limits quickly. A bot that keeps Offers refreshed across several Trait Indexes will feel that limit as approvals that never confirm and reads that time out.
If an LLM agent is setting this bot up, it must ask the user for an endpoint before it writes the client. Do not choose an endpoint for the user, and do not assume the public default is fine. Ask:
Do you have your own Base RPC endpoint — an Alchemy, QuickNode, Infura, or similar key? Paste the HTTPS URL if you do. If you do not, the bot will fall back to the public Base endpoint with a delay between calls, which is slower and less reliable.
Then set the bot up for whichever answer you get:
  • The user has an endpoint. Put the URL in BASE_RPC_URL and keep the pacing delays small.
  • The user has no endpoint. Say plainly that a bot working through many targets on the public endpoint will be slow and can fail partway, that a free tier from Alchemy, QuickNode, or Infura is enough, and that they can add BASE_RPC_URL later without changing any other code. If they still want to start now, turn pacing on and continue.
Treat the URL as a secret. Provider URLs carry the API key in the path, so anyone who has the URL can spend the user's quota. Keep it in a Git-ignored .env, never print it in logs or agent output, and never paste it into an issue, a chat, or a screenshot.
import { createWalletClient, http } from "viem";
import { base } from "viem/chains";

// Fail loudly when the endpoint is missing rather than quietly falling back to
// the public Base RPC. Set BASE_RPC_URL, or set USE_PUBLIC_BASE_RPC=true to
// choose the slow, throttled path on purpose.
const rpcUrl = process.env.BASE_RPC_URL;
if (!rpcUrl && process.env.USE_PUBLIC_BASE_RPC !== "true") {
  throw new Error(
    "Missing BASE_RPC_URL. Ask the user for their own Base RPC endpoint " +
      "(Alchemy, QuickNode, Infura, or similar), or set USE_PUBLIC_BASE_RPC=true " +
      "to run against the public endpoint with pacing.",
  );
}

export const usingPublicRpc = !rpcUrl;

// viem retries 429s and 5xx responses on its own. Give the public endpoint more
// room before it gives up.
export const transport = http(rpcUrl, {
  retryCount: usingPublicRpc ? 5 : 3,
  retryDelay: usingPublicRpc ? 1_000 : 250,
});

// Small on a dedicated endpoint, generous on the public one. Use it between
// targets in the bot loop, not just between RPC calls.
export const STEP_DELAY_MS = usingPublicRpc ? 1_000 : 250;

export const pause = (ms: number) =>
  new Promise((resolve) => setTimeout(resolve, ms));
Pace the Chonks API the same way. Build, sign, create, then await pause(STEP_DELAY_MS) before the next target instead of firing every Offer in parallel. One Offer at a time also keeps the replacement rule readable: the same bidder, currency, and target can only have one active Offer, so overlapping requests for one target race each other.

Shared Response Types

Dates are returned as ISO strings. Amounts are returned as raw smallest-unit strings unless a field is explicitly named human.
type Address = string;
type Hex = string;
type BidHash = string;

type TypedData = {
  domain: Record<string, unknown>;
  types: Record<string, Array<{ name: string; type: string }>>;
  primaryType: string;
  message: Record<string, unknown>;
};

type StoredTraitIndexBid = {
  id: number;
  bidHash: BidHash;
  bidderEOA: Address;
  receivingChonkId: number;
  traitIndex: number;
  traitType: number;
  currency: Address;
  currencyDecimals: number;
  amount: string;
  deadline: string;
  salt: string;
  permit2Nonce: string;
  permit2Deadline: string;
  signature: Hex;
  eip712TypedData: TypedData | null;
  status: "active" | "settling" | "canceled" | "settled" | "expired";
  replacedByBidHash: BidHash | null;
  settledTraitId: number | null;
  settlementTxHash: Hex | null;
  createdAt: string;
  updatedAt: string;
  canceledAt: string | null;
  settledAt: string | null;
};

type PublicTraitIndexBid = Omit<
  StoredTraitIndexBid,
  "salt" | "permit2Nonce" | "permit2Deadline" | "signature" | "eip712TypedData"
>;

type ApiErrorResponse = {
  error: string;
  code: string;
  details?: unknown;
  issues?: unknown[];
};

Build An Offer

Build returns canonical typed data and a createRequest. It does not create the Offer.
Param Required Values
bidderEOA Yes Bidder wallet address. This EOA signs the offer and owns the receiving Chonk.
receivingChonkId Yes Chonk ID owned by bidderEOA. Accepted Traits go into this Chonk's Backpack.
traitIndex Yes Target Trait Index number, such as 57.
currency Yes usdc or weth, case-insensitive.
amount Yes Human-readable amount string, such as "25.50" for USDC or "0.01" for WETH.
expirationDays No 1, 3, or 7. Defaults to 7.
curl -s https://www.chonks.xyz/api/trait-index-bids/build \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "bidderEOA": "0xYourBidderWallet",
    "receivingChonkId": 12345,
    "traitIndex": 57,
    "currency": "usdc",
    "amount": "25.50",
    "expirationDays": 7
  }'
currency is usdc or weth, case-insensitive. amount is a human-readable string. For USDC, "5" means 5 USDC and "0.5" means 50 cents.
Build preflight is guidance, not a write. Low balance and missing allowance do not make build fail. Create will hard-fail if you ignore those flags.
If preflight.approval.required is true, submit preflight.approval.transaction with the bidder EOA before signing. This approves the request currency, USDC or WETH, to Permit2. USDC and WETH approvals are separate, so a bot that offers both currencies needs to approve both tokens. The returned approval uses a max allowance for convenience. You may send a lower allowance yourself if your wallet policy requires it.
The marketplace permission is the typedData signature. It authorizes preflight.approval.marketplaceSpender to spend the offered amount through Permit2 for this exact Offer. On Base, that TraitIndex marketplace is 0x10DDD4C86f085C911E8c9000cA9c060EA261c4E3. This is also returned as typedData.message.spender. Token approval alone is not enough. Do not skip signing typedData.
If preflight.balance.hasEnoughBalance is false, fund the bidder EOA before signing.
If the request is replacing an active Offer and the amount is too low, build returns 409 REPLACEMENT_TOO_LOW with details.minimumAmount and details.minimumHumanAmount. Rebuild with at least details.minimumHumanAmount.
Treat typedData and createRequest as opaque bot inputs. Sign the returned typedData exactly, and POST the returned createRequest back unchanged with the signature.
Response:
type BuildOfferResponse = {
  bidHash: BidHash;
  traitIndex: number;
  traitType: number;
  receivingChonkId: number;
  currency: {
    symbol: "USDC" | "WETH";
    address: Address;
    decimals: number;
  };
  amount: {
    human: string;
    raw: string;
  };
  deadline: number;
  expirationDays: 1 | 3 | 7;
  salt: string;
  marketplaceSpender: Address;
  typedData: TypedData;
  createRequest: {
    bidHash: BidHash;
    bidderEOA: Address;
    receivingChonkId: number;
    traitIndex: number;
    traitType: number;
    currency: Address;
    currencyDecimals: number;
    amount: string;
    deadline: number;
    expirationDays: 1 | 3 | 7;
    salt: string;
    permit2Nonce: string;
    permit2Deadline: number;
    typedData: TypedData;
  };
  preflight: {
    balance: {
      balance: string;
      requiredAmount: string;
      hasEnoughBalance: boolean;
    };
    approval: {
      required: boolean;
      isSufficient: boolean;
      allowance: string;
      minimumAmount: string;
      recommendedAmount: string;
      spender: Address;
      permit2Spender: Address;
      marketplaceSpender: Address;
      function: "approve(address,uint256)";
      transaction: null | {
        chainId: 8453;
        to: Address;
        value: "0";
        data: Hex;
      };
    };
    replacement: {
      isReplacement: boolean;
      previousBidHash: BidHash | null;
      minimumAmount: string | null;
      minimumHumanAmount: string | null;
    };
  };
};

Create The Offer

Sign the returned typedData with the bidder EOA. Then POST the returned createRequest plus signature.
import { createWalletClient, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";

const rawPrivateKey = process.env.BIDDER_PRIVATE_KEY;
if (!rawPrivateKey) throw new Error("Missing BIDDER_PRIVATE_KEY");

const privateKey = (
  rawPrivateKey.startsWith("0x") ? rawPrivateKey : `0x${rawPrivateKey}`
) as Hex;
const account = privateKeyToAccount(privateKey);
// `transport` comes from the RPC Endpoint section above. A bare http() falls
// back to the public Base endpoint and will rate-limit a bot that runs targets
// in a loop.
const wallet = createWalletClient({ account, chain: base, transport });

async function readJson(response: Response) {
  const body = await response.json();
  if (!response.ok) throw body;
  return body;
}

const build = await fetch("https://www.chonks.xyz/api/trait-index-bids/build", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    bidderEOA: account.address,
    receivingChonkId: 12345,
    traitIndex: 57,
    currency: "usdc",
    amount: "25.50",
    expirationDays: 7,
  }),
}).then(readJson);

if (!build.preflight.balance.hasEnoughBalance) {
  throw new Error("Fund the bidder EOA before signing this Offer.");
}

if (build.preflight.approval.required) {
  await wallet.sendTransaction({
    to: build.preflight.approval.transaction.to,
    data: build.preflight.approval.transaction.data,
    value: 0n,
  });
}

const signature = await wallet.signTypedData(build.typedData);

const created = await fetch("https://www.chonks.xyz/api/trait-index-bids", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    ...build.createRequest,
    signature,
  }),
}).then(readJson);

console.log(created.bid.id, created.bid.bidHash);
Response:
type CreateOfferResponse = {
  bid: StoredTraitIndexBid;
};
Store both bid.id and bid.bidHash. You need both to cancel.

List Active Offers

List active Offers for one Trait Index and Trait category. Use the traitType returned by build.
Param Required Values
traitIndex Yes Trait Index number, such as 57.
traitType Yes Trait type number returned by build.
currency No usdc or weth. Only one currency is allowed.
limit No 1 to 250. Defaults to 100.
offset No 0 or higher. Defaults to 0.
curl -s "https://www.chonks.xyz/api/trait-index-bids?traitIndex=57&traitType=1"
Filter to one currency:
curl -s "https://www.chonks.xyz/api/trait-index-bids?traitIndex=57&traitType=1&currency=usdc"
If no currency is provided, WETH and USDC Offers are returned together, sorted highest to lowest by estimated USD value using the current ETH price. If ETH price lookup fails, the API falls back to deterministic token ordering.
Response:
type ListActiveOffersResponse = {
  bids: PublicTraitIndexBid[];
  limit: number;
  offset: number;
};

Cancel An Offer

Build the cancel message as a three-line string with \n line breaks. Do not add a leading or trailing blank line.
const bidId = 123;
const bidHash = "0xabc...";

const cancelMessage = [
  "Cancel Chonks TraitIndex Offer",
  "Bid id: " + bidId,
  "Bid hash: " + bidHash,
].join("\n");

const signature = await wallet.signMessage({
  account,
  message: cancelMessage,
});

const canceled = await fetch("https://www.chonks.xyz/api/trait-index-bids/cancel", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    id: bidId,
    bidHash,
    signature,
  }),
}).then(readJson);
Response:
type CancelOfferResponse = {
  bid: StoredTraitIndexBid;
  signedMessage: string;
};
The API checks that the bid ID and bid hash match, the signature recovers the bidder EOA, and that EOA still owns the receiving Chonk.

Error Recovery

  • VALIDATION_FAILED: Fix the request shape. Amounts must be strings. Currency is usdc or weth for build.
  • TRAIT_INDEX_NOT_FOUND: Check the target Trait Index.
  • CHONK_NOT_FOUND: Check the exact Chonk ID.
  • SELF_OWNED_CHONK: Exact Chonk Offers cannot target a Chonk already owned by the bidder.
  • BIDDER_DOES_NOT_CURRENTLY_OWN_THE_RECEIVING_CHONK: Use a receiving Chonk owned by the bidder EOA.
  • INSUFFICIENT_BALANCE: Build reports this as preflight.balance.hasEnoughBalance = false. Create returns 409 if ignored. Fund the bidder EOA, then build again.
  • INSUFFICIENT_ALLOWANCE: Build reports this as preflight.approval.required = true. Create returns 409 if ignored, with details.permit2Spender and details.marketplaceSpender. Submit the approval transaction from build, wait for confirmation, then create again.
  • REPLACEMENT_TOO_LOW: Increase the amount to at least details.minimumHumanAmount, then build again.
  • BID_HASH_MISMATCH: Use the exact createRequest returned by build and do not edit signed fields.
  • BID_NOT_ACTIVE: The Offer was already canceled, expired, settled, or replaced.
  • SETTLEMENT_CONTRACT_NOT_CONFIGURED: Local validation is missing TRAIT_INDEX_BIDS_SETTLEMENT_CONTRACT. Set it to 0x10DDD4C86f085C911E8c9000cA9c060EA261c4E3 and restart the dev server.
An HTTP 429, a request timeout, or an approval transaction that never confirms is not one of these errors. That is your RPC endpoint or the API rate-limiting the bot. Slow it down and get the user onto their own endpoint — see RPC Endpoint.

Bot Loop

For each configured Trait Index or exact Chonk target:
  1. Build the Offer.
  2. If build.preflight.approval.required is true, submit the returned token approval. A USDC Offer approves USDC to build.preflight.approval.permit2Spender. A WETH Offer approves WETH to build.preflight.approval.permit2Spender. If the bot offers both currencies, approve both.
  3. Sign typedData. This is the marketplace permission for build.preflight.approval.marketplaceSpender. TraitIndex Offers use 0x10DDD4C86f085C911E8c9000cA9c060EA261c4E3; exact Chonk Offers use 0x8927B8B87DDDa4b6f60aC1510CEE4C6b24e9DE49.
  4. Create the Offer.
  5. Store the id and bidHash from the Offer's return data in your database so you can programmatically cancel it later if you choose.
  6. Rebuild the Offer when replacing, renewing, or changing the amount.
  7. Cancel by id + bidHash when the bot should stop offering.
  8. Pause between targets with await pause(STEP_DELAY_MS). Work one target at a time instead of running them in parallel — see RPC Endpoint.
After a seller accepts an Offer, refresh your wallet's balances before renewing Other offers. This is recommended operating hygiene, not an API requirement.

Exact Chonk Offers

Exact Chonk Offers target one chonkId. The bidder EOA offers WETH or USDC for that Chonk, and seller acceptance later transfers that Chonk to the bidder.
The build/create flow is the same as TraitIndex Offers: call build, sign the returned typedData, then POST createRequest plus signature. Exact Chonk typed data uses the SignedChonkBid witness from ChonksMarketV2UUPS.
Build:
curl -s https://www.chonks.xyz/api/chonk-bids/build \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "bidderEOA": "0xYourBidderWallet",
    "chonkId": 12345,
    "currency": "usdc",
    "amount": "25.50",
    "expirationDays": 7
  }'
Create at POST /api/chonk-bids, list active Offers at GET /api/chonk-bids?chonkId=12345, and filter with currency=usdc or currency=weth.
Cancel by signing:
Cancel Chonks exact Chonk Offer
Bid id: <id>
Bid hash: <bidHash>
Then POST { id, bidHash, signature } to /api/chonk-bids/cancel.
Exact Chonk Offers spend through the Chonks marketplace contract, 0x8927B8B87DDDa4b6f60aC1510CEE4C6b24e9DE49. This is returned as typedData.message.spender and preflight.approval.marketplaceSpender.
Replacement behavior matches TraitIndex Offers: the same bidder, currency, and exact target can have one active Offer. A replacement must be at least 5% higher than the active Offer.