Back to Skills
solana-mobile/solana-mobile-skillsReview behavior before running

SKILL DETAIL

seeker-genesis-token

solana-mobile/solana-mobile-skills/seeker-genesis-token

The Seeker Genesis Token (SGT) is a Token-2022 NFT minted once per Seeker device. Holding one is evidence of owning a Seeker, which makes it useful for gating rewards and for one-claim-per-device logic. Verification has two halves, and both are required: 1) Prove the user controls the wallet — Sign-in-with-Solana (SIWS); 2) Prove that wallet holds an SGT — inspect the wallet's Token-2022 mints. Doing only the second means anyone can submit a real Seeker owner's public address and pass. Doing only the first proves wallet control but says nothing about a device. This must run on a server; never decide entitlement client-side. The client's job is to collect a signature; the server verifies it and owns the result. The server issues a single-use, short-lived nonce; the client signs a fully-specified SIWS payload; the server verifies the signature, checks the nonce and domain, then checks the wallet for an SGT. The SGT check confirms three properties of a Token-2022 mint — mint authority, metadata pointer, and token group membership — and all three must match. For anti-Sybil, use the mint address as the device identity, not the wallet address, since a wallet can hold a different SGT later, and a device's SGT can move between wallets.

Installs · 184View source

Installation

npx skills add https://github.com/solana-mobile/solana-mobile-skills --skill seeker-genesis-token

Skill files

SKILL.md

Last synced · Aug 29, 2026

references/sgt-verification.md
# SGT verification implementation

Server-side only. See the parent skill for why, and for the SIWS half of the check.

## What makes a mint an SGT

Three properties of a Token-2022 mint must **all** hold. Checking fewer is not a partial
check — it is a bypass, since any of them can be forged in isolation by an unrelated token.

These identify the mint. They say nothing about who holds it, so they are necessary but not
sufficient — see [the wallet must actually hold the token](#the-wallet-must-actually-hold-the-token).

| Property | Expected value |
| --- | --- |
| Mint authority | `GT2zuHVaZQYZSyQMgJPLzvkmyztfyXg2NJunqFp4p3A4` |
| Metadata pointer authority | `GT2zuHVaZQYZSyQMgJPLzvkmyztfyXg2NJunqFp4p3A4` |
| Metadata pointer address | `GT22s89nU4iWFkNXj1Bw6uYhJJWDRPpShHt4Bk8f99Te` |
| Token group member group | `GT22s89nU4iWFkNXj1Bw6uYhJJWDRPpShHt4Bk8f99Te` |

```js
const SGT_MINT_AUTHORITY = 'GT2zuHVaZQYZSyQMgJPLzvkmyztfyXg2NJunqFp4p3A4'

// The metadata address and group mint address are intentionally the same value.
const SGT_METADATA_ADDRESS = 'GT22s89nU4iWFkNXj1Bw6uYhJJWDRPpShHt4Bk8f99Te'
const SGT_GROUP_MINT_ADDRESS = 'GT22s89nU4iWFkNXj1Bw6uYhJJWDRPpShHt4Bk8f99Te'
```

SGTs live on **mainnet**. There is no devnet equivalent to test against.

## Dependencies

```bash
npm install @solana/web3.js @solana/spl-token
```

## Shared mint check

Both variants below produce a list of Token-2022 mint addresses, then run them through this:

```js
const {
  getMetadataPointerState,
  getTokenGroupMemberState,
  TOKEN_2022_PROGRAM_ID,
  unpackMint,
} = require('@solana/spl-token')

const BATCH_SIZE = 100

async function findSgtMint(connection, mintPubkeys) {
  for (let i = 0; i < mintPubkeys.length; i += BATCH_SIZE) {
    const batch = mintPubkeys.slice(i, i + BATCH_SIZE)
    const infos = await connection.getMultipleAccountsInfo(batch)

    for (let j = 0; j < infos.length; j++) {
      if (!infos[j]) continue

      let mint
      try {
        mint = unpackMint(batch[j], infos[j], TOKEN_2022_PROGRAM_ID)
      } catch {
        continue // Not a Token-2022 mint we can read; not an SGT.
      }

      const metadataPointer = getMetadataPointerState(mint)
      const groupMember = getTokenGroupMemberState(mint)

      const ok =
        mint.mintAuthority?.toBase58() === SGT_MINT_AUTHORITY &&
        metadataPointer?.authority?.toBase58() === SGT_MINT_AUTHORITY &&
        metadataPointer?.metadataAddress?.toBase58() === SGT_METADATA_ADDRESS &&
        groupMember?.group?.toBase58() === SGT_GROUP_MINT_ADDRESS

      if (ok) return mint.address.toBase58()
    }
  }

  return null
}
```

`getMultipleAccountsInfo` is batched at 100 because most RPC providers reject larger
multi-account requests.

Returning the **mint address** rather than a boolean is deliberate: the mint identifies the
device, which is what anti-Sybil logic needs to record. See the parent skill.

## The wallet must actually hold the token

Both variants filter out token accounts with `amount === '0'` before collecting mints. This is
not defensive noise — omitting it is a verification bypass.

Transferring an SGT out of a wallet does not close the source Associated Token Account. The ATA
stays open forever with `amount: "0"`, and `getTokenAccountsByOwner` keeps returning it. Since
`findSgtMint` validates properties of the **mint** and never touches the balance, an unfiltered
list makes every wallet that has *ever* held an SGT verify as a current holder, permanently.

SGTs do move between wallets in practice, so these stale accounts are common rather than
hypothetical. The same filter also covers revocation: the mint carries a permanent delegate and
a close authority, so an SGT can be burned out of a wallet, leaving identical zero-balance
residue.

**Do not additionally reject frozen accounts.** A legitimately held SGT sits in a *frozen* ATA —
Solana Mobile holds the freeze authority and re-freezes on arrival, which is what stops holders
from moving the token themselves. Treating `state === 'frozen'` as suspicious rejects every real
Seeker owner. The balance is the discriminator; the freeze state is not.

## Variant 1: standard RPC

Works with any Solana RPC, no API key. Suitable when wallets hold a modest number of tokens.

```js
const { Connection, PublicKey } = require('@solana/web3.js')

async function checkWalletForSGT(walletAddress, rpcUrl) {
  const connection = new Connection(rpcUrl, 'confirmed')

  const { value: tokenAccounts } = await connection.getParsedTokenAccountsByOwner(
    new PublicKey(walletAddress),
    { programId: TOKEN_2022_PROGRAM_ID },
  )

  const mintPubkeys = tokenAccounts
    .filter((entry) => entry.account.data.parsed?.info?.tokenAmount?.amount !== '0')
    .map((entry) => entry.account.data.parsed?.info?.mint)
    .filter(Boolean)
    .map((mint) => new PublicKey(mint))

  const mintAddress = await findSgtMint(connection, mintPubkeys)

  return { hasSGT: mintAddress !== null, mintAddress }
}
```

`getParsedTokenAccountsByOwner` returns every matching account in one response with no
pagination. For a wallet with an unusually large number of Token-2022 accounts the response
can exceed provider size limits — that is the case variant 2 exists for.

## Variant 2: Helius with pagination

Uses `getTokenAccountsByOwnerV2`, which pages. Needed for wallets holding many token accounts.

```js
const { Connection, PublicKey } = require('@solana/web3.js')

const TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'

async function checkWalletForSGT(walletAddress, heliusRpcUrl) {
  const connection = new Connection(heliusRpcUrl, 'confirmed')

  const accounts = []
  let paginationKey = null
  let page = 0

  do {
    const response = await fetch(heliusRpcUrl, {
      body: JSON.stringify({
        id: `page-${++page}`,
        jsonrpc: '2.0',
        method: 'getTokenAccountsByOwnerV2',
        params: [
          walletAddress,
          { programId: TOKEN_2022_PROGRAM },
          { encoding: 'jsonParsed', limit: 1000, ...(paginationKey && { paginationKey }) },
        ],
      }),
      headers: { 'Content-Type': 'application/json' },
      method: 'POST',
    })

    if (!response.ok) throw new Error(`RPC HTTP ${response.status}`)

    const data = await response.json()
    if (data.error) throw new Error(`RPC error: ${data.error.message}`)

    accounts.push(...(data.result?.value?.accounts ?? []))
    paginationKey = data.result?.paginationKey ?? null
  } while (paginationKey)

  const mintPubkeys = accounts
    .filter((entry) => entry?.account?.data?.parsed?.info?.tokenAmount?.amount !== '0')
    .map((entry) => entry?.account?.data?.parsed?.info?.mint)
    .filter(Boolean)
    .map((mint) => new PublicKey(mint))

  const mintAddress = await findSgtMint(connection, mintPubkeys)

  return { hasSGT: mintAddress !== null, mintAddress }
}
```

Guard the loop against a provider that keeps returning the same `paginationKey` — cap the page
count if you are not in control of the endpoint.

## Error handling

Let RPC failures throw. Swallowing them and returning `{ hasSGT: false }` turns a transient
outage into "you do not own a Seeker", which is both wrong and hard to debug. Distinguish the
two cases at the API boundary:

```js
try {
  const { hasSGT, mintAddress } = await checkWalletForSGT(address, rpcUrl)
  return reply.send({ hasSGT, mintAddress })
} catch (error) {
  return reply.status(503).send({ error: 'Verification temporarily unavailable.' })
}
```
SKILL.md
---
name: seeker-genesis-token
description: Verify Seeker device ownership by checking for the Seeker Genesis Token (SGT) with Sign-in-with-Solana and server-side token verification. Use when gating content or rewards to Seeker owners, verifying a user holds an SGT, adding anti-Sybil checks to a Solana mobile app, or implementing one-claim-per-device logic.
---

# Seeker Genesis Token verification

The Seeker Genesis Token (SGT) is a Token-2022 NFT minted once per Seeker device. Holding one
is evidence of owning a Seeker, which makes it useful for gating rewards and for
one-claim-per-device logic.

Verification has two halves, and **both are required**:

1. **Prove the user controls the wallet** — Sign-in-with-Solana (SIWS)
2. **Prove that wallet holds an SGT** — inspect the wallet's Token-2022 mints

Doing only the second means anyone can submit a real Seeker owner's public address and pass.
Doing only the first proves wallet control but says nothing about a device.

## This must run on a server

**Never decide entitlement client-side.** A client can be patched, so a client-side `hasSGT`
boolean is worth nothing. The client's job is to collect a signature; the server verifies it
and owns the result.

Requirements:

- A backend you control that can make Solana mainnet RPC calls
- Storage for nonces and claim records
- An RPC endpoint. A paid provider helps, since the check may enumerate many token accounts —
  keep that key server-side only, never in an `EXPO_PUBLIC_*` variable

## Prerequisites

A working wallet connection. If the app has none, use the `solana-mobile-wallet` skill first;
this skill assumes `useMobileWallet()` is available.

Testing needs a physical Seeker device — an emulator cannot hold an SGT. Plan for a code path
you can exercise without one, such as a server-side allowlist in development.

## Step 1: issue a nonce from the server

The nonce must be **server-generated, single-use, and short-lived**. A client-generated or
reusable nonce makes the signature replayable, which defeats the exercise.

```ts
// POST /api/siws/nonce
const nonce = crypto.randomBytes(16).toString('hex')
await store.put(nonce, { issuedAt: Date.now(), used: false }, { ttlSeconds: 300 })
return { nonce }
```

## Step 2: sign in on the client

`signIn` from the wallet hook authorizes and proves ownership in a single prompt:

```ts
import { useMobileWallet } from '@wallet-ui/react-native-kit'

const { signIn } = useMobileWallet()

const output = await signIn({
  address: account.address.toString(),
  chainId: 'solana:mainnet',
  domain: 'yourdapp.com',
  issuedAt: new Date().toISOString(),
  nonce, // from step 1
  statement: 'Sign in to verify Seeker ownership',
  uri: 'https://yourdapp.com',
  version: '1',
})
```

`chainId` is pinned to `solana:mainnet` deliberately rather than taken from the hook's `chain`:
SGTs exist only on mainnet, so a signature scoped to devnet proves nothing about a device.

This is the fully-specified payload, not the short `signIn` form — `nonce`, `domain`, and
`version` are what make the signature non-replayable and bind it to your app. The
`solana-mobile-wallet` skill covers both forms and when each is appropriate.

Post `output` to the server.

## Step 3: verify the signature on the server

```bash
npm install @solana/wallet-standard-util
```

```ts
import { verifySignIn } from '@solana/wallet-standard-util'

function verifySiws(payload, result) {
  return verifySignIn(payload, {
    account: { ...result.account, publicKey: new Uint8Array(result.account.publicKey) },
    signature: new Uint8Array(result.signature),
    signedMessage: new Uint8Array(result.signedMessage),
  })
}
```

## Step 4: check the wallet for an SGT

See [references/sgt-verification.md](references/sgt-verification.md) for the full
implementation. It confirms three properties of a Token-2022 mint — mint authority, metadata
pointer, and token group membership — and all three must match.

## Step 5: combine the checks correctly

This is where the subtle bug lives. The address whose SGT you check **must be the address that
signed**, read out of the verified payload — not an address the client sent alongside it:

```ts
async function verifySeekerUser({ payload, result }) {
  // 1. The nonce must be one we issued, unused, and unexpired.
  const record = await store.get(payload.nonce)
  if (!record || record.used) throw new Error('Invalid or reused nonce.')
  await store.markUsed(payload.nonce)

  // 2. The signature must be valid for that payload.
  if (!verifySiws(payload, result)) throw new Error('Invalid signature.')

  // 3. The domain must be ours, or a signature farmed by another site would pass.
  if (payload.domain !== 'yourdapp.com') throw new Error('Wrong domain.')

  // 4. Check the SGT against the *signed* address only.
  const { hasSGT, mintAddress } = await checkWalletForSGT(payload.address)

  return { address: payload.address, hasSGT, mintAddress }
}
```

Taking the address from anywhere other than the verified payload lets a caller submit a real
Seeker owner's address with their own signature and be granted access.

## Anti-Sybil: one claim per device

An SGT is per-device, so the **mint address** is the device identity. Store that, not the
wallet address — a wallet can hold a different SGT later, and a device's SGT can move between
wallets.

```ts
const { hasSGT, mintAddress } = await verifySeekerUser({ payload, result })
if (!hasSGT) throw new Error('No Seeker Genesis Token found.')

if (await claims.exists(mintAddress)) throw new Error('This device has already claimed.')
await claims.insert({ claimedAt: new Date(), mintAddress })
```

Have `checkWalletForSGT` return the mint address rather than a bare boolean — see the end of
the reference file.

## Reference material

- [references/sgt-verification.md](references/sgt-verification.md) — full verification
  implementation, SGT constants, standard-RPC and Helius variants

## Related skills

- `solana-mobile-wallet` — wallet connection and the `signIn` payload builder
- `seeker-domains` — `.skr` domain resolution, which Seeker users have by default

## Links

- Detecting Seeker users: https://docs.solanamobile.com/react-native/detecting-seeker-users
- Sign-in-with-Solana spec: https://github.com/phantom/sign-in-with-solana
seeker-genesis-token · Trending Agent Skills | Mengbi