Solana 交易可以要求多个签名,而常见的假设是签名的人就是付钱的人。★这是两个分开的角色,而有意识地把它们分开,是后端能用的最有用的模式之一。★
机制本身很简单。出问题的地方是 partialSign 的顺序,以及对一笔还没签完的交易怎么调 serialize。
付钱的不是授权的
手续费支付方是 accountKeys[0]。它签名,并且用它的余额覆盖手续费。其余每一个签名者都在授权某件事,但不一定付任何钱。
const message = new TransactionMessage({
payerKey: relayer.publicKey, // ★付手续费★
recentBlockhash: blockhash,
instructions, // ★用户的 authority 在里面签名★
}).compileToV0Message();
const tx = new VersionedTransaction(message);
tx.sign([relayer, userAuthority]);
这正是免 gas 流程得以实现的原因。 ★一个没有 SOL 的用户照样可以行动,因为你的 relayer 覆盖手续费,而用户的密钥提供授权。★ 用户完全不需要一个有余额的钱包就能交互。
它还意味着一把被攻破的 relayer 密钥动不了用户的资金 —— 它只能浪费手续费。角色分离限定了每把密钥能做什么。
partialSign 与签名槽位
当签名者分散在不同地方时,分步签名:
const tx = new Transaction({
feePayer: relayer.publicKey,
blockhash,
lastValidBlockHeight,
}).add(...instructions);
tx.partialSign(relayer); // ★先签,在哪里签都行★
// ... 发给用户,或者发给下一个审批人 ...
tx.partialSign(userAuthority); // ★后签,在别处★
★partialSign 的调用顺序无所谓,但对每一个签名者来说,message 必须完全相同。★ 签名覆盖的是序列化后的 message —— 指令、账户、blockhash。第一个签名之后再改动其中任何一项,那个签名就静默失效了。
这是多签场景最常见的那个 bug。 一个在用户签完之后才加上 setComputeUnitLimit 指令的后端,已经把用户的签名作废了 —— 而失败表现为一个签名校验错误,指向的还是错的那把密钥。
如果你的签名流程没问题、交易还是会错过,免费领个 BoltTx key 改一行就能测测提交路径。
序列化一笔部分签名的交易
默认的序列化会拒绝为一笔不完整的交易产出字节,这是对的,也是每个人第一个撞上的东西:
// ★抛异常:signature verification failed★
// const raw = tx.serialize();
// ★对部分签名交易的正确写法。★
const raw = tx.serialize({
requireAllSignatures: false,
verifySignatures: false,
});
const encoded = raw.toString("base64"); // 把这个发给下一个签名者
接收方那一侧:
const tx = Transaction.from(Buffer.from(encoded, "base64"));
tx.partialSign(nextSigner);
版本化交易的形状不同 —— 签名放在一个按签名者索引定位的数组里:
const tx = VersionedTransaction.deserialize(bytes);
tx.sign([nextSigner]); // ★填自己的槽位,保留其它人的★
★VersionedTransaction.sign 是叠加而不是替换★,所以用一把密钥调用它,不会清掉已经收集到的签名。
检查还缺什么
发送之前,确认每一个必需的槽位都填上了:
const missing = tx.signatures
.filter((s) => s.signature === null)
.map((s) => s.publicKey.toBase58());
if (missing.length) {
throw new Error(`未签名: ${missing.join(", ")}`);
}
对版本化交易,和 header 里的数量比对:
const required = tx.message.header.numRequiredSignatures;
const present = tx.signatures.filter((s) => s.some((b) => b !== 0)).length;
★一个全零的签名是空槽位,不是签名。★ 带着空槽位发送会产出签名校验失败 —— 而它读起来像是密钥有问题,而不是缺了一个审批。
blockhash 是所有人的共同截止时间
多签流程继承了和其它交易一样的过期规则,而这正是这类流程难做的原因。
★从取到 blockhash,到最后一个签名到达并把交易发出去,大约 150 个区块 —— 一分钟左右。★ 一个在别的时区的人工审批人赶不上这个窗口。
两种设计,而第二种通常更好:
最后才签。 先收集意图,只在最后一个批准到达时才构建真正的已签名交易。窗口从末尾开始,而不是从开头。
用 durable nonce。 当签名确实无法按需重新生成时 —— 放在保险库里的硬件钱包、离线签名者 —— nonce 会完全移除这个截止时间。
尽可能晚地取 blockhash。 如果所有签名者都在线,在第一个签名之前才取,而不是在流程一开始就取。
各种失败分别指向什么
| 症状 | 真正的原因 |
|---|---|
Missing required signature |
★有个槽位从没被填上★ |
| 签名校验失败 | ★签完之后 message 变了★ |
Blockhash not found |
收集签名超出了窗口 |
| serialize 抛异常 | 需要 requireAllSignatures: false |
| 付款账户不对 | 手续费支付方不在索引 0 |
★第二行是最浪费时间的那个★,因为报错点名的那把密钥完全有效。它确实签了 —— 它签的是一个和你提交的不同的 message。
上链应该是什么水平
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
★收集签名花多久就是多久;落块才是那个能被测量的部分。★ 对一个已经花了一分钟凑审批的流程来说,再因为提交路径慢而丢掉这笔交易,是可以避免的那一半。
BoltTx 在这里的位置
我们负责提交。签名完全归你,不管它涉及多少把密钥。
提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。当它是一笔几个人刚刚批准的金库划转时,这一点很重要。
你在本地签名。我们不托管资金、不代签、不修改交易内容 —— 这正是我们在多签流程里安全的原因,因为任何修改都会作废已经收集到的每一个签名。tip 带在交易里、从手续费支付方的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
常见问题
Solana 的 partialSign 是什么? 一个往交易里加一个签名、而不要求其它签名到位的方法。它让你可以从分散在不同地方的密钥逐个收集签名,然后再提交。
手续费支付方可以和签名者不是同一个吗?
可以,而且分开是有意为之。支付方是 accountKeys[0] 并覆盖手续费,其余签名者授权动作但不付钱。这正是免 gas 流程得以实现的原因。
怎么让用户没有 SOL 也能交易? 让你的 relayer 当手续费支付方,由用户的密钥作为 authority 签名。用户授权动作,你的密钥付手续费,所以他们永远不需要一个有余额的钱包。
部分签名的交易调 serialize 为什么抛异常?
因为它默认会校验签名。传 requireAllSignatures: false 和 verifySignatures: false,就能为一笔仍在收集审批的交易产出字节。
partialSign 的调用顺序重要吗? 不重要,但对每个签名者来说 message 必须完全相同。在某人签完之后再加一条指令会作废他的签名 —— 这是多签最常见的 bug。
密钥明明是对的,签名校验为什么失败? 那把密钥签的是一个和你提交的不同的 message。签完之后有东西变了 —— 常见的是后端加了一条 compute budget 指令 —— 所以签名对不上了。
怎么把一笔部分签名的交易发给另一方?
用 requireAllSignatures: false 序列化、编码成 base64,对方用 Transaction.from 还原。版本化交易用 VersionedTransaction.deserialize。
给版本化交易签名会清掉其它签名吗?
不会。VersionedTransaction.sign 只填签名者自己的槽位,已有的签名原封不动,所以各方可以依次调用它。
怎么检查还缺哪些签名?
检查 tx.signatures 里签名为 null 或全零的条目。那些是空槽位,带着它提交会产出校验失败,而不是一条清晰的提示。
收集签名有多长时间? 从取到 blockhash 起大约 150 个区块 —— 一分钟左右。不同时区的人工审批人塞不进这个窗口,那正是需要 durable nonce 的时候。
多签该用 durable nonce 吗? 只有当"可靠地收齐所有签名"确实比 blockhash 窗口更久时才该。如果你能在最后一个批准到达那一刻才产出已签名交易,普通 blockhash 更简单。
有人签完之后我还能改交易吗? 不能。签名覆盖的是序列化后的 message,所以任何改动都会静默作废已经收集到的每一个签名。要在请求第一个签名之前就把指令集定稿。
手续费支付方永远在索引 0 吗? 是。它永远是签名者、永远可写、永远排第一。 如果付款账户不对,那就是要检查的位置。
一笔交易能有多少个签名者? 够实际使用,受 1232 字节上限约束、每个签名 64 字节。签名字节通常比每个账户 key 的 32 字节更不容易成为瓶颈。
替用户付手续费的 relayer 能看到用户私钥吗? 不能。用户用自己的密钥在本地签名,然后把一笔部分签名的交易发给你。你的 relayer 作为手续费支付方加上自己的签名,全程不接触用户密钥。
手续费支付方 SOL 不够会怎样?
交易在执行之前就失败。构建之前先用 getBalance 查支付方余额,因为这种失败看起来和其它执行前拒绝一模一样。