Skip to main content
skill CX Codex

seeker-genesis-token — Claude Code skill: what it does, how to install

Verifies Seeker device ownership using Sign-in-with-Solana and server-side SGT checks. Useful for gating rewards and enforcing one claim per device.

Install into Claude Code · Skills CLI (Vercel) All methods and platforms ↓
npx skills add solana-mobile/solana-mobile-skills --skill seeker-genesis-token -a claude-code
Open on GitHub

What seeker-genesis-token does

This skill guides the two-step verification process for Solana Seeker devices. The user proves wallet ownership with Sign-in-with-Solana (SIWS), and the backend checks the wallet's Token-2022 accounts for the Seeker Genesis Token (SGT). Verifying only the wallet address fails because an attacker can submit another person's public key.

All entitlement decisions stay on the server. The mobile app signs the server-generated payload using useMobileWallet from @wallet-ui/react-native-kit. On the backend, signature checks use @solana/kit and @solana/wallet-standard-util, while SGT verification relies on @solana-mobile/skr-genesis-token. Testing requires a physical Seeker device because emulators cannot hold an SGT. For local development, you must prepare an allowlist bypass in your backend code.

What it helps with

  • gate rewards to verified Seeker owners
  • add anti-Sybil checks to a Solana mobile app
  • enforce one claim per physical device
  • verify wallet ownership via Sign-in-with-Solana

Example prompts for the agent

  • Help me set up Seeker Genesis Token verification on the backend using @solana-mobile/skr-genesis-token
  • Implement Sign-in-with-Solana to verify Seeker device ownership
  • How do I enforce one claim per Seeker device in my mobile app?

How to install seeker-genesis-token

1Skills CLI (Vercel)

npx skills add solana-mobile/solana-mobile-skills --skill seeker-genesis-token -a claude-code

2Manually: put the files in place

~/.claude/skills/seeker-genesis-token/SKILL.md

Copy the whole skill folder; for one project use .claude/skills/seeker-genesis-token/

Step by step, with commands for each platform: How to install skills in Claude Code: five ways

Questions about seeker-genesis-token

How do I install seeker-genesis-token in Claude Code?

Run npx skills add solana-mobile/solana-mobile-skills --skill seeker-genesis-token -a claude-code. The agent adds the skill to Claude Code.

What does the seeker-genesis-token skill do?

It provides implementation guidance for two-step Seeker verification: authenticating wallet control via Sign-in-with-Solana and inspecting Token-2022 mints for the SGT on your server.

Can you test SGT verification on an Android emulator?

No, an emulator cannot hold an SGT. Testing requires a physical Seeker device or a temporary server-side allowlist for development.

Source text

SKILL.md · Apache-2.0
---
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
- A mainnet RPC endpoint. A paid provider helps, since the check reads every Token-2022 account
  the wallet holds — 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 the payload from the server

The server issues the **whole** SIWS payload, not just a nonce, and stores it under the nonce
with a short TTL. Every field the client supplies is a field an attacker chooses, and each one
is fed straight to the signature check, so pin them all at issue time.

```ts
// POST /api/siws/nonce
const issuedAt = new Date()
const nonce = crypto.randomBytes(16).toString('hex')

const payload = {
  chainId: 'solana:mainnet',
  domain: 'yourdapp.com',
  expirationTime: new Date(issuedAt.getTime() + 300_000).toISOString(),
  issuedAt: issuedAt.toISOString(),
  nonce,
  statement: 'Sign in to verify Seeker ownership',
  uri: 'https://yourdapp.com',
  version: '1',
}

await store.put(nonce, payload, { ttlSeconds: 300 })
return payload
```

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.

`address` is the one field the server cannot fill in. The client adds it in step 2.

`chainId` is pinned to `solana:mainnet` deliberately rather than taken from the wallet's
`chain`: SGTs exist only on mainnet, so a signature scoped to devnet proves nothing about a
device. Issuing it server-side is what turns that into a guarantee instead of a comment.

## Step 2: sign in on the client

`signIn` from the wallet hook authorizes and proves ownership in a single prompt. Spread the
issued payload and add the address:

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

const { signIn } = useMobileWallet()

const issued = await fetch('https://yourdapp.com/api/siws/nonce', { method: 'POST' }).then(
  (response) => response.json(),
)

const address = account.address.toString()
const output = await signIn({ ...issued, address })

await fetch('https://yourdapp.com/api/siws/verify', {
  body: JSON.stringify({
    address,
    nonce: issued.nonce,
    signature: Array.from(output.signature),
    signedMessage: Array.from(output.signedMessage),
  }),
  headers: { 'Content-Type': 'application/json' },
  method: 'POST',
})
```

Post exactly those four fields. **Do not post `output.account`, and the server must never
accept an account object or a public key from the client** — the key the signature is checked
against has to be derived from the address the server is about to act on. Step 3 shows why.
`nonce` is only a lookup key here; the signature check is what binds it.

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

## Step 3: verify the signature on the server

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

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

function verifySiws(issued, { address, signature, signedMessage }) {
  // Reject malformed input here, not inside the ed25519 verify.
  if (!Array.isArray(signature) || signature.length !== 64) {
    throw new Error('Malformed signature.')
  }
  if (!Array.isArray(signedMessage)) throw new Error('Malformed signed message.')

  // Derive the verifying key from the address, never from the request body.
  const publicKey = getBase58Encoder().encode(address)
  if (publicKey.length !== 32) throw new Error('Malformed address.')

  return verifySignIn(
    { ...issued, address },
    {
      account: { address, chains: [], features: [], publicKey },
      signature: new Uint8Array(signature),
      signedMessage: new Uint8Array(signedMessage),
    },
  )
}
```

The three shape checks come first because every one of those values is attacker-chosen. Without
them the malformed cases reach `new Uint8Array()` and then the ed25519 verify, where they land
inconsistently: a short signature throws a library error about byte lengths, while a non-array
`signedMessage` coerces to an empty array and returns a plain `false`. Rejecting on shape gives
one clear answer for both and does not lean on internals you do not control.

`verifySignIn` compares the fields you pass it against the fields inside the signed text, then
verifies the signature with `account.publicKey`. **It never checks that the key and the address
agree.** Take that key from the request body and any throwaway keypair can sign a message
naming any address: the signature is genuine, it just is not the address holder's.

Because the server issued `issued`, `domain` and `chainId` are pinned by construction. No
client copy of either reaches the check, so no separate domain comparison is needed. If you
want a defensive assertion anyway, assert against `issued.domain` — the stored copy — never a
value off the request.

## Step 4: check the wallet for an SGT

Use [`@solana-mobile/skr-genesis-token`](https://www.npmjs.com/package/@solana-mobile/skr-genesis-token).
Do not hand-roll the check: the package is maintained by Solana Mobile and gets the details
right that a copied snippet tends to miss.

```bash
npm install @solana-mobile/skr-genesis-token @solana/kit
```

```ts
import { getSeekerGenesisTokens } from '@solana-mobile/skr-genesis-token'
import { address, createSolanaRpc } from '@solana/kit'

// SGTs exist only on mainnet, whatever cluster the rest of the app targets.
const rpc = createSolanaRpc(process.env.SOLANA_MAINNET_RPC_URL!)

async function getWalletSGTMints(walletAddress: string) {
  const tokens = await getSeekerGenesisTokens(rpc, address(walletAddress))
  return tokens.map((token) => token.mint)
}
```

Return the **mint addresses**, not just a boolean: a mint identifies a device, which is what
the anti-Sybil check below records. Keep every mint, not only the first: one wallet can hold the
SGTs of several devices, and each of them is entitled to claim. `hasSeekerGenesisToken` exists for a plain yes/no, and
`isSeekerGenesisToken` verifies a single mint.

What the package handles, so you do not add it again:

- **A mint is an SGT only if both its `TokenGroupMember` group and its `MetadataPointer` are the
  SGT group mint.** It decodes those extensions from the mint bytes, never from names or
  off-chain metadata.
- **Zero-balance token accounts are skipped.** Transferring an SGT out leaves the old account
  open at `0`; counting it would verify every past holder forever.
- **Frozen accounts are not rejected.** A legitimately held SGT sits in a frozen account — do not
  add a freeze check on top.
- **RPC failures throw.** Let them: map them to a 503 at the API boundary, never to
  an empty list, or an outage reads as "you do not own a Seeker".

It reads the wallet's Token-2022 accounts in one `getTokenAccountsByOwner` request, with no
pagination, using standard RPC methods only, so any mainnet endpoint works.

## Step 5: combine the checks correctly

This is where the subtle bug lives. `verifySignIn` does not bind the account key to the address
in the signed message, so the server has to do that binding itself: derive the key from the
address it is going to act on, and look the SGT up against that same address.

```ts
async function verifySeekerUser({ address, nonce, signature, signedMessage }) {
  // 1. Consume the nonce atomically. Read-then-mark leaves a window where two concurrent
  //    requests both see it unused, and one signature is accepted twice.
  //    Redis: GETDEL. SQL: DELETE FROM nonces WHERE nonce = $1 RETURNING payload.
  const issued = await store.consume(nonce)
  if (!issued) throw new Error('Invalid, reused, or expired nonce.')

  // 2. The signature must be valid for the payload we issued and for this address.
  if (!verifySiws(issued, { address, signature, signedMessage })) {
    throw new Error('Invalid signature.')
  }

  // 3. Check the SGT against the address the signature was just verified against.
  const mintAddresses = await getWalletSGTMints(address)

  return { address, mintAddresses }
}
```

Checking the SGT against any other address 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.

A `UNIQUE` constraint is what enforces one claim, not application code:

```sql
CREATE TABLE claims (
  claimed_at timestamptz NOT NULL DEFAULT now(),
  mint_address text NOT NULL UNIQUE,
  paid_at timestamptz
);
```

```ts
const { address, mintAddresses } = await verifySeekerUser(request.body)
if (mintAddresses.length === 0) throw new Error('No Seeker Genesis Token found.')

// Insert first and let the constraint decide. Asking whether a row exists and then
// inserting is a double-claim bug: two concurrent requests both find nothing.
// Try each held SGT in turn, so a wallet whose first device has claimed can claim for the next.
let mintAddress: string | undefined
for (const candidate of mintAddresses) {
  try {
    await claims.insert({ claimedAt: new Date(), mintAddress: candidate })
    mintAddress = candidate
    break
  } catch (error) {
    // 23505 is the Postgres unique violation. Other drivers report it differently.
    if ((error as { code?: unknown } | null)?.code !== '23505') throw error
  }
}
if (!mintAddress) throw new Error('Every Seeker in this wallet has already claimed.')

// Pay out only once the insert has landed. Anything paid before it can be paid twice.
await grantReward(address)
await claims.markPaid(mintAddress)
```

The insert and the payout are two separate writes, so decide what happens when the second one
fails. As written, a `grantReward` that throws after the row commits leaves that device unable
to retry: the constraint now rejects it as already claimed. Either drive retries off rows
with a null `paid_at`, or make `grantReward` idempotent on `mintAddress` so replaying it is
free.
This one is robustness rather than security: getting it wrong denies a real owner their
reward, it does not let anyone claim twice.

## Related skills

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

## Links

- `@solana-mobile/skr-genesis-token`: https://github.com/solana-mobile/skr-js/tree/main/packages/skr-genesis-token#readme
- 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

© solana-mobile · Apache-2.0 · solana-mobile/solana-mobile-skills

Similar skills

Everything for Mobile →

From the same set6

Project sponsors

Companies that keep this analytics open
Gold sponsor

Impressions, clicks and code copies — open statistics. We keep the numbers public for the same reason NeBlask itself is public. Become a sponsor