feat: [epic] bridge integration#413
Open
islandbitcoin wants to merge 44 commits into
Open
Conversation
…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
#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>
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 }, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Big bridge integration PR.
The following must be complete before this is merged:
[ ] - Sandbox e2e test suite pass
[ ] - CI checks pass
[ ] - UAT pass