> 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/hand-history-verification.md).

# Hand history and verification

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

**Capability: Wallet + RPC (indexer optional)**

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

## Purpose

This page is the read-and-verify path for completed hands. It reads two on-chain artifacts: the HAND\_REPORT\_V1 settle record for a hand, and the JPV1 jackpot receipt when a hand fired one. It then verifies two cryptographic claims:

1. The rolling hash chain. Each hand anchors into the table's Hand Ledger via `sha256(prev_hash | hand_number | merkle_root)`. Re-deriving the hash for a hand and matching it against the next hand's `prev_hash` proves no hand was swapped, inserted, or dropped.
2. The per-seat card Merkle proofs. Each revealed seat's hole cards commit to a keccak256 leaf that verifies against the `merkle_root` recorded in the same settle event.

This is the integrator-facing version of the concept tutorial at [../10-tutorials/verify-fairness.md](/tutorials/verify-fairness.md). That page explains the idea. This page gives the byte layouts, the hash domains, and a runnable verifier. It does not duplicate the conceptual walkthrough.

The verification math is keccak256 (cards) and sha256 (chain). Both are computable client-side. No signing and no custody.

## When to use / who signs

Use this when you build a public hand auditor, a "verify this hand" link, or a backend that revalidates settled hands. Nothing signs. Reads are unauthenticated RPC calls. A wallet is only an identity used to derive player-scoped PDAs, never a signer here.

An indexer is optional. With an indexer you fetch a parsed hand record directly. Without one, you read the HAND\_REPORT\_V1 chunks from L1 transaction logs and the SlimBuffer PDA from the RPC, then decode and verify the bytes yourself. The capability label reflects this: Wallet + RPC works on its own, and an indexer only saves the transaction-log crawl.

## Inputs and constants

Import the shared helper, program IDs, and PDA functions from [./setup.md](/building-on-the-protocol/setup.md). This page reuses `FASTPOKER_PROGRAM_ID` and the `getTablePda` helper from that module rather than redefining seeds.

### Carrier programs

The hand report and the jackpot receipt are not stored in dedicated accounts. They are inlined into transactions through two well-known programs.

| Artifact                | Carrier program | Program ID                                    |
| ----------------------- | --------------- | --------------------------------------------- |
| HAND\_REPORT\_V1 chunks | SPL Noop        | `noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV` |
| JPV1 jackpot receipt    | SPL Memo        | `MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr` |

SPL Noop carries the HAND\_REPORT\_V1 chunk bytes as raw instruction data. SPL Memo rejects non-UTF-8 bytes, so the JPV1 receipt is ASCII-wrapped: the memo string is `JPV1B64:` followed by base64 of the raw 131-byte payload. Strip the 8-byte `JPV1B64:` prefix and base64-decode to recover the raw payload before reading any offset.

### Anchor accounts read here

These live under the FastPoker program. For seeds, sizes, and field meaning see [../04-architecture/state-accounts.md](/architecture/state-accounts.md). For the general fetch-and-decode pattern see [reading-accounts.md](/building-on-the-protocol/reading-accounts.md).

| Account          | Seeds                            | Role in verification                                    |
| ---------------- | -------------------------------- | ------------------------------------------------------- |
| SlimBuffer       | `["slim_buffer", table_pda]`     | Holds the rolling-hash chain head and last hand number. |
| HandReportBuffer | `["hand_report_buf", table_pda]` | TEE-side buffer for a hand not yet flushed to L1.       |

### SlimBuffer layout (81 bytes total)

The SlimBuffer is the chain anchor. Its account is 81 bytes total: the 8-byte Anchor discriminator at offset 0, then 73 bytes of fields. Offsets count from the start of account data.

| Field           | Offset | Type      | Meaning                               |
| --------------- | ------ | --------- | ------------------------------------- |
| (discriminator) | 0      | \[u8; 8]  | `SHA256("account:SlimBuffer")[0..8]`  |
| table           | 8      | \[u8; 32] | Table PDA this buffer belongs to      |
| bump            | 40     | u8        | PDA bump                              |
| hand\_num       | 41     | u64 LE    | Last hand number recorded (monotonic) |
| rolling\_hash   | 49     | \[u8; 32] | Chain head: \`sha256(prev\_hash       |

The chain head at offset 49 is the hash produced by the most recent settle. It is the `prev_hash` input to the next hand.

### HAND\_REPORT\_V1 chunk header (81-byte header)

The report payload is split into chunks and emitted through SPL Noop. Each Noop instruction's data is one chunk: an 81-byte header followed by the chunk body.

| Field         | Offset | Type      | Meaning                           |
| ------------- | ------ | --------- | --------------------------------- |
| magic         | 0      | "HRV1"    | ASCII magic                       |
| version       | 4      | u8        | 1                                 |
| table         | 5      | \[u8; 32] | Table PDA                         |
| hand\_number  | 37     | u64 LE    | Hand this report covers           |
| chunk\_idx    | 45     | u16 LE    | Index of this chunk               |
| chunk\_count  | 47     | u16 LE    | Total chunks for this report      |
| payload\_hash | 49     | \[u8; 32] | `sha256(full_payload)`            |
| chunk\_bytes  | 81     | bytes     | This chunk's slice of the payload |

Reassemble all `chunk_count` chunks in `chunk_idx` order, concatenate the bodies, then confirm `sha256(payload)` equals `payload_hash` before trusting the report.

### HAND\_REPORT\_V1 settle event (160 bytes)

The reassembled payload is a sequence of events. Action events are 96 bytes. The settle event is 160 bytes and is identified by its first byte (`kind = 6`). It carries the values verification needs.

| Field         | Offset | Type      | Meaning                                            |
| ------------- | ------ | --------- | -------------------------------------------------- |
| kind          | 0      | u8        | 6 = settle                                         |
| hand\_number  | 1      | u64 LE    | Hand number                                        |
| total\_pot    | 9      | u64 LE    | Pot in lamports                                    |
| rake          | 17     | u64 LE    | Rake in lamports                                   |
| winner\_mask  | 25     | u16 LE    | Bit i set means seat i won                         |
| fold\_win     | 27     | u8        | 1 when the hand ended by everyone folding          |
| board         | 28     | \[u8; 5]  | Community cards (255 = none)                       |
| shown         | 33     | \[u8; 18] | Two card bytes per seat, seats 0..8 (255 = mucked) |
| merkle\_root  | 51     | \[u8; 32] | Keccak256 root of the 52-card commitment tree      |
| hand\_salt    | 83     | \[u8; 32] | Per-hand salt mixed into every leaf                |
| rolling\_hash | 115    | \[u8; 32] | The chain head this hand produced                  |

A card byte is `0..51`. Its rank is `byte % 13` and its suit is `floor(byte / 13)`. The `shown` block packs seat `s` at bytes `s*2` and `s*2+1`.

### Card Merkle commitment (keccak256)

The deck is committed as a 52-leaf Merkle tree, padded to 64 leaves with zero hashes. All nodes use keccak256, the same primitive as Ethereum.

| Step    | Formula                                                                         |
| ------- | ------------------------------------------------------------------------------- |
| Leaf    | \`keccak256(hand\_salt\[32]                                                     |
| Combine | \`keccak256(left\[32]                                                           |
| Verify  | Fold the proof siblings into the leaf by index parity, compare to `merkle_root` |

`position` is the card's deck slot (`0..51`). At each proof level, if the current index is even the sibling is the right child (`combine(current, sibling)`), otherwise the sibling is the left child (`combine(sibling, current)`), then the index halves. Mucked hands keep their leaves in the tree but their proofs are intentionally withheld, so you only verify seats that were revealed.

### JPV1 jackpot receipt (131-byte payload)

When a hand fires a Mini or Grand jackpot, the program emits a JPV1 receipt through SPL Memo. The memo string is `JPV1B64:` followed by base64 of the raw 131-byte payload. The offsets below are positions in the decoded 131-byte payload, not in the memo string. The fields used to tie a receipt back to a verified hand:

| Field                      | Offset | Type      | Meaning                                    |
| -------------------------- | ------ | --------- | ------------------------------------------ |
| magic                      | 0      | "JPV1"    | ASCII magic                                |
| version                    | 4      | u8        | 1                                          |
| table                      | 5      | \[u8; 32] | Table PDA                                  |
| hand\_number               | 37     | u64 LE    | Hand the jackpot resolved on               |
| rolling\_hash\_or\_entropy | 99     | \[u8; 32] | SlimBuffer chain head at the hand boundary |

The receipt's `rolling_hash_or_entropy` at payload offset 99 must equal the settle event's `rolling_hash` at offset 115 for the same `table` and `hand_number`. That equality is what binds the jackpot payout to the verified hand.

### SNG duel report event

`HandReportBuffer` can also carry a fixed 128-byte SNG duel event (`event_type = 10`). Stage values identify start, player action, timeout, round advance, and resolution. The resolution payload records duel seats, choices, winner/loser, blind level, symmetric stake, post-resolution stacks, board, both revealed duel hands, and flags for tie, tournament completion, knockout, and point transfer.

Verify a bounty change only when the point-transfer flag is present. A resolved non-tie duel always moves its capped chip stake, but it transfers a point only if the losing stack reached zero. Separately verify that all final point units sum to the originally seeded six or nine points.

## Steps

1. Resolve the hand record. With an indexer, request the parsed record for `(table, hand)`. Without one, read the SlimBuffer PDA, crawl the table's SPL Noop transactions, reassemble the HAND\_REPORT\_V1 chunks, and confirm `sha256(payload)` matches the chunk header `payload_hash`.
2. Locate the settle event (`kind = 6`) in the payload and read `merkle_root`, `hand_salt`, `rolling_hash`, `winner_mask`, board, and shown cards from the offsets above.
3. Verify the chain link. Compute `sha256(prev_hash | hand_number_le8 | merkle_root)`. Compare it to the settle event's `rolling_hash`. To check the chain is unbroken, fetch hand `n+1` and confirm its computed hash used your hand's `rolling_hash` as its `prev_hash`. The genesis `prev_hash` is 32 zero bytes.
4. Verify each revealed seat. For every shown card, compute its leaf with `hand_salt` and deck position, then verify the Merkle proof against `merkle_root`. A proof you do not hold (a mucked hand) is not a failure: skip it.
5. Optional jackpot binding. If a JPV1 memo exists for the hand, strip the `JPV1B64:` prefix, base64-decode it, then confirm the payload's `rolling_hash_or_entropy` (payload offset 99) equals the settle event's `rolling_hash`.

For the prev\_hash chaining input, the program reads it from SlimBuffer offset 49 before settle, then writes the new hash back to the same offset. So the chain head you read from SlimBuffer is always the most recently settled hand's hash.

## Example

This example imports the shared helper from [./setup.md](/building-on-the-protocol/setup.md). It computes the rolling hash for a settled hand, checks it against the recorded value, then verifies one revealed seat's hole cards against the Merkle root. It uses `js-sha3` for keccak256 and Node's `crypto` for sha256. The hand record fields shown here are exactly the offsets documented above, whether you got them from an indexer or decoded them yourself.

```ts
import { PublicKey } from '@solana/web3.js';
import { createHash } from 'crypto';
import { keccak_256 } from 'js-sha3';
import { FASTPOKER_PROGRAM_ID, getTablePda } from './setup'; // shared helper from ./setup.md

// --- hash primitives -------------------------------------------------------

function sha256(...parts: Buffer[]): Buffer {
  const h = createHash('sha256');
  for (const p of parts) h.update(p);
  return h.digest();
}

function keccak256(...parts: Buffer[]): Buffer {
  return Buffer.from(keccak_256.arrayBuffer(Buffer.concat(parts)));
}

const u64le = (n: bigint): Buffer => {
  const b = Buffer.alloc(8);
  b.writeBigUInt64LE(n);
  return b;
};

// --- rolling hash chain ----------------------------------------------------
// hand_hash = sha256(prev_hash[32] || hand_number_le8 || merkle_root[32])
// prev_hash is the SlimBuffer chain head before this hand (32 zero bytes at genesis).

function computeRollingHash(
  prevHash: Buffer, // 32 bytes
  handNumber: bigint,
  merkleRoot: Buffer, // 32 bytes
): Buffer {
  return sha256(prevHash, u64le(handNumber), merkleRoot);
}

// --- card Merkle proof (keccak256) -----------------------------------------
// leaf  = keccak256(hand_salt[32] || position_u8 || card_u8)
// node  = keccak256(left[32] || right[32])

function merkleLeaf(handSalt: Buffer, position: number, card: number): Buffer {
  return keccak256(handSalt, Buffer.from([position & 0xff, card & 0xff]));
}

function verifyMerkleProof(
  leaf: Buffer,
  proof: Buffer[], // sibling hashes, leaf -> root
  leafIndex: number, // deck position 0..51
  root: Buffer,
): boolean {
  let current = leaf;
  let idx = leafIndex;
  for (const sibling of proof) {
    current = idx % 2 === 0
      ? keccak256(current, sibling) // current is a left child
      : keccak256(sibling, current); // current is a right child
    idx = Math.floor(idx / 2);
  }
  return current.equals(root);
}

// --- the record you verify -------------------------------------------------
// Fields map 1:1 to the HAND_REPORT_V1 settle event offsets. Supply hex strings
// from your indexer or from your own decode of the 160-byte settle event.

interface SettleRecord {
  handNumber: bigint;
  merkleRootHex: string; // settle off 51
  handSaltHex: string; // settle off 83
  rollingHashHex: string; // settle off 115
  // For each revealed seat you want to check, its two cards, their deck
  // positions, and the withheld Merkle proofs (from the reveal logs).
  revealed: {
    seat: number;
    cards: { position: number; card: number; proof: string[] }[];
  }[];
}

function verifyHand(table: PublicKey, prevHashHex: string, rec: SettleRecord) {
  const prevHash = Buffer.from(prevHashHex, 'hex');
  const merkleRoot = Buffer.from(rec.merkleRootHex, 'hex');
  const handSalt = Buffer.from(rec.handSaltHex, 'hex');
  const recordedRolling = Buffer.from(rec.rollingHashHex, 'hex');

  // 1. Chain link: re-derive and compare.
  const computed = computeRollingHash(prevHash, rec.handNumber, merkleRoot);
  const chainOk = computed.equals(recordedRolling);
  console.log('table', table.toBase58());
  console.log('hand', rec.handNumber.toString(), 'chain link', chainOk ? 'PASS' : 'FAIL');

  // 2. Card commitments: verify each revealed leaf against the same root.
  for (const seat of rec.revealed) {
    for (const c of seat.cards) {
      const leaf = merkleLeaf(handSalt, c.position, c.card);
      const proof = c.proof.map((h) => Buffer.from(h, 'hex'));
      const ok = verifyMerkleProof(leaf, proof, c.position, merkleRoot);
      console.log(
        `seat ${seat.seat} pos ${c.position} card ${c.card}:`,
        ok ? 'PASS' : 'FAIL',
      );
    }
  }
}

async function main() {
  // tableId is the 32-byte table identifier you already hold.
  const tableId = new Uint8Array(32); // replace with the real id
  const [table] = getTablePda(tableId);
  console.log('program', FASTPOKER_PROGRAM_ID.toBase58());

  // prevHashHex is hand (n-1)'s rolling_hash, or 64 zeros for the first hand.
  const prevHashHex = '00'.repeat(32);

  // Replace the record below with one fetched from your indexer or decoded
  // from the 160-byte settle event. Proofs come from the reveal logs.
  const rec: SettleRecord = {
    handNumber: 1n,
    merkleRootHex: '00'.repeat(32),
    handSaltHex: '00'.repeat(32),
    rollingHashHex: '00'.repeat(32),
    revealed: [],
  };

  verifyHand(table, prevHashHex, rec);
}

main().catch(console.error);
```

The chain check passes when your re-derived `sha256(prev_hash | hand_number_le8 | merkle_root)` equals the recorded `rolling_hash`. The card check passes when each revealed leaf folds up to the recorded `merkle_root`. Both use the same `hand_salt` and `merkle_root` from one settle event, so the two checks are cross-consistent.

## Result

You have a self-contained verifier. For a settled hand you can confirm:

* The hand is correctly linked into its table's append-only Hand Ledger, with no hand swapped, inserted, or dropped.
* Every revealed seat's hole cards were committed before reveal and match the recorded Merkle root.
* Any JPV1 jackpot receipt for the hand is bound to the same `rolling_hash`, so the payout cannot be re-pointed to a different hand.

Verification needs only RPC and public hashing. The HAND\_REPORT\_V1 chunks on L1 are permanent, so a hand verified once stays verifiable.

## Pitfalls

* Do not mix the two hash functions. The chain uses sha256. The card tree uses keccak256. Swapping them fails every check.
* Encode `hand_number` as u64 little-endian in the rolling hash, exactly 8 bytes. A big-endian or trimmed encoding produces the wrong digest.
* The genesis `prev_hash` is 32 zero bytes, not an empty buffer. The first hand chains from zeros.
* Confirm `sha256(payload)` against the chunk header `payload_hash` before decoding. A report missing a chunk, or with chunks out of order, must be rejected, not best-effort parsed.
* The settle event is 160 bytes and begins with `kind = 6`. Action events are 96 bytes. Do not read settle offsets out of an action event.
* A mucked seat has no proof on purpose. Treat a withheld proof as "not checkable here," not as a verification failure. The `shown` block is always 18 bytes; a mucked or empty seat holds `255` in both of its card bytes.
* Card bytes are `0..51`; `255` is the "no card" sentinel. Rank is `byte % 13`, suit is `floor(byte / 13)`.
* A TEE-only report (`HandReportBuffer`, not yet flushed to L1) is pending, not final. The L1 HAND\_REPORT\_V1 chunks are the permanent record.
* The JPV1 memo is base64-wrapped, not raw bytes. Strip the `JPV1B64:` prefix and base64-decode before reading offsets. Reading offset 99 out of the raw memo string gives garbage.
* For the jackpot binding, compare the decoded JPV1 `rolling_hash_or_entropy` at payload offset 99 to the settle event `rolling_hash` at offset 115. They are at different offsets in different artifacts but must be equal.

## See also

* [../10-tutorials/verify-fairness.md](/tutorials/verify-fairness.md): the concept walkthrough this page implements.
* [reading-player-state.md](/building-on-the-protocol/reading-player-state.md): reading player-scoped state and profiles.
* [reading-accounts.md](/building-on-the-protocol/reading-accounts.md): the general fetch-and-decode pattern and RPC limits.
* [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): SlimBuffer, HandReportBuffer, and the HandLedger model.
