diff --git a/application/src/main/java/de/cotto/lndmanagej/service/OpenInitiatorResolverImpl.java b/application/src/main/java/de/cotto/lndmanagej/service/OpenInitiatorResolverImpl.java new file mode 100644 index 00000000..5055ebd8 --- /dev/null +++ b/application/src/main/java/de/cotto/lndmanagej/service/OpenInitiatorResolverImpl.java @@ -0,0 +1,27 @@ +package de.cotto.lndmanagej.service; + +import de.cotto.lndmanagej.model.OpenInitiator; +import de.cotto.lndmanagej.model.OpenInitiatorResolver; +import de.cotto.lndmanagej.transactions.service.TransactionService; +import org.springframework.stereotype.Component; + +@Component +public class OpenInitiatorResolverImpl implements OpenInitiatorResolver { + private final TransactionService transactionService; + + public OpenInitiatorResolverImpl(TransactionService transactionService) { + this.transactionService = transactionService; + } + + @Override + public OpenInitiator resolveFromOpenTransactionHash(String transactionHash) { + Boolean knownByLnd = transactionService.isKnownByLnd(transactionHash).orElse(null); + if (knownByLnd == null) { + return OpenInitiator.UNKNOWN; + } + if (knownByLnd) { + return OpenInitiator.LOCAL; + } + return OpenInitiator.REMOTE; + } +} diff --git a/application/src/test/java/de/cotto/lndmanagej/service/OpenInitiatorResolverImplTest.java b/application/src/test/java/de/cotto/lndmanagej/service/OpenInitiatorResolverImplTest.java new file mode 100644 index 00000000..53e43de2 --- /dev/null +++ b/application/src/test/java/de/cotto/lndmanagej/service/OpenInitiatorResolverImplTest.java @@ -0,0 +1,45 @@ +package de.cotto.lndmanagej.service; + +import de.cotto.lndmanagej.model.OpenInitiator; +import de.cotto.lndmanagej.transactions.service.TransactionService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static de.cotto.lndmanagej.model.ChannelPointFixtures.TRANSACTION_HASH; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OpenInitiatorResolverImplTest { + @InjectMocks + private OpenInitiatorResolverImpl openInitiatorResolverImpl; + + @Mock + private TransactionService transactionService; + + @Test + void transaction_not_known() { + when(transactionService.isKnownByLnd(TRANSACTION_HASH)).thenReturn(Optional.of(false)); + assertThat(openInitiatorResolverImpl.resolveFromOpenTransactionHash(TRANSACTION_HASH)) + .isEqualTo(OpenInitiator.REMOTE); + } + + @Test + void transaction_known() { + when(transactionService.isKnownByLnd(TRANSACTION_HASH)).thenReturn(Optional.of(true)); + assertThat(openInitiatorResolverImpl.resolveFromOpenTransactionHash(TRANSACTION_HASH)) + .isEqualTo(OpenInitiator.LOCAL); + } + + @Test + void unable_to_determine_transaction_status() { + when(transactionService.isKnownByLnd(TRANSACTION_HASH)).thenReturn(Optional.empty()); + assertThat(openInitiatorResolverImpl.resolveFromOpenTransactionHash(TRANSACTION_HASH)) + .isEqualTo(OpenInitiator.UNKNOWN); + } +} \ No newline at end of file diff --git a/grpc-adapter/build.gradle b/grpc-adapter/build.gradle index 9b38ce96..e6594bf8 100644 --- a/grpc-adapter/build.gradle +++ b/grpc-adapter/build.gradle @@ -15,10 +15,10 @@ jacocoTestCoverageVerification { rules.forEach {rule -> rule.limits.forEach {limit -> if (limit.counter == 'INSTRUCTION') { - limit.minimum = 0.83 + limit.minimum = 0.82 } if (limit.counter == 'METHOD') { - limit.minimum = 0.8 + limit.minimum = 0.79 } if (limit.counter == 'BRANCH') { limit.minimum = 0.92 diff --git a/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcClosedChannels.java b/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcClosedChannels.java index a8524a75..d33535a5 100644 --- a/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcClosedChannels.java +++ b/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcClosedChannels.java @@ -9,6 +9,8 @@ import de.cotto.lndmanagej.model.ClosedChannelBuilder; import de.cotto.lndmanagej.model.Coins; import de.cotto.lndmanagej.model.CoopClosedChannelBuilder; import de.cotto.lndmanagej.model.ForceClosedChannelBuilder; +import de.cotto.lndmanagej.model.OpenInitiator; +import de.cotto.lndmanagej.model.OpenInitiatorResolver; import de.cotto.lndmanagej.model.Pubkey; import lnrpc.ChannelCloseSummary; import lnrpc.ChannelCloseSummary.ClosureType; @@ -29,15 +31,18 @@ import static lnrpc.Initiator.INITIATOR_UNKNOWN; public class GrpcClosedChannels extends GrpcChannelsBase { private final GrpcService grpcService; private final GrpcGetInfo grpcGetInfo; + private final OpenInitiatorResolver openInitiatorResolver; public GrpcClosedChannels( GrpcService grpcService, GrpcGetInfo grpcGetInfo, - ChannelIdResolver channelIdResolver + ChannelIdResolver channelIdResolver, + OpenInitiatorResolver openInitiatorResolver ) { super(channelIdResolver); this.grpcService = grpcService; this.grpcGetInfo = grpcGetInfo; + this.openInitiatorResolver = openInitiatorResolver; } public Set getClosedChannels() { @@ -69,6 +74,10 @@ public class GrpcClosedChannels extends GrpcChannelsBase { builder = new ForceClosedChannelBuilder().withCloseInitiator(closeInitiator); } ChannelPoint channelPoint = ChannelPoint.create(channelCloseSummary.getChannelPoint()); + OpenInitiator openInitiator = getOpenInitiator( + channelCloseSummary.getOpenInitiator(), + channelPoint.getTransactionHash() + ); return getChannelId(channelCloseSummary.getChanId(), channelPoint) .map(channelId -> builder .withChannelId(channelId) @@ -77,11 +86,19 @@ public class GrpcClosedChannels extends GrpcChannelsBase { .withOwnPubkey(ownPubkey) .withRemotePubkey(Pubkey.create(channelCloseSummary.getRemotePubkey())) .withCloseTransactionHash(channelCloseSummary.getClosingTxHash()) - .withOpenInitiator(getOpenInitiator(channelCloseSummary.getOpenInitiator())) + .withOpenInitiator(openInitiator) .build() ); } + private OpenInitiator getOpenInitiator(Initiator initiator, String transactionHash) { + OpenInitiator openInitiator = getOpenInitiator(initiator); + if (openInitiator.equals(OpenInitiator.UNKNOWN)) { + return openInitiatorResolver.resolveFromOpenTransactionHash(transactionHash); + } + return openInitiator; + } + private CloseInitiator getCloseInitiator(ChannelCloseSummary channelCloseSummary) { Initiator closeInitiator = channelCloseSummary.getCloseInitiator(); ChannelCloseSummary.ClosureType closureType = channelCloseSummary.getCloseType(); diff --git a/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcService.java b/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcService.java index cac6e40c..4ae86b96 100644 --- a/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcService.java +++ b/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcService.java @@ -15,6 +15,7 @@ import lnrpc.ChannelCloseSummary; import lnrpc.ChannelEdge; import lnrpc.ClosedChannelsRequest; import lnrpc.GetInfoResponse; +import lnrpc.GetTransactionsRequest; import lnrpc.LightningGrpc; import lnrpc.ListChannelsRequest; import lnrpc.NodeInfo; @@ -22,6 +23,7 @@ import lnrpc.NodeInfoRequest; import lnrpc.PendingChannelsRequest; import lnrpc.PendingChannelsResponse; import lnrpc.PendingChannelsResponse.ForceClosedChannel; +import lnrpc.TransactionDetails; import org.springframework.stereotype.Component; import javax.annotation.PreDestroy; @@ -105,6 +107,15 @@ public class GrpcService extends GrpcBase { .orElse(List.of()); } + public Optional getTransactionsInBlock(int blockHeight) { + mark("getTransactions"); + GetTransactionsRequest request = GetTransactionsRequest.newBuilder() + .setStartHeight(blockHeight) + .setEndHeight(blockHeight) + .build(); + return get(() -> lightningStub.getTransactions(request)); + } + private Optional getPendingChannelsWithoutCache() { mark("pendingChannels"); return get(() -> lightningStub.pendingChannels(PendingChannelsRequest.getDefaultInstance())); diff --git a/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcTransactions.java b/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcTransactions.java new file mode 100644 index 00000000..c7f9e485 --- /dev/null +++ b/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcTransactions.java @@ -0,0 +1,28 @@ +package de.cotto.lndmanagej.grpc; + +import lnrpc.Transaction; +import lnrpc.TransactionDetails; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +public class GrpcTransactions { + private final GrpcService grpcService; + + public GrpcTransactions(GrpcService grpcService) { + this.grpcService = grpcService; + } + + public Optional> getKnownTransactionHashesInBlock(int blockHeight) { + return grpcService.getTransactionsInBlock(blockHeight) + .map(TransactionDetails::getTransactionsList) + .map(transactions -> + transactions.stream() + .map(Transaction::getTxHash) + .collect(Collectors.toSet()) + ); + } +} diff --git a/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/GrpcClosedChannelsTest.java b/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/GrpcClosedChannelsTest.java index 4056b029..97269e22 100644 --- a/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/GrpcClosedChannelsTest.java +++ b/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/GrpcClosedChannelsTest.java @@ -5,6 +5,7 @@ import de.cotto.lndmanagej.model.CloseInitiator; import de.cotto.lndmanagej.model.ClosedChannelFixtures; import de.cotto.lndmanagej.model.ForceClosedChannelBuilder; import de.cotto.lndmanagej.model.OpenInitiator; +import de.cotto.lndmanagej.model.OpenInitiatorResolver; import lnrpc.ChannelCloseSummary; import lnrpc.Initiator; import org.junit.jupiter.api.BeforeEach; @@ -22,6 +23,7 @@ import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_2; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_2_SHORT; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_SHORT; import static de.cotto.lndmanagej.model.ChannelPointFixtures.CHANNEL_POINT; +import static de.cotto.lndmanagej.model.ChannelPointFixtures.TRANSACTION_HASH; import static de.cotto.lndmanagej.model.ChannelPointFixtures.TRANSACTION_HASH_2; import static de.cotto.lndmanagej.model.CoopClosedChannelFixtures.CLOSED_CHANNEL; import static de.cotto.lndmanagej.model.CoopClosedChannelFixtures.CLOSED_CHANNEL_2; @@ -41,6 +43,7 @@ import static lnrpc.Initiator.INITIATOR_REMOTE; import static lnrpc.Initiator.INITIATOR_UNKNOWN; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -59,9 +62,14 @@ class GrpcClosedChannelsTest { @Mock private ChannelIdResolver channelIdResolver; + @Mock + private OpenInitiatorResolver openInitiatorResolver; + @BeforeEach void setUp() { when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY); + lenient().when(openInitiatorResolver.resolveFromOpenTransactionHash(TRANSACTION_HASH)) + .thenReturn(OpenInitiator.UNKNOWN); } @Test @@ -77,6 +85,15 @@ class GrpcClosedChannelsTest { verify(channelIdResolver, never()).resolveFromChannelPoint(any()); } + @Test + void getClosedChannels_resolves_initiator_from_chain_transactions() { + when(openInitiatorResolver.resolveFromOpenTransactionHash(TRANSACTION_HASH)).thenReturn(OpenInitiator.LOCAL); + when(grpcService.getClosedChannels()).thenReturn(List.of( + closedChannel(CHANNEL_ID_SHORT, COOPERATIVE_CLOSE, INITIATOR_UNKNOWN, INITIATOR_REMOTE) + )); + assertThat(grpcClosedChannels.getClosedChannels()).containsExactlyInAnyOrder(CLOSED_CHANNEL); + } + @Test void getClosedChannels_close_initiator_unknown_but_force_close_local() { when(grpcService.getClosedChannels()).thenReturn(List.of( @@ -110,6 +127,7 @@ class GrpcClosedChannelsTest { )); assertThat(grpcClosedChannels.getClosedChannels()) .containsExactlyInAnyOrder(FORCE_CLOSED_CHANNEL_LOCAL); + verify(openInitiatorResolver, never()).resolveFromOpenTransactionHash(any()); } @Test diff --git a/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/GrpcTransactionsTest.java b/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/GrpcTransactionsTest.java new file mode 100644 index 00000000..44d99346 --- /dev/null +++ b/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/GrpcTransactionsTest.java @@ -0,0 +1,61 @@ +package de.cotto.lndmanagej.grpc; + +import lnrpc.Transaction; +import lnrpc.TransactionDetails; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GrpcTransactionsTest { + private static final int BLOCK_HEIGHT = 123_456; + private static final String HASH = "abc"; + private static final String HASH_2 = "def"; + private static final Transaction LND_TRANSACTION = Transaction.newBuilder().setTxHash(HASH).build(); + private static final Transaction LND_TRANSACTION_2 = Transaction.newBuilder().setTxHash(HASH_2).build(); + private static final TransactionDetails LND_TRANSACTION_DETAILS_EMPTY = TransactionDetails.newBuilder().build(); + private static final TransactionDetails LND_TRANSACTION_DETAILS = + TransactionDetails.newBuilder() + .addTransactions(LND_TRANSACTION) + .addTransactions(LND_TRANSACTION_2) + .build(); + + @InjectMocks + private GrpcTransactions grpcTransactions; + + @Mock + private GrpcService grpcService; + + @Test + void uses_block_height() { + grpcTransactions.getKnownTransactionHashesInBlock(BLOCK_HEIGHT); + verify(grpcService).getTransactionsInBlock(BLOCK_HEIGHT); + } + + @Test + void empty_for_empty() { + assertThat(grpcTransactions.getKnownTransactionHashesInBlock(BLOCK_HEIGHT)).isEmpty(); + } + + @Test + void empty_set() { + when(grpcService.getTransactionsInBlock(anyInt())).thenReturn(Optional.of(LND_TRANSACTION_DETAILS_EMPTY)); + assertThat(grpcTransactions.getKnownTransactionHashesInBlock(BLOCK_HEIGHT)).contains(Set.of()); + } + + @Test + void contains_hash() { + when(grpcService.getTransactionsInBlock(anyInt())).thenReturn(Optional.of(LND_TRANSACTION_DETAILS)); + assertThat(grpcTransactions.getKnownTransactionHashesInBlock(BLOCK_HEIGHT)).contains(Set.of(HASH, HASH_2)); + } +} \ No newline at end of file diff --git a/model/src/main/java/de/cotto/lndmanagej/model/OpenInitiatorResolver.java b/model/src/main/java/de/cotto/lndmanagej/model/OpenInitiatorResolver.java new file mode 100644 index 00000000..23925f7f --- /dev/null +++ b/model/src/main/java/de/cotto/lndmanagej/model/OpenInitiatorResolver.java @@ -0,0 +1,5 @@ +package de.cotto.lndmanagej.model; + +public interface OpenInitiatorResolver { + OpenInitiator resolveFromOpenTransactionHash(String transactionHash); +} diff --git a/transactions/build.gradle b/transactions/build.gradle index e82c53c5..9c603fba 100644 --- a/transactions/build.gradle +++ b/transactions/build.gradle @@ -14,5 +14,6 @@ dependencies { implementation 'io.github.resilience4j:resilience4j-spring-boot2:1.7.1' implementation 'com.fasterxml.jackson.core:jackson-databind' implementation project(':model') + implementation project(':grpc-adapter') testFixturesApi testFixtures(project(':model')) } \ No newline at end of file diff --git a/transactions/src/main/java/de/cotto/lndmanagej/transactions/service/TransactionService.java b/transactions/src/main/java/de/cotto/lndmanagej/transactions/service/TransactionService.java index e83d45cb..ba896fbc 100644 --- a/transactions/src/main/java/de/cotto/lndmanagej/transactions/service/TransactionService.java +++ b/transactions/src/main/java/de/cotto/lndmanagej/transactions/service/TransactionService.java @@ -1,23 +1,43 @@ package de.cotto.lndmanagej.transactions.service; +import de.cotto.lndmanagej.grpc.GrpcTransactions; import de.cotto.lndmanagej.transactions.TransactionDao; import de.cotto.lndmanagej.transactions.download.TransactionProvider; import de.cotto.lndmanagej.transactions.model.Transaction; import org.springframework.stereotype.Component; import java.util.Optional; +import java.util.Set; @Component public class TransactionService { private final TransactionDao transactionDao; private final TransactionProvider transactionProvider; + private final GrpcTransactions grpcTransactions; public TransactionService( TransactionDao transactionDao, - TransactionProvider transactionProvider + TransactionProvider transactionProvider, + GrpcTransactions grpcTransactions ) { this.transactionDao = transactionDao; this.transactionProvider = transactionProvider; + this.grpcTransactions = grpcTransactions; + } + + @SuppressWarnings("PMD.LinguisticNaming") + public Optional isKnownByLnd(String transactionHash) { + Transaction transaction = getTransaction(transactionHash).orElse(null); + if (transaction == null) { + return Optional.empty(); + } + int blockHeight = transaction.blockHeight(); + Set knownTransactionsInBlock = grpcTransactions.getKnownTransactionHashesInBlock(blockHeight) + .orElse(null); + if (knownTransactionsInBlock == null) { + return Optional.empty(); + } + return Optional.of(knownTransactionsInBlock.contains(transactionHash)); } public Optional getTransaction(String transactionHash) { diff --git a/transactions/src/test/java/de/cotto/lndmanagej/transactions/service/TransactionServiceTest.java b/transactions/src/test/java/de/cotto/lndmanagej/transactions/service/TransactionServiceTest.java index f80e2a1c..8c22f168 100644 --- a/transactions/src/test/java/de/cotto/lndmanagej/transactions/service/TransactionServiceTest.java +++ b/transactions/src/test/java/de/cotto/lndmanagej/transactions/service/TransactionServiceTest.java @@ -1,5 +1,6 @@ package de.cotto.lndmanagej.transactions.service; +import de.cotto.lndmanagej.grpc.GrpcTransactions; import de.cotto.lndmanagej.transactions.TransactionDao; import de.cotto.lndmanagej.transactions.download.TransactionProvider; import org.junit.jupiter.api.Test; @@ -9,11 +10,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Optional; +import java.util.Set; import static de.cotto.lndmanagej.model.ChannelPointFixtures.TRANSACTION_HASH; +import static de.cotto.lndmanagej.model.ChannelPointFixtures.TRANSACTION_HASH_2; import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.TRANSACTION; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -29,6 +33,47 @@ class TransactionServiceTest { @Mock private TransactionProvider transactionProvider; + @Mock + private GrpcTransactions grpcTransactions; + + @Test + void isKnownByLnd_unable_to_retrieve_transaction_details() { + when(transactionDao.getTransaction(TRANSACTION_HASH)).thenReturn(Optional.empty()); + assertThat(transactionService.isKnownByLnd(TRANSACTION_HASH)).isEmpty(); + verify(grpcTransactions, never()).getKnownTransactionHashesInBlock(anyInt()); + } + + @Test + void isKnownByLnd_unable_to_retrieve_lnd_transaction_list() { + when(transactionDao.getTransaction(TRANSACTION_HASH)).thenReturn(Optional.of(TRANSACTION)); + assertThat(transactionService.isKnownByLnd(TRANSACTION_HASH)).isEmpty(); + } + + @Test + void isKnownByLnd_no_transaction_in_block() { + when(transactionDao.getTransaction(TRANSACTION_HASH)).thenReturn(Optional.of(TRANSACTION)); + when(grpcTransactions.getKnownTransactionHashesInBlock(TRANSACTION.blockHeight())) + .thenReturn(Optional.of(Set.of())); + assertThat(transactionService.isKnownByLnd(TRANSACTION_HASH)).contains(false); + } + + @Test + void isKnownByLnd_only_other_transactions_in_block() { + when(transactionDao.getTransaction(TRANSACTION_HASH)).thenReturn(Optional.of(TRANSACTION)); + when(grpcTransactions.getKnownTransactionHashesInBlock(TRANSACTION.blockHeight())) + .thenReturn(Optional.of(Set.of(TRANSACTION_HASH_2))); + assertThat(transactionService.isKnownByLnd(TRANSACTION_HASH)).contains(false); + } + + @Test + void isKnownByLnd() { + when(transactionDao.getTransaction(TRANSACTION_HASH)).thenReturn(Optional.of(TRANSACTION)); + when(grpcTransactions.getKnownTransactionHashesInBlock(TRANSACTION.blockHeight())).thenReturn( + Optional.of(Set.of(TRANSACTION_HASH_2, TRANSACTION_HASH)) + ); + assertThat(transactionService.isKnownByLnd(TRANSACTION_HASH)).contains(true); + } + @Test void getTransaction_empty() { assertThat(transactionService.getTransaction(TRANSACTION_HASH)).isEmpty();