背景
上个月,团队接了一个Solana生态的NFT项目,需要开发一个允许用户连接钱包、查看余额并铸造NFT的前端界面。作为一个在以太坊和EVM兼容链上摸爬滚打了五年的前端,我的工具箱里装满了ethers.js、viem和wagmi。当任务切换到Solana时,我意识到得从头学起。核心的挑战很明确:我需要快速掌握@solana/web3.js这个官方SDK,用它来实现钱包连接、读取链上数据和发送交易这些基础但至关重要的功能。一开始我以为这和以太坊开发大同小异,结果一脚踩进了好几个坑里。
问题分析
我的第一反应是去翻@solana/web3.js的官方文档和示例。文档结构清晰,但当我试图把文档里的代码片段拼凑成一个完整的React应用时,问题来了。首先,钱包连接逻辑和以太坊的window.ethereum完全不同,Solana主流钱包如Phantom将接口注入到window.solana。其次,账户模型差异巨大:Solana使用公钥(PublicKey)作为地址,交易需要"最近区块哈希"和"手续费支付者"等概念,这让我一开始构建交易时屡屡失败。最初的几次尝试,不是钱包弹不出连接框,就是交易签名后发送失败,控制台报错信息又比较晦涩。我意识到,不能只是机械地复制代码,必须理解Solana交易构建的基本流程。
核心实现
1. 环境搭建与钱包连接
首先,我创建了一个新的React + TypeScript项目,并安装核心依赖:
bash
npm install @solana/web3.js @solana/wallet-adapter-base @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets
这里有个关键点:单纯用@solana/web3.js也能连接钱包,但社区更推荐使用@solana/wallet-adapter-*这一套工具库,它封装了连接逻辑和UI组件,能省不少事。
接下来,我设置钱包上下文。这是整个应用能调用钱包功能的基础:
typescript
// 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-wallets';
import { clusterApiUrl } from '@solana/web3.js';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { MyComponent } from './MyComponent';
// 导入默认样式
import '@solana/wallet-adapter-react-ui/styles.css';
function App() {
// 配置网络。开发时通常用devnet或testnet,这里用devnet
const network = WalletAdapterNetwork.Devnet;
const endpoint = useMemo(() => clusterApiUrl(network), [network]);
// 配置支持的钱包列表
const wallets = useMemo(
() => [
new PhantomWalletAdapter(),
// 可以继续添加其他钱包适配器,如Solflare
],
[]
);
return (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
<MyComponent />
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
}
export default App;
注意这个细节 :ConnectionProvider的endpoint参数是必须的,它指定了你的应用要连接哪个Solana集群(主网、测试网等)。autoConnect属性会在页面加载时尝试重新连接上次的钱包,提升用户体验。
2. 获取钱包地址与余额
在子组件MyComponent中,我使用适配器提供的钩子来获取钱包状态和连接信息。
typescript
// MyComponent.tsx
import React, { useState, useEffect } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { LAMPORTS_PER_SOL } from '@solana/web3.js';
export const MyComponent: 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]);
return (
<div>
<p>钱包状态: {connected ? '已连接' : '未连接'}</p>
{publicKey && <p>钱包地址: {publicKey.toBase58()}</p>}
{loading && <p>查询余额中...</p>}
{balance !== null && !loading && <p>余额: {balance} SOL</p>}
</div>
);
};
这里有个坑 :connection.getBalance()返回的单位是lamports,而不是SOL。直接显示这个数字会非常大,必须除以LAMPORTS_PER_SOL(10^9)来转换。我一开始没注意,显示了一个9位数的"余额",闹了笑话。
3. 构建并发送一笔SOL转账交易
这是最核心也最容易出错的部分。在Solana上,一笔交易可以包含多个指令,我们需要构建一个"系统程序"的转账指令。
typescript
// 在MyComponent.tsx中添加发送交易函数
import { SystemProgram, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
const sendTransaction = async () => {
// 1. 基础校验
if (!publicKey || !connected) {
alert('请先连接钱包');
return;
}
if (!connection) {
alert('连接异常');
return;
}
// 2. 构建交易指令
// 假设我们向这个地址转账0.01 SOL
const toPublicKey = new PublicKey('接收方的Solana地址(Base58格式)');
const transferAmount = 0.01; // SOL
const lamportsToSend = transferAmount * LAMPORTS_PER_SOL;
const transferInstruction = SystemProgram.transfer({
fromPubkey: publicKey,
toPubkey: toPublicKey,
lamports: lamportsToSend,
});
// 3. 创建交易并添加指令
const transaction = new Transaction().add(transferInstruction);
// 4. 获取"最近区块哈希"(Recent Blockhash)------这是Solana交易必需的
let blockhash;
try {
const { blockhash: recentBlockhash } = await connection.getLatestBlockhash();
blockhash = recentBlockhash;
transaction.recentBlockhash = blockhash;
// 5. 设置交易的费用支付者(Fee Payer)
transaction.feePayer = publicKey;
} catch (error) {
console.error('获取区块哈希失败:', error);
alert('获取网络信息失败,请重试');
return;
}
// 6. 请求钱包签名并发送
try {
// 这里使用了wallet-adapter的signTransaction方法
// 注意:在真实场景中,我们通常使用wallet-adapter提供的sendTransaction方法,它内部处理了签名和发送。
// 但为了演示底层过程,这里先展示需要手动签名的流程,后面会给出更优方案。
const signedTransaction = await signTransaction(transaction); // 假设signTransaction来自useWallet
const signature = await connection.sendRawTransaction(signedTransaction.serialize());
console.log('交易已发送,签名:', signature);
// 7. 确认交易
const confirmation = await connection.confirmTransaction(signature);
if (confirmation.value.err) {
throw new Error('交易确认失败');
}
alert(`转账成功!交易签名: ${signature}`);
} catch (error: any) {
console.error('发送交易失败:', error);
alert(`交易失败: ${error.message}`);
}
};
注意这个细节 :recentBlockhash和feePayer是Solana交易对象必须设置的两个属性,缺一不可。忘记设置feePayer是我遇到的第一个报错。recentBlockhash用于防止交易重放,并让验证者知道交易的有效期。
4. 使用Wallet Adapter优化交易发送
上面的手动签名流程比较繁琐,而且useWallet钩子并不直接暴露signTransaction方法。实际上,@solana/wallet-adapter-react提供了更优雅的sendTransaction方法。
typescript
// 这是更推荐的实践,修改MyComponent.tsx
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
const { connection } = useConnection();
const { publicKey, sendTransaction } = useWallet(); // 使用钩子提供的sendTransaction
const sendTransactionEasy = async () => {
if (!publicKey) return;
const toPublicKey = new PublicKey('接收方地址');
const lamportsToSend = 0.01 * LAMPORTS_PER_SOL;
const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: publicKey,
toPubkey: toPublicKey,
lamports: lamportsToSend,
})
);
// 关键步骤:获取区块哈希并设置
const { blockhash } = await connection.getLatestBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = publicKey;
try {
// 一行代码搞定:钱包适配器会处理弹窗签名、发送、获取签名结果
const signature = await sendTransaction(transaction, connection);
console.log('交易签名:', signature);
// 可选:等待交易确认
const result = await connection.confirmTransaction(signature, 'confirmed');
console.log('确认结果:', result);
alert('转账成功!');
} catch (error: any) {
console.error('交易出错:', error);
alert(`用户拒绝或交易失败: ${error.message}`);
}
};
这里有个巨大的进步 :使用钱包适配器提供的sendTransaction方法,我们不需要手动处理签名、序列化、发送原始交易这些底层细节。它会自动触发钱包的签名请求,并返回交易签名。代码简洁且健壮。
完整代码
以下是一个整合了所有功能、可以直接运行的MyComponent.tsx示例:
typescript
import React, { useState, useEffect } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { SystemProgram, Transaction, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
export const MyComponent: React.FC = () => {
const { connection } = useConnection();
const { publicKey, connected, sendTransaction } = useWallet();
const [balance, setBalance] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const [recipient, setRecipient] = useState('');
// 获取余额
useEffect(() => {
const fetchBalance = async () => {
if (connected && publicKey) {
setLoading(true);
try {
const lamportsBalance = await connection.getBalance(publicKey);
setBalance(lamportsBalance / LAMPORTS_PER_SOL);
} catch (error) {
console.error('获取余额失败:', error);
setBalance(null);
} finally {
setLoading(false);
}
} else {
setBalance(null);
}
};
fetchBalance();
}, [connection, publicKey, connected]);
// 发送SOL交易
const handleSendSol = async () => {
if (!publicKey || !recipient) {
alert('请先连接钱包并填写接收地址');
return;
}
let toPubkey;
try {
toPubkey = new PublicKey(recipient);
} catch {
alert('接收地址格式无效');
return;
}
const transferAmount = 0.01; // 固定转账0.01 SOL,实际项目可以做成输入框
const lamportsToSend = transferAmount * LAMPORTS_PER_SOL;
const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: publicKey,
toPubkey: toPubkey,
lamports: lamportsToSend,
})
);
try {
const { blockhash } = await connection.getLatestBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = publicKey;
setSending(true);
const signature = await sendTransaction(transaction, connection);
console.log('交易完成,签名:', signature);
// 等待最终确认,提供更好反馈
await connection.confirmTransaction(signature, 'confirmed');
alert(`成功转账${transferAmount} SOL!交易签名: ${signature}`);
setRecipient(''); // 清空输入框
// 重新获取余额
const newBalance = await connection.getBalance(publicKey);
setBalance(newBalance / LAMPORTS_PER_SOL);
} catch (error: any) {
console.error('交易失败:', error);
if (error.message.includes('User rejected')) {
alert('您拒绝了交易签名。');
} else {
alert(`交易失败: ${error.message}`);
}
} finally {
setSending(false);
}
};
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Solana Web3.js 入门实战</h1>
<div style={{ marginBottom: '20px' }}>
<WalletMultiButton />
</div>
{connected && publicKey && (
<div>
<p>
<strong>钱包地址:</strong> {publicKey.toBase58()}
</p>
<p>
<strong>余额:</strong>{' '}
{loading ? '加载中...' : balance !== null ? `${balance.toFixed(4)} SOL` : '--'}
</p>
<hr style={{ margin: '20px 0' }} />
<h3>发送 SOL 测试</h3>
<div>
<input
type="text"
placeholder="输入接收方Solana地址"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
style={{ width: '400px', padding: '8px', marginRight: '10px' }}
/>
<button onClick={handleSendSol} disabled={sending || !recipient}>
{sending ? '发送中...' : '发送 0.01 SOL'}
</button>
<p style={{ fontSize: '0.9em', color: '#666', marginTop: '5px' }}>
请确保在Devnet网络,并使用Devnet的SOL进行测试。
</p>
</div>
</div>
)}
{!connected && <p>请点击上方按钮连接钱包(推荐Phantom)。</p>}
</div>
);
};
踩坑记录
-
"Cannot read properties of undefined (reading 'solana')" :这是我遇到的第一个错误。原因是我在没有安装Phantom钱包(或任何Solana钱包)的浏览器中运行代码。
window.solana对象不存在。解决方法:在代码中增加判断,或者引导用户安装钱包。钱包适配器的UI按钮会自动处理这个状态。 -
"Transaction recentBlockhash required" :构建交易后发送失败。我忘记给交易对象
transaction设置recentBlockhash属性。解决方法 :在发送交易前,必须调用connection.getLatestBlockhash()并赋值给transaction.recentBlockhash。 -
"FeePayer must be a PublicKey" :设置了
recentBlockhash后依然报错。因为我连feePayer也没设置。解决方法 :将当前用户的公钥publicKey赋值给transaction.feePayer。记住,这两个属性是SolanaTransaction对象的必选项。 -
交易签名成功但链上确认失败 :在测试网发送交易,钱包签名弹窗成功了,但最后交易失败。原因是我用的RPC节点不稳定或响应慢。解决方法 :更换更稳定、快速的RPC端点。对于开发,可以使用Solana基金会提供的公共端点
clusterApiUrl('devnet'),但对于生产环境,需要考虑使用付费的私有RPC服务以获得更好的可靠性。
小结
通过这个从零到一的实践,我深刻体会到Solana前端开发在交易构建细节上与EVM的差异。核心收获是:理解Solana交易必须包含recentBlockhash和feePayer,并善用@solana/wallet-adapter系列工具库能极大提升开发效率。下一步,我可以基于此继续探索如何与SPL代币(类似ERC20)交互、如何解析NFT元数据,以及如何与自定义的智能合约(Solana上称为程序)进行交互。