Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions dev/config/base-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,18 @@ fygaro:
lookbackDays: 7
maxPerSweep: 20

# ENG-653: Flash-native payment splits for incoming payments.
# ENG-653, v2 (ENG-717): Flash-native payment splits, settled through a shadow
# receive account.
paymentSplits:
enabled: false # flags-off baseline; enable per environment via config overrides
maxAttempts: 5
minShareCents: 1
minShareCents: 1 # a share below this folds into the merchant's net leg
loopIntervalMs: 60000 # executor + stale-sweep cadence inside the ibex-webhook pod
executorTimeBudgetMs: 45000 # one executor pass drains due rows for at most this long
claimGraceMs: 120000 # a new pending split is not claimable for this long (lets a post-payment LNURL internal marker land)
executorTimeBudgetMs: 45000 # one executor pass drains due legs for at most this long
claimGraceMs: 120000 # a new pending leg is not claimable for this long (lets a post-payment LNURL internal marker land)
reconcileIntervalMs: 900000 # webhook-safety-net reconcile cadence inside the same loop
cronIntervalMs: 86400000 # galoy cronjob schedule (chart: 0 2 * * *); sizes the cron reconcile lookback
shadowAlertAfterMs: 600000 # residual balance on a shadow with no open leg: ops alert (customer funds in custody)

bridge:
enabled: false # flags-off baseline; enable per environment via config overrides
Expand Down
21 changes: 21 additions & 0 deletions src/app/accounts/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { AccountsRepository, WalletsRepository } from "@services/mongoose"
import {
ensureReceiveAccount,
hasEnabledSplits,
} from "@app/payment-splits/receive-account"

export * from "./account-limit"
export * from "./add-new-contact"
Expand Down Expand Up @@ -86,5 +90,22 @@ export const getLnurlpFromWalletId = async (
): Promise<Lnurl | ApplicationError> => {
const wallet = await WalletsRepository().findById(walletId)
if (wallet instanceof Error) return wallet

// ENG-717: while the wallet has enabled split rules its Lightning address
// serves the SHADOW's LNURL-pay, so external pays settle on the shadow and
// fan out. (flash-pay must not cache this answer — see its no-cache PR.)
const shadow = await hasEnabledSplits(walletId)
if (shadow === true) {
const receiveAccount = await ensureReceiveAccount({
walletId,
currency: wallet.currency,
})
if (!(receiveAccount instanceof Error) && receiveAccount.lnurlp) {
return receiveAccount.lnurlp as Lnurl
}
// No shadow (or no LNURL on it): fall through to the wallet's own
// LNURL — an unsplit receive, the fail-safe everywhere else too.
}

return wallet.lnurlp
}
177 changes: 177 additions & 0 deletions src/app/payment-splits/build-legs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { PaymentSplitsConfig } from "@config"

import {
computeShare,
isSplitCurrency,
minorUnitsPerMajor,
type SplitLegKind,
} from "@domain/payment-splits"

import { isInternalTransferInvoice } from "@services/mongoose/internal-transfer-invoices"
import { PaymentSplitExecutionsRepository } from "@services/mongoose/payment-split-executions"
import { PaymentSplitRulesRepository } from "@services/mongoose/payment-split-rules"

export type BuildLegsResult = {
recorded: number
duplicates: number
}

const NOTHING: BuildLegsResult = { recorded: 0, duplicates: 0 }

/**
* Builds the payout leg set for one receive settled on a shadow receive
* account, under `triggerKey = paymentHash`. One `share` leg per enabled rule
* (v1 computeShare, unchanged), plus one `net` leg to the merchant: gross
* minus the sum of the shares, so rounding dust — and any share folded for
* landing below minShareCents — stays with the merchant and the shadow
* returns to exactly 0.
*
* All legs are inserted `pending` in one loop; the unique
* (triggerKey, legKind, destinationWalletId) index makes a replay or a
* reconcile re-run a duplicate-key no-op. Rules are snapshotted here: edits
* after settle never touch legs already built.
*
* Never sends: payouts happen in the executor (webhook-adjacent in-process,
* then the loop as the retry/safety net).
*
* `receivedAt`, when known (the reconcile reads it from IBEX, the webhook
* from its body), keeps a rule from firing on a receive that settled before
* the rule was created. Note this guards SHARE legs only — a receive on the
* shadow only exists because a rule was enabled at invoice time, so the net
* leg is always built.
*/
export const buildLegs = async ({
walletId,
sourceIbexAccountId,
triggerKey,
paymentHash,
grossAmount,
currency,
receivedAt,
now = new Date(),
}: {
// The merchant's Flash wallet (the shadow's owner).
walletId: string
// The shadow IBEX account the receive settled on; every leg pays FROM it.
sourceIbexAccountId: string
triggerKey: string
paymentHash?: string
// The gross receive in major units as IBEX reports it (dollars / USDT).
grossAmount: number
currency: string
receivedAt?: Date
now?: Date
}): Promise<BuildLegsResult | Error> => {
if (!PaymentSplitsConfig.enabled) return NOTHING
if (!isSplitCurrency(currency)) return NOTHING

// Invoices Flash minted for its own transfers (P2P, Fygaro top-ups, referral
// payouts, split payouts, cutover moves) never trigger splits. A shadow
// receive is always external by construction, but the marker check costs
// one indexed read and keeps the invariant absolute: the marker is written
// for the destination invoice of every payout leg, and a payout leg to a
// wallet that itself has splits would otherwise settle on THAT wallet's
// shadow if it ever landed there.
if (paymentHash) {
const internal = await isInternalTransferInvoice(paymentHash)
if (internal instanceof Error) return internal
if (internal) return NOTHING
}

// ENG-653 decision 1, kept: an LNURL send from another Flash user is marked
// internal only after payToLnurl returns, so this receive can be built
// before the marker exists. The executor re-checks the marker at pay time;
// holding the rows back for claimGraceMs lets that marker land first.
const firstClaimableAt = new Date(now.getTime() + PaymentSplitsConfig.claimGraceMs)

const rules = await PaymentSplitRulesRepository().listByWalletId(walletId, {
enabledOnly: true,
})
if (rules instanceof Error) return rules

const executions = PaymentSplitExecutionsRepository()
const result = { ...NOTHING }

// The shares first, so the net can subtract the shares actually built.
let sharesCents = 0
type PlannedLeg = {
legKind: SplitLegKind
destinationWalletId: string
ruleId: string
shareCents: number
}
const planned: PlannedLeg[] = []

for (const rule of rules) {
if (receivedAt && receivedAt.getTime() < rule.createdAt.getTime()) continue

const share = computeShare({
amount: grossAmount,
currency,
percent: rule.percent,
minShareCents: PaymentSplitsConfig.minShareCents,
})
if (share instanceof Error) return share

// A share below minShareCents folds into the net leg (decision 2:
// fold, never fail) — it is not recorded as its own row.
if (share.status === "skipped_min") continue

sharesCents += share.shareCents
planned.push({
legKind: "share",
destinationWalletId: rule.destinationWalletId,
ruleId: rule.id,
shareCents: share.shareCents,
})
}

// Gross in minor units via computeShare's own map, so net = gross − Σ
// shares can never diverge from the share conversion.
const perMajor = minorUnitsPerMajor(currency)
if (perMajor === undefined) return NOTHING // isSplitCurrency gate above
const grossMinor = BigInt(Math.round(grossAmount * Number(perMajor)))
const netCents = Number(grossMinor / (perMajor / 100n)) - sharesCents
if (netCents < 0) {
// Rules summing over 100 are refused at save time; a rule edited between
// snapshot and build cannot push Σ shares past the gross either (each
// share is floored). Unreachable in practice; refuse rather than pay a
// negative leg.
return new Error(
`split legs exceed the gross: net ${netCents} cents for trigger ${triggerKey}`,
)
}

// The merchant is the net destination. The wallet record's id IS the
// merchant's IBEX account id, so the leg pays wallet → wallet from the
// shadow. ruleId is required by the schema; the net leg has no rule, so it
// carries the merchant's wallet id as a stable placeholder.
planned.push({
legKind: "net",
destinationWalletId: walletId,
ruleId: walletId,
shareCents: netCents,
})

for (const leg of planned) {
const inserted = await executions.insert({
triggerKey,
legKind: leg.legKind,
ruleId: leg.ruleId,
walletId,
sourceIbexAccountId,
destinationWalletId: leg.destinationWalletId,
currency,
grossMinor: grossMinor.toString(),
shareCents: leg.shareCents,
status: "pending",
nextAttemptAt: firstClaimableAt,
now,
})
if (inserted instanceof Error) return inserted
if (!inserted.inserted) result.duplicates += 1
else result.recorded += 1
}

return result
}
Loading
Loading