背景
上个月,我在做一个Solana链上的NFT铸造DApp。用户点击“Mint”按钮后,需要等待交易被确认,然后更新UI显示铸造成功。听起来很简单对吧?我当时想:直接调sendAndConfirmTransaction,等它返回就完事了。
结果上线第一天就出了问题:用户铸造完成后,页面要等10-20秒才更新,有时候甚至直接卡死。更糟的是,如果用户同时开多个标签页,RPC节点直接返回429限流错误。我当时就意识到:用同步等待的方式监听Solana交易,在真实生产环境中完全不可行。
我需要一个异步的、实时的、不阻塞UI的交易监听方案。经过两天的折腾,我最终用@solana/web3.js的WebSocket订阅功能解决了这个问题。这篇文章就是完整的解决过程。
问题分析
最初的思路:sendAndConfirmTransaction
我的第一版代码大概是这样的:
import { Connection, Keypair, Transaction } from '@solana/web3.js';
const connection = new Connection('https://api.mainnet-beta.solana.com');
async function mintNFT() {
const tx = new Transaction();
// ...构建交易
const signature = await connection.sendAndConfirmTransaction(tx, [payer]);
console.log('交易已确认:', signature);
setStatus('success');
}
这个方案有两个致命问题:
-
阻塞UI:
sendAndConfirmTransaction会一直等待直到交易被确认,期间JavaScript主线程被占用,UI完全卡死。用户以为页面崩了,实际上是在等RPC响应。 -
RPC超时:Solana主网确认时间波动很大,有时候1秒,有时候20秒。
sendAndConfirmTransaction默认超时时间是30秒,如果网络拥堵,直接抛异常,用户铸造失败但钱已经扣了。
第一次改进:setInterval轮询
既然同步不行,那我就异步轮询吧:
const signature = await connection.sendTransaction(tx, [payer]);
const intervalId = setInterval(async () => {
const status = await connection.getSignatureStatus(signature);
if (status.value?.confirmationStatus === 'finalized') {
clearInterval(intervalId);
setStatus('success');
}
}, 1000);
结果踩了更大的坑:
- RPC限流:每个用户每秒轮询一次,100个用户就是100次/秒的请求。Solana公共RPC节点通常限制40次/秒,直接报429。
- 内存泄漏:如果用户关闭页面或切换路由,
clearInterval没执行,轮询一直跑,最后浏览器崩溃。 - 确认状态不准确:
getSignatureStatus返回的状态有confirmed和finalized两种,我一开始没区分,导致用户看到成功但实际交易还没最终确认。
核心实现
1. 用WebSocket替代HTTP轮询
Solana的@solana/web3.js提供了onSignature方法,底层使用WebSocket订阅交易状态变化。这意味着:
- 不需要轮询,RPC节点主动推送状态更新
- 只发一次请求,后续全是推送,极大减少RPC调用次数
- 状态变化实时响应,延迟在毫秒级
关键代码:
import { Connection, PublicKey } from '@solana/web3.js';
// 注意:必须使用支持WebSocket的RPC地址
const connection = new Connection('https://api.mainnet-beta.solana.com', {
wsEndpoint: 'wss://api.mainnet-beta.solana.com/', // 这里有个坑,后面说
commitment: 'confirmed' // 重要:指定确认级别
});
function listenTransaction(signature: string, callback: (status: any) => void) {
// 订阅签名状态
const subscriptionId = connection.onSignature(
signature,
(signatureResult, context) => {
console.log('交易状态更新:', signatureResult);
callback(signatureResult);
},
'confirmed' // 订阅时也要指定commitment
);
return subscriptionId; // 返回ID用于取消订阅
}
这里有个坑:wsEndpoint必须和HTTP的RPC地址匹配。我一开始用了https://api.mainnet-beta.solana.com但wsEndpoint写成了wss://solana-api.projectserum.com/,结果WebSocket一直连不上,报错WebSocket connection failed。后来才发现,不同RPC提供商的WebSocket端点不一样,必须用同一个服务商的。
2. 用状态机管理交易生命周期
光有监听还不够,交易有多个状态:pending → confirmed → finalized,还可能出错。我用状态机模式管理:
type TransactionState = 'idle' | 'pending' | 'confirmed' | 'finalized' | 'failed';
interface TransactionStatus {
state: TransactionState;
signature: string | null;
error: string | null;
timestamp: number;
}
class TransactionMonitor {
private status: TransactionStatus = {
state: 'idle',
signature: null,
error: null,
timestamp: 0
};
private subscriptionId: number | null = null;
private connection: Connection;
constructor(connection: Connection) {
this.connection = connection;
}
async sendAndMonitor(tx: Transaction, signers: Signer[]): Promise<string> {
// 状态1: 发送交易
this.status.state = 'pending';
const signature = await this.connection.sendTransaction(tx, signers);
this.status.signature = signature;
this.status.timestamp = Date.now();
// 状态2: 订阅确认
this.subscriptionId = this.connection.onSignature(
signature,
(result) => {
if (result.err) {
// 状态3: 交易失败
this.status.state = 'failed';
this.status.error = result.err.toString();
this.cleanup();
} else {
// 状态4: 已确认
this.status.state = 'confirmed';
// 注意:这里只是confirmed,不是finalized
// 如果需要finalized,需要额外监听
}
},
'confirmed'
);
return signature;
}
private cleanup() {
if (this.subscriptionId !== null) {
this.connection.removeSignatureListener(this.subscriptionId);
this.subscriptionId = null;
}
}
}
注意这个细节:onSignature的回调只触发一次。如果交易状态从confirmed变成finalized,不会再次回调。所以如果你需要监听finalized,必须单独再订阅一次,或者用commitment: 'finalized'一次性订阅。
3. 处理WebSocket断连和重连
生产环境中,WebSocket连接可能因为网络波动断开。@solana/web3.js的Connection对象默认不会自动重连,需要自己处理。
我参考了wagmi的源码,实现了一个重连机制:
class ReconnectingConnection {
private connection: Connection;
private endpoint: string;
private wsEndpoint: string;
private reconnectTimer: number | null = null;
private maxRetries = 5;
private retryCount = 0;
constructor(endpoint: string, wsEndpoint: string) {
this.endpoint = endpoint;
this.wsEndpoint = wsEndpoint;
this.connection = this.createConnection();
this.setupHeartbeat();
}
private createConnection(): Connection {
return new Connection(this.endpoint, {
wsEndpoint: this.wsEndpoint,
commitment: 'confirmed',
confirmTransactionInitialTimeout: 60000, // 超时时间设长一点
});
}
private setupHeartbeat() {
// 每30秒检查一次连接状态
setInterval(() => {
// 通过getSlot检查连接是否正常
this.connection.getSlot().catch(() => {
console.warn('WebSocket连接断开,尝试重连...');
this.reconnect();
});
}, 30000);
}
private reconnect() {
if (this.retryCount >= this.maxRetries) {
console.error('重连失败,已达最大重试次数');
return;
}
this.retryCount++;
// 指数退避:1s, 2s, 4s, 8s, 16s
const delay = Math.pow(2, this.retryCount) * 1000;
this.reconnectTimer = setTimeout(() => {
this.connection = this.createConnection();
this.retryCount = 0; // 重置重试计数
}, delay);
}
getConnection(): Connection {
return this.connection;
}
}
这里有个坑:WebSocket断连后,之前订阅的subscriptionId会失效。重连后需要重新订阅所有交易。我踩了这个坑,结果用户看到交易状态一直卡在pending,实际上交易早就确认了。解决方案是维护一个订阅列表,重连后重新订阅。
4. 集成到React组件
最后,我把以上逻辑封装成一个React Hook:
import { useCallback, useEffect, useRef, useState } from 'react';
import { Connection, Transaction, Signer } from '@solana/web3.js';
interface UseTransactionMonitorReturn {
status: TransactionState;
signature: string | null;
error: string | null;
sendTransaction: (tx: Transaction, signers: Signer[]) => Promise<string>;
reset: () => void;
}
export function useTransactionMonitor(connection: Connection): UseTransactionMonitorReturn {
const [status, setStatus] = useState<TransactionState>('idle');
const [signature, setSignature] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const subscriptionRef = useRef<number | null>(null);
const cleanup = useCallback(() => {
if (subscriptionRef.current !== null) {
connection.removeSignatureListener(subscriptionRef.current);
subscriptionRef.current = null;
}
}, [connection]);
const sendTransaction = useCallback(async (tx: Transaction, signers: Signer[]) => {
setStatus('pending');
setError(null);
try {
const sig = await connection.sendTransaction(tx, signers);
setSignature(sig);
// 订阅交易状态
const subId = connection.onSignature(
sig,
(result) => {
if (result.err) {
setStatus('failed');
setError(result.err.toString());
} else {
setStatus('confirmed');
}
cleanup();
},
'confirmed'
);
subscriptionRef.current = subId;
return sig;
} catch (err) {
setStatus('failed');
setError(err instanceof Error ? err.message : '交易发送失败');
throw err;
}
}, [connection, cleanup]);
const reset = useCallback(() => {
cleanup();
setStatus('idle');
setSignature(null);
setError(null);
}, [cleanup]);
// 组件卸载时清理
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
return { status, signature, error, sendTransaction, reset };
}
使用示例:
function MintButton() {
const { status, signature, error, sendTransaction, reset } = useTransactionMonitor(connection);
const handleMint = async () => {
const tx = new Transaction();
// ... 构建交易
await sendTransaction(tx, [wallet.publicKey]);
};
return (
<div>
<button onClick={handleMint} disabled={status === 'pending'}>
{status === 'pending' ? '铸造中...' : '铸造'}
</button>
{status === 'confirmed' && <p>铸造成功!交易签名:{signature}</p>}
{status === 'failed' && <p>铸造失败:{error}</p>}
</div>
);
}
完整代码
以下是一个可直接运行的React组件,包含了完整的交易监听逻辑:
// TransactionMonitor.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { Connection, Transaction, PublicKey, SystemProgram } from '@solana/web3.js';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
// 1. 创建带重连的Connection
function createConnectionWithRetry(endpoint: string, wsEndpoint: string): Connection {
return new Connection(endpoint, {
wsEndpoint,
commitment: 'confirmed',
confirmTransactionInitialTimeout: 60000,
});
}
// 2. 交易状态管理Hook
function useTransactionMonitor(connection: Connection) {
const [status, setStatus] = useState<'idle' | 'pending' | 'confirmed' | 'failed'>('idle');
const [signature, setSignature] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const subscriptionRef = React.useRef<number | null>(null);
const cleanup = useCallback(() => {
if (subscriptionRef.current !== null) {
connection.removeSignatureListener(subscriptionRef.current);
subscriptionRef.current = null;
}
}, [connection]);
const sendTransaction = useCallback(async (transaction: Transaction) => {
setStatus('pending');
setError(null);
try {
// 发送交易
const sig = await connection.sendRawTransaction(transaction.serialize());
setSignature(sig);
// 订阅确认
const subId = connection.onSignature(
sig,
(result) => {
if (result.err) {
setStatus('failed');
setError(result.err.toString());
} else {
setStatus('confirmed');
}
cleanup();
},
'confirmed'
);
subscriptionRef.current = subId;
return sig;
} catch (err) {
setStatus('failed');
setError(err instanceof Error ? err.message : '交易发送失败');
throw err;
}
}, [connection, cleanup]);
useEffect(() => {
return () => cleanup();
}, [cleanup]);
return { status, signature, error, sendTransaction };
}
// 3. 实际组件
export function MintNFT() {
const { connection } = useConnection();
const { publicKey, sendTransaction: walletSendTransaction } = useWallet();
const { status, signature, error, sendTransaction } = useTransactionMonitor(connection);
const handleMint = async () => {
if (!publicKey) return;
const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: publicKey,
toPubkey: new PublicKey('目标地址'),
lamports: 1000000, // 0.001 SOL
})
);
try {
// 使用钱包签名并发送
const sig = await walletSendTransaction(transaction, connection);
// 注意:这里用wallet的sendTransaction,它会自动签名
// 然后我们用自己Hook里的逻辑监听
await sendTransaction(transaction);
} catch (err) {
console.error('交易失败:', err);
}
};
return (
<div style={{ padding: '20px' }}>
<h2>铸造NFT</h2>
<button
onClick={handleMint}
disabled={!publicKey || status === 'pending'}
style={{
padding: '10px 20px',
fontSize: '16px',
cursor: status === 'pending' ? 'not-allowed' : 'pointer',
}}
>
{status === 'pending' ? '铸造中...' : '立即铸造'}
</button>
{status === 'confirmed' && (
<div style={{ marginTop: '20px', color: 'green' }}>
✅ 铸造成功!
<br />
交易签名:<code>{signature}</code>
</div>
)}
{status === 'failed' && (
<div style={{ marginTop: '20px', color: 'red' }}>
❌ 铸造失败:{error}
</div>
)}
</div>
);
}
踩坑记录
坑1:WebSocket端点不匹配导致连接失败
报错:WebSocket connection to 'wss://wrong-endpoint/' failed
原因:我用了Helius的HTTP RPC,但WebSocket端点写成了Solana官方的。不同RPC服务商的WebSocket端口和路径不同,必须配对使用。
解决:统一使用同一个RPC提供商的HTTP和WS端点。如果自己搭节点,HTTP和WS端口通常不同(比如8899和8900)。
坑2:onSignature回调只触发一次
现象:交易从confirmed变成finalized后,UI没有更新。
原因:onSignature默认只监听一次状态变化。如果你订阅时指定commitment: 'confirmed',它只会在交易达到confirmed状态时回调一次,不会继续监听finalized。
解决:要么用commitment: 'finalized'一次性订阅最终状态,要么分两次订阅(先confirmed再finalized)。我选择了前者,因为对于大多数场景,confirmed已经足够安全。
坑3:组件卸载后订阅还在运行
现象:用户切换页面后,控制台还在打印交易状态更新。
原因:React组件卸载时没有清理WebSocket订阅,导致内存泄漏和多余的RPC调用。
解决:在useEffect的清理函数中调用removeSignatureListener,并且用useRef保存subscriptionId,确保清理的是正确的订阅。
坑4:交易签名重复发送
现象:用户点击Mint按钮多次,导致同一笔交易被发送多次。
原因:按钮没有禁用状态,用户快速点击触发了多个sendTransaction调用。
解决:在pending状态下禁用按钮,并且用useRef或状态锁防止并发调用。
小结
这一趟折腾下来,我最大的收获是:Solana的WebSocket订阅是生产环境监听交易状态的正确姿势,但需要处理好重连、状态管理和组件生命周期。如果你也在做Solana DApp,建议深入研究@solana/web3.js的Connection文档,特别是commitment参数和onSignature的细节。下一步我打算研究如何用@solana/web3.js的logsSubscribe监听智能合约事件,实现更复杂的链上数据同步。