从零到一:我在Solana NFT铸造前端中搞定@solana/web3.js连接与交易
背景
上个月,团队决定开拓新链,启动了一个基于Solana的NFT铸造项目。作为团队里Web3前端经验相对丰富的,我自然被分配了搭建前端DApp的任务。我之前主要深耕以太坊和EVM兼容链,对ethers.js、wagmi那一套滚瓜烂熟,心想换个链的SDK能有多难?结果,从熟悉的ethers.providers.Web3Provider切换到@solana/web3.js的Connection类,从MetaMask切换到Phantom钱包,这一路的“水土不服”让我踩的坑比预想的多得多。我的首要目标很简单:让用户能用Phantom钱包连接,并正确显示其SOL余额。
问题分析
一开始,我试图沿用EVM链的思维模式。在以太坊上,流程通常是:注入的window.ethereum -> new ethers.providers.Web3Provider() -> 获取账号和余额。我查了@solana/web3.js的文档,发现核心是Connection(连接节点)和PublicKey(地址)。我的初步思路是:
- 检测Phantom钱包(
window.solana)。 - 连接钱包,获取公钥(
PublicKey)。 - 用
Connection查询该公钥的余额。
听起来很直接,但我马上遇到了第一个拦路虎:连接钱包后,余额始终为0。我确认了钱包里有SOL,RPC节点也换了好几个(devnet, mainnet-beta的公共节点)。排查后发现,问题出在两个地方:一是对Solana余额单位(lamports vs SOL)的转换不熟悉,二是没有正确处理钱包连接和状态变化的异步事件。这让我意识到,不能简单照搬EVM的模式,得从头理解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
这里有个关键点:单纯用@solana/web3.js也能直接操作window.solana,但社区更推荐使用@solana/wallet-adapter-*这一套工具库。它提供了React上下文、钩子和一套标准的UI组件,能更好地管理钱包状态、支持多钱包,并处理了大量底层细节。我决定采用这个推荐方案,避免重复造轮子。
钱包检测和连接的核心逻辑,我们封装在自定义钩子或上下文中。但首先,要在应用根组件进行配置。
2. 配置钱包上下文与连接节点
在App.tsx或主组件中,我们需要设置钱包适配器和提供连接。
// App.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';
function App() {
// 配置网络。这里以开发网为例,上线需切主网
const network = WalletAdapterNetwork.Devnet;
// 使用Memoized,避免每次渲染都创建新的endpoint和wallets实例
const endpoint = useMemo(() => clusterApiUrl(network), [network]);
const wallets = useMemo(
() => [
new PhantomWalletAdapter(),
// 可以添加其他钱包适配器,如Solflare
],
[]
);
return (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
{/* 你的应用组件 */}
<MyWalletComponent />
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
}
export default App;
注意这个细节:ConnectionProvider的endpoint参数非常重要。公共节点可能有速率限制或不稳定,对于生产环境,强烈建议使用付费的RPC服务(如QuickNode, Helius)提供的专属节点URL,这能极大提升连接稳定性和查询速度。
3. 连接钱包与获取余额
接下来,在具体的组件MyWalletComponent中,我们使用适配器提供的钩子来操作钱包和获取数据。
// components/MyWalletComponent.tsx
import React, { useEffect, useState } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { LAMPORTS_PER_SOL } from '@solana/web3.js';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
export const MyWalletComponent: 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 (!connection || !publicKey) {
setBalance(null);
return;
}
setLoading(true);
try {
// 这里有个坑:getBalance返回的是lamports(1 SOL = 10^9 lamports)
const lamportsBalance = await connection.getBalance(publicKey);
// 转换为SOL单位
const solBalance = lamportsBalance / LAMPORTS_PER_SOL;
setBalance(solBalance);
} catch (error) {
console.error('获取余额失败:', error);
setBalance(null);
} finally {
setLoading(false);
}
};
fetchBalance();
// 可以设置一个定时器来轮询余额,但对于实时性要求高的,建议用websocket订阅
}, [connection, publicKey]); // 依赖项:连接对象和公钥
return (
<div>
<WalletMultiButton />
{connected && publicKey ? (
<div>
<p>钱包地址: {publicKey.toBase58()}</p>
{loading ? (
<p>查询余额中...</p>
) : (
<p>余额: {balance !== null ? `${balance.toFixed(4)} SOL` : '--'}</p>
)}
</div>
) : (
<p>请连接钱包</p>
)}
</div>
);
};
这里有个大坑:connection.getBalance(publicKey)返回的是number类型的lamports,而不是SOL。直接显示这个数字会让人误以为余额极小。必须除以LAMPORTS_PER_SOL(一个常量,值为1_000_000_000)来转换。这是我一开始显示余额为0的罪魁祸首之一(因为我的devnet账户余额是2 SOL,显示为2_000_000_000 lamports,我误以为是0)。
4. 构造并发送一笔简单的转账交易
显示余额之后,下一步自然是想让用户能操作。我们实现一个简单的SOL转账功能。
// 在MyWalletComponent中添加状态和函数
import { SystemProgram, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
// ... 其他导入
export const MyWalletComponent: React.FC = () => {
// ... 之前的 states 和 hooks
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
const [sending, setSending] = useState(false);
const handleSendSol = async () => {
if (!publicKey || !recipient || !amount) {
alert('请填写完整信息');
return;
}
const lamports = parseFloat(amount) * LAMPORTS_PER_SOL;
if (isNaN(lamports) || lamports <= 0) {
alert('请输入有效的金额');
return;
}
setSending(true);
try {
// 1. 创建交易对象
const transaction = new Transaction();
// 2. 添加转账指令
const transferInstruction = SystemProgram.transfer({
fromPubkey: publicKey,
toPubkey: new PublicKey(recipient),
lamports,
});
transaction.add(transferInstruction);
// 3. 获取最近的区块哈希(Recent Blockhash),这是Solana交易必需的
const { blockhash } = await connection.getRecentBlockhash();
transaction.recentBlockhash = blockhash;
// 设置付费方(fee payer)
transaction.feePayer = publicKey;
// 4. 发送交易并等待确认
// 注意:这里需要钱包适配器来签名,不能直接用sendAndConfirmTransaction
// 我们先获取签名,然后发送
const signature = await sendTransaction(transaction, connection);
// 5. 等待确认(可选,对于快速反馈,可以只等“预确认”)
await connection.confirmTransaction(signature, 'confirmed');
alert(`转账成功!交易哈希: ${signature}`);
// 成功后刷新余额
const newBalance = await connection.getBalance(publicKey);
setBalance(newBalance / LAMPORTS_PER_SOL);
setRecipient('');
setAmount('');
} catch (error: any) {
console.error('转账失败:', error);
alert(`转账失败: ${error.message}`);
} finally {
setSending(false);
}
};
// 注意:我们需要从useWallet钩子中解构出sendTransaction函数
const { sendTransaction } = useWallet();
return (
<div>
{/* ... 之前的连接和余额显示代码 */}
{connected && (
<div>
<h3>转账SOL</h3>
<input
type="text"
placeholder="接收方地址"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<input
type="number"
step="any"
placeholder="金额 (SOL)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<button onClick={handleSendSol} disabled={sending}>
{sending ? '发送中...' : '发送'}
</button>
</div>
)}
</div>
);
};
这里有个至关重要的区别:在EVM链,我们通常用signer.sendTransaction(tx)一步完成签名和发送。而在Solana,构造交易(Transaction)和签名/发送是分离的。我们先用@solana/web3.js构造一个包含指令(Instruction)和必要元数据(blockhash, feePayer)的交易对象,然后通过钱包适配器提供的sendTransaction方法,将交易对象交给钱包(如Phantom)去签名并发送到网络。这是Solana交易模型的一个核心特点。
完整代码
以下是一个整合后的、可直接运行的简化版App.tsx,展示了完整的连接、查余额、转账流程。
// App.tsx
import React, { useMemo, useState, useEffect } from 'react';
import { ConnectionProvider, WalletProvider, useConnection, useWallet } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-phantom';
import { WalletModalProvider, WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { clusterApiUrl, PublicKey, SystemProgram, Transaction, LAMPORTS_PER_SOL } from '@solana/web3.js';
import '@solana/wallet-adapter-react-ui/styles.css';
// 主应用包装器
function AppWrapper() {
const network = WalletAdapterNetwork.Devnet;
const endpoint = useMemo(() => clusterApiUrl(network), [network]);
const wallets = useMemo(() => [new PhantomWalletAdapter()], []);
return (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Solana Web3.js 入门实战</h1>
<WalletDemo />
</div>
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
}
// 主要演示组件
function WalletDemo() {
const { connection } = useConnection();
const { publicKey, connected, sendTransaction } = useWallet();
const [balance, setBalance] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
const [sending, setSending] = useState(false);
// 获取余额
useEffect(() => {
const updateBalance = async () => {
if (!connection || !publicKey) {
setBalance(null);
return;
}
setLoading(true);
try {
const lamports = await connection.getBalance(publicKey);
setBalance(lamports / LAMPORTS_PER_SOL);
} catch (err) {
console.error(err);
setBalance(null);
} finally {
setLoading(false);
}
};
updateBalance();
}, [connection, publicKey]);
// 处理转账
const handleSend = async () => {
if (!publicKey || !recipient || !amount || !sendTransaction) return;
const lamports = parseFloat(amount) * LAMPORTS_PER_SOL;
if (isNaN(lamports) || lamports <= 0) {
alert('Invalid amount');
return;
}
setSending(true);
try {
const transaction = new Transaction();
transaction.add(
SystemProgram.transfer({
fromPubkey: publicKey,
toPubkey: new PublicKey(recipient),
lamports,
})
);
const { blockhash } = await connection.getRecentBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = publicKey;
const signature = await sendTransaction(transaction, connection);
console.log('Transaction signature:', signature);
// 等待确认,可根据需求调整确认级别('processed', 'confirmed', 'finalized')
await connection.confirmTransaction(signature, 'confirmed');
alert(`Sent ${amount} SOL to ${recipient}! Tx: ${signature}`);
// 刷新余额
const newLamports = await connection.getBalance(publicKey);
setBalance(newLamports / LAMPORTS_PER_SOL);
setRecipient('');
setAmount('');
} catch (error: any) {
console.error('Send failed:', error);
alert(`Send failed: ${error.message}`);
} finally {
setSending(false);
}
};
return (
<div>
<div style={{ marginBottom: '20px' }}>
<WalletMultiButton />
</div>
{connected && publicKey ? (
<div>
<p>
<strong>Address:</strong> {publicKey.toBase58().slice(0, 8)}...
</p>
<p>
<strong>Balance:</strong>{' '}
{loading ? 'Loading...' : balance !== null ? `${balance.toFixed(4)} SOL` : '--'}
</p>
<div style={{ marginTop: '30px', borderTop: '1px solid #ccc', paddingTop: '20px' }}>
<h3>Transfer SOL</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '400px' }}>
<input
type="text"
placeholder="Recipient Public Key"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
style={{ padding: '8px' }}
/>
<input
type="number"
step="any"
placeholder="Amount (SOL)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ padding: '8px' }}
/>
<button onClick={handleSend} disabled={sending} style={{ padding: '10px' }}>
{sending ? 'Sending...' : 'Send'}
</button>
</div>
<p style={{ fontSize: '0.9em', color: '#666', marginTop: '10px' }}>
<small>Use devnet SOL for testing. Get some from a faucet.</small>
</p>
</div>
</div>
) : (
<p>Connect your wallet to get started.</p>
)}
</div>
);
}
export default AppWrapper;
踩坑记录
-
余额显示为0或极小值:这是最经典的坑。
connection.getBalance()返回的是lamports,我没做转换就直接显示。解决方法:牢记SOL = lamports / LAMPORTS_PER_SOL。 -
交易发送失败:
Missing recent blockhash:构造交易对象Transaction后,没有设置recentBlockhash和feePayer属性就直接发送。解决方法:必须在发送前调用connection.getRecentBlockhash()获取,并赋值给transaction.recentBlockhash,同时明确指定transaction.feePayer。 -
Phantom钱包弹窗连接后,状态没更新:直接监听
window.solana的connect事件,但React状态管理混乱。解决方法:使用@solana/wallet-adapter-react提供的useWallet钩子,它封装了状态管理,connected和publicKey状态会自动更新。 -
sendTransactionis not a function:我试图直接从@solana/web3.js导入sendAndConfirmTransaction并传入交易对象,但这需要私钥签名者。在浏览器前端,私钥由钱包保管。解决方法:使用从useWallet()钩子解构出来的sendTransaction方法,它将交易发送到钱包扩展进行签名。
小结
这一趟下来,我最大的收获是理解了Solana前端交互的“范式转换”:从EVM的Provider/Signer模型,转向Solana的Connection/Transaction/Wallet Adapter模型。核心在于明确职责分离:前端构造交易,钱包负责签名。掌握了连接、查余额、转账这三板斧,就算在Solana前端开发中站稳了脚跟。接下来,可以继续深挖如何与智能合约(Solana叫Program)交互,比如调用一个NFT铸造的指令,那又会涉及到不同的指令构造和账户(Account)管理,将是下一个有趣的挑战。