背景
今年上半年,我参与了一个 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 的交易必须包含 feePayer 和 blockhash ,而且 sendTransaction 默认只做一次提交,如果 RPC 节点繁忙,交易可能根本没被广播。另外,NFT 铸造通常需要多个指令(比如创建账户、铸造、更新元数据),如果我把所有指令塞进一个交易里,交易大小可能超过 Solana 的 1232 字节限制。
我的排查步骤:
- 打印交易大小:
tx.serialize().length,发现超过 2000 字节。 - 检查
blockhash:用connection.getRecentBlockhash()获取,但有时返回的 blockhash 已经过期(因为 RPC 节点延迟)。 - 测试不同 RPC 节点:用
@solana/web3.js的clusterApiUrl连接 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 的参数顺序很容易搞错,我当时把 owner 和 payer 写反了,导致交易一直失败。注意:第一个参数是支付 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 } 对象,必须确保 blockhash 和 lastValidBlockHeight 是同一个 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>
);
}
踩坑记录
-
createAssociatedTokenAccountInstruction参数顺序 :官方文档写的是(payer, ata, owner, mint),但实际调试时发现如果payer和owner不同,必须确保payer有足够的 SOL 支付 gas,否则会报"insufficient lamports"。我一开始把payer和owner都传了publicKey,没问题,但后来换成多签钱包就踩坑了。 -
sendTransaction返回的签名不是立即可用 :我以为sendTransaction返回签名就代表交易成功了,但实际上它只是把交易提交到了 RPC。如果 RPC 节点繁忙,签名可能还没被广播。所以必须用confirmTransaction等待确认,并且要设置超时。 -
blockhash过期 :有一次我在 devnet 测试时,连续快速发送两笔交易,第二笔交易报错"blockhash not found"。原因是我两次都用了同一个blockhash,但第一笔交易已经改变了链状态,导致第二笔交易的blockhash无效。解决方案是每次发送交易前都重新获取blockhash。 -
公共 RPC 限流 :用
https://api.mainnet-beta.solana.com时,如果一秒钟发超过 10 个请求,就会被限流,返回 429 错误。后来我换成了付费的 RPC(比如 Helius 或 QuickNode),或者用多个 RPC 做负载均衡。
小结
这次经历让我彻底理解了 Solana 的交易模型:交易要小、blockhash 要新、RPC 要稳 。核心收获是学会了用 getLatestBlockhash 替代 getRecentBlockhash,以及手动实现重试逻辑。如果你也想深入 Solana 前端开发,下一步可以研究 @solana/web3.js 的 VersionedTransaction(v1.18+),它支持更大的交易大小和更好的并行处理。