seeker-genesis-token

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Seeker Genesis Token verification

Seeker Genesis Token (SGT) 验证

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.
Seeker Genesis Token (SGT) 是一种Token-2022标准的NFT,每台Seeker设备仅铸造一枚。持有该令牌即可证明用户拥有Seeker设备,因此可用于设置奖励权限、实现“每设备仅可申领一次”的逻辑。
验证流程分为两部分,两者缺一不可
  1. 证明用户控制钱包 —— Sign-in-with-Solana (SIWS)
  2. 证明该钱包持有SGT —— 检查钱包中的Token-2022铸币记录
仅完成第二部分的话,任何人都可以提交真实Seeker所有者的公钥地址通过验证;仅完成第一部分只能证明用户控制钱包,但无法确认其是否拥有对应设备。

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
绝对不要在客户端判断权限。客户端代码可能被篡改,因此客户端的
hasSGT
布尔值毫无意义。客户端的职责是收集签名,而服务器负责验证签名并决定最终结果。
要求:
  • 你可控的后端服务,能够调用Solana主网RPC接口
  • 用于存储随机数(nonce)和申领记录的存储服务
  • RPC端点。建议使用付费服务商,因为验证过程可能需要枚举多个令牌账户——请务必将RPC密钥仅保存在服务器端,绝不要存入
    EXPO_PUBLIC_*
    变量中

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.
已实现可用的钱包连接。如果应用尚未集成钱包,请先使用
solana-mobile-wallet
技能;本技能假设
useMobileWallet()
已可用。
测试需要物理Seeker设备——模拟器无法持有SGT。请规划无需Seeker设备即可测试的代码路径,例如开发环境下的服务器端白名单。

Step 1: issue a nonce from the server

步骤1:从服务器获取随机数(nonce)

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 }
随机数必须由服务器生成、单次使用且有效期短。如果由客户端生成或可重复使用,签名可能被重放,从而失去验证意义。
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

步骤2:在客户端完成登录

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.
钱包钩子中的
signIn
方法可通过一次授权请求完成身份验证和所有权证明:
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, // 来自步骤1
  statement: 'Sign in to verify Seeker ownership',
  uri: 'https://yourdapp.com',
  version: '1',
})
chainId
被固定为
solana:mainnet
,而非从钩子的
chain
中获取:因为SGT仅存在于主网,所以针对devnet的签名无法证明用户拥有设备。
这是完整的请求负载,而非简化版的
signIn
形式——
nonce
domain
version
是确保签名不可重放并与你的应用绑定的关键。
solana-mobile-wallet
技能涵盖了两种形式及其适用场景。
output
提交至服务器。

Step 3: verify the signature on the server

步骤3:在服务器端验证签名

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),
  })
}
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

步骤4:检查钱包是否持有SGT

See 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.
完整实现请参考references/sgt-verification.md。该实现会验证Token-2022铸币的三个属性——铸币权限、元数据指针和令牌组归属,且三者必须全部匹配。

Step 5: combine the checks correctly

步骤5:正确组合验证检查

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.
这是容易出现隐蔽bug的环节。你所检查的SGT对应的地址必须是签名的地址,即从已验证的负载中读取——而非客户端随签名一并发送的其他地址:
ts
async function verifySeekerUser({ payload, result }) {
  // 1. 随机数必须是我们生成的、未使用且未过期的。
  const record = await store.get(payload.nonce)
  if (!record || record.used) throw new Error('Invalid or reused nonce.')
  await store.markUsed(payload.nonce)

  // 2. 签名必须对应该负载有效。
  if (!verifySiws(payload, result)) throw new Error('Invalid signature.')

  // 3. 域名必须属于我们,否则其他网站获取的签名也可能通过验证。
  if (payload.domain !== 'yourdapp.com') throw new Error('Wrong domain.')

  // 4. 仅针对已签名的地址检查SGT。
  const { hasSGT, mintAddress } = await checkWalletForSGT(payload.address)

  return { address: payload.address, hasSGT, mintAddress }
}
如果从已验证负载以外的地方获取地址,攻击者可以提交真实Seeker所有者的地址并附上自己的签名,从而获取权限。

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.
SGT是与设备绑定的,因此铸币地址即为设备标识。请存储铸币地址而非钱包地址——因为一个钱包后续可能持有不同的SGT,而设备的SGT也可能在不同钱包间转移。
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 })
请让
checkWalletForSGT
返回铸币地址而非单纯的布尔值——详见参考文件的末尾部分。

Reference material

参考资料

  • references/sgt-verification.md — full verification implementation, SGT constants, standard-RPC and Helius variants
  • references/sgt-verification.md —— 完整的验证实现、SGT常量、标准RPC和Helius变体

Related skills

相关技能

  • solana-mobile-wallet
    — wallet connection and the
    signIn
    payload builder
  • seeker-domains
    .skr
    domain resolution, which Seeker users have by default
  • solana-mobile-wallet
    —— 钱包连接及
    signIn
    负载构建器
  • seeker-domains
    ——
    .skr
    域名解析,Seeker用户默认拥有该功能

Links

链接