> For the complete documentation index, see [llms.txt](https://docs.fast.poker/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.fast.poker/building-on-the-protocol/deriving-tables.md).

# Deriving the table list

> Verified against the public fastpoker IDL (PokerXYdXL2SKNnfGbv1WE7vJHipTpNsfZbZeVvoJLn) released 2026-07-17.

**Capability: Needs your own RPC (getProgramAccounts)**

> Beta software on mainnet. Examples can fail. Test with small amounts.

## Purpose

SNG pools live at fixed addresses you derive from constants. Tables do not. A `Table` PDA is seeded by a random 32-byte `table_id`, so you cannot enumerate tables by derivation. To list tables you scan the program account space with `getProgramAccounts`, filtered to the `Table` discriminator, and decode the fields a lobby needs.

This page covers the `getProgramAccounts` filters (`dataSize` and `memcmp` on the discriminator, on `game_type`, and on `creator`), a Helius `getProgramAccountsV2` cursor fallback for large result sets, a chunked `getMultipleAccounts` fallback when you only have a free RPC pool, decoding `game_type`, blinds, occupancy, creator, and token mint, and detecting delegated (in-play) tables by account owner.

This is a scan. It does not work on a free public RPC pool, which blocks `getProgramAccounts`. You need your own keyed RPC (Helius, QuickNode, or similar). The free-pool path here is a fallback that reads a known set of addresses, not a way to discover unknown tables.

## When to use / who signs

Use this when you build a lobby, a "my tables" view, or any surface that lists tables a user did not paste in by address. No transaction is sent and nothing is signed. A wallet pubkey is only an input to the `creator` filter; it is not a signer here.

If you already hold a table address, do not scan. Read it directly. See [reading-a-table.md](/building-on-the-protocol/reading-a-table.md).

## Inputs and constants

The shared helper module exports the program IDs and PDA derivations. Import it from [./setup.md](/building-on-the-protocol/setup.md). This page reuses `FASTPOKER_PROGRAM_ID`, `DELEGATION_PROGRAM_ID`, and `getTablePda` from that module.

### Program IDs

| Input              | Value                                          | Notes                                                           |
| ------------------ | ---------------------------------------------- | --------------------------------------------------------------- |
| FastPoker program  | `PokerXYdXL2SKNnfGbv1WE7vJHipTpNsfZbZeVvoJLn`  | Owner of an L1 (idle) table. Scan target.                       |
| Delegation program | `DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh` | Owner of a delegated (in-play) table on L1. Second scan target. |

A table that is being played is delegated to an Ephemeral Rollup. On L1 its account `owner` flips to the delegation program. A scan of only the FastPoker program therefore misses in-play tables. To see every table, scan both program IDs and merge the results.

### Table discriminator and account size

The first 8 bytes of a `Table` account are the Anchor discriminator. Match it with a `memcmp` at offset 0. The discriminator below is taken from the IDL `accounts[].discriminator` array.

| Constant              | Value                                    |
| --------------------- | ---------------------------------------- |
| `Table` discriminator | `[34, 100, 138, 97, 236, 129, 230, 112]` |
| Table data size       | 459 bytes                                |
| Table alloc size      | 478 bytes (459 + 19 shadow bytes)        |

Do not filter tables by `dataSize` alone. Older and newer tables can differ by the 19 shadow bytes, so a fixed `dataSize` drops valid tables. Filter by the discriminator instead. The `dataSize` filter is shown here only for the registry-style single-size accounts you may scan elsewhere.

### Table field offsets for filters and decode

Offsets count from the start of account data and include the 8-byte discriminator. These are fixed by the on-chain `table.rs` layout. For the full field list and meaning, see [../04-architecture/state-accounts.md](/architecture/state-accounts.md).

| Field             | Offset | Type      | Use                         |
| ----------------- | ------ | --------- | --------------------------- |
| (discriminator)   | 0      | \[u8; 8]  | `memcmp` filter, type check |
| `table_id`        | 8      | \[u8; 32] | re-derive PDA to confirm    |
| `authority`       | 40     | Pubkey    | decode                      |
| `game_type`       | 104    | u8        | `memcmp` filter, decode     |
| `small_blind`     | 105    | u64 LE    | decode                      |
| `big_blind`       | 113    | u64 LE    | decode                      |
| `max_players`     | 121    | u8        | decode                      |
| `current_players` | 122    | u8        | decode (occupancy)          |
| `seats_occupied`  | 250    | u16 LE    | decode (occupancy bitmask)  |
| `creator`         | 290    | Pubkey    | `memcmp` filter, decode     |
| `tier`            | 360    | u8        | decode (SNG tier)           |
| `token_mint`      | 385    | Pubkey    | decode (all zeros = SOL)    |

`current_players` is the seated count. `seats_occupied` is a bitmask where bit N is set when seat N is occupied. `token_mint` of all zeros (`PublicKey.default`) means a SOL table.

### GameType enum

| Value | Variant         | Players per table |
| ----- | --------------- | ----------------- |
| 0     | SitAndGoHeadsUp | 2                 |
| 1     | SitAndGo6Max    | 6                 |
| 2     | SitAndGo9Max    | 9                 |
| 3     | CashGame        | 2 to 9            |

To list only cash tables, add a `memcmp` on `game_type` (offset 104) equal to a single byte `3`. To list a single SNG format, filter on 0, 1, or 2.

## Steps

1. Build the filter list. Always include the discriminator `memcmp` at offset 0. Add a `game_type` `memcmp` at offset 104 to narrow by format, and a `creator` `memcmp` at offset 290 to list one creator's tables.
2. Run `getProgramAccounts` against the FastPoker program with those filters. Use `dataSlice: { offset: 0, length: 0 }` to fetch pubkeys only when you plan to batch-read the data separately, which keeps the scan payload small.
3. Run the same scan against the delegation program to pick up delegated (in-play) tables.
4. If your RPC rejects a large `getProgramAccounts` response, page through it with `getProgramAccountsV2` using the returned `paginationKey` as a cursor until it is `null`.
5. Read the account data. From the scan you already have it, or batch-read with `getMultipleAccountsInfo` if you sliced to pubkeys only. On a free pool, chunk that batch into groups of 10 or fewer.
6. For each account, confirm the discriminator, then re-derive the PDA from `table_id` at offset 8 and confirm it equals the account address. This rejects stray accounts that happen to share a prefix.
7. Decode `game_type`, blinds, occupancy, `creator`, and `token_mint` at the offsets above.
8. Tag the table by `owner`: the FastPoker program means idle on L1, the delegation program means delegated and in play. A delegated table's L1 data is a stale snapshot, so do not show its live pot or seated count from L1.

## Example

This example imports the shared helper from [./setup.md](/building-on-the-protocol/setup.md). It scans both programs, falls back to a `getProgramAccountsV2` cursor when a plain `getProgramAccounts` is rejected, chunks the follow-up `getMultipleAccounts` reads to 10, and decodes the lobby fields. It sends no transaction.

```ts
import { Connection, PublicKey } from '@solana/web3.js';
import {
  FASTPOKER_PROGRAM_ID,
  DELEGATION_PROGRAM_ID,
  getTablePda,
} from './setup'; // shared helper from ./setup.md

// Table discriminator from the IDL accounts[].discriminator array.
const TABLE_DISC = Buffer.from([34, 100, 138, 97, 236, 129, 230, 112]);

// Table field offsets (table.rs, little-endian). Offset 0 is the discriminator.
const OFF = {
  TABLE_ID: 8,
  AUTHORITY: 40,
  GAME_TYPE: 104,
  SMALL_BLIND: 105,
  BIG_BLIND: 113,
  MAX_PLAYERS: 121,
  CURRENT_PLAYERS: 122,
  SEATS_OCCUPIED: 250,
  CREATOR: 290,
  TIER: 360,
  TOKEN_MINT: 385,
};

const GAME_TYPE_LABEL = ['HeadsUp', '6Max', '9Max', 'Cash'];

type ScanOpts = {
  rpcUrl: string;           // your keyed RPC; the free pool blocks getProgramAccounts
  creator?: PublicKey;      // filter to one creator
  gameType?: number;        // 0,1,2 SNG or 3 cash
};

// Build the memcmp filter list. The discriminator filter is always present.
function buildFilters(opts: ScanOpts) {
  const filters: any[] = [
    { memcmp: { offset: 0, bytes: TABLE_DISC.toString('base64'), encoding: 'base64' } },
  ];
  if (opts.gameType !== undefined) {
    filters.push({
      memcmp: { offset: OFF.GAME_TYPE, bytes: Buffer.from([opts.gameType]).toString('base64'), encoding: 'base64' },
    });
  }
  if (opts.creator) {
    filters.push({ memcmp: { offset: OFF.CREATOR, bytes: opts.creator.toBase58() } });
  }
  return filters;
}

// Helius getProgramAccountsV2 cursor fallback. Pages with paginationKey.
async function gpaV2Pubkeys(rpcUrl: string, programId: PublicKey, filters: any[]): Promise<string[]> {
  const out: string[] = [];
  let paginationKey: string | undefined;
  do {
    const cfg: any = { encoding: 'base64', limit: 1000, filters, dataSlice: { offset: 0, length: 0 } };
    if (paginationKey) cfg.paginationKey = paginationKey;
    const res = await fetch(rpcUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 'gpaV2', method: 'getProgramAccountsV2', params: [programId.toBase58(), cfg] }),
    });
    if (!res.ok) throw new Error(`getProgramAccountsV2 HTTP ${res.status}`);
    const json = await res.json();
    if (json.error) throw new Error(json.error.message ?? 'getProgramAccountsV2 error');
    for (const a of json.result?.accounts ?? []) out.push(a.pubkey);
    paginationKey = typeof json.result?.paginationKey === 'string' ? json.result.paginationKey : undefined;
  } while (paginationKey);
  return out;
}

// Scan one program for table pubkeys. Plain getProgramAccounts first, V2 cursor on failure.
async function scanProgramPubkeys(connection: Connection, opts: ScanOpts, programId: PublicKey): Promise<string[]> {
  const filters = buildFilters(opts);
  try {
    const accts = await connection.getProgramAccounts(programId, { filters, dataSlice: { offset: 0, length: 0 } });
    return accts.map((a) => a.pubkey.toBase58());
  } catch {
    return gpaV2Pubkeys(opts.rpcUrl, programId, filters);
  }
}

// Read up to 10 accounts per call; chunk anything larger for the free pool.
async function getMultipleChunked(connection: Connection, keys: PublicKey[]) {
  const out: Awaited<ReturnType<Connection['getMultipleAccountsInfo']>> = [];
  for (let i = 0; i < keys.length; i += 10) {
    out.push(...(await connection.getMultipleAccountsInfo(keys.slice(i, i + 10), 'confirmed')));
  }
  return out;
}

function readU64(data: Buffer, off: number) {
  return data.length >= off + 8 ? Number(data.readBigUInt64LE(off)) : 0;
}
function readPubkey(data: Buffer, off: number) {
  return data.length >= off + 32 ? new PublicKey(data.subarray(off, off + 32)).toBase58() : '';
}

// Confirm discriminator + that the account is the PDA of its own table_id.
function isTablePda(pubkey: PublicKey, data: Buffer): boolean {
  if (data.length < OFF.TABLE_ID + 32) return false;
  if (!data.subarray(0, 8).equals(TABLE_DISC)) return false;
  const [pda] = getTablePda(data.subarray(OFF.TABLE_ID, OFF.TABLE_ID + 32));
  return pda.equals(pubkey);
}

function ownerLabel(owner: PublicKey): 'L1' | 'delegated' | 'other' {
  if (owner.equals(FASTPOKER_PROGRAM_ID)) return 'L1';
  if (owner.equals(DELEGATION_PROGRAM_ID)) return 'delegated';
  return 'other';
}

async function listTables(connection: Connection, opts: ScanOpts) {
  // 1) Scan both programs: FastPoker (idle) + delegation (in play). Merge by pubkey.
  const [l1, delegated] = await Promise.all([
    scanProgramPubkeys(connection, opts, FASTPOKER_PROGRAM_ID),
    scanProgramPubkeys(connection, opts, DELEGATION_PROGRAM_ID),
  ]);
  const keys = [...new Set([...l1, ...delegated])].map((k) => new PublicKey(k));
  if (keys.length === 0) return [];

  // 2) Batch-read the data, chunked to 10 for free-pool compatibility.
  const infos = await getMultipleChunked(connection, keys);

  // 3) Validate and decode.
  const rows: any[] = [];
  infos.forEach((info, i) => {
    if (!info) return;
    const data = info.data as Buffer;
    if (!isTablePda(keys[i], data)) return; // discriminator + PDA self-check
    const delegated = info.owner.equals(DELEGATION_PROGRAM_ID);
    rows.push({
      pubkey: keys[i].toBase58(),
      owner: ownerLabel(info.owner),
      gameType: GAME_TYPE_LABEL[data[OFF.GAME_TYPE]] ?? `unknown(${data[OFF.GAME_TYPE]})`,
      tier: data[OFF.TIER],
      smallBlind: readU64(data, OFF.SMALL_BLIND),
      bigBlind: readU64(data, OFF.BIG_BLIND),
      maxPlayers: data[OFF.MAX_PLAYERS],
      // Delegated tables hold a stale L1 snapshot: do not trust live occupancy.
      currentPlayers: delegated ? null : data[OFF.CURRENT_PLAYERS],
      seatsOccupied: delegated ? null : data.readUInt16LE(OFF.SEATS_OCCUPIED),
      creator: readPubkey(data, OFF.CREATOR),
      tokenMint: readPubkey(data, OFF.TOKEN_MINT), // all zeros = SOL table
      isSol: readPubkey(data, OFF.TOKEN_MINT) === PublicKey.default.toBase58(),
    });
  });
  return rows;
}

async function main() {
  const rpcUrl = process.env.RPC_URL; // your keyed RPC; the free pool blocks this scan
  if (!rpcUrl) throw new Error('set RPC_URL to a getProgramAccounts-capable endpoint');
  const connection = new Connection(rpcUrl, 'confirmed');

  // Example: list cash tables (gameType 3). Drop gameType to list all formats.
  const rows = await listTables(connection, { rpcUrl, gameType: 3 });
  for (const r of rows) {
    console.log(
      `${r.pubkey} | ${r.gameType} | ${r.owner} | ` +
        `blinds ${r.smallBlind}/${r.bigBlind} | players ${r.currentPlayers ?? 'in-play'}/${r.maxPlayers} | ` +
        `${r.isSol ? 'SOL' : r.tokenMint}`,
    );
  }
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});
```

## Result

You get a deduplicated list of tables across both programs, each tagged L1 (idle) or delegated (in play), with decoded game type, blinds, occupancy, creator, and token mint. The PDA self-check rejects any account that is not a real `Table` PDA. Delegated tables return `null` occupancy from L1, because their live state is on the Ephemeral Rollup, not in the L1 snapshot.

## Pitfalls

* Do not run this on a free public RPC pool. `getProgramAccounts` is blocked. The scan needs your own keyed RPC. The chunked `getMultipleAccounts` step is only for reading a known address set, not for discovery.
* Do not scan only the FastPoker program. In-play tables are owned on L1 by the delegation program `DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh`. Scan both and merge, or you will miss every active table.
* Do not filter by `dataSize` to find tables. The shadow-byte expansion makes table size vary. Filter by the discriminator `memcmp` at offset 0 instead.
* Do not trust live fields from a delegated table's L1 account. `current_players`, `pot`, and `seats_occupied` are a stale snapshot while the table is on the ER. Read live state from the ER endpoint.
* Do not skip the PDA self-check. Confirm the account is the PDA of its own `table_id` at offset 8 before trusting it, so a stray account with a matching prefix cannot leak into the list.
* Do not assume a non-paged result is complete. A large unkeyed `getProgramAccounts` can be truncated or rejected. Page with `getProgramAccountsV2` and drain the `paginationKey` cursor to `null`.
* `game_type` and the discriminator bytes are matched in a `memcmp`. Base64-encode raw byte filters and base58-encode pubkey filters, matching the encodings shown.

## See also

* [reading-accounts.md](/building-on-the-protocol/reading-accounts.md): the general fetch, discriminator-check, and decode pattern, plus free-pool batch limits.
* [reading-a-table.md](/building-on-the-protocol/reading-a-table.md): reading and decoding a single table you already hold the address for.
* [deriving-pools.md](/building-on-the-protocol/deriving-pools.md): SNG pools derive from constants and do not need a scan.
* [setup.md](/building-on-the-protocol/setup.md): the shared helper with program IDs and PDA derivations.
* [../04-architecture/state-accounts.md](/architecture/state-accounts.md): the full Table account size, seeds, and field layout.
