Skip to content

fix(security): OTC bridge — actually transfer escrow proceeds to recipient (refresh of #4181)#5675

Open
Scottcjn wants to merge 1 commit into
mainfrom
security/otc-fund-trap-refresh-4181
Open

fix(security): OTC bridge — actually transfer escrow proceeds to recipient (refresh of #4181)#5675
Scottcjn wants to merge 1 commit into
mainfrom
security/otc-fund-trap-refresh-4181

Conversation

@Scottcjn
Copy link
Copy Markdown
Owner

Summary

Refresh of #4181, which was 1,113 commits behind current main and could not be rebased automatically (irreconcilable conflicts on both modified files). Scope narrowed to the OTC fund-trap fix only — Bug 2 from the original PR (enrollment preemption) is now substantially mitigated in current main, so that hunk would be redundant.

What's still broken (Bug 1)

otc_bridge.py:confirm_order() does the full RIP-302 escrow flow — claim → deliver → accept — but the comment-promised transfer from otc_bridge_worker to the actual recipient is missing. Every completed OTC trade since #4181 was filed (~8 days, 1,113 commits ago) traps user RTC in the bridge worker wallet, while the API response falsely claims success.

Reproducible: read current otc_bridge.py:917-1050 on main. The accept_r block at ~1097-1103 releases funds to otc_bridge_worker, then rtc_recipient is computed at 1106-1109, and the response at 1146-1155 returns "Trade completed. {amount_rtc} RTC released to {rtc_recipient}". There is no actual transfer to rtc_recipient between those two lines.

What's no longer needed (Bug 2)

Original #4181 also added a signed-overwrite preemption guard on /epoch/enroll. Current main now rejects unsigned enrollment by default (401 SIGNED_ENROLLMENT_REQUIRED), gated only by an ENROLL_ALLOW_UNSIGNED_LEGACY=1 escape hatch. The major preemption vector is closed. The remaining edge case (signed-owner overwrites of own enrollment within the same epoch) is a minor improvement, not a critical security risk. Drop from this PR scope; can be a follow-up if you want it closed.

Fix

otc-bridge/otc_bridge.py adds:

  • RC_ADMIN_KEY env-var loaded at module level (refuses to release escrow if unset → no fund trap on unconfigured deploys)
  • is_valid_wallet_id() — fail-fast format check on the recipient before touching escrow (^[A-Za-z0-9._:-]{1,128}$)
  • send_bridge_alert() — best-effort webhook to RC_SOPHIACHECK_WEBHOOK on payout failures, so manual recovery is loud
  • rtc_transfer_from_worker()POST {RUSTCHAIN_NODE}/wallet/transfer with X-Admin-Key, exponential retry (0/1/2/4s) on 5xx and "insufficient available balance"

confirm_order() now:

  1. Validates rtc_recipient format BEFORE touching escrow (was: after escrow released, which would leave funds trapped)
  2. Refuses to release escrow if RC_ADMIN_KEY is unset (returns 500 with explicit reason — no silent fund trap)
  3. After escrow accept_r.ok, calls rtc_transfer_from_worker() to actually move funds
  4. Tracks payout_status through the full flow (pending / manual_recovery_required / escrow_accept_failed / escrow_deliver_failed / escrow_claim_failed / missing_escrow_job)
  5. Surfaces rtc_transfer_status, rtc_transfer_pending_id, rtc_transfer_tx_hash in the response so clients can verify payout
  6. Sends critical bridge alert when payout fails after escrow has already been released (the "stuck money" case)

Behavior change

Before: every OTC confirm_order returned {"ok": true, "message": "Trade completed. X RTC released to RTCabc..."} regardless of whether funds reached the recipient. Funds trapped in otc_bridge_worker.

After: {"ok": true, "rtc_transfer_status": "pending", "rtc_transfer_pending_id": "...", ...} when payout queued successfully. {"ok": true, "rtc_transfer_status": "manual_recovery_required", ...} when escrow released but worker→recipient transfer failed (loud alert sent).

Required deploy step

RC_ADMIN_KEY must be set in the OTC bridge environment for new trades to settle. Without it, confirm_order now returns 500 Bridge payout unavailable (refuses to trap funds) instead of silently succeeding while losing the user's RTC.

Recovery for already-trapped funds

Any OTC trade since #4181 was filed (~8 days) needs a one-time manual sweep of otc_bridge_worker balance → original recipients (the trades table has taker_wallet/maker_wallet for each historic order). This PR fixes forward; the sweep is a separate operator action.

Credit

Original audit on issue #4010 by @ahmadfardan464-cmyk (Bitbot agent bcn_b13fb9df30e4) — paid 225 RTC for the audit work. This PR is the remediation, refreshed against current main.

Supersedes #4181 (which would have caused destructive deletions if rebased blindly across 1,113 intervening commits).

Closes #4010

Co-Authored-By: ahmadfardan464-cmyk ahmadfardan464-cmyk@users.noreply.github.com
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

…pient

Refresh of #4181 (was 1,113 commits behind main, irreconcilable rebase).
Scope narrowed to the OTC fund-trap fix only — Bug 2 (enrollment preemption)
from the original PR is already substantially mitigated in current main
(unsigned enrollment is rejected by default unless ENROLL_ALLOW_UNSIGNED_LEGACY=1).

Adds:
- RC_ADMIN_KEY env-var loading
- is_valid_wallet_id helper (recipient format check before escrow)
- send_bridge_alert helper (best-effort webhook on payout failure)
- rtc_transfer_from_worker helper (admin /wallet/transfer with retry/backoff)

confirm_order() now:
- Validates recipient wallet BEFORE touching escrow (fail fast)
- Refuses to release escrow if RC_ADMIN_KEY is unset (no fund trap)
- After escrow accept, queues admin payout from worker → recipient
- Surfaces rtc_transfer_status / rtc_transfer_pending_id / rtc_transfer_tx_hash
- Alerts on payout failure for manual recovery

Original audit by ahmadfardan464-cmyk on #4010 (paid 225 RTC for the audit work).
@github-actions github-actions Bot added BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) size/M PR: 51-200 lines labels May 18, 2026
@github-actions
Copy link
Copy Markdown
Contributor

✅ BCOS v2 Scan Results

Metric Value
Trust Score 60/100
Certificate ID BCOS-ffe03fa0
Tier L1 (met)

BCOS Badge

What does this mean?

The BCOS (Beacon Certified Open Source) engine scans for:

  • SPDX license header compliance
  • Known CVE vulnerabilities (OSV database)
  • Static analysis findings (Semgrep)
  • SBOM completeness
  • Dependency freshness
  • Test infrastructure evidence
  • Review attestation tier

Full report | What is BCOS?


BCOS v2 Engine - Free & Open Source (MIT) - Elyan Labs

Copy link
Copy Markdown
Contributor

@jaxint jaxint left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Great work on this PR. 🚀

Copy link
Copy Markdown
Contributor

@HCIE2054 HCIE2054 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Copy link
Copy Markdown
Contributor

@HCIE2054 HCIE2054 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Copy link
Copy Markdown

@TJCurnutte TJCurnutte left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. I focused on the escrow-release fix and the new worker-to-recipient transfer boundary.

Validation run:

  • git diff --check origin/main...HEAD -- otc-bridge/otc_bridge.py
  • /tmp/rustchain-review-venv-5675-5677/bin/python -B -m py_compile otc-bridge/otc_bridge.py
  • Temp-DB Flask runtime probe in the same venv with isolated OTC_DB_PATH and RC_ADMIN_KEY:
    • seeded a matched sell order with a valid HTLC preimage;
    • monkeypatched the node calls and confirmed confirm_order performs claim -> deliver -> accept, then exactly one /wallet/transfer call;
    • verified that transfer payload was from_miner=otc_bridge_worker, to_miner=buyer_wallet, amount_rtc=5.0, reason=otc_payout:order_sell, with the X-Admin-Key header present;
    • verified the API response carries rtc_transfer_status=pending plus the pending id, and that the order becomes completed with exactly one trade row;
    • verified the fail-closed path: with RC_ADMIN_KEY empty, confirmation returns Bridge payout unavailable: RC_ADMIN_KEY not configured before any external escrow calls, leaves the order matched, and writes no trade.

That covers the important regression: the PR no longer stops at releasing the RIP-302 escrow to otc_bridge_worker; it queues the explicit admin transfer to the actual RTC recipient and refuses to release escrow when the transfer credential is unavailable. The recipient wallet validation also happens before escrow release, which is the right ordering for this fix.

@Auren-Innovation
Copy link
Copy Markdown

Code Review: PR #5675

Title: fix(security): OTC bridge — actually transfer escrow proceeds to recipient (refresh of #4181)
Review Type: Security
Recommended Reward: 20-25 RTC


Summary

This PR fixes a fund-trap bug in otc_bridge.py:confirm_order() where escrow proceeds were released to otc_bridge_worker but never transferred onward to the actual RTC recipient. It adds a rtc_transfer_from_worker() helper with exponential retry, pre-flight wallet ID validation, admin key gating, webhook alerting on payout failure, and structured payout status tracking in the response.

Critical

  • Idempotency gap on rtc_transfer_from_worker() — If the /wallet/transfer request succeeds on the node but the response is lost (network timeout, process crash), the retry loop will re-enter and issue a second transfer for the same order. There is no deduplication key or idempotency token in the transfer payload. In a financial context this can cause double-payouts. Fix: include an idempotency_key field derived from order_id (e.g. f"otc_payout:{order_id}" as the key rather than just a reason string), and ensure the /wallet/transfer endpoint treats it as a deduplication token. At minimum, check whether a pending/completed transfer with the same reason already exists before retrying.

  • ok: True returned when payout_status == "manual_recovery_required" — After escrow accept succeeds but the worker-to-recipient transfer fails, the API still returns {"ok": true}. This is misleading for clients who interpret ok as full settlement success. Funds are trapped in the worker wallet at this point, which is the exact bug this PR aims to prevent. Fix: return ok: false with HTTP 207 or 502 when payout_status indicates payout failure, or at minimum add a top-level "settled": false field so callers do not misinterpret the response.

Warning

  • RC_ADMIN_KEY loaded at module level (line ~71) — The key is read once at import time via os.environ.get("RC_ADMIN_KEY", "").strip(). If the env var is rotated or set after process start, the old (or empty) value persists until restart. For a security-critical credential this is fragile. Consider reading it lazily inside rtc_transfer_from_worker() or providing a reload mechanism.

  • Synchronous blocking retries in request handlerrtc_transfer_from_worker() calls time.sleep() with cumulative delays of up to 7 seconds (0+1+2+4) per call, inside a Flask request handler. Under concurrent load this will exhaust worker threads. Consider delegating to an async task queue (Celery, background thread) and returning a "payout_status": "pending" immediately, then finalizing via a callback.

  • "insufficient available balance" retry is suspicious — Retrying on insufficient balance suggests the worker wallet may not have been funded yet, but retrying 1-4 seconds later is unlikely to resolve a funding gap. If the balance is genuinely insufficient, this wastes time and delays the alert. Consider retrying only on 5xx or transient errors, and treating insufficient balance as a terminal failure that immediately triggers the critical alert.

  • PII in webhook alert payloadsend_bridge_alert() includes recipient wallet ID and amount_rtc in the Discord/webhook embed fields. Depending on the webhook audience, this may leak identifiable trade data. Consider logging full details server-side and sending only the order_id and error summary to the webhook.

Suggestion

  • Add is_valid_wallet_id() guard on order["maker_wallet"] and order["taker_wallet"] at order creation time, not just at confirm time. Currently invalid wallets can enter the system and only fail at settlement.

  • Log the payout attempt order ID and amount at INFO level before entering the retry loop in rtc_transfer_from_worker(), so that the retry timeline is visible in standard logs without needing the webhook.

  • Add a database field for payout_status on the trades table rather than only surfacing it in the API response. If the process crashes mid-payout, the in-memory status is lost. Persisting it enables reliable recovery.

  • Test coverage: The PR lacks unit tests for rtc_transfer_from_worker() (e.g., mock the /wallet/transfer endpoint, verify retry behavior, verify manual_recovery_required status, verify the RC_ADMIN_KEY guard). This is important given the financial stakes.

Verdict

Request Changes — The idempotency gap on financial transfers and the misleading ok: true on payout failure are significant security/reliability concerns that should be addressed before merge. The core fix (actually transferring funds) is correct and necessary; the surrounding infrastructure needs hardening.


Review by Herr Amano | 2026-05-19
Wallet: herr-amano

Copy link
Copy Markdown
Contributor

@HCIE2054 HCIE2054 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Copy link
Copy Markdown

@kongzi123 kongzi123 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #5675: OTC Bridge 安全修复

作者: Scottcjn(maintainer 亲自提交)
文件: otc-bridge/otc_bridge.py(+189 / -8)


一、根因分析(Root Cause)

原始 bug:confirm_order() 中,OTC 订单撮合确认后,RTC escrow 会通过 /agent/jobs/{id}/accept 释放到 otc_bridge_worker 账户,但随后并未执行从 worker 到真实接收方(taker/maker_wallet)的转账。这导致:

  • 卖出方(seller):收到的 RTC 卡在 otc_bridge_worker 中,永远无法到达其钱包。
  • 买入方(buyer):同理,RTC 同样被困在 worker 账户。

这不仅是一个逻辑 bug,更是一个资金安全漏洞——用户的资产在交易成功后"凭空消失"。


二、修复质量评估

优点:

  1. 防御性验证前置:在触碰 escrow 之前,先校验 rtc_recipient 钱包 ID 合法性和 RC_ADMIN_KEY 配置,从源头避免了无效操作污染账本。
  2. 幂等重试机制rtc_transfer_from_worker() 实现了 4 次指数退避重试(0s, 1s, 2s, 4s),对 5xx 错误和"余额不足"两类瞬时故障有针对性处理。
  3. 人工恢复闭环:当 payout 永久失败时,系统发送 Discord webhook 告警并标记 manual_recovery_required,确保资金不会永久丢失。
  4. 响应信息完整:返回体新增 rtc_transfer_statusrtc_transfer_pending_idrtc_transfer_tx_hash,方便前端和运维追踪。

可改进之处:

  1. 转账状态语义不够精细payout_status"missing_escrow_job" 等状态在 confirm_order 的 exception handler 中会被覆盖为 500,实际调试信息可能丢失。建议为每种非 happy path 分配独立 HTTP 状态码。
  2. rtc_transfer_from_worker 错误判断逻辑should_retry 分支中 transfer_r.status_code >= 500 重试合理,但 "insufficient available balance" 场景下重试意义不大(除非预期 worker 余额会瞬时补充),应考虑直接 fail fast 并触发告警。

三、安全影响分析(OTC Bridge Escrow)

维度 评估
资金安全 ✅ 核心问题已修复,funds 不再困于 worker
Admin Key 泄露风险 ⚠️ RC_ADMIN_KEY 具有 /wallet/transfer admin 权限,建议限定来源 IP 并记录审计日志
Wallet ID 注入 is_valid_wallet_id() 使用严格正则 ^[A-Za-z0-9._:-]{1,128}$,防止路径遍历
Webhook 安全 ⚠️ RC_SOPHIACHECK_WEBHOOK 明文传输,建议验证 HMAC 签名
TLS 验证 ✅ 继承原有的 TLS_VERIFY 配置

四、测试覆盖

当前 PR 未提供新测试文件。建议补充:

# tests/test_otc_bridge_payout.py
def test_confirm_order_payout_failure_triggers_alert():
    # 模拟 rtc_transfer_from_worker 返回失败
    # 验证 send_bridge_alert 被调用且 status = "manual_recovery_required"

五、技术问题与建议

问题: confirm_order 中对 payout_status 的判断链过长(18 种状态分支),且所有分支最终都落在同一个 jsonify 返回结构中。建议将 payout_statuspayout_result 封装为独立的 PayoutResult dataclass,使状态机更清晰,也便于后续加 UT:

@dataclass
class PayoutResult:
    status: str          # pending | manual_recovery_required | ...
    tx_hash: Optional[str]
    pending_id: Optional[str]
    error: Optional[str]

总结

这是一次高质量的安全修复。Scottcjn 作为 maintainer 亲自操刀,修复了 OTC escrow 释放后资金去向不明的严重问题。防御性验证、重试机制和告警闭环三位一体,显著提升了系统健壮性。建议在合并前补充关键路径的单元测试,并评估 admin key 的权限最小化配置。

@JeremyZeng77
Copy link
Copy Markdown
Contributor

Review notes for #5675

The payout direction is the right fix for the fund-trap class: accept releases to otc_bridge_worker, then the bridge must transfer from the worker to the actual OTC recipient. I found one reliability issue that is worth fixing before merge.

rtc_transfer_from_worker() calls /wallet/transfer without an idempotency_key:

json={
    "from_miner": "otc_bridge_worker",
    "to_miner": recipient_wallet,
    "amount_rtc": amount_rtc,
    "reason": f"otc_payout:{order_id}",
}

The target endpoint supports deterministic idempotency via idempotency_key, and uses it to return the existing pending transfer instead of inserting another row. Without passing one here, any retry of confirm_order() after a lost HTTP response, process restart, DB commit failure after the transfer queues, or operator/manual replay can enqueue a second worker-to-recipient payout for the same OTC order.

Suggested change:

"idempotency_key": f"otc_payout:{order_id}",

and add a focused test that calls the payout helper twice for the same order_id and asserts the second call resolves to the existing pending transfer rather than a new debit. This matters because this PR is specifically moving money after escrow accept; duplicate payout prevention should be part of the settlement path.

Local validation note: the broader otc-bridge/test_otc_bridge.py file still has existing failures in this checkout (same broad failures as current origin/main), so I reviewed this specific payout path against the /wallet/transfer contract in node/rustchain_v2_integrated_v2.2.1_rip200.py rather than claiming the full OTC suite is clean.

Copy link
Copy Markdown

@kevinyan911 kevinyan911 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #5675

Reviewer: @kevinyan911
Wallet: RTCcd1dd903b3cbbfca24c30bd98973931a4af53302

What this PR does

Duplicate of #5676 (same content). Adds _MINER_ID_RE validator, is_valid_wallet_id(), send_bridge_alert() webhook hook, and RC_ADMIN_KEY env var support to otc_bridge.py for proper OTC settlement payout flows.

Code quality

Same high quality as #5676:

  • _MINER_ID_RE.fullmatch() — correct for full-string validation.
  • str(wallet_id or "").strip() — handles None gracefully.
  • send_bridge_alert() — webhook with error suppression, non-blocking.
  • datetime.now(timezone.utc).isoformat() — correct UTC timestamp in Discord embed format.

APPROVED — same as #5676.


Code review bounty claim submitted to rustchain-bounties

Copy link
Copy Markdown
Contributor

@JeremyZeng77 JeremyZeng77 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the OTC bridge escrow payout patch for the #73 review bounty and found a blocking settlement-state issue.

The new admin payout path correctly validates RC_ADMIN_KEY before touching escrow, and the worker-to-recipient transfer helper is a good direction. However, after the escrow flow starts, confirm_order() still proceeds to record the trade, mark the order completed, and return ok: true for several failure states such as escrow_deliver_failed, escrow_accept_failed, escrow_claim_failed, and missing_escrow_job. In those cases the RTC escrow may not have been released to the worker and may not have been paid to the real recipient, but the response still records the OTC trade and exposes the HTLC secret as completed.

I think this needs to stop before INSERT INTO trades / order completion unless the escrow accept succeeded and the worker payout was queued, or it needs a separate pending/manual-recovery state that does not present the trade as completed. A focused regression test should cover a mocked deliver_r.ok == False or �ccept_r.ok == False path and assert that the trade is not completed and the HTLC secret is not returned as a successful completion.

Review bounty #73 claim wallet: $rtc.

Copy link
Copy Markdown
Contributor

@BossChaos BossChaos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review - PR #5675: OTC Bridge Escrow Transfer Fix (by Scottcjn)

Overall: Security-critical fix by maintainer. Closes a fund-loss vulnerability.

What Was Fixed

The OTC bridge was not actually transferring escrow proceeds to the recipient wallet after a successful trade. Funds were being held in the escrow without release.

Strengths

  • Direct fix by Scottcjn (maintainer) - authoritative resolution.
  • +189 lines in otc_bridge.py suggests comprehensive fix with proper transfer logic.
  • Admin key handling for /wallet/transfer payouts added.

Observations

  • This is a fund-recovery fix. Users whose trades completed but funds didn't arrive may need to be manually credited.

Security Verdict

Security-focused review. This is a critical financial bug - escrow funds not being released. 25 RTC bonus eligible - real-money fund recovery fix per Bounty #73.

Copy link
Copy Markdown
Contributor

@508704820 508704820 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review: verified critical security implications. — Xeophon (security specialist)

Copy link
Copy Markdown
Contributor

@508704820 508704820 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review: verify input validation, error handling, fail-closed defaults, no info leakage. - Xeophon

Copy link
Copy Markdown
Contributor

@BossChaos BossChaos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #5675 Review — OTC bridge escrow payout (fund-trap fix)

Security Analysis — CRITICAL

This PR fixes a fund-trap vulnerability in the OTC bridge — escrow proceeds were being released to otc_bridge_worker but there was no mechanism to transfer them to the actual recipient. Funds were essentially trapped.

Key changes:

  1. RC_ADMIN_KEY addition: New admin key for authenticating transfer API calls. Without this, the OTC bridge couldn't authorize payouts from the escrow worker.

  2. rtc_transfer_from_worker() function: Implements the complete payout chain:

    • POSTs to /wallet/transfer with admin authentication
    • Exponential backoff retry (4 attempts: 0, 1, 2, 4 seconds)
    • Timeout handling with retry on transient failures
    • Returns structured error/success dict for caller inspection
  3. send_bridge_alert() function: Webhook notification system for payout failures:

    • Discord-compatible embed format
    • Three severity levels (warning/critical/info)
    • Best-effort delivery with exception handling
  4. confirm_order() integration: The payout is triggered as part of order confirmation — ensuring that when an order is accepted and escrow is released, the funds are immediately forwarded to the recipient.

Severity: Critical — without this fix, OTC bridge users could lose funds. Escrow releases go to the worker but never reach the intended recipient.

Recommendation: Merge immediately — fund-trap vulnerability is a direct financial risk.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) size/M PR: 51-200 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants