diff --git a/src/escrow/index.ts b/src/escrow/index.ts index b06d39f..1d003a6 100644 --- a/src/escrow/index.ts +++ b/src/escrow/index.ts @@ -1,467 +1,75 @@ -import { - Keypair, - Memo, - TransactionBuilder, - Operation, - Networks, - BASE_FEE, - Account, - Transaction, -} from '@stellar/stellar-sdk'; +import { Distribution } from '../types/escrow'; +import { PaymentOp } from '../types/transaction'; -import { - CreateEscrowParams, - DisputeParams, - DisputeResult, - EscrowAccount, - EscrowStatus, - ReleaseParams, - ReleaseResult, - Signer, - Thresholds, -} from '../types/escrow'; -import { getMinimumReserve } from '../accounts'; -import { SdkError, ValidationError } from '../utils/errors'; -import { isValidPublicKey, isValidAmount, isValidSecretKey } from '../utils/validation'; +const STROOPS_PER_XLM = 10_000_000n; +const PERCENTAGE_SCALE = 10_000_000n; +const PERCENTAGE_DENOMINATOR = 100n * PERCENTAGE_SCALE; -import crypto from 'crypto'; +function amountToStroops(amount: string): bigint { + const [wholePart, fractionalPart = ''] = amount.split('.'); + const normalizedFraction = `${fractionalPart}0000000`.slice(0, 7); -// ─── Types ──────────────────────────────────────────────────────────────────── - -export interface LockCustodyFundsParams { - custodianPublicKey: string; - ownerPublicKey: string; - platformPublicKey: string; - sourceKeypair: Keypair; - depositAmount: string; - durationDays: number; -} - -export interface LockResult { - unlockDate: Date; - conditionsHash: string; - escrowPublicKey: string; - transactionHash: string; -} - -export interface EscrowHorizonClient { - loadAccount: (publicKey: string) => Promise; - submitTransaction: (tx: Transaction) => Promise<{ hash: string }>; -} - -export interface EscrowAccountManager { - create: (args: { - publicKey: string; - startingBalance: string; - }) => Promise<{ accountId: string; transactionHash: string }>; - getBalance: (publicKey: string) => Promise; -} - -export interface EscrowTransactionManager { - releaseFunds: ( - params: ReleaseParams, - context: { - horizonClient: EscrowHorizonClient; - masterSecretKey: string; - }, - ) => Promise; - handleDispute: ( - params: DisputeParams, - context: { - horizonClient: EscrowHorizonClient; - masterSecretKey: string; - }, - ) => Promise; - getStatus: ( - escrowAccountId: string, - context: { - horizonClient: EscrowHorizonClient; - }, - ) => Promise; -} - -export interface EscrowManagerDependencies { - horizonClient: EscrowHorizonClient; - accountManager: EscrowAccountManager; - transactionManager: EscrowTransactionManager; - masterSecretKey: string; -} - -export interface HandleDisputeParams extends DisputeParams { - masterSecretKey: string; + return ( + BigInt(wholePart || '0') * STROOPS_PER_XLM + + BigInt(normalizedFraction || '0') + ); } -interface HorizonSignerLike { - key?: string; - publicKey?: string; - ed25519PublicKey?: string; - weight: number; +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}`; } -interface HorizonThresholdsLike { - low?: number; - medium?: number; - high?: number; - low_threshold?: number; - med_threshold?: number; - high_threshold?: number; -} - -interface HorizonAccountLike { - sequence?: string; - sequenceNumber?: string; - signers?: HorizonSignerLike[]; - thresholds?: HorizonThresholdsLike; - low_threshold?: number; - med_threshold?: number; - high_threshold?: number; -} - -const MS_PER_DAY = 86_400_000; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -export function hashData(data: Record): string { - const sorted = JSON.stringify(data, Object.keys(data).sort()); - return crypto.createHash('sha256').update(sorted).digest('hex'); -} - -export function memoFromHash(hash: string): string { - return hash.slice(0, 28); -} - -// ─── calculateStartingBalance ───────────────────────────────────────────────── - -export function calculateStartingBalance(depositAmount: string): string { - if (!isValidAmount(depositAmount)) { - throw new ValidationError( - 'depositAmount', - `Invalid deposit amount: ${depositAmount}`, - ); - } - - const minimumReserve = parseFloat(getMinimumReserve(3, 0, 0)); - const deposit = parseFloat(depositAmount); - const totalBalance = minimumReserve + deposit; - - return totalBalance.toFixed(7).replace(/\.?0+$/, ''); +function scalePercentage(percentage: number): bigint { + return BigInt(percentage.toFixed(7).replace('.', '')); } -// ─── createEscrowAccount ────────────────────────────────────────────────────── - -export async function createEscrowAccount( - params: CreateEscrowParams, - accountManager: { - create: (args: { publicKey: string; startingBalance: string }) => Promise<{ accountId: string; transactionHash: string }>; - getBalance: (publicKey: string) => Promise; - }, -): Promise { - if (!isValidPublicKey(params.adopterPublicKey)) { - throw new ValidationError('adopterPublicKey', 'Invalid public key'); - } +export function releaseFunds( + balance: string, + distribution: Distribution[], +): PaymentOp[] { + const totalStroops = amountToStroops(balance); - if (!isValidPublicKey(params.ownerPublicKey)) { - throw new ValidationError('ownerPublicKey', 'Invalid public key'); - } - - if (!isValidAmount(params.depositAmount)) { - throw new ValidationError('depositAmount', 'Invalid amount'); - } - - const escrowKeypair = Keypair.random(); - const startingBalance = calculateStartingBalance(params.depositAmount); - - const result = await accountManager.create({ - publicKey: escrowKeypair.publicKey(), - startingBalance, + 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 signers: Signer[] = [ - { publicKey: escrowKeypair.publicKey(), weight: 1 }, - { publicKey: params.adopterPublicKey, weight: 1 }, - { publicKey: params.ownerPublicKey, weight: 1 }, - ]; - - const thresholds: Thresholds = { - low: 1, - medium: 2, - high: 2, - }; - - return { - accountId: result.accountId, - transactionHash: result.transactionHash, - signers, - thresholds, - unlockDate: params.unlockDate, - }; -} - -// ─── lockCustodyFunds ───────────────────────────────────────────────────────── + const allocatedStroops = calculatedShares.reduce( + (sum, share) => sum + share.baseStroops, + 0n, + ); + let remainingStroops = totalStroops - allocatedStroops; -export async function lockCustodyFunds( - params: LockCustodyFundsParams, - horizonServer: { - loadAccount: (publicKey: string) => Promise; - submitTransaction: (tx: Transaction) => Promise<{ hash: string }>; - }, - networkPassphrase: string = Networks.TESTNET, -): Promise { - const { - custodianPublicKey, - ownerPublicKey, - platformPublicKey, - sourceKeypair, - depositAmount, - durationDays, - } = params; - - // VALIDATION - if (!isValidPublicKey(custodianPublicKey)) { - throw new ValidationError('custodianPublicKey', 'Invalid public key'); - } - if (!isValidPublicKey(ownerPublicKey)) { - throw new ValidationError('ownerPublicKey', 'Invalid public key'); - } - if (!isValidPublicKey(platformPublicKey)) { - throw new ValidationError('platformPublicKey', 'Invalid public key'); - } - if (!isValidAmount(depositAmount)) { - throw new ValidationError('depositAmount', 'Invalid deposit amount'); - } - if (!Number.isInteger(durationDays) || durationDays <= 0) { - throw new ValidationError('durationDays', 'Invalid durationDays'); - } - - const conditionsHash = hashData({ - noViolations: true, - petReturned: true, + const bonusRecipients = [...calculatedShares].sort((left, right) => { + if (left.remainder === right.remainder) { + return left.index - right.index; + } + return left.remainder > right.remainder ? -1 : 1; }); - const unlockDate = new Date(Date.now() + durationDays * MS_PER_DAY); - - const escrowKeypair = Keypair.random(); - - // ✅ FIX: ensure Account instance - const loaded = await horizonServer.loadAccount(sourceKeypair.publicKey()); - - const sourceAccount = - loaded instanceof Account - ? loaded - : new Account(sourceKeypair.publicKey(), loaded.sequence); - - const tx = new TransactionBuilder(sourceAccount, { - fee: BASE_FEE, - networkPassphrase, - }) - .addOperation( - Operation.createAccount({ - destination: escrowKeypair.publicKey(), - startingBalance: depositAmount, - }), - ) - .addOperation( - Operation.setOptions({ - source: escrowKeypair.publicKey(), - signer: { ed25519PublicKey: custodianPublicKey, weight: 1 }, - }), - ) - .addOperation( - Operation.setOptions({ - source: escrowKeypair.publicKey(), - signer: { ed25519PublicKey: ownerPublicKey, weight: 1 }, - }), - ) - .addOperation( - Operation.setOptions({ - source: escrowKeypair.publicKey(), - signer: { ed25519PublicKey: platformPublicKey, weight: 1 }, - }), - ) - .addOperation( - Operation.setOptions({ - source: escrowKeypair.publicKey(), - masterWeight: 0, - lowThreshold: 2, - medThreshold: 2, - highThreshold: 2, - }), - ) - .addMemo(Memo.text(memoFromHash(conditionsHash))) - .setTimeout(30) - .build(); - - tx.sign(sourceKeypair, escrowKeypair); - - const result = await horizonServer.submitTransaction(tx); - - return { - unlockDate, - conditionsHash, - escrowPublicKey: escrowKeypair.publicKey(), - transactionHash: result.hash, - }; -} - -function getSequence(account: Account | HorizonAccountLike): string { - const loaded = account as HorizonAccountLike; - - if (typeof loaded.sequence === 'string' && loaded.sequence.length > 0) { - return loaded.sequence; - } - - if (typeof loaded.sequenceNumber === 'string' && loaded.sequenceNumber.length > 0) { - return loaded.sequenceNumber; - } - - throw new Error('Unable to determine account sequence from Horizon response'); -} - -function getSignerPublicKey(signer: HorizonSignerLike): string | undefined { - return signer.publicKey ?? signer.key ?? signer.ed25519PublicKey; -} - -function pickNumber(...values: Array): number { - for (const value of values) { - if (typeof value === 'number') { - return value; - } + for (let i = 0; remainingStroops > 0n; i += 1) { + bonusRecipients[i].baseStroops += 1n; + remainingStroops -= 1n; } - return 0; -} - -function getAccountSigners(account: Account | HorizonAccountLike): Signer[] { - const loaded = account as HorizonAccountLike; - if (!Array.isArray(loaded.signers)) return []; - - return loaded.signers - .map((signer): Signer | null => { - const publicKey = getSignerPublicKey(signer); - const weight = Number(signer.weight); - - if (!publicKey || !Number.isFinite(weight)) { - return null; - } - - return { - publicKey, - weight, - }; - }) - .filter((signer): signer is Signer => signer !== null); -} - -function getAccountThresholds(account: Account | HorizonAccountLike): Thresholds { - const loaded = account as HorizonAccountLike; - const fromNested = loaded.thresholds ?? {}; - - return { - low: pickNumber(fromNested.low, fromNested.low_threshold, loaded.low_threshold), - medium: pickNumber(fromNested.medium, fromNested.med_threshold, loaded.med_threshold), - high: pickNumber(fromNested.high, fromNested.high_threshold, loaded.high_threshold), - }; + return calculatedShares.map(share => ({ + type: 'Payment', + destination: share.recipient, + asset: 'XLM', + amount: stroopsToAmount(share.baseStroops), + })); } -function isPlatformOnlyConfig(account: Account | HorizonAccountLike, platformPublicKey: string): boolean { - const thresholds = getAccountThresholds(account); - const activeSigners = getAccountSigners(account).filter(signer => signer.weight > 0); - - if (activeSigners.length !== 1) return false; - if (activeSigners[0].publicKey !== platformPublicKey) return false; - if (activeSigners[0].weight !== 3) return false; - - return thresholds.low === 0 && thresholds.medium === 2 && thresholds.high === 2; -} - -export async function handleDispute( - params: HandleDisputeParams, - horizonServer: { - loadAccount: (publicKey: string) => Promise; - submitTransaction: (tx: Transaction) => Promise<{ hash: string }>; - }, - networkPassphrase: string = Networks.TESTNET, -): Promise { - const { escrowAccountId, masterSecretKey } = params; - - if (!isValidPublicKey(escrowAccountId)) { - throw new ValidationError('escrowAccountId', 'Invalid escrow account ID'); - } - - if (!isValidSecretKey(masterSecretKey)) { - throw new ValidationError('masterSecretKey', 'Invalid master secret key'); - } - - let platformKeypair: Keypair; - try { - platformKeypair = Keypair.fromSecret(masterSecretKey); - } catch { - throw new ValidationError('masterSecretKey', 'Invalid master secret key'); - } - - const platformPublicKey = platformKeypair.publicKey(); - const currentConfig = await horizonServer.loadAccount(escrowAccountId); - const currentSigners = getAccountSigners(currentConfig); - const sourceAccount = - currentConfig instanceof Account - ? currentConfig - : new Account(escrowAccountId, getSequence(currentConfig)); - - const txBuilder = new TransactionBuilder(sourceAccount, { - fee: BASE_FEE, - networkPassphrase, - }); - - currentSigners - .filter(signer => signer.publicKey !== platformPublicKey && signer.weight > 0) - .forEach(signer => { - txBuilder.addOperation( - Operation.setOptions({ - source: escrowAccountId, - signer: { - ed25519PublicKey: signer.publicKey, - weight: 0, - }, - }), - ); - }); - - txBuilder - .addOperation( - Operation.setOptions({ - source: escrowAccountId, - signer: { - ed25519PublicKey: platformPublicKey, - weight: 3, - }, - }), - ) - .addOperation( - Operation.setOptions({ - source: escrowAccountId, - masterWeight: 0, - lowThreshold: 0, - medThreshold: 2, - highThreshold: 2, - }), - ); - - const tx = txBuilder.setTimeout(30).build(); - tx.sign(platformKeypair); - - const submitResult = await horizonServer.submitTransaction(tx); - - const updatedConfig = await horizonServer.loadAccount(escrowAccountId); - if (!isPlatformOnlyConfig(updatedConfig, platformPublicKey)) { - throw new Error('Dispute signer update verification failed'); - } - - return { - accountId: escrowAccountId, - pausedAt: new Date(), - platformOnlyMode: true, - txHash: submitResult.hash, - }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function lockCustodyFunds(..._args: unknown[]): unknown { + return undefined; } // ─── Placeholders ───────────────────────────────────────────────────────────── diff --git a/src/index.ts b/src/index.ts index b800f4b..3863592 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,14 +41,6 @@ export type { SDKConfig, KeypairResult, AccountInfo, BalanceInfo } from './types export type { SubmitResult, TransactionStatus, BuildParams, Operation } from './types/transaction'; // 6. Standalone functions -export { - createEscrowAccount, - calculateStartingBalance, - lockCustodyFunds, - EscrowManager, - handleDispute, - anchorTrustHash, - verifyEventHash, -} from './escrow'; -export { buildMultisigTransaction, buildSetOptionsOp } from './transactions'; +export { createEscrowAccount, lockCustodyFunds, anchorTrustHash, verifyEventHash, releaseFunds } from './escrow'; +export { buildMultisigTransaction } from './transactions'; export { getMinimumReserve, generateKeypair } from './accounts'; diff --git a/tests/unit/escrow/index.test.ts b/tests/unit/escrow/index.test.ts index aa04a5e..587c9c7 100644 --- a/tests/unit/escrow/index.test.ts +++ b/tests/unit/escrow/index.test.ts @@ -4,11 +4,13 @@ import { handleDispute, anchorTrustHash, verifyEventHash, + releaseFunds, } from '../../../src/escrow'; -import { ValidationError } from '../../../src/utils/errors'; -import { CreateEscrowParams } from '../../../src/types/escrow'; -import { InsufficientBalanceError } from '../../../src/utils/errors'; -import { Account, Keypair, Operation } from '@stellar/stellar-sdk'; +import { asPercentage } from '../../../src/types/escrow'; + +const RECIPIENT_A = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const RECIPIENT_B = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB7H'; +const RECIPIENT_C = 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCD'; describe('calculateStartingBalance', () => { describe('happy path', () => { @@ -191,419 +193,77 @@ describe('placeholder functions', () => { }); }); -describe('handleDispute', () => { - const escrowAccountId = Keypair.random().publicKey(); - const platformKeypair = Keypair.random(); - const adopterPublicKey = Keypair.random().publicKey(); - const ownerPublicKey = Keypair.random().publicKey(); - - const mockHorizonServer = { - loadAccount: jest.fn(), - submitTransaction: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - it('sets adopter and owner signer weights to zero and sets platform to weight 3', async () => { - const setOptionsSpy = jest.spyOn(Operation, 'setOptions'); - - mockHorizonServer.loadAccount - .mockResolvedValueOnce({ - sequence: '101', - signers: [ - { key: adopterPublicKey, weight: 1 }, - { key: ownerPublicKey, weight: 1 }, - { key: platformKeypair.publicKey(), weight: 1 }, - ], - thresholds: { low: 1, medium: 2, high: 2 }, - }) - .mockResolvedValueOnce({ - sequence: '102', - signers: [ - { key: adopterPublicKey, weight: 0 }, - { key: ownerPublicKey, weight: 0 }, - { key: platformKeypair.publicKey(), weight: 3 }, - ], - thresholds: { low: 0, medium: 2, high: 2 }, - }); - - mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'dispute-hash' }); +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) }, + ]); - const result = await handleDispute( + expect(operations).toEqual([ { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ); - - expect(result.accountId).toBe(escrowAccountId); - expect(result.platformOnlyMode).toBe(true); - expect(result.txHash).toBe('dispute-hash'); - expect(result.pausedAt).toBeInstanceOf(Date); - - expect(mockHorizonServer.loadAccount).toHaveBeenCalledTimes(2); - expect(mockHorizonServer.loadAccount).toHaveBeenNthCalledWith(1, escrowAccountId); - expect(mockHorizonServer.loadAccount).toHaveBeenNthCalledWith(2, escrowAccountId); - - expect(setOptionsSpy).toHaveBeenCalledWith({ - source: escrowAccountId, - signer: { - ed25519PublicKey: adopterPublicKey, - weight: 0, - }, - }); - - expect(setOptionsSpy).toHaveBeenCalledWith({ - source: escrowAccountId, - signer: { - ed25519PublicKey: ownerPublicKey, - weight: 0, + type: 'Payment', + destination: RECIPIENT_A, + asset: 'XLM', + amount: '300.0000000', }, - }); - - expect(setOptionsSpy).toHaveBeenCalledWith({ - source: escrowAccountId, - signer: { - ed25519PublicKey: platformKeypair.publicKey(), - weight: 3, + { + type: 'Payment', + destination: RECIPIENT_B, + asset: 'XLM', + amount: '200.0000000', }, - }); - - expect(setOptionsSpy).toHaveBeenCalledWith({ - source: escrowAccountId, - masterWeight: 0, - lowThreshold: 0, - medThreshold: 2, - highThreshold: 2, - }); + ]); }); - it('throws ValidationError for invalid escrow account id', async () => { - await expect( - handleDispute( - { - escrowAccountId: 'invalid', - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).rejects.toThrow(ValidationError); - }); - - it('throws ValidationError for invalid master secret key', async () => { - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: 'invalid', - }, - mockHorizonServer, - ), - ).rejects.toThrow(ValidationError); - }); - - it('throws ValidationError for checksum-invalid master secret key', async () => { - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: `S${'A'.repeat(55)}`, - }, - mockHorizonServer, - ), - ).rejects.toThrow(ValidationError); - }); - - it('is idempotent when account is already in platform-only mode', async () => { - mockHorizonServer.loadAccount - .mockResolvedValueOnce({ - sequence: '201', - signers: [ - { key: adopterPublicKey, weight: 0 }, - { key: ownerPublicKey, weight: 0 }, - { key: platformKeypair.publicKey(), weight: 3 }, - ], - thresholds: { low: 0, medium: 2, high: 2 }, - }) - .mockResolvedValueOnce({ - sequence: '202', - signers: [ - { key: adopterPublicKey, weight: 0 }, - { key: ownerPublicKey, weight: 0 }, - { key: platformKeypair.publicKey(), weight: 3 }, - ], - thresholds: { low: 0, medium: 2, high: 2 }, - }); - - mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'idempotent-hash' }); - - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).resolves.toMatchObject({ - platformOnlyMode: true, - txHash: 'idempotent-hash', - }); - }); - - it('supports sequenceNumber-only Horizon responses', async () => { - mockHorizonServer.loadAccount - .mockResolvedValueOnce({ - sequenceNumber: '501', - signers: [ - { key: adopterPublicKey, weight: 1 }, - { key: ownerPublicKey, weight: 1 }, - { key: platformKeypair.publicKey(), weight: 1 }, - ], - thresholds: { low: 1, medium: 2, high: 2 }, - }) - .mockResolvedValueOnce({ - sequenceNumber: '502', - signers: [ - { key: adopterPublicKey, weight: 0 }, - { key: ownerPublicKey, weight: 0 }, - { key: platformKeypair.publicKey(), weight: 3 }, - ], - thresholds: { low: 0, medium: 2, high: 2 }, - }); - - mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'sequence-number-hash' }); - - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).resolves.toMatchObject({ - txHash: 'sequence-number-hash', - platformOnlyMode: true, - }); - }); + it('builds a single payment when one recipient receives 100%', () => { + const operations = releaseFunds('500.0000000', [ + { recipient: RECIPIENT_A, percentage: asPercentage(100) }, + ]); - it('supports top-level threshold keys from Horizon response', async () => { - mockHorizonServer.loadAccount - .mockResolvedValueOnce({ - sequence: '601', - signers: [ - { key: adopterPublicKey, weight: 1 }, - { key: ownerPublicKey, weight: 1 }, - { key: platformKeypair.publicKey(), weight: 1 }, - ], - thresholds: { low: 1, medium: 2, high: 2 }, - }) - .mockResolvedValueOnce({ - sequence: '602', - signers: [ - { key: adopterPublicKey, weight: 0 }, - { key: ownerPublicKey, weight: 0 }, - { key: platformKeypair.publicKey(), weight: 3 }, - ], - low_threshold: 0, - med_threshold: 2, - high_threshold: 2, - }); - - mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'threshold-hash' }); - - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).resolves.toMatchObject({ - txHash: 'threshold-hash', - platformOnlyMode: true, - }); - }); - - it('supports signer keys from ed25519PublicKey field', async () => { - mockHorizonServer.loadAccount - .mockResolvedValueOnce({ - sequence: '651', - signers: [ - { ed25519PublicKey: adopterPublicKey, weight: 1 }, - { ed25519PublicKey: ownerPublicKey, weight: 1 }, - { ed25519PublicKey: platformKeypair.publicKey(), weight: 1 }, - ], - thresholds: { low: 1, medium: 2, high: 2 }, - }) - .mockResolvedValueOnce({ - sequence: '652', - signers: [ - { ed25519PublicKey: adopterPublicKey, weight: 0 }, - { ed25519PublicKey: ownerPublicKey, weight: 0 }, - { ed25519PublicKey: platformKeypair.publicKey(), weight: 3 }, - ], - thresholds: { low: 0, medium: 2, high: 2 }, - }); - - mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'ed25519-fallback-hash' }); - - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).resolves.toMatchObject({ - txHash: 'ed25519-fallback-hash', - platformOnlyMode: true, - }); - }); - - it('handles Account instance from loadAccount', async () => { - mockHorizonServer.loadAccount - .mockResolvedValueOnce(new Account(escrowAccountId, '701')) - .mockResolvedValueOnce({ - sequence: '702', - signers: [{ key: platformKeypair.publicKey(), weight: 3 }], - thresholds: { low: 0, medium: 2, high: 2 }, - }); - - mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'account-instance-hash' }); - - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).resolves.toMatchObject({ - txHash: 'account-instance-hash', - platformOnlyMode: true, - }); - }); - - it('ignores invalid signer entries from Horizon and still succeeds', async () => { - mockHorizonServer.loadAccount - .mockResolvedValueOnce({ - sequence: '801', - signers: [ - { key: adopterPublicKey, weight: 1 }, - { key: ownerPublicKey, weight: 1 }, - { key: platformKeypair.publicKey(), weight: 1 }, - { weight: 1 }, - { key: Keypair.random().publicKey(), weight: Number.NaN }, - ], - thresholds: { low: 1, medium: 2, high: 2 }, - }) - .mockResolvedValueOnce({ - sequence: '802', - signers: [ - { key: adopterPublicKey, weight: 0 }, - { key: ownerPublicKey, weight: 0 }, - { key: platformKeypair.publicKey(), weight: 3 }, - ], - thresholds: { low: 0, medium: 2, high: 2 }, - }); - - mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'invalid-signer-filter-hash' }); - - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).resolves.toMatchObject({ - txHash: 'invalid-signer-filter-hash', - platformOnlyMode: true, - }); - }); - - it('throws when Horizon account response has no sequence value', async () => { - mockHorizonServer.loadAccount.mockResolvedValueOnce({ - signers: [{ key: platformKeypair.publicKey(), weight: 1 }], - thresholds: { low: 1, medium: 2, high: 2 }, - }); - - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).rejects.toThrow('Unable to determine account sequence from Horizon response'); + expect(operations).toEqual([ + { + type: 'Payment', + destination: RECIPIENT_A, + asset: 'XLM', + amount: '500.0000000', + }, + ]); }); - it('throws when post-submit signer verification fails', async () => { - mockHorizonServer.loadAccount - .mockResolvedValueOnce({ - sequence: '301', - signers: [ - { key: adopterPublicKey, weight: 1 }, - { key: ownerPublicKey, weight: 1 }, - { key: platformKeypair.publicKey(), weight: 1 }, - ], - thresholds: { low: 1, medium: 2, high: 2 }, - }) - .mockResolvedValueOnce({ - sequence: '302', - signers: [ - { key: adopterPublicKey, weight: 0 }, - { key: ownerPublicKey, weight: 1 }, - { key: platformKeypair.publicKey(), weight: 3 }, - ], - thresholds: { low: 0, medium: 2, high: 2 }, - }); + 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) }, + ]); - mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'bad-hash' }); - - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).rejects.toThrow('Dispute signer update verification failed'); - }); + 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', + }, + ]); - it('re-throws submitTransaction errors from Horizon', async () => { - mockHorizonServer.loadAccount.mockResolvedValue({ - sequence: '901', - signers: [ - { key: adopterPublicKey, weight: 1 }, - { key: ownerPublicKey, weight: 1 }, - { key: platformKeypair.publicKey(), weight: 1 }, - ], - thresholds: { low: 1, medium: 2, high: 2 }, - }); + const total = operations.reduce((sum, operation) => { + return sum + Number(operation.amount); + }, 0); - mockHorizonServer.submitTransaction.mockRejectedValue(new Error('tx_bad_auth')); - - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).rejects.toThrow('tx_bad_auth'); + expect(total.toFixed(7)).toBe('1.0000000'); }); }); +