From 51e9ce5aedb36327508b7590c7811e8aaa39cd75 Mon Sep 17 00:00:00 2001 From: RemmyAcee Date: Fri, 27 Mar 2026 16:26:40 +0100 Subject: [PATCH 1/2] =?UTF-8?q?[SDK=20=C2=B7=20Escrow=20lifecycle]=20Imple?= =?UTF-8?q?ment=20releaseFunds()=20=E2=80=94=20payment=20operations=20buil?= =?UTF-8?q?der?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/escrow/index.ts | 69 ++++++++++++++++++++++++++++ src/index.ts | 2 +- tests/unit/escrow/index.test.ts | 80 +++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/escrow/index.ts b/src/escrow/index.ts index 3dbf7c1..d260ce3 100644 --- a/src/escrow/index.ts +++ b/src/escrow/index.ts @@ -1,3 +1,72 @@ +import { Distribution } from '../types/escrow'; +import { PaymentOp } from '../types/transaction'; + +const STROOPS_PER_XLM = 10_000_000n; +const PERCENTAGE_SCALE = 10_000_000n; +const PERCENTAGE_DENOMINATOR = 100n * PERCENTAGE_SCALE; + +function amountToStroops(amount: string): bigint { + const [wholePart, fractionalPart = ''] = amount.split('.'); + const normalizedFraction = `${fractionalPart}0000000`.slice(0, 7); + + return ( + BigInt(wholePart || '0') * STROOPS_PER_XLM + + BigInt(normalizedFraction || '0') + ); +} + +function stroopsToAmount(stroops: bigint): string { + const whole = stroops / STROOPS_PER_XLM; + const fraction = (stroops % STROOPS_PER_XLM).toString().padStart(7, '0'); + return `${whole.toString()}.${fraction}`; +} + +function scalePercentage(percentage: number): bigint { + return BigInt(percentage.toFixed(7).replace('.', '')); +} + +export function releaseFunds( + balance: string, + distribution: Distribution[], +): PaymentOp[] { + const totalStroops = amountToStroops(balance); + + const calculatedShares = distribution.map((entry, index) => { + const numerator = totalStroops * scalePercentage(entry.percentage); + return { + index, + recipient: entry.recipient, + baseStroops: numerator / PERCENTAGE_DENOMINATOR, + remainder: numerator % PERCENTAGE_DENOMINATOR, + }; + }); + + const allocatedStroops = calculatedShares.reduce( + (sum, share) => sum + share.baseStroops, + 0n, + ); + let remainingStroops = totalStroops - allocatedStroops; + + const bonusRecipients = [...calculatedShares].sort((left, right) => { + if (left.remainder === right.remainder) { + return left.index - right.index; + } + return left.remainder > right.remainder ? -1 : 1; + }); + + for (let i = 0; remainingStroops > 0n; i += 1) { + bonusRecipients[i].baseStroops += 1n; + remainingStroops -= 1n; + } + + return calculatedShares.map(share => ({ + type: 'Payment', + destination: share.recipient, + asset: 'XLM', + amount: stroopsToAmount(share.baseStroops), + })); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createEscrowAccount(..._args: unknown[]): unknown { return undefined; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/index.ts b/src/index.ts index a2007d7..a3f80f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,6 @@ export type { SDKConfig, KeypairResult, AccountInfo, BalanceInfo } from './types export type { SubmitResult, TransactionStatus } from './types/transaction'; // 6. Standalone functions -export { createEscrowAccount, lockCustodyFunds, anchorTrustHash, verifyEventHash } from './escrow'; +export { createEscrowAccount, lockCustodyFunds, anchorTrustHash, verifyEventHash, releaseFunds } from './escrow'; export { buildMultisigTransaction } from './transactions'; export { getMinimumReserve } from './accounts'; diff --git a/tests/unit/escrow/index.test.ts b/tests/unit/escrow/index.test.ts index 1c7d960..8df7ce9 100644 --- a/tests/unit/escrow/index.test.ts +++ b/tests/unit/escrow/index.test.ts @@ -3,7 +3,13 @@ import { lockCustodyFunds, anchorTrustHash, verifyEventHash, + releaseFunds, } from '../../../src/escrow'; +import { asPercentage } from '../../../src/types/escrow'; + +const RECIPIENT_A = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const RECIPIENT_B = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB7H'; +const RECIPIENT_C = 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCD'; describe('escrow module placeholders', () => { it('exports callable placeholder functions', () => { @@ -14,3 +20,77 @@ describe('escrow module placeholders', () => { }); }); +describe('releaseFunds', () => { + it('builds exact payment operations for a 60/40 split from 500 XLM', () => { + const operations = releaseFunds('500.0000000', [ + { recipient: RECIPIENT_A, percentage: asPercentage(60) }, + { recipient: RECIPIENT_B, percentage: asPercentage(40) }, + ]); + + expect(operations).toEqual([ + { + type: 'Payment', + destination: RECIPIENT_A, + asset: 'XLM', + amount: '300.0000000', + }, + { + type: 'Payment', + destination: RECIPIENT_B, + asset: 'XLM', + amount: '200.0000000', + }, + ]); + }); + + it('builds a single payment when one recipient receives 100%', () => { + const operations = releaseFunds('500.0000000', [ + { recipient: RECIPIENT_A, percentage: asPercentage(100) }, + ]); + + expect(operations).toEqual([ + { + type: 'Payment', + destination: RECIPIENT_A, + asset: 'XLM', + amount: '500.0000000', + }, + ]); + }); + + it('keeps a three-way split summed exactly to the original balance', () => { + const operations = releaseFunds('1.0000000', [ + { recipient: RECIPIENT_A, percentage: asPercentage(33.3333333) }, + { recipient: RECIPIENT_B, percentage: asPercentage(33.3333333) }, + { recipient: RECIPIENT_C, percentage: asPercentage(33.3333334) }, + ]); + + expect(operations).toEqual([ + { + type: 'Payment', + destination: RECIPIENT_A, + asset: 'XLM', + amount: '0.3333333', + }, + { + type: 'Payment', + destination: RECIPIENT_B, + asset: 'XLM', + amount: '0.3333333', + }, + { + type: 'Payment', + destination: RECIPIENT_C, + asset: 'XLM', + amount: '0.3333334', + }, + ]); + + const total = operations.reduce((sum, operation) => { + return sum + Number(operation.amount); + }, 0); + + expect(total.toFixed(7)).toBe('1.0000000'); + }); +}); + From 79ac9bec2b4c0476bc5bc88262e79b683ce4a301 Mon Sep 17 00:00:00 2001 From: RemmyAcee Date: Fri, 27 Mar 2026 17:13:57 +0100 Subject: [PATCH 2/2] =?UTF-8?q?[SDK=20=C2=B7=20Escrow=20lifecycle]=20Assem?= =?UTF-8?q?ble=20full=20releaseFunds()=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/escrow/index.ts | 293 +++++++++++++++++- src/types/escrow.ts | 5 + tests/integration/escrow/releaseFunds.test.ts | 87 ++++++ tests/unit/escrow/index.test.ts | 192 ++++++++---- 4 files changed, 519 insertions(+), 58 deletions(-) create mode 100644 tests/integration/escrow/releaseFunds.test.ts diff --git a/src/escrow/index.ts b/src/escrow/index.ts index d260ce3..4d04093 100644 --- a/src/escrow/index.ts +++ b/src/escrow/index.ts @@ -1,10 +1,63 @@ -import { Distribution } from '../types/escrow'; +import { + Account, + Asset, + Horizon, + Keypair, + Memo, + Operation as StellarOperation, + TransactionBuilder, +} from '@stellar/stellar-sdk'; +import { Distribution, ReleaseParams, ReleaseResult } from '../types/escrow'; import { PaymentOp } from '../types/transaction'; +import { + DEFAULT_MAX_FEE, + DEFAULT_TRANSACTION_TIMEOUT, + MAINNET_HORIZON_URL, + MAINNET_PASSPHRASE, + TESTNET_HORIZON_URL, + TESTNET_PASSPHRASE, +} from '../utils/constants'; +import { + EscrowNotFoundError, + HorizonSubmitError, + SdkError, + ValidationError, +} from '../utils/errors'; +import { + isValidAmount, + isValidDistribution, + isValidPublicKey, + isValidSecretKey, +} from '../utils/validation'; const STROOPS_PER_XLM = 10_000_000n; const PERCENTAGE_SCALE = 10_000_000n; const PERCENTAGE_DENOMINATOR = 100n * PERCENTAGE_SCALE; +type HorizonAccount = Account & { + balances: Array<{ asset_type: string; balance: string }>; + accountId(): string; +}; + +interface HorizonSubmission { + successful: boolean; + hash: string; + ledger: number; +} + +interface ReleaseServer { + loadAccount(accountId: string): Promise; + submitTransaction(transaction: unknown): Promise; +} + +interface ReleaseFundsDependencies { + server?: ReleaseServer; + sleep?: (ms: number) => Promise; + horizonUrl?: string; + networkPassphrase?: string; + maxSubmitAttempts?: number; +} + function amountToStroops(amount: string): bigint { const [wholePart, fractionalPart = ''] = amount.split('.'); const normalizedFraction = `${fractionalPart}0000000`.slice(0, 7); @@ -25,7 +78,41 @@ function scalePercentage(percentage: number): bigint { return BigInt(percentage.toFixed(7).replace('.', '')); } -export function releaseFunds( +function getNativeBalance(account: HorizonAccount): string { + const nativeBalance = account.balances.find(balance => balance.asset_type === 'native'); + if (!nativeBalance) { + throw new SdkError( + `Native balance not found for account ${account.accountId()}`, + 'NATIVE_BALANCE_NOT_FOUND', + false, + ); + } + + return nativeBalance.balance; +} + +function validateReleaseParams(params: ReleaseParams): void { + if (!isValidPublicKey(params.escrowAccountId)) { + throw new ValidationError('escrowAccountId', 'Invalid escrow account public key'); + } + + if (!isValidDistribution(params.distribution)) { + throw new ValidationError( + 'distribution', + 'Distribution must contain valid recipients and total 100%', + ); + } + + if (params.balance !== undefined && !isValidAmount(params.balance)) { + throw new ValidationError('balance', 'Release balance must be a positive XLM amount'); + } + + if (params.sourceSecretKey !== undefined && !isValidSecretKey(params.sourceSecretKey)) { + throw new ValidationError('sourceSecretKey', 'Invalid source secret key'); + } +} + +function buildReleasePaymentOperations( balance: string, distribution: Distribution[], ): PaymentOp[] { @@ -67,6 +154,208 @@ export function releaseFunds( })); } +function paymentOperationToStellar(payment: PaymentOp) { + return StellarOperation.payment({ + destination: payment.destination, + asset: Asset.native(), + amount: payment.amount, + }); +} + +function getDefaultNetworkPassphrase(): string { + if (process.env.STELLAR_NETWORK_PASSPHRASE) { + return process.env.STELLAR_NETWORK_PASSPHRASE; + } + + return process.env.STELLAR_NETWORK === 'public' + ? MAINNET_PASSPHRASE + : TESTNET_PASSPHRASE; +} + +function getDefaultHorizonUrl(): string { + if (process.env.STELLAR_HORIZON_URL) { + return process.env.STELLAR_HORIZON_URL; + } + + return process.env.STELLAR_NETWORK === 'public' + ? MAINNET_HORIZON_URL + : TESTNET_HORIZON_URL; +} + +function getSourceSecretKey(params: ReleaseParams): string { + const sourceSecretKey = params.sourceSecretKey ?? process.env.MASTER_SECRET_KEY; + if (!sourceSecretKey) { + throw new ValidationError( + 'sourceSecretKey', + 'A source secret key is required to sign the release transaction', + ); + } + + if (!isValidSecretKey(sourceSecretKey)) { + throw new ValidationError('sourceSecretKey', 'Invalid source secret key'); + } + + return sourceSecretKey; +} + +function mapToSdkError(error: unknown, escrowAccountId: string): SdkError { + if (error instanceof SdkError) { + return error; + } + + const maybeError = error as { + response?: { status?: number; data?: { extras?: { result_codes?: { transaction?: string; operations?: string[] } } } }; + extras?: { result_codes?: { transaction?: string; operations?: string[] } }; + message?: string; + }; + + if (maybeError.response?.status === 404) { + return new EscrowNotFoundError(escrowAccountId); + } + + const resultCodes = + maybeError.response?.data?.extras?.result_codes ?? + maybeError.extras?.result_codes; + if (resultCodes?.transaction) { + return new HorizonSubmitError( + resultCodes.transaction, + resultCodes.operations ?? [], + ); + } + + return new SdkError( + maybeError.message ?? 'Failed to release funds', + 'RELEASE_FUNDS_FAILED', + false, + ); +} + +async function loadEscrowAccount( + server: Pick, + escrowAccountId: string, +): Promise { + try { + return await server.loadAccount(escrowAccountId); + } catch (error) { + throw mapToSdkError(error, escrowAccountId); + } +} + +async function submitReleaseTransaction( + server: Pick, + account: HorizonAccount, + params: ReleaseParams, + payments: PaymentOp[], + networkPassphrase: string, +): Promise { + const sourceSecretKey = getSourceSecretKey(params); + const sourceKeypair = Keypair.fromSecret(sourceSecretKey); + const fee = params.fee ?? process.env.MAX_FEE ?? String(DEFAULT_MAX_FEE); + const timeoutSeconds = params.timeoutSeconds ?? DEFAULT_TRANSACTION_TIMEOUT; + + const builder = payments.reduce( + (builder, payment) => builder.addOperation(paymentOperationToStellar(payment)), + new TransactionBuilder(account, { + fee, + networkPassphrase, + }), + ); + + if (params.memo) { + builder.addMemo(Memo.text(params.memo)); + } + + const transaction = builder.setTimeout(timeoutSeconds).build(); + + transaction.sign(sourceKeypair); + + try { + const submission = await server.submitTransaction(transaction); + return { + successful: submission.successful, + txHash: submission.hash, + ledger: submission.ledger, + payments: payments.map(payment => ({ + recipient: payment.destination, + amount: payment.amount, + })), + }; + } catch (error) { + throw mapToSdkError(error, params.escrowAccountId); + } +} + +/** + * Validates release inputs, builds payment operations from the requested + * distribution, signs the transaction, and submits it to Horizon. + * + * The release amount is taken from `params.balance` when provided; otherwise + * the current native XLM balance is loaded from Horizon for the escrow account. + * Distribution math is performed in stroops using integer arithmetic so the + * final payments preserve Stellar's 7-decimal precision and sum exactly to the + * requested release amount. + * + * Retry behavior is limited to retryable `SdkError`s such as transient Horizon + * submission failures. Non-retryable failures are rethrown immediately. + * + * @param params Release request containing the escrow account, recipients, + * optional explicit release amount, and signing secret. + * @param dependencies Optional test or runtime overrides for Horizon access, + * passphrase selection, and retry behavior. + * @returns A `ReleaseResult` describing the submitted transaction and the + * recipient payments included in it. + * @throws {ValidationError} If the account, balance, distribution, or secret is invalid. + * @throws {EscrowNotFoundError} If the escrow account cannot be loaded from Horizon. + * @throws {HorizonSubmitError} If Horizon rejects the submitted transaction. + * @throws {SdkError} For any other release failure that cannot be mapped more specifically. + */ +export async function releaseFunds( + params: ReleaseParams, + dependencies: ReleaseFundsDependencies = {}, +): Promise { + validateReleaseParams(params); + + const server = + dependencies.server ?? + new Horizon.Server(dependencies.horizonUrl ?? getDefaultHorizonUrl()); + const networkPassphrase = + dependencies.networkPassphrase ?? getDefaultNetworkPassphrase(); + const maxSubmitAttempts = dependencies.maxSubmitAttempts ?? 2; + const sleep = dependencies.sleep ?? (async () => undefined); + + let lastError: SdkError | undefined; + + for (let attempt = 1; attempt <= maxSubmitAttempts; attempt += 1) { + try { + const account = await loadEscrowAccount(server, params.escrowAccountId); + const releaseBalance = params.balance ?? getNativeBalance(account); + const payments = buildReleasePaymentOperations( + releaseBalance, + params.distribution, + ); + + return await submitReleaseTransaction( + server, + account, + { ...params, balance: releaseBalance }, + payments, + networkPassphrase, + ); + } catch (error) { + const sdkError = mapToSdkError(error, params.escrowAccountId); + lastError = sdkError; + + if (!sdkError.retryable || attempt === maxSubmitAttempts) { + throw sdkError; + } + + await sleep(0); + } + } + + throw lastError ?? new SdkError('Failed to release funds', 'RELEASE_FUNDS_FAILED', false); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createEscrowAccount(..._args: unknown[]): unknown { return undefined; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/types/escrow.ts b/src/types/escrow.ts index 74b2e7d..2878f96 100644 --- a/src/types/escrow.ts +++ b/src/types/escrow.ts @@ -66,6 +66,11 @@ export interface Distribution { export interface ReleaseParams { escrowAccountId: string; distribution: Distribution[]; + balance?: string; + sourceSecretKey?: string; + memo?: string; + fee?: string; + timeoutSeconds?: number; } /** Recorded payment made to a recipient during settlement. */ diff --git a/tests/integration/escrow/releaseFunds.test.ts b/tests/integration/escrow/releaseFunds.test.ts new file mode 100644 index 0000000..e6d6aa5 --- /dev/null +++ b/tests/integration/escrow/releaseFunds.test.ts @@ -0,0 +1,87 @@ +import { Horizon, Keypair } from '@stellar/stellar-sdk'; +import { releaseFunds } from '../../../src/escrow'; +import { asPercentage } from '../../../src/types/escrow'; +import { + TESTNET_HORIZON_URL, + TESTNET_PASSPHRASE, +} from '../../../src/utils/constants'; + +const FRIEND_BOT_URL = 'https://friendbot.stellar.org'; +const HORIZON_URL = process.env.STELLAR_HORIZON_URL ?? TESTNET_HORIZON_URL; +const NETWORK_PASSPHRASE = + process.env.STELLAR_NETWORK_PASSPHRASE ?? TESTNET_PASSPHRASE; + +function amountToStroops(amount: string): bigint { + const [wholePart, fractionalPart = ''] = amount.split('.'); + const normalizedFraction = `${fractionalPart}0000000`.slice(0, 7); + return BigInt(wholePart) * 10_000_000n + BigInt(normalizedFraction); +} + +function getNativeBalance(account: Awaited>): string { + const nativeBalance = account.balances.find(balance => balance.asset_type === 'native'); + if (!nativeBalance) { + throw new Error(`Native balance not found for ${account.accountId()}`); + } + + return nativeBalance.balance; +} + +async function fundWithFriendbot(publicKey: string): Promise { + const response = await fetch(`${FRIEND_BOT_URL}?addr=${encodeURIComponent(publicKey)}`); + if (!response.ok) { + throw new Error(`Friendbot funding failed for ${publicKey}: HTTP ${response.status}`); + } +} + +describe('releaseFunds integration', () => { + jest.setTimeout(120000); + + it('submits a real 60/40 release on testnet and changes recipient balances on-chain', async () => { + const server = new Horizon.Server(HORIZON_URL); + const source = Keypair.random(); + const recipientA = Keypair.random(); + const recipientB = Keypair.random(); + + await fundWithFriendbot(source.publicKey()); + await fundWithFriendbot(recipientA.publicKey()); + await fundWithFriendbot(recipientB.publicKey()); + + const beforeA = await server.loadAccount(recipientA.publicKey()); + const beforeB = await server.loadAccount(recipientB.publicKey()); + + const result = await releaseFunds( + { + escrowAccountId: source.publicKey(), + sourceSecretKey: source.secret(), + balance: '10.0000000', + distribution: [ + { recipient: recipientA.publicKey(), percentage: asPercentage(60) }, + { recipient: recipientB.publicKey(), percentage: asPercentage(40) }, + ], + }, + { + server, + networkPassphrase: NETWORK_PASSPHRASE, + maxSubmitAttempts: 1, + }, + ); + + const afterA = await server.loadAccount(recipientA.publicKey()); + const afterB = await server.loadAccount(recipientB.publicKey()); + + expect(result.successful).toBe(true); + expect(result.payments).toEqual([ + { recipient: recipientA.publicKey(), amount: '6.0000000' }, + { recipient: recipientB.publicKey(), amount: '4.0000000' }, + ]); + + expect( + amountToStroops(getNativeBalance(afterA)) - + amountToStroops(getNativeBalance(beforeA)), + ).toBe(amountToStroops('6.0000000')); + expect( + amountToStroops(getNativeBalance(afterB)) - + amountToStroops(getNativeBalance(beforeB)), + ).toBe(amountToStroops('4.0000000')); + }); +}); diff --git a/tests/unit/escrow/index.test.ts b/tests/unit/escrow/index.test.ts index 8df7ce9..b09ebc1 100644 --- a/tests/unit/escrow/index.test.ts +++ b/tests/unit/escrow/index.test.ts @@ -1,3 +1,7 @@ +import { + Account, + Keypair, +} from '@stellar/stellar-sdk'; import { createEscrowAccount, lockCustodyFunds, @@ -6,10 +10,24 @@ import { releaseFunds, } from '../../../src/escrow'; import { asPercentage } from '../../../src/types/escrow'; +import { HorizonSubmitError } from '../../../src/utils/errors'; + +function createHorizonAccount(accountId: string, balance: string, sequence = '1') { + return Object.assign(new Account(accountId, sequence), { + balances: [{ asset_type: 'native', balance }], + }); +} -const RECIPIENT_A = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; -const RECIPIENT_B = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB7H'; -const RECIPIENT_C = 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCD'; +function createServer(balance: string) { + return { + loadAccount: jest.fn(async (accountId: string) => createHorizonAccount(accountId, balance)), + submitTransaction: jest.fn(async (_transaction: unknown) => ({ + successful: true, + hash: 'tx-hash-123', + ledger: 123456, + })), + }; +} describe('escrow module placeholders', () => { it('exports callable placeholder functions', () => { @@ -21,76 +39,138 @@ describe('escrow module placeholders', () => { }); describe('releaseFunds', () => { - it('builds exact payment operations for a 60/40 split from 500 XLM', () => { - const operations = releaseFunds('500.0000000', [ - { recipient: RECIPIENT_A, percentage: asPercentage(60) }, - { recipient: RECIPIENT_B, percentage: asPercentage(40) }, - ]); + it('releases the full fetched balance on the happy path', async () => { + const source = Keypair.random(); + const recipient = Keypair.random(); + const server = createServer('500.0000000'); - expect(operations).toEqual([ - { - type: 'Payment', - destination: RECIPIENT_A, - asset: 'XLM', - amount: '300.0000000', - }, + const result = await releaseFunds( { - type: 'Payment', - destination: RECIPIENT_B, - asset: 'XLM', - amount: '200.0000000', + escrowAccountId: source.publicKey(), + sourceSecretKey: source.secret(), + distribution: [ + { recipient: recipient.publicKey(), percentage: asPercentage(100) }, + ], }, - ]); + { server }, + ); + + expect(result).toEqual({ + successful: true, + txHash: 'tx-hash-123', + ledger: 123456, + payments: [ + { recipient: recipient.publicKey(), amount: '500.0000000' }, + ], + }); + + const submittedTransaction = server.submitTransaction.mock.calls[0]?.[0] as { + operations: Array>; + }; + expect(submittedTransaction.operations).toHaveLength(1); + expect(submittedTransaction.operations[0]).toMatchObject({ + type: 'payment', + destination: recipient.publicKey(), + amount: '500.0000000', + }); }); - it('builds a single payment when one recipient receives 100%', () => { - const operations = releaseFunds('500.0000000', [ - { recipient: RECIPIENT_A, percentage: asPercentage(100) }, - ]); + it('submits a 60/40 split as two exact payment operations', async () => { + const source = Keypair.random(); + const recipientA = Keypair.random(); + const recipientB = Keypair.random(); + const server = createServer('999.0000000'); - expect(operations).toEqual([ + const result = await releaseFunds( { - type: 'Payment', - destination: RECIPIENT_A, - asset: 'XLM', - amount: '500.0000000', + escrowAccountId: source.publicKey(), + sourceSecretKey: source.secret(), + balance: '500.0000000', + distribution: [ + { recipient: recipientA.publicKey(), percentage: asPercentage(60) }, + { recipient: recipientB.publicKey(), percentage: asPercentage(40) }, + ], }, + { server }, + ); + + expect(result.payments).toEqual([ + { recipient: recipientA.publicKey(), amount: '300.0000000' }, + { recipient: recipientB.publicKey(), amount: '200.0000000' }, ]); + + const submittedTransaction = server.submitTransaction.mock.calls[0]?.[0] as { + operations: Array>; + }; + expect(submittedTransaction.operations).toHaveLength(2); + expect(submittedTransaction.operations[0]).toMatchObject({ + type: 'payment', + destination: recipientA.publicKey(), + amount: '300.0000000', + }); + expect(submittedTransaction.operations[1]).toMatchObject({ + type: 'payment', + destination: recipientB.publicKey(), + amount: '200.0000000', + }); }); - it('keeps a three-way split summed exactly to the original balance', () => { - const operations = releaseFunds('1.0000000', [ - { recipient: RECIPIENT_A, percentage: asPercentage(33.3333333) }, - { recipient: RECIPIENT_B, percentage: asPercentage(33.3333333) }, - { recipient: RECIPIENT_C, percentage: asPercentage(33.3333334) }, - ]); + it('supports a 100% refund to a single recipient', async () => { + const source = Keypair.random(); + const refundRecipient = Keypair.random(); + const server = createServer('999.0000000'); - expect(operations).toEqual([ - { - type: 'Payment', - destination: RECIPIENT_A, - asset: 'XLM', - amount: '0.3333333', - }, + const result = await releaseFunds( { - type: 'Payment', - destination: RECIPIENT_B, - asset: 'XLM', - amount: '0.3333333', - }, - { - type: 'Payment', - destination: RECIPIENT_C, - asset: 'XLM', - amount: '0.3333334', + escrowAccountId: source.publicKey(), + sourceSecretKey: source.secret(), + balance: '125.0000000', + distribution: [ + { recipient: refundRecipient.publicKey(), percentage: asPercentage(100) }, + ], }, + { server }, + ); + + expect(result.payments).toEqual([ + { recipient: refundRecipient.publicKey(), amount: '125.0000000' }, ]); + expect(server.loadAccount).toHaveBeenCalledTimes(1); + expect(server.submitTransaction).toHaveBeenCalledTimes(1); + }); + + it('does not retry when submit fails with a non-retryable SdkError', async () => { + const source = Keypair.random(); + const recipient = Keypair.random(); + const server = { + loadAccount: jest.fn(async (accountId: string) => + createHorizonAccount(accountId, '500.0000000'), + ), + submitTransaction: jest.fn(async (_transaction: unknown) => { + throw new HorizonSubmitError('tx_bad_auth'); + }), + }; - const total = operations.reduce((sum, operation) => { - return sum + Number(operation.amount); - }, 0); + await expect( + releaseFunds( + { + escrowAccountId: source.publicKey(), + sourceSecretKey: source.secret(), + balance: '500.0000000', + distribution: [ + { recipient: recipient.publicKey(), percentage: asPercentage(100) }, + ], + }, + { server, maxSubmitAttempts: 3 }, + ), + ).rejects.toMatchObject({ + code: 'HORIZON_SUBMIT_ERROR', + retryable: false, + resultCode: 'tx_bad_auth', + }); - expect(total.toFixed(7)).toBe('1.0000000'); + expect(server.loadAccount).toHaveBeenCalledTimes(1); + expect(server.submitTransaction).toHaveBeenCalledTimes(1); }); });