背景
最近,我加入了一个新的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.js的Connection类。
// 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.recentBlockhash和transaction.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.tsx或index.tsx中。
踩坑记录
-
Cannot find module '@solana/wallet-adapter-base'或类型错误:这是我遇到的第一个坑。原因是@solana/wallet-adapter-*各个库之间版本不匹配。解决方法:仔细查看官方文档或GitHub仓库,使用他们推荐的版本组合,或者全部安装最新稳定版,并确保@types/react的版本与你项目中的React版本兼容。 -
交易发送失败,错误信息模糊:经常遇到
Transaction failed或Blockhash not found。排查过程:首先检查是否设置了transaction.recentBlockhash和transaction.feePayer。其次,确保获取blockhash和发送交易之间的时间间隔不能太长(Solana的区块时间约400ms),否则区块哈希会过期。解决方法:在构建交易后尽快签名和发送,或者实现重试逻辑,在失败时重新获取最新的blockhash。 -
SPL代币转账失败,提示“Invalid Account”:这是最折磨我的一个错误。我一开始试图直接用用户的
publicKey作为代币账户地址。根本原因:没有理解ATA的概念。解决方法:必须使用getOrCreateAssociatedTokenAccount函数来获取正确的代币账户地址。这个函数内部会计算派生地址。 -
钱包弹出签名窗口后,交易卡住:发生在测试网网络拥堵或RPC节点不稳定时。解决方法:一是使用更稳定、付费的RPC节点(如Helius、QuickNode),而不是公用的
clusterApiUrl。二是在UI上添加明确的加载状态和超时提示,改善用户体验。
小结
通过这一轮实战,我深刻体会到,从以太坊切换到Solana前端开发,绝不仅仅是换一个SDK,核心在于理解其账户模型和交易构建流程的差异。一旦理解了ATA、Blockhash、Fee Payer这些核心概念,剩下的就是熟练使用@solana/web3.js和@solana/spl-token提供的各种工具函数。下一步,我可以继续深入探索如何与自定义智能合约(Solana上叫Program)交互,那将是另一个充满挑战和新知识的世界。