Skip to content

feat: add outbound USDT0 bridging - #158

Merged
ben-kaufman merged 16 commits into
masterfrom
feat/usdt0-outbound
Oct 1, 2026
Merged

ben-kaufman merged 16 commits into
masterfrom
feat/usdt0-outbound

Conversation

@ben-kaufman

@ben-kaufman ben-kaufman commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Adds outbound USDT0 transfers from the Arbitrum wallet to Ethereum, Polygon, Plasma and Stable. Users review the destination amount and maximum total USDT fee, including bridge costs, without needing ETH.

  • Uses USDT0's OFT contracts and transaction helper to fund the cross-network transfer in USDT.
  • Validates the route, helper liquidity and approved fee limits before submission. Helper approval is limited to the payment and revoked within the same operation.
  • Tracks source execution separately from destination delivery. A successful Arbitrum transaction enters the bridging state; confirmation requires LayerZero evidence matching the source transaction, message GUID and route.
  • Looks up delivery through the protected chain service, with backoff for retryable problems and no further polling after delivery or a permanent failure.
  • Preserves bridge amounts, fees and message identifiers in activity and restored history. Delivery problems remain visible and never trigger an automatic paid retry.

Based on #157. This PR adds outbound bridging APIs; native release sends remain Arbitrum-only until routes are enabled and validated. Inbound deposit addresses are provided separately by #159.

QA Notes

  • Run cargo test --locked --lib modules::usdt, cargo fmt --check and cargo clippy --locked --lib --tests.
  • Coverage includes bridge quotes, approval limits, source settlement, delivery-message matching, polling and history recovery. The opt-in Arbitrum fork test exercises deployed helper contracts, USDT fee collection and atomic rollback.
  • Fork fixtures do not establish live bridge pricing or destination delivery. Validate destination receipt, actual fees and blocked-delivery handling before enabling each route.
  • The deployed helper requires native liquidity and has documented audit limitations; see the module README for the pinned deployment and operating assumptions.
  • Release packaging targets unpublished v0.6.0. Before remote package consumption, build the selected merged source, record the final SwiftPM checksum in a release-preparation commit, and tag/publish that exact iOS archive and matching Android package. Use matching local artifacts for branch testing; later stack layers must use a new version if an earlier layer has already been released.

Base automatically changed from feat/usdt-arbitrum to master September 29, 2026 23:56
@ben-kaufman
ben-kaufman marked this pull request as ready for review September 30, 2026 18:54
@coreyphillips

Copy link
Copy Markdown
Collaborator

Two independent reviews.

needs changing before merge

  • Persisted transfers cannot be read after upgrade (src/modules/usdt/types.rs:91). UsdtTransfer.destination is required by Serde, but rows written by origin/master do not contain it. Store::transfers decodes every row directly, so one existing record makes history and pending recovery return a Storage error. I reproduced this with master-shaped JSON and received missing field destination. Default legacy transfers to Arbitrum or migrate stored rows. Stored quotes have the same problem with destination and received_amount, although they expire after 120 seconds.

worth doing, does not block

  • In-flight bridges are polled on every refresh with no delay (src/modules/usdt/wallet.rs:306). refresh_bridges backs off only when a lookup fails or returns BridgeNeedsAttention. An INFLIGHT or CONFIRMING answer maps to Bridging and adds no entry to bridge_retry_after (src/modules/usdt/wallet.rs, the !matches!(result, Ok(Ok(status)) if status != BridgeNeedsAttention) check). While any bridge is in flight, every refresh_transfers call issues up to three bitkit_getBridgeMessages requests. Those requests share the 80/minute chain budget with sends and recovery, and each one reaches LayerZero Scan through the service. Ethereum delivery can take several minutes, so a screen that refreshes often spends budget on answers that cannot have changed yet. The README documents this behaviour, so it is intended. A short minimum interval for in-flight lookups would still bound the cost. Found by reading; not measured.

nits

  • Restored bridges without an OFTSent match show the full amount as received (src/modules/usdt/history.rs:373). When history restores a bridge, it sets received_amount: amount (src/modules/usdt/history.rs, around line 373). Only settle replaces that value, and only when an OFTSent log matches. If the log is missing or doesn't match, the transfer becomes BridgeNeedsAttention but still reports the full source amount as received. The local send path uses the quoted minAmountLD instead. Decoding minAmountLD from the helper call in decode_payment would make the two paths agree. The same function uses Address::from_word(call.param.to), which silently drops any non-zero upper bytes of to when showing the recipient.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve

Review: diff 13 files.


Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest (author)

Copy link
Copy Markdown
Collaborator Author

@coreyphillips fixed the polling and restored-amount items in 25d4492.

  • In-flight bridge checks now wait at least 30 seconds. Lookup errors and retryable delivery problems keep the existing one-minute backoff, and terminal statuses stop polling.
  • Restored history keeps the saved receiving amount or decodes the signed minAmountLD, rather than substituting the full source amount. A matching OFTSent event still supplies the source-confirmed amount. The minimum is not treated as proof of destination delivery.

The missing-field reproduction is correct, but the USDT integration has not shipped in a Bitkit release. We are deliberately keeping one final schema rather than adding compatibility for development versions. This change does not reset databases or discard pending payments. If the Arbitrum-only schema ships before bridging, we will need to handle that released schema before shipping this change.

I left EVM recipient decoding unchanged. It matches LayerZero's bytes32ToAddress, and our sends already encode zero-padded EVM addresses. Non-EVM routes need separate recipient handling.

Validation: 61 USDT tests passed, with the opt-in deployed-contract fork test ignored. Formatting and Clippy passed, with existing warnings outside USDT. The existing bridge test now covers the polling interval and restored amounts without matching bridge evidence.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve

Reaudit: diff 4 files.
No new findings; the rest is in the review.


Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest (author)

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

utACk

@ben-kaufman
ben-kaufman merged commit ef91d0c into master Oct 1, 2026
5 checks passed
@ben-kaufman
ben-kaufman deleted the feat/usdt0-outbound branch October 1, 2026 03:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants