背景:一个看似简单的 NFT 元数据上传需求
上个月我在做一个小众 NFT 项目------一个链上生成艺术作品的平台。用户上传图片,前端把它和描述、属性组合成 JSON,然后铸造到链上。听起来很常规对吧?但当我真正开始写代码时,第一个坎就来了:元数据放哪儿?
我最初的想法是直接把 JSON 字符串传给智能合约的 mint 函数。但合约那边返回错误:"Data too large"。后来才知道,以太坊区块有 gas 限制,一个完整的 JSON 元数据(包含图片 base64)可能几 KB 到几十 KB,直接上链成本高到离谱,而且大部分 NFT 标准(如 ERC-721)的 tokenURI 只接受一个指向外部存储的 URL。
所以正确做法是:把图片和 JSON 元数据存到去中心化存储(IPFS),然后把 IPFS 的哈希(或 HTTP 网关地址)传给合约。我选了 Pinata,因为它是目前最成熟、文档最清晰、免费额度也够用的 IPFS 固定服务(pinning service)。
问题分析:为什么我一开始被卡住了?
我刚开始的思路很简单:用 axios 或者 fetch 直接调 Pinata 的 API。文档看起来也很直接------POST 到 https://api.pinata.cloud/pinning/pinFileToIPFS 上传文件,再 POST 到 https://api.pinata.cloud/pinning/pinJSONToIPFS 上传 JSON。
但第一个坑就来了:CORS 错误。
csharp
Access to fetch at 'https://api.pinata.cloud/pinning/pinFileToIPFS' from origin 'http://localhost:3000' has been blocked by CORS policy
我以为是本地开发环境的问题,但后来发现即使部署到生产环境,只要域名不在 Pinata 后台的白名单里,一样报错。当时我就在想:难道每个用户都要去 Pinata 后台配域名?那项目还怎么上线?
后来仔细看了文档,发现 Pinata 的 API 有两种认证方式:JWT(JSON Web Token)和 API Key + Secret Key。JWT 方式可以避免 CORS 问题,因为它是直接在前端请求头里带 Authorization: Bearer <jwt>,不需要通过服务器代理。
另一个坑是:上传图片和上传 JSON 是两套不同的 API 。图片用 FormData 传二进制流,JSON 用普通 POST 传字符串。我之前以为可以一次搞定,结果分开写了两个函数才跑通。
核心实现:一步步搞定 Pinata 上传
1. 获取 JWT Token(这个步骤容易忽略)
首先,你需要在 Pinata 后台生成一个 JWT token。登录 Pinata 后,点击右上角头像 → API Keys → 新建一个 Key,权限勾选 pinning 相关项。生成后你会得到一个 JWT 字符串,类似 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...。
注意 :这个 JWT 一定要保存在前端环境变量中(如 .env.local),不要硬编码在代码里。我当时图省事直接写在代码里,结果提交到 GitHub 后几分钟就收到了 Pinata 的安全警告邮件。虽然只是 test key,但也是教训。
2. 上传图片到 IPFS
上传图片的核心是构建 FormData。这里有个细节:Pinata 的 pinFileToIPFS 接口要求 file 字段是文件,pinataMetadata 字段是 JSON 字符串(包含 name 等元数据)。我一开始把 pinataMetadata 也当普通字段传,结果一直报 400 错误。
typescript
// uploadImageToIPFS.ts
import axios, { AxiosResponse } from 'axios';
const PINATA_JWT = process.env.NEXT_PUBLIC_PINATA_JWT!;
const PINATA_GATEWAY = 'https://gateway.pinata.cloud/ipfs/';
interface PinataResponse {
IpfsHash: string;
PinSize: number;
Timestamp: string;
}
export async function uploadImageToIPFS(file: File): Promise<string> {
const formData = new FormData();
formData.append('file', file);
// 注意:pinataMetadata 必须是一个 JSON 字符串,不能直接传对象
const metadata = JSON.stringify({
name: file.name,
keyvalues: {
type: 'nft-image'
}
});
formData.append('pinataMetadata', metadata);
const options = JSON.stringify({
cidVersion: 1,
wrapWithDirectory: false
});
formData.append('pinataOptions', options);
try {
const response: AxiosResponse<PinataResponse> = await axios.post(
'https://api.pinata.cloud/pinning/pinFileToIPFS',
formData,
{
headers: {
'Authorization': `Bearer ${PINATA_JWT}`,
'Content-Type': 'multipart/form-data',
},
}
);
// 返回 IPFS 哈希
const ipfsHash = response.data.IpfsHash;
console.log('图片上传成功,IPFS 哈希:', ipfsHash);
return ipfsHash;
} catch (error) {
console.error('上传图片失败:', error);
throw new Error('图片上传失败');
}
}
这里有个坑 :cidVersion 推荐用 1(对应 CID v1),因为 v0 是 base58btc 编码,v1 是 base32 编码,兼容性更好。我之前用 v0 导致有些浏览器无法正常解析网关地址。
3. 上传 JSON 元数据到 IPFS
JSON 上传比图片简单,直接传对象就行。但要注意:JSON 中的 image 字段必须指向图片的 IPFS 哈希 ,通常是 ipfs://<hash> 格式。我一开始用了 HTTP 网关地址(如 https://gateway.pinata.cloud/ipfs/<hash>),但后来发现很多钱包(如 MetaMask、Rainbow)只支持 ipfs:// 协议,不支持 HTTP 网关。
typescript
// uploadMetadataToIPFS.ts
interface NFTMetadata {
name: string;
description: string;
image: string; // ipfs:// 格式
attributes: Array<{ trait_type: string; value: string | number }>;
}
export async function uploadMetadataToIPFS(metadata: NFTMetadata): Promise<string> {
try {
const response: AxiosResponse<PinataResponse> = await axios.post(
'https://api.pinata.cloud/pinning/pinJSONToIPFS',
{
pinataContent: metadata,
pinataMetadata: {
name: `${metadata.name} Metadata`,
},
pinataOptions: {
cidVersion: 1,
},
},
{
headers: {
'Authorization': `Bearer ${PINATA_JWT}`,
'Content-Type': 'application/json',
},
}
);
const ipfsHash = response.data.IpfsHash;
console.log('元数据上传成功,IPFS 哈希:', ipfsHash);
return ipfsHash;
} catch (error) {
console.error('上传元数据失败:', error);
throw new Error('元数据上传失败');
}
}
4. 组合使用:从图片到元数据的完整流程
在实际项目中,用户先上传图片,我们拿到图片的 IPFS 哈希,然后构建元数据 JSON,再上传 JSON 获取最终的 tokenURI。这个流程看起来简单,但顺序很重要:必须先上传图片,再上传元数据。
typescript
// useNFTCreation.ts
import { useState } from 'react';
import { uploadImageToIPFS } from './uploadImageToIPFS';
import { uploadMetadataToIPFS } from './uploadMetadataToIPFS';
export function useNFTCreation() {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createNFTMetadata = async (
imageFile: File,
name: string,
description: string,
attributes: Array<{ trait_type: string; value: string | number }>
): Promise<string> => {
setUploading(true);
setError(null);
try {
// 步骤1: 上传图片
console.log('正在上传图片...');
const imageHash = await uploadImageToIPFS(imageFile);
// 步骤2: 构建元数据,image 字段用 ipfs:// 协议
const metadata: NFTMetadata = {
name,
description,
image: `ipfs://${imageHash}`,
attributes,
};
// 步骤3: 上传元数据
console.log('正在上传元数据...');
const metadataHash = await uploadMetadataToIPFS(metadata);
// 返回完整的 tokenURI(ipfs:// 格式)
return `ipfs://${metadataHash}`;
} catch (err) {
const message = err instanceof Error ? err.message : '创建 NFT 元数据失败';
setError(message);
throw err;
} finally {
setUploading(false);
}
};
return { createNFTMetadata, uploading, error };
}
注意这个细节 :tokenURI 返回的是 ipfs://<metadataHash>,而不是 HTTP 网关地址。这样钱包和平台(如 OpenSea)会自动解析。如果你非要测试,可以用网关地址临时查看,但正式上线一定用 ipfs:// 协议。
完整代码:一个可直接运行的 React 组件
下面是一个完整的 React 组件示例,用户选择图片、填写信息后,点击"铸造"按钮,自动完成上传并返回 tokenURI。你只需要替换 PINATA_JWT 为自己的 token。
tsx
// NFTCreationForm.tsx
import React, { useState } from 'react';
import { useNFTCreation } from './useNFTCreation';
const NFTCreationForm: React.FC = () => {
const [imageFile, setImageFile] = useState<File | null>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [tokenURI, setTokenURI] = useState<string | null>(null);
const { createNFTMetadata, uploading, error } = useNFTCreation();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!imageFile || !name) {
alert('请选择图片并填写名称');
return;
}
try {
const uri = await createNFTMetadata(
imageFile,
name,
description,
[
{ trait_type: '类型', value: '生成艺术' },
{ trait_type: '版本', value: '1.0' },
]
);
setTokenURI(uri);
console.log('铸造参数:', { tokenURI: uri });
// 这里可以调用合约的 mint 函数,传入 tokenURI
} catch (err) {
console.error(err);
}
};
return (
<div style={{ maxWidth: 500, margin: '2rem auto' }}>
<h2>铸造 NFT</h2>
<form onSubmit={handleSubmit}>
<div>
<label>图片:</label>
<input
type="file"
accept="image/*"
onChange={(e) => setImageFile(e.target.files?.[0] || null)}
required
/>
</div>
<div>
<label>名称:</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div>
<label>描述:</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<button type="submit" disabled={uploading}>
{uploading ? '上传中...' : '铸造'}
</button>
</form>
{error && <p style={{ color: 'red' }}>错误:{error}</p>}
{tokenURI && (
<div>
<p>tokenURI 已生成:</p>
<code>{tokenURI}</code>
</div>
)}
</div>
);
};
export default NFTCreationForm;
踩坑记录:我实际遇到的 3 个报错
-
CORS 错误(最烦人)
症状:浏览器控制台报
Access-Control-Allow-Origin缺失。解决方案:必须使用 JWT 认证方式,不要用 API Key + Secret Key 的前端直接调用。如果非要用 API Key,必须通过自己的后端代理转发请求。
-
上传图片返回 400 Bad Request
症状:
pinataMetadata字段传了对象而不是 JSON 字符串。排查:我打印了 FormData 内容,发现
pinataMetadata字段值是[object Object],说明 axios 自动序列化了,但 Pinata 要求必须是字符串。修复:用
JSON.stringify(metadata)包裹。 -
元数据中的 image 字段用了 HTTP 网关地址,导致钱包不显示
症状:MetaMask 的 NFT 面板显示"无法加载图片"。
排查:检查 OpenSea 的元数据标准,发现
image字段必须用ipfs://协议。修复:将
image字段改为ipfs://<imageHash>。
小结
核心收获就一句话:Pinata 上传的关键是 JWT 认证 + 分步上传(先图片后 JSON) + 元数据用 ipfs:// 协议 。如果你也在做 NFT 铸造功能,建议先跑通这个最小流程,再考虑加进度条、错误重试、批量上传等高级功能。下一步可以深挖的方向是:用 Pinata 的 Dedicated Gateway 加速访问,或者集成 wagmi 的 useContractWrite 直接调用合约 mint 函数。