> 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/tee-card-access.md).

# TEE card access and ER actions

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

**Capability: Needs TEE auth**

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

## Purpose

This page covers the protocol interaction needed to play a delegated hand: proving wallet ownership to the TEE, reading your own hole cards, and sending a gameplay action on the Ephemeral Rollup (ER).

The same registered session key signs `sng_duel_action` for a selected 6-max/9-max duelist. Duel `SeatCards` remain private to their owners. Action value `1` is PLAY (accept current cards); value `2` is NEXT CARDS (advance both players to a redeal). A duel timeout follows the NEXT CARDS progression. These choices do not opt out of the symmetric chip stake.

Three steps:

1. Mint a per-player TEE JWT from a wallet `signMessage` challenge.
2. Request a scoped hole-card read (your own `SeatCards`, through the tokenized TEE connection).
3. Send an ER gameplay transaction under two rules: the ER blockhash rule and the session-key (ephemeral signer) rule.

This is protocol interaction, not transport. For the trust model behind why cards are private and how the deck commitment works, see [../04-architecture/tee-deal-reveal.md](/architecture/tee-deal-reveal.md).

## When to use / who signs

Use this once a player is seated on a delegated table and needs to see their cards or act. Two distinct signers appear here, and confusing them is the most common failure.

| Signer                          | What it signs                                 | When                                           |
| ------------------------------- | --------------------------------------------- | ---------------------------------------------- |
| Wallet                          | The `signMessage` auth challenge (off-chain)  | Once per session, to mint the TEE JWT          |
| Session key (`approved_signer`) | The ER gameplay transaction (`player_action`) | Every action: fold, check, call, raise, all-in |

The wallet never signs a gameplay transaction on the ER. It signs the auth challenge to get the JWT, and it signs `approved_signer` registration once. The session key (an ephemeral keypair, gasless on the TEE) signs every action. The session-key model is described in [signer-matrix.md](/building-on-the-protocol/signer-matrix.md).

Reads of public state (table, seats) do not need this page. Those are unauthenticated RPC reads, covered in [reading-accounts.md](/building-on-the-protocol/reading-accounts.md). Only `SeatCards` and `DeckState` are gated.

## Inputs and constants

| Input                | Value                                         | Notes                                                                 |
| -------------------- | --------------------------------------------- | --------------------------------------------------------------------- |
| FastPoker program ID | `PokerXYdXL2SKNnfGbv1WE7vJHipTpNsfZbZeVvoJLn` | From [setup.md](/building-on-the-protocol/setup.md).                  |
| TEE base URL         | `https://mainnet-tee.magicblock.app`          | The MagicBlock TEE validator endpoint (the ER RPC).                   |
| Auth challenge       | `GET {TEE}/auth/challenge?pubkey=<base58>`    | Returns `{ challenge }`.                                              |
| Auth login           | `POST {TEE}/auth/login`                       | Body `{ pubkey, challenge, signature }`. Returns `{ token }`.         |
| Tokenized connection | `new Connection("{TEE}?token=<jwt>")`         | The JWT rides as a query parameter.                                   |
| Token lifetime       | About 45 to 50 minutes                        | The TEE rotates player tokens around 50 minutes. Refresh before then. |

The signature posted to `/auth/login` is the wallet's `signMessage` over the exact challenge string, base58-encoded. The PDA helpers (`getSeatCardsPda`, `getSeatPda`) and program IDs come from the shared module in [setup.md](/building-on-the-protocol/setup.md).

### Instruction: player\_action

The gameplay action is the `player_action` instruction. Its account order and args are fixed by the IDL:

| Position | Account  | Role                                                        |
| -------- | -------- | ----------------------------------------------------------- |
| 0        | `signer` | The session key (`approved_signer`). Signs the transaction. |
| 1        | `table`  | The Table PDA.                                              |
| 2        | `seat`   | The acting player's `PlayerSeat` PDA.                       |

| Arg       | Type               | Meaning                             |
| --------- | ------------------ | ----------------------------------- |
| `action`  | `PokerAction` enum | fold, check, call, raise, or all-in |
| `entropy` | `[u8; 32]`         | Per-action entropy bytes            |

The contract accepts either the wallet or the registered `approved_signer` as the `signer`. The client uses the session key so no wallet popup is needed per action. Build this instruction with the Anchor `Program` from [setup.md](/building-on-the-protocol/setup.md); the IDL carries its discriminator.

## Steps

### 1. Mint the per-player TEE JWT

1. Fetch a challenge: `GET {TEE}/auth/challenge?pubkey=<wallet base58>`.
2. Sign the returned `challenge` string with the wallet's `signMessage`.
3. POST `{ pubkey, challenge, signature }` (signature base58-encoded) to `{TEE}/auth/login`.
4. Read `token` from the response. Build a tokenized `Connection` to `{TEE}?token=<token>`.

This JWT is scoped to that wallet. It authorizes reads of that wallet's private accounts and nothing else.

### 2. Read your own hole cards

1. Derive the `SeatCards` PDA for your table and seat index with `getSeatCardsPda`.
2. Call `getAccountInfo(seatCardsPda)` on the tokenized TEE connection.
3. Read the two card bytes `card1` and `card2` at offsets 73 and 74 in the returned data.

A public RPC read of `SeatCards` returns `null` or sentinel bytes. Only the tokenized TEE connection for the owning wallet returns usable card data. You can read only your own seat.

### 3. Send the ER gameplay action

1. Build the `player_action` instruction with the session key as `signer`.
2. Set `tx.feePayer` to the session key public key.
3. Set `tx.recentBlockhash` from the TEE connection: `getLatestBlockhash` on the ER endpoint. Do not use an L1 blockhash.
4. Sign with the session key. Send with `sendRawTransaction({ skipPreflight: true })` on the TEE connection.
5. Poll `getSignatureStatuses` for confirmation.

The two hard rules:

* ER blockhash rule: the blockhash must come from the TEE/ER endpoint. A free public L1 pool can return a valid Solana blockhash that the ER will not accept, which leaves the action stuck at confirming.
* Session-key rule: the session key signs and pays. It is gasless on the TEE and needs no SOL. The wallet does not sign the action. The session key must already be registered as the seat's `approved_signer` (set during join, rotated with `update_approved_signer`).

## Example

This example imports the shared helper from [./setup.md](/building-on-the-protocol/setup.md). It mints a player JWT from a `signMessage` challenge, reads the caller's hole cards through the tokenized TEE connection, then builds and sends a `player_action` on the ER under both rules.

```ts
import {
  Connection,
  Keypair,
  PublicKey,
  Transaction,
  ComputeBudgetProgram,
} from '@solana/web3.js';
import { AnchorProvider, Program, Wallet } from '@coral-xyz/anchor';
import bs58 from 'bs58';
import {
  FASTPOKER_IDL,
  FASTPOKER_PROGRAM_ID,
  getTablePda,
  getSeatPda,
  getSeatCardsPda,
} from './setup'; // shared helper from ./setup.md

const TEE_BASE = 'https://mainnet-tee.magicblock.app';

// SeatCards layout: 8 (discriminator) + 32 (table) + 1 (seat_index) + 32 (player),
// then card1 (u8) at offset 73 and card2 (u8) at offset 74.
const CARD1_OFFSET = 73;
const CARD2_OFFSET = 74;

// 1) Mint a per-player TEE JWT from a wallet signMessage challenge.
//    `signMessage` is the wallet adapter's message signer (Uint8Array -> Uint8Array).
async function mintPlayerToken(
  walletPubkey: PublicKey,
  signMessage: (msg: Uint8Array) => Promise<Uint8Array>,
): Promise<string> {
  const pub = walletPubkey.toBase58();

  const cr = await fetch(`${TEE_BASE}/auth/challenge?pubkey=${pub}`).then((r) => r.json());
  if (!cr?.challenge) throw new Error('TEE challenge missing');

  const sig = await signMessage(new TextEncoder().encode(cr.challenge));

  const lr = await fetch(`${TEE_BASE}/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ pubkey: pub, challenge: cr.challenge, signature: bs58.encode(sig) }),
  }).then((r) => r.json());
  if (!lr?.token) throw new Error('TEE login returned no token');

  return lr.token;
}

// 2) Read the caller's own hole cards via the tokenized TEE connection.
async function readMyHoleCards(
  teeConn: Connection,
  tablePda: PublicKey,
  seatIndex: number,
): Promise<[number, number] | null> {
  const [seatCardsPda] = getSeatCardsPda(tablePda, seatIndex);
  const info = await teeConn.getAccountInfo(seatCardsPda, 'confirmed');
  if (!info || info.data.length < CARD2_OFFSET + 1) return null; // gated/sentinel
  const data = info.data as Buffer;
  return [data.readUInt8(CARD1_OFFSET), data.readUInt8(CARD2_OFFSET)];
}

// 3) Send an ER gameplay action signed by the session key.
async function sendErAction(
  teeConn: Connection,
  program: Program,
  sessionKey: Keypair, // the registered approved_signer (ephemeral, gasless)
  tablePda: PublicKey,
  seatPda: PublicKey,
  action: any, // a PokerAction enum value, e.g. { fold: {} }
): Promise<string> {
  const entropy = Array.from(crypto.getRandomValues(new Uint8Array(32)));

  const ix = await program.methods
    .playerAction(action, entropy)
    .accountsStrict({
      signer: sessionKey.publicKey,
      table: tablePda,
      seat: seatPda,
    })
    .instruction();

  const tx = new Transaction();
  tx.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }));
  tx.add(ix);
  tx.feePayer = sessionKey.publicKey;

  // ER blockhash rule: take the blockhash from the TEE endpoint, never L1.
  tx.recentBlockhash = (await teeConn.getLatestBlockhash('confirmed')).blockhash;

  // Session-key rule: the session key signs and pays. The wallet does not.
  tx.sign(sessionKey);

  const sig = await teeConn.sendRawTransaction(tx.serialize(), { skipPreflight: true });

  // Poll the ER for confirmation.
  for (let i = 0; i < 20; i++) {
    await new Promise((r) => setTimeout(r, 500));
    const st = await teeConn.getSignatureStatuses([sig]);
    const s = st.value[0];
    if (s && (s.confirmationStatus === 'confirmed' || s.confirmationStatus === 'finalized')) {
      if (s.err) throw new Error(`action failed: ${JSON.stringify(s.err)}`);
      return sig;
    }
  }
  throw new Error('ER action not confirmed in time');
}

async function main() {
  // Replace with a real wallet adapter that exposes publicKey + signMessage.
  const wallet = new Wallet(Keypair.generate());
  const signMessage = async (msg: Uint8Array) =>
    bs58.decode(bs58.encode(Buffer.from(msg))); // placeholder; use the real signer

  const token = await mintPlayerToken(wallet.publicKey, signMessage as any);

  // Tokenized TEE connection: the JWT rides as a query parameter.
  const teeConn = new Connection(`${TEE_BASE}?token=${token}`, 'confirmed');

  // Anchor Program over the TEE connection (address comes from the IDL).
  const provider = new AnchorProvider(teeConn, wallet, { commitment: 'confirmed' });
  const program = new Program(FASTPOKER_IDL as any, provider);
  console.log('program', program.programId.equals(FASTPOKER_PROGRAM_ID));

  const tableId = new Uint8Array(32); // your 32-byte table id
  const seatIndex = 0;
  const [tablePda] = getTablePda(tableId);
  const [seatPda] = getSeatPda(tablePda, seatIndex);

  const cards = await readMyHoleCards(teeConn, tablePda, seatIndex);
  console.log('my hole cards:', cards);

  // The session key must already be the seat's approved_signer.
  const sessionKey = Keypair.generate(); // load yours from secure storage
  const sig = await sendErAction(teeConn, program, sessionKey, tablePda, seatPda, { fold: {} });
  console.log('action sig:', sig);
}

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

## Result

After this page you can: prove wallet ownership to the TEE and hold a scoped JWT, read your own hole cards through the tokenized connection (and only your own), and submit a `player_action` on the ER signed by the gasless session key with an ER-sourced blockhash. The JWT expires in under an hour; re-run the challenge flow to refresh it.

## Pitfalls

* Using an L1 blockhash for the ER action. The ER rejects it and the action hangs at confirming. Always take the blockhash from the TEE connection.
* Signing the action with the wallet. The action is signed by the session key (`approved_signer`), not the wallet. The wallet only signs the auth challenge and `approved_signer` registration.
* Session key not registered. If the seat's `approved_signer` is not your session key, the action fails. Register it at join, or rotate with `update_approved_signer`.
* Reading another player's `SeatCards`. The JWT is scoped to your wallet. Other seats return gated or sentinel bytes.
* Reading cards over a public RPC. `SeatCards` and `DeckState` are permission-gated. Use the tokenized TEE connection, not a public pool.
* Stale JWT. The TEE rotates player tokens around 50 minutes. A read or action with an expired token fails; mint a fresh one from a new challenge.
* Wrong signature encoding. `/auth/login` expects the `signMessage` output base58-encoded over the exact challenge string. Do not alter or re-wrap the challenge.

## See also

* [../04-architecture/tee-deal-reveal.md](/architecture/tee-deal-reveal.md): why cards are private and how the deck commitment and reveal work.
* [setup.md](/building-on-the-protocol/setup.md): the shared helper, program IDs, and PDA-derivation functions.
* [reading-accounts.md](/building-on-the-protocol/reading-accounts.md): the unauthenticated read path for public state and the L1-vs-ER owner check.
* [signer-matrix.md](/building-on-the-protocol/signer-matrix.md): wallet vs session-key signing across every flow.
