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.js的Connection连接公共RPC节点,用@solana/wallet-adapter-react管理钱包状态。点击"铸造"按钮后,控制台报错TransactionExpiredBlockheightExceededError。我以为只是网络慢,重试了几次,还是不行。第二天,我换了RPC节点,结果又遇到Signature verification failed。我整个人都懵了------同样的代码,为什么有时候行有时候不行?
后来我发现,问题出在两个地方:第一,公共RPC节点不稳定,导致交易确认超时;第二,Transaction的recentBlockhash和feePayer没有正确设置,导致签名验证失败。这篇文章就是我从头到尾排查和解决这两个问题的记录,希望能帮你少走弯路。
问题分析
我最初的思路很简单:从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时,必须显式设置recentBlockhash和feePayer。recentBlockhash从Connection获取,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的createMint或mint指令。这里我用@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;
踩坑记录
-
TransactionExpiredBlockheightExceededError :公共RPC节点响应慢,导致
recentBlockhash过期。解决方法是换用商业RPC(如Helius、QuickNode),并设置confirmTransactionInitialTimeout为60秒。 -
Signature verification failed :Transaction的
feePayer没有设置,钱包签名时只签了部分内容。必须显式设置feePayer为当前钱包公钥。 -
Cannot read properties of undefined (reading 'signTransaction') :Phantom钱包未安装或未连接时,
signTransaction为undefined。需要在调用前检查publicKey和signTransaction是否存在。 -
Transaction simulation failed :铸造指令参数错误或余额不足。用
connection.simulateTransaction先模拟交易,检查返回的错误信息。
小结
Solana的Transaction生命周期和以太坊不同:必须手动设置recentBlockhash和feePayer,且签名和发送需要分开处理。核心收获是:不要依赖公共RPC节点,Transaction构建要完整,签名用signTransaction而不是signAndSendTransaction。如果想深入,可以研究一下@solana/web3.js的VersionedTransaction(v2版本),它解决了部分问题但又有新坑。