对消息签名可以证明用户控制着某个钱包,无需交易、无需手续费,也无需任何网络调用。应用可以使用已签名的消息让用户登录、记录用户已接受条款、将钱包关联到现有账户,或授权发生在链下的操作(例如下单)。
Solana 钱包通过两种方式对消息签名:
- 链下消息(
solana:signOffchainMessage)会将你的文本包装为标准格式,该格式由链下消息签名(OCMS)规范定义。这种格式绝不会被误认为交易,会指明必须签名的账户,并且是硬件钱包所签名的格式。 - 原始消息(
solana:signMessage)会对应用传入的字节原样签名,没有前缀,也不声明签名者。大多数软件钱包都支持这种方式,但这些字节本身并不能表明它是消息而不是交易。
在钱包支持链下消息时使用链下消息,不支持时回退到原始消息。本指南的其余部分以登录为例,但相同的签名和验证步骤适用于任何消息。
选择消息格式
| 链下消息 | 原始消息 | 交易 | |
|---|---|---|---|
| 钱包功能 | signOffchainMessage | signMessage | signTransaction |
| 硬件钱包支持 | 取决于设备 | 否 | 是 |
| 用户可读 | 是 | 仅限软件钱包 | 取决于设备 |
| 声明所需签名者 | 是 | 否 | 是 |
| RPC 调用和手续费 | 无 | 无 | 获取 blockhash、发送、手续费、确认 |
为什么不用交易?
过去,硬件钱包无法对可读消息签名。许多应用转而要求这些用户签署一笔空操作交易。这种做法虽然可行,但会增加延迟、成本和风险:
- 网络手续费。 交易需要支付手续费,即使它什么都不做。
- 多次 RPC 调用。 你的应用需要获取 blockhash、模拟、发送,然后轮询确认结果。
- 额外延迟。 在用户登录之前,每一次调用都是一次往返。
- 内容不可读。 用户批准的是交易字节,而不是对其所同意内容的说明。
- 行为风险。 仅为登录就要求用户签署交易,会让他们更容易被诱骗去签署有害交易。
链下消息可以省去这一切。唯一的往返只发生在你自己的服务器上。
钱包签名的内容
链下消息以固定的签名域 \xffsolana offchain 开头。任何有效交易都不会以这些字节开头,因此已签名的消息绝不可能被用作交易。格式的版本 1 随后会列出所需的签名者,并以 UTF-8 文本承载消息,不限制长度。
钱包构建这些字节并对其签名,然后将它们与签名一起返回。规范涵盖了完整的格式,包括旧版本 0。
选择登录方式
并非所有钱包都已支持链下消息。请在请求签名之前检查已连接账户的功能,并在缺少该功能时进行回退:
- 如果账户支持消息版本 1 的
solana:signOffchainMessage,则对链下消息签名。 - 否则,使用
solana:signMessage对同一个挑战消息签名。大多数软件钱包都支持该功能。 - 为不支持消息签名的硬件钱包用户提供明确的选择加入方式,例如“我正在使用硬件钱包”开关,改为签署一笔登录交易。不要默认将用户引向这条路径。
在浏览器中签名消息
在安装 Kit 的同时安装 Wallet Standard 软件包:
$npm install @solana/kit @solana/wallet-standard-features @wallet-standard/ui @wallet-standard/ui-features @wallet-standard/ui-registry
这些辅助函数接受 UiWalletAccount,例如 React 指南中 useConnectedWallet 返回的 account:
import {SolanaSignOffchainMessage,type SolanaSignOffchainMessageFeature} from "@solana/wallet-standard-features";import type { UiWalletAccount } from "@wallet-standard/ui";import { getWalletAccountFeature } from "@wallet-standard/ui-features";import { getWalletAccountForUiWalletAccount } from "@wallet-standard/ui-registry";type OffchainMessageFeature =SolanaSignOffchainMessageFeature[typeof SolanaSignOffchainMessage];export function supportsOffchainMessages(account: UiWalletAccount) {if (!account.features.includes(SolanaSignOffchainMessage)) return false;const feature = getWalletAccountFeature(account,SolanaSignOffchainMessage) as OffchainMessageFeature;return feature.supportedMessageVersions.includes(1);}export async function signOffchainMessage(account: UiWalletAccount,message: string) {const feature = getWalletAccountFeature(account,SolanaSignOffchainMessage) as OffchainMessageFeature;const [output] = await feature.signOffchainMessage({messageVersion: 1,account: getWalletAccountForUiWalletAccount(account),message,requiredSigners: [account.publicKey]});return output;}
signOffchainMessage 会返回 signedOffchainMessage(钱包所签名的完整字节)和 signature。将两者进行 Base64 编码,并与钱包地址一起发送到你的服务器。
撰写挑战消息时,要确保它在小屏幕上也易于阅读。写明你的应用名称和域名,说明签名的作用,并包含由服务器生成的一次性 nonce 和签发时间:
swap.app wants you to sign in with your Solana account.Signing in costs nothing and does not submit a transaction.Nonce: 8f3a1c9eIssued at: 2026-09-29T17:04:00Z
在服务器上验证签名
验证是本地的签名检查,不需要 RPC 连接。解码钱包所签名的字节,确认它们与你签发的挑战消息一致,然后检查签名:
import {address,assertOffchainMessageV1Equal,getBase64Encoder,getOffchainMessageDecoder,verifyOffchainMessageEnvelope,type OffchainMessageBytes,type SignatureBytes} from "@solana/kit";export async function verifySignIn(input: {walletAddress: string;expectedMessage: string;signedOffchainMessage: string;signature: string;}) {const signer = address(input.walletAddress);const base64 = getBase64Encoder();const content = base64.encode(input.signedOffchainMessage) as OffchainMessageBytes;const signature = base64.encode(input.signature) as SignatureBytes;const received = getOffchainMessageDecoder().decode(content);if (received.version !== 1) {throw new Error(`Expected a v1 message, got v${received.version}`);}assertOffchainMessageV1Equal(received, {version: 1,requiredSignatories: [{ address: signer }],content: input.expectedMessage});await verifyOffchainMessageEnvelope({content,signatures: { [signer]: signature }});}
如果内容或签名者与预期不一致,assertOffchainMessageV1Equal 会抛出错误。如果签名缺失或无效,verifyOffchainMessageEnvelope 会抛出错误。请在服务器上通过 nonce 查找 expectedMessage,而不要信任客户端回传的副本。验证通过后立即消耗该 nonce,以免同一签名被用于重复登录。
验证原始消息
signMessage 的签名直接覆盖挑战消息的字节,因此客户端只需发送签名和钱包地址。请对照你签发的挑战消息进行检查:
import {address,getBase64Encoder,getPublicKeyFromAddress,getUtf8Encoder,signatureBytes,verifySignature} from "@solana/kit";export async function verifyRawSignIn(input: {walletAddress: string;expectedMessage: string;signature: string;}) {const publicKey = await getPublicKeyFromAddress(address(input.walletAddress));const signature = signatureBytes(getBase64Encoder().encode(input.signature));const message = getUtf8Encoder().encode(input.expectedMessage);if (!(await verifySignature(publicKey, signature, message))) {throw new Error("Invalid signature");}}
同样适用 nonce 规则:通过 nonce 查找 expectedMessage,并在验证通过后消耗该 nonce。
@solana/kit 从 @solana/offchain-messages 重新导出了这些辅助函数。如果你的服务器不依赖 Kit,请使用独立的软件包。
使用 Solana 登录
使用 Solana 登录(SIWS)对挑战消息本身进行了标准化。你的应用将 domain、statement、nonce 和 issuedAt 等结构化字段传递给钱包的 solana:signIn 功能。钱包会将它们格式化为标准消息,在需要时建立连接,并一步完成签名。
solana:signIn 的 1.1.0 版本新增了 useOffchainMessage 选项。当你传入 useOffchainMessage: { messageVersion: 1 } 时,钱包会将 SIWS 文本包装为版本 1 的链下消息,并以签名账户作为唯一的所需签名者。硬件钱包可以显示并签署该消息,输出中会报告 signedMessageFormat: { kind: "offchainMessage", messageVersion: 1 }。声明版本为 1.0.0 的钱包不识别该选项,因此请先检查功能版本,否则不要传入该选项。
solana:signIn 是钱包功能而非账户功能,因此这些辅助函数接受 UiWallet:
import {SolanaSignIn,type SolanaSignInFeature,type SolanaSignInInput} from "@solana/wallet-standard-features";import type { UiWallet } from "@wallet-standard/ui";import { getWalletFeature } from "@wallet-standard/ui-features";type SignInFeature = SolanaSignInFeature[typeof SolanaSignIn];export function supportsOffchainSignIn(wallet: UiWallet) {if (!wallet.features.includes(SolanaSignIn)) return false;const feature = getWalletFeature(wallet, SolanaSignIn) as SignInFeature;return feature.version === "1.1.0";}export async function signIn(wallet: UiWallet, input: SolanaSignInInput) {const feature = getWalletFeature(wallet, SolanaSignIn) as SignInFeature;const [output] = await feature.signIn({...input,...(supportsOffchainSignIn(wallet) && {useOffchainMessage: { messageVersion: 1 }})});return output;}
在服务器上构建输入,包含一次性的 nonce 和 issuedAt 时间,并保留一份以 nonce 为键的副本。登录验证通过后立即消耗该 nonce。
如果不使用 Wallet Adapter,来自 @solana/wallet-standard-util 的 verifySignIn 只会检查纯 SIWS 输出。当 signedMessageFormat 报告为链下消息时,signedMessage 是内容为 SIWS 文本的链下消息。请使用 @solana/wallet-standard-util 中的 createSignInMessageText,根据你存储的输入和签名地址重建预期文本,然后像验证其他链下消息一样进行验证,详见在服务器上验证签名。@solana/offchain-messages README 介绍了在 Kit 中实现此流程的方法。
面向钱包
钱包通过实现 @solana/wallet-standard-features 中的 solana:signOffchainMessage Wallet Standard 功能来添加支持:
- 报告
version: "1.0.0",并在supportedMessageVersions中列出你能够签名的消息版本。目前为[1]。 - 根据
requiredSigners构建版本 1 的前导部分(preamble),需对其排序并检查是否有重复项。如果该账户不在必需签名者之列,则拒绝该请求。 - 在签名前,向用户完整显示消息文本,硬件设备上也不例外。
- 将您签名的确切字节作为
signedOffchainMessage返回,同时返回 Ed25519signature。 - 若要支持链下 Sign In With Solana,请为
solana:signIn报告version: "1.1.0"。当输入包含useOffchainMessage时,请将 SIWS 文本作为版本 1 链下消息进行签名,将其字节作为signedMessage返回,并设置signedMessageFormat。 - 请继续支持
solana:signMessage。该方法没有变化,应用仍将其用作后备方案。
Is this page helpful?