Skip to content

ENG-717: payment splits v2 — receive-side splits via shadow receive accounts - #529

Merged
islandbitcoin merged 2 commits into
mainfrom
jabariennis/eng-717-payment-splits-v2-receive-side-splits-via-shadow-receive
Oct 19, 2026
Merged

islandbitcoin merged 2 commits into
mainfrom
jabariennis/eng-717-payment-splits-v2-receive-side-splits-via-shadow-receive

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

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:

  • Every split-enabled wallet gets a Flash-owned shadow receive account (IBEX account + LNURL-pay, one per wallet, permanent — decision 4), created lazily by walletSplitsSet before rules are saved; creation failure fails the mutation.
  • The three external mint sites route through receiveAccountFor: add-invoice-for-wallet (self + recipient paths), ln-noamount-invoice-create, and accountDefaultWallet's lnurlp (the field flash-pay serves for Lightning addresses). Fail-safe: any error mints on the real account — unsplit, never failed.
  • On settle (invoice/lnurl webhook), buildLegs inserts one share leg per enabled rule and one net leg to the merchant (gross − Σ shares; sub-minShareCents shares 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.
  • The webhook responds 200, then legs execute inline in-process: the shadow pays each leg via a direct addInvoice+payInvoice (origin: "split" marker written before paying, fail-closed). Not intraledgerPaymentSendWalletId: 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).
  • Fold, never fail (decision 2): a terminal failure on a share leg tops the merchant up with a fold row (share-kind, keyed to the merchant — distinct from the settle-time net row by legKind) and pushes the owner once; an inactive destination also disables the rule (ENG-660 carry-over). A net-leg failure is the only human-resolution case: the leg stays pending, ops alert, funds remain in the shadow.
  • Reconcile scans shadows only (v1's IBEX-wide scan deleted): rebuilds missed-webhook leg sets keyed by payment hash, and alerts ops when a shadow holds a balance with no open leg for shadowAlertAfterMs (default 10 min) — customer funds in Flash custody.
  • Last rule deleted → routing reverts to the real account immediately; the shadow stays and the reconcile drains residue awareness remains.

Approved decisions (owner, 2026-10-15)

  1. Split legs exempt from merchant send limits/KYC; receive-side checks unchanged at mint.
  2. Fold, never fail.
  3. History = one +net from the shadow, memo carries the gross ("Payment received $X") in v2; merged gross view is a follow-up.
  4. Shadow is permanent per wallet.
  5. Replace, do not flag-switch: v1's recordSplitExecutions middleware, IBEX-wide reconcile and send-intraledger executor are deleted. paymentSplits.enabled remains the single kill switch (off → real-account minting, loop idle).
  6. Cash Wallet (USDT) only; shadow currency = wallet currency.

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-nothing
  • build-legs.spec.ts — net math to the cent (USD + USDT micros), fold-below-min, replay idempotence, rule snapshot, no-rules → net-only, internal-marker skip
  • execute-pending-splits.spec.ts — pays from sourceIbexAccountId; marker-before-pay fail-closed; fold on terminal share failure (+ENG-660 disable); net-leg failure stays pending; ibex_pending never re-paid; backoff ladder; concurrent claims; unknown+ops-alert on ambiguous pay errors; wall-clock stamps
  • reconcile-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-error
  • add-invoice-for-wallet.spec.ts, get-lnurlp.spec.ts (via accountDefaultWallet), send-intraledger.spec.ts (real-account minting unchanged), call-sites guard updated for the new internal-transfer.ts caller
  • integration: new unique index + splitreceiveaccounts uniqueness + v1 index gone; fold insert replay-safe

tsc-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.2 all green. Migration 20261019000000 up/down/up verified against a live Mongo.

Rollout order

  1. flash-pay PR first: fix(lnurlp): fetch wallet lnurlp with no-cache so wallet routing changes propagate flash-pay#40 (fetchPolicy: "no-cache" — the module-level Apollo cache otherwise pins a username's lnurlp per 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.
  2. This PR → Concourse builds image → bump-flash-image PR on charts → chart publish → pin PR on deployments; merge pin after CI.
  3. TEST deploy via scripts/deploy-test.sh flash; verify kubectl logs deploy/ibex-webhook | grep "payment splits loop started".
  4. TEST E2E per sandbox/ibex-splits-test/e2e-test.mjs recipe (demo_account → shadow invoice → external sandbox payer → +90%/+10% within 10 s; closed-destination fold; rules-deleted revert).
  5. Prod: human runs make flash ENV=prod on the jumpbox; verify loop line + first shadow receive.
  6. flash-docs guide rewrite + notify the API customer (after prod is live).

Ships behind paymentSplits.enabled (already true on TEST and prod — the flag now gates v2).

🤖 Generated with Claude Code

…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>
@linear

linear Bot commented Oct 19, 2026

Copy link
Copy Markdown

ENG-717

…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>
@islandbitcoin

Copy link
Copy Markdown
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.

@islandbitcoin
islandbitcoin merged commit 7402dd7 into main Oct 19, 2026
15 checks passed
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.

2 participants