PAYATHON 2026

Mina devnet 发送支付交易时出现 Invalid_signature

支付阿杰

结论

Invalid_signature 表示节点收到的交易内容与签名时使用的内容不一致,或者双方采用了不同的签名域。在 Mina devnet 上,常见原因包括:

  1. MinaSDK 签名客户端没有使用 devnet 对应的 testnet network identifier。
  2. sender 与用于签名的私钥不匹配。
  3. /broadcast/transaction 服务在转发前修改了 amount、fee、nonce、memo 等签名覆盖字段。
  4. 签名库与广播服务使用的交易格式或 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:

  • fee
  • feePayer
  • nonce
  • validUntil
  • memo
  • tokenId
  • amount
  • source
  • receiver
  • 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);

进一步排查顺序

可以按以下顺序排查:

  1. 确认签名客户端配置为 testnet。
  2. 用私钥派生公钥,确认结果与 sender 完全相同。
  3. 使用同一 SDK 在本地执行 verifyPayment。
  4. 打印签名前的 payment 和实际提交给节点的 payload,逐字段比较。
  5. 检查 amount 和 fee 是否在某一层重复进行了 MINA/nanomina 转换。
  6. 确认 nonce 来自发送账户,转换后也是正确的整数。
  7. 检查广播服务是否自动添加或修改了 memo、validUntil、tokenId 或 fee payer。
  8. 确认 mina-signer、广播服务和 devnet 节点使用兼容的交易格式。

/broadcast/tx 与 /broadcast/transaction 是否等价,取决于所用的 REST 封装服务。它们不是可以仅凭名称互换的标准 Mina GraphQL 路径。如果服务文档要求 /broadcast/tx,却实际调用了 /broadcast/transaction,请求可能会进入另一套转换逻辑。当前请求虽然已经转发到 sendPayment,但路径差异仍可能导致 payload 以不同方式重建,因此应以广播服务的接口定义为准。

备注:内容仅供参考。