交易工具入金监听
给交易/经纪/量化基础设施搭建多链入金监听。
本文走一遍典型的交易产品入金流程:用户希望往内部账户充值,可以从多条链上的任一条 付款,而你需要在入金不可逆的那一刻给账户加币。
同样的套路适用于 OTC 桌、经纪商、自营、游戏充值等:用户在入金时选链、你在自己侧维护 对应的账户余额。
我们要搭什么
┌────────────┐ POST /deposits ┌──────────────┐
│ 用户端 │ ─────────────────▶│ Your app │
└────────────┘ └──────┬───────┘
│ paymentOrders.create({ scenario: 'trading_deposit' })
▼
┌──────────────┐
│ StableOps │
└──────┬───────┘
│ payment.finalized
▼
┌──────────────┐
│ Your ledger │ 给用户余额加币
└──────────────┘这个场景下的几个关键决策:
- 加币用
payment.finalized,不是payment.confirmed。 交易产品对回滚的损失最大, 万一 confirmed 之后发生重组、而你已经让用户开仓交易,就是实打实的亏损。等 finality。 - 创建订单时带
scenario: 'trading_deposit'。 Dashboard 会按场景聚合报表,扫描器 也会在分析数据里带这个标签。 - 使用单地址(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,
scenario: 'trading_deposit',
amount,
settlementAsset: 'USDC',
acceptedAssets: [
{ chain: 'base', asset: 'USDC' },
{ chain: 'ethereum', asset: 'USDC' },
{ chain: 'arbitrum', asset: 'USDC' },
{ chain: 'tron', asset: 'USDT' },
],
expiresAt: new Date(Date.now() + 60 * 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可选但建议带上。永远不过期的入金单会把 dashboard 撑爆,1 小时是合适的默认。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 上原样展示即可 —— 扫描器
存的就是这个形态,你自己做归一化时必须使用 @stableops/shared 的 normalizeContractAddress。
为 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')
}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
finalized 之后再 revert 的情况很少,但仍可能发生:receipt 自身失败、或极深的重组让
stored block hash 失效。订阅 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 —— 差集应该恒为空。