从Promise地狱到优雅监听:我用@solana/web3.js实现Solana实时交易监听的全过程

10 阅读1分钟

背景

上个月,我在做一个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');
}

这个方案有两个致命问题:

  1. 阻塞UIsendAndConfirmTransaction会一直等待直到交易被确认,期间JavaScript主线程被占用,UI完全卡死。用户以为页面崩了,实际上是在等RPC响应。

  2. 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返回的状态有confirmedfinalized两种,我一开始没区分,导致用户看到成功但实际交易还没最终确认。

核心实现

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.comwsEndpoint写成了wss://solana-api.projectserum.com/,结果WebSocket一直连不上,报错WebSocket connection failed。后来才发现,不同RPC提供商的WebSocket端点不一样,必须用同一个服务商的。

2. 用状态机管理交易生命周期

光有监听还不够,交易有多个状态:pendingconfirmedfinalized,还可能出错。我用状态机模式管理:

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.jsConnection对象默认不会自动重连,需要自己处理。

我参考了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.jsConnection文档,特别是commitment参数和onSignature的细节。下一步我打算研究如何用@solana/web3.jslogsSubscribe监听智能合约事件,实现更复杂的链上数据同步。