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.
| Thing | Value |
|---|---|
| Chain | Robinhood Chain testnet, chainId 46630 |
| RPC | https://rpc.testnet.chain.robinhood.com |
| Explorer | https://explorer.testnet.chain.robinhood.com |
| Ledger (entry point, reads, EIP-712 verifying contract) | 0xB2E4833C2126A0390eDf43De6aeB7eBF90c1De34 |
| LedgerViews (aggregated snapshots) | 0xEEef132a22887bce3d820e7f065d0Be220625efd |
| eUSDC token | 0x424c1b50462c98FdE7d6a2e8E41069A0a8DBe342 |
| Relayer base URL | https://app.popularityx.com/relayer |
Relayer endpoints, all under the base URL:
| Endpoint | What it does |
|---|---|
POST /relay | Submit a signed BuyOrder (gasless trade) |
GET /history?marketId=N | Full per-position price history |
GET /history/trades?marketId=N | Recent individual trades |
GET /health | Liveness 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.05means the market assigns that position a 5% share of the board, and one Long token costs 0.05. Short price is always1 - 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,
withdrawfree 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 usdcInOn 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 tokeninfos 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.05The 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:
| Response | Meaning | Action |
|---|---|---|
{ 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: trueor a freshmetaNoncesbefore the next. usdcInmust not exceed the wallet'srealFreeCollateral.isLong: falsebuys the Short token. Same struct, same flow.- Always set a slippage floor.
minTokensOut: 0accepts 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) externalOn 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.