1. 背景:一个 NFT 铸造页面的前端需求
上个月,我参与了一个 Solana 链上的 NFT 铸造项目。项目方要求前端实现一个简单的页面:用户点击"Connect Wallet"按钮连接 Phantom 钱包,然后点击"Mint"按钮铸造 NFT。我当时的想法是:"不就是连接钱包吗?用 ethers.js 写过很多次了,Solana 应该差不多。"结果我错了。
项目用的是 React + TypeScript,我翻了一下 @solana/web3.js 的文档,发现它确实提供了一些类似 ethers.js 的 API,比如 Connection、PublicKey、Transaction。但当我真正开始写代码时,各种问题接踵而至:钱包连接后拿不到公钥、交易发送后报错"simulation failed"、甚至 Phantom 弹窗都不出来。这篇文章就是我把这些坑一个个填平的过程。
2. 问题分析:为什么我的 Solana 前端一上来就崩了?
最初的思路:照着 ethers.js 的写法
我习惯用 ethers.js 做以太坊开发,思路一般是:
- 通过
window.ethereum获取 provider - 调用
eth_requestAccounts获取账户 - 用
signer发送交易
于是我想当然地写了一段类似的 Solana 代码:
typescript
// 错误写法:直接照搬 ethers.js 的思路
const provider = window.solana; // 获取 Phantom 对象
const account = provider.publicKey.toString(); // 以为能直接拿到地址
const transaction = new Transaction().add(/* ... */);
await provider.sendTransaction(transaction); // 直接发送
结果控制台直接报错:Cannot read properties of undefined (reading 'toString')。我 debug 了半天才发现,Phantom 的 window.solana 对象在连接钱包之前,publicKey 是 null。这和 MetaMask 不一样------MetaMask 会在页面加载时自动注入账户信息,但 Phantom 必须用户手动点击连接后才会暴露公钥。
排查过程:翻文档 + 看 Phantom 官方示例
我花了一下午翻 @solana/web3.js 的文档和 Phantom 官方的 React 集成示例。发现 Solana 前端开发有几个关键点:
- 钱包连接是异步的 :必须先调用
provider.connect(),然后才能访问publicKey。 - 交易需要签名 :
sendTransaction不会自动弹出签名窗口,需要先构建Transaction对象,再调用provider.signAndSendTransaction。 - 网络配置很重要 :如果不指定
Connection的 RPC 节点,默认走 mainnet-beta,但测试时应该用 devnet。
意识到这些问题后,我决定从头开始,一步步实现一个完整可用的钱包连接和交易发送流程。
3. 核心实现:从零搭建 Solana 钱包连接
3.1 初始化 Connection 和网络选择
Solana 的 Connection 类类似于 ethers.js 的 JsonRpcProvider,用于与区块链交互。但有个坑:如果你不指定 commitment 参数,默认是 'finalized',这意味着交易确认时间会很长。我当时测试时等了 30 秒都没反应,后来发现改成 'confirmed' 就快多了。
typescript
// 初始化 Solana 连接
import { Connection, clusterApiUrl } from '@solana/web3.js';
// 选择网络:开发阶段用 devnet,上线前切换为 mainnet-beta
const network = 'devnet'; // 或者 'mainnet-beta'
const connection = new Connection(clusterApiUrl(network), {
commitment: 'confirmed', // 这里用 'confirmed' 而不是 'finalized',能显著减少等待时间
});
注意 :clusterApiUrl 返回的是 Solana 官方提供的公共 RPC 节点,生产环境建议换成自己的 RPC(比如 Helius 或 QuickNode),否则频繁请求容易被限流。
3.2 连接 Phantom 钱包并获取公钥
这是最核心的一步。我一开始犯的错误是直接读取 window.solana.publicKey,但没先调用 connect()。正确的做法是:
typescript
// 连接 Phantom 钱包
async function connectWallet(): Promise<string> {
const { solana } = window as any; // 注意:TypeScript 需要声明 window 上的 solana 属性
if (!solana || !solana.isPhantom) {
throw new Error('Phantom 钱包未安装');
}
// 关键:必须先调用 connect(),然后才能获取 publicKey
const response = await solana.connect();
const publicKey = response.publicKey.toString();
console.log('钱包连接成功,公钥:', publicKey);
return publicKey;
}
这里有个坑 :solana.connect() 返回的 response 对象包含 publicKey 属性,但有些旧版本的 Phantom 会直接返回一个 PublicKey 对象而不是 { publicKey }。所以我加了一个兼容性处理:
typescript
// 兼容不同版本的 Phantom
const publicKey = response.publicKey?.toString() || response.toString();
3.3 构建并发送交易
拿到公钥后,下一步是构建交易。Solana 的交易构建比以太坊复杂一点,因为需要指定 recentBlockhash 和 feePayer。我一开始忘了设置 recentBlockhash,结果报错"Transaction missing recent blockhash"。
typescript
import { Transaction, SystemProgram, LAMPORTS_PER_SOL } from '@solana/web3.js';
// 构建一个简单的转账交易(发送 0.1 SOL)
async function sendTransaction(fromPublicKey: string, toPublicKey: string) {
const { solana } = window as any;
const fromPubkey = new PublicKey(fromPublicKey);
const toPubkey = new PublicKey(toPublicKey);
// 第一步:获取最新的 blockhash
const { blockhash } = await connection.getLatestBlockhash();
// 第二步:构建交易
const transaction = new Transaction();
transaction.recentBlockhash = blockhash; // 必须设置,否则交易无效
transaction.feePayer = fromPubkey; // 谁支付手续费
// 添加转账指令
transaction.add(
SystemProgram.transfer({
fromPubkey,
toPubkey,
lamports: 0.1 * LAMPORTS_PER_SOL, // 0.1 SOL = 100,000,000 lamports
})
);
// 第三步:发送并签名
const signature = await solana.signAndSendTransaction(transaction);
console.log('交易已发送,签名:', signature);
// 第四步:等待确认
await connection.confirmTransaction(signature, 'confirmed');
console.log('交易已确认');
}
注意 :signAndSendTransaction 是 Phantom 提供的方法,它会自动弹出签名窗口。如果用户拒绝签名,会抛出一个错误,需要捕获处理。
3.4 在 React 组件中集成
现在把上面的逻辑封装成 React Hooks,方便复用。我写了一个 useWallet 钩子:
typescript
import { useState, useCallback } from 'react';
interface WalletState {
connected: boolean;
publicKey: string | null;
balance: number | null;
error: string | null;
}
export function useWallet() {
const [state, setState] = useState<WalletState>({
connected: false,
publicKey: null,
balance: null,
error: null,
});
const connect = useCallback(async () => {
try {
const { solana } = window as any;
if (!solana?.isPhantom) {
throw new Error('请安装 Phantom 钱包');
}
const response = await solana.connect();
const publicKey = response.publicKey.toString();
// 连接成功后查询余额
const balance = await connection.getBalance(new PublicKey(publicKey));
setState({
connected: true,
publicKey,
balance: balance / LAMPORTS_PER_SOL,
error: null,
});
} catch (err: any) {
setState(prev => ({ ...prev, error: err.message }));
}
}, []);
const disconnect = useCallback(async () => {
const { solana } = window as any;
if (solana) {
await solana.disconnect();
}
setState({
connected: false,
publicKey: null,
balance: null,
error: null,
});
}, []);
return { ...state, connect, disconnect };
}
然后组件中这样用:
tsx
function App() {
const { connected, publicKey, balance, error, connect, disconnect } = useWallet();
return (
<div>
{error && <p style={{ color: 'red' }}>{error}</p>}
{!connected ? (
<button onClick={connect}>Connect Phantom Wallet</button>
) : (
<div>
<p>地址: {publicKey}</p>
<p>余额: {balance} SOL</p>
<button onClick={disconnect}>Disconnect</button>
<button onClick={handleMint}>Mint NFT</button>
</div>
)}
</div>
);
}
4. 完整代码:可直接运行的 React 组件
下面是一个完整的、可直接复制运行的示例,包含了钱包连接、余额查询和发送交易的功能。注意:需要先安装依赖:
bash
npm install @solana/web3.js
typescript
// App.tsx
import React, { useState, useCallback } from 'react';
import {
Connection,
PublicKey,
Transaction,
SystemProgram,
LAMPORTS_PER_SOL,
clusterApiUrl,
} from '@solana/web3.js';
// 初始化连接
const connection = new Connection(clusterApiUrl('devnet'), {
commitment: 'confirmed',
});
// 目标地址(示例)
const TARGET_ADDRESS = 'Gjq3...'; // 替换为实际的接收地址
function App() {
const [publicKey, setPublicKey] = useState<string | null>(null);
const [balance, setBalance] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const connectWallet = useCallback(async () => {
try {
const { solana } = window as any;
if (!solana?.isPhantom) {
throw new Error('请安装 Phantom 钱包');
}
const response = await solana.connect();
const pubkey = response.publicKey.toString();
setPublicKey(pubkey);
// 查询余额
const bal = await connection.getBalance(new PublicKey(pubkey));
setBalance(bal / LAMPORTS_PER_SOL);
setError(null);
} catch (err: any) {
setError(err.message);
}
}, []);
const disconnectWallet = useCallback(async () => {
const { solana } = window as any;
if (solana) {
await solana.disconnect();
}
setPublicKey(null);
setBalance(null);
}, []);
const sendTransaction = useCallback(async () => {
if (!publicKey) return;
setLoading(true);
try {
const { solana } = window as any;
const fromPubkey = new PublicKey(publicKey);
const toPubkey = new PublicKey(TARGET_ADDRESS);
// 获取 blockhash
const { blockhash } = await connection.getLatestBlockhash();
// 构建交易
const transaction = new Transaction();
transaction.recentBlockhash = blockhash;
transaction.feePayer = fromPubkey;
transaction.add(
SystemProgram.transfer({
fromPubkey,
toPubkey,
lamports: 0.01 * LAMPORTS_PER_SOL, // 发送 0.01 SOL
})
);
// 签名并发送
const signature = await solana.signAndSendTransaction(transaction);
console.log('交易签名:', signature);
// 等待确认
await connection.confirmTransaction(signature, 'confirmed');
console.log('交易已确认');
// 更新余额
const newBalance = await connection.getBalance(fromPubkey);
setBalance(newBalance / LAMPORTS_PER_SOL);
setError(null);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, [publicKey]);
return (
<div style={{ padding: '20px' }}>
<h1>Solana 钱包连接示例</h1>
{error && <p style={{ color: 'red' }}>错误: {error}</p>}
{!publicKey ? (
<button onClick={connectWallet}>连接 Phantom 钱包</button>
) : (
<div>
<p>公钥: {publicKey}</p>
<p>余额: {balance !== null ? `${balance.toFixed(4)} SOL` : '加载中...'}</p>
<button onClick={disconnectWallet}>断开连接</button>
<button onClick={sendTransaction} disabled={loading}>
{loading ? '发送中...' : '发送 0.01 SOL'}
</button>
</div>
)}
</div>
);
}
export default App;
5. 踩坑记录:我实际遇到的 4 个报错
坑 1:Cannot read properties of undefined (reading 'isPhantom')
原因:Phantom 钱包未安装或未启用。有些用户安装了钱包但没在浏览器中启用扩展。
解决:增加更详细的检测逻辑,并提示用户检查扩展状态:
typescript
if (typeof window.solana === 'undefined') {
throw new Error('未检测到 Solana 钱包,请安装 Phantom 或 Solflare');
}
if (!window.solana.isPhantom) {
throw new Error('当前钱包不是 Phantom,请切换到 Phantom');
}
坑 2:Transaction simulation failed: Blockhash not found
原因 :recentBlockhash 过期了。Solana 的 blockhash 有有效期(大约 150 个 slot),如果构建交易后等待太久再发送就会失效。
解决 :在发送前重新获取 blockhash,或者使用 getLatestBlockhashAndContext 并检查 lastValidBlockHeight:
typescript
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
// 发送交易前检查当前 block height 是否超过 lastValidBlockHeight
const currentBlockHeight = await connection.getBlockHeight();
if (currentBlockHeight > lastValidBlockHeight) {
// 重新获取 blockhash
}
坑 3:Error processing Instruction 0: custom program error: 0x1
原因:这个错误通常出现在 NFT 铸造时,表示程序(Program)拒绝了交易。可能原因是账户余额不足、参数错误或权限问题。
解决 :启用 preflightCommitment: 'confirmed' 并查看详细错误日志:
typescript
const signature = await solana.signAndSendTransaction(transaction, {
preflightCommitment: 'confirmed', // 在模拟阶段就使用 confirmed 确认
});
// 如果还是报错,用 solana 的 RPC 日志查看具体原因
坑 4:User rejected the request 后界面无反馈
原因 :用户拒绝签名时,signAndSendTransaction 会抛出一个错误,但如果不捕获,界面会卡在"加载中"状态。
解决:在错误处理中区分用户取消和其他错误:
typescript
try {
await solana.signAndSendTransaction(transaction);
} catch (err: any) {
if (err.message.includes('User rejected')) {
console.log('用户取消了签名');
// 不显示红色错误,只重置状态
} else {
setError(err.message);
}
}
6. 小结
这次经历让我深刻认识到:虽然 Solana 和以太坊都是区块链,但前端开发范式差异很大。核心收获是:Solana 的钱包连接必须显式调用 connect(),交易构建必须手动设置 recentBlockhash,并且要习惯用 signAndSendTransaction 而不是 sendTransaction。
如果你也想深入 Solana 前端开发,建议继续研究以下几个方向:
- 使用
@solana/wallet-adapter-react库简化多钱包支持 - 学习 Solana 的 Program Derived Address (PDA) 和跨程序调用 (CPI)
- 掌握
@solana/spl-token处理代币和 NFT
希望这篇文章能帮你少走我走过的弯路。