Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ class ScriptSyncService {
// 최초 지갑 구독 시 Outgoing Transaction이 있을 경우 UTXO가 생성되지 않을 경우 임의로 UTXO를 생성해야 함
await _utxoSyncService.createOutgoingUtxos(walletItem);

// orphaned UTXO가 있으면 정리함
await _utxoSyncService.cleanupOrphanedUtxos(walletItem);

_stateManager.addWalletCompletedState(walletItem.id, UpdateElement.utxo);

final utxoEndTime = DateTime.now();
Expand Down
18 changes: 18 additions & 0 deletions lib/providers/node_provider/utxo_sync_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,22 @@ class UtxoSyncService {
Logger.error('Stack trace: $stackTrace');
}
}

/// orphaned UTXO를 정리합니다.
Future<void> cleanupOrphanedUtxos(WalletListItemBase walletItem) async {
final pendingUtxos = _utxoRepository.getUtxoStateList(walletItem.id).where((utxo) => utxo.isPending).toList();

final orphanUtxoSet = <UtxoState>{};
for (final utxo in pendingUtxos) {
final tx = _transactionRepository.getTransactionRecord(walletItem.id, utxo.transactionHash);
// tx가 null이거나 컨펌된 트랜잭션이면 orphan UTXO로 간주
if (tx == null || tx.blockHeight > 0) {
orphanUtxoSet.add(utxo);
}
}

if (orphanUtxoSet.isNotEmpty) {
await _utxoRepository.deleteUtxoList(walletItem.id, orphanUtxoSet.map((utxo) => utxo.utxoId).toList());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class UtxoDetailViewModel extends ChangeNotifier {
_selectedUtxoTagList = _tagProvider.getUtxoTagsByUtxoId(_walletId, _utxoId);

_transaction = _txProvider.getTransaction(_walletId, _utxo.transactionHash);
_dateString = DateTimeUtil.formatTimestamp(_transaction!.timestamp);
_dateString = _transaction != null ? DateTimeUtil.formatTimestamp(_transaction.timestamp) : ['-', '-'];

_initUtxoInOutputList();
_syncWalletStateSubscription = _syncWalletStateStream.listen(_onWalletUpdate);
Expand Down
290 changes: 290 additions & 0 deletions test/providers/node_provider/utxo/utxo_sync_service_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import 'package:coconut_wallet/model/utxo/utxo_state.dart';
import 'package:coconut_wallet/model/wallet/singlesig_wallet_list_item.dart';
import 'package:coconut_wallet/providers/node_provider/state/node_state_manager.dart';
import 'package:coconut_wallet/providers/node_provider/utxo_sync_service.dart';
import 'package:coconut_wallet/repository/realm/address_repository.dart';
import 'package:coconut_wallet/repository/realm/model/coconut_wallet_model.dart';
import 'package:coconut_wallet/repository/realm/transaction_repository.dart';
import 'package:coconut_wallet/repository/realm/utxo_repository.dart';
import 'package:coconut_wallet/services/electrum_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';

import '../../../mock/transaction_mock.dart';
import '../../../mock/wallet_mock.dart';
import '../../../repository/realm/test_realm_manager.dart';

// 모킹할 클래스 목록
@GenerateMocks([ElectrumService, NodeStateManager])
import 'utxo_sync_service_test.mocks.dart';

void main() {
late TestRealmManager realmManager;
late TransactionRepository transactionRepository;
late UtxoRepository utxoRepository;
late AddressRepository addressRepository;
late MockElectrumService electrumService;
late MockNodeStateManager stateManager;
late UtxoSyncService utxoSyncService;

const int testWalletId = 1;
final SinglesigWalletListItem testWalletItem = WalletMock.createSingleSigWalletItem();

setUp(() async {
realmManager = await setupTestRealmManager();
transactionRepository = TransactionRepository(realmManager);
addressRepository = AddressRepository(realmManager);
utxoRepository = UtxoRepository(realmManager);
electrumService = MockElectrumService();
stateManager = MockNodeStateManager();

utxoSyncService = UtxoSyncService(
electrumService,
stateManager,
utxoRepository,
transactionRepository,
addressRepository,
);

// 테스트용 지갑 생성
realmManager.realm.write(() {
realmManager.realm.add(RealmWalletBase(testWalletId, 0, 0, 'test_descriptor', 'Test Wallet', 'singleSignature'));
});
});

tearDown(() {
realmManager.reset();
realmManager.dispose();
});

group('cleanupOrphanedUtxos 테스트', () {
test('컨펌된 트랜잭션에 연결된 outgoing UTXO가 정리되는지 확인', () async {
// Given: 컨펌된 트랜잭션과 연결된 outgoing UTXO 생성
const String confirmedTxHash = 'confirmed_tx_hash_123';

// 트랜잭션 레코드 생성 (컨펌됨 - blockHeight > 0)
final confirmedTx = TransactionMock.createConfirmedTransactionRecord(
transactionHash: confirmedTxHash,
blockHeight: 100,
);
await transactionRepository.addAllTransactions(testWalletId, [confirmedTx]);

// Outgoing UTXO 생성
final orphanedUtxo1 = UtxoState(
transactionHash: confirmedTxHash,
index: 0,
amount: 1000000,
derivationPath: "m/84'/0'/0'/0/0",
blockHeight: 0,
to: testWalletItem.walletBase.getAddress(0),
timestamp: DateTime.now(),
status: UtxoStatus.outgoing,
spentByTransactionHash: confirmedTxHash,
);
final orphanedUtxo2 = UtxoState(
transactionHash: confirmedTxHash,
index: 1,
amount: 1000000,
derivationPath: "m/84'/0'/0'/0/1",
blockHeight: 0,
to: testWalletItem.walletBase.getAddress(1),
timestamp: DateTime.now(),
status: UtxoStatus.outgoing,
spentByTransactionHash: confirmedTxHash,
);

await utxoRepository.addAllUtxos(testWalletId, [orphanedUtxo1, orphanedUtxo2]);

// When: cleanupOrphanedUtxos 호출
await utxoSyncService.cleanupOrphanedUtxos(testWalletItem);

// Then: orphaned UTXO가 삭제되었는지 확인
final remainingUtxos = utxoRepository.getUtxoStateList(testWalletId);
expect(remainingUtxos.where((u) => u.utxoId == orphanedUtxo1.utxoId).isEmpty, isTrue);
expect(remainingUtxos.where((u) => u.utxoId == orphanedUtxo2.utxoId).isEmpty, isTrue);
});

test('컨펌된 트랜잭션에 연결된 incoming UTXO가 정리되는지 확인', () async {
// Given: 컨펌된 트랜잭션과 연결된 incoming UTXO 생성
const String confirmedTxHash = 'confirmed_tx_hash_123';

// 트랜잭션 레코드 생성 (컨펌됨 - blockHeight > 0)
final confirmedTx = TransactionMock.createConfirmedTransactionRecord(
transactionHash: confirmedTxHash,
blockHeight: 100,
);
await transactionRepository.addAllTransactions(testWalletId, [confirmedTx]);

// Incoming UTXO 생성
final orphanedUtxo1 = UtxoState(
transactionHash: confirmedTxHash,
index: 0,
amount: 1000000,
derivationPath: "m/84'/0'/0'/0/0",
blockHeight: 0,
to: testWalletItem.walletBase.getAddress(0),
timestamp: DateTime.now(),
status: UtxoStatus.incoming,
spentByTransactionHash: confirmedTxHash,
);
final orphanedUtxo2 = UtxoState(
transactionHash: confirmedTxHash,
index: 1,
amount: 1000000,
derivationPath: "m/84'/0'/0'/0/1",
blockHeight: 0,
to: testWalletItem.walletBase.getAddress(1),
timestamp: DateTime.now(),
status: UtxoStatus.incoming,
spentByTransactionHash: confirmedTxHash,
);

await utxoRepository.addAllUtxos(testWalletId, [orphanedUtxo1, orphanedUtxo2]);

// When: cleanupOrphanedUtxos 호출
await utxoSyncService.cleanupOrphanedUtxos(testWalletItem);

// Then: orphaned UTXO가 삭제되었는지 확인
final remainingUtxos = utxoRepository.getUtxoStateList(testWalletId);
expect(remainingUtxos.where((u) => u.utxoId == orphanedUtxo1.utxoId).isEmpty, isTrue);
expect(remainingUtxos.where((u) => u.utxoId == orphanedUtxo2.utxoId).isEmpty, isTrue);
});

test('존재하지 않는 트랜잭션에 연결된 outgoing UTXO가 정리되는지 확인', () async {
// Given: 존재하지 않는 트랜잭션에 연결된 outgoing UTXO 생성
const String nonExistentTxHash = 'non_existent_tx_hash';

final outgoingUtxo = UtxoState(
transactionHash: nonExistentTxHash,
index: 0,
amount: 500000,
derivationPath: "m/84'/0'/0'/1/0",
blockHeight: 0,
to: testWalletItem.walletBase.getAddress(0),
timestamp: DateTime.now(),
status: UtxoStatus.outgoing,
);

await utxoRepository.addAllUtxos(testWalletId, [outgoingUtxo]);

// 트랜잭션은 DB에 없음 (null 반환)

// When: cleanupOrphanedUtxos 호출
await utxoSyncService.cleanupOrphanedUtxos(testWalletItem);

// Then: orphaned UTXO가 삭제되었는지 확인
final remainingUtxos = utxoRepository.getUtxoStateList(testWalletId);
expect(remainingUtxos.where((u) => u.utxoId == outgoingUtxo.utxoId).isEmpty, isTrue);
});

test('존재하지 않는 트랜잭션에 연결된 incoming UTXO가 정리되는지 확인', () async {
// Given: 존재하지 않는 트랜잭션에 연결된 incoming UTXO 생성
const String nonExistentTxHash = 'non_existent_tx_hash';

final incomingUtxo = UtxoState(
transactionHash: nonExistentTxHash,
index: 0,
amount: 500000,
derivationPath: "m/84'/0'/0'/1/0",
blockHeight: 0,
to: testWalletItem.walletBase.getAddress(0),
timestamp: DateTime.now(),
status: UtxoStatus.incoming,
);

await utxoRepository.addAllUtxos(testWalletId, [incomingUtxo]);

// 트랜잭션은 DB에 없음 (null 반환)

// When: cleanupOrphanedUtxos 호출
await utxoSyncService.cleanupOrphanedUtxos(testWalletItem);

// Then: orphaned UTXO가 삭제되었는지 확인
final remainingUtxos = utxoRepository.getUtxoStateList(testWalletId);
expect(remainingUtxos.where((u) => u.utxoId == incomingUtxo.utxoId).isEmpty, isTrue);
});

test('정상적인 pending UTXO가 남아있는지 확인', () async {
// Given: 펜딩 트랜잭션과 연결된 outgoing, incoming UTXO 생성
const String pendingTxHash = 'unconfirmed_tx_hash_789';

final unconfirmedTx = TransactionMock.createMockTransactionRecord(
transactionHash: pendingTxHash,
blockHeight: 0, // 언컨펌
);

await transactionRepository.addAllTransactions(testWalletId, [unconfirmedTx]);

final validOutgoingUtxo = UtxoState(
transactionHash: pendingTxHash,
index: 0,
amount: 2000000,
derivationPath: "m/84'/0'/0'/0/1",
blockHeight: -1,
to: testWalletItem.walletBase.getAddress(0),
timestamp: DateTime.now(),
status: UtxoStatus.outgoing,
spentByTransactionHash: pendingTxHash,
);

final validIncomingUtxo = UtxoState(
transactionHash: pendingTxHash,
index: 1,
amount: 2000000,
derivationPath: "m/84'/0'/0'/1/1",
blockHeight: -1,
to: testWalletItem.walletBase.getAddress(1),
timestamp: DateTime.now(),
status: UtxoStatus.incoming,
);

await utxoRepository.addAllUtxos(testWalletId, [validOutgoingUtxo, validIncomingUtxo]);

// When: cleanupOrphanedUtxos 호출
await utxoSyncService.cleanupOrphanedUtxos(testWalletItem);

// Then: 정상적인 pending UTXO는 유지되는지 확인
final remainingUtxos = utxoRepository.getUtxoStateList(testWalletId);
expect(remainingUtxos.where((u) => u.utxoId == validOutgoingUtxo.utxoId).isNotEmpty, isTrue);
expect(remainingUtxos.where((u) => u.utxoId == validIncomingUtxo.utxoId).isNotEmpty, isTrue);
});

test('여러 orphaned UTXO가 한 번에 정리되는지 확인', () async {
// Given: 여러 orphaned UTXO 생성
final orphanedUtxos = <UtxoState>[];

for (int i = 0; i < 3; i++) {
final confirmedTx = TransactionMock.createConfirmedTransactionRecord(
transactionHash: 'confirmed_tx_$i',
blockHeight: 100 + i,
);
await transactionRepository.addAllTransactions(testWalletId, [confirmedTx]);

orphanedUtxos.add(
UtxoState(
transactionHash: confirmedTx.transactionHash,
index: 0,
amount: 1000000 * (i + 1),
derivationPath: "m/84'/0'/0'/0/$i",
blockHeight: 0,
to: testWalletItem.walletBase.getAddress(0),
timestamp: DateTime.now(),
status: UtxoStatus.outgoing,
spentByTransactionHash: 'confirmed_tx_$i',
),
);
}

await utxoRepository.addAllUtxos(testWalletId, orphanedUtxos);

// When: cleanupOrphanedUtxos 호출
await utxoSyncService.cleanupOrphanedUtxos(testWalletItem);

// Then: 모든 orphaned UTXO가 삭제되었는지 확인
final remainingUtxos = utxoRepository.getUtxoStateList(testWalletId);
for (final orphanedUtxo in orphanedUtxos) {
expect(remainingUtxos.where((u) => u.utxoId == orphanedUtxo.utxoId).isEmpty, isTrue);
}
});
});
}