Mina devnet 发送支付交易时出现 Invalid_signature
结论
Invalid_signature 表示节点收到的交易内容与签名时使用的内容不一致,或者双方采用了不同的签名域。在 Mina devnet 上,常见原因包括:
MinaSDK签名客户端没有使用 devnet 对应的testnetnetwork identifier。sender与用于签名的私钥不匹配。/broadcast/transaction服务在转发前修改了amount、fee、nonce、memo等签名覆盖字段。- 签名库与广播服务使用的交易格式或 Mina 协议版本不兼容。
HTTP 200 只能说明 GraphQL 请求已成功到达节点,交易是否提交成功要看响应中的 errors。因此,这不是余额不足或 nonce 冲突之类的普通验证错误,而是密码学签名验证失败。
首先检查网络标识
Mina 的交易签名包含网络域隔离。同一笔交易分别使用 mainnet 和 testnet 签名,得到的签名并不相同。devnet 通常应使用 testnet:
import Client from 'mina-signer';
const minaSigner = new Client({
network: 'testnet',
});
签名时也要使用这个客户端:
const signedPayment = minaSigner.signPayment(
{
to: receiver,
from: sender,
amount: 1_000_000_000, // 1 MINA,单位为 nanomina
fee: 10_000_000, // 0.01 MINA
nonce: Number(account.nonce),
memo: '',
},
privateKey,
);
不要因为网络名称是 devnet 就直接传入 "devnet",除非当前 SDK 版本明确支持该值。不同版本的构造参数可能不同,应以所用版本的类型定义和文档为准。签名端与验证端必须使用相同的 network identifier。
确认私钥确实属于 sender
from 地址必须能由传入 signPayment 的私钥派生出来。把某个地址填入 from 字段,并不能让另一把私钥为它生成有效签名。
const derivedSender = minaSigner.derivePublicKey(privateKey);
if (derivedSender !== sender) {
throw new Error(
`Private key does not match sender: expected ${sender}, got ${derivedSender}`,
);
}
如果 keys 是一个对象,需要确认 MinaSDK.signPayment(..., keys) 接收的是完整 key pair、私钥字符串,还是其他数据结构。具体约定取决于封装库。mina-signer 的常见接口是直接传入私钥字符串。
在广播前本地验证签名
如果 SDK 提供 verifyPayment,可以在发送前立即验证签名:
const payment = {
to: receiver,
from: sender,
amount: 1_000_000_000,
fee: 10_000_000,
nonce: Number(account.nonce),
memo: '',
};
const signedPayment = minaSigner.signPayment(payment, privateKey);
const valid = minaSigner.verifyPayment(signedPayment);
if (!valid) {
throw new Error('Payment signature failed local verification');
}
验证结果可以这样判断:
- 本地验证失败:检查 network identifier、私钥、SDK 调用方式和字段类型。
- 本地验证成功,但节点验证失败:重点检查广播服务是否重新构造或修改了交易,以及签名库与节点是否兼容。
保证签名内容与广播内容完全一致
Mina 签名覆盖的字段不只有 from、to 和 amount。以下任何字段发生变化,都可能触发 Invalid_signature:
feefeePayernoncevalidUntilmemotokenIdamountsourcereceiver- network identifier
签名完成后尤其不要再进行单位换算。Mina SDK 通常使用 nanomina:
1 MINA = 1,000,000,000 nanomina
0.01 MINA = 10,000,000 nanomina
原代码中的:
amount: 1,
fee: 10000000,
通常表示发送 1 nanomina,手续费为 0.01 MINA,并不是发送 1 MINA。如果中间的广播服务把 amount: 1 当作 1 MINA,再据此生成 GraphQL 请求,节点收到的 payload 就会与签名内容不同。
问题日志中的节点 payload 显示:
{
"fee": "0.01",
"amount": "1"
}
这很可能说明广播层进行了显示单位转换。需要确认转换后的二进制交易内容与签名时使用的 nanomina 值完全一致,而不是误将 1 nanomina 转换成了 1 MINA。
修正后的示例
以下示例使用常见的 mina-signer 接口。广播接口的请求体格式仍需符合实际 /broadcast/transaction 服务的约定:
import Client from 'mina-signer';
const minaSigner = new Client({
network: 'testnet',
});
async function transfer(
privateKey: string,
receiver: string,
amountNanomina: number,
) {
const sender = minaSigner.derivePublicKey(privateKey);
const accountResponse = await fetch(
`${process.env.MINA_REST_URL}/account/${sender}`,
);
if (!accountResponse.ok) {
throw new Error(`Failed to load account: ${accountResponse.status}`);
}
const accountJson = await accountResponse.json();
const nonce = Number(accountJson.account.nonce);
if (!Number.isSafeInteger(nonce)) {
throw new Error(`Invalid nonce: ${accountJson.account.nonce}`);
}
const payment = {
to: receiver,
from: sender,
amount: amountNanomina,
fee: 10_000_000,
nonce,
memo: '',
};
const signedPayment = minaSigner.signPayment(payment, privateKey);
if (!minaSigner.verifyPayment(signedPayment)) {
throw new Error('Local signature verification failed');
}
const response = await fetch(
`${process.env.MINA_REST_URL}/broadcast/transaction`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(signedPayment),
},
);
const result = await response.json();
if (!response.ok || result.errors) {
throw new Error(JSON.stringify(result));
}
return result;
}
// 发送 1 MINA
await transfer(privateKey, receiver, 1_000_000_000);
进一步排查顺序
可以按以下顺序排查:
- 确认签名客户端配置为
testnet。 - 用私钥派生公钥,确认结果与
sender完全相同。 - 使用同一 SDK 在本地执行
verifyPayment。 - 打印签名前的 payment 和实际提交给节点的 payload,逐字段比较。
- 检查
amount和fee是否在某一层重复进行了 MINA/nanomina 转换。 - 确认
nonce来自发送账户,转换后也是正确的整数。 - 检查广播服务是否自动添加或修改了
memo、validUntil、tokenId或 fee payer。 - 确认
mina-signer、广播服务和 devnet 节点使用兼容的交易格式。
/broadcast/tx 与 /broadcast/transaction 是否等价,取决于所用的 REST 封装服务。它们不是可以仅凭名称互换的标准 Mina GraphQL 路径。如果服务文档要求 /broadcast/tx,却实际调用了 /broadcast/transaction,请求可能会进入另一套转换逻辑。当前请求虽然已经转发到 sendPayment,但路径差异仍可能导致 payload 以不同方式重建,因此应以广播服务的接口定义为准。
备注:内容仅供参考。
