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. Enumerating a Backpack is one call per Trait, so a bot that polls will be rate-limited on a public endpoint
  • The Chonk IDs you want to sell from

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
  • Put the private key in an environment variable or secrets manager like 1Password. 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

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.

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);
// Point these at your own RPC. Bare http() falls back to the public Base
// endpoint, which will rate-limit a bot that enumerates Backpacks.
const transport = http(process.env.BASE_RPC_URL);

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 });

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.

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.
  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.
  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.
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.