背景:一个看似简单的 NFT 铸造需求
两个月前,我接手了一个 Solana 上的 NFT 铸造项目。产品需求很明确:用户在浏览器中连接 Phantom 钱包,输入数量,点击"铸造"按钮,就能 Mint 一个 NFT。听起来和以太坊上的流程差不多,我当时想:"不就是调个钱包 API 嘛,半天搞定。"
结果,我花了整整两天时间,才让第一笔交易成功上链。问题出在哪里?不是我不懂区块链,而是我对 Solana 的"前端开发范式"完全没概念。在以太坊上,我用 ethers.js 或 wagmi 习惯了,但 Solana 的账户模型、交易结构、钱包交互方式都和以太坊完全不同。更坑的是,@solana/web3.js 的版本迭代很快,网上很多教程用的是 v1.x,而现在已经是 v2.x 了,API 变化很大。
我当时的困境是:钱包连接上了,也能获取到地址,但一发送交易就报错,不是"invalid account"就是"failed to serialize"。这篇文章,就是我把整个排查和实现过程完整记录下来,希望帮到同样在 Solana 前端路上踩坑的你。
问题分析:为什么官方示例跑不通?
我的第一步,当然是去翻 @solana/web3.js 的官方文档。文档给了一个简单的"发送 SOL"示例,大概长这样:
typescript
import { Connection, Transaction, SystemProgram, LAMPORTS_PER_SOL } from '@solana/web3.js';
const connection = new Connection('https://api.mainnet-beta.solana.com');
const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: sender.publicKey,
toPubkey: receiver.publicKey,
lamports: LAMPORTS_PER_SOL,
})
);
const signature = await sendAndConfirmTransaction(connection, transaction, [sender]);
我当时心想:"这不挺简单吗?直接抄过来改改就行。" 但是,当我把这段代码放到 React 项目里时,问题接踵而至。
第一个坑:sendAndConfirmTransaction 这个函数在 v2.x 里已经废弃了。 是的,我用的 @solana/web3.js 版本是 2.0.0,这个 API 被移除了。取而代之的是 sendTransaction 和独立的 confirmTransaction 方法。但文档的示例还是旧的,我一开始没注意版本号,直接复制粘贴,然后报错说找不到这个函数。
第二个坑:钱包的 signTransaction 方法返回的是 Uint8Array,不是 Transaction 对象。 我用 Phantom 钱包的 window.solana.signTransaction(transaction) 时,发现它返回的是一个序列化后的字节数组,而 sendTransaction 需要的是 Transaction 对象。这就尴尬了------我到底该用哪个?
第三个坑:确认交易时,状态码的含义。 交易发送后,我需要等待确认。但 confirmTransaction 返回的 SignatureResult 里有个 err 字段,如果交易失败,err 是一个对象,不是简单的字符串。我第一次没做错误判断,直接用了 result.value 的布尔值,结果交易失败了还显示"成功"。
这三个坑让我意识到,官方文档只是告诉你"能做什么",但没告诉你"在真实项目中怎么做"。我需要一个完整的、能直接跑通的流程。
核心实现:从零搭建 Solana 前端交互
第一步:选对版本,装对依赖
我先确认了项目环境:React 18 + TypeScript + @solana/web3.js v2.0.0。安装命令很简单:
bash
npm install @solana/web3.js@2.0.0
但这里有个细节:如果你需要钱包适配(比如 Phantom),还要装 @solana/wallet-adapter-wallets 和 @solana/wallet-adapter-react。我当时只装了核心库,结果用 window.solana 直接操作时,发现它和 React 的状态管理不太兼容。后来我改用 @solana/wallet-adapter-base 来统一处理钱包连接,省心很多。
不过,为了更贴近"纯 @solana/web3.js"的使用场景,我决定在本文中只依赖核心库和 Phantom 的官方 API,不引入额外的钱包适配器。这样你就能更清楚地看到每一步在做什么。
注意: Solana 的 RPC 节点需要选择。我用的是 Helius 的公共节点(https://api.devnet.solana.com),因为开发网免费且稳定。主网的话,建议用 QuickNode 或自己搭建的节点,避免被限流。
第二步:连接钱包并获取账户信息
核心思路:使用 window.solana 对象(Phantom 注入的)来请求连接,然后获取用户的公钥。这里有个坑:window.solana 在页面加载时可能还没准备好,需要检查是否存在。
我写了一个自定义 Hook,专门处理钱包连接:
typescript
// hooks/useWallet.ts
import { useEffect, useState } from 'react';
import { PublicKey, Connection } from '@solana/web3.js';
interface WalletState {
publicKey: PublicKey | null;
connected: boolean;
connect: () => Promise<void>;
disconnect: () => void;
}
export function useWallet(): WalletState {
const [publicKey, setPublicKey] = useState<PublicKey | null>(null);
const [connected, setConnected] = useState(false);
const connect = async () => {
try {
// 检查 Phantom 是否安装
if (!window.solana || !window.solana.isPhantom) {
alert('请安装 Phantom 钱包!');
return;
}
// 请求连接
const response = await window.solana.connect();
// response.publicKey 是一个 PublicKey 对象
setPublicKey(response.publicKey);
setConnected(true);
} catch (error) {
console.error('连接失败:', error);
}
};
const disconnect = () => {
window.solana.disconnect();
setPublicKey(null);
setConnected(false);
};
// 监听账户变化
useEffect(() => {
if (window.solana?.on) {
window.solana.on('accountChanged', (publicKey: PublicKey | null) => {
if (publicKey) {
setPublicKey(publicKey);
} else {
setPublicKey(null);
setConnected(false);
}
});
}
return () => {
window.solana?.removeAllListeners('accountChanged');
};
}, []);
return { publicKey, connected, connect, disconnect };
}
这里有个坑: 我一开始以为 window.solana.connect() 返回的是 { publicKey: string },但实际上它返回的是 { publicKey: PublicKey },而且 PublicKey 是一个类,不是普通的字符串。如果你直接把它存成字符串,后面做交易时会报类型错误。
第三步:构建并发送交易
这是最核心的部分。我需要在用户点击"铸造"按钮时,构建一个包含 NFT 铸造指令的交易,然后让用户用钱包签名,最后发送到链上。
Solana 的交易结构是:一个 Transaction 对象包含一个或多个 Instruction,每个 Instruction 指定程序 ID、账户列表和数据。对于 NFT 铸造,我需要调用 Metaplex 的 Candy Machine 程序,但为了简化演示,这里我用一个简单的 SOL 转账为例,流程是一样的。
typescript
// utils/sendTransaction.ts
import { Connection, Transaction, SystemProgram, LAMPORTS_PER_SOL, sendAndConfirmTransaction } from '@solana/web3.js';
export async function sendSol(
connection: Connection,
fromPubkey: PublicKey,
toPubkey: PublicKey,
amount: number
): Promise<string> {
// 构建交易
const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: fromPubkey,
toPubkey: toPubkey,
lamports: amount * LAMPORTS_PER_SOL, // 注意单位转换
})
);
// 设置交易参数:最新区块哈希和费用
const { blockhash } = await connection.getLatestBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = fromPubkey;
// 用钱包签名
const signedTransaction = await window.solana.signTransaction(transaction);
// 发送交易
const signature = await connection.sendRawTransaction(signedTransaction.serialize());
// 等待确认
const confirmation = await connection.confirmTransaction(signature, 'confirmed');
if (confirmation.value.err) {
throw new Error(`交易失败: ${JSON.stringify(confirmation.value.err)}`);
}
return signature;
}
注意这个细节: 我用了 sendRawTransaction 而不是 sendTransaction。这是因为 window.solana.signTransaction 返回的是签名后的 Transaction 对象,但 sendTransaction 方法在 v2.x 中接收的是序列化后的字节数组。所以我们需要调用 signedTransaction.serialize() 来获取 Uint8Array,然后用 sendRawTransaction 发送。
这里有个坑: 我一开始用 sendTransaction(signedTransaction),结果报错说"Transaction object is not serializable"。后来查文档才发现,sendTransaction 在 v2.x 中已经被重构了,它期望的参数是 Uint8Array 或 Buffer,而不是 Transaction 对象。所以正确做法是 sendRawTransaction。
第四步:处理确认状态和错误
确认交易时,confirmTransaction 返回的是一个 Promise<SignatureResult>,其中 SignatureResult 的结构是:
typescript
{
context: { slot: number },
value: { err: object | null }
}
如果 err 不为 null,说明交易失败。但 err 可能是一个对象(如 { InstructionError: [0, "Custom"] }),也可能是一个字符串。所以不能简单用 if (err) 来判断,需要解析。
我写了一个辅助函数:
typescript
function parseTransactionError(err: any): string {
if (err === null) return '成功';
if (typeof err === 'string') return err;
if (err.InstructionError) {
const [index, errorCode] = err.InstructionError;
return `指令 ${index} 错误: ${errorCode}`;
}
return JSON.stringify(err);
}
这样在 UI 上就能显示具体的错误信息,而不是一个冰冷的"交易失败"。
完整代码:一个可运行的 React 组件
下面是一个完整的 React 组件,集成了钱包连接和 SOL 转账功能。你可以直接复制到一个新的 React 项目中运行,只需要确保安装了 @solana/web3.js@2.0.0。
tsx
// App.tsx
import React, { useState } from 'react';
import { Connection, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
import { useWallet } from './hooks/useWallet';
import { sendSol } from './utils/sendTransaction';
// 使用 Devnet 节点
const connection = new Connection('https://api.devnet.solana.com', 'confirmed');
function App() {
const { publicKey, connected, connect, disconnect } = useWallet();
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState(0.01);
const [status, setStatus] = useState('');
const handleSend = async () => {
if (!publicKey) return;
setStatus('正在构建交易...');
try {
const toPubkey = new PublicKey(recipient);
const signature = await sendSol(connection, publicKey, toPubkey, amount);
setStatus(`交易成功!签名: ${signature}`);
} catch (error) {
setStatus(`交易失败: ${error.message}`);
}
};
return (
<div style={{ padding: '20px' }}>
<h1>Solana 钱包交互示例</h1>
{!connected ? (
<button onClick={connect}>连接 Phantom 钱包</button>
) : (
<div>
<p>已连接: {publicKey.toBase58()}</p>
<button onClick={disconnect}>断开连接</button>
<div style={{ marginTop: '20px' }}>
<input
type="text"
placeholder="接收地址 (Base58)"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<input
type="number"
step="0.001"
value={amount}
onChange={(e) => setAmount(parseFloat(e.target.value))}
/>
<button onClick={handleSend}>发送 SOL</button>
</div>
<p>{status}</p>
</div>
)}
</div>
);
}
export default App;
注意: 这个组件依赖上面写的 useWallet Hook 和 sendSol 函数。你需要创建对应的文件并导出。另外,window.solana 的类型定义需要安装 @solana/wallet-adapter-base 或手动声明:
typescript
// global.d.ts
interface Window {
solana: any;
}
踩坑记录:我实际遇到的 4 个报错
-
TypeError: Cannot read properties of undefined (reading 'signTransaction')- 原因:
window.solana未定义,因为 Phantom 没安装或未注入。 - 解决:在
connect()中加入if (!window.solana)的检查,并提示用户安装。
- 原因:
-
Error: Transaction simulation failed: Invalid account data- 原因:我试图发送一个 NFT 铸造交易,但账户的
owner不是预期的程序。 - 解决:检查了
fromPubkey是否正确,以及是否调用了正确的程序 ID。后来发现是账户数据格式问题,需要先获取账户的accountInfo。
- 原因:我试图发送一个 NFT 铸造交易,但账户的
-
Error: 410 Gone: This RPC node is not available- 原因:使用了公共 RPC 节点,但请求频率过高被限流。
- 解决:切换到开发网节点
https://api.devnet.solana.com,并在本地缓存区块哈希,减少不必要的 RPC 调用。
-
Error: Transaction has already been processed- 原因:重复发送了同一笔交易(比如用户双击了提交按钮)。
- 解决:在发送交易前禁用按钮,并使用
useRef存储当前交易的 blockhash,避免重复。
小结
这次经历让我深刻体会到,Solana 和以太坊的前端开发思路完全不同。核心收获是:一定要理解 Solana 的交易模型------Transaction 是容器,Instruction 是操作,签名后序列化再发送。 同时,不要完全信任官方文档的示例,要结合版本号确认 API 是否已废弃。如果你想继续深挖,可以研究一下 @solana/web3.js 的 TransactionMessage API(v2.x 新引入的),它提供了一种更声明式的交易构建方式。