From 3a082fc88dadc674664fc786c6161e3fb80cb0f6 Mon Sep 17 00:00:00 2001 From: Andreschuks101 Date: Fri, 27 Mar 2026 02:06:37 +0100 Subject: [PATCH] feat(account): implement mergeAccount with validation and trustline handling - Accept escrowAccountId and destinationAccountId params - Validate both public keys and master secret key - Build AccountMerge operation from escrow account - Sign with masterSecretKey (platform holds medium threshold weight) - Submit and return mergedAccountId and txHash - Throw TrustlineError when escrow has non-native trustlines - Add unit tests for validation, trustline error, and merge result Co-Authored-By: Claude Opus 4.6 --- src/accounts/index.ts | 2 + src/accounts/merge.ts | 91 ++++++++++++++++++ src/index.ts | 3 +- tests/unit/accounts/merge.test.ts | 149 ++++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 src/accounts/merge.ts create mode 100644 tests/unit/accounts/merge.test.ts diff --git a/src/accounts/index.ts b/src/accounts/index.ts index 90133d3..bf354c0 100644 --- a/src/accounts/index.ts +++ b/src/accounts/index.ts @@ -1 +1,3 @@ export { getMinimumReserve } from './keypair'; +export { mergeAccount, TrustlineError } from './merge'; +export type { MergeAccountParams, MergeAccountResult } from './merge'; diff --git a/src/accounts/merge.ts b/src/accounts/merge.ts new file mode 100644 index 0000000..db97a0b --- /dev/null +++ b/src/accounts/merge.ts @@ -0,0 +1,91 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { ValidationError } from '../utils/errors'; +import { isValidPublicKey, isValidSecretKey } from '../utils/validation'; +import { logger } from '../utils/logger'; + +export interface MergeAccountParams { + escrowAccountId: string; + destinationAccountId: string; +} + +export interface MergeAccountResult { + mergedAccountId: string; + txHash: string; +} + +export class TrustlineError extends Error { + constructor(public readonly accountId: string) { + super( + `Cannot merge account ${accountId}: account has non-native trustlines that must be removed first`, + ); + this.name = 'TrustlineError'; + } +} + +/** + * Merge a settled escrow account back into the destination account + * to recover the minimum reserve XLM. + * + * @throws {ValidationError} if keys are invalid + * @throws {TrustlineError} if the escrow account has non-native trustlines + */ +export async function mergeAccount( + params: MergeAccountParams, + masterSecretKey: string, + horizonUrl: string, + networkPassphrase: string, +): Promise { + const { escrowAccountId, destinationAccountId } = params; + + // Validate keys + if (!isValidPublicKey(escrowAccountId)) { + throw new ValidationError('escrowAccountId', 'Invalid escrow account public key'); + } + if (!isValidPublicKey(destinationAccountId)) { + throw new ValidationError('destinationAccountId', 'Invalid destination account public key'); + } + if (!isValidSecretKey(masterSecretKey)) { + throw new ValidationError('masterSecretKey', 'Invalid master secret key'); + } + + const server = new StellarSdk.Horizon.Server(horizonUrl); + const masterKeypair = StellarSdk.Keypair.fromSecret(masterSecretKey); + + // Load escrow account to check for non-native trustlines + const escrowAccount = await server.loadAccount(escrowAccountId); + const nonNativeTrustlines = escrowAccount.balances.filter( + (b: StellarSdk.Horizon.HorizonApi.BalanceLine) => b.asset_type !== 'native', + ); + + if (nonNativeTrustlines.length > 0) { + throw new TrustlineError(escrowAccountId); + } + + logger.info(`Merging escrow ${escrowAccountId} into ${destinationAccountId}`); + + // Build AccountMerge transaction + const tx = new StellarSdk.TransactionBuilder(escrowAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }) + .addOperation( + StellarSdk.Operation.accountMerge({ + destination: destinationAccountId, + }), + ) + .setTimeout(180) + .build(); + + // Sign with master key (platform holds weight for medium threshold) + tx.sign(masterKeypair); + + // Submit + const result = await server.submitTransaction(tx); + + logger.info(`Account merge successful: ${result.hash}`); + + return { + mergedAccountId: escrowAccountId, + txHash: result.hash, + }; +} diff --git a/src/index.ts b/src/index.ts index a2007d7..6d54462 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,4 +31,5 @@ export type { SubmitResult, TransactionStatus } from './types/transaction'; // 6. Standalone functions export { createEscrowAccount, lockCustodyFunds, anchorTrustHash, verifyEventHash } from './escrow'; export { buildMultisigTransaction } from './transactions'; -export { getMinimumReserve } from './accounts'; +export { getMinimumReserve, mergeAccount, TrustlineError } from './accounts'; +export type { MergeAccountParams, MergeAccountResult } from './accounts'; diff --git a/tests/unit/accounts/merge.test.ts b/tests/unit/accounts/merge.test.ts new file mode 100644 index 0000000..f5917a8 --- /dev/null +++ b/tests/unit/accounts/merge.test.ts @@ -0,0 +1,149 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { mergeAccount, TrustlineError } from '../../../src/accounts/merge'; +import { ValidationError } from '../../../src/utils/errors'; + +// Generate valid Stellar keypairs for testing +const masterKeypair = StellarSdk.Keypair.random(); +const escrowKeypair = StellarSdk.Keypair.random(); +const destinationKeypair = StellarSdk.Keypair.random(); + +const MASTER_SECRET = masterKeypair.secret(); +const ESCROW_PUBLIC = escrowKeypair.publicKey(); +const DESTINATION_PUBLIC = destinationKeypair.publicKey(); +const HORIZON_URL = 'https://horizon-testnet.stellar.org'; +const NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015'; + +// Mock the Stellar SDK Horizon server +jest.mock('@stellar/stellar-sdk', () => { + const actual = jest.requireActual('@stellar/stellar-sdk'); + return { + ...actual, + Horizon: { + ...actual.Horizon, + Server: jest.fn(), + }, + }; +}); + +describe('mergeAccount', () => { + let mockServer: { + loadAccount: jest.Mock; + submitTransaction: jest.Mock; + }; + + beforeEach(() => { + mockServer = { + loadAccount: jest.fn(), + submitTransaction: jest.fn(), + }; + (StellarSdk.Horizon.Server as unknown as jest.Mock).mockImplementation(() => mockServer); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should validate escrowAccountId', async () => { + await expect( + mergeAccount( + { escrowAccountId: 'invalid', destinationAccountId: DESTINATION_PUBLIC }, + MASTER_SECRET, + HORIZON_URL, + NETWORK_PASSPHRASE, + ), + ).rejects.toThrow(ValidationError); + }); + + it('should validate destinationAccountId', async () => { + await expect( + mergeAccount( + { escrowAccountId: ESCROW_PUBLIC, destinationAccountId: 'invalid' }, + MASTER_SECRET, + HORIZON_URL, + NETWORK_PASSPHRASE, + ), + ).rejects.toThrow(ValidationError); + }); + + it('should validate masterSecretKey', async () => { + await expect( + mergeAccount( + { escrowAccountId: ESCROW_PUBLIC, destinationAccountId: DESTINATION_PUBLIC }, + 'invalid', + HORIZON_URL, + NETWORK_PASSPHRASE, + ), + ).rejects.toThrow(ValidationError); + }); + + it('should throw TrustlineError when escrow has non-native trustlines', async () => { + mockServer.loadAccount.mockResolvedValue({ + accountId: () => ESCROW_PUBLIC, + sequenceNumber: () => '1', + balances: [ + { asset_type: 'native', balance: '100' }, + { asset_type: 'credit_alphanum4', balance: '50', asset_code: 'USD', asset_issuer: DESTINATION_PUBLIC }, + ], + incrementSequenceNumber: jest.fn(), + }); + + await expect( + mergeAccount( + { escrowAccountId: ESCROW_PUBLIC, destinationAccountId: DESTINATION_PUBLIC }, + MASTER_SECRET, + HORIZON_URL, + NETWORK_PASSPHRASE, + ), + ).rejects.toThrow(TrustlineError); + }); + + it('should merge account and return correct result', async () => { + const mockAccount = new StellarSdk.Account(ESCROW_PUBLIC, '1'); + Object.assign(mockAccount, { + balances: [{ asset_type: 'native', balance: '100' }], + }); + + mockServer.loadAccount.mockResolvedValue(mockAccount); + mockServer.submitTransaction.mockResolvedValue({ + hash: 'abc123txhash', + ledger: 42, + }); + + const result = await mergeAccount( + { escrowAccountId: ESCROW_PUBLIC, destinationAccountId: DESTINATION_PUBLIC }, + MASTER_SECRET, + HORIZON_URL, + NETWORK_PASSPHRASE, + ); + + expect(result.mergedAccountId).toBe(ESCROW_PUBLIC); + expect(result.txHash).toBe('abc123txhash'); + expect(mockServer.submitTransaction).toHaveBeenCalledTimes(1); + }); + + it('should set correct merge destination in the transaction', async () => { + const mockAccount = new StellarSdk.Account(ESCROW_PUBLIC, '1'); + Object.assign(mockAccount, { + balances: [{ asset_type: 'native', balance: '100' }], + }); + + mockServer.loadAccount.mockResolvedValue(mockAccount); + mockServer.submitTransaction.mockResolvedValue({ + hash: 'txhash456', + ledger: 50, + }); + + await mergeAccount( + { escrowAccountId: ESCROW_PUBLIC, destinationAccountId: DESTINATION_PUBLIC }, + MASTER_SECRET, + HORIZON_URL, + NETWORK_PASSPHRASE, + ); + + // Verify the transaction was submitted with the correct destination + const submittedTx = mockServer.submitTransaction.mock.calls[0][0] as StellarSdk.Transaction; + expect(submittedTx.operations).toHaveLength(1); + expect(submittedTx.operations[0].type).toBe('accountMerge'); + expect((submittedTx.operations[0] as StellarSdk.Operation.AccountMerge).destination).toBe(DESTINATION_PUBLIC); + }); +});