From bb94e788fd20ae527c1bef33d29e2798af825d3f Mon Sep 17 00:00:00 2001 From: kingsrichie79-cmyk Date: Tue, 20 Oct 2026 23:54:49 +0100 Subject: [PATCH] feat: implement split payment distribution engine with concurrent execution and confirmation workflow --- .../SplitPaymentConfirmation.test.tsx | 146 ++++++++++ src/__tests__/splitPayment.test.ts | 226 ++++++++++++++++ src/__tests__/useSplitPayment.test.ts | 71 +++++ src/components/SplitPaymentConfirmation.tsx | 205 ++++++++++++++ src/hooks/useSplitPayment.ts | 200 ++++++++++++++ src/lib/splitPayment.ts | 253 ++++++++++++++++++ src/lib/splitPaymentRules.ts | 228 ++++++++++++++++ src/lib/walletSigner.ts | 23 ++ src/types/splitPayment.ts | 185 +++++++++++++ 9 files changed, 1537 insertions(+) create mode 100644 src/__tests__/SplitPaymentConfirmation.test.tsx create mode 100644 src/__tests__/splitPayment.test.ts create mode 100644 src/__tests__/useSplitPayment.test.ts create mode 100644 src/components/SplitPaymentConfirmation.tsx create mode 100644 src/hooks/useSplitPayment.ts create mode 100644 src/lib/splitPayment.ts create mode 100644 src/lib/splitPaymentRules.ts create mode 100644 src/lib/walletSigner.ts create mode 100644 src/types/splitPayment.ts diff --git a/src/__tests__/SplitPaymentConfirmation.test.tsx b/src/__tests__/SplitPaymentConfirmation.test.tsx new file mode 100644 index 00000000..084e099b --- /dev/null +++ b/src/__tests__/SplitPaymentConfirmation.test.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { SplitPaymentConfirmationModal } from '@/components/SplitPaymentConfirmation'; +import type { SplitPaymentConfirmation, SplitPaymentResult } from '@/types/splitPayment'; + +const MOCK_CONFIRMATION: SplitPaymentConfirmation = { + request: { + id: 'sp_test_1', + payerAddress: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + totalAmount: 100, + assetCode: 'USDC', + recipients: [ + { address: 'GDNJ22TG7PGTDFB46L7TA3R6S4F7Z3HQALNPYZ5BTRUT7Y2L73PBGBH5', sharePercent: 60, taxRatePercent: 0, fixedFeeXLM: 0 }, + { address: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', sharePercent: 40, taxRatePercent: 0, fixedFeeXLM: 0 }, + ], + }, + derivedLines: [ + { address: 'GDNJ22TG7PGTDFB46L7TA3R6S4F7Z3HQALNPYZ5BTRUT7Y2L73PBGBH5', sharePercent: 60, taxRatePercent: 0, fixedFeeXLM: 0, grossAmount: 60, effectiveTaxAmount: 0, netAmount: 60 }, + { address: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', sharePercent: 40, taxRatePercent: 0, fixedFeeXLM: 0, grossAmount: 40, effectiveTaxAmount: 0, netAmount: 40 }, + ], + validation: { + sharesSumTo100: true, + sharesDelta: 0, + hasValidTotal: true, + hasRecipients: true, + allSharesNonNegative: true, + allTaxRatesValid: true, + allFeesNonNegative: true, + isValid: true, + errorMessage: null, + }, + rules: [ + { type: 'min_amount', description: 'Min amount check', passed: true }, + { type: 'unique_addresses', description: 'Unique addresses check', passed: true }, + ], + allRulesPassed: true, + totalGross: 100, + totalTax: 0, + totalFees: 0, + totalNet: 100, + userConfirmed: false, + createdAt: Date.now(), +}; + +describe('SplitPaymentConfirmationModal', () => { + it('renders confirmation modal with rules and recipients', () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + + render( + + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Confirm Split Payment')).toBeInTheDocument(); + expect(screen.getByText('Min amount check')).toBeInTheDocument(); + expect(screen.getByText('Unique addresses check')).toBeInTheDocument(); + expect(screen.getByText('60.0000000 USDC')).toBeInTheDocument(); + expect(screen.getByText('40.0000000 USDC')).toBeInTheDocument(); + + const confirmBtn = screen.getByRole('button', { name: /confirm & execute/i }); + expect(confirmBtn).toBeEnabled(); + + fireEvent.click(confirmBtn); + expect(onConfirm).toHaveBeenCalled(); + }); + + it('disables confirm button when rules fail or is executing', () => { + const failingConfirmation: SplitPaymentConfirmation = { + ...MOCK_CONFIRMATION, + allRulesPassed: false, + }; + + const { rerender } = render( + + ); + + expect(screen.getByRole('button', { name: /confirm & execute/i })).toBeDisabled(); + + // While executing + rerender( + + ); + expect(screen.getByRole('button', { name: /distributing.../i })).toBeDisabled(); + }); + + it('displays status banner and closes when completed', () => { + const mockResult: SplitPaymentResult = { + request: MOCK_CONFIRMATION.request, + status: 'completed', + recipientResults: [ + { + address: 'GDNJ22TG7PGTDFB46L7TA3R6S4F7Z3HQALNPYZ5BTRUT7Y2L73PBGBH5', + amount: 60, + sharePercent: 60, + status: 'succeeded', + retryCount: 0, + initiatedAt: Date.now(), + }, + ], + successCount: 1, + failureCount: 0, + totalDistributed: 60, + totalFailed: 0, + startedAt: Date.now(), + }; + + const onCancel = vi.fn(); + render( + + ); + + expect( + screen.getByText(/All 1 recipient payments distributed successfully!/i) + ).toBeInTheDocument(); + const closeBtn = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeBtn); + expect(onCancel).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/splitPayment.test.ts b/src/__tests__/splitPayment.test.ts new file mode 100644 index 00000000..e3ed86ba --- /dev/null +++ b/src/__tests__/splitPayment.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + generateSplitPaymentId, + buildSplitConfirmation, + executeSplitPayment, + getSplitPaymentSummary, +} from '@/lib/splitPayment'; +import { + validateMinAmount, + validateMaxAmount, + validateMaxRecipients, + validateMinSharePercent, + validateUniqueAddresses, + validateAddresses, + validateSufficientBalance, + validateSplitRules, +} from '@/lib/splitPaymentRules'; +import type { + SplitPaymentRequest, + PaymentExecutor, +} from '@/types/splitPayment'; +import type { RecipientLine } from '@/hooks/useSplitCalculator'; + +const VALID_ADDR_1 = 'GDNJ22TG7PGTDFB46L7TA3R6S4F7Z3HQALNPYZ5BTRUT7Y2L73PBGBH5'; +const VALID_ADDR_2 = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; +const VALID_ADDR_3 = 'GCKF7343QGVT6H767XPR3F5Z5EPLN6K425F4C7U2X5V73MDKUPOXF65V'; +const PAYER_ADDR = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + +describe('Split Payment Rules Validation', () => { + const recipients: RecipientLine[] = [ + { address: VALID_ADDR_1, sharePercent: 50, taxRatePercent: 0, fixedFeeXLM: 0 }, + { address: VALID_ADDR_2, sharePercent: 50, taxRatePercent: 0, fixedFeeXLM: 0 }, + ]; + + it('validates minimum recipient amount', () => { + // 50% of 0.0000001 is 0.00000005, which is < 0.0000001 + const ruleFail = validateMinAmount(0.0000001, recipients, 0.0000001); + expect(ruleFail.passed).toBe(false); + + const rulePass = validateMinAmount(100, recipients, 1); + expect(rulePass.passed).toBe(true); + }); + + it('validates maximum total amount', () => { + const rulePass = validateMaxAmount(500, 1000); + expect(rulePass.passed).toBe(true); + + const ruleFail = validateMaxAmount(1500, 1000); + expect(ruleFail.passed).toBe(false); + }); + + it('validates maximum recipients', () => { + const rulePass = validateMaxRecipients(recipients, 5); + expect(rulePass.passed).toBe(true); + + const ruleFail = validateMaxRecipients(recipients, 1); + expect(ruleFail.passed).toBe(false); + }); + + it('validates minimum share percentage', () => { + const uneven: RecipientLine[] = [ + { address: VALID_ADDR_1, sharePercent: 99.999, taxRatePercent: 0, fixedFeeXLM: 0 }, + { address: VALID_ADDR_2, sharePercent: 0.001, taxRatePercent: 0, fixedFeeXLM: 0 }, + ]; + const rulePass = validateMinSharePercent(recipients, 1); + expect(rulePass.passed).toBe(true); + + const ruleFail = validateMinSharePercent(uneven, 0.01); + expect(ruleFail.passed).toBe(false); + }); + + it('validates unique addresses', () => { + const duplicates: RecipientLine[] = [ + { address: VALID_ADDR_1, sharePercent: 50, taxRatePercent: 0, fixedFeeXLM: 0 }, + { address: VALID_ADDR_1, sharePercent: 50, taxRatePercent: 0, fixedFeeXLM: 0 }, + ]; + expect(validateUniqueAddresses(recipients).passed).toBe(true); + expect(validateUniqueAddresses(duplicates).passed).toBe(false); + }); + + it('validates Stellar public keys', () => { + const invalidList: RecipientLine[] = [ + { address: 'invalid_address', sharePercent: 50, taxRatePercent: 0, fixedFeeXLM: 0 }, + { address: VALID_ADDR_2, sharePercent: 50, taxRatePercent: 0, fixedFeeXLM: 0 }, + ]; + expect(validateAddresses(recipients).passed).toBe(true); + expect(validateAddresses(invalidList).passed).toBe(false); + }); + + it('validates sufficient balance', () => { + expect(validateSufficientBalance(100, 150).passed).toBe(true); + expect(validateSufficientBalance(100, 50).passed).toBe(false); + expect(validateSufficientBalance(100, undefined).passed).toBe(true); // skips check + }); + + it('runs all rules aggregated with validateSplitRules', () => { + const res = validateSplitRules(100, recipients); + expect(res.allPassed).toBe(true); + expect(res.rules.length).toBeGreaterThanOrEqual(6); + + const badRes = validateSplitRules(100, [ + { address: 'not-stellar', sharePercent: 50, taxRatePercent: 0, fixedFeeXLM: 0 }, + { address: 'not-stellar', sharePercent: 50, taxRatePercent: 0, fixedFeeXLM: 0 }, + ]); + expect(badRes.allPassed).toBe(false); + }); +}); + +describe('Split Payment Confirmation & Execution', () => { + const request: SplitPaymentRequest = { + id: generateSplitPaymentId(), + payerAddress: PAYER_ADDR, + totalAmount: 100, + assetCode: 'USDC', + recipients: [ + { address: VALID_ADDR_1, sharePercent: 60, taxRatePercent: 10, fixedFeeXLM: 0 }, + { address: VALID_ADDR_2, sharePercent: 40, taxRatePercent: 0, fixedFeeXLM: 0 }, + ], + }; + + it('builds confirmation correctly with accurate calculations', () => { + const confirmation = buildSplitConfirmation(request); + expect(confirmation.allRulesPassed).toBe(true); + expect(confirmation.userConfirmed).toBe(false); + expect(confirmation.totalGross).toBe(100); + + // Recipient 1: 60 gross, 10% tax = 6 tax, 54 net + expect(confirmation.derivedLines[0].grossAmount).toBe(60); + expect(confirmation.derivedLines[0].effectiveTaxAmount).toBe(6); + expect(confirmation.derivedLines[0].netAmount).toBe(54); + + // Recipient 2: 40 gross, 0 tax, 40 net + expect(confirmation.derivedLines[1].grossAmount).toBe(40); + expect(confirmation.derivedLines[1].netAmount).toBe(40); + + expect(confirmation.totalNet).toBe(94); + }); + + it('executes payments concurrently to all recipients', async () => { + const mockExecutor: PaymentExecutor = vi.fn().mockImplementation(async ({ recipientAddress }) => { + return { txHash: `tx_${recipientAddress.slice(0, 8)}` }; + }); + + const onComplete = vi.fn(); + const result = await executeSplitPayment(request, mockExecutor, { + maxRetries: 1, + onRecipientComplete: onComplete, + }); + + expect(result.status).toBe('completed'); + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(0); + expect(result.recipientResults).toHaveLength(2); + expect(mockExecutor).toHaveBeenCalledTimes(2); + expect(onComplete).toHaveBeenCalledTimes(2); + + expect(result.recipientResults[0].status).toBe('succeeded'); + expect(result.recipientResults[0].amount).toBe(54); + expect(result.recipientResults[0].txHash).toBe(`tx_${VALID_ADDR_1.slice(0, 8)}`); + + expect(result.recipientResults[1].status).toBe('succeeded'); + expect(result.recipientResults[1].amount).toBe(40); + + expect(result.totalDistributed).toBe(94); + expect(result.totalFailed).toBe(0); + + const summary = getSplitPaymentSummary(result); + expect(summary).toContain('Successfully distributed'); + }); + + it('handles partial failures gracefully', async () => { + // Fail recipient 1, succeed recipient 2 + const mockExecutor: PaymentExecutor = vi.fn().mockImplementation(async ({ recipientAddress }) => { + if (recipientAddress === VALID_ADDR_1) { + throw new Error('Destination account inactive'); + } + return { txHash: 'tx_success' }; + }); + + const result = await executeSplitPayment(request, mockExecutor, { + maxRetries: 1, + }); + + expect(result.status).toBe('partial_failure'); + expect(result.successCount).toBe(1); + expect(result.failureCount).toBe(1); + expect(result.totalDistributed).toBe(40); + expect(result.totalFailed).toBe(54); + expect(result.error).toContain('1 of 2 payments failed'); + + const summary = getSplitPaymentSummary(result); + expect(summary).toContain('Partially completed'); + }); + + it('handles complete failures gracefully', async () => { + const mockExecutor: PaymentExecutor = vi.fn().mockRejectedValue(new Error('Network error')); + + const result = await executeSplitPayment(request, mockExecutor, { + maxRetries: 0, + }); + + expect(result.status).toBe('failed'); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(2); + expect(result.totalDistributed).toBe(0); + expect(result.totalFailed).toBe(94); + expect(result.error).toContain('All payments failed'); + }); + + it('fails fast on invalid shares before initiating any payment', async () => { + const invalidRequest: SplitPaymentRequest = { + ...request, + recipients: [ + { address: VALID_ADDR_1, sharePercent: 40, taxRatePercent: 0, fixedFeeXLM: 0 }, + { address: VALID_ADDR_2, sharePercent: 40, taxRatePercent: 0, fixedFeeXLM: 0 }, + ], + }; + + const mockExecutor: PaymentExecutor = vi.fn(); + const result = await executeSplitPayment(invalidRequest, mockExecutor); + + expect(result.status).toBe('failed'); + expect(mockExecutor).not.toHaveBeenCalled(); + expect(result.error).toContain('Share percentages must sum to 100%'); + }); +}); diff --git a/src/__tests__/useSplitPayment.test.ts b/src/__tests__/useSplitPayment.test.ts new file mode 100644 index 00000000..f6f599ec --- /dev/null +++ b/src/__tests__/useSplitPayment.test.ts @@ -0,0 +1,71 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { useSplitPayment } from '@/hooks/useSplitPayment'; +import type { PaymentExecutor } from '@/types/splitPayment'; +import type { RecipientLine } from '@/hooks/useSplitCalculator'; + +const VALID_ADDR_1 = 'GDNJ22TG7PGTDFB46L7TA3R6S4F7Z3HQALNPYZ5BTRUT7Y2L73PBGBH5'; +const VALID_ADDR_2 = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; +const PAYER_ADDR = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + +describe('useSplitPayment hook', () => { + const recipients: RecipientLine[] = [ + { address: VALID_ADDR_1, sharePercent: 50, taxRatePercent: 0, fixedFeeXLM: 0 }, + { address: VALID_ADDR_2, sharePercent: 50, taxRatePercent: 0, fixedFeeXLM: 0 }, + ]; + + it('prepares confirmation and executes workflow cleanly', async () => { + const mockExecutor: PaymentExecutor = vi.fn().mockResolvedValue({ txHash: 'tx_abc' }); + const onComplete = vi.fn(); + + const { result } = renderHook(() => + useSplitPayment({ + executor: mockExecutor, + onComplete, + }) + ); + + expect(result.current.state.status).toBe('pending'); + expect(result.current.state.confirmation).toBeNull(); + + // 1. Prepare Split + act(() => { + result.current.prepareSplit(PAYER_ADDR, 100, 'USDC', recipients); + }); + + expect(result.current.state.status).toBe('confirmed'); + expect(result.current.state.confirmation).not.toBeNull(); + expect(result.current.state.confirmation?.allRulesPassed).toBe(true); + + // 2. Confirm and execute + await act(async () => { + await result.current.confirmAndExecute(); + }); + + expect(result.current.state.status).toBe('completed'); + expect(result.current.state.result?.successCount).toBe(2); + expect(mockExecutor).toHaveBeenCalledTimes(2); + expect(onComplete).toHaveBeenCalled(); + }); + + it('handles cancellation and reset', () => { + const mockExecutor: PaymentExecutor = vi.fn(); + const { result } = renderHook(() => useSplitPayment({ executor: mockExecutor })); + + act(() => { + result.current.prepareSplit(PAYER_ADDR, 100, 'USDC', recipients); + }); + expect(result.current.state.status).toBe('confirmed'); + + act(() => { + result.current.cancel(); + }); + expect(result.current.state.status).toBe('cancelled'); + + act(() => { + result.current.reset(); + }); + expect(result.current.state.status).toBe('pending'); + expect(result.current.state.confirmation).toBeNull(); + }); +}); diff --git a/src/components/SplitPaymentConfirmation.tsx b/src/components/SplitPaymentConfirmation.tsx new file mode 100644 index 00000000..ad34e32a --- /dev/null +++ b/src/components/SplitPaymentConfirmation.tsx @@ -0,0 +1,205 @@ +'use client'; + +import React from 'react'; +import type { + SplitPaymentConfirmation as SplitConfirmationType, + SplitPaymentResult, + SplitPaymentStatus, +} from '@/types/splitPayment'; + +export interface SplitPaymentConfirmationProps { + confirmation: SplitConfirmationType; + result?: SplitPaymentResult | null; + status: SplitPaymentStatus; + isExecuting: boolean; + onConfirm: () => void; + onCancel: () => void; + onReset?: () => void; +} + +export const SplitPaymentConfirmationModal: React.FC = ({ + confirmation, + result, + status, + isExecuting, + onConfirm, + onCancel, + onReset, +}) => { + const { request, derivedLines, rules, allRulesPassed, totalGross, totalNet, totalTax, totalFees } = confirmation; + + return ( +
+
+

+ Confirm Split Payment +

+

+ Review distribution rules and recipient allocations before executing payment. +

+
+ + {/* Rules Validation Section */} +
+

+ Rule Validations +

+
+ {rules.map((rule, idx) => ( +
+ {rule.description} + + {rule.passed ? 'Passed' : 'Failed'} + +
+ ))} +
+
+ + {/* Recipient Distribution Table */} +
+ + + + + + + {result && } + + + + {derivedLines.map((line, idx) => { + const res = result?.recipientResults.find((r) => r.address === line.address); + return ( + + + + + {result && ( + + )} + + ); + })} + +
RecipientShareNet AmountStatus
+ {line.address} + {line.sharePercent}% + {line.netAmount.toFixed(7)} {request.assetCode} + + + {res?.status ?? 'Pending'} + +
+
+ + {/* Totals Summary */} +
+
+ Gross Total + + {totalGross.toFixed(7)} {request.assetCode} + +
+
+ Tax Withheld + + {totalTax.toFixed(7)} {request.assetCode} + +
+
+ Fees + + {totalFees.toFixed(7)} {request.assetCode} + +
+
+ Net Distributed + + {totalNet.toFixed(7)} {request.assetCode} + +
+
+ + {/* Status / Results Notification */} + {status === 'completed' && ( +
+ All {result?.successCount} recipient payments distributed successfully! +
+ )} + + {status === 'partial_failure' && ( +
+ {result?.error} +
+ )} + + {status === 'failed' && ( +
+ {result?.error || 'Split payment failed to execute.'} +
+ )} + + {/* Action Buttons */} +
+ {status === 'completed' || status === 'partial_failure' || status === 'failed' ? ( + + ) : ( + <> + + + + )} +
+
+ ); +}; diff --git a/src/hooks/useSplitPayment.ts b/src/hooks/useSplitPayment.ts new file mode 100644 index 00000000..eac729b4 --- /dev/null +++ b/src/hooks/useSplitPayment.ts @@ -0,0 +1,200 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { + buildSplitConfirmation, + executeSplitPayment, + generateSplitPaymentId, +} from '@/lib/splitPayment'; +import type { + SplitPaymentState, + SplitPaymentRequest, + SplitPaymentConfirmation, + SplitPaymentResult, + PaymentExecutor, + SplitRuleConfig, + RecipientPaymentResult, +} from '@/types/splitPayment'; +import type { RecipientLine } from '@/hooks/useSplitCalculator'; + +const INITIAL_STATE: SplitPaymentState = { + status: 'pending', + confirmation: null, + result: null, + isExecuting: false, + error: null, +}; + +export interface UseSplitPaymentOptions { + /** Payment executor function — abstracted for testability */ + executor: PaymentExecutor; + /** Rule configuration overrides */ + ruleConfig?: Partial; + /** Maximum retries per recipient */ + maxRetries?: number; + /** Callback when a single recipient payment completes */ + onRecipientComplete?: (result: RecipientPaymentResult, index: number) => void; + /** Callback when the overall split payment completes */ + onComplete?: (result: SplitPaymentResult) => void; +} + +export interface UseSplitPaymentReturn { + /** Current split payment state */ + state: SplitPaymentState; + /** Build a confirmation for the user to review before executing */ + prepareSplit: ( + payerAddress: string, + totalAmount: number, + assetCode: 'XLM' | 'USDC', + recipients: RecipientLine[], + options?: { memo?: string; invoiceId?: string } + ) => SplitPaymentConfirmation; + /** Confirm and execute the split payment */ + confirmAndExecute: () => Promise; + /** Cancel the pending split payment */ + cancel: () => void; + /** Reset state to initial */ + reset: () => void; +} + +/** + * React hook for managing split payment distribution. + * + * Provides a workflow: + * 1. `prepareSplit()` — build a confirmation with calculated amounts & rule validation + * 2. User reviews the confirmation in the UI + * 3. `confirmAndExecute()` — execute all recipient payments concurrently + * 4. Track results in `state.result` + */ +export function useSplitPayment(options: UseSplitPaymentOptions): UseSplitPaymentReturn { + const { executor, ruleConfig, maxRetries = 2, onRecipientComplete, onComplete } = options; + const [state, setState] = useState(INITIAL_STATE); + + const prepareSplit = useCallback( + ( + payerAddress: string, + totalAmount: number, + assetCode: 'XLM' | 'USDC', + recipients: RecipientLine[], + opts?: { memo?: string; invoiceId?: string } + ): SplitPaymentConfirmation => { + const request: SplitPaymentRequest = { + id: generateSplitPaymentId(), + payerAddress, + totalAmount, + assetCode, + recipients, + memo: opts?.memo, + invoiceId: opts?.invoiceId, + }; + + const confirmation = buildSplitConfirmation(request, ruleConfig); + + setState({ + status: 'confirmed', + confirmation, + result: null, + isExecuting: false, + error: null, + }); + + return confirmation; + }, + [ruleConfig] + ); + + const confirmAndExecute = useCallback(async (): Promise => { + const confirmation = state.confirmation; + if (!confirmation) { + setState((prev) => ({ + ...prev, + error: 'No split payment prepared. Call prepareSplit() first.', + })); + return null; + } + + if (!confirmation.allRulesPassed) { + setState((prev) => ({ + ...prev, + error: 'Split payment rules not satisfied. Fix validation errors before executing.', + })); + return null; + } + + if (!confirmation.validation.isValid) { + setState((prev) => ({ + ...prev, + error: confirmation.validation.errorMessage || 'Invalid split configuration', + })); + return null; + } + + // Mark as confirmed + const confirmedConfirmation: SplitPaymentConfirmation = { + ...confirmation, + userConfirmed: true, + confirmedAt: Date.now(), + }; + + setState((prev) => ({ + ...prev, + status: 'executing', + confirmation: confirmedConfirmation, + isExecuting: true, + error: null, + })); + + try { + const result = await executeSplitPayment( + confirmation.request, + executor, + { + maxRetries, + onRecipientComplete, + onStatusChange: (status) => { + setState((prev) => ({ ...prev, status })); + }, + } + ); + + setState({ + status: result.status, + confirmation: confirmedConfirmation, + result, + isExecuting: false, + error: result.error || null, + }); + + onComplete?.(result); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + setState((prev) => ({ + ...prev, + status: 'failed', + isExecuting: false, + error: errorMessage, + })); + return null; + } + }, [state.confirmation, executor, maxRetries, onRecipientComplete, onComplete]); + + const cancel = useCallback(() => { + setState({ + ...INITIAL_STATE, + status: 'cancelled', + }); + }, []); + + const reset = useCallback(() => { + setState(INITIAL_STATE); + }, []); + + return { + state, + prepareSplit, + confirmAndExecute, + cancel, + reset, + }; +} diff --git a/src/lib/splitPayment.ts b/src/lib/splitPayment.ts new file mode 100644 index 00000000..d4521a6d --- /dev/null +++ b/src/lib/splitPayment.ts @@ -0,0 +1,253 @@ +/** + * Split payment execution engine. + * + * Handles splitting a single payment into multiple concurrent payments + * to different recipients, with failure handling and confirmation tracking. + */ + +import { calculateSplit, validateShares, type RecipientLine } from '@/hooks/useSplitCalculator'; +import { validateSplitRules } from '@/lib/splitPaymentRules'; +import type { + SplitPaymentRequest, + SplitPaymentResult, + SplitPaymentConfirmation, + RecipientPaymentResult, + SplitPaymentStatus, + PaymentExecutor, + SplitRuleConfig, +} from '@/types/splitPayment'; + +// ── ID Generation ─────────────────────────────────────────────────────────── + +let _counter = 0; + +/** + * Generate a unique split payment ID. + * Uses timestamp + counter for uniqueness within a session. + */ +export function generateSplitPaymentId(): string { + _counter += 1; + return `sp_${Date.now()}_${_counter}`; +} + +// ── Confirmation ──────────────────────────────────────────────────────────── + +/** + * Build a split payment confirmation object. This performs split calculations + * and rule validation so the user can review before executing. + */ +export function buildSplitConfirmation( + request: SplitPaymentRequest, + ruleConfig: Partial = {} +): SplitPaymentConfirmation { + const splitResult = calculateSplit( + request.totalAmount, + request.recipients, + request.assetCode + ); + + const { rules, allPassed } = validateSplitRules( + request.totalAmount, + request.recipients, + ruleConfig + ); + + return { + request, + derivedLines: splitResult.derivedLines, + validation: splitResult.validation, + rules, + allRulesPassed: allPassed, + totalGross: splitResult.totalGross, + totalTax: splitResult.totalTax, + totalFees: splitResult.totalFees, + totalNet: splitResult.totalNet, + userConfirmed: false, + createdAt: Date.now(), + }; +} + +// ── Concurrent Execution ──────────────────────────────────────────────────── + +/** + * Execute a single recipient payment with retry tracking. + */ +async function executeRecipientPayment( + executor: PaymentExecutor, + payerAddress: string, + recipient: RecipientLine, + amount: number, + assetCode: 'XLM' | 'USDC', + memo?: string, + maxRetries: number = 2 +): Promise { + const result: RecipientPaymentResult = { + address: recipient.address, + amount, + sharePercent: recipient.sharePercent, + status: 'executing', + retryCount: 0, + initiatedAt: Date.now(), + }; + + let lastError: string | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const { txHash } = await executor({ + payerAddress, + recipientAddress: recipient.address, + amount, + assetCode, + memo, + }); + + result.status = 'succeeded'; + result.txHash = txHash; + result.completedAt = Date.now(); + return result; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + result.retryCount = attempt + 1; + + if (attempt < maxRetries) { + result.status = 'retrying'; + // Exponential backoff: 500ms, 1000ms, 2000ms... + await new Promise((resolve) => + setTimeout(resolve, 500 * Math.pow(2, attempt)) + ); + } + } + } + + result.status = 'failed'; + result.error = lastError; + result.completedAt = Date.now(); + return result; +} + +/** + * Execute split payments to all recipients concurrently. + * + * Uses Promise.allSettled to ensure all payments are attempted even if + * some fail. Returns a comprehensive result object. + */ +export async function executeSplitPayment( + request: SplitPaymentRequest, + executor: PaymentExecutor, + options: { + maxRetries?: number; + onRecipientComplete?: (result: RecipientPaymentResult, index: number) => void; + onStatusChange?: (status: SplitPaymentStatus) => void; + } = {} +): Promise { + const { maxRetries = 2, onRecipientComplete, onStatusChange } = options; + + // Calculate the split amounts + const splitResult = calculateSplit( + request.totalAmount, + request.recipients, + request.assetCode + ); + + // Validate shares before execution + const validation = validateShares(request.recipients); + if (!validation.isValid) { + return { + request, + status: 'failed', + recipientResults: [], + successCount: 0, + failureCount: request.recipients.length, + totalDistributed: 0, + totalFailed: request.totalAmount, + startedAt: Date.now(), + completedAt: Date.now(), + error: validation.errorMessage || 'Invalid split configuration', + }; + } + + onStatusChange?.('executing'); + + const startedAt = Date.now(); + + // Execute all recipient payments concurrently + const paymentPromises = splitResult.derivedLines.map(async (line, index) => { + const result = await executeRecipientPayment( + executor, + request.payerAddress, + request.recipients[index], + line.netAmount, + request.assetCode, + request.memo, + maxRetries + ); + + onRecipientComplete?.(result, index); + return result; + }); + + const recipientResults = await Promise.all(paymentPromises); + + // Aggregate results + const successCount = recipientResults.filter((r) => r.status === 'succeeded').length; + const failureCount = recipientResults.filter((r) => r.status === 'failed').length; + const totalDistributed = recipientResults + .filter((r) => r.status === 'succeeded') + .reduce((sum, r) => sum + r.amount, 0); + const totalFailed = recipientResults + .filter((r) => r.status === 'failed') + .reduce((sum, r) => sum + r.amount, 0); + + let status: SplitPaymentStatus; + let error: string | undefined; + + if (failureCount === 0) { + status = 'completed'; + } else if (successCount === 0) { + status = 'failed'; + const errors = recipientResults + .filter((r) => r.error) + .map((r) => `${r.address}: ${r.error}`); + error = `All payments failed: ${errors.join('; ')}`; + } else { + status = 'partial_failure'; + const errors = recipientResults + .filter((r) => r.error) + .map((r) => `${r.address}: ${r.error}`); + error = `${failureCount} of ${recipientResults.length} payments failed: ${errors.join('; ')}`; + } + + onStatusChange?.(status); + + return { + request, + status, + recipientResults, + successCount, + failureCount, + totalDistributed, + totalFailed, + startedAt, + completedAt: Date.now(), + error, + }; +} + +/** + * Get a human-readable summary of a split payment result. + */ +export function getSplitPaymentSummary(result: SplitPaymentResult): string { + const { status, successCount, failureCount, totalDistributed, request } = result; + + switch (status) { + case 'completed': + return `Successfully distributed ${totalDistributed.toFixed(7)} ${request.assetCode} to ${successCount} recipients`; + case 'partial_failure': + return `Partially completed: ${successCount} succeeded, ${failureCount} failed. ${totalDistributed.toFixed(7)} ${request.assetCode} distributed`; + case 'failed': + return `Split payment failed: ${result.error || 'Unknown error'}`; + default: + return `Split payment status: ${status}`; + } +} diff --git a/src/lib/splitPaymentRules.ts b/src/lib/splitPaymentRules.ts new file mode 100644 index 00000000..8e97b824 --- /dev/null +++ b/src/lib/splitPaymentRules.ts @@ -0,0 +1,228 @@ +/** + * Split payment rule validation. + * + * Validates split payment requests against configurable rules + * before execution to ensure all recipients receive correct amounts. + */ + +import type { RecipientLine } from '@/hooks/useSplitCalculator'; +import type { SplitRule, SplitRuleConfig } from '@/types/splitPayment'; +import { MAX_RECIPIENTS } from '@/lib/stellar'; + +/** Default rule configuration */ +export const DEFAULT_SPLIT_RULE_CONFIG: SplitRuleConfig = { + minRecipientAmount: 0.0000001, // 1 stroop + maxTotalAmount: 1_000_000_000, // 1 billion units + maxRecipients: MAX_RECIPIENTS, + minSharePercent: 0.0001, + requireUniqueAddresses: true, + requireValidAddresses: true, +}; + +/** Stellar public key regex (starts with G, 56 chars, base32) */ +const STELLAR_ADDRESS_REGEX = /^G[A-Z2-7]{55}$/; + +/** + * Validate minimum per-recipient amount. + * Each recipient must receive at least `minRecipientAmount` after split. + */ +export function validateMinAmount( + totalAmount: number, + recipients: RecipientLine[], + minRecipientAmount: number +): SplitRule { + const failingRecipients: number[] = []; + + for (let i = 0; i < recipients.length; i++) { + const recipientAmount = (totalAmount * (recipients[i].sharePercent || 0)) / 100; + if (recipientAmount > 0 && recipientAmount < minRecipientAmount) { + failingRecipients.push(i + 1); + } + } + + const passed = failingRecipients.length === 0; + return { + type: 'min_amount', + description: `Each recipient must receive at least ${minRecipientAmount} units`, + passed, + errorMessage: passed + ? undefined + : `Recipients ${failingRecipients.join(', ')} would receive less than the minimum amount of ${minRecipientAmount}`, + }; +} + +/** + * Validate maximum total amount. + */ +export function validateMaxAmount( + totalAmount: number, + maxTotalAmount: number +): SplitRule { + const passed = totalAmount <= maxTotalAmount; + return { + type: 'max_amount', + description: `Total amount must not exceed ${maxTotalAmount}`, + passed, + errorMessage: passed + ? undefined + : `Total amount ${totalAmount} exceeds maximum allowed ${maxTotalAmount}`, + }; +} + +/** + * Validate maximum number of recipients. + */ +export function validateMaxRecipients( + recipients: RecipientLine[], + maxRecipients: number +): SplitRule { + const passed = recipients.length <= maxRecipients; + return { + type: 'max_recipients', + description: `Maximum ${maxRecipients} recipients allowed`, + passed, + errorMessage: passed + ? undefined + : `${recipients.length} recipients exceeds the maximum of ${maxRecipients}`, + }; +} + +/** + * Validate minimum share percentage per recipient. + */ +export function validateMinSharePercent( + recipients: RecipientLine[], + minSharePercent: number +): SplitRule { + const failingRecipients: number[] = []; + + for (let i = 0; i < recipients.length; i++) { + if (recipients[i].sharePercent > 0 && recipients[i].sharePercent < minSharePercent) { + failingRecipients.push(i + 1); + } + } + + const passed = failingRecipients.length === 0; + return { + type: 'min_share_percent', + description: `Each recipient must have at least ${minSharePercent}% share`, + passed, + errorMessage: passed + ? undefined + : `Recipients ${failingRecipients.join(', ')} have share below minimum ${minSharePercent}%`, + }; +} + +/** + * Validate that all recipient addresses are unique. + */ +export function validateUniqueAddresses(recipients: RecipientLine[]): SplitRule { + const addresses = recipients.map((r) => r.address); + const uniqueAddresses = new Set(addresses); + const passed = uniqueAddresses.size === addresses.length; + + let errorMessage: string | undefined; + if (!passed) { + const seen = new Set(); + const duplicates = new Set(); + for (const addr of addresses) { + if (seen.has(addr)) { + duplicates.add(addr); + } + seen.add(addr); + } + errorMessage = `Duplicate recipient addresses found: ${Array.from(duplicates).join(', ')}`; + } + + return { + type: 'unique_addresses', + description: 'All recipient addresses must be unique', + passed, + errorMessage, + }; +} + +/** + * Validate that all recipient addresses are valid Stellar public keys. + */ +export function validateAddresses(recipients: RecipientLine[]): SplitRule { + const invalidRecipients: number[] = []; + + for (let i = 0; i < recipients.length; i++) { + if (!STELLAR_ADDRESS_REGEX.test(recipients[i].address)) { + invalidRecipients.push(i + 1); + } + } + + const passed = invalidRecipients.length === 0; + return { + type: 'valid_addresses', + description: 'All recipient addresses must be valid Stellar public keys', + passed, + errorMessage: passed + ? undefined + : `Recipients ${invalidRecipients.join(', ')} have invalid Stellar addresses`, + }; +} + +/** + * Validate that the payer has sufficient balance for the total amount. + */ +export function validateSufficientBalance( + totalAmount: number, + availableBalance: number | undefined +): SplitRule { + if (availableBalance === undefined) { + return { + type: 'sufficient_balance', + description: 'Payer must have sufficient balance', + passed: true, // Skip if balance is unknown + }; + } + + const passed = availableBalance >= totalAmount; + return { + type: 'sufficient_balance', + description: 'Payer must have sufficient balance', + passed, + errorMessage: passed + ? undefined + : `Insufficient balance: ${availableBalance} available, ${totalAmount} required`, + }; +} + +/** + * Run all split payment rules against the provided request parameters. + */ +export function validateSplitRules( + totalAmount: number, + recipients: RecipientLine[], + config: Partial = {} +): { rules: SplitRule[]; allPassed: boolean } { + const mergedConfig: SplitRuleConfig = { + ...DEFAULT_SPLIT_RULE_CONFIG, + ...config, + }; + + const rules: SplitRule[] = [ + validateMinAmount(totalAmount, recipients, mergedConfig.minRecipientAmount), + validateMaxAmount(totalAmount, mergedConfig.maxTotalAmount), + validateMaxRecipients(recipients, mergedConfig.maxRecipients), + validateMinSharePercent(recipients, mergedConfig.minSharePercent), + ]; + + if (mergedConfig.requireUniqueAddresses) { + rules.push(validateUniqueAddresses(recipients)); + } + + if (mergedConfig.requireValidAddresses) { + rules.push(validateAddresses(recipients)); + } + + rules.push( + validateSufficientBalance(totalAmount, mergedConfig.availableBalance) + ); + + const allPassed = rules.every((r) => r.passed); + return { rules, allPassed }; +} diff --git a/src/lib/walletSigner.ts b/src/lib/walletSigner.ts new file mode 100644 index 00000000..94a34371 --- /dev/null +++ b/src/lib/walletSigner.ts @@ -0,0 +1,23 @@ +import { signTransaction as freighterSignTransaction } from "@stellar/freighter-api"; + +export interface SignTransactionOptions { + address?: string; + networkPassphrase?: string; + network?: string; +} + +export async function signTransaction( + xdr: string, + opts?: SignTransactionOptions +): Promise { + if (typeof freighterSignTransaction === "function") { + const res = await freighterSignTransaction(xdr, opts as any); + if (typeof res === "string") { + return res; + } + if (res && typeof (res as any).signedTxXdr === "string") { + return (res as any).signedTxXdr; + } + } + return xdr; +} diff --git a/src/types/splitPayment.ts b/src/types/splitPayment.ts new file mode 100644 index 00000000..fca71891 --- /dev/null +++ b/src/types/splitPayment.ts @@ -0,0 +1,185 @@ +/** + * Types for split payment distribution — enabling a single payment + * to be automatically split and distributed to multiple recipients. + */ + +import type { RecipientLine, DerivedRecipientLine, SplitValidation } from '@/hooks/useSplitCalculator'; + +// ── Split Payment Status ──────────────────────────────────────────────────── + +export type SplitPaymentStatus = + | 'pending' + | 'validating' + | 'confirmed' + | 'executing' + | 'completed' + | 'partial_failure' + | 'failed' + | 'cancelled'; + +export type RecipientPaymentStatus = + | 'pending' + | 'executing' + | 'succeeded' + | 'failed' + | 'retrying'; + +// ── Split Rule Types ──────────────────────────────────────────────────────── + +export type SplitRuleType = + | 'min_amount' + | 'max_amount' + | 'max_recipients' + | 'min_share_percent' + | 'unique_addresses' + | 'valid_addresses' + | 'sufficient_balance'; + +export interface SplitRule { + type: SplitRuleType; + /** Human-readable description of this rule */ + description: string; + /** Whether the rule passed validation */ + passed: boolean; + /** Error message when the rule fails */ + errorMessage?: string; +} + +export interface SplitRuleConfig { + /** Minimum per-recipient amount (in asset units, e.g. 0.0000001 XLM) */ + minRecipientAmount: number; + /** Maximum total amount for a single split payment */ + maxTotalAmount: number; + /** Maximum number of recipients per split */ + maxRecipients: number; + /** Minimum share percentage per recipient */ + minSharePercent: number; + /** Whether to enforce unique addresses */ + requireUniqueAddresses: boolean; + /** Whether to validate Stellar addresses (G...) */ + requireValidAddresses: boolean; + /** Available balance for sufficient balance check */ + availableBalance?: number; +} + +// ── Recipient Payment Result ──────────────────────────────────────────────── + +export interface RecipientPaymentResult { + /** Recipient address */ + address: string; + /** Amount sent to this recipient */ + amount: number; + /** Share percentage of total */ + sharePercent: number; + /** Current status */ + status: RecipientPaymentStatus; + /** Transaction hash when succeeded */ + txHash?: string; + /** Error message when failed */ + error?: string; + /** Number of retry attempts made */ + retryCount: number; + /** Timestamp when payment was initiated */ + initiatedAt: number; + /** Timestamp when payment was completed (success or final failure) */ + completedAt?: number; +} + +// ── Split Payment Request ─────────────────────────────────────────────────── + +export interface SplitPaymentRequest { + /** Unique ID for this split payment */ + id: string; + /** Payer wallet address */ + payerAddress: string; + /** Total amount to split */ + totalAmount: number; + /** Asset code for the payment */ + assetCode: 'XLM' | 'USDC'; + /** Recipient definitions with share percentages */ + recipients: RecipientLine[]; + /** Optional memo for the transactions */ + memo?: string; + /** Optional invoice ID this split is associated with */ + invoiceId?: string; +} + +// ── Split Payment Confirmation ────────────────────────────────────────────── + +export interface SplitPaymentConfirmation { + /** The original split payment request */ + request: SplitPaymentRequest; + /** Derived split calculations */ + derivedLines: DerivedRecipientLine[]; + /** Validation state */ + validation: SplitValidation; + /** Rule validation results */ + rules: SplitRule[]; + /** Whether all rules passed */ + allRulesPassed: boolean; + /** Summary totals */ + totalGross: number; + totalTax: number; + totalFees: number; + totalNet: number; + /** Whether user has confirmed the split */ + userConfirmed: boolean; + /** Timestamp when confirmation was created */ + createdAt: number; + /** Timestamp when user confirmed */ + confirmedAt?: number; +} + +// ── Split Payment Execution Result ────────────────────────────────────────── + +export interface SplitPaymentResult { + /** The original split payment request */ + request: SplitPaymentRequest; + /** Overall status of the split payment */ + status: SplitPaymentStatus; + /** Per-recipient payment results */ + recipientResults: RecipientPaymentResult[]; + /** Number of successful payments */ + successCount: number; + /** Number of failed payments */ + failureCount: number; + /** Total amount successfully distributed */ + totalDistributed: number; + /** Total amount that failed to distribute */ + totalFailed: number; + /** Timestamp when execution started */ + startedAt: number; + /** Timestamp when execution completed */ + completedAt?: number; + /** Aggregated error message for partial/full failure */ + error?: string; +} + +// ── Split Payment Hook State ──────────────────────────────────────────────── + +export interface SplitPaymentState { + /** Current status of the overall split payment */ + status: SplitPaymentStatus; + /** Confirmation object when in confirmed state */ + confirmation: SplitPaymentConfirmation | null; + /** Execution result when executing or completed */ + result: SplitPaymentResult | null; + /** Whether the payment is in progress */ + isExecuting: boolean; + /** Global error message */ + error: string | null; +} + +// ── Payment Executor Interface ────────────────────────────────────────────── + +/** + * Interface for the function that executes a single recipient payment. + * Allows dependency injection for testing and different payment backends. + */ +export type PaymentExecutor = (params: { + payerAddress: string; + recipientAddress: string; + amount: number; + assetCode: 'XLM' | 'USDC'; + memo?: string; +}) => Promise<{ txHash: string }>;