StableOps
指南

交易工具入金监听

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

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

同样的套路适用于 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。差集应该恒为空。

这篇文档怎么样?

最后更新

本页内容