Skip to content

feat: [epic] bridge integration#413

Open
islandbitcoin wants to merge 44 commits into
mainfrom
tmp/bridge-rebase-pr-ready
Open

feat: [epic] bridge integration#413
islandbitcoin wants to merge 44 commits into
mainfrom
tmp/bridge-rebase-pr-ready

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Big bridge integration PR.

The following must be complete before this is merged:
[ ] - Sandbox e2e test suite pass
[ ] - CI checks pass
[ ] - UAT pass

forge0x and others added 30 commits May 31, 2026 08:37
…63 ENG-297 fix(bridge): apply audit hardening and hosted KYC refinements
Ready to merge.

Verified locally:
- `yarn check:sdl` passes
- targeted bridge/IBEX unit tests pass
- `git diff --check` passes

**squash & merge** used to keep `tmp/bridge-rebase-pr-ready` clean.
* ENG-297 fix(wallets): support USDT cash wallet parity

* fix(wallets): support USDT intraledger sends
* feat: add timeout for Bridge request
* feat(bridge): ENG-350 — reset withdrawal state on transfer.failed and surface failure reason
* feat(bridge): ENG-350 harden transfer webhook edge cases and notify users
* feat(bridge): ENG-350 introduce full KYC status lifecycle with offboarded support
* refactor(bridge): rename BridgeDepositLog model to BridgeDeposits
* refactor(ibex): rename IbexCryptoReceiveLog model to IbexCryptoReceive
* refactor(bridge): rename BridgeReplayLog model to BridgeReplay
* fix(bridge) : ENG-289 timeout increase to 15s
* feat: adding full kyc status to reflect them stored as bridgeKycStatus in mongoDB instead of just pending state
* feat: Kyc status
* fix: Duplicated webhook event message
* fix: Duplicated webhook deposit event message
* fix: Virtual Account Edge case
* fix(graphql): make usdt wallet balance nullable (#369)

* feat(cutover): add cash wallet migration state primitives

* feat(cutover): persist cash wallet cutover state

* feat(cutover): add cash wallet write guard

* feat(cutover): classify cash wallet migration candidates

* feat(cutover): add cash wallet preflight summary

* feat(cutover): collect cash wallet discovery results

* feat(cutover): plan primary cash wallet migrations

* feat(cutover): upsert planned migration records

* feat(cutover): prepare primary migration batch

* feat(cutover): start migration worker checkpoint

* feat(cutover): record migration source balance

* feat(cutover): create balance move invoice checkpoint

* feat(cutover): send balance move payment checkpoint

* feat(cutover): verify balance move checkpoint

* feat(cutover): create fee reimbursement invoice checkpoint

* feat(cutover): complete fee reimbursement checkpoint

* feat(cutover): flip default wallet checkpoint

* feat(cutover): complete migration worker checkpoints

* feat(cutover): provision destination checkpoint

* feat(cutover): dispatch migration worker steps

* feat(cutover): run locked migration batches

* feat(cutover): build migration step handlers

* feat(cutover): wire migration runtime services

* feat(cutover): orchestrate primary migration batches

* feat(cutover): add migration lifecycle controls

* fix(cutover): align migration indexes with run ids

* feat(cutover): add operator command script

* chore(cutover): format migration state helpers

* feat(cutover): preview dry-run migration plan

* fix(cutover): satisfy production build types

* feat(cutover): add operator controls and verification

* feat(cutover): add client-aware cash wallet presentation

* fix(cutover): harden operator cutover run (#376)

* feat(cutover): add operator dashboard and provisioning tools

* fix(cutover): use ibex oauth credentials in local setup

* fix(cutover): retry wallet provisioning and refresh stale invoices

* fix(cutover): retry ibex rate limits during migration payments

* fix(cutover): clarify dashboard run anomalies

* chore(cutover): remove local run artifacts

* chore(cutover): trim bridge PR noise

* chore(cutover): move unit tests to separate PR

* Potential fix for pull request finding

* Potential fix for pull request finding

* Potential fix for pull request finding

Co-authored-by: Island Bitcoin <34528298+islandbitcoin@users.noreply.github.com>
* fix(wallets): preserve USDT transaction history precision

* chore(wallets): restore base relay import

* fix(wallets): handle missing IBEX USDT history amounts
…et (#379)

* feat(externalAccounts): Webhook endpoint firing for external accounts

feat: external accounts event triggering

this commit initialize the webhook endpoint to register on Bridge to trigger external account link after plaid completion

* fix(ENG-350) : reset pending withdraw state on transfer failure.

this commit mark the transfer as failed in our bridge withdrawals collection when something went wrong during the trasfer flow, the user is notified with a popup and on return state, as well for transfer successfully completed

* fix(bridge): normalize currency display in withdrawal notification

Map "usdt" to "USD" and uppercase other currency codes so the push
notification shows a human-readable label instead of a raw enum value.
Require a prepared runnable migration row before Cash Wallet cutover start can move config to in_progress.
#386)

* feat: add BridgeTransferRequest ERPNext audit model and webhook wiring

Adds a full Bridge Transfer Request audit record system that writes
ERPNext DocType rows from Bridge deposit, IBEX crypto receive, and
Bridge transfer webhook events.

- BridgeTransferRequest model with toErpnext() serialization
- BridgeTransferRequestWriter with 4 event handlers
- ErpNext.upsertBridgeTransferRequest with idempotent upsert
- Webhook wiring in deposit, transfer, and crypto-receive routes
- Bridge deposit log made idempotent by eventId

* fix: normalize IBEX network casing for ERPNext consistency

The IBEX crypto receive route lowercases the network field for Mongo
storage, but was passing the lowercased value to the BridgeTransferRequest
model. This created inconsistent casing in ERPNext records: deposits used
'Ethereum' (model default) while IBEX receives used 'ethereum'.

Fix: capitalize the normalized network before passing to the writer.
The lowercase value is still stored in the IBEX log and raw payload.
#385)

* fix(bridge): lower account level requirement from level 2 to level > 0

Bridge operations now allow any non-zero account level instead of requiring Pro (level 2+).

* fix: account denomination for non zero level

---------

Co-authored-by: Island Bitcoin <34528298+islandbitcoin@users.noreply.github.com>
- Rename createBridgeReplayLog → createBridgeReplay in replay.spec.ts to
  match the production export; spec was failing on every run before this.
- Remove start_date/end_date from ListEventsParams — Bridge /webhook_events
  only supports starting_after, ending_before, limit and category; date
  range filtering is now applied locally inside listAllEvents.
- Update replay-bridge-events.ts to pass start/end (local filter) instead
  of the silently-dropped start_date/end_date.
- Update client.spec.ts to assert local filtering behaviour rather than
  asserting those params are forwarded to the Bridge API.
Co-authored-by: Vandana <forge@getflash.io>
Co-authored-by: Vandana <forge@getflash.io>
* fix(ibex): convert payToLnurl amount to explicit millisatoshis

The IBEX LNURL-pay API (POST /v2/lnurl/pay/send) expects the amount
in millisatoshis, but PayLnurlArgs.send.amount passed the wallet
currency's base unit directly (USDT micros, USD cents, or BTC sats).

Changed PayLnurlArgs to accept amountMsat: number instead of
send: IbexCurrency. This makes the expected unit explicit and forces
callers to perform the msat conversion at the app layer where the
DealerPriceService is available.

ENG-406

* feat(lnurl): add USD wallet LNURL payment mutation
Fires a best-effort push when a Bridge USDT deposit settles via the IBEX
crypto.received webhook (the must-have launch gate). Mirrors the existing
sendBridgeWithdrawalNotification pattern:
- new src/app/bridge/send-deposit-notification.ts
- wired at the crypto-receive settlement success (idempotent — inside the
  per-txHash lock; fires once)
- notification.bridgeDeposit i18n phrases (en + es)

Withdrawal-completion push already exists (transfer.ts) — this completes the
deposit side. IBEX→USD currency display mirrors the withdrawal notif.

Co-authored-by: Dread <bobodread@bobodread.com>
#387)

* feat(bridge): split withdrawal into request, confirm, and cancel steps

Introduce a two-step off-ramp flow so users review a pending withdrawal
before Bridge is called, with deduplication, cancellation, and push
notifications for the cancelled outcome.

* docs(bridge): document two-step withdrawal flow in FLOWS and API

Update bridge-integration docs to describe bridgeRequestWithdrawal,
bridgeInitiateWithdrawal(withdrawalId), and bridgeCancelWithdrawalRequest.

* chore(bruno): add GraphQL requests for bridge withdrawal flow

Add Bruno mutations and query for request, initiate, cancel, and fetch
pending withdrawal, with local env vars for bridgeWithdrawalId.

* fix(config): add external_account webhook public key to dev config

Required by the Bridge webhook schema so local write-sdl and config
validation succeed.

* chore(graphql): regenerate supergraph for bridge withdrawal mutations

Add bridgeRequestWithdrawal, bridgeCancelWithdrawalRequest, and
bridgeWithdrawalRequest to the federated supergraph schema.

* fix(bridge): map withdrawal request errors to Bridge codes

BridgeWithdrawalNotFoundError and BridgeWithdrawalAlreadyInitiatedError
now map to dedicated BRIDGE_WITHDRAWAL_NOT_FOUND and
BRIDGE_WITHDRAWAL_ALREADY_INITIATED codes instead of the generic
INVALID_INPUT. Tests updated to assert the correct Bridge-specific codes.

* test(bridge): add resolver coverage for request/initiate/cancel withdrawal

Eight resolver-layer tests across the three bridge withdrawal mutations:
happy-path delegation to BridgeService and the two new Bridge-specific
error codes (BRIDGE_WITHDRAWAL_NOT_FOUND, BRIDGE_WITHDRAWAL_ALREADY_INITIATED)
flowing through the full error-map pipeline.

* fix(bridge): resolve duplicate const declarations in deposit webhook handler

Auto-merge conflict left two `const lockKey`/`const lockResult` pairs in
the same function scope. Rename the audit-idempotency pair to
`auditLockKey`/`auditLockResult` to fix TS2451.

* fix(graphql): align BridgeWithdrawal type with new id/status contract

The type had transferId: NonNull and state: NonNull left over from the
old single-step flow. All new resolvers (request/initiate/cancel
mutations plus bridgeWithdrawalRequest query) return id + status, so
those NonNull fields were always null at runtime.

Changes:
- bridge-withdrawal.ts: transferId → id (NonNull), state removed,
  bridgeTransferId added (nullable)
- index.ts: WithdrawalResult and InitiateWithdrawalResult updated to
  match; getWithdrawals map rewritten (id/status/bridgeTransferId/
  externalAccountId) and the always-true filter removed
- bridge-withdrawal-request.ts: add bridgeTransferId to return object
- bridge-contract.spec.ts: assertions updated to new field set

* test(bridge): cover getWithdrawals mapping to id/status/bridgeTransferId

Five tests asserting that getWithdrawals emits the new GQL-facing shape
(id, status, bridgeTransferId) and not the old transferId/state fields.
Also adds findWithdrawalsByAccountId to the bridge-accounts mock so the
describe block can exercise the mapping path.

* feat(bridge): add submitted status for initiated withdrawals

Introduces an intermediary "submitted" status so the UI can distinguish
between a pending withdrawal (awaiting user confirmation) and one that has
already been submitted to Bridge (awaiting Bridge processing).

- schema.ts: adds "submitted" to IBridgeWithdrawalRecord.status union and
  Mongoose enum
- bridge-accounts.ts: updateWithdrawalTransferId now sets status →
  "submitted" atomically with the bridgeTransferId write;
  updateWithdrawalStatus query filter changed from "pending" to "submitted"
  so webhook-driven transitions (completed/failed) match the correct row
- index.spec.ts: mock fixture updated; initiate test now asserts
  result.status === "submitted" and result.bridgeTransferId is set

* fix(bridge): restore getWithdrawals filter with correct && operator

The original filter `w.bridgeTransferId !== null || w.bridgeTransferId !==
undefined` was a tautology (always true), so pending rows with no
bridgeTransferId leaked through — surfacing a null id on a NonNull field
when transferId was the source.

The filter is now `!== null && !== undefined`, preserving the intended
invariant: getWithdrawals (bridgeWithdrawals query) returns only rows
that have been submitted to Bridge. Clients use bridgeWithdrawalRequest(id)
to inspect individual pending/cancelled rows.

Tests updated to assert the exclusion of pending/cancelled rows and the
inclusion of submitted/completed/failed rows.

* fix(graphql): map bridge withdrawal errors through bridgeGqlError

Use BRIDGE_WITHDRAWAL_NOT_FOUND and BRIDGE_WITHDRAWAL_ALREADY_INITIATED
via the shared Bridge error helper so these codes match the documented
contract and other Bridge GraphQL mappings.

* test(bridge): cover withdrawal request confirm and cancel flow

Add chained service tests for request, dedupe, initiate, cancel, and
error guards, and tighten resolver assertions for pending/submitted
status and cancelled delegation.

* chore(graphql): regenerate schema for withdrawal request flow

Align BridgeWithdrawal SDL with id/status/bridgeTransferId and
recompose the federated supergraph.

* test(bridge): guard getWithdrawals id/status GraphQL contract

Add return-shape regression tests so getWithdrawals and initiateWithdrawal
emit id/status (not legacy transferId/state) and exclude rows without a
bridgeTransferId. Add bridgeWithdrawals resolver coverage for passthrough.

* chore(bridge): fix surgical lint issues in withdrawal flow

* chore(bridge): finish withdrawal lint and docs cleanup
patoo0x and others added 13 commits June 8, 2026 06:14
#391)

* feat(alerts): add Bridge AlertService (PagerDuty/Slack/Discord) [ENG-361]

Severity-routed best-effort alert fan-out: critical pages PagerDuty + informs
Slack/Mattermost + Discord; warning informs only. Each sender no-ops when its
env credential is unset. Config: ALERT_PAGERDUTY_ROUTING_KEY / ALERT_SLACK_WEBHOOK_URL
/ ALERT_DISCORD_WEBHOOK_URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(alerts): wire Bridge alert sources to AlertService [ENG-361]

Fire-and-forget alertBridge() at the Bridge failure points (alongside existing
logging), all critical/page:
- ERPNext audit-write failures (deposit + transfer completed/failed)
- Bridge webhook processing exceptions (deposit + transfer catch)
- Bridge API outage in client.request(): 5xx / timeout / network (4xx not alerted)

IBEX-error source deferred: on-receive.ts is general LN/onchain receive handling,
not the Bridge<->IBEX movement path; needs the exact call site (warning sev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(alerts): add Bridge alerting setup guide [ENG-361]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(alerts): clean bridge alert lint and deposit test

* feat(alerts): dedupe Bridge alerts and wire IBEX movement warnings [ENG-361]

Group PagerDuty triggers with dedup_key, suppress duplicate Slack/Discord informs, and alert IBEX crypto-receive and reconciliation failures as warnings.

* chore(alerts): format bridge alert updates

* chore(alerts): trim dedup cleanup noise

---------

Co-authored-by: Dread <bobodread@bobodread.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vandana <forge@getflash.io>
Co-authored-by: heyolaniran <olaniran.abd@gmail.com>
…395)

* feat(cashout): route Cashout V1 wallets via cutover guard (ENG-357)

Cashout V1 always debited the legacy USD wallet and credited the bank-owner
USD wallet. Post-cutover the user's funds live in an ETH-USDT cash wallet, so
the offer must debit USDT and credit the bank-owner's USDT wallet. The Flash
bank-owner account holds both a USD and a USDT wallet, so the route simply
selects the matching pair on both sides — no cross-currency swap.

- Add resolveCashoutWalletSelection: reads the cutover config + per-account
  migration and runs evaluateCashWalletCutoverGuard to pick the route. Source
  and destination wallets are resolved server-side from the guard, NOT from the
  client-supplied walletId (trusted only for wallet-level auth). The guard
  blocks the cashout while a migration is in-flight or has failed.
- CashoutManager.createOffer builds a USD or USDT invoice per route; the
  USD/JMD payout math is unchanged (1 USDT = 1 USD).
- executeCashout authorizes by account, since an old client may still present
  the zeroed legacy USD walletId while the offer settles in USDT, instead of
  an exact wallet-id match.
- CashoutValidator and CashoutDetails.payment.amount are currency-aware; the
  USD path stays byte-identical. ErpNext.draftCashout records the USDT amount.

Adds unit coverage for the routing decision tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cashout): add ENG-357 Bruno smoke coverage

* fix(cashout): harden offer redis deserialization
* feat(bridge): add opt-in sandbox e2e test suite

ENG-274. Covers KYC, virtual account, external account, deposit,
withdrawal, post-cutover state, ETH-USDT LN parity, and ERPNext
audit-row verification. Guarded by RUN_BRIDGE_SANDBOX_E2E=true.
Includes preflight check for Level 1 service guard and
documentation drift cleanup (Level 2->Level 1, Tron->ETH-USDT).

* chore(bridge): automate sandbox webhook setup

* chore(bridge): add ERPNext audit snapshot

* test(bridge): gate hosted sandbox success paths

* test(bridge): quiet sandbox e2e service warnings

* chore(bridge): polish sandbox e2e PR cleanup

* chore(bridge): refresh sandbox e2e backup

* test(bridge): address sandbox e2e review feedback
The Bridge sandbox e2e suite never passed through tsc (CI was disabled),
so type errors accumulated and now fail Check Code. All test-only:

- helpers.ts: route mock req/res through 'unknown' before casting to the
  Express handler param types (TS2352).
- execQuery: make generic (default Record<string, unknown>, backward
  compatible) so callers can type the GraphQL payload (TS2339).
- HandlerResponse.body: type as Record<string, unknown> instead of
  unknown, fixing .body access in the deposit/external-account specs
  without per-site casts (TS18046).
- cutover-state.spec: type the execQuery result and narrow the error
  union before asserting.

No production code touched.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Bridge sandbox API key was committed in dev/config/base-config.yaml.
The key has been rotated/revoked at Bridge (so the value is now inert), and
this removes it from the live tree, replacing it with an empty placeholder —
the real key is injected via config overrides, consistent with the ibex
credentials in the same file.

Note: the rotated key still exists in commit history; since it is revoked
this is no longer a live secret, so a history rewrite of the shared branch
is optional and should be coordinated separately.

Co-authored-by: Dread <bobodread@bobodread.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(bridge): normalize webhook payload envelopes

* fix: key bridge deposit audit rows by stable id

* fix: handle uncreditable bridge deposit activity

* test(bridge): update transfer webhook spec to Bridge envelope + fix kyc status type

- transfer.spec.ts now posts Bridge's real webhook envelope (event_type/event_object,
  nested source.failure_reason) instead of the legacy { event, data } shape the
  handler no longer reads; restores coverage for failed-cashout audit and
  already_terminal paths.
- kyc.ts: cast bridgeKycStatus to Account["bridgeKycStatus"] (BridgeKycStatus was
  referenced but never defined/imported).

---------

Co-authored-by: Vandana <forge@getflash.io>
* fix(ibex): map crypto send transactions as USDT sends

* fix: sign IBEX crypto send display amounts

---------

Co-authored-by: Vandana <forge@getflash.io>
…403)

* feat(bridge): add withdrawal fee estimate config

Introduce developerFeePercent and withdrawalFeeEstimate settings
(bridge fixed fee, gas limit, RPC URL, and fallbacks) for customer
fee breakdown on Bridge withdrawals.

* feat(bridge): compute withdrawal customer fee estimates

Add flash fee, Bridge rail fee (0.6%), and buffered Ethereum gas
estimates, plus presenter helpers for pending vs receipt amounts.

* feat(bridge): persist withdrawal fee breakdown in MongoDB

Store flash, Bridge, gas, and total customer fee estimates on pending
withdrawals and refresh them when reusing an existing pending request.

* feat(bridge): surface fee estimates in withdrawal service flow

Resolve and persist fee estimates on request, map Bridge transfer
receipt fees on initiate, improve Ibex balance error mapping, and pass
developer_fee_percent when creating virtual accounts.

* feat(bridge): add localized flash fee notice for withdrawals

Expose flashFeeNotice copy explaining Flash, Bridge, and gas buffer
components while totals remain estimates until Bridge settles.

* feat(bridge): expose withdrawal fee fields in GraphQL API

Add estimated fee breakdown, subtotal/final amounts, receipt fees,
and flashFeeNotice on BridgeWithdrawal for request and query paths.

* test(bridge): fix unit test failures from fee field additions

- replay.spec.ts: spread jest.requireActual('@config') so getFeesConfig
  survives the partial @config mock
- index.spec.ts: mock updateWithdrawalFeeEstimates in the dedup test so
  stale clearAllMocks() impl doesn't bleed in from a prior test
- return-shapes.spec.ts: toEqual → toMatchObject for getWithdrawals shape
  check, now that presentBridgeWithdrawal emits fee fields
- client-usd-wallet.spec.ts: spread jest.requireActual('ibex-client') so
  IbexUrls survives the partial ibex-client mock

* fix: remove the hardcoded bridge developer fees percentage

* fix: address bridge withdrawal fee review

* fix: address bridge withdrawal fee review

---------

Co-authored-by: Patoo <262265744+patoo0x@users.noreply.github.com>
Co-authored-by: Vandana <forge@getflash.io>
* feat(bridge): send cashout USDT to Bridge deposits

* fix: show pending bridge cashouts in erp

* fix: omit idempotency key when deleting bridge transfers

* fix: preserve accepted bridge cashout sends

* chore(graphql): regenerate SDL + supergraph for bridgeCreateExternalAccount

The bridgeCreateExternalAccount resolver was registered in
src/graphql/public/mutations.ts but the generated schema.graphql and the
Apollo supergraph were never regenerated, so the field was absent from the
SDL/supergraph (and would fail check:sdl in CI). Regenerate both so the
mutation is exposed on the public API.

---------

Co-authored-by: Vandana <forge@getflash.io>
@islandbitcoin islandbitcoin self-assigned this Jun 19, 2026
Conflicts resolved:
- package.json: keep proto-loader 0.8.1 (main) + bridge-mcp dep (ours)
- ibex/client.ts: take main's cappedIbexReceiveExpiration (ENG-427), deduplicate imports
- yarn.lock: regenerated from merged package.json
try {
const [bridgeDeposit, ibexReceive] = await Promise.all([
BridgeDeposits.findOne({
destinationTxHash: { $regex: new RegExp(`^${normalizedHash}$`, "i") },
.lean()
.exec(),
IbexCryptoReceive.findOne({
txHash: { $regex: new RegExp(`^${normalizedHash}$`, "i") },
})

// Webhook routes with signature verification
app.post("/kyc", verifyBridgeSignature("kyc"), kycHandler)

// Webhook routes with signature verification
app.post("/kyc", verifyBridgeSignature("kyc"), kycHandler)
app.post("/deposit", verifyBridgeSignature("deposit"), depositHandler)
// Webhook routes with signature verification
app.post("/kyc", verifyBridgeSignature("kyc"), kycHandler)
app.post("/deposit", verifyBridgeSignature("deposit"), depositHandler)
app.post("/transfer", verifyBridgeSignature("transfer"), transferHandler)
try {
const { bridgeExternalAccountId, accountId, status, ...metadata } = data
const record = await BridgeExternalAccount.findOneAndUpdate(
{ bridgeExternalAccountId, accountId },
if (truncatedReason !== undefined) update.failureReason = truncatedReason

const record = await BridgeWithdrawal.findOneAndUpdate(
{ bridgeTransferId, status: { $in: ["submitted", "usdt_sent"] } },

const record = await BridgeWithdrawal.findOneAndUpdate(
{ bridgeTransferId, status: { $in: ["submitted", "usdt_sent"] } },
update,
)
if (record) return record

const existing = await BridgeWithdrawal.findOne({ bridgeTransferId })
}): Promise<{ id: string } | Error> => {
try {
const log = await BridgeDeposits.findOneAndUpdate(
{ eventId: data.eventId },
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.

5 participants