StableOps
指南

交易工具入金監聽

學習如何為交易、經紀或量化基礎設施搭建多鏈穩定幣入金監聽:為使用者分配收款地址,匹配不同網路上的轉帳,跟蹤確認與最終性,透過 Webhook 安全入帳,並處理錯鏈、錯額、遲到付款、區塊重組、日常對帳與營運告警。

本文走一遍典型的交易產品入金流程:使用者希望往內部帳戶充值,可以從多條鏈上的任一條 付款,而你需要在入金不可逆的那一刻給帳戶加幣。

同樣的套路適用於 OTC 桌、經紀商、自營、遊戲充值等:使用者在入金時選鏈、你在自己側維護 對應的帳戶餘額。

我們要搭什麼

┌────────────┐  POST /deposits   ┌──────────────┐
│   使用者端    │ ─────────────────▶│   Your app   │
└────────────┘                   └──────┬───────┘
                                        │ paymentOrders.create({ metadata: { kind: 'deposit' } })

                                  ┌──────────────┐
                                  │   StableOps  │
                                  └──────┬───────┘
                                         │ payment.finalized

                                  ┌──────────────┐
                                  │  Your ledger │  給使用者餘額加幣
                                  └──────────────┘

這個場景下的幾個關鍵決策:

  • 加幣用 payment.finalized,不是 payment.confirmed 交易產品對回滾的損失最大, 萬一 confirmed 之後發生重組、而你已經讓使用者開倉交易,就是實打實的虧損。等 finality。
  • metadata 給入金打標。 自己加個標記(如 metadata: { kind: 'deposit' }),方便在你自己的分析與對帳裡聚合入金。
  • 使用單地址(single-use)模式。 交易入金通常金額不定且由使用者發起,每筆分配獨立 地址可以避免共享地址按金額匹配帶來的歧義。

1. 建立入金請求

import { StableOps } from '@stableops/api-sdk'

const client = new StableOps({
  apiKey: process.env.STABLEOPS_API_KEY!,
})

export async function createDeposit(userId: string, amount: string) {
  const deposit = await db.deposits.create({
    data: { userId, amount, status: 'creating' },
  })

  const order = await client.paymentOrders.create(
    {
      merchantOrderId: deposit.id,
      amount,
      acceptedAssets: [
        { chain: 'base', asset: 'USDC' },
        { chain: 'ethereum', asset: 'USDC' },
        { chain: 'arbitrum', asset: 'USDC' },
        { chain: 'tron', asset: 'USDT' },
      ],
      expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
      metadata: { user_id: userId, deposit_id: deposit.id, kind: 'deposit' },
    },
    { idempotencyKey: `deposit:${deposit.id}:create` },
  )

  await db.deposits.update({
    where: { id: deposit.id },
    data: { stableopsOrderId: order.id, status: 'pending' },
  })

  return {
    depositId: deposit.id,
    stableopsOrderId: order.id,
    paymentInstructions: order.paymentInstructions,
    expiresAt: order.expiresAt,
  }
}

一些欄位說明:

  • acceptedAssets 列出使用者允許使用的全部鏈/資產組合。StableOps 會按池子可用情況 分配出候選地址,返回的 paymentInstructions 讓前端根據使用者錢包選擇一條鏈支付。
  • amount 是資產單位的十進位制字串("50.00" USDC,而不是 50_000_000)。
  • expiresAt 必傳。sandbox 環境最多隻能設到 30 分鐘後,live 環境上限 24 小時;入金場景 30 分鐘是合適的預設。
  • idempotencyKey 從你已經落庫的 deposit id 派生。同一筆 deposit 的每次重試都必須複用它; 生成一個新 key 會建立新訂單,而不是重放第一次回應。

2. 展示入金指令

每條 paymentInstructions 都是一條完整的可支付候選:

const instruction = order.paymentInstructions[0]
// {
//   chain: 'base',
//   asset: 'USDC',
//   address: '0xabc...123',
// }

TRON 返回 T… 開頭的 base58 地址;EVM 鏈返回小寫 0x…。UI 上原樣展示即可。掃描器 存的就是這個形態,在你自己的系統中比對地址時請直接按 paymentInstructions 返回的原樣 匹配,不要再做額外歸一化。

address 渲染二維碼、為金額渲染複製按鈕。把金額編進二維碼 payload 多數錢包會自動填好。

3. 處理 Webhook

至少訂閱 payment.finalized。如果想給使用者展示「已收到,確認中…」的狀態,再訂閱 payment.detectedpayment.confirmed

import { SIGNATURE_HEADER, verifySignature } from '@stableops/api-sdk/webhooks'

export async function POST(req: Request) {
  const rawBody = await req.text()
  const result = verifySignature({
    secrets: [process.env.STABLEOPS_WEBHOOK_SECRET!],
    header: req.headers.get(SIGNATURE_HEADER) ?? undefined,
    rawBody,
  })
  if (!result.ok) return new Response('invalid', { status: 400 })

  const event = JSON.parse(rawBody) as { type: string; data: any }
  const eventId = req.headers.get('x-event-id')!

  if (event.type === 'payment.finalized') {
    await creditUserBalance({
      eventId,
      userId: event.data.metadata.user_id,
      depositId: event.data.metadata.deposit_id,
      stableopsOrderId: event.data.payment_order_id,
      amount: event.data.amount,
      asset: event.data.settlement_asset,
    })
  }

  return new Response('ok')
}

注意 event.data.settlement_asset 只出現在 payment.confirmedpayment.finalizedpayment.revertedpayment.expired 事件中。payment.detected 用的是 asset 欄位, payment_order.created 則完全沒有該欄位,鏈上檢測到交易前無法得知實際資產。

creditUserBalance 必須按 eventId 冪等。Webhook 投遞可能因為網路錯誤或人工重放 而重複到達。

4. 在你的帳本上冪等加幣

最簡單的模式是一張 processed_events 表,對 event id 加唯一約束:

CREATE TABLE processed_events (
  event_id TEXT PRIMARY KEY,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
async function creditUserBalance(input: {
  eventId: string
  userId: string
  depositId: string
  stableopsOrderId: string
  amount: string
  asset: string
}) {
  await db.$transaction(async (tx) => {
    try {
      await tx.processedEvents.create({ data: { eventId: input.eventId } })
    } catch (err) {
      if (isUniqueViolation(err)) return // 已經入帳過
      throw err
    }

    await tx.balances.update({
      where: { userId_asset: { userId: input.userId, asset: input.asset } },
      data: { available: { increment: input.amount } },
    })

    await tx.deposits.update({
      where: { id: input.depositId },
      data: {
        status: 'credited',
        stableopsOrderId: input.stableopsOrderId,
        creditedAt: new Date(),
      },
    })
  })
}

唯一插入 + 事務就是整套冪等保證。重複到達時插入失敗,餘額更新永遠不會跑第二次。

5. 處理 payment.reverted

revert 只會發生在訂單處於 detectedconfirmed 階段時:receipt 失敗、或重組讓 已存的 block hash 失效。訂單一旦到達 finalized 就不會再 revert,這正是按 payment.finalized 入帳安全的原因。若你在更早階段就入帳,請務必訂閱 payment.reverted 並衝回:

if (event.type === 'payment.reverted') {
  await tx.balances.update({
    where: {
      userId_asset: {
        userId: event.data.metadata.user_id,
        asset: event.data.settlement_asset,
      },
    },
    data: { available: { decrement: event.data.amount } },
  })
  await tx.deposits.update({
    where: { id: event.data.metadata.deposit_id },
    data: { status: 'reverted' },
  })
}

如果使用者已經把入帳餘額提走,那就需要自家的爭議流程兜底。StableOps 無法把已經離開 你係統的資金追回。這也是隻在 finalized 加幣的核心理由。

上線 checklist

  • 認 finality,不要認 confirmation。 業務執行的關鍵路徑只盯 payment.finalized
  • 不要硬編碼確認數。 StableOps 已經按鏈固化了合理閾值,相信狀態機即可。
  • payment.expired UI 上把過期入金當作已取消,引導使用者重新發起。
  • 地址池水位。 單地址模式下,每條鏈都要保證匯入地址數能覆蓋峰值併發入金。 參考 BYO 地址
  • 對帳。 每天列一次 API 上的 payment.finalized 事件,對你的 processed_events 做 diff。差集應該恆為空。

這篇文件怎麼樣?

最後更新

本頁內容