diff --git a/application/build.gradle b/application/build.gradle index 0a841483..d29e7371 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -8,8 +8,10 @@ dependencies { implementation project(':model') implementation project(':caching') implementation project(':metrics') + implementation project(':transactions') runtimeOnly 'org.postgresql:postgresql' testImplementation testFixtures(project(':model')) + testImplementation testFixtures(project(':transactions')) } bootJar { diff --git a/application/src/integrationTest/java/de/cotto/lndmanagej/controller/LegacyControllerIT.java b/application/src/integrationTest/java/de/cotto/lndmanagej/controller/LegacyControllerIT.java index 331df274..7dd999a4 100644 --- a/application/src/integrationTest/java/de/cotto/lndmanagej/controller/LegacyControllerIT.java +++ b/application/src/integrationTest/java/de/cotto/lndmanagej/controller/LegacyControllerIT.java @@ -19,6 +19,8 @@ import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_3; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_COMPACT; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_COMPACT_3; +import static de.cotto.lndmanagej.model.ClosedChannelFixtures.CLOSED_CHANNEL; +import static de.cotto.lndmanagej.model.ClosedChannelFixtures.CLOSED_CHANNEL_3; import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL; import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL_3; import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL_TO_NODE_3; @@ -26,8 +28,6 @@ import static de.cotto.lndmanagej.model.NodeFixtures.ALIAS; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_3; -import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.CLOSED_CHANNEL; -import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.CLOSED_CHANNEL_3; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; diff --git a/application/src/main/java/de/cotto/lndmanagej/service/ChannelIdResolver.java b/application/src/main/java/de/cotto/lndmanagej/service/ChannelIdResolver.java new file mode 100644 index 00000000..96d386bc --- /dev/null +++ b/application/src/main/java/de/cotto/lndmanagej/service/ChannelIdResolver.java @@ -0,0 +1,30 @@ +package de.cotto.lndmanagej.service; + +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.ChannelPoint; +import de.cotto.lndmanagej.transactions.model.Transaction; +import de.cotto.lndmanagej.transactions.service.TransactionService; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +public class ChannelIdResolver { + private final TransactionService transactionService; + + public ChannelIdResolver(TransactionService transactionService) { + this.transactionService = transactionService; + } + + public Optional resolve(ChannelPoint channelPoint) { + return transactionService.getTransaction(channelPoint.getTransactionHash()) + .map(transaction -> getChannelId(transaction, channelPoint)); + } + + private ChannelId getChannelId(Transaction transaction, ChannelPoint channelPoint) { + int block = transaction.blockHeight(); + int transactionIndex = transaction.positionInBlock(); + int output = channelPoint.getOutput(); + return ChannelId.fromCompactForm(block + ":" + transactionIndex + ":" + output); + } +} diff --git a/application/src/main/java/de/cotto/lndmanagej/service/ChannelService.java b/application/src/main/java/de/cotto/lndmanagej/service/ChannelService.java index 40f7ce17..99822288 100644 --- a/application/src/main/java/de/cotto/lndmanagej/service/ChannelService.java +++ b/application/src/main/java/de/cotto/lndmanagej/service/ChannelService.java @@ -3,11 +3,13 @@ package de.cotto.lndmanagej.service; import com.google.common.cache.LoadingCache; import de.cotto.lndmanagej.caching.CacheBuilder; import de.cotto.lndmanagej.grpc.GrpcChannels; +import de.cotto.lndmanagej.model.ClosedChannel; import de.cotto.lndmanagej.model.LocalOpenChannel; import de.cotto.lndmanagej.model.Pubkey; import de.cotto.lndmanagej.model.UnresolvedClosedChannel; import org.springframework.stereotype.Component; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -16,22 +18,41 @@ public class ChannelService { private static final int CACHE_EXPIRY_MINUTES = 1; private final LoadingCache> channelsCache; - private final LoadingCache> closedChannelsCache; + private final LoadingCache> closedChannelsCache; + private final GrpcChannels grpcChannels; + private final ChannelIdResolver channelIdResolver; - public ChannelService(GrpcChannels grpcChannels) { + public ChannelService(GrpcChannels grpcChannels, ChannelIdResolver channelIdResolver) { + this.grpcChannels = grpcChannels; + this.channelIdResolver = channelIdResolver; channelsCache = new CacheBuilder() .withExpiryMinutes(CACHE_EXPIRY_MINUTES) - .build(grpcChannels::getChannels); + .build(this.grpcChannels::getChannels); closedChannelsCache = new CacheBuilder() .withExpiryMinutes(CACHE_EXPIRY_MINUTES) - .build(grpcChannels::getUnresolvedClosedChannels); + .build(this::getClosedChannelsWithoutCache); + } + + private Set getClosedChannelsWithoutCache() { + return grpcChannels.getUnresolvedClosedChannels().stream() + .map(this::toClosedChannel) + .flatMap(Optional::stream) + .collect(Collectors.toSet()); + } + + private Optional toClosedChannel(UnresolvedClosedChannel unresolvedClosedChannel) { + if (unresolvedClosedChannel.getId().isUnresolved()) { + return channelIdResolver.resolve(unresolvedClosedChannel.getChannelPoint()) + .map(channelId -> ClosedChannel.create(unresolvedClosedChannel, channelId)); + } + return Optional.of(ClosedChannel.create(unresolvedClosedChannel)); } public Set getOpenChannels() { return channelsCache.getUnchecked(""); } - public Set getClosedChannels() { + public Set getClosedChannels() { return closedChannelsCache.getUnchecked(""); } diff --git a/application/src/main/resources/application.properties b/application/src/main/resources/application.properties index a49eb886..2448ed2b 100644 --- a/application/src/main/resources/application.properties +++ b/application/src/main/resources/application.properties @@ -2,11 +2,26 @@ spring.application.name=lnd-manageJ spring.main.banner-mode=off spring.profiles.active=default logging.level.root=info -logging.pattern.console=%d %clr(%-5p) %logger: %m%rEx{2}%n +logging.pattern.console=%d %clr(%-5p) %logger: %m%rEx%n spring.task.scheduling.pool.size=20 server.address=127.0.0.1 server.port=8081 +spring.datasource.url=jdbc:postgresql://localhost:5432/lndmanagej +spring.datasource.username=bitcoin +spring.datasource.password=unset + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.database-platform=org.hibernate.dialect.PostgreSQL10Dialect +spring.flyway.baseline-on-migrate=true + +resilience4j.ratelimiter.instances.blockcypher.limit-for-period=3 +resilience4j.ratelimiter.instances.blockcypher.limit-refresh-period=1s +resilience4j.ratelimiter.instances.blockcypher.timeout-duration=100ms + +feign.client.config.default.connectTimeout=5000 +feign.client.config.default.readTimeout=10000 + lndmanagej.macaroon-file=${user.home}/.lnd/data/chain/bitcoin/mainnet/admin.macaroon lndmanagej.cert-file=${user.home}/.lnd/tls.cert lndmanagej.host=localhost diff --git a/application/src/test/java/de/cotto/lndmanagej/controller/LegacyControllerTest.java b/application/src/test/java/de/cotto/lndmanagej/controller/LegacyControllerTest.java index 31e66d6b..baa1fc03 100644 --- a/application/src/test/java/de/cotto/lndmanagej/controller/LegacyControllerTest.java +++ b/application/src/test/java/de/cotto/lndmanagej/controller/LegacyControllerTest.java @@ -27,6 +27,8 @@ import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_3; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_COMPACT; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_COMPACT_3; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_COMPACT_4; +import static de.cotto.lndmanagej.model.ClosedChannelFixtures.CLOSED_CHANNEL; +import static de.cotto.lndmanagej.model.ClosedChannelFixtures.CLOSED_CHANNEL_3; import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL; import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL_2; import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL_3; @@ -37,8 +39,6 @@ import static de.cotto.lndmanagej.model.NodeFixtures.ALIAS_3; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_3; -import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.CLOSED_CHANNEL; -import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.CLOSED_CHANNEL_3; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.verify; diff --git a/application/src/test/java/de/cotto/lndmanagej/service/ChannelIdResolverTest.java b/application/src/test/java/de/cotto/lndmanagej/service/ChannelIdResolverTest.java new file mode 100644 index 00000000..c5512359 --- /dev/null +++ b/application/src/test/java/de/cotto/lndmanagej/service/ChannelIdResolverTest.java @@ -0,0 +1,42 @@ +package de.cotto.lndmanagej.service; + +import de.cotto.lndmanagej.model.ChannelId; +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.CHANNEL_POINT; +import static de.cotto.lndmanagej.model.ChannelPointFixtures.OUTPUT; +import static de.cotto.lndmanagej.model.ChannelPointFixtures.TRANSACTION_HASH; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.BLOCK_HEIGHT; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.POSITION_IN_BLOCK; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.TRANSACTION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ChannelIdResolverTest { + @InjectMocks + private ChannelIdResolver channelIdResolver; + + @Mock + private TransactionService transactionService; + + @Test + void unknown() { + when(transactionService.getTransaction(TRANSACTION_HASH)).thenReturn(Optional.empty()); + assertThat(channelIdResolver.resolve(CHANNEL_POINT)).isEmpty(); + } + + @Test + void known() { + ChannelId expectedChannelId = ChannelId.fromCompactForm(BLOCK_HEIGHT + ":" + POSITION_IN_BLOCK + ":" + OUTPUT); + when(transactionService.getTransaction(TRANSACTION_HASH)).thenReturn(Optional.of(TRANSACTION)); + assertThat(channelIdResolver.resolve(CHANNEL_POINT)).contains(expectedChannelId); + } +} \ No newline at end of file diff --git a/application/src/test/java/de/cotto/lndmanagej/service/ChannelServiceTest.java b/application/src/test/java/de/cotto/lndmanagej/service/ChannelServiceTest.java index 990bb8ef..417a8de7 100644 --- a/application/src/test/java/de/cotto/lndmanagej/service/ChannelServiceTest.java +++ b/application/src/test/java/de/cotto/lndmanagej/service/ChannelServiceTest.java @@ -10,18 +10,24 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.Optional; import java.util.Set; import static de.cotto.lndmanagej.model.BalanceInformationFixtures.BALANCE_INFORMATION; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_2; +import static de.cotto.lndmanagej.model.ChannelPointFixtures.CHANNEL_POINT; +import static de.cotto.lndmanagej.model.ClosedChannelFixtures.CLOSED_CHANNEL; +import static de.cotto.lndmanagej.model.ClosedChannelFixtures.CLOSED_CHANNEL_2; import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL; import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL_2; import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL_3; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_3; -import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.CLOSED_CHANNEL; -import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.CLOSED_CHANNEL_2; +import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.CLOSED_CHANNEL_UNRESOLVED_ID; +import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.UNRESOLVED_CLOSED_CHANNEL; +import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.UNRESOLVED_CLOSED_CHANNEL_2; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -33,6 +39,9 @@ class ChannelServiceTest { @Mock private GrpcChannels grpcChannels; + @Mock + private ChannelIdResolver channelIdResolver; + @Test void getOpenChannelsWith_by_pubkey() { when(grpcChannels.getChannels()).thenReturn(Set.of(LOCAL_OPEN_CHANNEL, LOCAL_OPEN_CHANNEL_3)); @@ -60,8 +69,26 @@ class ChannelServiceTest { @Test void getClosedChannels() { - when(grpcChannels.getUnresolvedClosedChannels()).thenReturn(Set.of(CLOSED_CHANNEL, CLOSED_CHANNEL_2)); + when(grpcChannels.getUnresolvedClosedChannels()) + .thenReturn(Set.of(UNRESOLVED_CLOSED_CHANNEL, UNRESOLVED_CLOSED_CHANNEL_2)); assertThat(channelService.getClosedChannels()) .containsExactlyInAnyOrder(CLOSED_CHANNEL, CLOSED_CHANNEL_2); } + + @Test + void getClosedChannels_resolves_id() { + when(channelIdResolver.resolve(CHANNEL_POINT)).thenReturn(Optional.of(CHANNEL_ID)); + when(grpcChannels.getUnresolvedClosedChannels()) + .thenReturn(Set.of(UNRESOLVED_CLOSED_CHANNEL_2, CLOSED_CHANNEL_UNRESOLVED_ID)); + assertThat(channelService.getClosedChannels()) + .containsExactlyInAnyOrder(CLOSED_CHANNEL, CLOSED_CHANNEL_2); + } + + @Test + void getClosedChannels_unresolvable_id() { + when(channelIdResolver.resolve(CHANNEL_POINT)).thenReturn(Optional.empty()); + when(grpcChannels.getUnresolvedClosedChannels()) + .thenReturn(Set.of(UNRESOLVED_CLOSED_CHANNEL_2, CLOSED_CHANNEL_UNRESOLVED_ID)); + assertThat(channelService.getClosedChannels()).containsExactlyInAnyOrder(CLOSED_CHANNEL_2); + } } \ No newline at end of file diff --git a/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcChannels.java b/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcChannels.java index 61d11c0b..f5e0c8ae 100644 --- a/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcChannels.java +++ b/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/GrpcChannels.java @@ -9,6 +9,7 @@ import de.cotto.lndmanagej.model.LocalOpenChannel; import de.cotto.lndmanagej.model.Pubkey; import de.cotto.lndmanagej.model.UnresolvedClosedChannel; import lnrpc.ChannelCloseSummary; +import lnrpc.ChannelCloseSummary.ClosureType; import org.springframework.stereotype.Component; import java.util.Optional; @@ -39,6 +40,7 @@ public class GrpcChannels { public Set getUnresolvedClosedChannels() { Pubkey ownPubkey = grpcGetInfo.getPubkey(); return grpcService.getClosedChannels().stream() + .filter(this::shouldConsider) .map(channelCloseSummary -> toUnresolvedClosedChannel(channelCloseSummary, ownPubkey)) .collect(toSet()); } @@ -90,4 +92,9 @@ public class GrpcChannels { } return ChannelId.fromShortChannelId(chanId); } + + private boolean shouldConsider(ChannelCloseSummary channelCloseSummary) { + ClosureType closeType = channelCloseSummary.getCloseType(); + return closeType != ClosureType.ABANDONED && closeType != ClosureType.FUNDING_CANCELED; + } } diff --git a/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/GrpcChannelsTest.java b/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/GrpcChannelsTest.java index 79914730..4e3e1e75 100644 --- a/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/GrpcChannelsTest.java +++ b/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/GrpcChannelsTest.java @@ -3,6 +3,7 @@ package de.cotto.lndmanagej.grpc; import de.cotto.lndmanagej.model.ChannelId; import lnrpc.Channel; import lnrpc.ChannelCloseSummary; +import lnrpc.ChannelConstraints; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -12,6 +13,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; +import static de.cotto.lndmanagej.model.BalanceInformationFixtures.BALANCE_INFORMATION; import static de.cotto.lndmanagej.model.ChannelFixtures.CAPACITY; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_2; @@ -20,9 +22,11 @@ import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHAN import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL_2; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; -import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.CLOSED_CHANNEL; -import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.CLOSED_CHANNEL_2; import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.CLOSED_CHANNEL_UNRESOLVED_ID; +import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.UNRESOLVED_CLOSED_CHANNEL; +import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.UNRESOLVED_CLOSED_CHANNEL_2; +import static lnrpc.ChannelCloseSummary.ClosureType.ABANDONED; +import static lnrpc.ChannelCloseSummary.ClosureType.FUNDING_CANCELED; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -64,7 +68,7 @@ class GrpcChannelsTest { List.of(closedChannel(CHANNEL_ID.getShortChannelId()), closedChannel(CHANNEL_ID_2.getShortChannelId())) ); assertThat(grpcChannels.getUnresolvedClosedChannels()) - .containsExactlyInAnyOrder(CLOSED_CHANNEL, CLOSED_CHANNEL_2); + .containsExactlyInAnyOrder(UNRESOLVED_CLOSED_CHANNEL, UNRESOLVED_CLOSED_CHANNEL_2); } @Test @@ -73,11 +77,31 @@ class GrpcChannelsTest { List.of(closedChannel(CHANNEL_ID.getShortChannelId()), closedChannel(0)) ); assertThat(grpcChannels.getUnresolvedClosedChannels()).containsExactlyInAnyOrder( - CLOSED_CHANNEL, + UNRESOLVED_CLOSED_CHANNEL, CLOSED_CHANNEL_UNRESOLVED_ID ); } + @Test + void getUnresolvedClosedChannels_ignores_abandoned() { + when(grpcService.getClosedChannels()).thenReturn( + List.of(closedChannel(CHANNEL_ID.getShortChannelId()), closedChannelWithType(ABANDONED)) + ); + assertThat(grpcChannels.getUnresolvedClosedChannels()).containsExactlyInAnyOrder( + UNRESOLVED_CLOSED_CHANNEL + ); + } + + @Test + void getUnresolvedClosedChannels_ignores_funding_canceled() { + when(grpcService.getClosedChannels()).thenReturn( + List.of(closedChannel(CHANNEL_ID.getShortChannelId()), closedChannelWithType(FUNDING_CANCELED)) + ); + assertThat(grpcChannels.getUnresolvedClosedChannels()).containsExactlyInAnyOrder( + UNRESOLVED_CLOSED_CHANNEL + ); + } + @Test void getChannel() { when(grpcService.getChannels()).thenReturn(List.of(channel(CHANNEL_ID_2), channel(CHANNEL_ID))); @@ -90,11 +114,21 @@ class GrpcChannelsTest { } private Channel channel(ChannelId channelId) { + ChannelConstraints localConstraints = ChannelConstraints.newBuilder() + .setChanReserveSat(BALANCE_INFORMATION.localReserve().satoshis()) + .build(); + ChannelConstraints remoteConstraints = ChannelConstraints.newBuilder() + .setChanReserveSat(BALANCE_INFORMATION.remoteReserve().satoshis()) + .build(); return Channel.newBuilder() .setChanId(channelId.getShortChannelId()) .setCapacity(CAPACITY.satoshis()) .setRemotePubkey(PUBKEY_2.toString()) .setChannelPoint(CHANNEL_POINT.toString()) + .setLocalBalance(BALANCE_INFORMATION.localBalance().satoshis()) + .setRemoteBalance(BALANCE_INFORMATION.remoteBalance().satoshis()) + .setLocalConstraints(localConstraints) + .setRemoteConstraints(remoteConstraints) .build(); } @@ -106,4 +140,14 @@ class GrpcChannelsTest { .setChannelPoint(CHANNEL_POINT.toString()) .build(); } + + private ChannelCloseSummary closedChannelWithType(ChannelCloseSummary.ClosureType abandoned) { + return ChannelCloseSummary.newBuilder() + .setChanId(0) + .setRemotePubkey(PUBKEY_2.toString()) + .setCapacity(CAPACITY.satoshis()) + .setChannelPoint(CHANNEL_POINT.toString()) + .setCloseType(abandoned) + .build(); + } } \ No newline at end of file diff --git a/model/src/main/java/de/cotto/lndmanagej/model/Channel.java b/model/src/main/java/de/cotto/lndmanagej/model/Channel.java index 0b247a59..6e890123 100644 --- a/model/src/main/java/de/cotto/lndmanagej/model/Channel.java +++ b/model/src/main/java/de/cotto/lndmanagej/model/Channel.java @@ -31,6 +31,10 @@ public class Channel { return new Builder(); } + public Channel getWithId(ChannelId channelId) { + return new Channel(channelId, getCapacity(), getChannelPoint(), getPubkeys()); + } + public Coins getCapacity() { return capacity; } @@ -121,14 +125,4 @@ public class Channel { public int hashCode() { return Objects.hash(channelId, capacity, channelPoint, pubkeys); } - - @Override - public String toString() { - return "Channel[" + - "channelId=" + channelId + - ", capacity=" + capacity + - ", channelPoint=" + channelPoint + - ", pubkeys=" + pubkeys + - ']'; - } } diff --git a/model/src/main/java/de/cotto/lndmanagej/model/ChannelPoint.java b/model/src/main/java/de/cotto/lndmanagej/model/ChannelPoint.java index 26540e71..dcd58aff 100644 --- a/model/src/main/java/de/cotto/lndmanagej/model/ChannelPoint.java +++ b/model/src/main/java/de/cotto/lndmanagej/model/ChannelPoint.java @@ -9,11 +9,11 @@ public final class ChannelPoint { private static final Splitter SPLITTER = Splitter.on(":"); private final String transactionHash; - private final int index; + private final int output; - private ChannelPoint(String transactionHash, Integer index) { + private ChannelPoint(String transactionHash, Integer output) { this.transactionHash = transactionHash; - this.index = index; + this.output = output; } public static ChannelPoint create(String channelPoint) { @@ -25,8 +25,8 @@ public final class ChannelPoint { return transactionHash; } - public int getIndex() { - return index; + public int getOutput() { + return output; } @Override @@ -38,16 +38,16 @@ public final class ChannelPoint { return false; } ChannelPoint that = (ChannelPoint) other; - return index == that.index && Objects.equals(transactionHash, that.transactionHash); + return output == that.output && Objects.equals(transactionHash, that.transactionHash); } @Override public int hashCode() { - return Objects.hash(transactionHash, index); + return Objects.hash(transactionHash, output); } @Override public String toString() { - return transactionHash + ':' + index; + return transactionHash + ':' + output; } } diff --git a/model/src/main/java/de/cotto/lndmanagej/model/ClosedChannel.java b/model/src/main/java/de/cotto/lndmanagej/model/ClosedChannel.java new file mode 100644 index 00000000..660ba8ba --- /dev/null +++ b/model/src/main/java/de/cotto/lndmanagej/model/ClosedChannel.java @@ -0,0 +1,18 @@ +package de.cotto.lndmanagej.model; + +public final class ClosedChannel extends LocalChannel { + private ClosedChannel(LocalChannel localChannel) { + super(localChannel, localChannel.getRemotePubkey()); + } + + public static ClosedChannel create(UnresolvedClosedChannel unresolvedClosedChannel) { + return create(unresolvedClosedChannel, unresolvedClosedChannel.getId()); + } + + public static ClosedChannel create(UnresolvedClosedChannel unresolvedClosedChannel, ChannelId channelId) { + if (channelId.isUnresolved()) { + throw new IllegalArgumentException("Channel ID must be resolved"); + } + return new ClosedChannel(unresolvedClosedChannel.getWithId(channelId)); + } +} diff --git a/model/src/main/java/de/cotto/lndmanagej/model/LocalChannel.java b/model/src/main/java/de/cotto/lndmanagej/model/LocalChannel.java index c53b9cea..ce358e8f 100644 --- a/model/src/main/java/de/cotto/lndmanagej/model/LocalChannel.java +++ b/model/src/main/java/de/cotto/lndmanagej/model/LocalChannel.java @@ -1,5 +1,6 @@ package de.cotto.lndmanagej.model; +import java.util.Objects; import java.util.Set; public class LocalChannel extends Channel { @@ -20,4 +21,24 @@ public class LocalChannel extends Channel { public Pubkey getRemotePubkey() { return remotePubkey; } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + if (!super.equals(other)) { + return false; + } + LocalChannel that = (LocalChannel) other; + return Objects.equals(remotePubkey, that.remotePubkey); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), remotePubkey); + } } diff --git a/model/src/main/java/de/cotto/lndmanagej/model/LocalOpenChannel.java b/model/src/main/java/de/cotto/lndmanagej/model/LocalOpenChannel.java index cab822ec..4743967d 100644 --- a/model/src/main/java/de/cotto/lndmanagej/model/LocalOpenChannel.java +++ b/model/src/main/java/de/cotto/lndmanagej/model/LocalOpenChannel.java @@ -1,5 +1,7 @@ package de.cotto.lndmanagej.model; +import java.util.Objects; + public class LocalOpenChannel extends LocalChannel { private final BalanceInformation balanceInformation; @@ -11,4 +13,25 @@ public class LocalOpenChannel extends LocalChannel { public BalanceInformation getBalanceInformation() { return balanceInformation; } + + @Override + @SuppressWarnings("CPD-START") + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + if (!super.equals(other)) { + return false; + } + LocalOpenChannel that = (LocalOpenChannel) other; + return Objects.equals(balanceInformation, that.balanceInformation); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), balanceInformation); + } } diff --git a/model/src/main/java/de/cotto/lndmanagej/model/UnresolvedClosedChannel.java b/model/src/main/java/de/cotto/lndmanagej/model/UnresolvedClosedChannel.java index 6c316e08..5f67d963 100644 --- a/model/src/main/java/de/cotto/lndmanagej/model/UnresolvedClosedChannel.java +++ b/model/src/main/java/de/cotto/lndmanagej/model/UnresolvedClosedChannel.java @@ -1,7 +1,15 @@ package de.cotto.lndmanagej.model; public class UnresolvedClosedChannel extends LocalChannel { + private final Pubkey ownPubkey; + public UnresolvedClosedChannel(Channel channel, Pubkey ownPubkey) { super(channel, ownPubkey); + this.ownPubkey = ownPubkey; + } + + @Override + public LocalChannel getWithId(ChannelId channelId) { + return new UnresolvedClosedChannel(super.getWithId(channelId), ownPubkey); } } diff --git a/model/src/test/java/de/cotto/lndmanagej/model/BalanceInformationTest.java b/model/src/test/java/de/cotto/lndmanagej/model/BalanceInformationTest.java index 90339b66..b94759ca 100644 --- a/model/src/test/java/de/cotto/lndmanagej/model/BalanceInformationTest.java +++ b/model/src/test/java/de/cotto/lndmanagej/model/BalanceInformationTest.java @@ -1,5 +1,6 @@ package de.cotto.lndmanagej.model; +import nl.jqno.equalsverifier.EqualsVerifier; import org.junit.jupiter.api.Test; import static de.cotto.lndmanagej.model.BalanceInformationFixtures.BALANCE_INFORMATION; @@ -29,4 +30,9 @@ class BalanceInformationTest { new BalanceInformation(Coins.NONE, Coins.NONE, Coins.ofSatoshis(100), Coins.ofSatoshis(200)); assertThat(balanceInformation.availableRemoteBalance()).isEqualTo(Coins.NONE); } + + @Test + void testEquals() { + EqualsVerifier.forClass(BalanceInformation.class).usingGetClass().verify(); + } } \ No newline at end of file diff --git a/model/src/test/java/de/cotto/lndmanagej/model/ChannelPointTest.java b/model/src/test/java/de/cotto/lndmanagej/model/ChannelPointTest.java index abf55432..868cc350 100644 --- a/model/src/test/java/de/cotto/lndmanagej/model/ChannelPointTest.java +++ b/model/src/test/java/de/cotto/lndmanagej/model/ChannelPointTest.java @@ -4,7 +4,7 @@ import nl.jqno.equalsverifier.EqualsVerifier; import org.junit.jupiter.api.Test; import static de.cotto.lndmanagej.model.ChannelPointFixtures.CHANNEL_POINT; -import static de.cotto.lndmanagej.model.ChannelPointFixtures.INDEX; +import static de.cotto.lndmanagej.model.ChannelPointFixtures.OUTPUT; import static de.cotto.lndmanagej.model.ChannelPointFixtures.TRANSACTION_HASH; import static org.assertj.core.api.Assertions.assertThat; @@ -16,12 +16,12 @@ class ChannelPointTest { @Test void getIndex() { - assertThat(CHANNEL_POINT.getIndex()).isEqualTo(INDEX); + assertThat(CHANNEL_POINT.getOutput()).isEqualTo(OUTPUT); } @Test void getIndex_more_than_one_digit() { - assertThat(ChannelPoint.create(TRANSACTION_HASH + ":123").getIndex()).isEqualTo(123); + assertThat(ChannelPoint.create(TRANSACTION_HASH + ":123").getOutput()).isEqualTo(123); } @Test diff --git a/model/src/test/java/de/cotto/lndmanagej/model/ChannelTest.java b/model/src/test/java/de/cotto/lndmanagej/model/ChannelTest.java index 7e5a4297..ec9dc18b 100644 --- a/model/src/test/java/de/cotto/lndmanagej/model/ChannelTest.java +++ b/model/src/test/java/de/cotto/lndmanagej/model/ChannelTest.java @@ -5,7 +5,9 @@ import org.junit.jupiter.api.Test; import static de.cotto.lndmanagej.model.ChannelFixtures.CAPACITY; import static de.cotto.lndmanagej.model.ChannelFixtures.CHANNEL; +import static de.cotto.lndmanagej.model.ChannelFixtures.CHANNEL_2; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_2; import static de.cotto.lndmanagej.model.ChannelPointFixtures.CHANNEL_POINT; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; @@ -126,6 +128,11 @@ class ChannelTest { assertThat(CHANNEL.getChannelPoint()).isEqualTo(CHANNEL_POINT); } + @Test + void getWithId() { + assertThat(CHANNEL.getWithId(CHANNEL_ID_2)).isEqualTo(CHANNEL_2); + } + @Test void testEquals() { EqualsVerifier.forClass(Channel.class).usingGetClass().verify(); @@ -142,16 +149,4 @@ class ChannelTest { .build(); assertThat(CHANNEL).isEqualTo(channel); } - - @Test - void testToString() { - assertThat(CHANNEL).hasToString( - "Channel[" + - "channelId=" + CHANNEL_ID + - ", capacity=" + CAPACITY + - ", channelPoint=" + CHANNEL_POINT + - ", pubkeys=[" + PUBKEY + ", " + PUBKEY_2 + "]" + - "]" - ); - } } \ No newline at end of file diff --git a/model/src/test/java/de/cotto/lndmanagej/model/ClosedChannelTest.java b/model/src/test/java/de/cotto/lndmanagej/model/ClosedChannelTest.java new file mode 100644 index 00000000..d6f80405 --- /dev/null +++ b/model/src/test/java/de/cotto/lndmanagej/model/ClosedChannelTest.java @@ -0,0 +1,71 @@ +package de.cotto.lndmanagej.model; + +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; + +import static de.cotto.lndmanagej.model.ChannelFixtures.CAPACITY; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.ChannelPointFixtures.CHANNEL_POINT; +import static de.cotto.lndmanagej.model.ClosedChannelFixtures.CLOSED_CHANNEL; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; +import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.CLOSED_CHANNEL_UNRESOLVED_ID; +import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.UNRESOLVED_CLOSED_CHANNEL; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class ClosedChannelTest { + @Test + void create_with_implicit_channel_id() { + assertThat(ClosedChannel.create(UNRESOLVED_CLOSED_CHANNEL)).isEqualTo(CLOSED_CHANNEL); + } + + @Test + void create_with_unresolved_channel_id() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ClosedChannel.create(CLOSED_CHANNEL_UNRESOLVED_ID)) + .withMessage("Channel ID must be resolved"); + } + + @Test + void create_with_explicit_channel_id() { + assertThat(ClosedChannel.create(CLOSED_CHANNEL_UNRESOLVED_ID, CHANNEL_ID)).isEqualTo(CLOSED_CHANNEL); + } + + @Test + void create_with_explicit_unresolved_channel_id() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ClosedChannel.create(CLOSED_CHANNEL_UNRESOLVED_ID, ChannelId.UNRESOLVED)) + .withMessage("Channel ID must be resolved"); + } + + @Test + void getId() { + assertThat(CLOSED_CHANNEL.getId()).isEqualTo(CHANNEL_ID); + } + + @Test + void getRemotePubkey() { + assertThat(CLOSED_CHANNEL.getRemotePubkey()).isEqualTo(PUBKEY); + } + + @Test + void getCapacity() { + assertThat(CLOSED_CHANNEL.getCapacity()).isEqualTo(CAPACITY); + } + + @Test + void getChannelPoint() { + assertThat(CLOSED_CHANNEL.getChannelPoint()).isEqualTo(CHANNEL_POINT); + } + + @Test + void getPubkeys() { + assertThat(CLOSED_CHANNEL.getPubkeys()).containsExactlyInAnyOrder(PUBKEY, PUBKEY_2); + } + + @Test + void testEquals() { + EqualsVerifier.forClass(ClosedChannel.class).usingGetClass().verify(); + } +} \ No newline at end of file diff --git a/model/src/test/java/de/cotto/lndmanagej/model/LocalOpenChannelTest.java b/model/src/test/java/de/cotto/lndmanagej/model/LocalOpenChannelTest.java index 9a6069dd..7a37b1ad 100644 --- a/model/src/test/java/de/cotto/lndmanagej/model/LocalOpenChannelTest.java +++ b/model/src/test/java/de/cotto/lndmanagej/model/LocalOpenChannelTest.java @@ -1,5 +1,6 @@ package de.cotto.lndmanagej.model; +import nl.jqno.equalsverifier.EqualsVerifier; import org.junit.jupiter.api.Test; import static de.cotto.lndmanagej.model.BalanceInformationFixtures.BALANCE_INFORMATION; @@ -57,4 +58,9 @@ class LocalOpenChannelTest { void getRemoteReserve() { assertThat(LOCAL_OPEN_CHANNEL.getBalanceInformation().remoteReserve()).isEqualTo(REMOTE_RESERVE); } + + @Test + void testEquals() { + EqualsVerifier.forClass(LocalOpenChannel.class).usingGetClass().verify(); + } } \ No newline at end of file diff --git a/model/src/test/java/de/cotto/lndmanagej/model/UnresolvedClosedChannelTest.java b/model/src/test/java/de/cotto/lndmanagej/model/UnresolvedClosedChannelTest.java index d4d89bc2..1072c10a 100644 --- a/model/src/test/java/de/cotto/lndmanagej/model/UnresolvedClosedChannelTest.java +++ b/model/src/test/java/de/cotto/lndmanagej/model/UnresolvedClosedChannelTest.java @@ -7,6 +7,8 @@ import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_3; +import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.CLOSED_CHANNEL_UNRESOLVED_ID; +import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.UNRESOLVED_CLOSED_CHANNEL; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -33,4 +35,9 @@ class UnresolvedClosedChannelTest { .isThrownBy(() -> new UnresolvedClosedChannel(CHANNEL_2, PUBKEY_3)) .withMessage("Channel must have given pubkey as peer"); } + + @Test + void getWithId() { + assertThat(CLOSED_CHANNEL_UNRESOLVED_ID.getWithId(CHANNEL_ID)).isEqualTo(UNRESOLVED_CLOSED_CHANNEL); + } } \ No newline at end of file diff --git a/model/src/testFixtures/java/de/cotto/lndmanagej/model/ChannelPointFixtures.java b/model/src/testFixtures/java/de/cotto/lndmanagej/model/ChannelPointFixtures.java index df250159..908c0716 100644 --- a/model/src/testFixtures/java/de/cotto/lndmanagej/model/ChannelPointFixtures.java +++ b/model/src/testFixtures/java/de/cotto/lndmanagej/model/ChannelPointFixtures.java @@ -2,6 +2,6 @@ package de.cotto.lndmanagej.model; public class ChannelPointFixtures { public static final String TRANSACTION_HASH = "abc000abc000abc000abc000abc000abc000abc000abc000abc000abc000abc0"; - public static final int INDEX = 1; - public static final ChannelPoint CHANNEL_POINT = ChannelPoint.create(TRANSACTION_HASH + ":" + INDEX); + public static final int OUTPUT = 1; + public static final ChannelPoint CHANNEL_POINT = ChannelPoint.create(TRANSACTION_HASH + ":" + OUTPUT); } diff --git a/model/src/testFixtures/java/de/cotto/lndmanagej/model/ClosedChannelFixtures.java b/model/src/testFixtures/java/de/cotto/lndmanagej/model/ClosedChannelFixtures.java new file mode 100644 index 00000000..1d0d36b9 --- /dev/null +++ b/model/src/testFixtures/java/de/cotto/lndmanagej/model/ClosedChannelFixtures.java @@ -0,0 +1,11 @@ +package de.cotto.lndmanagej.model; + +import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.UNRESOLVED_CLOSED_CHANNEL; +import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.UNRESOLVED_CLOSED_CHANNEL_2; +import static de.cotto.lndmanagej.model.UnresolvedClosedChannelFixtures.UNRESOLVED_CLOSED_CHANNEL_3; + +public class ClosedChannelFixtures { + public static final ClosedChannel CLOSED_CHANNEL = ClosedChannel.create(UNRESOLVED_CLOSED_CHANNEL); + public static final ClosedChannel CLOSED_CHANNEL_2 = ClosedChannel.create(UNRESOLVED_CLOSED_CHANNEL_2); + public static final ClosedChannel CLOSED_CHANNEL_3 = ClosedChannel.create(UNRESOLVED_CLOSED_CHANNEL_3); +} diff --git a/model/src/testFixtures/java/de/cotto/lndmanagej/model/UnresolvedClosedChannelFixtures.java b/model/src/testFixtures/java/de/cotto/lndmanagej/model/UnresolvedClosedChannelFixtures.java index fc769135..b97e655c 100644 --- a/model/src/testFixtures/java/de/cotto/lndmanagej/model/UnresolvedClosedChannelFixtures.java +++ b/model/src/testFixtures/java/de/cotto/lndmanagej/model/UnresolvedClosedChannelFixtures.java @@ -7,9 +7,12 @@ import static de.cotto.lndmanagej.model.ChannelFixtures.CHANNEL_UNRESOLVED_ID; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; public class UnresolvedClosedChannelFixtures { - public static final UnresolvedClosedChannel CLOSED_CHANNEL = new UnresolvedClosedChannel(CHANNEL, PUBKEY); + public static final UnresolvedClosedChannel UNRESOLVED_CLOSED_CHANNEL = + new UnresolvedClosedChannel(CHANNEL, PUBKEY); public static final UnresolvedClosedChannel CLOSED_CHANNEL_UNRESOLVED_ID = new UnresolvedClosedChannel(CHANNEL_UNRESOLVED_ID, PUBKEY); - public static final UnresolvedClosedChannel CLOSED_CHANNEL_2 = new UnresolvedClosedChannel(CHANNEL_2, PUBKEY); - public static final UnresolvedClosedChannel CLOSED_CHANNEL_3 = new UnresolvedClosedChannel(CHANNEL_3, PUBKEY); + public static final UnresolvedClosedChannel UNRESOLVED_CLOSED_CHANNEL_2 = + new UnresolvedClosedChannel(CHANNEL_2, PUBKEY); + public static final UnresolvedClosedChannel UNRESOLVED_CLOSED_CHANNEL_3 = + new UnresolvedClosedChannel(CHANNEL_3, PUBKEY); } diff --git a/settings.gradle b/settings.gradle index 4057e6fc..8f35d047 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,4 +4,5 @@ include 'model' include 'grpc-client' include 'grpc-adapter' include 'caching' -include 'metrics' \ No newline at end of file +include 'metrics' +include 'transactions' diff --git a/transactions/build.gradle b/transactions/build.gradle new file mode 100644 index 00000000..e82c53c5 --- /dev/null +++ b/transactions/build.gradle @@ -0,0 +1,18 @@ +plugins { + id 'lnd-manageJ.java-library-conventions' +} + +ext { + set('springCloudVersion', '2020.0.4') +} + +dependencies { + api platform("org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}") + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'io.github.resilience4j:resilience4j-spring-boot2:1.7.1' + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation project(':model') + testFixturesApi testFixtures(project(':model')) +} \ No newline at end of file diff --git a/transactions/src/main/java/de/cotto/lndmanagej/transactions/TransactionDao.java b/transactions/src/main/java/de/cotto/lndmanagej/transactions/TransactionDao.java new file mode 100644 index 00000000..10975219 --- /dev/null +++ b/transactions/src/main/java/de/cotto/lndmanagej/transactions/TransactionDao.java @@ -0,0 +1,11 @@ +package de.cotto.lndmanagej.transactions; + +import de.cotto.lndmanagej.transactions.model.Transaction; + +import java.util.Optional; + +public interface TransactionDao { + Optional getTransaction(String transactionHash); + + void saveTransaction(Transaction transaction); +} diff --git a/transactions/src/main/java/de/cotto/lndmanagej/transactions/download/BlockcypherClient.java b/transactions/src/main/java/de/cotto/lndmanagej/transactions/download/BlockcypherClient.java new file mode 100644 index 00000000..880ace7c --- /dev/null +++ b/transactions/src/main/java/de/cotto/lndmanagej/transactions/download/BlockcypherClient.java @@ -0,0 +1,17 @@ +package de.cotto.lndmanagej.transactions.download; + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.ratelimiter.annotation.RateLimiter; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.Optional; + +@FeignClient(value = "blockcypher", url = "https://api.blockcypher.com") +@RateLimiter(name = "blockcypher") +@CircuitBreaker(name = "blockcypher") +public interface BlockcypherClient { + @GetMapping("/v1/btc/main/txs/{transactionHash}") + Optional getTransaction(@PathVariable String transactionHash); +} diff --git a/transactions/src/main/java/de/cotto/lndmanagej/transactions/download/BlockcypherTransactionDto.java b/transactions/src/main/java/de/cotto/lndmanagej/transactions/download/BlockcypherTransactionDto.java new file mode 100644 index 00000000..6852915a --- /dev/null +++ b/transactions/src/main/java/de/cotto/lndmanagej/transactions/download/BlockcypherTransactionDto.java @@ -0,0 +1,50 @@ +package de.cotto.lndmanagej.transactions.download; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.transactions.model.Transaction; + +import java.io.IOException; + +@JsonDeserialize(using = BlockcypherTransactionDto.Deserializer.class) +public class BlockcypherTransactionDto { + private final String hash; + private final int blockHeight; + private final int positionInBlock; + private final Coins fees; + + public BlockcypherTransactionDto( + String hash, + int blockHeight, + int positionInBlock, + Coins fees + ) { + this.hash = hash; + this.blockHeight = blockHeight; + this.positionInBlock = positionInBlock; + this.fees = fees; + } + + public Transaction toModel() { + return new Transaction(hash, blockHeight, positionInBlock, fees); + } + + public static class Deserializer extends JsonDeserializer { + @Override + public BlockcypherTransactionDto deserialize( + JsonParser jsonParser, + DeserializationContext context + ) throws IOException { + JsonNode transactionDetailsNode = jsonParser.getCodec().readTree(jsonParser); + String hash = transactionDetailsNode.get("hash").textValue(); + int blockHeight = transactionDetailsNode.get("block_height").asInt(); + long fees = transactionDetailsNode.get("fees").asLong(); + int positionInBlock = transactionDetailsNode.get("block_index").asInt(); + return new BlockcypherTransactionDto(hash, blockHeight, positionInBlock, Coins.ofSatoshis(fees)); + } + } +} diff --git a/transactions/src/main/java/de/cotto/lndmanagej/transactions/download/FeignConfiguration.java b/transactions/src/main/java/de/cotto/lndmanagej/transactions/download/FeignConfiguration.java new file mode 100644 index 00000000..7aea4f7e --- /dev/null +++ b/transactions/src/main/java/de/cotto/lndmanagej/transactions/download/FeignConfiguration.java @@ -0,0 +1,12 @@ +package de.cotto.lndmanagej.transactions.download; + +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableFeignClients +public class FeignConfiguration { + public FeignConfiguration() { + // default constructor + } +} diff --git a/transactions/src/main/java/de/cotto/lndmanagej/transactions/download/TransactionProvider.java b/transactions/src/main/java/de/cotto/lndmanagej/transactions/download/TransactionProvider.java new file mode 100644 index 00000000..ad282043 --- /dev/null +++ b/transactions/src/main/java/de/cotto/lndmanagej/transactions/download/TransactionProvider.java @@ -0,0 +1,38 @@ +package de.cotto.lndmanagej.transactions.download; + +import de.cotto.lndmanagej.transactions.model.Transaction; +import feign.FeignException; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +public class TransactionProvider { + private final BlockcypherClient blockcypherClient; + private final Logger logger = LoggerFactory.getLogger(getClass()); + + public TransactionProvider(BlockcypherClient blockcypherClient) { + this.blockcypherClient = blockcypherClient; + } + + public Optional get(String transactionHash) { + return getAndHandleExceptions(transactionHash) + .map(BlockcypherTransactionDto::toModel); + } + + private Optional getAndHandleExceptions(String transactionHash) { + try { + return blockcypherClient.getTransaction(transactionHash); + } catch (FeignException feignException) { + logger.warn("Feign exception: ", feignException); + return Optional.empty(); + } catch (RequestNotPermitted requestNotPermitted) { + logger.warn("Blockcypher is rate limited: ", requestNotPermitted); + return Optional.empty(); + } + } + +} diff --git a/transactions/src/main/java/de/cotto/lndmanagej/transactions/model/Transaction.java b/transactions/src/main/java/de/cotto/lndmanagej/transactions/model/Transaction.java new file mode 100644 index 00000000..5222bb0a --- /dev/null +++ b/transactions/src/main/java/de/cotto/lndmanagej/transactions/model/Transaction.java @@ -0,0 +1,11 @@ +package de.cotto.lndmanagej.transactions.model; + +import de.cotto.lndmanagej.model.Coins; + +public record Transaction( + String hash, + int blockHeight, + int positionInBlock, + Coins fees +) { +} \ No newline at end of file diff --git a/transactions/src/main/java/de/cotto/lndmanagej/transactions/persistence/TransactionDaoImpl.java b/transactions/src/main/java/de/cotto/lndmanagej/transactions/persistence/TransactionDaoImpl.java new file mode 100644 index 00000000..95fbe6c7 --- /dev/null +++ b/transactions/src/main/java/de/cotto/lndmanagej/transactions/persistence/TransactionDaoImpl.java @@ -0,0 +1,29 @@ +package de.cotto.lndmanagej.transactions.persistence; + +import de.cotto.lndmanagej.transactions.TransactionDao; +import de.cotto.lndmanagej.transactions.model.Transaction; +import org.springframework.stereotype.Component; + +import javax.transaction.Transactional; +import java.util.Optional; + +@Component +@Transactional +public class TransactionDaoImpl implements TransactionDao { + private final TransactionRepository transactionRepository; + + public TransactionDaoImpl(TransactionRepository transactionRepository) { + this.transactionRepository = transactionRepository; + } + + @Override + public Optional getTransaction(String transactionHash) { + return transactionRepository.findById(transactionHash) + .flatMap(TransactionJpaDto::toModel); + } + + @Override + public void saveTransaction(Transaction transaction) { + transactionRepository.save(TransactionJpaDto.fromModel(transaction)); + } +} diff --git a/transactions/src/main/java/de/cotto/lndmanagej/transactions/persistence/TransactionJpaDto.java b/transactions/src/main/java/de/cotto/lndmanagej/transactions/persistence/TransactionJpaDto.java new file mode 100644 index 00000000..6784ffcf --- /dev/null +++ b/transactions/src/main/java/de/cotto/lndmanagej/transactions/persistence/TransactionJpaDto.java @@ -0,0 +1,93 @@ +package de.cotto.lndmanagej.transactions.persistence; + +import com.google.common.annotations.VisibleForTesting; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.transactions.model.Transaction; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; +import java.util.Optional; + +@Entity +@Table(name = "transactions") +class TransactionJpaDto { + @Id + @Nullable + private String hash; + + private int blockHeight; + + private long fees; + + private int positionInBlock; + + TransactionJpaDto() { + // for JPA + } + + protected static TransactionJpaDto fromModel(Transaction transaction) { + TransactionJpaDto dto = new TransactionJpaDto(); + dto.setHash(transaction.hash()); + dto.setBlockHeight(transaction.blockHeight()); + dto.setFees(transaction.fees().satoshis()); + dto.setPositionInBlock(transaction.positionInBlock()); + return dto; + } + + protected Optional toModel() { + if (hash == null) { + return Optional.empty(); + } + return Optional.of(new Transaction( + hash, + blockHeight, + positionInBlock, + Coins.ofSatoshis(fees) + )); + } + + @VisibleForTesting + protected void setHash(@Nonnull String hash) { + this.hash = hash; + } + + @VisibleForTesting + protected void setBlockHeight(int blockHeight) { + this.blockHeight = blockHeight; + } + + @VisibleForTesting + protected void setFees(long fees) { + this.fees = fees; + } + + @VisibleForTesting + protected void setPositionInBlock(int positionInBlock) { + this.positionInBlock = positionInBlock; + } + + @VisibleForTesting + protected int getBlockHeight() { + return blockHeight; + } + + @CheckForNull + @VisibleForTesting + protected String getHash() { + return hash; + } + + @VisibleForTesting + protected int getPositionInBlock() { + return positionInBlock; + } + + @VisibleForTesting + protected long getFees() { + return fees; + } +} diff --git a/transactions/src/main/java/de/cotto/lndmanagej/transactions/persistence/TransactionRepository.java b/transactions/src/main/java/de/cotto/lndmanagej/transactions/persistence/TransactionRepository.java new file mode 100644 index 00000000..6c8851b7 --- /dev/null +++ b/transactions/src/main/java/de/cotto/lndmanagej/transactions/persistence/TransactionRepository.java @@ -0,0 +1,6 @@ +package de.cotto.lndmanagej.transactions.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TransactionRepository extends JpaRepository { +} 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 new file mode 100644 index 00000000..9db5b47c --- /dev/null +++ b/transactions/src/main/java/de/cotto/lndmanagej/transactions/service/TransactionService.java @@ -0,0 +1,36 @@ +package de.cotto.lndmanagej.transactions.service; + +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; + +@Component +public class TransactionService { + private final TransactionDao transactionDao; + private final TransactionProvider transactionProvider; + + public TransactionService( + TransactionDao transactionDao, + TransactionProvider transactionProvider + ) { + this.transactionDao = transactionDao; + this.transactionProvider = transactionProvider; + } + + public Optional getTransaction(String transactionHash) { + Optional persistedTransaction = transactionDao.getTransaction(transactionHash); + if (persistedTransaction.isPresent()) { + return persistedTransaction; + } + return downloadAndPersist(transactionHash); + } + + private Optional downloadAndPersist(String transactionHash) { + Optional optionalTransaction = transactionProvider.get(transactionHash); + optionalTransaction.ifPresent(transactionDao::saveTransaction); + return optionalTransaction; + } +} diff --git a/transactions/src/test/java/de/cotto/lndmanagej/transactions/download/BlockcypherTransactionDtoTest.java b/transactions/src/test/java/de/cotto/lndmanagej/transactions/download/BlockcypherTransactionDtoTest.java new file mode 100644 index 00000000..b60fe4da --- /dev/null +++ b/transactions/src/test/java/de/cotto/lndmanagej/transactions/download/BlockcypherTransactionDtoTest.java @@ -0,0 +1,61 @@ +package de.cotto.lndmanagej.transactions.download; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static de.cotto.lndmanagej.model.ChannelPointFixtures.TRANSACTION_HASH; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.BLOCK_HEIGHT; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.FEES; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.POSITION_IN_BLOCK; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.TRANSACTION; +import static org.assertj.core.api.Assertions.assertThat; + +class BlockcypherTransactionDtoTest { + private final ObjectMapper objectMapper = new TestObjectMapper(); + + @Test + void deserialization() throws Exception { + String json = """ + { + "block_height": %d, + "hash": "%s", + "fees": %d, + "confirmed": "2019-10-26T20:06:35Z", + "received": "2019-10-26T20:06:34Z", + "block_index": %d, + "inputs": [ + { + "output_value": 100, + "addresses": [ + "aaa" + ] + }, + { + "output_value": 200, + "addresses": [ + "bbb" + ] + } + ], + "outputs": [ + { + "value": 123, + "addresses": [ + "abc" + ] + }, + { + "value": 456, + "addresses": [ + "def" + ] + } + ] + }""".formatted( + BLOCK_HEIGHT, TRANSACTION_HASH, FEES.satoshis(), POSITION_IN_BLOCK + ); + BlockcypherTransactionDto blockcypherTransactionDto = + objectMapper.readValue(json, BlockcypherTransactionDto.class); + assertThat(blockcypherTransactionDto.toModel()).isEqualTo(TRANSACTION); + } +} \ No newline at end of file diff --git a/transactions/src/test/java/de/cotto/lndmanagej/transactions/download/FeignConfigurationTest.java b/transactions/src/test/java/de/cotto/lndmanagej/transactions/download/FeignConfigurationTest.java new file mode 100644 index 00000000..d67f5e14 --- /dev/null +++ b/transactions/src/test/java/de/cotto/lndmanagej/transactions/download/FeignConfigurationTest.java @@ -0,0 +1,13 @@ +package de.cotto.lndmanagej.transactions.download; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FeignConfigurationTest { + + @Test + void test() { + assertThat(new FeignConfiguration()).isNotNull(); + } +} \ No newline at end of file diff --git a/transactions/src/test/java/de/cotto/lndmanagej/transactions/download/TestObjectMapper.java b/transactions/src/test/java/de/cotto/lndmanagej/transactions/download/TestObjectMapper.java new file mode 100644 index 00000000..3b5b35f8 --- /dev/null +++ b/transactions/src/test/java/de/cotto/lndmanagej/transactions/download/TestObjectMapper.java @@ -0,0 +1,11 @@ +package de.cotto.lndmanagej.transactions.download; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class TestObjectMapper extends ObjectMapper { + public TestObjectMapper() { + super(); + configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } +} diff --git a/transactions/src/test/java/de/cotto/lndmanagej/transactions/download/TransactionProviderTest.java b/transactions/src/test/java/de/cotto/lndmanagej/transactions/download/TransactionProviderTest.java new file mode 100644 index 00000000..771ee39f --- /dev/null +++ b/transactions/src/test/java/de/cotto/lndmanagej/transactions/download/TransactionProviderTest.java @@ -0,0 +1,50 @@ +package de.cotto.lndmanagej.transactions.download; + +import feign.FeignException; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; +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 de.cotto.lndmanagej.transactions.download.BlockcypherTransactionDtoFixtures.BLOCKCYPHER_TRANSACTION; +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.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TransactionProviderTest { + @InjectMocks + private TransactionProvider transactionProvider; + + @Mock + private BlockcypherClient blockcypherClient; + + @Test + void get_empty() { + assertThat(transactionProvider.get(TRANSACTION_HASH)).isEmpty(); + } + + @Test + void get_feign_exception() { + when(blockcypherClient.getTransaction(any())).thenThrow(FeignException.class); + assertThat(transactionProvider.get(TRANSACTION_HASH)).isEmpty(); + } + + @Test + void get_rate_limited_exception() { + when(blockcypherClient.getTransaction(any())).thenThrow(RequestNotPermitted.class); + assertThat(transactionProvider.get(TRANSACTION_HASH)).isEmpty(); + } + + @Test + void get() { + when(blockcypherClient.getTransaction(TRANSACTION_HASH)).thenReturn(Optional.of(BLOCKCYPHER_TRANSACTION)); + assertThat(transactionProvider.get(TRANSACTION_HASH)).contains(TRANSACTION); + } +} \ No newline at end of file diff --git a/transactions/src/test/java/de/cotto/lndmanagej/transactions/model/TransactionTest.java b/transactions/src/test/java/de/cotto/lndmanagej/transactions/model/TransactionTest.java new file mode 100644 index 00000000..79a27ce8 --- /dev/null +++ b/transactions/src/test/java/de/cotto/lndmanagej/transactions/model/TransactionTest.java @@ -0,0 +1,39 @@ +package de.cotto.lndmanagej.transactions.model; + +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; + +import static de.cotto.lndmanagej.model.ChannelPointFixtures.TRANSACTION_HASH; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.BLOCK_HEIGHT; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.FEES; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.POSITION_IN_BLOCK; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.TRANSACTION; +import static org.assertj.core.api.Assertions.assertThat; + +class TransactionTest { + + @Test + void getHash() { + assertThat(TRANSACTION.hash()).isEqualTo(TRANSACTION_HASH); + } + + @Test + void getFees() { + assertThat(TRANSACTION.fees()).isEqualTo(FEES); + } + + @Test + void getPositionInBlock() { + assertThat(TRANSACTION.positionInBlock()).isEqualTo(POSITION_IN_BLOCK); + } + + @Test + void getBlockHeight() { + assertThat(TRANSACTION.blockHeight()).isEqualTo(BLOCK_HEIGHT); + } + + @Test + void testEquals() { + EqualsVerifier.forClass(Transaction.class).usingGetClass().verify(); + } +} \ No newline at end of file diff --git a/transactions/src/test/java/de/cotto/lndmanagej/transactions/persistence/TransactionDaoImplTest.java b/transactions/src/test/java/de/cotto/lndmanagej/transactions/persistence/TransactionDaoImplTest.java new file mode 100644 index 00000000..006e7d1b --- /dev/null +++ b/transactions/src/test/java/de/cotto/lndmanagej/transactions/persistence/TransactionDaoImplTest.java @@ -0,0 +1,51 @@ +package de.cotto.lndmanagej.transactions.persistence; + +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 de.cotto.lndmanagej.transactions.model.TransactionFixtures.BLOCK_HEIGHT; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.FEES; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.POSITION_IN_BLOCK; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.TRANSACTION; +import static de.cotto.lndmanagej.transactions.persistence.TransactionJpaDtoFixtures.TRANSACTION_JPA_DTO; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TransactionDaoImplTest { + + @InjectMocks + private TransactionDaoImpl transactionDao; + + @Mock + private TransactionRepository transactionRepository; + + @Test + void getTransaction_unknown() { + assertThat(transactionDao.getTransaction(TRANSACTION_HASH)).isEmpty(); + } + + @Test + void getTransaction() { + when(transactionRepository.findById(TRANSACTION_HASH)).thenReturn(Optional.of(TRANSACTION_JPA_DTO)); + + assertThat(transactionDao.getTransaction(TRANSACTION_HASH)).contains(TRANSACTION); + } + + @Test + void saveTransaction() { + transactionDao.saveTransaction(TRANSACTION); + verify(transactionRepository).save(argThat(dto -> TRANSACTION_HASH.equals(dto.getHash()))); + verify(transactionRepository).save(argThat(dto -> BLOCK_HEIGHT == dto.getBlockHeight())); + verify(transactionRepository).save(argThat(dto -> POSITION_IN_BLOCK == dto.getPositionInBlock())); + verify(transactionRepository).save(argThat(dto -> FEES.satoshis() == dto.getFees())); + } +} \ No newline at end of file diff --git a/transactions/src/test/java/de/cotto/lndmanagej/transactions/persistence/TransactionJpaDtoTest.java b/transactions/src/test/java/de/cotto/lndmanagej/transactions/persistence/TransactionJpaDtoTest.java new file mode 100644 index 00000000..fcc6f375 --- /dev/null +++ b/transactions/src/test/java/de/cotto/lndmanagej/transactions/persistence/TransactionJpaDtoTest.java @@ -0,0 +1,27 @@ +package de.cotto.lndmanagej.transactions.persistence; + +import org.junit.jupiter.api.Test; + +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.TRANSACTION; +import static de.cotto.lndmanagej.transactions.persistence.TransactionJpaDtoFixtures.TRANSACTION_JPA_DTO; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class TransactionJpaDtoTest { + + @Test + void toModel_nullHash() { + TransactionJpaDto dto = new TransactionJpaDto(); + assertThat(dto.toModel()).isEmpty(); + } + + @Test + void toModel() { + assertThat(TRANSACTION_JPA_DTO.toModel()).contains(TRANSACTION); + } + + @Test + void fromModel() { + TransactionJpaDto fromModel = TransactionJpaDto.fromModel(TRANSACTION); + assertThat(fromModel).usingRecursiveComparison().isEqualTo(TRANSACTION_JPA_DTO); + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..3e7a66d8 --- /dev/null +++ b/transactions/src/test/java/de/cotto/lndmanagej/transactions/service/TransactionServiceTest.java @@ -0,0 +1,60 @@ +package de.cotto.lndmanagej.transactions.service; + +import de.cotto.lndmanagej.transactions.TransactionDao; +import de.cotto.lndmanagej.transactions.download.TransactionProvider; +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 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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TransactionServiceTest { + @InjectMocks + private TransactionService transactionService; + + @Mock + private TransactionDao transactionDao; + + @Mock + private TransactionProvider transactionProvider; + + @Test + void getTransaction_empty() { + assertThat(transactionService.getTransaction(TRANSACTION_HASH)).isEmpty(); + } + + @Test + void getTransaction_known_in_dao() { + when(transactionDao.getTransaction(TRANSACTION_HASH)).thenReturn(Optional.of(TRANSACTION)); + assertThat(transactionService.getTransaction(TRANSACTION_HASH)).contains(TRANSACTION); + verify(transactionDao, never()).saveTransaction(any()); + verify(transactionProvider, never()).get(any()); + } + + @Test + void getTransaction_unknown_in_dao_successful_download() { + when(transactionDao.getTransaction(TRANSACTION_HASH)).thenReturn(Optional.empty()); + when(transactionProvider.get(TRANSACTION_HASH)).thenReturn(Optional.of(TRANSACTION)); + assertThat(transactionService.getTransaction(TRANSACTION_HASH)).contains(TRANSACTION); + verify(transactionDao).saveTransaction(TRANSACTION); + } + + @Test + void getTransaction_unknown_in_dao_failed_download() { + when(transactionDao.getTransaction(TRANSACTION_HASH)).thenReturn(Optional.empty()); + when(transactionProvider.get(TRANSACTION_HASH)).thenReturn(Optional.empty()); + assertThat(transactionService.getTransaction(TRANSACTION_HASH)).isEmpty(); + verify(transactionDao, never()).saveTransaction(any()); + } +} \ No newline at end of file diff --git a/transactions/src/testFixtures/java/de/cotto/lndmanagej/transactions/download/BlockcypherTransactionDtoFixtures.java b/transactions/src/testFixtures/java/de/cotto/lndmanagej/transactions/download/BlockcypherTransactionDtoFixtures.java new file mode 100644 index 00000000..a661549b --- /dev/null +++ b/transactions/src/testFixtures/java/de/cotto/lndmanagej/transactions/download/BlockcypherTransactionDtoFixtures.java @@ -0,0 +1,11 @@ +package de.cotto.lndmanagej.transactions.download; + +import static de.cotto.lndmanagej.model.ChannelPointFixtures.TRANSACTION_HASH; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.BLOCK_HEIGHT; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.FEES; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.POSITION_IN_BLOCK; + +public class BlockcypherTransactionDtoFixtures { + public static final BlockcypherTransactionDto BLOCKCYPHER_TRANSACTION = + new BlockcypherTransactionDto(TRANSACTION_HASH, BLOCK_HEIGHT, POSITION_IN_BLOCK, FEES); +} diff --git a/transactions/src/testFixtures/java/de/cotto/lndmanagej/transactions/model/TransactionFixtures.java b/transactions/src/testFixtures/java/de/cotto/lndmanagej/transactions/model/TransactionFixtures.java new file mode 100644 index 00000000..3e7815a1 --- /dev/null +++ b/transactions/src/testFixtures/java/de/cotto/lndmanagej/transactions/model/TransactionFixtures.java @@ -0,0 +1,13 @@ +package de.cotto.lndmanagej.transactions.model; + +import de.cotto.lndmanagej.model.Coins; + +import static de.cotto.lndmanagej.model.ChannelPointFixtures.TRANSACTION_HASH; + +public class TransactionFixtures { + public static final int BLOCK_HEIGHT = 700_123; + public static final int POSITION_IN_BLOCK = 1234; + public static final Coins FEES = Coins.ofSatoshis(123); + public static final Transaction TRANSACTION = + new Transaction(TRANSACTION_HASH, BLOCK_HEIGHT, POSITION_IN_BLOCK, FEES); +} diff --git a/transactions/src/testFixtures/java/de/cotto/lndmanagej/transactions/persistence/TransactionJpaDtoFixtures.java b/transactions/src/testFixtures/java/de/cotto/lndmanagej/transactions/persistence/TransactionJpaDtoFixtures.java new file mode 100644 index 00000000..5d0d52fe --- /dev/null +++ b/transactions/src/testFixtures/java/de/cotto/lndmanagej/transactions/persistence/TransactionJpaDtoFixtures.java @@ -0,0 +1,18 @@ +package de.cotto.lndmanagej.transactions.persistence; + +import static de.cotto.lndmanagej.model.ChannelPointFixtures.TRANSACTION_HASH; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.BLOCK_HEIGHT; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.FEES; +import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.POSITION_IN_BLOCK; + +public class TransactionJpaDtoFixtures { + public static final TransactionJpaDto TRANSACTION_JPA_DTO; + + static { + TRANSACTION_JPA_DTO = new TransactionJpaDto(); + TRANSACTION_JPA_DTO.setHash(TRANSACTION_HASH); + TRANSACTION_JPA_DTO.setBlockHeight(BLOCK_HEIGHT); + TRANSACTION_JPA_DTO.setPositionInBlock(POSITION_IN_BLOCK); + TRANSACTION_JPA_DTO.setFees(FEES.satoshis()); + } +}