跳至主要內容

NIP-15: Nostr 市場

基於 Nostr 的去中心化市場協議,支持商品展示、訂單管理和支付整合

概述

NIP-15 定義了在 Nostr 上建立去中心化市場的協議。商家可以發布商品目錄(Stalls) 和商品清單(Products),買家可以瀏覽、下單,並通過閃電網路完成支付。 整個流程不需要中央伺服器,完全建立在 Nostr 協議之上。

事件類型

Kind 說明 類型
30017 商店(Stall) 可定址
30018 商品(Product) 可定址

商店結構 (Kind 30017)

商店是商家的店面,包含基本資訊和支付配置。

Content 欄位(JSON)

{
  "id": "stall-unique-id",
  "name": "我的比特幣商店",
  "description": "販售各種比特幣相關商品",
  "currency": "sat",
  "shipping": [
    {
      "id": "worldwide",
      "name": "全球配送",
      "cost": 10000,
      "regions": ["worldwide"]
    },
    {
      "id": "local",
      "name": "本地取貨",
      "cost": 0,
      "regions": ["TW"]
    }
  ]
}

標籤

標籤 說明
d 商店唯一識別符
t 分類標籤(可選)

商品結構 (Kind 30018)

商品是商店中的具體項目,包含價格、庫存和圖片等資訊。

Content 欄位(JSON)

{
  "id": "product-unique-id",
  "stall_id": "stall-unique-id",
  "name": "Bitcoin 硬體錢包",
  "description": "安全儲存你的比特幣",
  "images": [
    "https://example.com/wallet1.jpg",
    "https://example.com/wallet2.jpg"
  ],
  "currency": "sat",
  "price": 500000,
  "quantity": 10,
  "specs": [
    ["顏色", "黑色"],
    ["型號", "Mk4"]
  ],
  "shipping": [
    {
      "id": "worldwide",
      "cost": 5000
    }
  ]
}

標籤

標籤 說明
d 商品唯一識別符
t 商品標籤

範例

建立商店

{
  "kind": 30017,
  "content": "{\"id\":\"btc-store-2024\",\"name\":\"比特幣精品店\",\"description\":\"提供各種比特幣周邊商品\",\"currency\":\"sat\",\"shipping\":[{\"id\":\"tw-shipping\",\"name\":\"台灣宅配\",\"cost\":5000,\"regions\":[\"TW\"]}]}",
  "tags": [
    ["d", "btc-store-2024"],
    ["t", "bitcoin"],
    ["t", "商店"]
  ]
}

發布商品

{
  "kind": 30018,
  "content": "{\"id\":\"coldcard-mk4\",\"stall_id\":\"btc-store-2024\",\"name\":\"Coldcard Mk4 硬體錢包\",\"description\":\"頂級比特幣硬體錢包,支援氣隙簽名\",\"images\":[\"https://example.com/coldcard.jpg\"],\"currency\":\"sat\",\"price\":1500000,\"quantity\":5,\"specs\":[[\"品牌\",\"Coinkite\"],[\"型號\",\"Mk4\"],[\"顏色\",\"黑色\"]],\"shipping\":[{\"id\":\"tw-shipping\",\"cost\":5000}]}",
  "tags": [
    ["d", "coldcard-mk4"],
    ["t", "硬體錢包"],
    ["t", "coldcard"],
    ["t", "bitcoin"]
  ]
}

TypeScript 實作

定義類型

interface ShippingOption {
  id: string;
  name: string;
  cost: number;
  regions: string[];
}

interface Stall {
  id: string;
  name: string;
  description?: string;
  currency: string;
  shipping: ShippingOption[];
}

interface ProductSpec {
  name: string;
  value: string;
}

interface ProductShipping {
  id: string;
  cost: number;
}

interface Product {
  id: string;
  stall_id: string;
  name: string;
  description?: string;
  images?: string[];
  currency: string;
  price: number;
  quantity: number;
  specs?: [string, string][];
  shipping: ProductShipping[];
}

建立商店

import { finalizeEvent } from 'nostr-tools';

function createStall(
  stall: Stall,
  secretKey: Uint8Array,
  tags: string[][] = []
) {
  const event = {
    kind: 30017,
    content: JSON.stringify(stall),
    tags: [
      ['d', stall.id],
      ...tags,
    ],
    created_at: Math.floor(Date.now() / 1000),
  };

  return finalizeEvent(event, secretKey);
}

// 使用範例
const stallEvent = createStall(
  {
    id: 'my-btc-store',
    name: '比特幣精品店',
    description: '專營比特幣硬體錢包和周邊',
    currency: 'sat',
    shipping: [
      { id: 'tw', name: '台灣宅配', cost: 5000, regions: ['TW'] },
      { id: 'intl', name: '國際配送', cost: 20000, regions: ['worldwide'] },
    ],
  },
  secretKey,
  [['t', 'bitcoin'], ['t', '硬體錢包']]
);

建立商品

function createProduct(
  product: Product,
  secretKey: Uint8Array,
  tags: string[][] = []
) {
  const event = {
    kind: 30018,
    content: JSON.stringify(product),
    tags: [
      ['d', product.id],
      ...tags,
    ],
    created_at: Math.floor(Date.now() / 1000),
  };

  return finalizeEvent(event, secretKey);
}

// 使用範例
const productEvent = createProduct(
  {
    id: 'trezor-model-t',
    stall_id: 'my-btc-store',
    name: 'Trezor Model T',
    description: '觸控螢幕硬體錢包,支援多幣種',
    images: [
      'https://example.com/trezor1.jpg',
      'https://example.com/trezor2.jpg',
    ],
    currency: 'sat',
    price: 1200000,
    quantity: 10,
    specs: [
      ['品牌', 'SatoshiLabs'],
      ['型號', 'Model T'],
      ['螢幕', '彩色觸控'],
    ],
    shipping: [
      { id: 'tw', cost: 5000 },
      { id: 'intl', cost: 15000 },
    ],
  },
  secretKey,
  [['t', 'trezor'], ['t', '硬體錢包']]
);

解析商店與商品

function parseStall(event: any): Stall | null {
  if (event.kind !== 30017) return null;

  try {
    return JSON.parse(event.content);
  } catch {
    return null;
  }
}

function parseProduct(event: any): Product | null {
  if (event.kind !== 30018) return null;

  try {
    return JSON.parse(event.content);
  } catch {
    return null;
  }
}

// 使用範例
const stall = parseStall(stallEvent);
const product = parseProduct(productEvent);

if (stall && product) {
  console.log(`商店: ${stall.name}`);
  console.log(`商品: ${product.name} - ${product.price} sats`);
}

搜尋商品

import { SimplePool } from 'nostr-tools';

interface ProductSearchOptions {
  tags?: string[];
  merchantPubkey?: string;
  limit?: number;
}

async function searchProducts(
  pool: SimplePool,
  relays: string[],
  options: ProductSearchOptions = {}
): Promise<{ event: any; product: Product }[]> {
  const filter: any = {
    kinds: [30018],
    limit: options.limit || 50,
  };

  if (options.merchantPubkey) {
    filter.authors = [options.merchantPubkey];
  }

  if (options.tags && options.tags.length > 0) {
    filter['#t'] = options.tags;
  }

  const events = await pool.querySync(relays, filter);

  return events
    .map((event) => ({
      event,
      product: parseProduct(event),
    }))
    .filter((item): item is { event: any; product: Product } =>
      item.product !== null
    )
    .sort((a, b) => b.event.created_at - a.event.created_at);
}

// 使用範例
const results = await searchProducts(pool, relays, {
  tags: ['硬體錢包'],
  limit: 20,
});

results.forEach(({ product }) => {
  console.log(`${product.name}: ${product.price} sats`);
});

取得商家所有商品

async function getMerchantCatalog(
  pool: SimplePool,
  relays: string[],
  merchantPubkey: string
): Promise<{
  stalls: { event: any; stall: Stall }[];
  products: { event: any; product: Product }[];
}> {
  // 取得商店
  const stallEvents = await pool.querySync(relays, {
    kinds: [30017],
    authors: [merchantPubkey],
  });

  const stalls = stallEvents
    .map((event) => ({ event, stall: parseStall(event) }))
    .filter((item): item is { event: any; stall: Stall } =>
      item.stall !== null
    );

  // 取得商品
  const productEvents = await pool.querySync(relays, {
    kinds: [30018],
    authors: [merchantPubkey],
  });

  const products = productEvents
    .map((event) => ({ event, product: parseProduct(event) }))
    .filter((item): item is { event: any; product: Product } =>
      item.product !== null
    );

  return { stalls, products };
}

React 元件

interface ProductCardProps {
  product: Product;
  onAddToCart?: (product: Product) => void;
}

function ProductCard({ product, onAddToCart }: ProductCardProps) {
  const formatPrice = (sats: number) => {
    if (sats >= 1000000) {
      return `${(sats / 100000000).toFixed(8)} BTC`;
    }
    return `${sats.toLocaleString()} sats`;
  };

  return (
    <div className="product-card">
      {product.images && product.images[0] && (
        <img
          src={product.images[0]}
          alt={product.name}
          className="product-image"
        />
      )}
      <div className="product-info">
        <h3>{product.name}</h3>
        {product.description && (
          <p className="description">{product.description}</p>
        )}
        <div className="price">{formatPrice(product.price)}</div>
        <div className="quantity">
          庫存: {product.quantity > 0 ? product.quantity : '售罄'}
        </div>
        {product.specs && (
          <div className="specs">
            {product.specs.map(([key, value]) => (
              <div key={key}>
                <span className="spec-key">{key}:</span>
                <span className="spec-value">{value}</span>
              </div>
            ))}
          </div>
        )}
        <button
          onClick={() => onAddToCart?.(product)}
          disabled={product.quantity === 0}
        >
          加入購物車
        </button>
      </div>
    </div>
  );
}

function StallPage({ stall, products }: {
  stall: Stall;
  products: Product[];
}) {
  return (
    <div className="stall-page">
      <header>
        <h1>{stall.name}</h1>
        {stall.description && <p>{stall.description}</p>}
        <div className="shipping-options">
          <h4>配送選項</h4>
          {stall.shipping.map((option) => (
            <div key={option.id}>
              {option.name}: {option.cost} sats
            </div>
          ))}
        </div>
      </header>
      <div className="products-grid">
        {products
          .filter((p) => p.stall_id === stall.id)
          .map((product) => (
            <ProductCard key={product.id} product={product} />
          ))}
      </div>
    </div>
  );
}

支付流程

NIP-15 通常與閃電網路支付整合:

  1. 買家選擇商品和配送方式
  2. 計算總金額(商品價格 + 運費)
  3. 商家生成閃電網路發票
  4. 買家支付發票
  5. 商家確認收款並處理訂單

使用場景

比特幣商品

  • 硬體錢包銷售
  • 比特幣周邊商品
  • 書籍和教育資源

數位商品

  • 電子書和課程
  • 軟體授權
  • 數位藝術作品

服務販售

  • 諮詢服務
  • 開發服務
  • 設計服務

最佳實踐

  • 清晰的商品描述:提供詳細的商品資訊和規格
  • 高品質圖片:使用清晰的商品圖片
  • 合理的定價:使用 sats 作為計價單位方便閃電網路支付
  • 即時更新庫存:商品售出後及時更新數量
  • 明確的運費:清楚列出各地區的運費
  • NIP-01:基本協議 - 事件格式
  • NIP-57:Zaps - 閃電網路支付
  • NIP-99:分類廣告 - 簡易商品列表

參考資源

已複製連結
已複製到剪貼簿