diff --git a/src/escrow/index.ts b/src/escrow/index.ts index b06d39f..7f38f08 100644 --- a/src/escrow/index.ts +++ b/src/escrow/index.ts @@ -1,467 +1,382 @@ import { + Account, + Asset, + Horizon, Keypair, Memo, + Operation as StellarOperation, TransactionBuilder, - Operation, - Networks, - BASE_FEE, - Account, - Transaction, } from '@stellar/stellar-sdk'; - +import { Distribution, ReleaseParams, ReleaseResult } 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'; - -import crypto from 'crypto'; - -// ─── 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 }>; + 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; +}; +type SubmissionTransaction = Parameters[0]; + +interface HorizonSubmission { + successful: boolean; + hash: string; + ledger: number; } -export interface EscrowAccountManager { - create: (args: { - publicKey: string; - startingBalance: string; - }) => Promise<{ accountId: string; transactionHash: string }>; - getBalance: (publicKey: string) => Promise; +interface ReleaseServer { + loadAccount(accountId: string): Promise; + submitTransaction(transaction: SubmissionTransaction): 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; +interface ReleaseTransactionManager { + submit(transaction: SubmissionTransaction): Promise; } -export interface EscrowManagerDependencies { - horizonClient: EscrowHorizonClient; - accountManager: EscrowAccountManager; - transactionManager: EscrowTransactionManager; - masterSecretKey: string; +interface ReleaseFundsDependencies { + server?: ReleaseServer; + transactionManager?: ReleaseTransactionManager; + sleep?: (ms: number) => Promise; + horizonUrl?: string; + networkPassphrase?: string; + maxSubmitAttempts?: number; } -export interface HandleDisputeParams extends DisputeParams { - masterSecretKey: string; -} +function amountToStroops(amount: string): bigint { + const [wholePart, fractionalPart = ''] = amount.split('.'); + const normalizedFraction = `${fractionalPart}0000000`.slice(0, 7); -interface HorizonSignerLike { - key?: string; - publicKey?: string; - ed25519PublicKey?: string; - weight: number; + return ( + BigInt(wholePart || '0') * STROOPS_PER_XLM + + BigInt(normalizedFraction || '0') + ); } -interface HorizonThresholdsLike { - low?: number; - medium?: number; - high?: number; - low_threshold?: number; - med_threshold?: number; - high_threshold?: 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 HorizonAccountLike { - sequence?: string; - sequenceNumber?: string; - signers?: HorizonSignerLike[]; - thresholds?: HorizonThresholdsLike; - low_threshold?: number; - med_threshold?: number; - high_threshold?: number; +function scalePercentage(percentage: number): bigint { + return BigInt(percentage.toFixed(7).replace('.', '')); } -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'); -} +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, + ); + } -export function memoFromHash(hash: string): string { - return hash.slice(0, 28); + return nativeBalance.balance; } -// ─── calculateStartingBalance ───────────────────────────────────────────────── +function validateReleaseParams(params: ReleaseParams): void { + if (!isValidPublicKey(params.escrowAccountId)) { + throw new ValidationError('escrowAccountId', 'Invalid escrow account public key'); + } -export function calculateStartingBalance(depositAmount: string): string { - if (!isValidAmount(depositAmount)) { + if (!isValidDistribution(params.distribution)) { throw new ValidationError( - 'depositAmount', - `Invalid deposit amount: ${depositAmount}`, + 'distribution', + 'Distribution must contain valid recipients and total 100%', ); } - const minimumReserve = parseFloat(getMinimumReserve(3, 0, 0)); - const deposit = parseFloat(depositAmount); - const totalBalance = minimumReserve + deposit; - - return totalBalance.toFixed(7).replace(/\.?0+$/, ''); -} - -// ─── 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'); + if (params.balance !== undefined && !isValidAmount(params.balance)) { + throw new ValidationError('balance', 'Release balance must be a positive XLM amount'); } - if (!isValidPublicKey(params.ownerPublicKey)) { - throw new ValidationError('ownerPublicKey', 'Invalid public key'); + if (params.masterSecretKey !== undefined && !isValidSecretKey(params.masterSecretKey)) { + throw new ValidationError('masterSecretKey', 'Invalid master secret key'); } - if (!isValidAmount(params.depositAmount)) { - throw new ValidationError('depositAmount', 'Invalid amount'); + if (params.sourceSecretKey !== undefined && !isValidSecretKey(params.sourceSecretKey)) { + throw new ValidationError('sourceSecretKey', 'Invalid source secret key'); } - - const escrowKeypair = Keypair.random(); - const startingBalance = calculateStartingBalance(params.depositAmount); - - const result = await accountManager.create({ - publicKey: escrowKeypair.publicKey(), - startingBalance, - }); - - 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 ───────────────────────────────────────────────────────── - -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, +function buildReleasePaymentOperations( + 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 unlockDate = new Date(Date.now() + durationDays * MS_PER_DAY); + const allocatedStroops = calculatedShares.reduce( + (sum, share) => sum + share.baseStroops, + 0n, + ); + let remainingStroops = totalStroops - allocatedStroops; - 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 bonusRecipients = [...calculatedShares].sort((left, right) => { + if (left.remainder === right.remainder) { + return left.index - right.index; + } + return left.remainder > right.remainder ? -1 : 1; + }); - const result = await horizonServer.submitTransaction(tx); + for (let i = 0; remainingStroops > 0n; i += 1) { + bonusRecipients[i].baseStroops += 1n; + remainingStroops -= 1n; + } - return { - unlockDate, - conditionsHash, - escrowPublicKey: escrowKeypair.publicKey(), - transactionHash: result.hash, - }; + return calculatedShares.map(share => ({ + type: 'Payment', + destination: share.recipient, + asset: 'XLM', + amount: stroopsToAmount(share.baseStroops), + })); } -function getSequence(account: Account | HorizonAccountLike): string { - const loaded = account as HorizonAccountLike; - - if (typeof loaded.sequence === 'string' && loaded.sequence.length > 0) { - return loaded.sequence; - } +function paymentOperationToStellar(payment: PaymentOp) { + return StellarOperation.payment({ + destination: payment.destination, + asset: Asset.native(), + amount: payment.amount, + }); +} - if (typeof loaded.sequenceNumber === 'string' && loaded.sequenceNumber.length > 0) { - return loaded.sequenceNumber; +function getDefaultNetworkPassphrase(): string { + if (process.env.STELLAR_NETWORK_PASSPHRASE) { + return process.env.STELLAR_NETWORK_PASSPHRASE; } - throw new Error('Unable to determine account sequence from Horizon response'); + return process.env.STELLAR_NETWORK === 'public' + ? MAINNET_PASSPHRASE + : TESTNET_PASSPHRASE; } -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; - } +function getDefaultHorizonUrl(): string { + if (process.env.STELLAR_HORIZON_URL) { + return process.env.STELLAR_HORIZON_URL; } - return 0; + return process.env.STELLAR_NETWORK === 'public' + ? MAINNET_HORIZON_URL + : TESTNET_HORIZON_URL; } -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); +function getMasterSecretKey(params: ReleaseParams): string { + const masterSecretKey = + params.masterSecretKey ?? + params.sourceSecretKey ?? + process.env.MASTER_SECRET_KEY; + if (!masterSecretKey) { + throw new ValidationError( + 'masterSecretKey', + 'A master secret key is required to sign the release transaction', + ); + } - if (!publicKey || !Number.isFinite(weight)) { - return null; - } + if (!isValidSecretKey(masterSecretKey)) { + throw new ValidationError('masterSecretKey', 'Invalid master secret key'); + } - return { - publicKey, - weight, - }; - }) - .filter((signer): signer is Signer => signer !== null); + return masterSecretKey; } -function getAccountThresholds(account: Account | HorizonAccountLike): Thresholds { - const loaded = account as HorizonAccountLike; - const fromNested = loaded.thresholds ?? {}; +function mapToSdkError(error: unknown, escrowAccountId: string): SdkError { + if (error instanceof SdkError) { + return error; + } - 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), + const maybeError = error as { + response?: { status?: number; data?: { extras?: { result_codes?: { transaction?: string; operations?: string[] } } } }; + extras?: { result_codes?: { transaction?: string; operations?: string[] } }; + message?: string; }; -} -function isPlatformOnlyConfig(account: Account | HorizonAccountLike, platformPublicKey: string): boolean { - const thresholds = getAccountThresholds(account); - const activeSigners = getAccountSigners(account).filter(signer => signer.weight > 0); + if (maybeError.response?.status === 404) { + return new EscrowNotFoundError(escrowAccountId); + } - if (activeSigners.length !== 1) return false; - if (activeSigners[0].publicKey !== platformPublicKey) return false; - if (activeSigners[0].weight !== 3) return false; + const resultCodes = + maybeError.response?.data?.extras?.result_codes ?? + maybeError.extras?.result_codes; + if (resultCodes?.transaction) { + return new HorizonSubmitError( + resultCodes.transaction, + resultCodes.operations ?? [], + ); + } - return thresholds.low === 0 && thresholds.medium === 2 && thresholds.high === 2; + return new SdkError( + maybeError.message ?? 'Failed to release funds', + 'RELEASE_FUNDS_FAILED', + false, + ); } -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'); +async function loadEscrowAccount( + server: Pick, + escrowAccountId: string, +): Promise { + try { + return await server.loadAccount(escrowAccountId); + } catch (error) { + throw mapToSdkError(error, escrowAccountId); } +} - if (!isValidSecretKey(masterSecretKey)) { - throw new ValidationError('masterSecretKey', 'Invalid master secret key'); +async function submitReleaseTransaction( + transactionManager: ReleaseTransactionManager, + account: HorizonAccount, + params: ReleaseParams, + payments: PaymentOp[], + networkPassphrase: string, +): Promise { + const masterSecretKey = getMasterSecretKey(params); + const platformKeypair = Keypair.fromSecret(masterSecretKey); + 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)); } - let platformKeypair: Keypair; + const transaction = builder.setTimeout(timeoutSeconds).build(); + + transaction.sign(platformKeypair); + try { - platformKeypair = Keypair.fromSecret(masterSecretKey); - } catch { - throw new ValidationError('masterSecretKey', 'Invalid master secret key'); + const submission = await transactionManager.submit(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); } +} - 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, - }, - }), +/** + * 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 transactionManager = + dependencies.transactionManager ?? { + submit: (transaction: SubmissionTransaction) => + server.submitTransaction(transaction), + }; + 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, ); - }); - - 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); + return await submitReleaseTransaction( + transactionManager, + account, + { ...params, balance: releaseBalance }, + payments, + networkPassphrase, + ); + } catch (error) { + const sdkError = mapToSdkError(error, params.escrowAccountId); + lastError = sdkError; - const submitResult = await horizonServer.submitTransaction(tx); + if (!sdkError.retryable || attempt === maxSubmitAttempts) { + throw sdkError; + } - const updatedConfig = await horizonServer.loadAccount(escrowAccountId); - if (!isPlatformOnlyConfig(updatedConfig, platformPublicKey)) { - throw new Error('Dispute signer update verification failed'); + await sleep(0); + } } - return { - accountId: escrowAccountId, - pausedAt: new Date(), - platformOnlyMode: true, - txHash: submitResult.hash, - }; + throw lastError ?? new SdkError('Failed to release funds', 'RELEASE_FUNDS_FAILED', false); +} + +// 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/src/types/escrow.ts b/src/types/escrow.ts index 6dcddf4..534da8b 100644 --- a/src/types/escrow.ts +++ b/src/types/escrow.ts @@ -66,6 +66,12 @@ export interface Distribution { export interface ReleaseParams { escrowAccountId: string; distribution: Distribution[]; + balance?: string; + masterSecretKey?: 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 aa04a5e..eb7893d 100644 --- a/tests/unit/escrow/index.test.ts +++ b/tests/unit/escrow/index.test.ts @@ -1,14 +1,45 @@ +import { + Account, + Keypair, +} from '@stellar/stellar-sdk'; import { createEscrowAccount, calculateStartingBalance, 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'; +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 }], + }); +} + +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, + })), + }; +} + +function createTransactionManager() { + return { + submit: jest.fn(async (transaction: unknown) => ({ + successful: true, + hash: 'tx-hash-123', + ledger: 123456, + transaction, + })), + }; +} describe('calculateStartingBalance', () => { describe('happy path', () => { @@ -191,419 +222,200 @@ 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('releases the full fetched balance on the happy path', async () => { + const escrow = Keypair.random(); + const master = Keypair.random(); + const recipient = Keypair.random(); + const server = createServer('500.0000000'); + const transactionManager = createTransactionManager(); - const result = await handleDispute( + const result = await releaseFunds( { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), + escrowAccountId: escrow.publicKey(), + masterSecretKey: master.secret(), + distribution: [ + { recipient: recipient.publicKey(), percentage: asPercentage(100) }, + ], }, - mockHorizonServer, + { server, transactionManager }, ); - 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, - }, - }); - - expect(setOptionsSpy).toHaveBeenCalledWith({ - source: escrowAccountId, - signer: { - ed25519PublicKey: platformKeypair.publicKey(), - weight: 3, - }, + expect(result).toEqual({ + successful: true, + txHash: 'tx-hash-123', + ledger: 123456, + payments: [ + { recipient: recipient.publicKey(), amount: '500.0000000' }, + ], }); - expect(setOptionsSpy).toHaveBeenCalledWith({ - source: escrowAccountId, - masterWeight: 0, - lowThreshold: 0, - medThreshold: 2, - highThreshold: 2, + const submittedTransaction = transactionManager.submit.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', }); + expect(transactionManager.submit).toHaveBeenCalledTimes(1); + expect(server.submitTransaction).not.toHaveBeenCalled(); }); - 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( + it('uses masterSecretKey for signing and keeps payment amounts aligned with distribution', async () => { + const escrow = Keypair.random(); + const master = Keypair.random(); + const recipientA = Keypair.random(); + const recipientB = Keypair.random(); + const server = createServer('999.0000000'); + const transactionManager = createTransactionManager(); + const fromSecretSpy = jest.spyOn(Keypair, 'fromSecret'); + + try { + const result = await releaseFunds( { - escrowAccountId, - masterSecretKey: 'invalid', + escrowAccountId: escrow.publicKey(), + masterSecretKey: master.secret(), + balance: '500.0000000', + distribution: [ + { recipient: recipientA.publicKey(), percentage: asPercentage(60) }, + { recipient: recipientB.publicKey(), percentage: asPercentage(40) }, + ], }, - 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); - }); + { server, transactionManager }, + ); - 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 }, + expect(result.payments).toEqual([ + { recipient: recipientA.publicKey(), amount: '300.0000000' }, + { recipient: recipientB.publicKey(), amount: '200.0000000' }, + ]); + + const submittedTransaction = transactionManager.submit.mock.calls[0]?.[0] as { + operations: Array>; + signatures: unknown[]; + }; + expect(submittedTransaction.operations).toHaveLength(2); + expect(submittedTransaction.operations[0]).toMatchObject({ + type: 'payment', + destination: recipientA.publicKey(), + amount: '300.0000000', }); - - 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 }, + expect(submittedTransaction.operations[1]).toMatchObject({ + type: 'payment', + destination: recipientB.publicKey(), + amount: '200.0000000', }); - - mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'sequence-number-hash' }); - - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).resolves.toMatchObject({ - txHash: 'sequence-number-hash', - platformOnlyMode: true, - }); + expect(submittedTransaction.signatures.length).toBe(1); + expect(fromSecretSpy).toHaveBeenCalledWith(master.secret()); + } finally { + fromSecretSpy.mockRestore(); + } }); - 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 a 100% refund to a single recipient', async () => { + const escrow = Keypair.random(); + const master = Keypair.random(); + const refundRecipient = Keypair.random(); + const server = createServer('999.0000000'); + const transactionManager = createTransactionManager(); - 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 }, + const result = await releaseFunds( + { + escrowAccountId: escrow.publicKey(), + masterSecretKey: master.secret(), + balance: '125.0000000', + distribution: [ + { recipient: refundRecipient.publicKey(), percentage: asPercentage(100) }, ], - thresholds: { low: 0, medium: 2, high: 2 }, - }); - - mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'ed25519-fallback-hash' }); + }, + { server, transactionManager }, + ); - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).resolves.toMatchObject({ - txHash: 'ed25519-fallback-hash', - platformOnlyMode: true, - }); + expect(result.payments).toEqual([ + { recipient: refundRecipient.publicKey(), amount: '125.0000000' }, + ]); + expect(server.loadAccount).toHaveBeenCalledTimes(1); + expect(transactionManager.submit).toHaveBeenCalledTimes(1); }); - 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, + it('does not retry when submit fails with a non-retryable SdkError', async () => { + const escrow = Keypair.random(); + const master = Keypair.random(); + const recipient = Keypair.random(); + const server = { + loadAccount: jest.fn(async (accountId: string) => + createHorizonAccount(accountId, '500.0000000'), ), - ).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' }); + submitTransaction: jest.fn(), + }; + const transactionManager = { + submit: jest.fn(async (_transaction: unknown) => { + throw new HorizonSubmitError('tx_bad_auth'); + }), + }; await expect( - handleDispute( + releaseFunds( { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), + escrowAccountId: escrow.publicKey(), + masterSecretKey: master.secret(), + balance: '500.0000000', + distribution: [ + { recipient: recipient.publicKey(), percentage: asPercentage(100) }, + ], }, - mockHorizonServer, + { server, transactionManager, maxSubmitAttempts: 3 }, ), - ).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 }, + ).rejects.toMatchObject({ + code: 'HORIZON_SUBMIT_ERROR', + retryable: false, + resultCode: 'tx_bad_auth', }); - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).rejects.toThrow('Unable to determine account sequence from Horizon response'); + expect(server.loadAccount).toHaveBeenCalledTimes(1); + expect(transactionManager.submit).toHaveBeenCalledTimes(1); }); - 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 }, - }); - - mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'bad-hash' }); + it('surfaces op_no_destination as a HorizonSubmitError with operation codes intact', async () => { + const escrow = Keypair.random(); + const master = Keypair.random(); + const recipient = Keypair.random(); + const server = createServer('500.0000000'); + const transactionManager = { + submit: jest.fn(async (_transaction: unknown) => { + throw { + response: { + data: { + extras: { + result_codes: { + transaction: 'tx_failed', + operations: ['op_no_destination'], + }, + }, + }, + }, + }; + }), + }; await expect( - handleDispute( + releaseFunds( { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), + escrowAccountId: escrow.publicKey(), + masterSecretKey: master.secret(), + balance: '500.0000000', + distribution: [ + { recipient: recipient.publicKey(), percentage: asPercentage(100) }, + ], }, - mockHorizonServer, + { server, transactionManager, maxSubmitAttempts: 1 }, ), - ).rejects.toThrow('Dispute signer update verification failed'); - }); - - 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 }, + ).rejects.toMatchObject({ + code: 'HORIZON_SUBMIT_ERROR', + resultCode: 'tx_failed', + operationCodes: ['op_no_destination'], }); - - mockHorizonServer.submitTransaction.mockRejectedValue(new Error('tx_bad_auth')); - - await expect( - handleDispute( - { - escrowAccountId, - masterSecretKey: platformKeypair.secret(), - }, - mockHorizonServer, - ), - ).rejects.toThrow('tx_bad_auth'); }); }); +