跳至主要內容
高級

NIP-90 資料販賣機

Nostr 資料販賣機 (DVM) 標準,實現去中心化的 AI 計算和服務市場。

18 分鐘

概述

NIP-90 定義了「資料販賣機」(Data Vending Machines, DVM)標準, 建立一個去中心化的計算服務市場。客戶發布任務請求,服務提供者競爭完成任務並獲得報酬。 這是 Nostr 上的「Money in, data out」模式。

AI 計算市場

DVM 常用於 AI 相關服務:文字翻譯、圖片生成、語音轉文字、內容摘要等。 服務提供者可以運行 GPU 或 API 來處理任務並收取費用。

事件類型

NIP-90 保留了 kind 5000-7000:

Kind 範圍 類型 說明
5000-5999 任務請求 客戶發布的任務
6000-6999 任務結果 服務者返回的結果(kind = 請求 + 1000)
7000 任務回饋 狀態更新、報價、錯誤

常見任務類型

Kind 任務類型 說明
5000 文字生成 LLM 文字生成
5001 文字轉語音 TTS 語音合成
5002 翻譯 多語言翻譯
5003 語音轉文字 STT 語音識別
5100 圖片生成 AI 繪圖
5250 內容摘要 文章/影片摘要

任務請求 (Kind 5xxx)

{`{
  "kind": 5002,
  "content": "",
  "tags": [
    ["i", "Hello, how are you?", "text"],
    ["param", "language", "zh-TW"],
    ["output", "text/plain"],
    ["relays", "wss://relay.damus.io", "wss://nos.lol"],
    ["bid", "1000"]
  ],
  ...
}`}

標籤說明

  • i - 輸入資料(可多個)
  • param - 任務參數
  • output - 期望的輸出格式
  • relays - 監聽結果的中繼器
  • bid - 願意支付的最高金額(毫聰)
  • p - 指定服務提供者(選填)

輸入類型

類型 格式 說明
text ["i", "內容", "text"] 直接文字輸入
url ["i", "https://...", "url"] 遠端資源 URL
event ["i", "<event-id>", "event", "<relay>"] Nostr 事件
job ["i", "<job-id>", "job"] 前一個任務的輸出

任務結果 (Kind 6xxx)

{`{
  "kind": 6002,
  "pubkey": "",
  "content": "你好,你好嗎?",
  "tags": [
    ["request", "<原始請求JSON>"],
    ["e", "", ""],
    ["p", ""],
    ["amount", "500", ""]
  ],
  ...
}`}

任務回饋 (Kind 7000)

服務提供者用來更新任務狀態:

{`{
  "kind": 7000,
  "content": "",
  "tags": [
    ["status", "processing", "正在處理中..."],
    ["e", "", ""],
    ["p", ""]
  ],
  ...
}`}

狀態類型

狀態 說明
payment-required 需要先付款才能繼續
processing 任務正在處理中
success 任務完成
partial 部分結果(可能含預覽)
error 處理失敗

付款流程

1

客戶發布請求

包含 bid 標籤表示願意支付的金額

2

服務者回應報價

發布 kind 7000 帶 payment-required 狀態和 BOLT11 發票

3

客戶付款

支付 BOLT11 發票或 Zap 任務事件

4

服務者交付結果

發布 kind 6xxx 結果事件

風險模式

服務提供者可自行決定風險模式:先付款後交付、先交付後收款、 或根據客戶信譽決定。這是一個自由市場。

實作

發布任務請求(客戶端)

{`import { finalizeEvent } from 'nostr-tools';

interface JobRequest {
  kind: number;
  inputs: Array<{
    data: string;
    type: 'text' | 'url' | 'event' | 'job';
    relay?: string;
    marker?: string;
  }>;
  params?: Record;
  output?: string;
  bid?: number;
  serviceProvider?: string;
}

// 建立任務請求
function createJobRequest(
  privateKey: Uint8Array,
  job: JobRequest,
  relays: string[]
) {
  const tags: string[][] = [];

  // 添加輸入
  for (const input of job.inputs) {
    const tag = ['i', input.data, input.type];
    if (input.relay) tag.push(input.relay);
    if (input.marker) tag.push(input.marker);
    tags.push(tag);
  }

  // 添加參數
  if (job.params) {
    for (const [key, value] of Object.entries(job.params)) {
      tags.push(['param', key, value]);
    }
  }

  // 添加輸出格式
  if (job.output) {
    tags.push(['output', job.output]);
  }

  // 添加中繼器
  tags.push(['relays', ...relays]);

  // 添加出價
  if (job.bid) {
    tags.push(['bid', job.bid.toString()]);
  }

  // 指定服務提供者
  if (job.serviceProvider) {
    tags.push(['p', job.serviceProvider]);
  }

  return finalizeEvent({
    kind: job.kind,
    created_at: Math.floor(Date.now() / 1000),
    tags,
    content: '',
  }, privateKey);
}

// 翻譯請求範例
const translateJob = createJobRequest(
  privateKey,
  {
    kind: 5002,
    inputs: [
      { data: 'Hello, how are you today?', type: 'text' },
    ],
    params: {
      language: 'zh-TW',
    },
    output: 'text/plain',
    bid: 1000, // 1000 毫聰
  },
  ['wss://relay.damus.io']
);

// 圖片生成請求範例
const imageJob = createJobRequest(
  privateKey,
  {
    kind: 5100,
    inputs: [
      { data: 'A cute cat sitting on a rainbow', type: 'text' },
    ],
    params: {
      size: '1024x1024',
      style: 'anime',
    },
    output: 'image/png',
    bid: 5000,
  },
  ['wss://relay.damus.io']
);`}

監聽任務結果

{`import { SimplePool } from 'nostr-tools';

const pool = new SimplePool();

interface JobResult {
  status: 'processing' | 'success' | 'error' | 'payment-required' | 'partial';
  content?: string;
  amount?: number;
  bolt11?: string;
  error?: string;
}

// 監聽任務回應
function subscribeToJobResults(
  jobRequestId: string,
  relays: string[],
  onResult: (result: JobResult) => void,
  onFeedback: (feedback: JobResult) => void
) {
  // 監聽結果和回饋
  return pool.subscribeMany(
    relays,
    [
      { kinds: [6000, 6001, 6002, 6003, 6100, 6250], '#e': [jobRequestId] },
      { kinds: [7000], '#e': [jobRequestId] },
    ],
    {
      onevent: (event) => {
        if (event.kind === 7000) {
          // 任務回饋
          const statusTag = event.tags.find(t => t[0] === 'status');
          const amountTag = event.tags.find(t => t[0] === 'amount');

          onFeedback({
            status: statusTag?.[1] as any || 'processing',
            content: event.content || statusTag?.[2],
            amount: amountTag ? parseInt(amountTag[1]) : undefined,
            bolt11: amountTag?.[2],
          });
        } else {
          // 任務結果
          const amountTag = event.tags.find(t => t[0] === 'amount');

          onResult({
            status: 'success',
            content: event.content,
            amount: amountTag ? parseInt(amountTag[1]) : undefined,
            bolt11: amountTag?.[2],
          });
        }
      },
    }
  );
}

// 使用範例
const sub = subscribeToJobResults(
  jobRequestId,
  ['wss://relay.damus.io'],
  (result) => {
    console.log('任務完成:', result.content);
    if (result.bolt11) {
      console.log('請付款:', result.bolt11);
    }
  },
  (feedback) => {
    if (feedback.status === 'payment-required') {
      console.log('需要付款:', feedback.bolt11);
    } else if (feedback.status === 'processing') {
      console.log('處理中:', feedback.content);
    } else if (feedback.status === 'error') {
      console.log('錯誤:', feedback.content);
    }
  }
);`}

處理任務(服務提供者)

{`// 服務提供者:監聽任務請求
function startDVMService(
  servicePrivateKey: Uint8Array,
  supportedKinds: number[],
  relays: string[],
  processJob: (job: Event) => Promise
) {
  const servicePubkey = getPublicKey(servicePrivateKey);

  return pool.subscribeMany(
    relays,
    [{ kinds: supportedKinds }],
    {
      onevent: async (jobRequest) => {
        try {
          // 發送處理中狀態
          const processingFeedback = finalizeEvent({
            kind: 7000,
            created_at: Math.floor(Date.now() / 1000),
            tags: [
              ['status', 'processing', '正在處理您的請求...'],
              ['e', jobRequest.id],
              ['p', jobRequest.pubkey],
            ],
            content: '',
          }, servicePrivateKey);

          await pool.publish(relays, processingFeedback);

          // 處理任務
          const result = await processJob(jobRequest);

          // 發送結果
          const resultKind = jobRequest.kind + 1000;
          const jobResult = finalizeEvent({
            kind: resultKind,
            created_at: Math.floor(Date.now() / 1000),
            tags: [
              ['request', JSON.stringify(jobRequest)],
              ['e', jobRequest.id],
              ['p', jobRequest.pubkey],
              ['amount', '500'], // 收費 500 毫聰
            ],
            content: result,
          }, servicePrivateKey);

          await pool.publish(relays, jobResult);

        } catch (error) {
          // 發送錯誤狀態
          const errorFeedback = finalizeEvent({
            kind: 7000,
            created_at: Math.floor(Date.now() / 1000),
            tags: [
              ['status', 'error', error.message],
              ['e', jobRequest.id],
              ['p', jobRequest.pubkey],
            ],
            content: '',
          }, servicePrivateKey);

          await pool.publish(relays, errorFeedback);
        }
      },
    }
  );
}

// 啟動翻譯服務
startDVMService(
  servicePrivateKey,
  [5002], // 翻譯任務
  ['wss://relay.damus.io'],
  async (job) => {
    const input = job.tags.find(t => t[0] === 'i')?.[1];
    const lang = job.tags.find(t => t[0] === 'param' && t[1] === 'language')?.[2];

    // 呼叫翻譯 API
    const translated = await translateAPI(input, lang);
    return translated;
  }
);`}

任務串接

可以將前一個任務的輸出作為下一個任務的輸入,實現複雜的工作流程:

{`// 範例:播客轉錄 → 摘要 → 翻譯

// 1. 語音轉文字
const transcribeJob = createJobRequest(privateKey, {
  kind: 5003,
  inputs: [{ data: 'https://example.com/podcast.mp3', type: 'url' }],
  output: 'text/plain',
}, relays);

// 發布並等待結果...
const transcribeJobId = transcribeJob.id;

// 2. 摘要(使用前一個任務的輸出)
const summarizeJob = createJobRequest(privateKey, {
  kind: 5250,
  inputs: [{ data: transcribeJobId, type: 'job' }],
  params: { length: 'short' },
  output: 'text/plain',
}, relays);

// 3. 翻譯摘要
const translateJob = createJobRequest(privateKey, {
  kind: 5002,
  inputs: [{ data: summarizeJob.id, type: 'job' }],
  params: { language: 'zh-TW' },
  output: 'text/plain',
}, relays);`}

加密請求

敏感資料可以使用 NIP-04 加密:

{`import * as nip04 from 'nostr-tools/nip04';

// 建立加密的任務請求
async function createEncryptedJobRequest(
  privateKey: Uint8Array,
  serviceProviderPubkey: string,
  job: JobRequest,
  relays: string[]
) {
  // 加密輸入資料
  const encryptedContent = await nip04.encrypt(
    privateKey,
    serviceProviderPubkey,
    JSON.stringify({
      inputs: job.inputs,
      params: job.params,
    })
  );

  const tags: string[][] = [
    ['p', serviceProviderPubkey],
    ['encrypted'],
    ['relays', ...relays],
  ];

  if (job.output) tags.push(['output', job.output]);
  if (job.bid) tags.push(['bid', job.bid.toString()]);

  return finalizeEvent({
    kind: job.kind,
    created_at: Math.floor(Date.now() / 1000),
    tags,
    content: encryptedContent,
  }, privateKey);
}`}

服務發現

服務提供者可以使用 NIP-89 發布能力:

{`{
  "kind": 31990,
  "content": "{\"name\":\"AI 翻譯服務\",\"about\":\"支援 50+ 語言\"}",
  "tags": [
    ["d", "translation-service"],
    ["k", "5002"],
    ["t", "translation"],
    ["t", "ai"]
  ],
  ...
}`}

取消任務

{`// 取消任務請求
function cancelJob(
  privateKey: Uint8Array,
  jobRequestId: string
) {
  return finalizeEvent({
    kind: 5, // NIP-09 刪除事件
    created_at: Math.floor(Date.now() / 1000),
    tags: [['e', jobRequestId]],
    content: 'Job cancelled',
  }, privateKey);
}`}

最佳實踐

  • 合理出價:根據任務複雜度設定 bid
  • 監控狀態:訂閱 kind 7000 追蹤進度
  • 處理超時:設定合理的等待時間
  • 驗證結果:確認結果來自可信的服務者
  • 加密敏感資料:使用 encrypted 標籤保護隱私
  • NIP-01 - 基本協議與事件結構
  • NIP-04 - 加密(用於加密請求)
  • NIP-57 - Zaps(用於付款)
  • NIP-09 - 事件刪除(用於取消)

參考資源

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