Skip to content

fix: credit RustChain destination on completed bridge withdraw#5566

Merged
Scottcjn merged 2 commits into
Scottcjn:mainfrom
iamdinhthuan:fix-bridge-withdraw-destination-credit
May 18, 2026
Merged

fix: credit RustChain destination on completed bridge withdraw#5566
Scottcjn merged 2 commits into
Scottcjn:mainfrom
iamdinhthuan:fix-bridge-withdraw-destination-credit

Conversation

@iamdinhthuan
Copy link
Copy Markdown
Contributor

Summary

  • include the stored bridge transfer amount in get_bridge_transfer_by_hash()
  • credit the RustChain destination balance when a withdraw transfer reaches the required external confirmation threshold
  • add a regression test proving a completed external-to-RustChain withdraw creates and credits the destination wallet balance

Why

direction="withdraw" represents an external-chain to RustChain transfer. Before this change, update_external_confirmation() could mark the bridge transfer completed without adding any balances.amount_i64 credit for the destination RustChain wallet, leaving funds stuck even though the bridge state said completion succeeded.

The existing terminal-transfer early return keeps repeat confirmations from double-crediting a completed withdraw.

Validation

PYTHONPATH=node python3 -m pytest -q node/test_bridge_withdraw_completion_credits_destination_poc.py tests/test_bridge_lock_ledger.py node/test_bridge_precision.py node/tests/test_bridge_api_limit_validation.py
python3 -m py_compile node/bridge_api.py node/test_bridge_withdraw_completion_credits_destination_poc.py
git diff --check

Observed locally: 48 passed, py_compile passed, and git diff --check was clean.

Duplicate / safety checks

Before opening this PR I searched public issues and PRs for bridge withdraw destination credit, update_external_confirmation withdraw balance, bridge withdraw completed no balance, and external_tx_hash completed withdraw balances amount_i64. I did not find an exact duplicate.

No private keys, tokens, credentials, or account-internal data are included.

@github-actions
Copy link
Copy Markdown
Contributor

Welcome to RustChain! Thanks for your first pull request.

Before we review, please make sure:

  • Non-doc PRs have a BCOS-L1 or BCOS-L2 label
  • Doc-only PRs are exempt from BCOS tier labels when they only touch docs/**, *.md, or common image/PDF files
  • New code files include an SPDX license header
  • You've tested your changes against the live node

Bounty tiers: Micro (1-10 RTC) | Standard (20-50) | Major (75-100) | Critical (100-150)

A maintainer will review your PR soon. Thanks for contributing!

@github-actions github-actions Bot added BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) node Node server related size/M PR: 51-200 lines labels May 17, 2026
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

@LubuSeb LubuSeb left a comment

Choose a reason for hiding this comment

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

Disclosure: I am reviewing this under Scottcjn/rustchain-bounties#73 as @LubuSeb.

Requesting changes on head b4e4b0cd7f06dca9f8a452d4796036589b7a5155. The patch fixes the missing credit on the normal withdraw completion path, and the focused + bridge regression suite passes locally, but the new credit is not made as an atomic one-time transition.

update_external_confirmation() reads the transfer before taking any write lock or doing a conditional terminal-state update. The update at completion time then sets status = 'completed' with only WHERE tx_hash = ?, and the new withdraw credit runs whenever the stale in-memory transfer[direction] == withdraw. Two duplicate bridge callbacks can both read the pre-completed row, both run the completion branch, and both credit balances.amount_i64 for the destination wallet.

I reproduced the stale-read interleaving with two SQLite connections against this PR head:

created withdraw transfer for 10 RTC
connection 1 reads status=pending
connection 2 reads status=pending
connection 1 completes and credits RTCdest1234 by 10_000_000
connection 2 completes from its stale read and credits RTCdest1234 by another 10_000_000
expected balance: 10_000_000
actual balance:   20_000_000

This means a duplicated external confirmation callback can mint/credit the RustChain destination twice even though the bridge transfer ends in a single completed state.

Suggested fix: make completion and crediting a single one-time transition. For example, acquire BEGIN IMMEDIATE before re-reading/updating the transfer, or use a conditional update such as UPDATE bridge_transfers ... WHERE tx_hash = ? AND status NOT IN ('completed','failed','voided') and only release/credit when the update actually changed one row. Add a regression that simulates two duplicate completion calls and asserts the destination balance remains exactly one transfer amount.

Validation I ran:

git diff --check origin/main...HEAD -- node/bridge_api.py node/test_bridge_withdraw_completion_credits_destination_poc.py
python -B -m py_compile node/bridge_api.py node/test_bridge_withdraw_completion_credits_destination_poc.py
PYTHONPATH=node python -B -m pytest -q node/test_bridge_withdraw_completion_credits_destination_poc.py tests/test_bridge_lock_ledger.py node/test_bridge_precision.py node/tests/test_bridge_api_limit_validation.py --tb=short
# 48 passed

Copy link
Copy Markdown
Contributor

@kekehanshujun kekehanshujun left a comment

Choose a reason for hiding this comment

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

I found a correctness blocker in the new credit side effect. The callback path can double-credit a withdraw if the same completed callback is processed concurrently or after a stale read: update_external_confirmation reads the transfer before taking a write lock, then updates with WHERE tx_hash = ? only, and the new balance credit runs whenever this invocation computed new_status == 'completed'. Two workers can both read a pending transfer; after the first commits, the second can still update the already-completed row and add amount_i64 again. Please make the terminal transition atomic before crediting, for example BEGIN IMMEDIATE plus UPDATE ... WHERE tx_hash = ? AND status IN ('pending','locked','confirming') and only credit when that update wins. Also add the required SPDX header to the new test file so the BCOS gate accepts it.

Copy link
Copy Markdown

@ZacharyZhang-NY ZacharyZhang-NY left a comment

Choose a reason for hiding this comment

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

Reviewed the bridge withdraw completion credit fix at head b4e4b0c. The patch carries amount_i64 through get_bridge_transfer_by_hash() and credits the RustChain destination balance only when a withdraw transfer reaches completed status. The existing terminal-status guard prevents a later confirmation call from crediting the same completed transfer again.\n\nValidation performed:\n- python -B -m py_compile node/bridge_api.py node/test_bridge_withdraw_completion_credits_destination_poc.py\n- git diff --check origin/main...HEAD -- node/bridge_api.py node/test_bridge_withdraw_completion_credits_destination_poc.py\n- PYTHONPATH=node python -B -m pytest -q node/test_bridge_withdraw_completion_credits_destination_poc.py --noconftest -> 1 passed\n- PYTHONPATH=node python -B -m pytest -q node/test_bridge_withdraw_completion_credits_destination_poc.py tests/test_bridge_lock_ledger.py node/test_bridge_precision.py node/tests/test_bridge_api_limit_validation.py --noconftest -> 48 passed\n- Manual idempotency probe: completed withdraw credited the destination once; a repeat confirmation returned current_status=completed and left the balance at one credit.\n\nI did not find a blocking issue in this PR.

@iamdinhthuan
Copy link
Copy Markdown
Contributor Author

Update after atomic-credit review feedback:

  • Pushed commit ec20f3a.
  • Added the missing SPDX header to node/test_bridge_withdraw_completion_credits_destination_poc.py.
  • Added a stale-read regression showing a duplicate completed callback must not credit the RustChain destination twice. I verified it failed on the previous implementation with 20000000 != 10000000.
  • Updated update_external_confirmation() to take BEGIN IMMEDIATE, update only when the transfer is still in pending/locked/confirming, and only release/credit when that conditional update wins.

Verification on head ec20f3a:

python3 tools/bcos_spdx_check.py --base-ref origin/main
# BCOS SPDX check: OK

python3 -B -m py_compile node/bridge_api.py node/test_bridge_withdraw_completion_credits_destination_poc.py
# passed

PYTHONPATH=node python3 -B -m pytest -q node/test_bridge_withdraw_completion_credits_destination_poc.py --noconftest
# 2 passed

PYTHONPATH=node python3 -B -m pytest -q node/test_bridge_withdraw_completion_credits_destination_poc.py tests/test_bridge_lock_ledger.py node/test_bridge_precision.py node/tests/test_bridge_api_limit_validation.py --noconftest
# 49 passed

git diff --check origin/main...HEAD -- node/bridge_api.py node/test_bridge_withdraw_completion_credits_destination_poc.py
# passed

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 reviewed this on head ec20f3ac8832b8dd479dcde1ebf3ebe7dd662d26. The important behavior is covered: get_bridge_transfer_by_hash() now exposes amount_i64, the confirmation update only transitions rows still in pending/locked/confirming under BEGIN IMMEDIATE, and completed withdraw transfers credit balances[dest_address] exactly once. That closes the completed external-to-RustChain withdraw gap without turning stale/repeated callbacks into duplicate credits.

Validation run:

git diff --check origin/main...HEAD -- node/bridge_api.py node/test_bridge_withdraw_completion_credits_destination_poc.py
python3 -B -m py_compile node/bridge_api.py node/test_bridge_withdraw_completion_credits_destination_poc.py
PYTHONPATH=node python3 -B -m unittest -v node.test_bridge_precision node.test_bridge_withdraw_completion_credits_destination_poc
# runtime probe: create withdraw, complete it, retry same callback, assert no second credit

Results: focused unittest ran 5 cases OK, including the new destination-credit and stale-callback coverage. The runtime probe returned {'first_ok': True, 'second_ok': False, 'second_error': 'Cannot update completed/failed/voided transfer', 'current_status': 'completed', 'balance_i64': 7500000, 'expected_i64': 7500000}.

No blockers from me.

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
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
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
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
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!

@Scottcjn Scottcjn merged commit 499d904 into Scottcjn:main May 18, 2026
10 of 11 checks passed
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) node Node server related size/M PR: 51-200 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants