Solana前端开发:我在一个NFT铸造页面上被@solana/web3.js的Connection和Transaction签名坑了两天

Solana前端开发:我在一个NFT铸造页面上被@solana/web3.js的Connection和Transaction签名坑了两天

摘要

两个月前,我给一个Solana NFT项目做铸造页面,本以为用@solana/web3.js和Phantom钱包就能轻松搞定,结果被Connection配置和Transaction签名折磨了两天。这篇文章就是我当时踩坑的全过程记录,包括怎么排查、怎么修、最终能用代码直接跑。

背景

我参与的是一个Solana生态的NFT项目,用户需要连接Phantom钱包,然后铸造NFT。项目本身不复杂:前端调用合约的mint指令,用户签名并发送交易,等确认后显示铸造成功。我当时觉得这事简单------我在以太坊上用ethers.js写过几十个铸造页面,Solana不也是类似吗?结果我错了。

第一天,我按官方文档快速搭了个React页面,用@solana/web3.jsConnection连接公共RPC节点,用@solana/wallet-adapter-react管理钱包状态。点击"铸造"按钮后,控制台报错TransactionExpiredBlockheightExceededError。我以为只是网络慢,重试了几次,还是不行。第二天,我换了RPC节点,结果又遇到Signature verification failed。我整个人都懵了------同样的代码,为什么有时候行有时候不行?

后来我发现,问题出在两个地方:第一,公共RPC节点不稳定,导致交易确认超时;第二,Transaction的recentBlockhashfeePayer没有正确设置,导致签名验证失败。这篇文章就是我从头到尾排查和解决这两个问题的记录,希望能帮你少走弯路。

问题分析

我最初的思路很简单:从Phantom钱包获取publicKey,用@solana/web3.js构建一个Transaction对象,添加一个mint指令,然后调用钱包的signAndSendTransaction方法。代码看起来像这样:

typescript 复制代码
// 我的第一个版本(有问题的)
import { Connection, Transaction, SystemProgram, PublicKey } from '@solana/web3.js';

const connection = new Connection('https://api.mainnet-beta.solana.com', 'confirmed');
const transaction = new Transaction();
transaction.add(/* 铸造指令 */);
const signature = await wallet.signAndSendTransaction(transaction);

结果控制台报错:TransactionExpiredBlockheightExceededError。我查了一下,这个错误的意思是交易在区块链上超时了------具体来说,recentBlockhash指的是交易创建时的区块哈希,如果这个区块被确认后交易还没被打包,它就会失效。公共RPC节点响应慢,有时候recentBlockhash还没获取到就超时了。

我试了换个RPC节点,比如用Helius的免费节点,结果又遇到Signature verification failed。这次更迷惑:签名明明是从钱包里拿到的,为什么验证失败?后来我发现,问题出在Transaction的feePayer字段。我构建Transaction时没有显式设置feePayer,默认是空,而钱包签名时只签了交易内容,没有包含费用支付者信息,导致验证不通过。

核心实现

第一步:正确配置Connection,避免超时

我首先意识到,公共RPC节点不适合生产环境。Solana的公共节点https://api.mainnet-beta.solana.com经常限流或响应慢,导致getRecentBlockhash超时。我换用了Helius的免费RPC(注册后获得专属URL),并调整了Connection的参数。

关键点是:Connection构造函数的第二个参数是Commitment,它决定了交易确认的可靠性。我用'confirmed'表示等待区块确认,但公共节点有时无法及时返回状态。换成Helius后,我还设置了confirmTransaction的超时时间。

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

// 使用Helius RPC节点(需替换为自己的API Key)
const RPC_URL = 'https://rpc.helius.xyz/?api-key=YOUR_API_KEY';
const COMMITMENT: Commitment = 'confirmed';

export const connection = new Connection(RPC_URL, {
  commitment: COMMITMENT,
  // 设置确认超时时间为60秒
  confirmTransactionInitialTimeout: 60000,
});

这里有个坑:confirmTransactionInitialTimeout默认是30秒,但Solana网络拥堵时可能需要更久。我把它调大到60秒后,TransactionExpiredBlockheightExceededError就很少出现了。

第二步:构建Transaction并正确设置recentBlockhash和feePayer

构建Transaction时,必须显式设置recentBlockhashfeePayerrecentBlockhashConnection获取,feePayer是当前钱包的公钥。如果遗漏任何一个,签名验证就会失败。

typescript 复制代码
// buildTransaction.ts
import { Transaction, PublicKey, SystemProgram } from '@solana/web3.js';
import { connection } from './connection';

export async function buildMintTransaction(
  userPublicKey: PublicKey,
  mintAddress: PublicKey
): Promise<Transaction> {
  // 获取最新的区块哈希
  const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
  
  const transaction = new Transaction({
    feePayer: userPublicKey, // 必须设置,否则签名验证失败
    recentBlockhash: blockhash, // 必须设置,否则交易超时
  });

  // 这里添加铸造指令,实际项目中需要根据合约ABI来
  // 这里用一个简单的转账作为示例
  transaction.add(
    SystemProgram.transfer({
      fromPubkey: userPublicKey,
      toPubkey: mintAddress,
      lamports: 1000, // 0.000001 SOL
    })
  );

  return transaction;
}

注意这个细节:recentBlockhash必须在交易创建时获取,并且交易必须在lastValidBlockHeight之前被确认。如果网络慢,可以重新获取一次。

第三步:用钱包签名并发送交易

Phantom钱包的signAndSendTransaction方法会自动签名并发送,但前提是Transaction对象必须完整。我之前的错误是直接传一个空的Transaction,导致签名时只签了部分数据。

typescript 复制代码
// MintButton.tsx
import { useWallet } from '@solana/wallet-adapter-react';
import { buildMintTransaction } from './buildTransaction';

export function MintButton() {
  const { publicKey, signTransaction, connected } = useWallet();

  const handleMint = async () => {
    if (!publicKey || !signTransaction) {
      alert('请先连接钱包');
      return;
    }

    try {
      // 构建交易
      const transaction = await buildMintTransaction(publicKey, MINT_ADDRESS);
      
      // 用钱包签名交易(这里必须用signTransaction,而不是signAndSendTransaction)
      const signedTransaction = await signTransaction(transaction);
      
      // 发送签名后的交易
      const signature = await connection.sendRawTransaction(
        signedTransaction.serialize()
      );
      
      // 等待确认
      const confirmation = await connection.confirmTransaction({
        signature,
        blockhash: transaction.recentBlockhash!,
        lastValidBlockHeight: (await connection.getLatestBlockhash()).lastValidBlockHeight,
      });

      if (confirmation.value.err) {
        throw new Error('交易确认失败');
      }

      console.log('铸造成功,交易签名:', signature);
    } catch (error) {
      console.error('铸造失败:', error);
    }
  };

  return (
    <button onClick={handleMint} disabled={!connected}>
      {connected ? '铸造NFT' : '连接钱包'}
    </button>
  );
}

这里有个坑:我一开始用了signAndSendTransaction,但这个方法在Phantom中会自己处理签名和发送,但有时会返回一个签名,而confirmTransaction需要完整的Transaction对象。所以更稳妥的做法是:先用signTransaction签名,再用connection.sendRawTransaction发送,最后用confirmTransaction确认。

第四步:处理铸造指令(以Metaplex为例)

实际NFT铸造通常用Metaplex的createMintmint指令。这里我用@metaplex-foundation/js来简化,但核心还是Transaction构建。

typescript 复制代码
// metaplexMint.ts
import { Metaplex, walletAdapterIdentity } from '@metaplex-foundation/js';
import { useWallet } from '@solana/wallet-adapter-react';
import { connection } from './connection';

export async function mintNFTWithMetaplex(
  publicKey: PublicKey,
  signTransaction: any
) {
  const metaplex = Metaplex.make(connection)
    .use(walletAdapterIdentity({ publicKey, signTransaction }));

  // 创建NFT集合(实际项目中可能已有集合)
  const { nft } = await metaplex.nfts().create({
    uri: 'https://example.com/metadata.json',
    name: 'My NFT',
    sellerFeeBasisPoints: 500, // 5%版税
  });

  console.log('NFT铸造成功,地址:', nft.address.toBase58());
}

注意:Metaplex的create方法内部已经处理了Transaction构建和签名,但如果你需要自定义指令,还是得手动构建Transaction。

完整代码

以下是一个完整的React组件,包含所有步骤,可以直接复制运行(需替换RPC URL和合约地址)。

typescript 复制代码
// App.tsx
import React, { useCallback } from 'react';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletModalProvider, WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-wallets';
import { clusterApiUrl, PublicKey, Transaction, SystemProgram } from '@solana/web3.js';
import { useWallet } from '@solana/wallet-adapter-react';

const RPC_URL = 'https://rpc.helius.xyz/?api-key=YOUR_API_KEY'; // 替换为你的Helius API Key
const MINT_ADDRESS = new PublicKey('YourMintAddressHere'); // 替换为实际铸造地址

function MintNFT() {
  const { publicKey, signTransaction, connected } = useWallet();

  const handleMint = useCallback(async () => {
    if (!publicKey || !signTransaction) return;

    try {
      // 1. 创建Connection
      const connection = new (require('@solana/web3.js').Connection)(RPC_URL, {
        commitment: 'confirmed',
        confirmTransactionInitialTimeout: 60000,
      });

      // 2. 获取最新区块哈希
      const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();

      // 3. 构建Transaction
      const transaction = new Transaction({
        feePayer: publicKey,
        recentBlockhash: blockhash,
      });

      // 4. 添加转账指令(示例)
      transaction.add(
        SystemProgram.transfer({
          fromPubkey: publicKey,
          toPubkey: MINT_ADDRESS,
          lamports: 1000,
        })
      );

      // 5. 签名
      const signedTransaction = await signTransaction(transaction);

      // 6. 发送
      const signature = await connection.sendRawTransaction(
        signedTransaction.serialize()
      );

      // 7. 等待确认
      const confirmation = await connection.confirmTransaction({
        signature,
        blockhash,
        lastValidBlockHeight,
      });

      if (confirmation.value.err) {
        throw new Error('交易确认失败');
      }

      alert(`铸造成功!交易签名: ${signature}`);
    } catch (error) {
      console.error('铸造失败:', error);
      alert('铸造失败,请检查控制台错误信息');
    }
  }, [publicKey, signTransaction]);

  return (
    <div>
      <WalletMultiButton />
      <button onClick={handleMint} disabled={!connected}>
        {connected ? '铸造NFT' : '请先连接钱包'}
      </button>
    </div>
  );
}

function App() {
  return (
    <ConnectionProvider endpoint={RPC_URL}>
      <WalletProvider wallets={[new PhantomWalletAdapter()]} autoConnect>
        <WalletModalProvider>
          <MintNFT />
        </WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  );
}

export default App;

踩坑记录

  1. TransactionExpiredBlockheightExceededError :公共RPC节点响应慢,导致recentBlockhash过期。解决方法是换用商业RPC(如Helius、QuickNode),并设置confirmTransactionInitialTimeout为60秒。

  2. Signature verification failed :Transaction的feePayer没有设置,钱包签名时只签了部分内容。必须显式设置feePayer为当前钱包公钥。

  3. Cannot read properties of undefined (reading 'signTransaction') :Phantom钱包未安装或未连接时,signTransactionundefined。需要在调用前检查publicKeysignTransaction是否存在。

  4. Transaction simulation failed :铸造指令参数错误或余额不足。用connection.simulateTransaction先模拟交易,检查返回的错误信息。

小结

Solana的Transaction生命周期和以太坊不同:必须手动设置recentBlockhashfeePayer,且签名和发送需要分开处理。核心收获是:不要依赖公共RPC节点,Transaction构建要完整,签名用signTransaction而不是signAndSendTransaction。如果想深入,可以研究一下@solana/web3.jsVersionedTransaction(v2版本),它解决了部分问题但又有新坑。

相关推荐
冬奇Lab1 小时前
每日一个开源项目(第144篇):ai-website-cloner-template - 一条命令、多 Agent 并行,把任意网站逆向成 Next.js 代码
前端·人工智能·开源
玄玄子1 小时前
webpack publicPath作用原理
前端·webpack·程序员
HduSy1 小时前
帮 Claude Code 做了个菜单栏 Token 看板,聊聊里面的一些实现逻辑
前端
用户059540174462 小时前
用了6个月LangChain,才发现AI Agent的记忆存储一直有坑——写了23个Pytest用例才彻底修好
前端·css
奶油mm2 小时前
我偷偷把公司的祖传 jQuery 项目改成了 Vue3,CTO 没发现,但全组都来抄我的代码了
前端
用户2136610035722 小时前
Vue2非父子通信与动态组件
前端·vue.js
PedroQue992 小时前
Vite插件体系1.0.0:API稳定,生产就绪
前端·vite
用户059540174462 小时前
把LLM记忆测试从手工脚本换成Pytest参数化,回归时间从2小时降到10分钟
前端·css
donecoding2 小时前
3 条命令搞定闭环 Monorepo:Lerna 版本管理 + 拓扑构建 + 自定义分发
前端·前端框架·node.js