从以太坊转战Solana:我用@solana/web3.js实现钱包连接与代币转账的踩坑实录

5 阅读1分钟

背景

最近,我加入了一个新的Solana生态NFT项目团队。作为一个有几年以太坊前端开发经验的“老兵”,我本以为切换公链只是换个库(从ethers.js/viem换成@solana/web3.js)的事情,但现实很快给了我一个下马威。项目初期,我需要快速搭建一个基础的前端DApp,实现最核心的功能:连接Phantom钱包、显示SOL和项目代币余额、以及支持用户转账。我心想,这和在以太坊上连接MetaMask、调用合约应该大同小异吧?结果,从第一个npm install开始,我就踩进了一连串的坑里。

问题分析

我的初始思路很直接:1. 安装@solana/web3.js;2. 像window.ethereum一样寻找window.solana;3. 调用API连接、获取账户、发起交易。然而,第一步就遇到了版本兼容性问题。Solana的库生态迭代很快,一些几个月前的博客代码已经无法运行。接着,我发现Solana的交易构建和发送逻辑与以太坊有显著不同,尤其是涉及“区块哈希(blockhash)”、“最近区块(recent blockhash)”这些概念,以及交易需要显式地“部分签名(partial sign)”。最让我头疼的是SPL代币(类似于ERC20)的转账,它需要关联一个“代币账户(Token Account)”的概念,这完全超出了我基于以太坊智能合约转账的思维定式。我意识到,不能简单照搬以太坊的模式,必须从头理解Solana的账户模型和交易结构。

核心实现

1. 环境搭建与钱包连接

首先,我创建了一个新的React + TypeScript项目,并安装核心依赖。这里第一个注意点就来了:必须注意版本兼容性。我选择了当时最新的稳定版本。

npm install @solana/web3.js @solana/wallet-adapter-base @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets @solana/wallet-adapter-phantom
npm install @types/react @types/react-dom

接下来是配置钱包上下文。与以太坊直接注入window.ethereum不同,Solana生态通常使用@solana/wallet-adapter-*这套官方推荐的适配器库来统一管理不同钱包的连接,这其实更优雅。

// WalletContextProvider.tsx
import React, { useMemo } from 'react';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-phantom';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { clusterApiUrl } from '@solana/web3.js';

// 导入默认样式
import '@solana/wallet-adapter-react-ui/styles.css';

export const WalletContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
    // 网络配置,这里用Devnet测试网
    const network = WalletAdapterNetwork.Devnet;
    const endpoint = useMemo(() => clusterApiUrl(network), [network]);

    // 配置支持的钱包,这里只用了Phantom
    const wallets = useMemo(
        () => [
            new PhantomWalletAdapter(),
        ],
        []
    );

    return (
        <ConnectionProvider endpoint={endpoint}>
            <WalletProvider wallets={wallets} autoConnect>
                <WalletModalProvider>
                    {children}
                </WalletModalProvider>
            </WalletProvider>
        </ConnectionProvider>
    );
};

然后在App.tsx外层包裹这个Provider。连接按钮可以直接使用适配器库提供的WalletMultiButton,非常方便。

2. 获取连接状态与原生SOL余额

连接钱包后,我需要获取用户地址和SOL余额。这里用到了useWallet钩子,以及@solana/web3.jsConnection类。

// components/WalletInfo.tsx
import React, { useEffect, useState } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { LAMPORTS_PER_SOL } from '@solana/web3.js';

export const WalletInfo: React.FC = () => {
    const { connection } = useConnection();
    const { publicKey, connected } = useWallet();
    const [balance, setBalance] = useState<number | null>(null);
    const [loading, setLoading] = useState(false);

    useEffect(() => {
        const fetchBalance = async () => {
            if (connected && publicKey) {
                setLoading(true);
                try {
                    // 注意:getBalance返回的是lamports(1 SOL = 10^9 lamports)
                    const lamportsBalance = await connection.getBalance(publicKey);
                    setBalance(lamportsBalance / LAMPORTS_PER_SOL); // 转换为SOL
                } catch (error) {
                    console.error('获取余额失败:', error);
                    setBalance(null);
                } finally {
                    setLoading(false);
                }
            } else {
                setBalance(null);
            }
        };
        fetchBalance();
    }, [connection, publicKey, connected]); // 依赖项:连接、公钥、连接状态变化时重新获取

    if (!connected) {
        return <p>请先连接钱包</p>;
    }

    return (
        <div>
            <p>钱包地址: {publicKey?.toBase58()}</p>
            <p>
                SOL 余额: {loading ? '加载中...' : `${balance?.toFixed(4) ?? '--'} SOL`}
            </p>
        </div>
    );
};

这里有个坑connection.getBalance返回的单位是lamports,而不是SOL。所有涉及金额的计算,都必须牢记这个单位转换,否则数字会错得离谱。LAMPORTS_PER_SOL这个常量非常有用。

3. 发送原生SOL交易

发送SOL是基础功能,但构建交易的过程让我第一次深刻体会到Solana与以太坊的不同。在Solana中,你需要一个“最近区块哈希(recent blockhash)”来防止交易重放,并且交易必须被显式地签名。

// utils/sendSolTransaction.ts
import { Connection, PublicKey, Transaction, SystemProgram, sendAndConfirmTransaction } from '@solana/web3.js';
import { WalletContextState } from '@solana/wallet-adapter-react';

export const sendSol = async (
    connection: Connection,
    wallet: WalletContextState,
    toAddress: string,
    amountInSol: number
): Promise<string> => {
    // 1. 参数校验
    if (!wallet.publicKey || !wallet.signTransaction) {
        throw new Error('钱包未连接或不支持签名');
    }
    const toPublicKey = new PublicKey(toAddress);
    const lamports = amountInSol * LAMPORTS_PER_SOL;
    if (lamports <= 0) {
        throw new Error('转账金额必须大于0');
    }

    // 2. 构建交易指令
    const transaction = new Transaction().add(
        SystemProgram.transfer({
            fromPubkey: wallet.publicKey,
            toPubkey: toPublicKey,
            lamports,
        })
    );

    // 3. 获取最近区块哈希并设置到交易中(关键步骤!)
    const { blockhash } = await connection.getLatestBlockhash();
    transaction.recentBlockhash = blockhash;
    transaction.feePayer = wallet.publicKey;

    // 4. 钱包签名交易
    const signedTransaction = await wallet.signTransaction(transaction);

    // 5. 发送并等待确认
    const signature = await sendAndConfirmTransaction(connection, signedTransaction, []);
    return signature; // 返回交易哈希
};

注意这个细节transaction.recentBlockhashtransaction.feePayer是必须设置的,否则交易会无效。sendAndConfirmTransaction会等待交易被网络确认,返回交易签名(哈希)。

4. 处理SPL代币(项目代币)转账

这是最复杂的一步。在Solana上,代币余额并不直接存放在主账户(如publicKey)下,而是存放在一个独立的“关联代币账户(Associated Token Account, ATA)”中。转账前,必须确保收款方有这个代币的ATA。

// utils/sendSplToken.ts
import { Connection, PublicKey, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
import { getOrCreateAssociatedTokenAccount, createTransferInstruction } from '@solana/spl-token'; // 注意这个库
import { WalletContextState } from '@solana/wallet-adapter-react';

export const sendSplToken = async (
    connection: Connection,
    wallet: WalletContextState,
    mintAddress: string, // 代币合约地址
    toAddress: string,
    amount: number // 这里是以代币最小单位(如wei)计的数量
): Promise<string> => {
    if (!wallet.publicKey || !wallet.signTransaction) {
        throw new Error('钱包未连接或不支持签名');
    }

    const mintPublicKey = new PublicKey(mintAddress);
    const toPublicKey = new PublicKey(toAddress);

    // 1. 获取发送方的代币账户
    const fromTokenAccount = await getOrCreateAssociatedTokenAccount(
        connection,
        wallet, // 这里需要signer,用于支付创建ATA的费用(如果需要)
        mintPublicKey,
        wallet.publicKey
    );

    // 2. 获取或创建接收方的代币账户
    const toTokenAccount = await getOrCreateAssociatedTokenAccount(
        connection,
        wallet, // 注意:创建接收方ATA的费用也是由发送方支付!
        mintPublicKey,
        toPublicKey
    );

    // 3. 构建转账指令
    const transferInstruction = createTransferInstruction(
        fromTokenAccount.address, // 来源代币账户
        toTokenAccount.address,   // 目标代币账户
        wallet.publicKey,         // 代币账户所有者(发送方)
        amount
    );

    // 4. 构建、设置并发送交易
    const transaction = new Transaction().add(transferInstruction);
    const { blockhash } = await connection.getLatestBlockhash();
    transaction.recentBlockhash = blockhash;
    transaction.feePayer = wallet.publicKey;

    const signedTransaction = await wallet.signTransaction(transaction);
    const signature = await sendAndConfirmTransaction(connection, signedTransaction, []);
    return signature;
};

这里有一个巨大的坑getOrCreateAssociatedTokenAccount这个函数,如果接收方没有对应的ATA,它会自动创建。但是,创建ATA的交易费用(租金)是由调用这个函数时传入的payer(即wallet)来支付的。这意味着,作为发送方的你,需要为你转账的每一个新收款地址支付一笔额外的创建账户的SOL费用。这在产品设计时必须考虑清楚,并给用户明确的提示。另外,操作SPL代币需要安装@solana/spl-token这个专门的库。

完整代码

以下是一个整合了上述功能的简化版主组件示例:

// App.tsx
import React, { useState } from 'react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { WalletInfo } from './components/WalletInfo';
import { sendSol } from './utils/sendSolTransaction';
import { sendSplToken } from './utils/sendSplToken';
import { LAMPORTS_PER_SOL } from '@solana/web3.js';

function App() {
    const { connection } = useConnection();
    const wallet = useWallet();
    const [toAddress, setToAddress] = useState('');
    const [solAmount, setSolAmount] = useState('');
    const [tokenAmount, setTokenAmount] = useState('');
    const [txSignature, setTxSignature] = useState<string | null>(null);
    const [loading, setLoading] = useState(false);

    // 假设我们的项目代币Mint地址
    const PROJECT_TOKEN_MINT = 'YOUR_PROJECT_TOKEN_MINT_ADDRESS_HERE';

    const handleSendSol = async () => {
        if (!toAddress || !solAmount) return;
        setLoading(true);
        setTxSignature(null);
        try {
            const amount = parseFloat(solAmount);
            const sig = await sendSol(connection, wallet, toAddress, amount);
            setTxSignature(sig);
            alert(`SOL转账成功!签名: ${sig}`);
        } catch (error: any) {
            console.error(error);
            alert(`转账失败: ${error.message}`);
        } finally {
            setLoading(false);
        }
    };

    const handleSendToken = async () => {
        if (!toAddress || !tokenAmount) return;
        setLoading(true);
        setTxSignature(null);
        try {
            // 假设代币有9位小数,类似SOL
            const amount = parseFloat(tokenAmount) * LAMPORTS_PER_SOL;
            const sig = await sendSplToken(connection, wallet, PROJECT_TOKEN_MINT, toAddress, amount);
            setTxSignature(sig);
            alert(`代币转账成功!签名: ${sig}`);
        } catch (error: any) {
            console.error(error);
            alert(`代币转账失败: ${error.message}`);
        } finally {
            setLoading(false);
        }
    };

    return (
        <div style={{ padding: '20px' }}>
            <h1>Solana DApp 演示</h1>
            <div style={{ marginBottom: '20px' }}>
                <WalletMultiButton />
            </div>

            <WalletInfo />

            <hr style={{ margin: '20px 0' }} />

            <h3>转账测试</h3>
            <div>
                <input
                    type="text"
                    placeholder="收款方地址"
                    value={toAddress}
                    onChange={(e) => setToAddress(e.target.value)}
                    style={{ width: '400px', marginRight: '10px' }}
                />
            </div>
            <div style={{ marginTop: '10px' }}>
                <input
                    type="number"
                    placeholder="SOL 数量"
                    value={solAmount}
                    onChange={(e) => setSolAmount(e.target.value)}
                    style={{ marginRight: '10px' }}
                />
                <button onClick={handleSendSol} disabled={loading || !wallet.connected}>
                    {loading ? '处理中...' : '发送 SOL'}
                </button>
            </div>
            <div style={{ marginTop: '10px' }}>
                <input
                    type="number"
                    placeholder="项目代币数量"
                    value={tokenAmount}
                    onChange={(e) => setTokenAmount(e.target.value)}
                    style={{ marginRight: '10px' }}
                />
                <button onClick={handleSendToken} disabled={loading || !wallet.connected}>
                    {loading ? '处理中...' : '发送项目代币'}
                </button>
            </div>

            {txSignature && (
                <div style={{ marginTop: '20px', wordBreak: 'break-all' }}>
                    <p>最近交易签名:</p>
                    <code>{txSignature}</code>
                    <p>
                        可以在{' '}
                        <a href={`https://explorer.solana.com/tx/${txSignature}?cluster=devnet`} target="_blank" rel="noreferrer">
                            Solana Explorer
                        </a>{' '}
                        上查看。
                    </p>
                </div>
            )}
        </div>
    );
}

export default App;

记得将App组件用之前定义的WalletContextProvider包裹在main.tsxindex.tsx中。

踩坑记录

  1. Cannot find module '@solana/wallet-adapter-base' 或类型错误:这是我遇到的第一个坑。原因是@solana/wallet-adapter-*各个库之间版本不匹配。解决方法:仔细查看官方文档或GitHub仓库,使用他们推荐的版本组合,或者全部安装最新稳定版,并确保@types/react的版本与你项目中的React版本兼容。

  2. 交易发送失败,错误信息模糊:经常遇到Transaction failedBlockhash not found排查过程:首先检查是否设置了transaction.recentBlockhashtransaction.feePayer。其次,确保获取blockhash和发送交易之间的时间间隔不能太长(Solana的区块时间约400ms),否则区块哈希会过期。解决方法:在构建交易后尽快签名和发送,或者实现重试逻辑,在失败时重新获取最新的blockhash。

  3. SPL代币转账失败,提示“Invalid Account”:这是最折磨我的一个错误。我一开始试图直接用用户的publicKey作为代币账户地址。根本原因:没有理解ATA的概念。解决方法:必须使用getOrCreateAssociatedTokenAccount函数来获取正确的代币账户地址。这个函数内部会计算派生地址。

  4. 钱包弹出签名窗口后,交易卡住:发生在测试网网络拥堵或RPC节点不稳定时。解决方法:一是使用更稳定、付费的RPC节点(如Helius、QuickNode),而不是公用的clusterApiUrl。二是在UI上添加明确的加载状态和超时提示,改善用户体验。

小结

通过这一轮实战,我深刻体会到,从以太坊切换到Solana前端开发,绝不仅仅是换一个SDK,核心在于理解其账户模型交易构建流程的差异。一旦理解了ATA、Blockhash、Fee Payer这些核心概念,剩下的就是熟练使用@solana/web3.js@solana/spl-token提供的各种工具函数。下一步,我可以继续深入探索如何与自定义智能合约(Solana上叫Program)交互,那将是另一个充满挑战和新知识的世界。