StableOps
指南

交易工具入金监听

给交易/经纪/量化基础设施搭建多链入金监听。

本文走一遍典型的交易产品入金流程:用户希望往内部账户充值,可以从多条链上的任一条 付款,而你需要在入金不可逆的那一刻给账户加币。

同样的套路适用于 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/sharednormalizeContractAddress

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')
}

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 —— 差集应该恒为空。

本页内容