Repository navigation
ENG-717: payment splits v2 — receive-side splits via shadow receive accounts - #529
Merged
islandbitcoin merged 2 commits intoOct 19, 2026
Conversation
…ccounts Replaces v1's hold-and-forward executor: external Lightning receives on a split-enabled wallet are minted on a Flash-owned shadow receive account (IBEX account + LNURL-pay, one per wallet, permanent), and on settle the webhook fans the gross out — each rule's share, then the merchant's net — within seconds. No user wallet ever holds the gross. - receiveAccountFor routes the three external mint sites (self/recipient addInvoice, ln-noamount-invoice-create) and accountDefaultWallet's lnurlp (what flash-pay serves for Lightning addresses; its no-cache companion PR must deploy before Lightning-address splits are relied on) - buildLegs (ex recordSplitExecutions): one share leg per enabled rule plus one net leg to the merchant = gross − Σ shares; sub-minShareCents shares fold into the net; unique (triggerKey, legKind, destinationWalletId) dedupes webhook replays and the reconcile - payout legs pay from the shadow directly (origin "split" marker) — exempt from the merchant's send limits/KYC by design; fold-never-fail on share legs (inactive destination also disables the rule); net-leg failure stays pending with an ops alert (the only human-resolution case) - reconcile scans shadows only: missed-webhook leg rebuild + residual- balance alert (customer funds in Flash custody; shadowAlertAfterMs) - execution kicks inline after the webhook responds 200; the loop remains the retry/safety net; claim/backoff/ibex_pending/unknown mechanics kept - migration drops v1's execution unique index, creates the legKind one, ensures splitreceiveaccounts indexes (v1 rows all terminal in prod) Ships behind paymentSplits.enabled (already true on TEST and prod — the flag now gates v2). Rollout: flash-pay first, then this, per the PR body. Co-Authored-By: Claude Code <noreply@anthropic.com>
…hadow resolve, read-only hot paths Review findings applied: Blocking: 1. foldShareToNet now inserts the fold row BEFORE notifying the owner (the push claimed money was accounted for before it was true), and a failed insert pages ops (split-fold-failed) instead of dying in a log line. 2. A share leg whose trigger hash was marked internal after build now FOLDS to the merchant like any terminal share failure — it was dropped with no fold, no push, no ops event, leaving the share stranded in the shadow. 3. The webhook fails OPEN when shadow resolution errors: a 500 made IBEX redeliver and re-run notifications; the reconcile rebuilds missed legs, so a plain-receive treatment loses nothing but time. Should-fix: 1. ownerAccountId type-narrowed (no cast; explicit loud path if a refactor ever reaches the fold before account resolution). 2. receiveAccountFor and accountDefaultWallet's lnurlp swap are READ-ONLY: no IBEX account/LNURL creation on invoice-creation and lookup hot paths. Creation stays walletSplitsSet's job; shadowless wallets fail safe to the real account. 3. buildLegs uses computeShare's exported minorUnitsPerMajor map — one source of truth for the share/net conversion. Co-Authored-By: Claude Code <noreply@anthropic.com>
Contributor
Author
|
Review in flight, don't merge — now resolved: all 3 Blocking findings and Should-fix 1–3 from the review are applied in 64ba5c4 (fold-before-notify + fold-failure paging; internal-marked share legs fold instead of dropping silently; webhook fails open on shadow-resolve errors; read-only hot paths; shared minor-unit map). Full unit suite 3842 ✅, affected integration specs ✅, lint/prettier/typos clean. |
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.
What / why
v1 (ENG-653) holds the merchant's gross for 2–3 minutes and forwards each share as a normal Flash-to-Flash send. The API customer who asked for splits will not use hold-and-forward. v2 replaces the trigger and executor so no user wallet ever holds the gross:
walletSplitsSetbefore rules are saved; creation failure fails the mutation.receiveAccountFor:add-invoice-for-wallet(self + recipient paths),ln-noamount-invoice-create, andaccountDefaultWallet'slnurlp(the field flash-pay serves for Lightning addresses). Fail-safe: any error mints on the real account — unsplit, never failed.invoice/lnurlwebhook),buildLegsinserts one share leg per enabled rule and one net leg to the merchant (gross − Σ shares; sub-minShareCentsshares fold into the net), all under the unique(triggerKey, legKind, destinationWalletId)index — webhook replays and reconcile re-runs are duplicate-key no-ops. Rules are snapshotted at settle.addInvoice+payInvoice(origin: "split"marker written before paying, fail-closed). NotintraledgerPaymentSendWalletId: the shadow has no Flash account and split legs are exempt from the merchant's send limits/KYC (decision 1). Internal pays are fee-free and synchronous on IBEX (Phase 0 verified).pending, ops alert, funds remain in the shadow.shadowAlertAfterMs(default 10 min) — customer funds in Flash custody.Approved decisions (owner, 2026-10-15)
+netfrom the shadow, memo carries the gross ("Payment received $X") in v2; merged gross view is a follow-up.recordSplitExecutionsmiddleware, IBEX-wide reconcile and send-intraledger executor are deleted.paymentSplits.enabledremains the single kill switch (off → real-account minting, loop idle).Ops runbook line (custody attribution)
User funds sit in a Flash-owned IBEX account (the shadow, named
flash-splits-<walletId>) for seconds per receive — longer only in the stuck-net case. Accounting: treat shadow balances as customer liabilities. Alert💰 Deposit — Split Residue(split-residue) means a shadow holds a balance; resolve by hand (IBEX dashboard → pay the merchant's wallet), never by re-running the executor.Tests
Every branch of the new paths is covered:
receive-account.spec.ts— routing (rules/flag/error fail-safes), ensure idempotence + failure-persists-nothingbuild-legs.spec.ts— net math to the cent (USD + USDT micros), fold-below-min, replay idempotence, rule snapshot, no-rules → net-only, internal-marker skipexecute-pending-splits.spec.ts— pays fromsourceIbexAccountId; marker-before-pay fail-closed; fold on terminal share failure (+ENG-660 disable); net-leg failure stays pending;ibex_pendingnever re-paid; backoff ladder; concurrent claims; unknown+ops-alert on ambiguous pay errors; wall-clock stampsreconcile-splits.spec.ts— shadow-only scan, missed-webhook rebuild, residue alert (open-leg window, zero balance, read-failure)on-receive-splits.spec.ts— shadow resolve → legs → 200 → inline kick → split push; real-account receives untouched; onchain/zap untouched; 200-on-erroradd-invoice-for-wallet.spec.ts,get-lnurlp.spec.ts(viaaccountDefaultWallet),send-intraledger.spec.ts(real-account minting unchanged), call-sites guard updated for the newinternal-transfer.tscallersplitreceiveaccountsuniqueness + v1 index gone; fold insert replay-safetsc-check/eslint-check/ full unit suite (3842 ✅) / integration (36 ✅; the 3 kratos-preregistration failures are pre-existing on clean main — local dev Kratos staleness, unrelated) /write-sdl+check:sdl(no SDL movement) /typos/prettier@3.6.2all green. Migration20261019000000up/down/up verified against a live Mongo.Rollout order
fetchPolicy: "no-cache"— the module-level Apollo cache otherwise pins a username'slnurlpper process, so the real→shadow swap would not propagate). Deploy flash-pay to TEST + prod before relying on Lightning-address splits; bolt11/API-invoice splits work without it.bump-flash-imagePR on charts → chart publish → pin PR on deployments; merge pin after CI.scripts/deploy-test.sh flash; verifykubectl logs deploy/ibex-webhook | grep "payment splits loop started".sandbox/ibex-splits-test/e2e-test.mjsrecipe (demo_account → shadow invoice → external sandbox payer → +90%/+10% within 10 s; closed-destination fold; rules-deleted revert).make flash ENV=prodon the jumpbox; verify loop line + first shadow receive.Ships behind
paymentSplits.enabled(already true on TEST and prod — the flag now gates v2).🤖 Generated with Claude Code