Bot Guide

Bot Guide

This is the technical guide to running a bot against PopularityX. Everything a bot needs is a private key and HTTP access: reads are plain RPC calls, trades are signed orders that a relayer submits and pays gas for, and there is a direct on-chain path if you would rather manage your own gas.

New to the beta? Start at Welcome. For the short, non-technical version of running a bot, see Running a Bot.

Giving your bot a key

Your account holds a stack of eUSDC on your app wallet, and every wallet you add maps back to your @handle, so the tape and the leaderboard attribute all of it to you. For how accounts and the beta stack work, see the House Rules.

To trade, a bot needs a private key it can sign with, and some eUSDC on the wallet that key controls. There are two ways to give it a key.

Option A: link an external wallet

In the wallet panel you can link external wallets to your account (up to 3). Link a wallet you generated yourself, and give your bot that key. The linked wallet shows up on the tape under your @handle like your app wallet does.

The stack lives on your app wallet, so move some of it over: eUSDC is a standard ERC20, and a plain transfer to the bot wallet moves tradeable balance. The bot then trades its own balance with its own key.

This is the recommended setup. The key never leaves the machine that generated it, and the bot only ever holds the balance you gave it.

Option B: export your app wallet's key

The app wallet is a Privy embedded wallet, and Privy supports exporting its private key from the wallet panel. A bot holding that key trades your full stack directly.

Be sober about what this means. Whoever holds that key controls the wallet: the whole stack, not a budget you set. Export it only onto a machine you control, keep it out of source control, logs, and shell history, and prefer Option A if you plan to run anything you did not write yourself. Treat a leaked key as a lost stack.

Network and endpoints

Everything runs on Robinhood Chain testnet.

ThingValue
ChainRobinhood Chain testnet, chainId 46630
RPChttps://rpc.testnet.chain.robinhood.com
Explorerhttps://explorer.testnet.chain.robinhood.com
Ledger (entry point, reads, EIP-712 verifying contract)0xB2E4833C2126A0390eDf43De6aeB7eBF90c1De34
LedgerViews (aggregated snapshots)0xEEef132a22887bce3d820e7f065d0Be220625efd
eUSDC token0x424c1b50462c98FdE7d6a2e8E41069A0a8DBe342
Relayer base URLhttps://app.popularityx.com/relayer

Relayer endpoints, all under the base URL:

EndpointWhat it does
POST /relaySubmit a signed BuyOrder (gasless trade)
GET /history?marketId=NFull per-position price history
GET /history/trades?marketId=NRecent individual trades
GET /healthLiveness check

The mental model

  • The unit of account is eUSDC, shown in the app as dollars. Balances and trade amounts are 1e6 fixed point, so $1 is 1_000_000.
  • A board contains a fixed roster of positions. Each position has a Long token and a Short token.
  • Prices are 1e18 WAD. A Long price of 0.05 means the market assigns that position a 5% share of the board, and one Long token costs 0.05. Short price is always 1 - long.
  • At the contract level everything is a buy. To reduce or reverse a position you buy the opposite side: Long and Short cancel when they arrive in the same wallet, releasing collateral. The app presents this as selling; a bot just buys the other token. To cash out, withdraw free collateral.
  • Gasless orders use a sequential per-trader nonce: one in-flight order at a time.

Enumerate boards and positions at runtime rather than hardcoding them. getMarkets() on the Ledger is the source of truth. The flagship Crypto Influencers board is marketId 1, with one position per influencer. On-chain a position is identified by its numeric position id; the X handle you see in the app is the roster's display mapping for that id, so key on the id, not the handle, and a handle change does not change the position.

Board and position creation is permissioned. Bots trade existing boards; to request a new board or position, ask the team.

Reading the board

All reads are view calls against the RPC. Prices in WAD (1e18), amounts in 1e6.

On the Ledger:

function getMarkets() view returns (uint256[])
function getMarketPositions(uint256 marketId) view returns (uint256[])
function getPricingMM(uint256 marketId) view returns (address)
function metaNonces(address trader) view returns (uint256)
function realFreeCollateral(address account) view returns (uint256)

On the pricing MM (address from getPricingMM):

function getLongPriceWad(uint256 marketId, uint256 positionId) view returns (uint256)
function getShortPriceWad(uint256 marketId, uint256 positionId) view returns (uint256)
function previewBuyForUSDCFull(uint256 marketId, uint256 positionId, bool isLong, uint256 usdcIn)
  view returns (FullVectorPreview) // .amount = tokensOut for usdcIn

On LedgerViews, one call for a whole board:

function getMarketSnapshot(uint256 marketId)
  returns (MarketInfo m, PositionInfoExtended[] infos)
function getMarketSnapshotForAccount(uint256 marketId, address account)
  returns (MarketInfo m, PositionInfoWithBalanceExtended[] infos) // adds .balance per token

infos has 2*n entries for n positions: index 2*i is Long, 2*i+1 is Short.

const { ethers } = require("ethers");
const provider = new ethers.JsonRpcProvider("https://rpc.testnet.chain.robinhood.com");
const LEDGER = "0xB2E4833C2126A0390eDf43De6aeB7eBF90c1De34";
 
const ledger = new ethers.Contract(LEDGER, [
  "function getMarkets() view returns (uint256[])",
  "function getPricingMM(uint256) view returns (address)",
  "function metaNonces(address) view returns (uint256)",
  "function realFreeCollateral(address) view returns (uint256)",
], provider);
 
const mm = new ethers.Contract(await ledger.getPricingMM(1n), [
  "function getLongPriceWad(uint256,uint256) view returns (uint256)",
], provider);
const longWad = await mm.getLongPriceWad(1n, 2n);
console.log("Long price:", Number(longWad) / 1e18); // e.g. 0.05

The price history endpoint (/history?marketId=1) and the trades feed (/history/trades?marketId=1) give you movement and momentum without scanning chain events.

Placing a trade (gasless)

Build a BuyOrder, sign it with EIP-712, POST it to the relayer. The relayer simulates, batches, submits, and pays gas. The position is credited to the signer, never the relayer.

The signing domain is a protocol constant:

const DOMAIN = {
  name: "Sentidex-Ledger", // protocol-level constant, exact string required
  version: "1",
  chainId: 46630,
  verifyingContract: "0xB2E4833C2126A0390eDf43De6aeB7eBF90c1De34", // Ledger
};
const TYPES = { BuyOrder: [
  { name: "trader",       type: "address" },
  { name: "marketId",     type: "uint256" },
  { name: "positionId",   type: "uint256" },
  { name: "isLong",       type: "bool"    },
  { name: "usdcIn",       type: "uint256" },
  { name: "minTokensOut", type: "uint256" },
  { name: "nonce",        type: "uint256" },
  { name: "deadline",     type: "uint256" },
] };
const RELAYER_BASE = "https://app.popularityx.com/relayer";
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
 
async function buy({ marketId, positionId, isLong, usdcIn, slippageBps = 300 }) {
  const mm = new ethers.Contract(await ledger.getPricingMM(marketId), [
    "function previewBuyForUSDCFull(uint256,uint256,bool,uint256) view returns (tuple(uint256 amount,uint256[] a,uint256[] b,uint256[] c,uint256 d,uint256 e,uint256 f,uint256 g))",
  ], provider);
  const expected = (await mm.previewBuyForUSDCFull(marketId, positionId, isLong, usdcIn)).amount;
  const minTokensOut = expected * BigInt(10_000 - slippageBps) / 10_000n;
 
  const order = {
    trader: wallet.address,
    marketId: BigInt(marketId),
    positionId: BigInt(positionId),
    isLong,
    usdcIn: BigInt(usdcIn),
    minTokensOut,
    nonce: await ledger.metaNonces(wallet.address),
    deadline: BigInt(Math.floor(Date.now() / 1000) + 600),
  };
  const signature = await wallet.signTypedData(DOMAIN, TYPES, order);
 
  const res = await fetch(`${RELAYER_BASE}/relay`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      order: {
        trader: order.trader, marketId: order.marketId.toString(),
        positionId: order.positionId.toString(), isLong: order.isLong,
        usdcIn: order.usdcIn.toString(), minTokensOut: order.minTokensOut.toString(),
        nonce: order.nonce.toString(), deadline: order.deadline.toString(),
      },
      signature,
    }),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data.error || `relay ${res.status}`);
  return data; // { txHash, included }
}

Response handling:

ResponseMeaningAction
{ txHash, included: true }Filled. Position credited, nonce advanced.Next order.
{ txHash, included: false }Batched but skipped on-chain (slippage or solvency). Nonce did not advance.Re-quote, re-sign with a looser minTokensOut.
409 { error: "stale nonce: ..." }Your nonce does not match the chain.Re-read metaNonces, retry.
4xx { error }Bad signature, expired, or malformed.Fix and re-sign.

Rules of the road:

  • One in-flight order per wallet. Wait for included: true or a fresh metaNonces before the next.
  • usdcIn must not exceed the wallet's realFreeCollateral.
  • isLong: false buys the Short token. Same struct, same flow.
  • Always set a slippage floor. minTokensOut: 0 accepts any fill.

Placing a trade (own gas)

If your bot wallet holds testnet ETH it can skip the relayer and call the Ledger directly. No EIP-712, no per-trader nonce coordination; the wallet submits the transaction, pays gas, and the position is credited to msg.sender. The same eUSDC balance is spent either way, so a bot can mix both paths.

function buyForMarket(uint256 marketId, uint256 positionId, bool isLong,
                      uint256 usdcIn, uint256 minTokensOut) external
function buyExactTokensForMarket(uint256 marketId, uint256 positionId, bool isLong,
                                 uint256 t, uint256 maxUSDCIn) external

On a bad trade this path reverts instead of returning included: false; catch the revert, re-quote, retry. If your bot wallet needs gas, ask the team.

Reading back a position

// LedgerViews: balances for every Long/Short token in a board, one call
getMarketSnapshotForAccount(marketId, account) // .infos[i].balance, .isLong
 
// Ledger: net view of one position
getPositionLiquidity(account, marketId, positionId)

Testnet values only. The stack is test eUSDC with no real value, which is exactly why the beta is the right place to prove a strategy.