从 RPC 超时到批量签名:我用 @solana/web3.js 重构了一个 NFT 铸造页面,踩了这些坑

背景

今年上半年,我参与了一个 Solana 上的 NFT 铸造项目,类似一个限时白名单 Mint 活动。项目方要求前端在用户点击"Mint"按钮后,快速完成代币铸造,并且要处理高并发场景(比如同一时间几百人同时 Mint)。我之前主要做 EVM 系(以太坊、Polygon)开发,对 Solana 的账户模型和交易模型完全不熟。刚接手时,我心想:不就是调一个 connection.sendTransaction 吗?结果第一天就被 RPC 超时和签名失败打脸了。

当时项目用的是 @solana/web3.js v1.87,React 前端,钱包用的是 @solana/wallet-adapter-react。我写了一个简单的 Mint 函数,结果用户反馈"点了没反应"、"钱包弹窗卡死"、"交易确认等了半分钟"。我排查了两天,发现核心问题有三个:RPC 节点不稳定、交易签名流程没处理好、单笔交易太大导致被拒绝。

这篇文章就记录我是怎么一步步解决这些问题的。

问题分析

我的第一版代码长这样:

typescript 复制代码
// 第一版:天真版
async function mintNFT(publicKey: PublicKey) {
  const tx = new Transaction().add(
    SystemProgram.transfer({
      fromPubkey: publicKey,
      toPubkey: new PublicKey(MINT_ADDRESS),
      lamports: LAMPORTS_PER_SOL * 0.01,
    })
  );
  const signature = await sendTransaction(tx, connection);
  await connection.confirmTransaction(signature, 'confirmed');
}

用户一调用,问题就来了:sendTransaction 返回的签名是 Promise<string>,但 confirmTransaction 有时候卡住,有时候直接抛错 "Transaction signature verification failure"。我一开始以为是 RPC 节点的问题,换了好几个公共节点(比如 https://api.mainnet-beta.solana.com),但依然不稳定。

后来我仔细看了 Solana 文档,发现关键点:Solana 的交易必须包含 feePayerblockhash ,而且 sendTransaction 默认只做一次提交,如果 RPC 节点繁忙,交易可能根本没被广播。另外,NFT 铸造通常需要多个指令(比如创建账户、铸造、更新元数据),如果我把所有指令塞进一个交易里,交易大小可能超过 Solana 的 1232 字节限制。

我的排查步骤:

  1. 打印交易大小:tx.serialize().length,发现超过 2000 字节。
  2. 检查 blockhash:用 connection.getRecentBlockhash() 获取,但有时返回的 blockhash 已经过期(因为 RPC 节点延迟)。
  3. 测试不同 RPC 节点:用 @solana/web3.jsclusterApiUrl 连接 devnet 时没问题,一上 mainnet 就超时。

所以核心问题就是:单笔交易太大 + RPC 不稳定 + 签名流程不严谨

核心实现

1. 拆分交易:用"批次"代替"大包"

Solana 的交易大小限制是 1232 字节,而一个 NFT 铸造通常需要 3-4 个指令(创建关联账户、铸造、更新元数据),很容易超限。我的解决方案是:把铸造拆成两步------先创建账户,再铸造

第一步:创建关联 Token 账户(ATA)

typescript 复制代码
import { getAssociatedTokenAddress, createAssociatedTokenAccountInstruction } from '@solana/spl-token';

async function createATA(mint: PublicKey, owner: PublicKey) {
  const ata = await getAssociatedTokenAddress(mint, owner);
  const tx = new Transaction().add(
    createAssociatedTokenAccountInstruction(
      owner,        // payer
      ata,          // ata
      owner,        // owner
      mint          // mint
    )
  );
  return { tx, ata };
}

这里有个坑:createAssociatedTokenAccountInstruction 的参数顺序很容易搞错,我当时把 ownerpayer 写反了,导致交易一直失败。注意:第一个参数是支付 gas 的人(payer),第二个参数是要创建的 ATA 地址,第三个参数是 ATA 的拥有者,第四个参数是 mint 地址

第二步:铸造 Token

typescript 复制代码
import { createMintToInstruction } from '@solana/spl-token';

function createMintInstruction(mint: PublicKey, ata: PublicKey, authority: PublicKey, amount: number) {
  return createMintToInstruction(
    mint,
    ata,
    authority,
    amount
  );
}

这样每个交易只有 1-2 个指令,大小控制在 500 字节以内,基本不会超限。

2. 处理 RPC 超时:自定义连接 + 重试逻辑

公共 RPC 节点经常超时,我的做法是:用多个 RPC 节点做兜底,并设置超时时间

typescript 复制代码
import { Connection, clusterApiUrl } from '@solana/web3.js';

const RPC_ENDPOINTS = [
  process.env.NEXT_PUBLIC_RPC_URL || clusterApiUrl('mainnet-beta'),
  'https://solana-mainnet.g.alchemy.com/v2/YOUR_API_KEY',
  'https://rpc.ankr.com/solana',
];

function createConnectionWithRetry(timeout = 10000) {
  const connection = new Connection(RPC_ENDPOINTS[0], {
    commitment: 'confirmed',
    confirmTransactionInitialTimeout: timeout,
  });
  return connection;
}

但我发现 confirmTransactionInitialTimeout 只对 confirmTransaction 有效,对 sendTransaction 没用。所以我又加了一层手动重试:

typescript 复制代码
async function sendTransactionWithRetry(tx: Transaction, wallet: any, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const signature = await wallet.sendTransaction(tx, connection);
      return signature;
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      console.warn(`发送交易失败,第 ${i+1} 次重试...`);
      await new Promise(resolve => setTimeout(resolve, 2000 * (i+1)));
    }
  }
}

注意这个细节:重试间隔用指数退避(2 秒、4 秒、6 秒),避免短时间内频繁请求导致 RPC 限流。

3. 签名流程:确保 blockhash 是最新的

Solana 的交易需要 blockhash,如果 blockhash 过期(超过 150 个 slot),交易会被拒绝。我之前直接用 connection.getRecentBlockhash(),但这个方法在 RPC 繁忙时可能返回过期的 blockhash。后来我改用 connection.getLatestBlockhash('confirmed')

typescript 复制代码
async function createTransactionWithBlockhash(instructions: TransactionInstruction[], feePayer: PublicKey) {
  const tx = new Transaction();
  instructions.forEach(ix => tx.add(ix));
  
  const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed');
  tx.recentBlockhash = blockhash;
  tx.feePayer = feePayer;
  
  return { tx, lastValidBlockHeight };
}

然后确认交易时,我加入了 lastValidBlockHeight 检查,如果超过这个高度,就认为交易失败并重新构造:

typescript 复制代码
async function confirmTransactionWithTimeout(signature: string, lastValidBlockHeight: number) {
  const confirmation = await connection.confirmTransaction({
    signature,
    blockhash: tx.recentBlockhash,
    lastValidBlockHeight,
  }, 'confirmed');
  
  if (confirmation.value.err) {
    throw new Error(`交易失败: ${confirmation.value.err}`);
  }
}

这里有个坑confirmTransaction 的第二个参数是 commitment,但如果你用 { signature, blockhash, lastValidBlockHeight } 对象,必须确保 blockhashlastValidBlockHeight 是同一个 RPC 调用返回的,否则会报 "blockhash not found" 错误。

完整代码

下面是一个可运行的 React 组件示例,包含了创建 ATA、铸造、处理 RPC 超时和重试的完整逻辑。你需要安装 @solana/web3.js@solana/spl-token@solana/wallet-adapter-react@solana/wallet-adapter-wallets

typescript 复制代码
// MintComponent.tsx
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { Transaction, PublicKey, TransactionInstruction } from '@solana/web3.js';
import { getAssociatedTokenAddress, createAssociatedTokenAccountInstruction, createMintToInstruction } from '@solana/spl-token';
import { useState } from 'react';

const MINT_ADDRESS = new PublicKey('你的Mint地址');
const MINT_AUTHORITY = new PublicKey('你的Mint权限地址');
const MINT_AMOUNT = 1; // 铸造数量

export default function MintComponent() {
  const { connection } = useConnection();
  const { publicKey, sendTransaction } = useWallet();
  const [status, setStatus] = useState<'idle' | 'creating-ata' | 'minting' | 'success' | 'error'>('idle');
  const [error, setError] = useState<string | null>(null);

  // 获取最新的 blockhash
  async function getBlockhash() {
    const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed');
    return { blockhash, lastValidBlockHeight };
  }

  // 发送交易并确认
  async function sendAndConfirm(tx: Transaction, maxRetries = 3) {
    for (let i = 0; i < maxRetries; i++) {
      try {
        const signature = await sendTransaction(tx, connection);
        const { lastValidBlockHeight } = await getBlockhash();
        const confirmation = await connection.confirmTransaction({
          signature,
          blockhash: tx.recentBlockhash!,
          lastValidBlockHeight,
        }, 'confirmed');
        if (confirmation.value.err) {
          throw new Error(`交易确认失败: ${confirmation.value.err}`);
        }
        return signature;
      } catch (err: any) {
        if (i === maxRetries - 1) throw err;
        console.warn(`重试 ${i+1}/${maxRetries}: ${err.message}`);
        await new Promise(resolve => setTimeout(resolve, 2000 * (i+1)));
      }
    }
  }

  async function handleMint() {
    if (!publicKey) return;
    setStatus('creating-ata');
    setError(null);

    try {
      // 1. 获取关联 Token 账户地址
      const ata = await getAssociatedTokenAddress(MINT_ADDRESS, publicKey);

      // 2. 创建 ATA 交易(如果账户不存在)
      const ataTx = new Transaction();
      ataTx.add(
        createAssociatedTokenAccountInstruction(
          publicKey,  // payer
          ata,        // ata
          publicKey,  // owner
          MINT_ADDRESS // mint
        )
      );
      const { blockhash: blockhash1, lastValidBlockHeight: height1 } = await getBlockhash();
      ataTx.recentBlockhash = blockhash1;
      ataTx.feePayer = publicKey;

      // 发送创建 ATA 交易
      await sendAndConfirm(ataTx);
      setStatus('minting');

      // 3. 创建铸造交易
      const mintTx = new Transaction();
      mintTx.add(
        createMintToInstruction(
          MINT_ADDRESS,
          ata,
          MINT_AUTHORITY,
          MINT_AMOUNT
        )
      );
      const { blockhash: blockhash2, lastValidBlockHeight: height2 } = await getBlockhash();
      mintTx.recentBlockhash = blockhash2;
      mintTx.feePayer = publicKey;

      // 发送铸造交易
      await sendAndConfirm(mintTx);
      setStatus('success');
    } catch (err: any) {
      setStatus('error');
      setError(err.message || '未知错误');
    }
  }

  return (
    <div>
      <button onClick={handleMint} disabled={!publicKey || status === 'creating-ata' || status === 'minting'}>
        {status === 'creating-ata' ? '创建账户中...' :
         status === 'minting' ? '铸造中...' :
         'Mint NFT'}
      </button>
      {status === 'success' && <p>铸造成功!</p>}
      {status === 'error' && <p style={{color: 'red'}}>错误: {error}</p>}
    </div>
  );
}

踩坑记录

  1. createAssociatedTokenAccountInstruction 参数顺序 :官方文档写的是 (payer, ata, owner, mint),但实际调试时发现如果 payerowner 不同,必须确保 payer 有足够的 SOL 支付 gas,否则会报 "insufficient lamports"。我一开始把 payerowner 都传了 publicKey,没问题,但后来换成多签钱包就踩坑了。

  2. sendTransaction 返回的签名不是立即可用 :我以为 sendTransaction 返回签名就代表交易成功了,但实际上它只是把交易提交到了 RPC。如果 RPC 节点繁忙,签名可能还没被广播。所以必须用 confirmTransaction 等待确认,并且要设置超时。

  3. blockhash 过期 :有一次我在 devnet 测试时,连续快速发送两笔交易,第二笔交易报错 "blockhash not found"。原因是我两次都用了同一个 blockhash,但第一笔交易已经改变了链状态,导致第二笔交易的 blockhash 无效。解决方案是每次发送交易前都重新获取 blockhash

  4. 公共 RPC 限流 :用 https://api.mainnet-beta.solana.com 时,如果一秒钟发超过 10 个请求,就会被限流,返回 429 错误。后来我换成了付费的 RPC(比如 Helius 或 QuickNode),或者用多个 RPC 做负载均衡。

小结

这次经历让我彻底理解了 Solana 的交易模型:交易要小、blockhash 要新、RPC 要稳 。核心收获是学会了用 getLatestBlockhash 替代 getRecentBlockhash,以及手动实现重试逻辑。如果你也想深入 Solana 前端开发,下一步可以研究 @solana/web3.jsVersionedTransaction(v1.18+),它支持更大的交易大小和更好的并行处理。

相关推荐
工业HMI实战笔记1 小时前
工业HMI界面布局“1核2辅”黄金结构,适配90%场景
前端·ui·性能优化·自动化·交互
橘子星2 小时前
从零手写 RAG 语义检索:基于 Node.js 实现轻量级向量搜索
javascript·人工智能
林希_Rachel_傻希希2 小时前
web性能优化之————图片效果
前端·javascript·面试
橘子星2 小时前
基于 MCP 协议实现本地文件读取工具服务开发实践
javascript·人工智能
Darling噜啦啦2 小时前
前端存储与 this 指向完全指南:从 LocalStorage 实战到 call/apply/bind 深度解析
前端·javascript
sugar__salt2 小时前
手撕字符串算法:反转、回文、验证回文 Ⅱ 完整拆解
javascript·算法·面试·职场和发展
wei1986212 小时前
.net添加web引用和添加服务引用有什么区别?
java·前端·.net
To_OC2 小时前
从一行报错开始,把字符串反转、回文算法连带着包装类一起捋明白
javascript·算法·api
蜡台3 小时前
Node 安装 awesome-qr 失败解决
javascript·vue·qrcode·awesome-qr