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
79 changes: 79 additions & 0 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,9 @@ type BTCWallet implements Wallet
"""An unconfirmed incoming onchain balance."""
pendingIncomingBalance: SignedAmount!

"""Payment splits are not supported on BTC wallets; always empty."""
splits: [WalletSplit!]!

"""A list of BTC transactions associated with this wallet."""
transactions(
"""Returns the items in the list that come after the specified cursor."""
Expand Down Expand Up @@ -2259,6 +2262,18 @@ type Mutation
userUpdateLanguage(input: UserUpdateLanguageInput!): UserUpdateLanguagePayload!
userUpdateNpub(input: UserUpdateNpubInput!): UserUpdateNpubPayload!
userUpdateUsername(input: UserUpdateUsernameInput!): UserUpdateUsernamePayload! @deprecated(reason: "Username will be moved to @Handle in Accounts. Also SetUsername naming should be used instead of UpdateUsername to reflect the idempotency of Handles")

"""
Deletes one payment split from a wallet. Splits already triggered still pay out.
"""
walletSplitDelete(input: WalletSplitDeleteInput!): SuccessPayload!

"""
Replaces the payment splits on a USD or USDT wallet. Each
external Lightning receive on the wallet then pays the given percentages on
to the destination wallets. Percentages must add up to at most 100.
"""
walletSplitsSet(input: WalletSplitsSetInput!): WalletSplitsSetPayload!
}

type MyReferralInvite
Expand Down Expand Up @@ -3081,6 +3096,11 @@ type UsdtWallet implements Wallet

"""An unconfirmed incoming onchain balance."""
pendingIncomingBalance: SignedAmount!

"""
Payment split rules on this wallet. Visible only to the wallet's owner.
"""
splits: [WalletSplit!]!
transactions(
"""Returns the items in the list that come after the specified cursor."""
after: String
Expand Down Expand Up @@ -3132,6 +3152,11 @@ type UsdWallet implements Wallet

"""An unconfirmed incoming onchain balance."""
pendingIncomingBalance: SignedAmount!

"""
Payment split rules on this wallet. Visible only to the wallet's owner.
"""
splits: [WalletSplit!]!
transactions(
"""Returns the items in the list that come after the specified cursor."""
after: String
Expand Down Expand Up @@ -3468,6 +3493,9 @@ interface Wallet
lnurlp: Lnurl
pendingIncomingBalance: SignedAmount!

"""Payment split rules on this wallet. Always empty for BTC wallets."""
splits: [WalletSplit!]!

"""
Transactions are ordered anti-chronologically,
ie: the newest transaction will be first
Expand Down Expand Up @@ -3520,3 +3548,54 @@ enum WalletCurrency
"""Unique identifier of a wallet"""
scalar WalletId
@join__type(graph: PUBLIC)

"""
A payment split rule: this percentage of every external Lightning receive on the wallet is paid on to the destination wallet.
"""
type WalletSplit
@join__type(graph: PUBLIC)
{
createdAt: Timestamp!
destinationWalletId: WalletId!
enabled: Boolean!
id: ID!

"""Whole percent of each receive, from 1 to 100."""
percent: Int!
}

input WalletSplitDeleteInput
@join__type(graph: PUBLIC)
{
splitId: ID!
walletId: WalletId!
}

input WalletSplitInput
@join__type(graph: PUBLIC)
{
"""A Flash username or a walletId in the same currency."""
destination: String!

"""Whole percent of each receive, from 1 to 100."""
percent: Int!
}

input WalletSplitsSetInput
@join__type(graph: PUBLIC)
{
"""
The complete split set. Replaces every existing split; an empty list clears them all.
"""
splits: [WalletSplitInput!]!

"""The wallet whose incoming payments are split."""
walletId: WalletId!
}

type WalletSplitsSetPayload
@join__type(graph: PUBLIC)
{
errors: [Error!]!
splits: [WalletSplit!]
}
11 changes: 11 additions & 0 deletions dev/config/base-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ fygaro:
lookbackDays: 7
maxPerSweep: 20

# ENG-653: Flash-native payment splits for incoming payments.
paymentSplits:
enabled: false # flags-off baseline; enable per environment via config overrides
maxAttempts: 5
minShareCents: 1
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)
reconcileIntervalMs: 900000 # webhook-safety-net reconcile cadence inside the same loop
cronIntervalMs: 86400000 # galoy cronjob schedule (chart: 0 2 * * *); sizes the cron reconcile lookback

bridge:
enabled: false # flags-off baseline; enable per environment via config overrides
apiKey: "" # real key injected via config overrides; never commit a real key
Expand Down
30 changes: 28 additions & 2 deletions src/app/cash-wallet-cutover/runtime-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { AccountsRepository, WalletsRepository } from "@services/mongoose"
import Ibex from "@services/ibex/client"
import { UnexpectedIbexResponse } from "@services/ibex/errors"
import { getFunderWalletId } from "@services/ledger/caching"
import { recordInternalTransferInvoice } from "@services/mongoose/internal-transfer-invoices"
import { InternalTransferMarkerError } from "@domain/payment-splits/errors"

import {
CashWalletMigrationFailedError,
Expand All @@ -33,6 +35,7 @@ type RuntimeServiceDependencies = {
walletsRepo?: Pick<ReturnType<typeof WalletsRepository>, "findById" | "listByAccountId">
getFunderWalletId?: typeof getFunderWalletId
getTreasuryWalletId?: () => Promise<WalletId | ApplicationError>
recordInternalTransferInvoice?: typeof recordInternalTransferInvoice
maxRateLimitAttempts?: number
rateLimitRetryDelayMs?: number
sleep?: SleepFn
Expand Down Expand Up @@ -181,6 +184,25 @@ export const createCashWalletMigrationRuntimeServices = (
const invoiceForRecipient = deps.createInvoice ?? Ibex.addInvoice
const noAmountInvoiceForRecipient = deps.createNoAmountInvoice ?? Ibex.addInvoice
const payInvoice = deps.payInvoice ?? Ibex.payInvoice
const recordMarker = deps.recordInternalTransferInvoice ?? recordInternalTransferInvoice
// ENG-653: every invoice the cutover mints is paid by Flash itself, so its
// receive must never trigger the recipient's payment splits. The marker is
// written before the invoice is handed back to be paid; if it cannot be
// written the invoice is not returned and nothing is paid.
const markCutoverInvoice =
(recipientWalletId: WalletId) =>
async (
invoice: LnInvoice | ApplicationError,
): Promise<LnInvoice | ApplicationError> => {
if (invoice instanceof Error) return invoice
const recorded = await recordMarker({
paymentHash: invoice.paymentHash,
recipientWalletId,
origin: "cutover",
})
if (recorded instanceof Error) return new InternalTransferMarkerError(recorded)
return invoice
}
const accountsRepo = deps.accountsRepo ?? AccountsRepository()
const walletsRepo = deps.walletsRepo ?? WalletsRepository()
const funderWalletId = deps.getFunderWalletId ?? getFunderWalletId
Expand Down Expand Up @@ -292,7 +314,9 @@ export const createCashWalletMigrationRuntimeServices = (
memo,
expiration: CUTOVER_IBEX_INVOICE_EXPIRATION_SECONDS as Seconds,
}),
}).then(ibexInvoiceToDomainInvoice)
})
.then(ibexInvoiceToDomainInvoice)
.then(markCutoverInvoice(recipientWalletId))
},
createNoAmountInvoice: ({
recipientWalletId,
Expand All @@ -310,7 +334,9 @@ export const createCashWalletMigrationRuntimeServices = (
memo,
expiration: CUTOVER_IBEX_INVOICE_EXPIRATION_SECONDS as Seconds,
}),
}).then(ibexInvoiceToDomainInvoice),
})
.then(ibexInvoiceToDomainInvoice)
.then(markCutoverInvoice(recipientWalletId)),
},
paymentService: {
payInvoice: async ({
Expand Down
2 changes: 2 additions & 0 deletions src/app/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import * as ErpNextErrors from "@services/frappe/errors"
import * as BridgeErrors from "@services/bridge/errors"
import * as FygaroErrors from "@services/fygaro/errors"
import * as ApiKeyErrors from "@domain/api-keys/errors"
import * as PaymentSplitErrors from "@domain/payment-splits/errors"

export const ApplicationErrors = {
...SharedErrors,
Expand Down Expand Up @@ -69,4 +70,5 @@ export const ApplicationErrors = {
...BridgeErrors,
...FygaroErrors,
...ApiKeyErrors,
...PaymentSplitErrors,
} as const
44 changes: 44 additions & 0 deletions src/app/payment-splits/delete-split.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { PaymentSplitsConfig } from "@config"

import {
PaymentSplitsDisabledError,
SplitRuleNotFoundError,
} from "@domain/payment-splits"

import { LockService } from "@services/lock"
import { PaymentSplitRulesRepository } from "@services/mongoose/payment-split-rules"

import { resolveOwnedWallet } from "./set-splits"

/**
* Deletes one split rule. Executions already recorded for it are left alone
* and still pay out: they belong to receives that arrived while it existed.
*/
export const deletePaymentSplit = async ({
account,
walletId,
splitId,
}: {
account: Account
walletId: string
splitId: string
}): Promise<true | ApplicationError> => {
if (!PaymentSplitsConfig.enabled) return new PaymentSplitsDisabledError()

const wallet = await resolveOwnedWallet({ account, walletId })
if (wallet instanceof Error) return wallet

return LockService().lockWalletId(wallet.id, async () => {
const rules = PaymentSplitRulesRepository()
const rule = await rules.findById(splitId)
if (rule instanceof Error) return rule
if (rule === null || rule.walletId !== wallet.id) {
return new SplitRuleNotFoundError(splitId)
}

const deleted = await rules.deleteById(rule.id)
if (deleted instanceof Error) return deleted
if (!deleted) return new SplitRuleNotFoundError(splitId)
return true as const
})
}
Loading
Loading