fuels-rs 签名实践指南:Message 签名、Transaction Builder 挂载 Signer 与已构建交易的 sign_with
【免费下载链接】fuels-rsFuel Network Rust SDK项目地址: https://gitcode.com/GitHub_Trending/fu/fuels-rs
fuel-rs(Fuel Network Rust SDK)将「签名」抽象为统一的Signertrait,无论是钱包直持私钥、KMS 托管密钥还是测试用的 Fake 签名器,都可以用同一套调用方式完成消息签名与交易签名。本文以 docs/src/wallets/signing.md 为主线,结合 packages/fuels-accounts 与 packages/fuels-core 的源码实现,系统讲解在 fuels-rs 中如何用任意Signer签名一条消息、如何将Signer挂载到 Transaction Builder 并让 SDK 自动解析见证(witness),以及如何对已构建完成的交易通过sign_with追加签名。读完本文你将能在自己的钱包/签名器实现上复现完整的「签名—恢复公钥—验证」闭环,并理解 witness index 与 witness 顺序为何决定交易能否被链上校验通过。
签名器的统一抽象:Signertrait
在进行任何签名之前,先理解 SDK 的签名入口。fuels-rs 将签名能力收敛到位于 packages/fuels-core/src/traits/signer.rs 的一个异步 trait:
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] #[auto_impl(&, Box, Rc, Arc)] pub trait Signer { async fn sign(&self, message: Message) -> Result<Signature>; fn address(&self) -> Address; }两个核心方法语义如下:
async fn sign(&self, message: Message) -> Result<Signature>:对给定的fuel_crypto::Message进行签名并返回签名结果。注意message的类型不是字节数组,而是密码学层统一包装的Message,通常由Message::new(...)或Message::from_bytes(...)构造。fn address(&self) -> Address:返回该签名器对应的 Fuel 地址,用于标识「谁签的」。
trait 上的auto_impl(&, Box, Rc, Arc)意味着&Signer、Box<dyn Signer>、Arc<dyn Signer>等包装形态自动实现同一 trait,因此你在交易构建器里存入一个Arc<dyn Signer>后,仍然可以直接调用sign。这也是 docs/src/wallets/signing.md 开头所说「可以用 SDK 提供的任意 signer,也可以自己实现Signer」——接入一个新的签名后端(硬件钱包、托管服务等)只需要实现上面这两个方法。
SDK 现成的签名器实现位于 packages/fuels-accounts/src/signers 目录下:
- private_key.rs:
PrivateKeySigner,直接用私钥签名(详见 private_key_signer.md); - fake.rs:
FakeSigner,生成伪签名用于本地测试(详见 fake_signer.md); - kms.rs 与 kms/aws.rs、kms/google.rs:分别委托 AWS / Google KMS 托管签名(详见 kms.md)。
不同签名器在 wallets/index.md 的「Signer Options」一节有横向对照,可根据「是否持有私钥明文、是否需要云端 KMS、是否仅用于本地 mock」来选型。更多关于账户体系与只读/解锁钱包的区分,可参考 accounts.md 与 access.md。
用Signer签名一条消息
消息签名是理解签名流程的最佳起点:它直接演示了sign的输入输出,以及 Fuel 密码学层如何验证一份签名的归属。下面这段代码取自 packages/fuels-accounts/src/signers/private_key.rs 中sign_and_verify测试用例(对应文档中通过sign_messageanchor 引入的示例),它完整走了一遍「构造私钥 → 创建签名器 → 签名 → 校验期望值 → 恢复公钥 → 验证」的链路:
let mut rng = StdRng::seed_from_u64(2322u64); let mut secret_seed = [0u8; 32]; rng.fill_bytes(&mut secret_seed); let secret = secret_seed.as_slice().try_into()?; // Create a signer using the private key created above. let signer = PrivateKeySigner::new(secret); let message = Message::new("my message".as_bytes()); let signature = signer.sign(message).await?; // Check if signature is what we expect it to be assert_eq!( signature, Signature::from_str( "0x8eeb238db1adea4152644f1cd827b552dfa9ab3f4939718bb45ca476d167c6512a656f4d4c7356bfb9561b14448c230c6e7e4bd781df5ee9e5999faa6495163d" )? ); // Recover the public key that signed the message let recovered_pub_key: PublicKey = signature.recover(&message)?; assert_eq!(*signer.address, *recovered_pub_key.hash()); // Verify signature signature.verify(&recovered_pub_key, &message)?;几个值得展开的细节:
Message与Signature均来自fuel_crypto。Message::new("my message".as_bytes())负责把原文规整为密码学运算所需的消息格式;Signature则同时支持序列化(from_bytes)与十六进制字符串互转(from_str/to_string),方便跨进程传递或持久化。- 私钥到签名器的转换:
secret_seed.as_slice().try_into()?把 32 字节的随机种子转换为SecretKey,再交给PrivateKeySigner::new。真实项目中私钥通常来自助记词(如generate_mnemonic_phrase配合SecretKey::new_from_mnemonic_phrase_with_path,见同文件测试)或 KMS。 - 确定性断言:测试用固定随机种子
2322u64生成确定的私钥,因此签名结果必须精确等于那串固定的十六进制值。这既验证了 SDK 签名的可复现性,也提醒你在把签名写进存储前先做同样严格的校验。 - 签名可以反推出签名者:
signature.recover(&message)返回签名所用公钥,其哈希与signer.address相等——这正是链上/钱包侧「由签名反查地址」的原理。随后signature.verify(&recovered_pub_key, &message)用恢复出的公钥做最终验证,形成闭环。
注意该签名是对任意消息的签名(例如用于账户所有权证明、授权登录等场景),而交易签名略有不同:签名对象是交易 ID 而非原始字节,这也是下一节为何强调「先定好所有 witness index 再签名」的原因。
把Signer挂载到 Transaction Builder
交易签名的复杂性在于:每一个需要签名的资源输入(input)都必须携带一个指向有效 witness 的witness_index,而改动 input 里的witness_index会使交易 ID 发生变化。也就是说,witness index 必须全部在最终签名前确定,且 witness 的排列顺序要与 index 严格对应,否则签出来的交易校验不过。
fuels-rs 的做法是:SDK 在 Transaction Builder 内部替你把签名器缓存起来(add_signer),并在调用build()构建最终交易时自动完成「计算 witness index → 生成交易 ID → 逐一对齐签名 → 写入 witness 列表」的整套解析。
从文档 anchor 提取的完整示例
该示例对应 packages/fuels-accounts/src/account.rs 中sign_tx_and_verify测试里被sign_tbanchor 包裹的代码,展示了「手动构造一笔转账 → 添加签名器」的最小完整流程:
let secret = SecretKey::from_str( "5f70feeff1f229e4a95e1056e8b4d80d0b24b565674860cc213bdb07127ce1b1", )?; let signer = PrivateKeySigner::new(secret); // Set up a transaction let mut tb = { let input_coin = Input::ResourceSigned { resource: CoinType::Coin(Coin { amount: 10000000, owner: signer.address(), ..Default::default() }), }; let output_coin = Output::coin( Address::from_str( "0xc7862855b418ba8f58878db434b21053a61a2025209889cc115989e8040ff077", )?, 1, Default::default(), ); let change = Output::change(signer.address(), 0, Default::default()); ScriptTransactionBuilder::prepare_transfer( vec![input_coin], vec![output_coin, change], Default::default(), ) }; // Add `Signer` to the transaction builder tb.add_signer(signer.clone())?;解读这段代码的三层含义:
prepare_transfer是构造脚本交易的高层入口,它接收资源输入(input_coin,携带金额10000000与所有者地址)、目标输出与找零输出,返回一个ScriptTransactionBuilder。- 资源被标记为
Input::ResourceSigned,表示该 coin 的支出需要签名授权——这类输入正是「需要 witness index 指向有效 witness」的对象。 tb.add_signer(signer.clone())把签名器登记进 builder。builder 内部会持有这份签名器,等待build()时使用。之所以是clone,是因为交易构建后你可能还需要保留原签名器继续操作。
add_signer并非ScriptTransactionBuilder的私有魔法,而是定义在一组可复用 trait 上:在 packages/fuels-core/src/types/transaction_builders.rs 中可以看到fn add_signer(&mut self, signer: impl Signer + Send + Sync + 'static) -> Result<&mut Self>以及批量版的add_signers<'a>(接收&Arc<dyn Signer + Send + Sync>迭代器)。impl Signer + Send + Sync + 'static意味着你既可以把PrivateKeySigner这样的具名类型直接塞进去,也可以塞一个Arc<dyn Signer + Send + Sync>形式的动态签名器。在合约调用层 packages/fuels-programs/src/calls/call_handler.rs 中还有同名方法,用于把签名器绑定到某个具体的合约方法调用上,最终同样汇入交易构建流程。
关键约束:不调build()交易不会解析
Note: When you add a
Signerto a transaction builder, the signer is stored inside it and the transaction will not be resolved until you callbuild()!
这是官方文档特别标注的约束:add_signer只负责暂存签名器,不产生任何签名副作用。交易的真实形态(witness 数组、各 input 的 witness index)要到build()那一刻才被确定并解析出来。在上述测试的后续代码中,tb.build(MockDryRunner::default()).await?完成了这一解析,随后用断言验证了「builder 自动写入的 witness 中提取出的签名」与「手动用交易 ID 签出的签名」完全一致:
let tx = tb.build(MockDryRunner::default()).await?; // Resolve signatures and add corresponding witness indexes // Extract the signature from the tx witnesses let bytes = <[u8; Signature::LEN]>::try_from(tx.witnesses().first().unwrap().as_ref())?; let tx_signature = Signature::from_bytes(bytes); // Sign the transaction manually let message = Message::from_bytes(*tx.id(0.into())); let signature = signer.sign(message).await?; // Check if the signatures are the same assert_eq!(signature, tx_signature);这段代码反向印证了前文原理:
- 手动签名时,签名对象是
tx.id(0.into())产生的交易 ID,而不是交易字节本身; - builder 自动解析出的 witness 签名与「用最终交易 ID 手动签名」的结果逐一相等,证明 SDK 的内部顺序是正确的;
- 提取出的签名还能继续
recover出签名地址,与signer.address()比对(见该测试后半部分),从而把「自动解析」和「账户身份」两个维度都校验到位。
顺带一提,测试中用于build的MockDryRunner只是让交易在无网络环境下也能完成 gas 估算与签名解析;真实场景下你会传入实现了DryRunner的Provider,例如从钱包获取的provider。
对已构建交易追加签名:sign_with
有些场景下你已经拥有一笔构建好的交易(比如先把签名策略设为「不带签名」做预演或传输,或者把交易在网络间传递后再补签),此时不必回到 builder 重新走一遍流程,可以直接在交易对象上调用sign_with。
官方文档给出的示例来自 e2e/tests/contracts.rs 中被tx_sign_withanchor 包裹的代码,它先从钱包拿到一个合约调用的transaction_builder,用ScriptBuildStrategy::NoSignatures明确跳过签名解析构建出原始交易,再用sign_with手动补签:
let mut tx = tb .with_build_strategy(ScriptBuildStrategy::NoSignatures) .build(provider) .await?; tx.sign_with(wallet.signer(), consensus_parameters.chain_id()) .await?;这里有两个参数值得单独说明:
- 第一个参数是签名器:示例用的是
wallet.signer()——Wallet内部持有并暴露其签名器,返回类型同样实现Signer(完整的Wallet/Account能力说明见 wallets/index.md)。 - 第二个参数是
chain_id:签名需要先算出交易 ID,而交易 ID 的计算依赖链的 ID(网络区分参数),因此必须传入consensus_parameters.chain_id()。代码中provider.consensus_parameters().await?从链上拉取共识参数,再取其中的chain_id。如果你传错 chain ID,签名将对应错误的交易 ID,链上校验必然失败。
从类型定义看,sign_with位于 packages/fuels-core/src/types/wrappers/transaction.rs,其签名为:
async fn sign_with( &mut self, signer: &(impl Signer + Send + Sync), chain_id: ChainId, ) -> Result<Signature>;它接收任何实现Signer的引用,返回追加的Signature。注意签名后的交易tx之后被直接provider.send_transaction(tx).await?送出(示例后续代码),说明该交易已经具备了完整可提交的 witness 列表,无需再次构建。这也正是「先NoSignatures构建、后sign_with补签」策略的典型用途:让同一笔交易在不同阶段由不同密钥补齐签名,或先完成费用估算、input 调整等会改变交易 ID 的操作,最后一步才真正落笔签名。
三种签名方式的选型小结
| 场景 | 推荐 API | 关键点 |
|---|---|---|
| 对任意消息做签名证明(非交易) | signer.sign(message) | 直接调用Signer::sign,配合recover/verify完成校验 |
| 从零构造交易并由 SDK 自动处理签名 | TransactionBuilder+add_signer+build() | 签名器被暂存在 builder,witness index 与 witness 顺序由build()自动解析,务必记得调用build() |
| 对已构建的裸交易补签名 | 交易对象上的sign_with(signer, chain_id) | 需要提供正确的chain_id;适用于ScriptBuildStrategy::NoSignatures之类的无签名构建后延迟补签 |
三者共用同一Signertrait,因此签名器可以在不同模式间自由复用:本地开发用PrivateKeySigner,云上托管用 KMS Signer,纯测试可换FakeSigner,甚至自定义签名后端——只要实现了async fn sign与fn address两个方法,就能无缝接入上述全部流程。
深入阅读
- 签名文档原文与相邻章节:signing.md、wallets/index.md、accounts.md
- 各签名器实现:private_key.rs、fake.rs、kms.rs(含 kms/aws.rs、kms/google.rs)
Signertrait 定义:packages/fuels-core/src/traits/signer.rs- 交易构建器上的
add_signer/add_signers接口:packages/fuels-core/src/types/transaction_builders.rs - 已构建交易上的
sign_with:packages/fuels-core/src/types/wrappers/transaction.rs - 端到端验证用例:e2e/tests/contracts.rs 中的
tx_sign_with、packages/fuels-accounts/src/account.rs 中的sign_tx_and_verify、packages/fuels-accounts/src/signers/private_key.rs 中的sign_and_verify
【免费下载链接】fuels-rsFuel Network Rust SDK项目地址: https://gitcode.com/GitHub_Trending/fu/fuels-rs
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考