Build a Selling Bot

Run a seller-side bot that lists, reprices, and cancels Chonk Trait listings directly on the Chonks marketplace contract.

What is this?

If you hold Chonks with Traits sitting in their Backpacks, you can run a bot that lists those Traits for sale, reprices them, and cancels listings without ever opening the website.
Selling is direct. Your wallet sends transactions to the Chonks marketplace contract on Base. There is no API to call and nothing to sign off-chain.
Read below for more.

How Selling Differs From Offers

The Offer bot docs cover the buy side. There you POST to the Chonks API, sign EIP-712 typed data, and your Offer waits off-chain until a seller accepts it.
Listing works the other way around:
  • You call the marketplace contract yourself and pay gas for every listing, reprice, and cancel.
  • The listing lives on-chain in the contract's traitOffers mapping, so anyone can read it.
  • Your bot does nothing at sale time. The buyer's transaction moves the Trait out of your Chonk's Backpack and pays you in the same transaction.
  • Listings never expire. A listing stays up until it is bought, canceled, or invalidated by a transfer.

What You Need

  • A dedicated seller wallet EOA and private key
  • One or more Chonks owned by that EOA. A fully dressed Chonk has no listable Traits at all, so expect to unequip before you sell
  • ETH on Base for gas
  • viem for sending transactions and reading contract state
  • Your own Base RPC endpoint, from Alchemy, QuickNode, Infura, or a similar provider. Enumerating a Backpack is one call per Trait, so a bot that polls will be rate-limited on the public endpoint. See RPC Endpoint — an agent setting this up should ask the user for their key rather than defaulting to the public RPC
  • The Chonk IDs you want to sell from

Safety

  • Please use a dedicated EOA for this. Do not use your main wallet or expose your private key
  • 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
  • Every listing is a real transaction with a real price. Test with one cheap Trait before you point the bot at a full Backpack
  • Approving the marketplace lets it move Traits out of your Chonk's Token Bound Account when a listing fills. Only ever approve the marketplace address below

LLM Seller Wallet Setup

  • Use a new seller wallet for this bot. Keep only the Chonks that you want the bot to manage and enough ETH on Base for gas in this wallet.
  • Ask the user for their own Base RPC endpoint before writing the client, and treat the URL as a secret. See RPC Endpoint.
  • 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 seller 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 sends a transaction.
  • 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 seller 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 specific Chonks that they want the bot to manage. Explain that the seller wallet will control each transferred Chonk and the Traits in its Backpack. The bot must not transfer the assets for the user.
  • Before the first listing, verify on Base that the seller wallet owns the expected Chonk IDs. Show the Chonk IDs, Trait IDs, currencies, and prices, then ask the user for confirmation.
  • If the private key might have been exposed, stop the bot. Cancel its active listings, move its Chonks and funds to a new wallet, and replace the compromised seller wallet.

Contracts and Currencies

Everything below is on Base, chain ID 8453.
Contract Address
Marketplace (proxy) 0x8927B8B87DDDa4b6f60aC1510CEE4C6b24e9DE49
ChonksMain 0x07152bfde079b5319e5308C43fB1Dbc9C76cb4F9
ChonkTraits 0x74D8725A65C21251A83f6647aa23140Bd80504b1
USDC 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
The marketplace address is a UUPS proxy. It does not change when the implementation is upgraded, so hard-code the proxy.
Every listing function takes a _currency argument:
  • address(0), the zero address, lists in ETH.
  • 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 lists in USDC.
Any other token has to be enabled by the contract owner first, otherwise the call reverts UnsupportedCurrency. WETH, 0x4200000000000000000000000000000000000006, is rejected on purpose even though it is a valid Offer currency on the bid side. List in ETH instead.
Prices are raw smallest units, never human decimals. 1 ETH is 1000000000000000000 (18 decimals) and 25.50 USDC is 25500000 (6 decimals). A price of 0 reverts CantBeZero.
Listing is gated by the contract's pause flag. If the marketplace is paused, offerTraitWithCurrency, offerTraitsWithCurrency, and offerTraitToAddressWithCurrency all revert Paused. Canceling still works while paused.

RPC Endpoint

Every read and every transaction below goes 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. Enumerating one Backpack is one call per Trait, so a bot that walks a handful of Chonks can burn through the limit inside a single loop. This is the first wall most seller bots hit, and it does not announce itself: reads come back slow or empty, and a bulk listing run fails partway with listings you think are live but never made it on-chain.
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 for bulk listing.
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 bulk listing run on the public endpoint will be slow and can fail partway, that a free tier from Alchemy, QuickNode, or Infura is enough for a seller bot, 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.
// 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. Drop `batch` if your provider rejects batched
// JSON-RPC payloads.
export const transport = http(rpcUrl, {
  retryCount: usingPublicRpc ? 5 : 3,
  retryDelay: usingPublicRpc ? 1_000 : 250,
  batch: true,
});

Pacing And Retries

Rate limits are counted per unit of time, so the fix is spacing your calls, not only retrying them. Keep one delay helper next to the transport above and use it in every loop:
// Small on a dedicated endpoint, generous on the public one.
export const READ_DELAY_MS = usingPublicRpc ? 250 : 50;
export const WRITE_DELAY_MS = usingPublicRpc ? 2_000 : 500;

export const pause = (ms: number) =>
  new Promise((resolve) => setTimeout(resolve, ms));
  • Space your reads. Enumerating a Backpack, checking equipped state, and reading traitOffers are one call each. Await pause(READ_DELAY_MS) between them, or read in small groups, instead of firing a whole Backpack at once with Promise.all.
  • Space your transactions. Wait for each receipt with waitForTransactionReceipt, then await pause(WRITE_DELAY_MS) before the next one. Listings sent back to back without waiting are the ones that come back as dropped or nonce-conflicting transactions.
  • Batch instead of looping. offerTraitsWithCurrency lists an entire Chonk in one transaction. One batch of twenty Traits is one nonce and one rate-limit slot; twenty single listings are twenty of each.
  • Never blind-retry a write. If a listing transaction fails, re-read traitOffers(traitId) before you retry. A transaction that landed after your client gave up is already a live listing, and relisting over it reverts CantChangeListingPrice.
  • Verify the run. After a bulk run, re-read traitOffers for every Trait you meant to list and relist only the ones that are missing. That check is what turns a partially rate-limited run into a complete one.

Approve The Marketplace

Traits are not held by your wallet. Each Chonk owns an ERC-6551 Token Bound Account, the TBA, and the TBA is the address that holds Traits in the Backpack.
The approval lives on the TBA, not on your wallet. Your EOA just tells the TBA to grant it. The marketplace checks ChonkTraits.isApprovedForAll(tba, marketplace), so the account that has to call setApprovalForAll on ChonkTraits is the TBA itself. Calling setApprovalForAll from your own wallet does nothing at all, because your wallet does not own the Trait tokens.
The way to make the TBA do it is to send a transaction to the TBA address calling ERC-6551 execute(to, value, data, operation) with:
  • to = ChonkTraits, 0x74D8725A65C21251A83f6647aa23140Bd80504b1
  • value = 0
  • data = encoded setApprovalForAll(marketplace, true)
  • operation = 0, a plain CALL
Because you own the Chonk, the TBA accepts the execute and makes the inner call, so msg.sender on ChonkTraits is the TBA and the approval is recorded against the TBA. Note that the transaction below goes to tba, never to ChonkTraits directly.
This is once per Chonk, not once per Trait — but it is also once per owner. The approval is stored against the pair of the TBA and the Chonk's current owner, and isApprovedForAll resolves the owner at read time, so a Chonk that changes hands arrives at its new owner unapproved no matter how many times the previous owner approved it. That is deliberate: buying a Chonk must not inherit the seller's marketplace approvals. A new owner has to approve before their first listing, and if the Chonk ever returns to a previous owner that owner's approval applies again. Listing without it reverts ApproveTheMarketplace.
The snippets below build one file. Later sections import from it and call await at the top level, so run them as ESM — set "type": "module" in your package.json, or wrap the calls in an async entrypoint.
import {
  createPublicClient,
  createWalletClient,
  encodeFunctionData,
  http,
  zeroAddress,
  type Address,
  type Hex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";

export const MARKETPLACE = "0x8927B8B87DDDa4b6f60aC1510CEE4C6b24e9DE49" as const;
export const CHONKS_MAIN = "0x07152bfde079b5319e5308C43fB1Dbc9C76cb4F9" as const;
export const CHONK_TRAITS = "0x74D8725A65C21251A83f6647aa23140Bd80504b1" as const;
export const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const;
export const ETH = zeroAddress;

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

const privateKey = (
  rawPrivateKey.startsWith("0x") ? rawPrivateKey : `0x${rawPrivateKey}`
) as Hex;
export const account = privateKeyToAccount(privateKey);
// `transport`, `usingPublicRpc`, and the delay helpers come from the
// RPC Endpoint and Pacing And Retries sections above. Bare http() falls back to
// the public Base endpoint, which will rate-limit a bot that enumerates
// Backpacks, so do not replace them with a plain http().

export const publicClient = createPublicClient({ chain: base, transport });
export const wallet = createWalletClient({ account, chain: base, transport });

export const chonksMainAbi = [
  {
    type: "function",
    name: "getOwnerAndTBAAddressForChonkId",
    stateMutability: "view",
    inputs: [{ name: "_chonkId", type: "uint256" }],
    outputs: [
      { name: "owner", type: "address" },
      { name: "tbaAddress", type: "address" },
    ],
  },
  {
    type: "function",
    name: "getTraitsForChonkId",
    stateMutability: "view",
    inputs: [{ name: "_chonkId", type: "uint256" }],
    outputs: [{ name: "traitTokens", type: "uint256[]" }],
  },
  {
    type: "function",
    name: "getFullPictureForTrait",
    stateMutability: "view",
    inputs: [{ name: "_chonkTraitTokenId", type: "uint256" }],
    outputs: [
      { name: "traitOwnerTBA", type: "address" },
      { name: "chonkTokenId", type: "uint256" },
      { name: "chonkOwner", type: "address" },
      { name: "isEquipped", type: "bool" },
    ],
  },
  {
    type: "function",
    name: "checkIfTraitIsEquipped",
    stateMutability: "view",
    inputs: [
      { name: "_chonkId", type: "uint256" },
      { name: "_traitId", type: "uint256" },
    ],
    outputs: [{ name: "", type: "bool" }],
  },
  {
    type: "function",
    name: "unequip",
    stateMutability: "nonpayable",
    inputs: [
      { name: "_chonkTokenId", type: "uint256" },
      { name: "traitType", type: "uint8" },
    ],
    outputs: [],
  },
] as const;

export const chonkTraitsAbi = [
  {
    type: "function",
    name: "isApprovedForAll",
    stateMutability: "view",
    inputs: [
      { name: "owner", type: "address" },
      { name: "operator", type: "address" },
    ],
    outputs: [{ name: "", type: "bool" }],
  },
  {
    type: "function",
    name: "setApprovalForAll",
    stateMutability: "nonpayable",
    inputs: [
      { name: "operator", type: "address" },
      { name: "approved", type: "bool" },
    ],
    outputs: [],
  },
] as const;

const tbaExecuteAbi = [
  {
    type: "function",
    name: "execute",
    stateMutability: "payable",
    inputs: [
      { name: "to", type: "address" },
      { name: "value", type: "uint256" },
      { name: "data", type: "bytes" },
      { name: "operation", type: "uint8" },
    ],
    outputs: [{ name: "", type: "bytes" }],
  },
] as const;

const CALL_OPERATION = 0;

export async function tbaForChonk(chonkId: bigint) {
  const [owner, tba] = await publicClient.readContract({
    address: CHONKS_MAIN,
    abi: chonksMainAbi,
    functionName: "getOwnerAndTBAAddressForChonkId",
    args: [chonkId],
  });

  return { owner, tba };
}

export async function ensureMarketplaceApproved(chonkId: bigint) {
  const { tba } = await tbaForChonk(chonkId);

  // The approval is recorded against the TBA, so that is the owner we check.
  const isApproved = await publicClient.readContract({
    address: CHONK_TRAITS,
    abi: chonkTraitsAbi,
    functionName: "isApprovedForAll",
    args: [tba, MARKETPLACE],
  });

  if (isApproved) return tba as Address;

  const setApprovalForAll = encodeFunctionData({
    abi: chonkTraitsAbi,
    functionName: "setApprovalForAll",
    args: [MARKETPLACE, true],
  });

  // Sent to the TBA, not to ChonkTraits. The TBA makes the inner call.
  const hash = await wallet.writeContract({
    address: tba,
    abi: tbaExecuteAbi,
    functionName: "execute",
    args: [CHONK_TRAITS, 0n, setApprovalForAll, CALL_OPERATION],
    gas: 500_000n,
  });

  await publicClient.waitForTransactionReceipt({ hash });
  return tba as Address;
}
Nested calls through a TBA are easy to under-estimate, so the example sends an explicit gas limit. Wait for the approval receipt before you list, or the first listing in the same block can still revert ApproveTheMarketplace.

Find Your Trait IDs

ChonksMain.getTraitsForChonkId(chonkId) returns every Trait token ID held by that Chonk's TBA, equipped or not. ChonksMain.checkIfTraitIsEquipped(chonkId, traitId) tells you which ones are wearable right now, and ChonksMain.getFullPictureForTrait(traitId) returns the Trait's TBA, its Chonk ID, the Chonk's owner, and its equipped state in one call.
Equipped Traits cannot be listed. Listing one reverts TraitEquipped. Unequip it first with ChonksMain.unequip(chonkId, category), where category is the Trait category enum: 1 Head, 2 Hair, 3 Face, 4 Accessory, 5 Top, 6 Bottom, 7 Shoes.
unequip is keyed by category, not by Trait ID, so read the category off the Trait before you call it: ChonkTraits.getTraitMetadata(traitId) returns a struct whose traitType field is that enum value. Do not go hunting for the slot by unequipping categories one at a time — that strips the rest of the Chonk's outfit to find one Trait.
Unequipping a category that holds nothing succeeds silently rather than reverting, so a wrong category is a wasted transaction with no error to catch. Confirm with checkIfTraitIsEquipped afterward.
export const traitMetadataAbi = [
  {
    type: "function",
    name: "getTraitMetadata",
    stateMutability: "view",
    inputs: [{ name: "_tokenId", type: "uint256" }],
    outputs: [
      {
        type: "tuple",
        components: [
          { name: "traitIndex", type: "uint256" },
          { name: "traitName", type: "string" },
          { name: "traitType", type: "uint8" },
          { name: "colorMap", type: "bytes" },
          { name: "zMap", type: "bytes" },
          { name: "dataMinterContract", type: "address" },
          { name: "creatorAddress", type: "address" },
          { name: "creatorName", type: "string" },
          { name: "release", type: "string" },
        ],
      },
    ],
  },
] as const;

export async function traitCategory(traitId: bigint) {
  const metadata = await publicClient.readContract({
    address: CHONK_TRAITS,
    abi: traitMetadataAbi,
    functionName: "getTraitMetadata",
    args: [traitId],
  });

  return metadata.traitType;
}

// traitType comes back as a plain number, which is what unequip's uint8 wants.
export async function unequipForSale(chonkId: bigint, traitId: bigint) {
  const category = await traitCategory(traitId);

  const unequipHash = await wallet.writeContract({
    address: CHONKS_MAIN,
    abi: chonksMainAbi,
    functionName: "unequip",
    args: [chonkId, category],
  });

  await publicClient.waitForTransactionReceipt({ hash: unequipHash });
}
export async function sellableTraits(chonkId: bigint) {
  const traitIds = await publicClient.readContract({
    address: CHONKS_MAIN,
    abi: chonksMainAbi,
    functionName: "getTraitsForChonkId",
    args: [chonkId],
  });

  const sellable: bigint[] = [];
  for (const traitId of traitIds) {
    const isEquipped = await publicClient.readContract({
      address: CHONKS_MAIN,
      abi: chonksMainAbi,
      functionName: "checkIfTraitIsEquipped",
      args: [chonkId, traitId],
    });

    if (!isEquipped) sellable.push(traitId);
  }

  return sellable;
}

List A Trait

function offerTraitWithCurrency(
    uint256 _traitId,
    uint256 _chonkId,
    uint256 _price,
    address _currency
) external
_chonkId is the Chonk whose TBA holds _traitId. The contract checks all of this before it writes the listing:
  • You must be the EOA that owns _chonkId, and _chonkId's TBA must be the current owner of _traitId. Otherwise it reverts NotYourTrait.
  • The Trait must be unequipped, otherwise TraitEquipped.
  • The Chonk's TBA must have approved the marketplace on ChonkTraits, otherwise ApproveTheMarketplace.
  • _price must not be zero, otherwise CantBeZero.
  • _currency must be address(0) or a supported ERC-20, otherwise UnsupportedCurrency.
  • The marketplace must not be paused, otherwise Paused.
Listing a Trait also cancels any active listing on the Chonk itself, emitting ChonkOfferCanceled. You cannot sell a Chonk and strip Traits out of it at the same time.
import { parseEther } from "viem";

export const marketplaceAbi = [
  {
    type: "function",
    name: "offerTraitWithCurrency",
    stateMutability: "nonpayable",
    inputs: [
      { name: "_traitId", type: "uint256" },
      { name: "_chonkId", type: "uint256" },
      { name: "_price", type: "uint256" },
      { name: "_currency", type: "address" },
    ],
    outputs: [],
  },
  {
    type: "function",
    name: "offerTraitsWithCurrency",
    stateMutability: "nonpayable",
    inputs: [
      { name: "_traitIds", type: "uint256[]" },
      { name: "_chonkId", type: "uint256" },
      { name: "_prices", type: "uint256[]" },
      { name: "_currency", type: "address" },
    ],
    outputs: [],
  },
  {
    type: "function",
    name: "offerTraitToAddressWithCurrency",
    stateMutability: "nonpayable",
    inputs: [
      { name: "_traitId", type: "uint256" },
      { name: "_chonkId", type: "uint256" },
      { name: "_price", type: "uint256" },
      { name: "_onlySellTo", type: "address" },
      { name: "_currency", type: "address" },
    ],
    outputs: [],
  },
  {
    type: "function",
    name: "cancelOfferTrait",
    stateMutability: "nonpayable",
    inputs: [
      { name: "_traitId", type: "uint256" },
      { name: "_chonkId", type: "uint256" },
    ],
    outputs: [],
  },
  {
    type: "function",
    name: "traitOffers",
    stateMutability: "view",
    inputs: [{ name: "traitId", type: "uint256" }],
    outputs: [
      { name: "price", type: "uint256" },
      { name: "currency", type: "address" },
      { name: "seller", type: "address" },
      { name: "sellerTBA", type: "address" },
      { name: "onlySellTo", type: "address" },
    ],
  },
  {
    type: "function",
    name: "traitOfferIsValid",
    stateMutability: "view",
    inputs: [{ name: "_traitId", type: "uint256" }],
    outputs: [{ name: "", type: "bool" }],
  },
  {
    type: "function",
    name: "withdrawFunds",
    stateMutability: "nonpayable",
    inputs: [],
    outputs: [],
  },
  {
    type: "function",
    name: "withdrawERC20Funds",
    stateMutability: "nonpayable",
    inputs: [{ name: "_token", type: "address" }],
    outputs: [],
  },
  {
    type: "function",
    name: "withdrawableFunds",
    stateMutability: "view",
    inputs: [{ name: "eoa", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
  },
  {
    type: "function",
    name: "withdrawableERC20Funds",
    stateMutability: "view",
    inputs: [
      { name: "eoa", type: "address" },
      { name: "token", type: "address" },
    ],
    outputs: [{ name: "", type: "uint256" }],
  },
] as const;

const chonkId = 12345n;
const traitId = 345678n;

await ensureMarketplaceApproved(chonkId);

// Simulating first surfaces a revert as a named error instead of a failed
// transaction you have already paid for. marketplaceAbiWithErrors is the
// function ABI plus the error fragments from Error Recovery below.
const { request } = await publicClient.simulateContract({
  account,
  address: MARKETPLACE,
  abi: marketplaceAbiWithErrors,
  functionName: "offerTraitWithCurrency",
  args: [traitId, chonkId, parseEther("0.01"), ETH],
});

const listHash = await wallet.writeContract(request);
await publicClient.waitForTransactionReceipt({ hash: listHash });
A successful listing emits TraitOfferedWithCurrency. traitId, price, and seller are indexed, so those are the three you can topic-filter on; the rest arrive in the data.
export const traitOfferedEvent = {
  type: "event",
  name: "TraitOfferedWithCurrency",
  inputs: [
    { name: "traitId", type: "uint256", indexed: true },
    { name: "price", type: "uint256", indexed: true },
    { name: "seller", type: "address", indexed: true },
    { name: "sellerTBA", type: "address", indexed: false },
    { name: "currency", type: "address", indexed: false },
    { name: "onlySellTo", type: "address", indexed: false },
  ],
} as const;

List Multiple Traits

function offerTraitsWithCurrency(
    uint256[] calldata _traitIds,
    uint256 _chonkId,
    uint256[] calldata _prices,
    address _currency
) external
This is the path most bots want. One transaction, one Chonk, one currency, and a _prices array aligned by index with _traitIds.
  • Every Trait must belong to the same _chonkId. Each one is validated individually, so a single bad Trait reverts the whole batch with NotYourTrait.
  • Empty arrays revert CantBeZero, and so does any zero price in _prices.
  • Mismatched array lengths revert WrongAmount.
  • All Traits are listed in the same _currency. Two currencies means two transactions.
import { parseUnits } from "viem";

const traitIds = [345678n, 345679n, 345680n];
const prices = [
  parseUnits("25.50", 6),
  parseUnits("40", 6),
  parseUnits("12.75", 6),
];

await ensureMarketplaceApproved(chonkId);

const batchHash = await wallet.writeContract({
  address: MARKETPLACE,
  abi: marketplaceAbiWithErrors,
  functionName: "offerTraitsWithCurrency",
  args: [traitIds, chonkId, prices, USDC],
});

await publicClient.waitForTransactionReceipt({ hash: batchHash });
Listing a whole wardrobe means several of these transactions. Send them one at a time, with a receipt and a pause between each, rather than firing them off together — see Pacing And Retries.
const CHUNK_SIZE = 20;

for (let start = 0; start < traitIds.length; start += CHUNK_SIZE) {
  const idChunk = traitIds.slice(start, start + CHUNK_SIZE);
  const priceChunk = prices.slice(start, start + CHUNK_SIZE);

  const hash = await wallet.writeContract({
    address: MARKETPLACE,
    abi: marketplaceAbiWithErrors,
    functionName: "offerTraitsWithCurrency",
    args: [idChunk, chonkId, priceChunk, USDC],
  });

  // One nonce at a time. Waiting for the receipt before the next chunk is what
  // keeps a rate-limited endpoint from dropping the transactions behind it.
  await publicClient.waitForTransactionReceipt({ hash });
  await pause(WRITE_DELAY_MS);
}

Private Listings

function offerTraitToAddressWithCurrency(
    uint256 _traitId,
    uint256 _chonkId,
    uint256 _price,
    address _onlySellTo,
    address _currency
) external
Same rules as a public listing, plus one: only _onlySellTo can buy the Trait. Everyone else reverts YouCantBuyThatTrait.
_onlySellTo must be a regular wallet. Passing a Chonk's TBA reverts OnlySellToEOAs, because the buyer's own Chonk is what receives the Trait, and the marketplace matches _onlySellTo against the buying wallet.
// Must be a plain wallet address. A Chonk's TBA reverts OnlySellToEOAs.
const buyer = "0x1111111111111111111111111111111111111111" as Address;

const privateHash = await wallet.writeContract({
  address: MARKETPLACE,
  abi: marketplaceAbiWithErrors,
  functionName: "offerTraitToAddressWithCurrency",
  args: [traitId, chonkId, parseEther("0.01"), buyer, ETH],
});

Read A Listing

traitOffers(traitId) is a public getter. It returns price, currency, seller, sellerTBA, and onlySellTo. A seller of the zero address means there is no listing.
traitOfferIsValid(traitId) returns false when there is no listing, or when the stored seller no longer owns the Chonk that holds the Trait. Use it as your staleness check.
const [price, currency, seller, sellerTBA, onlySellTo] =
  await publicClient.readContract({
    address: MARKETPLACE,
    abi: marketplaceAbi,
    functionName: "traitOffers",
    args: [traitId],
  });

const isValid = await publicClient.readContract({
  address: MARKETPLACE,
  abi: marketplaceAbi,
  functionName: "traitOfferIsValid",
  args: [traitId],
});
Store the price and currency your bot intended alongside what the contract returns. Any drift means someone bought, canceled, or moved the Trait.

Cancel A Listing

function cancelOfferTrait(uint256 _traitId, uint256 _chonkId) external
Canceling reverts NoOfferToCancel when there is nothing listed, and NotYourTrait when the Trait is no longer in that Chonk's Backpack or the Chonk is not yours. Cancel is not gated by the pause flag, so you can always pull your listings.
const cancelHash = await wallet.writeContract({
  address: MARKETPLACE,
  abi: marketplaceAbiWithErrors,
  functionName: "cancelOfferTrait",
  args: [traitId, chonkId],
});
A successful cancel emits TraitOfferCanceled. Both parameters are indexed, so the log carries no data payload.
export const traitOfferCanceledEvent = {
  type: "event",
  name: "TraitOfferCanceled",
  inputs: [
    { name: "traitId", type: "uint256", indexed: true },
    { name: "seller", type: "address", indexed: true },
  ],
} as const;
Some things cancel listings for you:
  • Transferring the Trait out of the Chonk clears its listing on the way through.
  • Selling or transferring the Chonk does not clear the Trait listings its TBA holds. Clearing every one of them would make a Chonk transfer cost scale with the size of its Backpack. Those listings survive in storage but stop working: traitOfferIsValid returns false and any buyer reverts SellerMustRelistTrait. The new owner has to relist.

Repricing

There is no reprice function. An active listing's price and currency are frozen:
  • Relisting the same Trait at a different price reverts CantChangeListingPrice.
  • Relisting it in a different currency reverts CantChangeListingCurrency.
The price is checked first, so in practice a currency switch also reports CantChangeListingPrice. ETH prices carry 18 decimals and USDC 6, so switching currency almost always changes the raw number too, and the price guard trips before the currency guard is reached. You only see CantChangeListingCurrency when the raw price integer is identical in both currencies. Do not branch on the difference — the fix is the same either way.
To move a price, call cancelOfferTrait and then list again. Two transactions, in that order. Relisting at the exact same price and currency does succeed — it rewrites the identical listing and re-emits TraitOfferedWithCurrency — which is convenient if your bot is not tracking state precisely, but it still costs gas.

Getting Paid

You are paid inside the buyer's transaction. There is nothing to claim in the normal case.
The marketplace takes its royalty first. royaltyPercentage is basis points out of 10,000 and is capped at 500, so 5% is the ceiling. It is a public getter on the marketplace, so read the live value rather than hard-coding one — add { type: "function", name: "royaltyPercentage", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] } to your ABI if you want to net out proceeds before listing. That share goes to the team wallet and the rest is sent to the seller EOA in the listing currency: ETH for address(0) listings, the token for ERC-20 listings.
Payouts are attempted, not assumed. The ETH payout is sent with a gas stipend, so a seller address that reverts or burns too much gas on receive does not block the sale. When a direct payout fails, the amount is credited instead:
  • ETH accrues to withdrawableFunds(yourAddress) and is pulled with withdrawFunds().
  • ERC-20 accrues to withdrawableERC20Funds(yourAddress, token) and is pulled with withdrawERC20Funds(token). Withdrawing a zero balance reverts CantBeZero.
A plain EOA seller will effectively never hit this path, but a bot selling into a contract address should sweep both balances on a schedule.
Sales emit TraitBought(traitId, buyerTBA, amountInWei, buyer, seller) for ETH listings and TraitBoughtWithCurrency(traitId, buyerTBA, amount, buyer, seller, currency) for ERC-20 listings. seller is not an indexed parameter, so you cannot topic-filter on it — watch both events and filter client-side on log.args.seller.
publicClient.watchContractEvent({
  address: MARKETPLACE,
  abi: [
    {
      type: "event",
      name: "TraitBought",
      inputs: [
        { name: "traitId", type: "uint256", indexed: true },
        { name: "buyerTBA", type: "address", indexed: true },
        { name: "amountInWei", type: "uint256", indexed: true },
        { name: "buyer", type: "address", indexed: false },
        { name: "seller", type: "address", indexed: false },
      ],
    },
  ] as const,
  eventName: "TraitBought",
  onLogs: (logs) => {
    for (const log of logs) {
      if (log.args.seller !== account.address) continue;
      console.log("sold", log.args.traitId);
    }
  },
});

Error Recovery

Every one of these is a custom revert from the marketplace contract. viem only decodes a revert into a name when the ABI you pass includes the matching error fragment, and the function-only ABI above has none — append these entries (or use the full verified ABI from Basescan):
export const marketplaceErrors = [
  { type: "error", name: "ApproveTheMarketplace", inputs: [] },
  { type: "error", name: "CantBeZero", inputs: [] },
  { type: "error", name: "CantChangeListingCurrency", inputs: [{ name: "id", type: "uint256" }] },
  { type: "error", name: "CantChangeListingPrice", inputs: [{ name: "id", type: "uint256" }] },
  { type: "error", name: "NoOfferToCancel", inputs: [] },
  { type: "error", name: "NotYourOffer", inputs: [] },
  { type: "error", name: "NotYourTrait", inputs: [] },
  { type: "error", name: "OnlySellToEOAs", inputs: [] },
  { type: "error", name: "Paused", inputs: [] },
  { type: "error", name: "SellerMustRelistTrait", inputs: [{ name: "traitId", type: "uint256" }] },
  { type: "error", name: "TraitEquipped", inputs: [{ name: "traitId", type: "uint256" }] },
  { type: "error", name: "UnsupportedCurrency", inputs: [] },
  { type: "error", name: "WrongAmount", inputs: [] },
] as const;

// Use this everywhere instead of the bare marketplaceAbi. Passing the
// function-only ABI leaves you with a raw selector like 0xdca7df9c rather
// than TraitEquipped(5756).
export const marketplaceAbiWithErrors = [
  ...marketplaceAbi,
  ...marketplaceErrors,
] as const;
Reverts surface most cleanly through publicClient.simulateContract({ account, ... }) before you write, as in List A Trait. A simulation that throws costs no gas and gives you the named error; a writeContract that reverts has already cost you a transaction.
  • NotYourTrait: The calling wallet does not own the Chonk, or the Trait is not in that Chonk's Backpack. Re-read getFullPictureForTrait and use the right Chonk ID.
  • TraitEquipped: Unequip the Trait with ChonksMain.unequip(chonkId, category), then list.
  • ApproveTheMarketplace: The Chonk's TBA has not approved the marketplace on ChonkTraits. Run the one-time TBA approval and wait for the receipt.
  • UnsupportedCurrency: Use address(0) for ETH or USDC. WETH is rejected for listings.
  • CantBeZero: A price was zero, a batch array was empty, or a withdrawal had no balance.
  • WrongAmount: _traitIds and _prices are different lengths in a batch listing.
  • CantChangeListingPrice: Cancel first, then relist at the new price.
  • CantChangeListingCurrency: Cancel first, then relist in the new currency.
  • NoOfferToCancel: There is no active listing for that Trait. Your local state is stale.
  • NotYourOffer: The cancel guard for a listing you do not own. cancelOfferTrait's owner check already blocks this case earlier, so in practice you should never see it — if you do, refresh from traitOffers.
  • OnlySellToEOAs: A private listing targeted a Chonk TBA. Use the buyer's wallet address.
  • Paused: The marketplace is paused. Listing is blocked, canceling is not. Back off and retry.
  • SellerMustRelistTrait: A buyer hit your stale listing after the Chonk changed hands. The current Chonk owner has to relist.
Not every failure is a revert. An HTTP 429, a request timeout, or a transaction that never appears on-chain is your RPC endpoint rate-limiting you, not the marketplace rejecting the call. Those have no error name to catch. Slow the bot down, get the user onto their own endpoint, and re-read traitOffers before you retry anything — see RPC Endpoint and Pacing And Retries.

Bot Loop

For each Chonk your seller EOA owns:
  1. Resolve the Chonk's TBA with getOwnerAndTBAAddressForChonkId.
  2. Ensure the TBA has approved the marketplace once, and wait for the receipt.
  3. Enumerate Traits with getTraitsForChonkId and drop the equipped ones with checkIfTraitIsEquipped. Pace these reads — see Pacing And Retries.
  4. Read traitOffers for each and drop every Trait that already has an active listing — seller is not the zero address — unless you are relisting at the exact same price and currency. Filtering only the ones already at your target price is not enough: a Trait listed at a different price is the one that reverts, and because a batch is all-or-nothing it takes the rest of the batch down with it.
  5. List the rest with offerTraitsWithCurrency, batched per currency, waiting for each receipt and pausing between transactions.
  6. Watch TraitBought and TraitBoughtWithCurrency filtered on your seller address to know what filled.
  7. To reprice, call cancelOfferTrait and then list again. Never relist over a live listing at a new price.
  8. If a Chonk changed hands, treat every listing its TBA held as unsellable. Check traitOfferIsValid. The new owner has to approve the marketplace again before relisting, because approval does not transfer with the Chonk. Note the listings are only dormant, not deleted: if the Chonk returns to its previous owner they go valid again at their original prices, so cancel anything you no longer want rather than assuming a transfer cleared it.
  9. Re-read traitOffers for every Trait the run meant to list and relist the ones that are missing. On a rate-limited endpoint a run can end with fewer listings than it thinks it made.
Store the Trait ID, Chonk ID, price, and currency for every listing you create. Those four values are all you need to cancel or reprice later.