背景
两个月前,我们团队接了一个 Solana 上的 NFT 市场项目。我主要负责前端,需要实现:用户连接钱包后,能查看自己拥有的 NFT 列表,以及点击"购买"按钮后,通过钱包签名完成交易。
我的问题是:之前所有 Web3 前端经验都在 EVM 链上(以太坊、Polygon),用 ethers.js 和 wagmi 很顺手。但 Solana 的账户模型和交易结构完全不同------没有合约地址、没有 ABI、没有 call 和 send 的概念,连钱包连接方式都不一样。
我当时拿到需求后,第一反应是:Solana 前端开发到底怎么搞?@solana/web3.js 这个库到底怎么用?文档看起来挺全,但一上手就发现,很多细节文档没说清楚,或者说了但我没看懂。
这篇文章就是我完整解决问题的过程记录。如果你也是从 EVM 链转到 Solana 的前端开发者,这篇文章应该能帮你省下至少两天踩坑时间。
问题分析:我最初的想法为什么行不通?
我的第一个想法是:直接调用 Solana RPC,传一个账户地址,就能拿到它的所有数据------就像 ethers.js 里用 provider.getBalance(address) 一样简单。
于是我写了这样一段代码:
typescript
import { Connection, PublicKey } from '@solana/web3.js';
const connection = new Connection('https://api.mainnet-beta.solana.com');
const publicKey = new PublicKey('用户钱包地址');
const balance = await connection.getBalance(publicKey);
这个确实能拿到 SOL 余额。但问题来了:我需要的是 NFT 列表,也就是用户拥有的 SPL Token(Solana 上的代币标准)。getBalance 只返回原生 SOL,不返回代币。
然后我找到了 getTokenAccountsByOwner 这个方法:
typescript
const tokenAccounts = await connection.getTokenAccountsByOwner(publicKey, {
programId: TOKEN_PROGRAM_ID
});
这个方法返回的是一个数组,每个元素包含 pubkey 和 account 两个字段。account.data 是一段 Buffer,需要反序列化。
这里有个大坑 :我当时直接打印 tokenAccounts,发现 account.data 是一串看不懂的十六进制数。我心想:这怎么解析?难道要手动按字节拆?
后来查了文档才知道,Solana 的账户数据是 Borsh 编码的,需要用一个叫 AccountLayout 的类来解析。而且这个 AccountLayout 在 @solana/spl-token 包里,不是 @solana/web3.js 自带的。
所以我的第一个教训是:Solana 的账户数据不是 JSON,是二进制编码,必须用对应的 Layout 来解码。
核心实现
1. 搭建基础环境:连接钱包和 RPC
首先,我选择了 @solana/wallet-adapter-react 和 @solana/wallet-adapter-react-ui 这套官方推荐的钱包连接方案。它和 wagmi 类似,提供了 React Context 和 Hook。
安装依赖:
bash
npm install @solana/web3.js @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets @solana/spl-token
然后创建 SolanaProvider:
typescript
// SolanaProvider.tsx
import { useMemo } from 'react';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter, SolflareWalletAdapter } from '@solana/wallet-adapter-wallets';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { clusterApiUrl } from '@solana/web3.js';
// 必须引入样式
import '@solana/wallet-adapter-react-ui/styles.css';
export function SolanaProvider({ children }: { children: React.ReactNode }) {
const network = WalletAdapterNetwork.Devnet;
const endpoint = useMemo(() => clusterApiUrl(network), [network]);
// 支持的 wallet 列表
const wallets = useMemo(
() => [
new PhantomWalletAdapter(),
new SolflareWalletAdapter(),
],
[network]
);
return (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
{children}
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
}
注意这个细节 :clusterApiUrl 自动生成 Devnet 的 RPC 地址,但生产环境建议用自己的 RPC 节点或第三方服务(如 Helius、QuickNode),因为默认公共 RPC 有速率限制,很容易 429。
2. 查询用户持有的 SPL Token(NFT)
接下来是核心功能:查询用户钱包中所有 SPL Token,并过滤出 NFT(即代币数量为 1 且不可分割的)。
typescript
// hooks/useUserTokens.ts
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { PublicKey } from '@solana/web3.js';
import { getAccount, getMint, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token';
import { useEffect, useState } from 'react';
interface TokenInfo {
mint: string;
amount: string;
decimals: number;
isNft: boolean;
}
export function useUserTokens() {
const { connection } = useConnection();
const { publicKey } = useWallet();
const [tokens, setTokens] = useState<TokenInfo[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!publicKey) return;
const fetchTokens = async () => {
setLoading(true);
try {
// 获取用户所有的代币账户
const tokenAccounts = await connection.getTokenAccountsByOwner(
publicKey,
{ programId: TOKEN_2022_PROGRAM_ID } // 注意:SPL Token 2022 标准用这个 Program ID
);
const tokenInfos: TokenInfo[] = [];
for (const { pubkey, account } of tokenAccounts.value) {
// 解析账户数据
const tokenAccount = getAccount(account, TOKEN_2022_PROGRAM_ID);
// 获取 mint 信息
const mintInfo = await getMint(connection, tokenAccount.mint);
tokenInfos.push({
mint: tokenAccount.mint.toBase58(),
amount: tokenAccount.amount.toString(),
decimals: mintInfo.decimals,
isNft: mintInfo.decimals === 0 && tokenAccount.amount === BigInt(1),
});
}
setTokens(tokenInfos);
} catch (error) {
console.error('获取代币失败:', error);
} finally {
setLoading(false);
}
};
fetchTokens();
}, [publicKey, connection]);
return { tokens, loading };
}
这里有个坑 :我一开始用了 TOKEN_PROGRAM_ID(旧版 SPL Token),结果发现很多新发行的 NFT 都查不到。后来才知道,现在 Solana 官方推荐使用 Token-2022 标准,它的 Program ID 是 TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb。如果你只查旧版,会漏掉大部分数据。
另一个坑是:getAccount 函数需要传入 Buffer 类型的 account.data,而不是直接传 account 对象。我一开始传错了,报错信息是 Invalid account data,排查了半小时才发现。
3. 构建并签名交易:实现"购买"功能
查询到 NFT 之后,下一步是实现购买交易。在 Solana 上,交易由指令(Instruction)组成,每个指令对应一个程序调用。
以购买 NFT 为例,假设我们调用一个市场合约的 buy 指令:
typescript
// hooks/useBuyNft.ts
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { Transaction, SystemProgram, PublicKey, TransactionInstruction } from '@solana/web3.js';
export function useBuyNft() {
const { connection } = useConnection();
const { publicKey, sendTransaction } = useWallet();
const buyNft = async (nftMint: string, price: number) => {
if (!publicKey) throw new Error('钱包未连接');
// 1. 构建交易
const transaction = new Transaction();
// 2. 添加指令:这里假设有一个市场合约,其 buy 指令需要传入 NFT mint 和价格
// 实际项目中,你需要知道市场合约的 Program ID 和指令布局
const buyInstruction = new TransactionInstruction({
keys: [
{ pubkey: publicKey, isSigner: true, isWritable: true }, // 买家
{ pubkey: new PublicKey(nftMint), isSigner: false, isWritable: true }, // NFT mint
{ pubkey: new PublicKey('市场合约地址'), isSigner: false, isWritable: true }, // 市场合约
],
programId: new PublicKey('市场合约 Program ID'), // 这是假的,需要替换
data: Buffer.from([]), // 指令数据,需要按合约规范编码
});
transaction.add(buyInstruction);
// 3. 设置最近区块哈希(必须!)
const { blockhash } = await connection.getLatestBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = publicKey;
// 4. 发送交易并让钱包签名
const signature = await sendTransaction(transaction, connection);
// 5. 等待确认
await connection.confirmTransaction(signature, 'confirmed');
return signature;
};
return { buyNft };
}
注意这个细节 :Solana 的交易必须设置 recentBlockhash,否则会被网络拒绝。这个 blockhash 有 150 个 slot 的生命周期(约 1-2 分钟),过期后需要重新获取。我一开始忘了设这个,交易一直报 Blockhash not found 错误,排查了好久。
另外,sendTransaction 是 @solana/wallet-adapter-react 提供的 Hook,它会自动弹出钱包签名界面。如果你用 Phantom 或其他钱包,它会自动处理。
4. 模拟交易:先试错再提交
在实际发送交易之前,强烈建议先用 simulateTransaction 模拟执行,避免因为参数错误导致 gas 浪费(Solana 虽然 gas 低,但也会扣)。
typescript
// 在发送之前模拟
const simulationResult = await connection.simulateTransaction(transaction);
if (simulationResult.value.err) {
console.error('模拟交易失败:', simulationResult.value.err);
throw new Error(`交易模拟失败: ${simulationResult.value.err}`);
}
// 模拟成功后再发送
const signature = await sendTransaction(transaction, connection);
这里有个坑:模拟交易时,如果指令中包含了需要签名的账户(isSigner: true),但模拟时钱包没有实际签名,会导致模拟失败。所以模拟只能检查逻辑错误,不能完全替代实际签名。
完整代码:一个可运行的 React 组件
下面是一个完整的组件,它实现了连接钱包、查询 NFT、购买 NFT 的完整流程。
typescript
// App.tsx
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { useUserTokens } from './hooks/useUserTokens';
import { useBuyNft } from './hooks/useBuyNft';
function App() {
const { publicKey } = useWallet();
const { tokens, loading } = useUserTokens();
const { buyNft } = useBuyNft();
const handleBuy = async (mint: string) => {
try {
const tx = await buyNft(mint, 0.1); // 假设价格 0.1 SOL
alert(`购买成功,交易签名: ${tx}`);
} catch (error) {
console.error('购买失败:', error);
alert('购买失败,请查看控制台错误');
}
};
return (
<div style={{ padding: '2rem' }}>
<h1>Solana NFT 市场</h1>
<WalletMultiButton />
{publicKey && (
<div>
<h2>你的 NFT 列表</h2>
{loading && <p>加载中...</p>}
{!loading && tokens.length === 0 && <p>没有找到 NFT</p>}
<ul>
{tokens.filter(t => t.isNft).map(token => (
<li key={token.mint}>
Mint: {token.mint}
<button onClick={() => handleBuy(token.mint)}>购买</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}
export default App;
踩坑记录
-
Blockhash not found错误- 现象:发送交易后返回
Blockhash not found。 - 原因:没有设置
transaction.recentBlockhash。 - 解决:调用
connection.getLatestBlockhash()获取最新 blockhash 并赋值。
- 现象:发送交易后返回
-
Invalid account data错误- 现象:调用
getAccount时报错。 - 原因:传入了错误的参数格式,
getAccount需要Buffer类型。 - 解决:确保传入
account.data(类型为Buffer),而不是整个account对象。
- 现象:调用
-
查不到 NFT 数据
- 现象:
getTokenAccountsByOwner返回空数组。 - 原因:使用了旧版
TOKEN_PROGRAM_ID,而 NFT 是 Token-2022 标准。 - 解决:改用
TOKEN_2022_PROGRAM_ID。
- 现象:
-
模拟交易成功但实际交易失败
- 现象:模拟通过,但
sendTransaction后报错。 - 原因:模拟时没有检查签名,实际签名时钱包拒绝或参数变化。
- 解决:在模拟后立即发送,避免 blockhash 过期;同时检查钱包是否已授权。
- 现象:模拟通过,但
小结
Solana 前端开发和 EVM 链有本质不同:账户数据是二进制编码需要反序列化、交易必须设置 blockhash、Token 标准有新旧之分。核心收获是:不要用 EVM 的思维去套 Solana,老老实实看文档和 SDK 的 API 签名。如果想深入,可以研究 Solana 的 PDA(程序派生地址)和跨程序调用(CPI),这是 Solana 开发的高级特性。