交易工具入金监听
给交易/经纪/量化基础设施搭建多链入金监听。
本文走一遍典型的交易产品入金流程:用户希望往内部账户充值,可以从多条链上的任一条 付款,而你需要在入金不可逆的那一刻给账户加币。
同样的套路适用于 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。差集应该恒为空。
这篇文档怎么样?
最后更新