关于 seeker-genesis-token
Seeker Genesis Token verification 是一项技能,用于在服务器端验证用户控制 Solana 钱包并持有 Seeker Genesis Token。它说明基于一次性 nonce 的 SIWS 验证及按设备限制领取,适用于奖励准入等流程。
主要功能
- 01服务器签发一次性 SIWS nonce
- 02钱包签名验证指南
- 03主网 SGT 持有情况检查
- 04使用 mint 地址限制每台设备领取
- 05并发与重试处理注意事项
适用场景
- 01仅向 Seeker 持有者开放奖励
- 02限制每台设备领取一次
- 03在服务器端验证钱包所有权
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ solana-mobile/ solana-mobile-skills --skill seeker-genesis-token安装后在 Claude Code 中输入/seeker-genesis-token确认已加载。
详细介绍
Seeker Genesis Token 验证
Seeker Genesis Token(SGT)是每台 Seeker 设备铸造一次的 Token-2022 NFT。持有它即可证明拥有一台 Seeker,因此可用于奖励资格验证和每台设备仅可领取一次的逻辑。
验证分为两部分,两者缺一不可:
- 证明用户控制该钱包——使用 Solana 登录(SIWS)
- 证明该钱包持有 SGT——检查钱包的 Token-2022 铸币记录
只做第二步,任何人都可以提交真实 Seeker 所有者的公开地址并通过验证。只做第一步,则只能证明用户控制该钱包,无法证明其拥有设备。
必须在服务器上运行
切勿在客户端判断是否具备资格。 客户端可以被修改,因此客户端的 hasSGT 布尔值毫无意义。客户端的任务是收集签名;由服务器验证签名并掌握验证结果。
要求:
- 由你控制且能够调用 Solana 主网 RPC 的后端
- 用于存储 nonce 和领取记录的存储空间
- 主网 RPC 端点。付费服务会有所帮助,因为检查需要读取钱包持有的每个 Token-2022 账户;密钥只能保存在服务器端,绝不能放在
EXPO_PUBLIC_*变量中
前提条件
已建立可用的钱包连接。如果应用尚未建立连接,请先使用 solana-mobile-wallet 技能;本技能假设已有 useMobileWallet() 可用。
测试需要一台实体 Seeker 设备——模拟器无法持有 SGT。请规划一条无需实体设备也能测试的代码路径,例如在开发环境中使用服务器端允许列表。
第 1 步:由服务器签发载荷
服务器签发完整的 SIWS 载荷,而不只是 nonce,并将其按 nonce 存储一段较短的有效期。客户端提供的每个字段都由攻击者选择,而且会直接用于签名验证,因此应在签发时固定所有字段。
// 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
nonce 必须由服务器生成、仅能使用一次且有效期短。由客户端生成或可重复使用的 nonce 会使签名可被重放,从而让整个验证失去意义。
address 是服务器无法填写的唯一字段。客户端会在第 2 步添加它。
chainId 有意固定为 solana:mainnet,而不是从钱包的 chain 获取:SGT 只存在于主网,因此限定在 devnet 的签名无法证明用户拥有设备。在服务器端签发此字段,才能将其变成保证,而不只是一条注释。
第 2 步:在客户端登录
钱包 hook 中的 signIn 会通过一次提示完成授权并证明所有权。展开服务器签发的载荷并添加地址:
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',
})
只提交这四个字段。不要提交 output.account,服务器也绝不能接受来自客户端的账户对象或公钥——用于验证签名的公钥必须根据服务器即将执行操作的地址推导得出。第 3 步会说明原因。这里的 nonce 仅用作查询键;真正将其绑定起来的是签名验证。
这是完整指定的载荷,而不是简写的 signIn 形式。domain、nonce 和 version 用于防止签名被重放并将其绑定到你的应用,而服务器必须签发这些字段才能确保这一点。solana-mobile-wallet 技能介绍了这两种形式及各自适用的场景。
步骤 3:在服务器上验证签名
npm install @solana/kit @solana/wallet-standard-util
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),
},
)
}
三个形状检查必须放在最前面,因为这些值都由攻击者选择。如果没有这些检查,格式错误的情况就会进入 new Uint8Array(),然后传给 ed25519 验证;结果并不一致:短签名会触发一个关于字节长度的库错误,而非数组类型的 signedMessage 会被转换为空数组,并返回普通的 false。先检查形状,就能对这两种情况给出一致明确的结果,而且不会依赖你无法控制的内部实现。
verifySignIn 会将你传入的字段与签名文本中的字段进行比较,然后使用 account.publicKey 验证签名。**它绝不会检查密钥与地址是否匹配。**从请求正文中获取该密钥后,任意临时密钥对都可以签署一条声明任意地址的消息:签名确实有效,但签署者并非该地址的持有者。
由于服务器签发了 issued,domain 和 chainId 从一开始就已固定。检查时不会用到客户端提供的这两个字段副本,因此无需单独比较域名。如果你仍想添加防御性断言,请与 issued.domain——也就是存储的副本——进行断言,绝不要使用请求中的值。
步骤 4:检查钱包是否持有 SGT
使用 @solana-mobile/skr-genesis-token。
不要自行编写检查逻辑:该软件包由 Solana Mobile 维护,能正确处理复制代码片段时容易遗漏的细节。
npm install @solana-mobile/skr-genesis-token @solana/kit
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)
}
返回铸币地址,而不只是布尔值:铸币地址用于标识设备,也是下文反 Sybil 检查所记录的信息。保留所有铸币地址,而不只是第一个:一个钱包可以持有多个设备对应的 SGT,每个设备都有权申领。hasSeekerGenesisToken 可用于简单的是/否检查,而 isSeekerGenesisToken 用于验证单个铸币地址。
该软件包已经处理了以下事项,无需重复添加:
- **只有当铸币账户的
TokenGroupMember组和MetadataPointer都指向 SGT 组铸币地址时,该铸币地址才是 SGT。**它会从铸币账户字节中解码这些扩展,而不是从名称或链下元数据中读取。 - **余额为零的代币账户会被跳过。**转出 SGT 后,旧账户仍会以余额
0保持开启;若将其计入,就会永远验证所有过去的持有者。 - **不会拒绝被冻结的账户。**合法持有的 SGT 存放在冻结账户中,因此不要额外添加冻结检查。
- **RPC 故障会抛出异常。**应保留异常:在 API 边界将其映射为 503,绝不要映射为空列表,否则服务中断会被误报为“你不拥有 Seeker”。
它会通过一次 getTokenAccountsByOwner 请求读取钱包的 Token-2022 账户,不进行分页,并且只使用标准 RPC 方法,因此任何主网端点都适用。
步骤 5:正确组合这些检查
微妙的 bug 就在这里。verifySignIn 不会将账户密钥绑定到签名消息中的地址,因此服务器必须自行完成绑定:从服务器将要操作的地址派生密钥,并针对同一个地址查询 SGT。
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 }
}
如果针对其他地址检查 SGT,调用者就可以提交真实 Seeker 持有者的地址并使用自己的签名,从而获得访问权限。
Anti-Sybil:每台设备只能领取一次
SGT 按设备发放,因此 铸造地址就是设备身份。应存储该地址,而不是钱包地址——钱包之后可能持有不同的 SGT,而设备的 SGT 也可能在不同钱包之间转移。
强制每台设备只能领取一次的是 UNIQUE 约束,而不是应用代码:
CREATE TABLE claims (
claimed_at timestamptz NOT NULL DEFAULT now(),
mint_address text NOT NULL UNIQUE,
paid_at timestamptz
);
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)
插入记录和发放奖励是两次独立写入,因此需要决定第二次写入失败时如何处理。按当前写法,如果 grantReward 在记录提交后抛出错误,该设备将无法重试:约束会将其判定为已领取并拒绝请求。可以基于 paid_at 为 null 的记录进行重试,也可以让 grantReward 对 mintAddress 具备幂等性,这样重复执行就不会产生额外影响。
这是稳健性问题,而非安全问题:处理不当会导致真实所有者无法领取奖励,但不会让任何人重复领取。
相关技能
solana-mobile-wallet— 钱包连接和两种signIn负载形式seeker-domains—.skr域名解析,Seeker 用户默认拥有该域名
链接
@solana-mobile/skr-genesis-token:https://github.com/solana-mobile/skr-js/tree/main/packages/skr-genesis-token#readme- 检测 Seeker 用户:https://docs.solanamobile.com/react-native/detecting-seeker-users
- Sign-in-with-Solana 规范:https://github.com/phantom/sign-in-with-solana