DEV Community

Cover image for Building a Pons Memestock Trading Bot on Robinhood Chain with TypeScript
hamssog
hamssog

Posted on Originally published at hamssog.substack.com

Building a Pons Memestock Trading Bot on Robinhood Chain with TypeScript

A production-oriented architecture for launch detection, market analysis, risk management, automated execution, position tracking, and reconciliation.

A simple trading bot can look like this:

Token Launch
     ↓
Signal
     ↓
BUY
Enter fullscreen mode Exit fullscreen mode

That is enough for a prototype.

It is not enough for a production-oriented Pons memestock trading bot.

A real system needs to answer:

What token launched?

Is this the correct contract?

Where is it trading?

What is the current market state?

Is the token inside the launch-protection window?

Is there enough liquidity?

Does the strategy produce a signal?

How much should be traded?

What is the executable quote?

Was the transaction confirmed?

How much actually filled?

What is the real position now?

Does local state match the chain?
Enter fullscreen mode Exit fullscreen mode

A better architecture is:

PONS EVENTS
     ↓
TOKEN INDEXER
     ↓
MARKET STATE
     ↓
STRATEGY
     ↓
RISK
     ↓
EXECUTION
     ↓
ORDER / FILL STATE
     ↓
POSITION
     ↓
RECONCILIATION
Enter fullscreen mode Exit fullscreen mode

Pons currently runs on Robinhood Chain, chain ID 4663. Its current documentation describes launches that create the token and its WETH trading pool together, with trading occurring in that same pool after launch. The documentation also recommends indexing factory and pool events as the onchain source of truth.

The term “memestock” is used by projects in the broader Pons ecosystem, but it should not be presented as an official Pons protocol category. Pons describes the underlying protocol more generally as a place to launch and trade tokens on Robinhood Chain.


1. Scanner and trading bot are different systems

A scanner answers:

What just happened?

A trading bot answers:

What should I do about it?

For example, the scanner may produce:

NEW PONS TOKEN

Symbol:
MEMESTOCK

Contract:
0x41...

Pair:
WETH

Pool:
0x82...

Liquidity:
$125,000
Enter fullscreen mode Exit fullscreen mode

That does not mean:

BUY
Enter fullscreen mode Exit fullscreen mode

The trading engine still needs to validate:

Launch
  ↓
Contract
  ↓
Market
  ↓
Liquidity
  ↓
Trading State
  ↓
Strategy
  ↓
Risk
  ↓
Execution
Enter fullscreen mode Exit fullscreen mode

This separation is one of the most important architectural decisions in the system.


2. Current Pons market model

A trading bot should be built against the actual protocol model rather than assumptions from older launchpad designs.

The current Pons documentation says each launch creates the token and its WETH trading pool together. Current launches do not start on a bonding curve and later migrate; trading occurs in that same pool from launch, and graduation confirms that the pool has reached its configured threshold.

The current flow is:

CREATE
  ↓
TOKEN + WETH POOL
  ↓
TRADING
  ↓
GRADUATION
Enter fullscreen mode Exit fullscreen mode

The current integration docs identify the active factory and Uniswap V3 infrastructure and recommend registering the pool emitted by TokenLaunched, then indexing its Swap events.

A production bot should therefore make the market resolver explicit:

interface TokenMarket {
  tokenAddress: string;

  pairToken: string;

  poolAddress: string;

  marketType: "PONS_V3";
}
Enter fullscreen mode Exit fullscreen mode

Do not hard-code assumptions throughout the strategy.


3. Build the blockchain client

A simple viem client:

import {
  createPublicClient,
  http,
} from "viem";

export const publicClient =
  createPublicClient({
    chain: {
      id: 4663,
      name: "Robinhood Chain",
      nativeCurrency: {
        name: "Ether",
        symbol: "ETH",
        decimals: 18,
      },
      rpcUrls: {
        default: {
          http: [
            "https://rpc.mainnet.chain.robinhood.com",
          ],
        },
      },
    },
    transport: http(
      process.env.RH_RPC_URL
    ),
  });
Enter fullscreen mode Exit fullscreen mode

In production, keep infrastructure configuration outside the repository:

const rpcUrl =
  process.env.RH_RPC_URL;

if (!rpcUrl) {
  throw new Error(
    "RH_RPC_URL is required"
  );
}
Enter fullscreen mode Exit fullscreen mode

For live trading, use a dedicated RPC provider and proper monitoring rather than treating a public endpoint as unlimited infrastructure.


4. Detect new launches

The first event the scanner should process is:

TokenLaunched
Enter fullscreen mode Exit fullscreen mode

Define the event:

import {
  parseAbiItem,
} from "viem";

export const tokenLaunchedEvent =
  parseAbiItem(
    "event TokenLaunched(" +
      "address indexed token," +
      "address indexed deployer," +
      "address indexed dexFactory," +
      "address pairToken," +
      "address pool," +
      "uint256 dexId," +
      "uint256 launchConfigId," +
      "uint256 positionId," +
      "uint256 restrictionsEndBlock," +
      "uint256 initialBuyAmount" +
    ")"
  );
Enter fullscreen mode Exit fullscreen mode

Then:

const logs =
  await publicClient.getLogs({
    address:
      "0xA5aAb3F0c6EeadF30Ef1D3Eb997108E976351feB",

    event:
      tokenLaunchedEvent,

    fromBlock: startBlock,
    toBlock: endBlock,
  });
Enter fullscreen mode Exit fullscreen mode

The important step is not getting the log.

It is turning the log into persistent application state.


5. Normalize launch information

Create an internal model:

interface PonsLaunch {
  tokenAddress: string;

  deployer: string;

  pairToken: string;

  poolAddress: string;

  launchBlock: bigint;

  restrictionsEndBlock: bigint;

  initialBuyAmount: bigint;

  transactionHash: string;

  createdAt: number;
}
Enter fullscreen mode Exit fullscreen mode

Now the rest of the application works with normalized fields rather than raw ABI data.


6. Track the launch-protection window

Current Pons documentation describes temporary launch protection around the first blocks after launch. The launch event exposes restrictionsEndBlock, so the bot should model that state explicitly.

interface LaunchProtection {
  startBlock: bigint;
  endBlock: bigint;
  active: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Then:

function isLaunchProtectionActive(
  currentBlock: bigint,
  endBlock: bigint
): boolean {
  return currentBlock <= endBlock;
}
Enter fullscreen mode Exit fullscreen mode

The strategy can therefore decide:

Launch detected
      ↓
Protection active?
      │
      ├── YES → wait / strategy-specific handling
      │
      └── NO  → normal execution rules
Enter fullscreen mode Exit fullscreen mode

Do not assume that detecting a launch means an unrestricted buy is immediately executable.


7. Validate the token contract

A ticker is not a sufficient identity.

Pons's documentation warns that names and symbols can be copied and instructs users to verify the token address.

The bot should therefore store:

interface TokenIdentity {
  address: string;
  symbol: string;
  name: string;

  chainId: number;
}
Enter fullscreen mode Exit fullscreen mode

Normalize addresses:

function normalizeAddress(
  address: string
): string {
  return address.toLowerCase();
}
Enter fullscreen mode Exit fullscreen mode

The contract address becomes the primary identifier throughout the system.


8. Read token state directly

The current Pons launch token exposes useful state directly onchain, including metadata and its liquidity pool.

For example:

import { parseAbi } from "viem";

const tokenAbi = parseAbi([
  "function name() view returns (string)",
  "function symbol() view returns (string)",
  "function decimals() view returns (uint8)",
  "function totalSupply() view returns (uint256)",
  "function logo() view returns (string)",
  "function description() view returns (string)",
  "function liquidityPool() view returns (address)",
]);
Enter fullscreen mode Exit fullscreen mode

Then:

const [
  name,
  symbol,
  decimals,
  totalSupply,
  logo,
  description,
  pool,
] = await Promise.all([
  publicClient.readContract({
    address: tokenAddress,
    abi: tokenAbi,
    functionName: "name",
  }),

  publicClient.readContract({
    address: tokenAddress,
    abi: tokenAbi,
    functionName: "symbol",
  }),

  publicClient.readContract({
    address: tokenAddress,
    abi: tokenAbi,
    functionName: "decimals",
  }),

  publicClient.readContract({
    address: tokenAddress,
    abi: tokenAbi,
    functionName:
      "totalSupply",
  }),

  publicClient.readContract({
    address: tokenAddress,
    abi: tokenAbi,
    functionName: "logo",
  }),

  publicClient.readContract({
    address: tokenAddress,
    abi: tokenAbi,
    functionName: "description",
  }),

  publicClient.readContract({
    address: tokenAddress,
    abi: tokenAbi,
    functionName:
      "liquidityPool",
  }),
]);
Enter fullscreen mode Exit fullscreen mode

This avoids depending on a centralized token database.


9. Store the token registry

Every discovered token should be persisted.

interface PonsToken {
  address: string;

  symbol: string;
  name: string;

  deployer: string;

  pairToken: string;
  poolAddress: string;

  launchBlock: bigint;

  restrictionsEndBlock: bigint;

  firstSeenAt: number;
}
Enter fullscreen mode Exit fullscreen mode

A PostgreSQL table might look like:

pons_tokens
----------------------------
address
symbol
name
deployer
pair_token
pool_address
launch_block
restrictions_end_block
first_seen_at
updated_at
Enter fullscreen mode Exit fullscreen mode

Use the token address as a unique identifier.


10. Index pool trades

After detecting TokenLaunched, register the emitted pool and index its Swap events.

Conceptually:

TokenLaunched
      ↓
Pool Address
      ↓
Swap Events
      ↓
Normalized Trades
      ↓
Market Metrics
Enter fullscreen mode Exit fullscreen mode

A normalized trade model:

interface NormalizedTrade {
  tokenAddress: string;

  trader: string;

  side: "BUY" | "SELL";

  tokenAmount: bigint;

  quoteAmount: bigint;

  blockNumber: bigint;

  transactionHash: string;

  logIndex: number;

  timestamp: number;
}
Enter fullscreen mode Exit fullscreen mode

Now the strategy does not need to know the original ABI structure of the swap.


11. Derive trade direction correctly

For a Uniswap-style pool, the token ordering matters.

A normalized approach:

function getTradeSide(
  tokenIsToken0: boolean,
  amount0: bigint,
  amount1: bigint
): "BUY" | "SELL" {

  const signedPairAmount =
    tokenIsToken0
      ? amount1
      : amount0;

  return signedPairAmount > 0n
    ? "BUY"
    : "SELL";
}
Enter fullscreen mode Exit fullscreen mode

This belongs in the market-data layer.

Do not duplicate it inside every strategy.


12. Build a market snapshot

The strategy needs a clean market representation:

interface MarketSnapshot {
  tokenAddress: string;

  price: number;

  liquidity: number;

  volume24h: number;

  buyVolume: number;
  sellVolume: number;

  tradeCount: number;
  uniqueTraders: number;

  updatedAt: number;
}
Enter fullscreen mode Exit fullscreen mode

These number fields are analytics/display values. They are not the exact onchain execution quantities.

The scanner can then expose:

MEMESTOCK

Price
0.00042 WETH

Liquidity
$125K

24h Volume
$890K

Buys
1,241

Sells
934
Enter fullscreen mode Exit fullscreen mode

The exact USD conversions should come from a separate price service rather than hard-coded assumptions.


13. Keep the strategy separate from market data

The strategy receives:

interface StrategyContext {
  token: PonsToken;

  market: MarketSnapshot;

  currentBlock: bigint;

  now: number;
}
Enter fullscreen mode Exit fullscreen mode

and returns:

interface TradingSignal {
  action:
    | "IGNORE"
    | "WATCH"
    | "BUY"
    | "SELL";

  reason: string;

  generatedAt: number;
}
Enter fullscreen mode Exit fullscreen mode

The strategy might check:

Is market active?
Is launch protection over?
Is liquidity sufficient?
Is activity sufficient?
Is the token already in the portfolio?
Is there already an open order?
Enter fullscreen mode Exit fullscreen mode

The signal still does not execute anything.


14. Example signal engine

A simple example:

function generateSignal(
  context: StrategyContext
): TradingSignal {

  if (
    context.currentBlock <=
    context.token.restrictionsEndBlock
  ) {
    return {
      action: "WATCH",
      reason:
        "Launch protection is active",
      generatedAt: Date.now(),
    };
  }

  if (
    context.market.liquidity < 50_000
  ) {
    return {
      action: "IGNORE",
      reason:
        "Liquidity below configured threshold",
      generatedAt: Date.now(),
    };
  }

  if (
    context.market.volume24h < 100_000
  ) {
    return {
      action: "WATCH",
      reason:
        "Trading activity below configured threshold",
      generatedAt: Date.now(),
    };
  }

  return {
    action: "BUY",
    reason:
      "Configured market filters passed",
    generatedAt: Date.now(),
  };
}
Enter fullscreen mode Exit fullscreen mode

The values here are examples.

They are not recommendations for trading thresholds.

The architecture is the important part.


15. Fix the amount model before adding risk

This is where TypeScript needs to be strict.

There are at least three different kinds of numeric values in this system:

Token amount
USD amount
Basis points
Enter fullscreen mode Exit fullscreen mode

They should not all be plain number.

Use explicit types:

type TokenAmount = bigint;

/**
 * USD expressed in whole cents.
 *
 * Example:
 * $12.34 → 1234n
 */
type UsdCents = bigint;

/**
 * Basis points.
 *
 * 100 bps = 1%
 * 200 bps = 2%
 */
type BasisPoints = bigint;
Enter fullscreen mode Exit fullscreen mode

This gives the compiler a clear convention.

For example:

const tradeAmount: TokenAmount =
  1_500_000_000_000_000_000n;

const maxTradeUsd: UsdCents =
  100_000n;

const maxSlippage: BasisPoints =
  100n;
Enter fullscreen mode Exit fullscreen mode

The important rule is:

Never compare a token amount directly with a USD amount.


16. Correct position-sizing types

Instead of mixing USD numbers with token quantities, keep the two steps separate.

First calculate notional:

function calculateTradeNotionalUsd(
  availableCapitalUsdCents: UsdCents,
  allocationBps: BasisPoints
): UsdCents {
  return (
    availableCapitalUsdCents *
    allocationBps /
    10_000n
  );
}
Enter fullscreen mode Exit fullscreen mode

Example:

const notional =
  calculateTradeNotionalUsd(
    100_000n,
    500n
  );

// $1,000.00
Enter fullscreen mode Exit fullscreen mode

Then use an executable quote to convert that notional into an exact token/WETH amount.

Do not try to derive:

USD → token amount
Enter fullscreen mode Exit fullscreen mode

with floating-point multiplication inside the risk layer.

The quote/execution layer should produce the exact atomic quantity.


17. Correct risk types

The old model mixed units:

interface RiskRequest {
  tokenAddress: string;
  side: "BUY" | "SELL";
  requestedAmount: bigint;
  estimatedPriceImpactBps: number;
}

interface PositionLimits {
  maxTradeUsd: number;
}
Enter fullscreen mode Exit fullscreen mode

That is unsafe because:

requestedAmount
Enter fullscreen mode Exit fullscreen mode

is a token amount, while:

maxTradeUsd
Enter fullscreen mode Exit fullscreen mode

is USD.

Use:

interface RiskRequest {
  tokenAddress: string;

  side: "BUY" | "SELL";

  /**
   * Exact token quantity / atomic quantity
   * that would be executed.
   */
  requestedAmount: TokenAmount;

  /**
   * Estimated USD notional represented
   * by requestedAmount.
   */
  notionalUsdCents: UsdCents;

  estimatedPriceImpactBps: BasisPoints;
}
Enter fullscreen mode Exit fullscreen mode

And:

interface PositionLimits {
  maxTradeUsdCents: UsdCents;

  maxPositionUsdCents: UsdCents;

  maxPortfolioWeightBps: BasisPoints;

  maxPriceImpactBps: BasisPoints;
}
Enter fullscreen mode Exit fullscreen mode

Now every comparison uses the same unit.


18. Correct risk-engine implementation

A type-safe version:

function validateRisk(
  request: RiskRequest,
  limits: PositionLimits
): RiskDecision {

  if (
    request.notionalUsdCents >
    limits.maxTradeUsdCents
  ) {
    return {
      approved: false,
      reason:
        "Maximum USD trade size exceeded",
    };
  }

  if (
    request.estimatedPriceImpactBps >
    limits.maxPriceImpactBps
  ) {
    return {
      approved: false,
      reason:
        "Price impact above configured limit",
    };
  }

  return {
    approved: true,

    approvedAmount:
      request.requestedAmount,

    reason:
      "Risk checks passed",
  };
}
Enter fullscreen mode Exit fullscreen mode

No:

Number(bigint)
Enter fullscreen mode Exit fullscreen mode

is required.

No token-vs-USD comparison is possible.


19. Add an explicit risk decision type

interface RiskDecision {
  approved: boolean;

  approvedAmount?: TokenAmount;

  reason: string;
}
Enter fullscreen mode Exit fullscreen mode

The approvedAmount is a token quantity, so it stays bigint.

A rejected trade should not return a fake zero value.

Use undefined:

{
  approved: false,
  reason: "Maximum USD trade size exceeded"
}
Enter fullscreen mode Exit fullscreen mode

That makes downstream handling explicit.


20. Quote the actual trade

Never assume:

displayed price = execution price
Enter fullscreen mode Exit fullscreen mode

For a meaningful trade size, request an executable quote.

interface ExecutionQuote {
  tokenAddress: string;

  amountIn: TokenAmount;
  expectedAmountOut: TokenAmount;

  averagePrice: number;

  priceImpactBps: BasisPoints;

  minimumAmountOut: TokenAmount;

  expiresAt: number;
}
Enter fullscreen mode Exit fullscreen mode

Notice the distinction:

amountIn
expectedAmountOut
minimumAmountOut
Enter fullscreen mode Exit fullscreen mode

are exact chain quantities.

averagePrice can remain a number if it is used only for display/analytics.

For safety-critical calculations, prefer integer units or fixed-point representations.


21. Working end-to-end execution example

The current Pons deployment documents WETH, SwapRouter02, QuoterV2, Robinhood Chain chain ID 4663, and the 1% pool fee for current launches.

The execution path is:

ETH
 ↓
WETH
 ↓
Quote
 ↓
Slippage Bound
 ↓
WETH Approval
 ↓
SwapRouter02
 ↓
Transaction Receipt
 ↓
Token Balance
Enter fullscreen mode Exit fullscreen mode

Install:

npm install viem dotenv
Enter fullscreen mode Exit fullscreen mode

.env:

RH_RPC_URL=https://rpc.mainnet.chain.robinhood.com
PRIVATE_KEY=0xYOUR_DEDICATED_TRADING_WALLET_PRIVATE_KEY
TOKEN_ADDRESS=0xTHE_PONS_TOKEN_ADDRESS
WETH_AMOUNT=0.01
Enter fullscreen mode Exit fullscreen mode

Never commit .env.

Complete execution example:

import "dotenv/config";

import {
  createPublicClient,
  createWalletClient,
  formatEther,
  http,
  parseAbi,
  parseEther,
  type Address,
} from "viem";

import {
  privateKeyToAccount,
} from "viem/accounts";

const CHAIN_ID = 4663;

const RPC_URL =
  process.env.RH_RPC_URL ??
  "https://rpc.mainnet.chain.robinhood.com";

const PRIVATE_KEY =
  process.env.PRIVATE_KEY as
    | `0x${string}`
    | undefined;

const TOKEN_ADDRESS =
  process.env.TOKEN_ADDRESS as
    | Address
    | undefined;

if (!PRIVATE_KEY) {
  throw new Error(
    "PRIVATE_KEY is required"
  );
}

if (!TOKEN_ADDRESS) {
  throw new Error(
    "TOKEN_ADDRESS is required"
  );
}

const WETH =
  "0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73"
    as Address;

const SWAP_ROUTER =
  "0xCaf681a66D020601342297493863E78C959E5cb2"
    as Address;

const QUOTER_V2 =
  "0x33e885eD0Ec9bF04EcfB19341582aADCb4c8A9E7"
    as Address;

const POOL_FEE = 10_000;

const account =
  privateKeyToAccount(
    PRIVATE_KEY
  );

const chain = {
  id: CHAIN_ID,
  name: "Robinhood Chain",

  nativeCurrency: {
    name: "Ether",
    symbol: "ETH",
    decimals: 18,
  },

  rpcUrls: {
    default: {
      http: [RPC_URL],
    },
  },
} as const;

const publicClient =
  createPublicClient({
    chain,
    transport: http(RPC_URL),
  });

const walletClient =
  createWalletClient({
    account,
    chain,
    transport: http(RPC_URL),
  });

const erc20Abi = parseAbi([
  "function balanceOf(address owner) view returns (uint256)",
  "function allowance(address owner, address spender) view returns (uint256)",
  "function approve(address spender, uint256 amount) returns (bool)",
]);

const wethAbi = parseAbi([
  "function deposit() payable",
]);

const quoterAbi = [
  {
    name: "quoteExactInputSingle",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      {
        name: "params",
        type: "tuple",
        components: [
          {
            name: "tokenIn",
            type: "address",
          },
          {
            name: "tokenOut",
            type: "address",
          },
          {
            name: "amountIn",
            type: "uint256",
          },
          {
            name: "fee",
            type: "uint24",
          },
          {
            name: "sqrtPriceLimitX96",
            type: "uint160",
          },
        ],
      },
    ],
    outputs: [
      {
        name: "amountOut",
        type: "uint256",
      },
      {
        name: "sqrtPriceX96After",
        type: "uint160",
      },
      {
        name: "initializedTicksCrossed",
        type: "uint32",
      },
      {
        name: "gasEstimate",
        type: "uint256",
      },
    ],
  },
] as const;

const routerAbi = [
  {
    name: "exactInputSingle",
    type: "function",
    stateMutability: "payable",
    inputs: [
      {
        name: "params",
        type: "tuple",
        components: [
          {
            name: "tokenIn",
            type: "address",
          },
          {
            name: "tokenOut",
            type: "address",
          },
          {
            name: "fee",
            type: "uint24",
          },
          {
            name: "recipient",
            type: "address",
          },
          {
            name: "amountIn",
            type: "uint256",
          },
          {
            name: "amountOutMinimum",
            type: "uint256",
          },
          {
            name: "sqrtPriceLimitX96",
            type: "uint160",
          },
        ],
      },
    ],
    outputs: [
      {
        name: "amountOut",
        type: "uint256",
      },
    ],
  },
] as const;

async function main() {
  const wallet =
    account.address;

  const wethAmount =
    parseEther(
      process.env.WETH_AMOUNT ?? "0.01"
    );

  console.log(
    `Trading wallet: ${wallet}`
  );

  console.log(
    `Token: ${TOKEN_ADDRESS}`
  );

  console.log(
    `Input: ${formatEther(
      wethAmount
    )} WETH`
  );

  /*
   * 1. Check WETH balance.
   */
  let wethBalance =
    await publicClient.readContract({
      address: WETH,
      abi: erc20Abi,
      functionName: "balanceOf",
      args: [wallet],
    });

  /*
   * 2. Wrap native ETH → WETH if required.
   */
  if (wethBalance < wethAmount) {
    const required =
      wethAmount - wethBalance;

    const ethBalance =
      await publicClient.getBalance({
        address: wallet,
      });

    if (ethBalance < required) {
      throw new Error(
        `Not enough ETH. Need at least ${formatEther(
          required
        )} ETH`
      );
    }

    console.log(
      `Wrapping ${formatEther(
        required
      )} ETH`
    );

    const wrapHash =
      await walletClient.writeContract({
        address: WETH,
        abi: wethAbi,
        functionName: "deposit",
        value: required,
      });

    await publicClient.waitForTransactionReceipt({
      hash: wrapHash,
    });

    wethBalance =
      await publicClient.readContract({
        address: WETH,
        abi: erc20Abi,
        functionName: "balanceOf",
        args: [wallet],
      });
  }

  if (wethBalance < wethAmount) {
    throw new Error(
      "WETH funding step failed"
    );
  }

  /*
   * 3. Get an executable quote.
   */
  const { result } =
    await publicClient.simulateContract({
      address: QUOTER_V2,
      abi: quoterAbi,
      functionName:
        "quoteExactInputSingle",
      args: [
        {
          tokenIn: WETH,
          tokenOut: TOKEN_ADDRESS,
          amountIn: wethAmount,
          fee: POOL_FEE,
          sqrtPriceLimitX96: 0n,
        },
      ],
    });

  const quotedAmountOut =
    result[0];

  /*
   * 4. Apply a slippage bound.
   *
   * 200 basis points = 2%.
   * This is only an example configuration.
   */
  const slippageBps: BasisPoints =
    200n;

  const amountOutMinimum =
    quotedAmountOut *
    (10_000n - slippageBps) /
    10_000n;

  console.log(
    `Quoted output: ${
      quotedAmountOut.toString()
    }`
  );

  console.log(
    `Minimum output: ${
      amountOutMinimum.toString()
    }`
  );

  /*
   * 5. Approve WETH.
   */
  const allowance =
    await publicClient.readContract({
      address: WETH,
      abi: erc20Abi,
      functionName: "allowance",
      args: [
        wallet,
        SWAP_ROUTER,
      ],
    });

  if (allowance < wethAmount) {
    console.log(
      "Approving WETH..."
    );

    const approvalHash =
      await walletClient.writeContract({
        address: WETH,
        abi: erc20Abi,
        functionName: "approve",
        args: [
          SWAP_ROUTER,
          wethAmount,
        ],
      });

    await publicClient.waitForTransactionReceipt({
      hash: approvalHash,
    });
  }

  /*
   * 6. Execute the swap.
   */
  console.log(
    "Submitting swap..."
  );

  const swapHash =
    await walletClient.writeContract({
      address: SWAP_ROUTER,
      abi: routerAbi,
      functionName:
        "exactInputSingle",
      args: [
        {
          tokenIn: WETH,
          tokenOut: TOKEN_ADDRESS,
          fee: POOL_FEE,
          recipient: wallet,
          amountIn: wethAmount,
          amountOutMinimum,
          sqrtPriceLimitX96: 0n,
        },
      ],
    });

  console.log(
    `Transaction: ${swapHash}`
  );

  /*
   * 7. Wait for confirmation.
   */
  const receipt =
    await publicClient.waitForTransactionReceipt({
      hash: swapHash,
    });

  if (receipt.status !== "success") {
    throw new Error(
      "Swap transaction failed"
    );
  }

  console.log(
    "Swap confirmed."
  );

  /*
   * 8. Verify final token balance.
   */
  const tokenBalance =
    await publicClient.readContract({
      address: TOKEN_ADDRESS,
      abi: erc20Abi,
      functionName: "balanceOf",
      args: [wallet],
    });

  console.log(
    `Final token balance: ${
      tokenBalance.toString()
    }`
  );
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

The exact chain amounts remain bigint from start to finish:

wethAmount
     ↓
amountIn
     ↓
quotedAmountOut
     ↓
amountOutMinimum
     ↓
tokenBalance
Enter fullscreen mode Exit fullscreen mode

No floating-point conversion is required for the transaction values.


33. Keep USD risk accounting separate from chain quantities

This is the key type boundary:

Portfolio / Risk Layer

USD:
bigint cents

      ↓

Quote / Execution Layer

Token quantities:
bigint atomic units

      ↓

Blockchain

uint256
Enter fullscreen mode Exit fullscreen mode

For example:

const maxTradeUsdCents: UsdCents =
  100_000n; // $1,000

const requestedTokenAmount:
  TokenAmount =
  quotedAmountOut;
Enter fullscreen mode Exit fullscreen mode

The risk layer receives both:

const riskRequest: RiskRequest = {
  tokenAddress,
  side: "BUY",

  requestedAmount:
    quotedAmountOut,

  notionalUsdCents:
    100_000n,

  estimatedPriceImpactBps:
    25n,
};
Enter fullscreen mode Exit fullscreen mode

Now the risk engine can correctly evaluate:

request.notionalUsdCents >
limits.maxTradeUsdCents
Enter fullscreen mode Exit fullscreen mode

instead of incorrectly comparing a token quantity against USD.


34. Make unit names part of the API

Prefer:

maxTradeUsdCents
Enter fullscreen mode Exit fullscreen mode

over:

maxTradeUsd
Enter fullscreen mode Exit fullscreen mode

Prefer:

estimatedPriceImpactBps
Enter fullscreen mode Exit fullscreen mode

over:

estimatedPriceImpact
Enter fullscreen mode Exit fullscreen mode

Prefer:

amountOutMinimum
Enter fullscreen mode Exit fullscreen mode

over:

minimumTokens
Enter fullscreen mode Exit fullscreen mode

The name itself communicates the unit.

That makes code review much safer.


35. Never use Number() on large chain amounts for risk checks

Avoid:

Number(request.requestedAmount)
Enter fullscreen mode Exit fullscreen mode

for token quantities.

Large uint256 values can exceed JavaScript's safe integer range.

Bad:

if (
  Number(
    request.requestedAmount
  ) > limit
) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

Better:

if (
  request.notionalUsdCents >
  limits.maxTradeUsdCents
) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

And if token amounts themselves need comparison:

if (
  request.requestedAmount >
  maxTokenAmount
) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

Both sides remain bigint.


36. Complete amount/risk type definitions

For a reusable codebase, I would put these in one file:

// src/domain/units.ts

export type TokenAmount = bigint;

export type UsdCents = bigint;

export type BasisPoints = bigint;
Enter fullscreen mode Exit fullscreen mode

Then:

// src/risk/types.ts

import type {
  BasisPoints,
  TokenAmount,
  UsdCents,
} from "../domain/units";

export interface RiskRequest {
  tokenAddress: string;

  side: "BUY" | "SELL";

  requestedAmount: TokenAmount;

  notionalUsdCents: UsdCents;

  estimatedPriceImpactBps:
    BasisPoints;
}

export interface PositionLimits {
  maxTradeUsdCents: UsdCents;

  maxPositionUsdCents:
    UsdCents;

  maxPortfolioWeightBps:
    BasisPoints;

  maxPriceImpactBps:
    BasisPoints;
}

export interface RiskDecision {
  approved: boolean;

  approvedAmount?:
    TokenAmount;

  reason: string;
}
Enter fullscreen mode Exit fullscreen mode

Now every service shares the same unit model.


37. Order types

Use the same convention for orders:

import type {
  BasisPoints,
  TokenAmount,
  UsdCents,
} from "../domain/units";

export interface ExecutionRequest {
  tokenAddress: string;

  side: "BUY" | "SELL";

  amountIn: TokenAmount;

  maxSlippageBps:
    BasisPoints;

  notionalUsdCents:
    UsdCents;

  amountOutMinimum:
    TokenAmount;
}
Enter fullscreen mode Exit fullscreen mode

This creates a clean contract between risk and execution.


38. Final trading flow with correct units

The whole system becomes:

TOKEN DISCOVERY
      ↓
MARKET DATA
      ↓
SIGNAL
      ↓
POSITION SIZING

USD cents
      ↓
RISK

USD cents
+
basis points
+
token amount
      ↓
QUOTE

token atomic units
      ↓
SLIPPAGE

basis points
      ↓
EXECUTION

uint256 / bigint
      ↓
FILL

uint256 / bigint
      ↓
POSITION

uint256 / bigint
      ↓
RECONCILIATION
Enter fullscreen mode Exit fullscreen mode

The unit boundaries are now explicit.


39. Reconciliation

Do not trust only the application's own state.

Imagine:

Database:
6,500 tokens

Chain:
6,500 tokens
Enter fullscreen mode Exit fullscreen mode

Everything matches.

But after a connection problem:

Database:
6,500

Chain:
7,200
Enter fullscreen mode Exit fullscreen mode

The system needs to notice.

Architecture:

Local State
     +
Onchain State
     ↓
Reconciliation
     ↓
Correct Position
Enter fullscreen mode Exit fullscreen mode

A basic model:

interface PositionSnapshot {
  tokenAddress: string;

  amount: TokenAmount;

  updatedAt: number;
}
Enter fullscreen mode Exit fullscreen mode

Then:

function positionsMatch(
  local: PositionSnapshot,
  chain: PositionSnapshot
): boolean {
  return (
    local.tokenAddress ===
      chain.tokenAddress &&
    local.amount ===
      chain.amount
  );
}
Enter fullscreen mode Exit fullscreen mode

40. Prevent duplicate execution

Consider:

Signal
 ↓
BUY
 ↓
RPC timeout
 ↓
Retry
Enter fullscreen mode Exit fullscreen mode

The original transaction may actually have succeeded.

A blind retry can create a duplicate position.

Use an idempotency key:

const idempotencyKey =
  [
    strategyId,
    tokenAddress,
    signalId,
  ].join(":");
Enter fullscreen mode Exit fullscreen mode

Persist it before execution.

Then:

Same signal
   ↓
Same idempotency key
   ↓
Already processed
   ↓
Do not submit another order
Enter fullscreen mode Exit fullscreen mode

41. Alerts

The bot should notify important state changes:

NEW_LAUNCH
SIGNAL_CREATED
RISK_APPROVED
ORDER_SUBMITTED
ORDER_PARTIALLY_FILLED
ORDER_FILLED
ORDER_FAILED
POSITION_OPEN
POSITION_CLOSED
RECONCILIATION_REQUIRED
Enter fullscreen mode Exit fullscreen mode

For example:

PONS BOT

MEMESTOCK

ENTRY PARTIALLY FILLED

Requested:
10,000

Filled:
6,500

Remaining:
3,500

State:
PARTIALLY_OPEN
Enter fullscreen mode Exit fullscreen mode

A normalized alert:

interface TradingAlert {
  type:
    | "SIGNAL"
    | "ORDER"
    | "FILL"
    | "POSITION"
    | "RECONCILIATION";

  tokenAddress: string;

  message: string;

  createdAt: number;
}
Enter fullscreen mode Exit fullscreen mode

42. Restart recovery

A trading process can crash.

The architecture should survive that.

Startup:

START
 ↓
Load Open Orders
 ↓
Load Open Positions
 ↓
Query Chain
 ↓
Reconcile
 ↓
Resume
Enter fullscreen mode Exit fullscreen mode

For example:

async function recover() {
  const openOrders =
    await orderStore.getOpenOrders();

  const openPositions =
    await positionStore.getOpenPositions();

  for (const order of openOrders) {
    await reconciler.reconcileOrder(
      order
    );
  }

  for (const position of openPositions) {
    await reconciler.reconcilePosition(
      position
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The bot does not assume the previous process ended cleanly.


43. API architecture

The bot can expose its state to a dashboard:

GET /api/tokens
GET /api/tokens/:address

GET /api/signals

GET /api/orders
GET /api/orders/:id

GET /api/positions
GET /api/positions/:address

GET /api/alerts
Enter fullscreen mode Exit fullscreen mode

Then:

Trading Terminal
      ↓
Trading API
      ↓
Trading Engine
      ↓
Robinhood Chain
Enter fullscreen mode Exit fullscreen mode

The same backend can power web and mobile interfaces.


44. Scanner to bot architecture

The complete product becomes:

                 PONS / ROBINHOOD CHAIN
                           │
                           ▼
                    EVENT INDEXER
                           │
                           ▼
                     TOKEN SCANNER
                           │
                           ▼
                    MARKET STATE
                           │
                           ▼
                    STRATEGY ENGINE
                           │
                           ▼
                      RISK ENGINE
                           │
                           ▼
                   EXECUTION ENGINE
                           │
                    ┌──────┴──────┐
                    ▼             ▼
                 FILLS          FAILED
                    │
                    ▼
                POSITION
                    │
                    ▼
             RECONCILIATION
Enter fullscreen mode Exit fullscreen mode

That is the architecture I would implement rather than putting everything into one bot.ts file.


45. Suggested TypeScript project structure

pons-memestock-trading-bot/
│
├── src/
│   ├── domain/
│   │   └── units.ts
│   │
│   ├── chain/
│   │   ├── client.ts
│   │   ├── contracts.ts
│   │   └── events.ts
│   │
│   ├── indexer/
│   │   ├── worker.ts
│   │   ├── cursor.ts
│   │   └── processor.ts
│   │
│   ├── tokens/
│   │   ├── registry.ts
│   │   └── metadata.ts
│   │
│   ├── markets/
│   │   ├── resolver.ts
│   │   ├── pricing.ts
│   │   └── trades.ts
│   │
│   ├── strategy/
│   │   ├── filters.ts
│   │   ├── signal.ts
│   │   └── positionSizing.ts
│   │
│   ├── risk/
│   │   ├── riskEngine.ts
│   │   └── types.ts
│   │
│   ├── execution/
│   │   ├── executor.ts
│   │   ├── orders.ts
│   │   └── transactionMonitor.ts
│   │
│   ├── positions/
│   │   ├── manager.ts
│   │   └── stateMachine.ts
│   │
│   ├── reconciliation/
│   │   └── reconciler.ts
│   │
│   ├── alerts/
│   │   └── router.ts
│   │
│   └── api/
│       └── server.ts
│
├── database/
├── tests/
├── .env.example
├── package.json
└── README.md
Enter fullscreen mode Exit fullscreen mode

46. Paper trading

Before enabling live execution, use the same strategy and risk pipeline with a simulation adapter.

interface ExecutionAdapter {
  execute(
    request: ExecutionRequest
  ): Promise<string>;
}
Enter fullscreen mode Exit fullscreen mode

Paper:

class PaperExecutor
  implements ExecutionAdapter {

  async execute(
    request: ExecutionRequest
  ): Promise<string> {

    console.log(
      "[PAPER]",
      request
    );

    return "paper-trade";
  }
}
Enter fullscreen mode Exit fullscreen mode

Live:

class LiveExecutor
  implements ExecutionAdapter {

  async execute(
    request: ExecutionRequest
  ): Promise<string> {

    // Build transaction
    // Submit transaction
    // Return tx hash

    return "0x...";
  }
}
Enter fullscreen mode Exit fullscreen mode

That lets you test:

Scanner
 ↓
Signal
 ↓
Risk
 ↓
Execution
 ↓
Position
Enter fullscreen mode Exit fullscreen mode

without immediately connecting the final step to live capital.


47. Optional Pons v2 adapter

If you want the repository to support multiple Pons generations, do not mix their mechanics inside the same execution code.

Use adapters:

interface PonsMarketAdapter {
  detectLaunch(
    token: string
  ): Promise<PonsLaunch>;

  getMarketState(
    token: string
  ): Promise<MarketSnapshot>;

  getQuote(
    token: string,
    amount: TokenAmount
  ): Promise<ExecutionQuote>;
}
Enter fullscreen mode Exit fullscreen mode

Then:

PonsMarketAdapter
      │
      ├── CurrentPoolAdapter
      │
      └── V2CurveAdapter
Enter fullscreen mode Exit fullscreen mode

This keeps version-specific behavior isolated.


48. Complete architecture

                    PONS
                     │
                     ▼
              EVENT INDEXER
                     │
                     ▼
              TOKEN REGISTRY
                     │
                     ▼
              MARKET RESOLVER
                     │
                     ▼
              MARKET SNAPSHOT
                     │
                     ▼
              STRATEGY ENGINE
                     │
                     ▼
                 RISK ENGINE
                     │
              ┌──────┴──────┐
              │             │
            REJECT        APPROVE
              │             │
              │             ▼
              │         EXECUTION
              │             │
              │             ▼
              │        ORDER STATE
              │             │
              │        ┌────┴────┐
              │        ▼         ▼
              │     FILLED     FAILED
              │        │
              │        ▼
              │     POSITION
              │        │
              │        ▼
              │ RECONCILIATION
              │        │
              └────────┴──────► MONITORING
Enter fullscreen mode Exit fullscreen mode

The architecture separates:

DISCOVERY
Enter fullscreen mode Exit fullscreen mode

from:

STRATEGY
Enter fullscreen mode Exit fullscreen mode

from:

RISK
Enter fullscreen mode Exit fullscreen mode

from:

EXECUTION
Enter fullscreen mode Exit fullscreen mode

from:

STATE
Enter fullscreen mode Exit fullscreen mode

That separation is the core of the system.


Conclusion

A Pons memestock trading bot on Robinhood Chain should not be designed as:

NEW TOKEN → BUY
Enter fullscreen mode Exit fullscreen mode

A more reliable architecture is:

Pons Events
    ↓
Token Indexing
    ↓
Market State
    ↓
Strategy
    ↓
Risk
    ↓
Execution
    ↓
Orders / Fills
    ↓
Position
    ↓
Reconciliation
Enter fullscreen mode Exit fullscreen mode

The most important TypeScript rule is just as simple:

Never mix units.

Use:

type TokenAmount = bigint;
type UsdCents = bigint;
type BasisPoints = bigint;
Enter fullscreen mode Exit fullscreen mode

Then make the boundaries explicit:

USD cents
   ↓
Risk
   ↓
Token atomic units
   ↓
Blockchain uint256
Enter fullscreen mode Exit fullscreen mode

This avoids unsafe Number(bigint) conversions and prevents bugs such as comparing a token quantity directly with a USD risk limit.

The executable path then becomes:

WETH
 ↓
Quote
 ↓
Slippage Bound
 ↓
Approval
 ↓
SwapRouter02
 ↓
Receipt
 ↓
Balance Verification
Enter fullscreen mode Exit fullscreen mode

The signal says what the strategy wants.

The risk engine decides whether it is permitted.

The execution engine submits the transaction.

The fill engine records what actually happened.

The position manager calculates the resulting state.

Reconciliation verifies that state against the chain.

That is what turns a Pons trading script into a real trading system.

Building a custom Pons trading bot?

I build custom Pons and Robinhood Chain trading infrastructure, including:

  • Pons memestock trading bots
  • Pons sniper bots
  • Pons token scanners
  • Pons launch monitors
  • Pons copy-trading systems
  • Pons trading terminals
  • market-data APIs
  • automated execution engines
  • position-management systems
  • reconciliation infrastructure

The system can be built as a standalone bot, a web trading terminal, or a backend/API that powers an existing trading application.


Technical note: Pons terminology and deployed contracts can evolve through new factory versions. The integration should therefore resolve protocol version and contract addresses from the current official documentation rather than hard-coding assumptions from an older implementation.

Top comments (0)