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 通常與閃電網路支付整合:
- 買家選擇商品和配送方式
- 計算總金額(商品價格 + 運費)
- 商家生成閃電網路發票
- 買家支付發票
- 商家確認收款並處理訂單
使用場景
比特幣商品
- 硬體錢包銷售
- 比特幣周邊商品
- 書籍和教育資源
數位商品
- 電子書和課程
- 軟體授權
- 數位藝術作品
服務販售
- 諮詢服務
- 開發服務
- 設計服務
最佳實踐
- 清晰的商品描述:提供詳細的商品資訊和規格
- 高品質圖片:使用清晰的商品圖片
- 合理的定價:使用 sats 作為計價單位方便閃電網路支付
- 即時更新庫存:商品售出後及時更新數量
- 明確的運費:清楚列出各地區的運費
相關 NIPs
參考資源
已複製連結