mcpField
S
seeker-genesis-token

seeker-genesis-token

github.com/solana-mobile安全812.6k 次安装1 周前更新
代码仓库

关于 seeker-genesis-token

Seeker Genesis Token verification 是一项技能,用于在服务器端验证用户控制 Solana 钱包并持有 Seeker Genesis Token。它说明基于一次性 nonce 的 SIWS 验证及按设备限制领取,适用于奖励准入等流程。

主要功能

  1. 01服务器签发一次性 SIWS nonce
  2. 02钱包签名验证指南
  3. 03主网 SGT 持有情况检查
  4. 04使用 mint 地址限制每台设备领取
  5. 05并发与重试处理注意事项

适用场景

  1. 01仅向 Seeker 持有者开放奖励
  2. 02限制每台设备领取一次
  3. 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,因此可用于奖励资格验证和每台设备仅可领取一次的逻辑。

验证分为两部分,两者缺一不可:

  1. 证明用户控制该钱包——使用 Solana 登录(SIWS)
  2. 证明该钱包持有 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 用户默认拥有该域名

链接

评论

支持 Markdown