交易工具入金監聽
學習如何為交易、經紀或量化基礎設施搭建多鏈穩定幣入金監聽:為使用者分配收款地址,匹配不同網路上的轉帳,跟蹤確認與最終性,透過 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.detected 和 payment.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.confirmed、payment.finalized、
payment.reverted 和 payment.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 只會發生在訂單處於 detected 或 confirmed 階段時: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。差集應該恆為空。
這篇文件怎麼樣?
最後更新