用 Pinata + IPFS 存 NFT 元数据踩了三天坑,我总结了这份完整的前端实现方案

背景:一个看似简单的 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 个报错

  1. CORS 错误(最烦人)

    症状:浏览器控制台报 Access-Control-Allow-Origin 缺失。

    解决方案:必须使用 JWT 认证方式,不要用 API Key + Secret Key 的前端直接调用。如果非要用 API Key,必须通过自己的后端代理转发请求。

  2. 上传图片返回 400 Bad Request

    症状:pinataMetadata 字段传了对象而不是 JSON 字符串。

    排查:我打印了 FormData 内容,发现 pinataMetadata 字段值是 [object Object],说明 axios 自动序列化了,但 Pinata 要求必须是字符串。

    修复:用 JSON.stringify(metadata) 包裹。

  3. 元数据中的 image 字段用了 HTTP 网关地址,导致钱包不显示

    症状:MetaMask 的 NFT 面板显示"无法加载图片"。

    排查:检查 OpenSea 的元数据标准,发现 image 字段必须用 ipfs:// 协议。

    修复:将 image 字段改为 ipfs://<imageHash>

小结

核心收获就一句话:Pinata 上传的关键是 JWT 认证 + 分步上传(先图片后 JSON) + 元数据用 ipfs:// 协议 。如果你也在做 NFT 铸造功能,建议先跑通这个最小流程,再考虑加进度条、错误重试、批量上传等高级功能。下一步可以深挖的方向是:用 Pinata 的 Dedicated Gateway 加速访问,或者集成 wagmi 的 useContractWrite 直接调用合约 mint 函数。

相关推荐
林希_Rachel_傻希希1 小时前
web性能优化之延迟加载图片和<inframe>
前端·javascript·面试
小米渣的逆袭2 小时前
Chrome Extension Script World(ISOLATED / MAIN)原理与适用场景
前端·javascript·chrome
Esaka_Forever3 小时前
Python 与 JS (V8) 垃圾回收核心区别 + 底层根源分析
开发语言·javascript·jvm
林希_Rachel_傻希希3 小时前
web性能优化之——AI总结视频
前端·javascript·面试
binbin_523 小时前
UIAbility 与 WindowStage:窗口创建、加载、销毁的完整链路
开发语言·javascript·深度学习·华为·harmonyos
weedsfly4 小时前
Cookie 安全三属性:HttpOnly、Secure、SameSite 分别防什么?
前端·javascript·面试
前端炒粉4 小时前
马克思主义基本原理在Vue框架中的指导作用探析
前端·javascript·vue.js
Marco_Marco1354 小时前
从百度/高德迁移到丰图地图API,需要注意什么?
开发语言·javascript·经验分享
happyprince4 小时前
12-vLLM 量化方案全面分析
前端·javascript·vllm