背景
最近,我加入了一个新的团队,负责开发一个基于Solana链的NFT铸造平台前端。作为一个有几年以太坊生态开发经验的“老兵”,我最初觉得这不过是换个链,把ethers.js换成@solana/web3.js,把MetaMask换成Phantom钱包而已。然而,真正上手后我才发现,Solana的开发范式与EVM链差异巨大,从账户模型到交易构建,处处是“惊喜”。项目第一个里程碑是实现用户钱包连接、显示SOL余额以及支持用户使用特定SPL代币支付铸造费用。这篇文章,就记录了我从零开始,用@solana/web3.js解决这些核心需求的完整过程。
问题分析
我的第一反应是去翻看官方文档和找一些现成的React Hook库(类似wagmi)。确实找到了@solana/wallet-adapter系列库,它提供了钱包连接的UI组件和上下文。但是,当我需要执行具体的链上操作,比如查询一个自定义SPL代币的余额、构建一笔转账交易时,我发现这些高阶库封装得太好了,反而让我对底层发生了什么一无所知。一旦遇到文档没覆盖的边缘情况,或者需要调试交易失败的原因时,我就完全束手无策。
我意识到,必须从底层@solana/web3.js开始,亲手构建和发送交易,才能真正理解Solana的前端开发。我的目标很明确:1. 连接Phantom钱包;2. 获取用户SOL和特定SPL代币余额;3. 构建并发送一笔SPL代币转账交易。最初的尝试是直接照搬以太坊的思路,结果在“账户”、“关联代币账户”、“交易指令”这些概念上撞得头破血流。
核心实现
第一步:搭建环境与连接钱包
首先,我创建了一个新的React (TypeScript)项目,并安装核心依赖。
npm install @solana/web3.js @solana/wallet-adapter-wallets @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-base
@solana/wallet-adapter系列库负责钱包连接的UI和状态管理,而@solana/web3.js则是与链交互的核心工具包。这里我决定混合使用:用适配器库连接钱包,用web3.js执行所有链上操作,这样既能快速获得连接能力,又能深入理解交易细节。
我创建了一个WalletContextProvider组件来包裹应用,并初始化了主要的钱包适配器(这里以Phantom为例)。
// App.tsx
import React from 'react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-wallets';
import { clusterApiUrl } from '@solana/web3.js';
// 导入默认样式
import '@solana/wallet-adapter-react-ui/styles.css';
function App() {
const network = WalletAdapterNetwork.Devnet; // 使用开发网
const endpoint = clusterApiUrl(network);
const wallets = [new PhantomWalletAdapter()];
return (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
{/* 你的应用组件 */}
<MySolanaDapp />
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
}
export default App;
在业务组件MySolanaDapp中,我使用useWallet钩子获取连接状态和公钥。
// MySolanaDapp.tsx
import React from 'react';
import { useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
export const MySolanaDapp: React.FC = () => {
const { publicKey, connected } = useWallet();
return (
<div>
<WalletMultiButton />
{connected && <p>钱包地址: {publicKey?.toBase58()}</p>}
</div>
);
};
这里有个坑:@solana/wallet-adapter-react-ui的CSS样式需要单独导入,否则连接按钮的样式会错乱。另外,ConnectionProvider的endpoint参数非常重要,它决定了你的应用连接哪个Solana集群(主网、测试网、开发网或自定义RPC)。项目初期务必使用Devnet(开发网),因为主网的SOL是真金白银。
第二步:获取SOL余额与创建连接对象
钱包连接成功后,下一步是获取用户的SOL余额。这需要用到@solana/web3.js的Connection对象。ConnectionProvider已经为我们创建了一个全局的连接对象,但为了更清晰地展示流程,我选择在组件内手动创建一次。
import { Connection, LAMPORTS_PER_SOL } from '@solana/web3.js';
import { useWallet } from '@solana/wallet-adapter-react';
import { useEffect, useState } from 'react';
export const MySolanaDapp: React.FC = () => {
const { publicKey, connected } = useWallet();
const [solBalance, setSolBalance] = useState<number | null>(null);
const [connection] = useState(() => new Connection(clusterApiUrl('devnet')));
useEffect(() => {
const fetchBalance = async () => {
if (!publicKey) {
setSolBalance(null);
return;
}
try {
// 获取余额,单位是Lamports(1 SOL = 10^9 Lamports)
const balanceInLamports = await connection.getBalance(publicKey);
// 转换为SOL单位
const balanceInSOL = balanceInLamports / LAMPORTS_PER_SOL;
setSolBalance(balanceInSOL);
} catch (error) {
console.error('获取SOL余额失败:', error);
setSolBalance(null);
}
};
fetchBalance();
}, [publicKey, connection]); // 当公钥变化时重新获取
return (
<div>
{/* ... 钱包连接按钮 ... */}
{connected && (
<div>
<p>钱包地址: {publicKey?.toBase58()}</p>
<p>SOL余额: {solBalance !== null ? `${solBalance.toFixed(4)} SOL` : '加载中...'}</p>
</div>
)}
</div>
);
};
这一步相对简单,和以太坊的provider.getBalance(address)很像。关键是要记住余额的单位是Lamports,需要除以LAMPORTS_PER_SOL(一个常量)来转换成我们熟悉的SOL。
第三步:理解关联代币账户(ATA)并查询SPL代币余额
接下来是重头戏,也是我踩坑最多的地方:查询SPL代币(类似于ERC20)的余额。在Solana上,用户的代币并不直接存储在钱包的主账户(即我们刚才查SOL余额的账户)里。相反,每种代币都有一个独立的“关联代币账户”。
每个关联代币账户(Associated Token Account, ATA)由钱包地址和代币的铸币地址(Mint Address)共同决定,并且可以通过一个确定性的算法计算出来。这意味着,用户可能还没有持有某种代币,也就没有对应的ATA。查询之前,我们需要先找到(或计算出)这个ATA的地址,然后查询它的余额。
import { Connection, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
import { TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID, getAssociatedTokenAddress } from '@solana/spl-token'; // 注意这个新包
import { useEffect, useState } from 'react';
// 假设我们想查询的SPL代币的铸币地址(这里是USDC在Devnet的测试地址)
const USDC_MINT_DEVNET = new PublicKey('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
export const MySolanaDapp: React.FC = () => {
const { publicKey, connected } = useWallet();
const [usdcBalance, setUsdcBalance] = useState<number | null>(null);
const [connection] = useState(() => new Connection(clusterApiUrl('devnet')));
useEffect(() => {
const fetchTokenBalance = async () => {
if (!publicKey) {
setUsdcBalance(null);
return;
}
try {
// 1. 计算用户对于USDC代币的关联代币账户(ATA)地址
const associatedTokenAddress = await getAssociatedTokenAddress(
USDC_MINT_DEVNET, // 代币铸币地址
publicKey, // 代币持有者(用户)地址
false, // 是否允许所有者不是关联者?通常为false
TOKEN_PROGRAM_ID,
ASSOCIATED_TOKEN_PROGRAM_ID
);
// 2. 获取该ATA账户的信息
const accountInfo = await connection.getAccountInfo(associatedTokenAddress);
if (accountInfo === null) {
// 账户不存在,意味着用户余额为0(或者从未持有过该代币)
setUsdcBalance(0);
return;
}
// 3. 解析ATA账户数据,获取余额(单位是代币的最小单位,例如USDC是6位小数)
// 这里需要@solana/spl-token包中的`AccountLayout`来解析,更简单的方法是使用`getAccount`
// 我们先安装并引入`spl-token`包
import { getAccount } from '@solana/spl-token';
const tokenAccount = await getAccount(connection, associatedTokenAddress);
// tokenAccount.amount 是一个BigInt,表示基础单位的数量
const balance = Number(tokenAccount.amount) / (10 ** 6); // 假设USDC是6位小数
setUsdcBalance(balance);
} catch (error) {
console.error('获取USDC余额失败:', error);
// 如果是因为ATA不存在而报错,可以视为余额0
if (error instanceof Error && error.message.includes('Account does not exist')) {
setUsdcBalance(0);
} else {
setUsdcBalance(null);
}
}
};
fetchTokenBalance();
}, [publicKey, connection]);
return (
<div>
{/* ... 其他UI ... */}
{connected && (
<div>
<p>USDC余额: {usdcBalance !== null ? `${usdcBalance.toFixed(4)} USDC` : '加载中...'}</p>
</div>
)}
</div>
);
};
注意这个细节:我额外安装了@solana/spl-token包(npm install @solana/spl-token),它提供了与SPL代币交互的更高层级的工具函数,比如getAssociatedTokenAddress和getAccount,这比直接用web3.js的原始方法解析账户数据要方便和安全得多。另外,代币余额的单位也是坑,需要根据代币的小数位数(decimals)进行转换,这个信息通常存储在代币的元数据或铸币账户中,示例中我直接硬编码了USDC的6位小数。
第四步:构建并发送SPL代币转账交易
最后,也是最复杂的一步:让用户发送一笔USDC代币转账。在Solana中,一笔交易(Transaction)包含一个或多个指令(Instruction)。一个SPL代币转账指令至少需要以下几个步骤:
- 确保发送方和接收方的ATA存在(如果接收方的ATA不存在,可能需要先创建它)。
- 构建一个
transferChecked指令(或transfer)。 - 将指令添加到交易中。
- 获取最近的区块哈希(作为交易的“门票”)。
- 用户(发送方)签名并发送交易。
由于创建接收方ATA的步骤会增加复杂性,我决定先实现一个前提:假设接收方已经拥有对应代币的ATA。在实际产品中,你需要处理ATA不存在的场景,通常是通过在转账交易中附加一个“创建ATA”的指令来完成。
import { Connection, PublicKey, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
import { getAssociatedTokenAddress, createTransferCheckedInstruction, getAccount } from '@solana/spl-token';
import { useWallet } from '@solana/wallet-adapter-react';
import { useState } from 'react';
const USDC_MINT_DEVNET = new PublicKey('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
export const MySolanaDapp: React.FC = () => {
const { publicKey, connected, signTransaction, sendTransaction } = useWallet();
const [connection] = useState(() => new Connection(clusterApiUrl('devnet')));
const [recipientAddress, setRecipientAddress] = useState('');
const [transferAmount, setTransferAmount] = useState('');
const [isSending, setIsSending] = useState(false);
const handleTransfer = async () => {
if (!publicKey || !recipientAddress || !transferAmount || isSending) return;
setIsSending(true);
try {
// 1. 验证接收方地址格式
const recipientPubkey = new PublicKey(recipientAddress);
// 2. 获取发送方和接收方的ATA地址
const fromATA = await getAssociatedTokenAddress(USDC_MINT_DEVNET, publicKey);
const toATA = await getAssociatedTokenAddress(USDC_MINT_DEVNET, recipientPubkey);
// 3. 检查发送方ATA是否存在且有足够余额(可选但推荐)
const fromAccount = await getAccount(connection, fromATA);
const amountInSmallestUnit = Math.floor(parseFloat(transferAmount) * (10 ** 6)); // 转换为最小单位
if (fromAccount.amount < BigInt(amountInSmallestUnit)) {
throw new Error('余额不足');
}
// 4. 构建转账指令
const transferInstruction = createTransferCheckedInstruction(
fromATA, // 来源ATA
USDC_MINT_DEVNET, // 代币铸币地址
toATA, // 目标ATA
publicKey, // 代币的授权所有者(即发送方)
amountInSmallestUnit, // 转账数量(最小单位)
6 // 代币的小数位数
);
// 5. 构建交易并添加指令
const transaction = new Transaction().add(transferInstruction);
// 6. 获取最近区块哈希并设置为交易的最近区块引用(防止重放攻击)
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = publicKey;
// 7. 发送交易(使用钱包适配器的sendTransaction,它会处理签名和发送)
// 注意:这里使用钱包适配器提供的`sendTransaction`,而不是`spl-token`或`web3.js`的
const signature = await sendTransaction(transaction, connection);
console.log('交易已发送,签名:', signature);
// 8. 确认交易(等待区块链确认)
const confirmation = await connection.confirmTransaction({
signature,
blockhash,
lastValidBlockHeight,
});
if (confirmation.value.err) {
throw new Error('交易确认失败');
}
alert(`转账成功!交易签名: ${signature}`);
} catch (error: any) {
console.error('转账失败:', error);
alert(`转账失败: ${error.message}`);
} finally {
setIsSending(false);
setTransferAmount(''); // 清空输入框
}
};
return (
<div>
{/* ... 其他UI ... */}
{connected && (
<div>
<h3>转账USDC</h3>
<input
type="text"
placeholder="接收方Solana地址"
value={recipientAddress}
onChange={(e) => setRecipientAddress(e.target.value)}
/>
<input
type="number"
step="0.000001"
placeholder="转账数量"
value={transferAmount}
onChange={(e) => setTransferAmount(e.target.value)}
/>
<button onClick={handleTransfer} disabled={isSending}>
{isSending ? '发送中...' : '发送'}
</button>
</div>
)}
</div>
);
};
这里有个巨大的坑:我一开始试图直接用@solana/web3.js的sendAndConfirmTransaction函数,并手动调用钱包的signTransaction方法。但这样处理起来非常繁琐,而且容易出错。后来发现,@solana/wallet-adapter-react提供的useWallet钩子中的sendTransaction方法已经完美地封装了签名和发送的过程,它会自动弹出钱包(如Phantom)让用户确认并签名,这是最佳实践。务必使用这个sendTransaction,而不是自己手动处理签名。
另一个细节是recentBlockhash和feePayer必须设置,否则交易无效。feePayer通常是交易的发起者(即发送方)。
完整代码
以下是一个整合了以上所有功能的简化版完整组件代码,你可以直接复制到一个React TypeScript项目中运行(确保已安装所有依赖)。
// MySolanaDapp.tsx
import React, { useEffect, useState } from 'react';
import { Connection, PublicKey, clusterApiUrl } from '@solana/web3.js';
import { getAssociatedTokenAddress, createTransferCheckedInstruction, getAccount } from '@solana/spl-token';
import { useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
// 开发网USDC测试代币铸币地址
const USDC_MINT_DEVNET = new PublicKey('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
const SOL_DECIMALS = 9;
const USDC_DECIMALS = 6;
export const MySolanaDapp: React.FC = () => {
const { publicKey, connected, sendTransaction } = useWallet();
const [connection] = useState(() => new Connection(clusterApiUrl('devnet'), 'confirmed'));
const [solBalance, setSolBalance] = useState<number | null>(null);
const [usdcBalance, setUsdcBalance] = useState<number | null>(null);
const [recipientAddress, setRecipientAddress] = useState('');
const [transferAmount, setTransferAmount] = useState('');
const [isSending, setIsSending] = useState(false);
// 获取SOL余额
useEffect(() => {
const fetchSolBalance = async () => {
if (!publicKey) {
setSolBalance(null);
return;
}
try {
const balanceInLamports = await connection.getBalance(publicKey);
setSolBalance(balanceInLamports / 10 ** SOL_DECIMALS);
} catch (error) {
console.error('获取SOL余额失败:', error);
setSolBalance(null);
}
};
fetchSolBalance();
}, [publicKey, connection]);
// 获取USDC余额
useEffect(() => {
const fetchUsdcBalance = async () => {
if (!publicKey) {
setUsdcBalance(null);
return;
}
try {
const associatedTokenAddress = await getAssociatedTokenAddress(
USDC_MINT_DEVNET,
publicKey
);
const tokenAccount = await getAccount(connection, associatedTokenAddress);
setUsdcBalance(Number(tokenAccount.amount) / 10 ** USDC_DECIMALS);
} catch (error: any) {
// 如果账户不存在,余额为0
if (error.message?.includes('Account does not exist')) {
setUsdcBalance(0);
} else {
console.error('获取USDC余额失败:', error);
setUsdcBalance(null);
}
}
};
fetchUsdcBalance();
}, [publicKey, connection]);
// 处理USDC转账
const handleTransfer = async () => {
if (!publicKey || !recipientAddress || !transferAmount || isSending) return;
setIsSending(true);
try {
const recipientPubkey = new PublicKey(recipientAddress);
const fromATA = await getAssociatedTokenAddress(USDC_MINT_DEVNET, publicKey);
const toATA = await getAssociatedTokenAddress(USDC_MINT_DEVNET, recipientPubkey);
// 检查余额
const fromAccount = await getAccount(connection, fromATA);
const amountInSmallestUnit = Math.floor(parseFloat(transferAmount) * 10 ** USDC_DECIMALS);
if (fromAccount.amount < BigInt(amountInSmallestUnit)) {
throw new Error('USDC余额不足');
}
// 构建指令和交易
const transferInstruction = createTransferCheckedInstruction(
fromATA,
USDC_MINT_DEVNET,
toATA,
publicKey,
amountInSmallestUnit,
USDC_DECIMALS
);
const transaction = new Transaction().add(transferInstruction);
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = publicKey;
// 通过钱包发送交易
const signature = await sendTransaction(transaction, connection);
console.log('交易签名:', signature);
// 等待确认
await connection.confirmTransaction({
signature,
blockhash,
lastValidBlockHeight,
});
alert(`转账成功!签名: ${signature}`);
setTransferAmount(''); // 清空输入
// 刷新余额
const newBalance = Number(fromAccount.amount) / 10 ** USDC_DECIMALS - parseFloat(transferAmount);
setUsdcBalance(newBalance);
} catch (error: any) {
console.error('转账失败:', error);
alert(`转账失败: ${error.message}`);
} finally {
setIsSending(false);
}
};
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Solana Web3.js 入门实战</h1>
<div style={{ marginBottom: '20px' }}>
<WalletMultiButton />
</div>
{connected && publicKey && (
<div>
<h2>账户信息</h2>
<p><strong>地址:</strong> {publicKey.toBase58()}</p>
<p><strong>SOL余额:</strong> {solBalance !== null ? `${solBalance.toFixed(4)} SOL` : '加载中...'}</p>
<p><strong>USDC余额:</strong> {usdcBalance !== null ? `${usdcBalance.toFixed(4)} USDC` : '加载中...'}</p>
<hr style={{ margin: '20px 0' }} />
<h2>USDC转账</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '400px' }}>
<input
type="text"
placeholder="接收方Solana地址"
value={recipientAddress}
onChange={(e) => setRecipientAddress(e.target.value)}
style={{ padding: '8px' }}
/>
<input
type="number"
step="0.000001"
placeholder="转账数量 (USDC)"
value={transferAmount}
onChange={(e) => setTransferAmount(e.target.value)}
style={{ padding: '8px' }}
/>
<button
onClick={handleTransfer}
disabled={isSending || !recipientAddress || !transferAmount}
style={{
padding: '10px',
backgroundColor: isSending ? '#ccc' : '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isSending ? 'not-allowed' : 'pointer'
}}
>
{isSending ? '发送中...' : '发送 USDC'}
</button>
<p style={{ fontSize: '0.9em', color: '#666' }}>
注意:请确保接收方地址有效,且已在Devnet持有USDC测试代币的关联账户。
</p>
</div>
</div>
)}
</div>
);
};
踩坑记录
-
“Account does not exist” 错误当查询代币余额时:这是我遇到的第一个拦路虎。我直接用用户的主账户地址去查询代币余额,结果一直报错。后来才明白必须通过
getAssociatedTokenAddress先计算出该代币的ATA地址。如果ATA不存在(用户从未持有过该代币),getAccount会抛出这个错误。解决方法:在错误处理中捕获此特定错误,并将余额视为0。 -
交易发送失败,提示“Blockhash not found”:我在构建交易时忘了设置
recentBlockhash和feePayer。Solana交易需要这两个字段来防止重放攻击和支付手续费。解决方法:在发送交易前,务必调用connection.getLatestBlockhash()获取最新的区块哈希,并将其与feePayer一起设置到交易对象中。 -
手动签名流程复杂且容易出错:最初我尝试用
connection.sendTransaction,然后自己从钱包获取签名,过程非常繁琐,还经常遇到签名不匹配的问题。解决方法:直接使用@solana/wallet-adapter-react的useWallet钩子提供的sendTransaction方法。它内部处理了与钱包的交互、签名和发送,是官方推荐的方式。 -
代币金额单位混淆:和SOL一样,SPL代币的余额也有最小单位。我一开始直接把UI上输入的数值传给了指令,导致转账了巨大数量的代币(例如想转1个,实际转了10^6个)。解决方法:牢记代币的小数位数(decimals),在构建指令前,将UI输入的数量乘以
10 ** decimals转换为最小单位整数。
小结
通过这次实战,我深刻体会到Solana编程的核心在于理解其账户模型和交易指令系统。从“以太坊思维”切换过来需要一些时间,但亲手用@solana/web3.js走通整个流程后,心里踏实多了。下一步,我可以继续探索如何动态创建关联代币账户、如何与智能合约(Solana上叫Program)交互,以及如何优化交易确认的体验。希望这篇记录也能帮你绕过我踩过的这些坑。