背景
上个月,我接了一个Solana生态NFT项目的前端开发工作。之前我的经验主要集中在以太坊生态,用的是ethers.js和wagmi,整套流程已经很熟了。但这次客户明确要求项目部署在Solana上,我必须快速上手@solana/web3.js这个官方SDK。
项目需求很明确:一个简单的页面,用户能连接Phantom钱包,看到自己的SOL余额和持有的项目NFT,并能完成一次Mint操作。听起来和以太坊的DApp流程差不多,对吧?但真正动手才发现,从思维习惯到API设计,差异都不小。我花了差不多两天时间,才把核心链路跑通。这篇文章就是把这个“踩坑-学习-解决”的过程完整记录下来。
问题分析
一开始,我试图用以太坊的开发思维来套Solana。在以太坊里,核心对象是Provider和Signer,通过它们与链交互。我本能地在Solana的文档里寻找类似的概念。我发现@solana/web3.js里有一个Connection对象,它似乎负责与节点的RPC通信。那“Signer”呢?我找了半天,发现Solana里交易签名和钱包交互的逻辑,和以太坊很不一样。
我的第一个思路是:先创建一个Connection实例,然后想办法拿到用户的“签名者”身份。我写了下面这段初始代码:
import { Connection } from '@solana/web3.js';
const connection = new Connection('https://api.mainnet-beta.solana.com');
然后我就卡住了。在以太坊里,我可以通过window.ethereum直接唤起钱包,拿到signer。但在Solana里,我该怎么做?我查了Phantom钱包的文档,发现它向window对象注入的是solana对象,而不是一个统一的Provider。而且,这个对象如何与Connection配合生成可签名的交易,我完全没有头绪。
我意识到,我需要彻底理解Solana的几个核心概念:PublicKey(地址)、Transaction(交易结构)、以及钱包如何作为Signer参与。不能简单套用以太坊的模式。
核心实现
1. 连接Phantom钱包并获取PublicKey
在Solana中,用户的地址是一个PublicKey对象。与以太坊的十六进制字符串地址不同,它是一个包含椭圆曲线公钥信息的类。连接钱包的第一步,就是获取这个PublicKey。
Phantom钱包的浏览器扩展会注入window.solana对象。这里有个关键点:Phantom的API设计是事件驱动的。你需要检查isPhantom属性,并监听connect和disconnect事件。
我创建了一个React组件,状态里存放公钥和连接状态:
import React, { useState, useEffect } from 'react';
import { PublicKey } from '@solana/web3.js';
// 声明全局的Solana钱包类型
interface PhantomWindow extends Window {
solana?: any;
}
declare const window: PhantomWindow;
function WalletConnector() {
const [publicKey, setPublicKey] = useState<PublicKey | null>(null);
const [isConnected, setIsConnected] = useState(false);
// 检查钱包是否已安装
const checkIfWalletInstalled = () => {
return !!window.solana && window.solana.isPhantom;
};
// 连接钱包的核心函数
const connectWallet = async () => {
if (!checkIfWalletInstalled()) {
alert('请安装Phantom钱包!');
return;
}
try {
// 这里有个坑:直接调用connect()可能被用户拒绝,需要try-catch
const response = await window.solana.connect();
// response.publicKey 是一个PublicKey对象
setPublicKey(new PublicKey(response.publicKey.toString()));
setIsConnected(true);
} catch (err) {
console.error('用户拒绝了连接请求:', err);
}
};
// 组件加载时,检查是否已经连接过
useEffect(() => {
if (checkIfWalletInstalled() && window.solana.isConnected) {
setPublicKey(new PublicKey(window.solana.publicKey.toString()));
setIsConnected(true);
}
}, []);
return (
<div>
{!isConnected ? (
<button onClick={connectWallet}>连接Phantom钱包</button>
) : (
<div>
<p>已连接地址: {publicKey?.toBase58()}</p>
<button onClick={() => window.solana.disconnect()}>断开连接</button>
</div>
)}
</div>
);
}
注意这个细节:window.solana.publicKey在连接后可以直接访问,但最好还是通过connect()方法获取,因为它会处理权限请求。另外,PublicKey对象有toBase58()方法,可以转换成我们常见的字符串地址格式。
2. 建立RPC连接并查询余额
拿到PublicKey后,下一步就是通过RPC节点查询链上数据。这里需要创建Connection实例。我选择了Solana官方的主网RPC端点,但在实际项目中,强烈建议使用付费的RPC服务(如QuickNode、Helius),因为公共端点有速率限制,很容易在用户多的时候出问题。
查询余额使用connection.getBalance(),它返回的是以lamports为单位的余额。1 SOL = 10^9 lamports。
import { Connection, LAMPORTS_PER_SOL } from '@solana/web3.js';
// 创建连接
const connection = new Connection('https://api.mainnet-beta.solana.com');
// 在组件内查询余额的函数
const fetchBalance = async (publicKey: PublicKey) => {
if (!publicKey) return;
try {
// 返回的是lamports,需要转换
const balanceInLamports = await connection.getBalance(publicKey);
const balanceInSOL = balanceInLamports / LAMPORTS_PER_SOL;
console.log(`余额: ${balanceInSOL} SOL`);
return balanceInSOL;
} catch (error) {
console.error('查询余额失败:', error);
}
};
// 在useEffect中调用,当publicKey变化时
useEffect(() => {
if (publicKey) {
fetchBalance(publicKey);
}
}, [publicKey]);
这里我踩了第一个性能坑:不要在组件的渲染函数中直接调用getBalance,也不要在没有防抖的情况下频繁调用(比如用setInterval)。因为每次调用都是一次网络请求,公共RPC端点很快会把你限流。正确的做法是在连接钱包后查询一次,或者由用户主动触发刷新。
3. 构建并发送一笔简单的转账交易
这是最复杂的一步。在Solana上构建一笔交易(Transaction),和以太坊有显著区别。一个Solana交易可以包含多个指令(Instructions),每个指令都有对应的程序ID(类似于智能合约地址)、账户列表和操作数据。
为了先跑通流程,我决定实现最简单的功能:从连接的钱包向另一个地址发送0.01 SOL。这需要用到SystemProgram.transfer这个内置指令。
import {
Connection,
PublicKey,
Transaction,
SystemProgram,
sendAndConfirmTransaction,
} from '@solana/web3.js';
const sendTransaction = async (fromPublicKey: PublicKey) => {
if (!window.solana) return;
// 1. 创建一个新交易对象
const transaction = new Transaction();
// 2. 定义接收方地址(这里用了一个测试地址,实际应从输入框获取)
const toPublicKey = new PublicKey('接收方的Solana地址(Base58格式)');
// 3. 创建转账指令
const transferInstruction = SystemProgram.transfer({
fromPubkey: fromPublicKey,
toPubkey: toPublicKey,
// 金额以lamports为单位
lamports: 0.01 * LAMPORTS_PER_SOL,
});
// 4. 将指令添加到交易中
transaction.add(transferInstruction);
// 5. 设置交易的必要参数:最近区块哈希(recent blockhash)
// 这是Solana交易的一个安全特性,防止交易重放
const { blockhash } = await connection.getRecentBlockhash();
transaction.recentBlockhash = blockhash;
// 设置付费账户(即支付交易手续费和转账的账户)
transaction.feePayer = fromPublicKey;
// 6. 关键步骤:让钱包签名并发送交易
try {
// 这里有个大坑:`sendAndConfirmTransaction` 在浏览器前端环境通常不直接使用
// 因为它需要传入一个本地的签名者(Keypair),而我们的签名者是用户钱包。
// 正确的方式是使用钱包提供的方法来签名和发送。
const { signature } = await window.solana.signAndSendTransaction(transaction);
console.log('交易签名:', signature);
// 7. 确认交易
const result = await connection.confirmTransaction(signature);
console.log('交易确认状态:', result);
if (result.value.err) {
throw new Error('交易失败');
}
alert('转账成功!');
} catch (error) {
console.error('发送交易失败:', error);
alert('交易被用户取消或失败');
}
};
这里有个至关重要的坑:我最开始试图用sendAndConfirmTransaction(connection, transaction, [signer])这个函数,但很快发现我根本拿不到用户的私钥来创建本地signer(这是理所当然的,私钥永远不该离开钱包)。正确的模式是:前端构建好交易对象,然后交给钱包(通过window.solana.signAndSendTransaction)去签名和广播。这是与以太坊前端开发(signer.sendTransaction)一个很大的思维转换。
4. 处理网络费用和错误
在测试时,我遇到了交易失败,提示Blockhash not found。这是因为Solana交易中的recentBlockhash有过期时间(大约2分钟)。如果用户从弹出交易签名窗口到点击确认的时间过长,或者网络慢,这个区块哈希就可能失效。
解决方案是:在用户即将签名前(例如点击确认按钮的瞬间)再去获取最新的blockhash,而不是在构建交易时就获取。更好的做法是,监听钱包的签名过程,如果失败且原因是区块哈希过期,则自动用新的区块哈希更新交易,并让用户重新签名。
此外,手续费(Fee)由交易的第一位付款人(feePayer)支付,金额很小,但必须确保付款人账户有足够的SOL来支付。在交易模拟或发送前,最好先通过connection.getFeeForMessage估算一下手续费,并给用户提示。
完整代码
下面是一个整合了以上所有功能的简单React组件示例,你可以直接复制到一个React项目(如Create React App)中运行测试。记得先安装依赖:npm install @solana/web3.js。
import React, { useState, useEffect } from 'react';
import {
Connection,
PublicKey,
Transaction,
SystemProgram,
LAMPORTS_PER_SOL,
} from '@solana/web3.js';
// 扩展Window类型以包含Phantom
interface PhantomWindow extends Window {
solana?: any;
}
declare const window: PhantomWindow;
const SOLANA_RPC_URL = 'https://api.devnet.solana.com'; // 为测试改用开发网
const SolanaBasicDApp: React.FC = () => {
const [publicKey, setPublicKey] = useState<PublicKey | null>(null);
const [balance, setBalance] = useState<number | null>(null);
const [connection] = useState<Connection>(new Connection(SOLANA_RPC_URL));
const [toAddress, setToAddress] = useState<string>('');
const [sendAmount, setSendAmount] = useState<string>('0.01');
const [txSignature, setTxSignature] = useState<string | null>(null);
// 1. 检查并连接钱包
const checkAndConnectWallet = async () => {
if (!window.solana || !window.solana.isPhantom) {
alert('请安装Phantom钱包!');
return;
}
try {
const response = await window.solana.connect();
const pk = new PublicKey(response.publicKey.toString());
setPublicKey(pk);
// 连接后立即查询余额
fetchBalance(pk);
} catch (err) {
console.error('连接失败:', err);
}
};
// 2. 查询余额
const fetchBalance = async (pk: PublicKey) => {
try {
const lamports = await connection.getBalance(pk);
setBalance(lamports / LAMPORTS_PER_SOL);
} catch (error) {
console.error('查询余额失败:', error);
}
};
// 3. 发送SOL
const handleSendSol = async () => {
if (!publicKey || !toAddress || !sendAmount) {
alert('请填写完整信息并连接钱包');
return;
}
if (!window.solana) return;
try {
// 构建交易
const transaction = new Transaction();
const toPublicKey = new PublicKey(toAddress);
const lamports = parseFloat(sendAmount) * LAMPORTS_PER_SOL;
transaction.add(
SystemProgram.transfer({
fromPubkey: publicKey,
toPubkey: toPublicKey,
lamports,
})
);
// 关键:在发送前获取最新的区块哈希
const { blockhash } = await connection.getRecentBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = publicKey;
// 让钱包签名并发送
const { signature } = await window.solana.signAndSendTransaction(transaction);
setTxSignature(signature);
console.log('交易已发送,签名:', signature);
// 等待交易确认(简单等待,生产环境应用更健壮的轮询)
await connection.confirmTransaction(signature);
alert('转账成功!');
// 刷新余额
fetchBalance(publicKey);
} catch (error: any) {
console.error('发送交易出错:', error);
if (error.message.includes('rejected')) {
alert('用户拒绝了交易签名');
} else {
alert(`交易失败: ${error.message}`);
}
}
};
// 初始化:检查是否已连接
useEffect(() => {
if (window.solana?.isPhantom && window.solana.isConnected) {
const pk = new PublicKey(window.solana.publicKey.toString());
setPublicKey(pk);
fetchBalance(pk);
}
}, []);
return (
<div style={{ padding: '20px' }}>
<h1>Solana 基础DApp</h1>
{!publicKey ? (
<button onClick={checkAndConnectWallet}>连接Phantom钱包</button>
) : (
<div>
<p><strong>已连接地址:</strong> {publicKey.toBase58()}</p>
<p><strong>余额:</strong> {balance !== null ? `${balance.toFixed(4)} SOL` : '加载中...'}</p>
<button onClick={() => fetchBalance(publicKey)}>刷新余额</button>
<hr />
<h3>发送SOL</h3>
<div>
<input
type="text"
placeholder="接收方地址"
value={toAddress}
onChange={(e) => setToAddress(e.target.value)}
style={{ width: '400px', marginRight: '10px' }}
/>
<input
type="number"
step="0.01"
placeholder="金额 (SOL)"
value={sendAmount}
onChange={(e) => setSendAmount(e.target.value)}
style={{ width: '100px', marginRight: '10px' }}
/>
<button onClick={handleSendSol}>发送</button>
</div>
{txSignature && (
<p>
<strong>最近交易签名:</strong>{' '}
<a
href={`https://explorer.solana.com/tx/${txSignature}?cluster=devnet`}
target="_blank"
rel="noopener noreferrer"
>
{txSignature.slice(0, 8)}... (查看详情)
</a>
</p>
)}
<hr />
<button onClick={() => window.solana.disconnect()}>断开钱包连接</button>
</div>
)}
</div>
);
};
export default SolanaBasicDApp;
运行前注意:将代码中的SOLANA_RPC_URL改为https://api.mainnet-beta.solana.com可连接主网,但测试时建议先用开发网或测试网,避免损失真实资产。同时,确保你的Phantom钱包已安装,并且网络切换到对应的网络(如Devnet)。
踩坑记录
-
“sendAndConfirmTransaction” requires a signer错误:这是我遇到的第一个拦路虎。我试图像以太坊一样,用SDK直接发送交易。解决方法:理解Solana的前端签名流程必须依赖钱包扩展(如Phantom)提供的signAndSendTransaction方法,前端SDK只负责构建交易,不负责持有私钥。 -
交易因
Blockhash not found而失败:用户操作慢导致交易过期。解决方法:将获取recentBlockhash的时机尽可能推迟到用户确认前,并考虑实现交易重试逻辑,在检测到该错误时自动用新区块哈希更新交易并重新请求签名。 -
公共RPC端点速率限制:在开发时频繁刷新页面和查询余额,很快收到429错误。解决方法:对于正式项目,务必使用付费的RPC服务提供商。在开发阶段,可以适当增加查询间隔,或使用本地测试节点。
-
Phantom钱包连接状态不同步:有时页面刷新后,组件状态
isConnected为false,但钱包扩展实际已连接。解决方法:在组件初始化时(useEffect中),不仅检查window.solana.isPhantom,还要检查window.solana.isConnected,并据此同步前端状态。
小结
通过这个实战项目,我最大的收获是理解了Solana前端交互的“构建交易 -> 钱包签名 -> 广播确认”这一核心模式,它与以太坊的“Provider/Signer”模式有思维上的差异。@solana/web3.js的API虽然底层,但足够灵活。下一步,我可以在此基础上深入探索如何与自定义智能合约(Solana上叫Program)交互,处理更复杂的交易指令,以及优化交易确认的用户体验。