Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/accounts/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { getMinimumReserve } from './keypair';
export { mergeAccount, TrustlineError } from './merge';
export type { MergeAccountParams, MergeAccountResult } from './merge';
91 changes: 91 additions & 0 deletions src/accounts/merge.ts
Original file line number Diff line number Diff line change
@@ -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<MergeAccountResult> {
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,
};
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
149 changes: 149 additions & 0 deletions tests/unit/accounts/merge.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading