NIP-75: Zap 目標
Zap Goals - 去中心化群眾募資與打賞目標
概述
NIP-75 定義了 Zap 目標事件,允許用戶創建募資目標並追蹤進度。這是一種去中心化的群眾募資機制, 結合 NIP-57 的 Zaps 功能,讓創作者、專案或活動可以設定募資目標並接收閃電網路打賞。
核心概念:
Zap 目標使用 kind 9041 事件,透過 amount 標籤設定目標金額(毫聰), 並可設定截止時間和多個受益人。
事件結構
Zap 目標事件使用 kind 9041:
| 標籤 | 必要 | 格式 | 說明 |
|---|---|---|---|
| amount | ✓ | ["amount", "21000000"] | 目標金額(毫聰 millisats) |
| relays | ✓ | ["relays", "wss://...", "wss://..."] | 追蹤 Zaps 的中繼器列表 |
| closed_at | - | ["closed_at", "1704067200"] | 截止時間(Unix 時間戳) |
| image | - | ["image", "https://..."] | 目標圖片 URL |
| summary | - | ["summary", "簡短描述"] | 簡短摘要 |
| zap | - | ["zap", "pubkey", "relay", "weight"] | 多個受益人分配 |
| r | - | ["r", "https://..."] | 相關外部連結 |
| a | - | ["a", "30023:pubkey:slug"] | 關聯的可定址事件 |
金額單位: amount
使用毫聰(millisatoshi)作為單位。1 sat = 1000 millisats。例如 21,000 sats = 21000000 millisats。
使用範例
基本募資目標
{
"kind": 9041,
"tags": [
["amount", "21000000"],
["relays", "wss://relay.damus.io", "wss://relay.nostr.band"]
],
"content": "幫助我購買新的錄音設備,提升 Podcast 品質!"
} 帶截止日期的目標
{
"kind": 9041,
"tags": [
["amount", "100000000"],
["relays", "wss://relay.example.com"],
["closed_at", "1735689600"],
["image", "https://example.com/goal-cover.jpg"],
["summary", "年底前募集 100,000 sats 用於開發新功能"]
],
"content": "我們計劃在明年推出全新的 Nostr 客戶端功能,需要您的支持!募集的資金將用於:\n\n1. 伺服器成本\n2. 開發人員工時\n3. 設計與測試"
} 多受益人目標
{
"kind": 9041,
"tags": [
["amount", "50000000"],
["relays", "wss://relay.example.com"],
["zap", "<dev1-pubkey>", "wss://relay.example.com", "2"],
["zap", "<dev2-pubkey>", "wss://relay.example.com", "1"],
["zap", "<designer-pubkey>", "wss://relay.example.com", "1"]
],
"content": "開源專案開發基金 - 資金將按比例分配給團隊成員"
} 權重說明: 上例中 dev1 獲得 2/(2+1+1) = 50%,dev2 和 designer 各獲得 25%。
關聯文章的目標
{
"kind": 9041,
"tags": [
["amount", "10000000"],
["relays", "wss://relay.example.com"],
["a", "30023:<author-pubkey>:my-article-slug", "wss://relay.example.com"],
["r", "https://github.com/myproject"]
],
"content": "如果這篇文章對你有幫助,請考慮支持作者繼續創作!"
} TypeScript 實作
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
interface ZapSplit {
pubkey: string
relay?: string
weight: number
}
interface ZapGoalOptions {
amountSats: number
relays: string[]
content: string
closedAt?: number
image?: string
summary?: string
zapSplits?: ZapSplit[]
linkedUrl?: string
linkedEvent?: { kind: number; pubkey: string; identifier: string }
}
// 創建 Zap 目標事件
function createZapGoal(
options: ZapGoalOptions,
secretKey: Uint8Array
): object {
// 轉換為毫聰
const amountMillisats = (options.amountSats * 1000).toString()
const tags: string[][] = [
['amount', amountMillisats],
['relays', ...options.relays],
]
if (options.closedAt) {
tags.push(['closed_at', options.closedAt.toString()])
}
if (options.image) {
tags.push(['image', options.image])
}
if (options.summary) {
tags.push(['summary', options.summary])
}
// 添加受益人分配
if (options.zapSplits) {
for (const split of options.zapSplits) {
tags.push(['zap', split.pubkey, split.relay || '', split.weight.toString()])
}
}
// 添加相關連結
if (options.linkedUrl) {
tags.push(['r', options.linkedUrl])
}
// 添加關聯事件
if (options.linkedEvent) {
const { kind, pubkey, identifier } = options.linkedEvent
tags.push(['a', `${kind}:${pubkey}:${identifier}`])
}
const event = {
kind: 9041,
created_at: Math.floor(Date.now() / 1000),
tags,
content: options.content,
}
return finalizeEvent(event, secretKey)
}
// 計算目標進度
interface ZapReceipt {
tags: string[][]
created_at: number
}
function calculateGoalProgress(
goal: { tags: string[][]; created_at: number },
zapReceipts: ZapReceipt[]
): {
targetSats: number
raisedSats: number
percentage: number
isComplete: boolean
isClosed: boolean
contributors: number
} {
// 獲取目標金額
const amountTag = goal.tags.find(t => t[0] === 'amount')
const targetMillisats = amountTag ? parseInt(amountTag[1]) : 0
const targetSats = targetMillisats / 1000
// 獲取截止時間
const closedAtTag = goal.tags.find(t => t[0] === 'closed_at')
const closedAt = closedAtTag ? parseInt(closedAtTag[1]) : null
const now = Math.floor(Date.now() / 1000)
const isClosed = closedAt ? now > closedAt : false
// 計算已募集金額(只計算截止前的 Zaps)
let raisedMillisats = 0
const validReceipts = zapReceipts.filter(receipt => {
if (closedAt && receipt.created_at > closedAt) {
return false
}
return true
})
for (const receipt of validReceipts) {
const amountTag = receipt.tags.find(t => t[0] === 'amount')
if (amountTag) {
raisedMillisats += parseInt(amountTag[1])
}
}
const raisedSats = raisedMillisats / 1000
const percentage = targetSats > 0 ? Math.min((raisedSats / targetSats) * 100, 100) : 0
return {
targetSats,
raisedSats,
percentage,
isComplete: raisedSats >= targetSats,
isClosed,
contributors: validReceipts.length,
}
}
// 格式化金額顯示
function formatSats(sats: number): string {
if (sats >= 100000000) {
return `${(sats / 100000000).toFixed(2)} BTC`
} else if (sats >= 1000000) {
return `${(sats / 1000000).toFixed(2)}M sats`
} else if (sats >= 1000) {
return `${(sats / 1000).toFixed(1)}K sats`
}
return `${sats} sats`
}
// 使用範例
const sk = generateSecretKey()
const pk = getPublicKey(sk)
// 創建基本募資目標
const basicGoal = createZapGoal({
amountSats: 21000,
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
content: '幫助我購買新的錄音設備!',
}, sk)
// 創建帶截止日期的目標
const timedGoal = createZapGoal({
amountSats: 100000,
relays: ['wss://relay.example.com'],
content: '年度開發基金募集',
closedAt: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, // 30 天後
image: 'https://example.com/goal.jpg',
summary: '為開源專案籌集開發資金',
}, sk)
// 創建多受益人目標
const teamGoal = createZapGoal({
amountSats: 50000,
relays: ['wss://relay.example.com'],
content: '團隊開發基金',
zapSplits: [
{ pubkey: 'dev1-pubkey', weight: 2 },
{ pubkey: 'dev2-pubkey', weight: 1 },
{ pubkey: 'designer-pubkey', weight: 1 },
],
}, sk)
console.log('Basic goal:', basicGoal)
console.log('Timed goal:', timedGoal)
console.log('Team goal:', teamGoal) 查詢與追蹤進度
import { SimplePool } from 'nostr-tools'
const pool = new SimplePool()
const relays = ['wss://relay.example.com']
// 查詢用戶的所有募資目標
async function getUserGoals(pubkey: string) {
const goals = await pool.querySync(relays, {
kinds: [9041],
authors: [pubkey],
})
return goals
}
// 查詢目標收到的 Zaps
async function getGoalZaps(goalEvent: any) {
const goalRelays = goalEvent.tags.find((t: string[]) => t[0] === 'relays')
const targetRelays = goalRelays ? goalRelays.slice(1) : relays
// 查詢 Zap receipts(kind 9735)指向此目標
const zaps = await pool.querySync(targetRelays, {
kinds: [9735],
'#e': [goalEvent.id],
})
return zaps
}
// 獲取目標完整資訊與進度
async function getGoalWithProgress(goalId: string) {
// 獲取目標事件
const goals = await pool.querySync(relays, {
ids: [goalId],
})
if (goals.length === 0) return null
const goal = goals[0]
const zaps = await getGoalZaps(goal)
const progress = calculateGoalProgress(goal, zaps)
return {
goal: parseGoal(goal),
progress,
zaps,
}
}
// 解析目標事件
function parseGoal(event: any) {
const getTag = (name: string) =>
event.tags.find((t: string[]) => t[0] === name)?.[1]
const relaysTags = event.tags.find((t: string[]) => t[0] === 'relays')
const goalRelays = relaysTags ? relaysTags.slice(1) : []
const zapSplits = event.tags
.filter((t: string[]) => t[0] === 'zap')
.map((t: string[]) => ({
pubkey: t[1],
relay: t[2] || undefined,
weight: parseInt(t[3]) || 1,
}))
const amountMillisats = parseInt(getTag('amount') || '0')
return {
id: event.id,
pubkey: event.pubkey,
amountSats: amountMillisats / 1000,
relays: goalRelays,
content: event.content,
closedAt: getTag('closed_at') ? parseInt(getTag('closed_at')!) : undefined,
image: getTag('image'),
summary: getTag('summary'),
zapSplits,
createdAt: event.created_at,
}
}
// 訂閱目標的 Zaps 更新
function subscribeGoalZaps(
goalEvent: any,
onZap: (zap: any, progress: ReturnType<typeof calculateGoalProgress>) => void
) {
const goalRelays = goalEvent.tags.find((t: string[]) => t[0] === 'relays')
const targetRelays = goalRelays ? goalRelays.slice(1) : relays
let allZaps: any[] = []
return pool.subscribeMany(
targetRelays,
[{
kinds: [9735],
'#e': [goalEvent.id],
}],
{
onevent(event) {
allZaps.push(event)
const progress = calculateGoalProgress(goalEvent, allZaps)
onZap(event, progress)
},
}
)
}
// 使用範例
const goalId = 'abc123...'
const result = await getGoalWithProgress(goalId)
if (result) {
console.log(`目標: ${result.goal.content}`)
console.log(`目標金額: ${formatSats(result.goal.amountSats)}`)
console.log(`已募集: ${formatSats(result.progress.raisedSats)}`)
console.log(`進度: ${result.progress.percentage.toFixed(1)}%`)
console.log(`贊助者: ${result.progress.contributors} 人`)
console.log(`已完成: ${result.progress.isComplete ? '是' : '否'}`)
} React 元件範例
import { useState, useEffect } from 'react'
interface GoalProgress {
targetSats: number
raisedSats: number
percentage: number
isComplete: boolean
isClosed: boolean
contributors: number
}
interface ZapGoalCardProps {
goal: {
id: string
content: string
amountSats: number
image?: string
summary?: string
closedAt?: number
}
progress: GoalProgress
onZap: () => void
}
function ZapGoalCard({ goal, progress, onZap }: ZapGoalCardProps) {
const daysLeft = goal.closedAt
? Math.max(0, Math.ceil((goal.closedAt - Date.now() / 1000) / 86400))
: null
return (
<div className="rounded-xl bg-gray-800 border border-gray-700 overflow-hidden">
{goal.image && (
<img
src={goal.image}
alt="Goal"
className="w-full h-48 object-cover"
/>
)}
<div className="p-6">
<h3 className="text-xl font-bold text-white mb-2">
{goal.summary || '募資目標'}
</h3>
<p className="text-gray-400 text-sm mb-4">
{goal.content}
</p>
{/* 進度條 */}
<div className="mb-4">
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-400">
{formatSats(progress.raisedSats)} 已募集
</span>
<span className="text-gray-400">
目標 {formatSats(goal.amountSats)}
</span>
</div>
<div className="h-3 bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
progress.isComplete
? 'bg-green-500'
: 'bg-gradient-to-r from-orange-500 to-yellow-500'
}`}
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
/>
</div>
<div className="flex justify-between text-sm mt-2">
<span className="text-purple-400 font-medium">
{progress.percentage.toFixed(1)}%
</span>
<span className="text-gray-500">
{progress.contributors} 位贊助者
</span>
</div>
</div>
{/* 狀態和截止時間 */}
<div className="flex items-center justify-between mb-4">
{progress.isComplete ? (
<span className="px-3 py-1 bg-green-500/20 text-green-400 text-sm rounded-full">
✓ 目標達成
</span>
) : progress.isClosed ? (
<span className="px-3 py-1 bg-red-500/20 text-red-400 text-sm rounded-full">
已截止
</span>
) : (
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 text-sm rounded-full">
募資中
</span>
)}
{daysLeft !== null && !progress.isClosed && (
<span className="text-gray-500 text-sm">
剩餘 {daysLeft} 天
</span>
)}
</div>
{/* Zap 按鈕 */}
{!progress.isClosed && (
<button
onClick={onZap}
className="w-full py-3 px-4 bg-gradient-to-r from-orange-500 to-yellow-500
hover:from-orange-600 hover:to-yellow-600
text-white font-bold rounded-lg transition-all
flex items-center justify-center gap-2"
>
⚡ Zap 支持
</button>
)}
</div>
</div>
)
}
// 進度環形圖
function GoalProgressRing({ percentage }: { percentage: number }) {
const radius = 45
const circumference = 2 * Math.PI * radius
const offset = circumference - (percentage / 100) * circumference
return (
<svg width="120" height="120" className="transform -rotate-90">
<circle
cx="60"
cy="60"
r={radius}
stroke="currentColor"
className="text-gray-700"
strokeWidth="8"
fill="none"
/>
<circle
cx="60"
cy="60"
r={radius}
stroke="url(#gradient)"
strokeWidth="8"
fill="none"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
className="transition-all duration-500"
/>
<defs>
<linearGradient id="gradient">
<stop offset="0%" stopColor="#f97316" />
<stop offset="100%" stopColor="#eab308" />
</linearGradient>
</defs>
</svg>
)
} 應用場景
🎯 創作者支持
設備升級、創作基金、專案開發等個人募資目標。
💻 開源專案
為開源軟體開發籌集資金,支持多個開發者。
📺 直播活動
結合 NIP-53 直播,設定直播期間的打賞目標。
📝 長文內容
為 NIP-23 長文文章附加募資目標,支持作者創作。
🏆 成就解鎖
結合 NIP-58 徽章,達成目標後自動頒發感謝徽章。
🤝 團隊分潤
使用 zap 標籤自動分配募得資金給多個受益人。
最佳實踐
✓ 設定合理的目標金額
根據實際需求設定可達成的目標,過高的目標可能讓支持者失去信心。
✓ 提供清晰的描述
在 content 和 summary 中詳細說明資金用途,增加透明度和信任。
✓ 使用多個中繼器
在 relays 標籤中包含多個中繼器,確保 Zaps 不會因單一中繼器故障而遺失。
⚠ 客戶端必須使用目標指定的中繼器
發送 Zap 時,客戶端應使用目標事件中 relays 標籤指定的中繼器,確保能正確追蹤。
⚠ 截止後的 Zaps 不計入
closed_at 時間之後收到的 Zaps 不應計入目標進度,但仍會送達受益人。
相關 NIP
總結
NIP-75 Zap 目標為 Nostr 提供了去中心化的群眾募資機制。透過與 NIP-57 Zaps 的整合, 創作者可以設定透明的募資目標,支持者可以即時追蹤進度,打造開放、無需許可的資金募集平台。