diff --git a/documentation/lnd.md b/documentation/lnd.md index 28d6f898..b33c060b 100644 --- a/documentation/lnd.md +++ b/documentation/lnd.md @@ -43,4 +43,38 @@ Then you can use the command as follows: ``` BitBook$ lnd-add-from-sweeps /tmp/lnd-sweeps.json Added information for 86 sweep transactions +``` + +### Closed Channels +lnd stores information about closed channels, including references to the opening and closing transactions. +This record also includes information about the value returned to your own wallet. + +BitBook offers the command `lnd-add-from-closed-channels` which parses this information and + +- marks the channel address (input of closing transaction) as owned if you opened the channel, + or as foreign if the remote opened the channel +- for the closing transaction: + - sets the description to "Closing Channel with ()" + - type is one out of "cooperative", "cooperative local", "cooperative remote", "force local", "force remote" + - sets the input address description to "Lightning-Channel with " + - for addresses which are used to return channel funds, marks these as owned +- for the opening transaction: + - sets the description to "Opening Channel with ()" + - type is one out of "local", "remote", "unknown" + +Notes: + +- If your lnd node has/had channels to another node you own, setting ownership of the address belonging to the remote + node as *foreign* may be wrong. To avoid this, for addresses already marked as *owned* the ownership is not changed. +- If a transaction opens more than one channel, only one of these is mentioned in the description. + +To run the command: +1. first create the JSON file using lnd: `$ lncli closedchannels > lnd-closedchannels.json` +2. transfer the JSON file to the host where you are running BitBook: `$ scp server:/home/lnd/lnd-closedchannels.json /tmp/` +3. start BitBook + +Then you can use the command as follows: +``` +BitBook$ lnd-add-from-closed-channels /tmp/lnd-closedchannels.json +TODO ``` \ No newline at end of file diff --git a/lnd-cli/src/main/java/de/cotto/bitbook/lnd/cli/LndCommands.java b/lnd-cli/src/main/java/de/cotto/bitbook/lnd/cli/LndCommands.java index afb2e209..b86a7c4b 100644 --- a/lnd-cli/src/main/java/de/cotto/bitbook/lnd/cli/LndCommands.java +++ b/lnd-cli/src/main/java/de/cotto/bitbook/lnd/cli/LndCommands.java @@ -35,6 +35,15 @@ public String lndAddFromUnspentOutputs(File jsonFile) throws IOException { return "Marked " + numberOfUnspentOutputs + " addresses as owned by lnd"; } + @ShellMethod("Add information from closed channels obtained by `lncli closedchannels`") + public String lndAddFromClosedChannels(File jsonFile) throws IOException { + long closedChannels = lndService.addFromClosedChannels(readFile(jsonFile)); + if (closedChannels == 0) { + return "Unable to find closed channel in file"; + } + return "Added information for %d closed channels".formatted(closedChannels); + } + private String readFile(File jsonFile) throws IOException { return Files.readString(jsonFile.toPath(), StandardCharsets.US_ASCII); } diff --git a/lnd-cli/src/test/java/de/cotto/bitbook/lnd/cli/LndCommandsTest.java b/lnd-cli/src/test/java/de/cotto/bitbook/lnd/cli/LndCommandsTest.java index da28659a..bc0c2fef 100644 --- a/lnd-cli/src/test/java/de/cotto/bitbook/lnd/cli/LndCommandsTest.java +++ b/lnd-cli/src/test/java/de/cotto/bitbook/lnd/cli/LndCommandsTest.java @@ -63,6 +63,25 @@ void lndAddFromUnspentOutputs_failure() throws IOException { .isEqualTo("Unable to find unspent output address in file"); } + @Test + void lndAddFromClosedChannels() throws IOException { + when(lndService.addFromClosedChannels(any())).thenReturn(123L); + String json = "{\"foo\": \"bar\"}"; + File file = createTempFile(json); + + assertThat(lndCommands.lndAddFromClosedChannels(file)).isEqualTo("Added information for 123 closed channels"); + + verify(lndService).addFromClosedChannels(json); + } + + @Test + void lndAddFromClosedChannels_failure() throws IOException { + when(lndService.addFromClosedChannels(any())).thenReturn(0L); + File file = createTempFile(); + + assertThat(lndCommands.lndAddFromClosedChannels(file)).isEqualTo("Unable to find closed channel in file"); + } + private File createTempFile(String json) throws IOException { File file = createTempFile(); Files.writeString(file.toPath(), json); diff --git a/lnd/build.gradle b/lnd/build.gradle index 083116e5..e34bbd9d 100644 --- a/lnd/build.gradle +++ b/lnd/build.gradle @@ -7,16 +7,5 @@ dependencies { implementation project(':backend-transaction') implementation project(':ownership') testImplementation testFixtures(project(':backend-transaction-models')) -} - -jacocoTestCoverageVerification { - violationRules { - rules.forEach {rule -> - rule.limits.forEach {limit -> - if (limit.counter == 'BRANCH') { - limit.minimum = 0.96 - } - } - } - } + testFixturesImplementation testFixtures(project(':backend-transaction-models')) } \ No newline at end of file diff --git a/lnd/src/main/java/de/cotto/bitbook/lnd/LndService.java b/lnd/src/main/java/de/cotto/bitbook/lnd/LndService.java index 303aee07..59813cb1 100644 --- a/lnd/src/main/java/de/cotto/bitbook/lnd/LndService.java +++ b/lnd/src/main/java/de/cotto/bitbook/lnd/LndService.java @@ -3,8 +3,15 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import de.cotto.bitbook.backend.transaction.TransactionService; +import de.cotto.bitbook.backend.transaction.model.Coins; +import de.cotto.bitbook.backend.transaction.model.Transaction; +import de.cotto.bitbook.lnd.features.ClosedChannelsService; import de.cotto.bitbook.lnd.features.SweepTransactionsService; import de.cotto.bitbook.lnd.features.UnspentOutputsService; +import de.cotto.bitbook.lnd.model.CloseType; +import de.cotto.bitbook.lnd.model.ClosedChannel; +import de.cotto.bitbook.lnd.model.Initiator; import org.springframework.stereotype.Component; import java.io.IOException; @@ -14,18 +21,26 @@ @Component public class LndService { + private static final String UNKNOWN_HASH = "0000000000000000000000000000000000000000000000000000000000000000"; + private final ObjectMapper objectMapper; + private final ClosedChannelsService closedChannelsService; private final UnspentOutputsService unspentOutputsService; private final SweepTransactionsService sweepTransactionsService; + private final TransactionService transactionService; public LndService( ObjectMapper objectMapper, + ClosedChannelsService closedChannelsService, UnspentOutputsService unspentOutputsService, - SweepTransactionsService sweepTransactionsService + SweepTransactionsService sweepTransactionsService, + TransactionService transactionService ) { this.objectMapper = objectMapper; + this.closedChannelsService = closedChannelsService; this.unspentOutputsService = unspentOutputsService; this.sweepTransactionsService = sweepTransactionsService; + this.transactionService = transactionService; } public long addFromSweeps(String json) { @@ -38,6 +53,11 @@ public long addFromUnspentOutputs(String json) { return unspentOutputsService.addFromUnspentOutputs(addresses); } + public long addFromClosedChannels(String json) { + Set closedChannels = parse(json, this::parseClosedChannels); + return closedChannelsService.addFromClosedChannels(closedChannels); + } + private Set parse(String json, Function> parseFunction) { try (JsonParser parser = objectMapper.createParser(json)) { JsonNode rootNode = parser.getCodec().readTree(parser); @@ -84,4 +104,66 @@ private Set parseAddressesFromUnspentOutputs(JsonNode rootNode) { } return addresses; } + + public Set parseClosedChannels(JsonNode jsonNode) { + JsonNode channels = jsonNode.get("channels"); + if (channels == null || !channels.isArray()) { + return Set.of(); + } + preloadTransactionHashes(channels); + Set result = new LinkedHashSet<>(); + for (JsonNode channelNode : channels) { + if (getValidTransactionHashes(channelNode).isEmpty()) { + continue; + } + result.add(parseClosedChannel(channelNode)); + } + return result; + } + + private void preloadTransactionHashes(JsonNode channels) { + Set allTransactionHashes = new LinkedHashSet<>(); + for (JsonNode channelNode : channels) { + allTransactionHashes.addAll(getValidTransactionHashes(channelNode)); + } + allTransactionHashes.parallelStream().forEach(transactionService::getTransactionDetails); + } + + private Set getValidTransactionHashes(JsonNode channelNode) { + int closeHeight = channelNode.get("close_height").intValue(); + if (closeHeight == 0) { + return Set.of(); + } + Set hashes = new LinkedHashSet<>(); + hashes.add(channelNode.get("closing_tx_hash").textValue()); + hashes.add(parseOpeningTransaction(channelNode)); + if (hashes.contains(UNKNOWN_HASH)) { + return Set.of(); + } + return hashes; + } + + private ClosedChannel parseClosedChannel(JsonNode channelNode) { + String openingTransactionHash = parseOpeningTransaction(channelNode); + String closingTransactionHash = channelNode.get("closing_tx_hash").textValue(); + Transaction openingTransaction = transactionService.getTransactionDetails(openingTransactionHash); + Transaction closingTransaction = transactionService.getTransactionDetails(closingTransactionHash); + return ClosedChannel.builder() + .withChainHash(channelNode.get("chain_hash").textValue()) + .withOpeningTransaction(openingTransaction) + .withClosingTransaction(closingTransaction) + .withRemotePubkey(channelNode.get("remote_pubkey").textValue()) + .withSettledBalance(Coins.ofSatoshis(Long.parseLong(channelNode.get("settled_balance").textValue()))) + .withOpenInitiator(Initiator.fromString(channelNode.get("open_initiator").textValue())) + .withCloseType(CloseType.fromStringAndInitiator( + channelNode.get("close_type").textValue(), + channelNode.get("close_initiator").textValue() + )) + .build(); + } + + private String parseOpeningTransaction(JsonNode channel) { + String channelPoint = channel.get("channel_point").textValue(); + return channelPoint.substring(0, channelPoint.indexOf(':')); + } } diff --git a/lnd/src/main/java/de/cotto/bitbook/lnd/features/ClosedChannelsService.java b/lnd/src/main/java/de/cotto/bitbook/lnd/features/ClosedChannelsService.java new file mode 100644 index 00000000..15eb6d25 --- /dev/null +++ b/lnd/src/main/java/de/cotto/bitbook/lnd/features/ClosedChannelsService.java @@ -0,0 +1,90 @@ +package de.cotto.bitbook.lnd.features; + +import de.cotto.bitbook.backend.AddressDescriptionService; +import de.cotto.bitbook.backend.TransactionDescriptionService; +import de.cotto.bitbook.lnd.model.ClosedChannel; +import de.cotto.bitbook.lnd.model.Initiator; +import de.cotto.bitbook.ownership.AddressOwnershipService; +import de.cotto.bitbook.ownership.OwnershipStatus; +import org.springframework.stereotype.Component; + +import java.util.Set; + +@Component +public class ClosedChannelsService { + private static final String DEFAULT_ADDRESS_DESCRIPTION = "lnd"; + + private final TransactionDescriptionService transactionDescriptionService; + private final AddressDescriptionService addressDescriptionService; + private final AddressOwnershipService addressOwnershipService; + + public ClosedChannelsService( + TransactionDescriptionService transactionDescriptionService, + AddressDescriptionService addressDescriptionService, + AddressOwnershipService addressOwnershipService + ) { + this.transactionDescriptionService = transactionDescriptionService; + this.addressDescriptionService = addressDescriptionService; + this.addressOwnershipService = addressOwnershipService; + } + + public long addFromClosedChannels(Set closedChannels) { + return closedChannels.parallelStream() + .filter(ClosedChannel::isValid) + .map(this::addFromClosedChannel) + .count(); + } + + private ClosedChannel addFromClosedChannel(ClosedChannel closedChannel) { + String channelAddress = closedChannel.getChannelAddress(); + String remotePubkey = closedChannel.getRemotePubkey(); + + setTransactionDescriptions(closedChannel); + setForSettlementAddress(closedChannel); + setChannelAddressOwnershipAndDescription(channelAddress, closedChannel.getOpenInitiator(), remotePubkey); + return closedChannel; + } + + private void setTransactionDescriptions(ClosedChannel closedChannel) { + String remotePubkey = closedChannel.getRemotePubkey(); + transactionDescriptionService.set( + closedChannel.getOpeningTransaction().getHash(), + "Opening Channel with %s (%s)".formatted(remotePubkey, closedChannel.getOpenInitiator()) + ); + transactionDescriptionService.set( + closedChannel.getClosingTransaction().getHash(), + "Closing Channel with %s (%s)".formatted(remotePubkey, closedChannel.getCloseType()) + ); + } + + private void setForSettlementAddress(ClosedChannel closedChannel) { + closedChannel.getSettlementAddress().ifPresent(address -> { + addressOwnershipService.setAddressAsOwned(address); + addressDescriptionService.set(address, DEFAULT_ADDRESS_DESCRIPTION); + }); + } + + private void setChannelAddressOwnershipAndDescription( + String channelAddress, + Initiator openInitiator, + String remotePubkey + ) { + setChannelAddressDescription(channelAddress, remotePubkey); + setChannelAddressOwnership(channelAddress, openInitiator); + } + + private void setChannelAddressOwnership(String channelAddress, Initiator openInitiator) { + if (openInitiator.equals(Initiator.LOCAL)) { + addressOwnershipService.setAddressAsOwned(channelAddress); + } else if (openInitiator.equals(Initiator.REMOTE)) { + OwnershipStatus ownershipStatus = addressOwnershipService.getOwnershipStatus(channelAddress); + if (!OwnershipStatus.OWNED.equals(ownershipStatus)) { + addressOwnershipService.setAddressAsForeign(channelAddress); + } + } + } + + private void setChannelAddressDescription(String channelAddress, String remotePubkey) { + addressDescriptionService.set(channelAddress, "Lightning-Channel with " + remotePubkey); + } +} diff --git a/lnd/src/main/java/de/cotto/bitbook/lnd/model/CloseType.java b/lnd/src/main/java/de/cotto/bitbook/lnd/model/CloseType.java new file mode 100644 index 00000000..8f1f5d19 --- /dev/null +++ b/lnd/src/main/java/de/cotto/bitbook/lnd/model/CloseType.java @@ -0,0 +1,37 @@ +package de.cotto.bitbook.lnd.model; + +public enum CloseType { + COOPERATIVE("cooperative"), + COOPERATIVE_REMOTE("cooperative remote"), + COOPERATIVE_LOCAL("cooperative local"), + FORCE_REMOTE("force remote"), + FORCE_LOCAL("force local"); + + private final String stringRepresentation; + + CloseType(String stringRepresentation) { + this.stringRepresentation = stringRepresentation; + } + + public static CloseType fromStringAndInitiator(String closeType, String closeInitiatorString) { + if ("REMOTE_FORCE_CLOSE".equals(closeType)) { + return FORCE_REMOTE; + } + if ("LOCAL_FORCE_CLOSE".equals(closeType)) { + return FORCE_LOCAL; + } + Initiator closeInitiator = Initiator.fromString(closeInitiatorString); + if (closeInitiator.equals(Initiator.REMOTE)) { + return COOPERATIVE_REMOTE; + } + if (closeInitiator.equals(Initiator.LOCAL)) { + return COOPERATIVE_LOCAL; + } + return COOPERATIVE; + } + + @Override + public String toString() { + return stringRepresentation; + } +} diff --git a/lnd/src/main/java/de/cotto/bitbook/lnd/model/ClosedChannel.java b/lnd/src/main/java/de/cotto/bitbook/lnd/model/ClosedChannel.java new file mode 100644 index 00000000..b5a53794 --- /dev/null +++ b/lnd/src/main/java/de/cotto/bitbook/lnd/model/ClosedChannel.java @@ -0,0 +1,209 @@ +package de.cotto.bitbook.lnd.model; + +import de.cotto.bitbook.backend.transaction.model.Coins; +import de.cotto.bitbook.backend.transaction.model.Output; +import de.cotto.bitbook.backend.transaction.model.Transaction; + +import javax.annotation.Nullable; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +public class ClosedChannel { + private static final String BITCOIN_GENESIS_BLOCK_HASH + = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + + private final String chainHash; + private final Transaction openingTransaction; + private final Transaction closingTransaction; + private final String remotePubkey; + private final Coins settledBalance; + private final Initiator openInitiator; + private final CloseType closeType; + + private ClosedChannel( + String chainHash, + Transaction openingTransaction, + Transaction closingTransaction, + String remotePubkey, + Coins settledBalance, + Initiator openInitiator, + CloseType closeType + ) { + this.chainHash = chainHash; + this.openingTransaction = openingTransaction; + this.closingTransaction = closingTransaction; + this.remotePubkey = remotePubkey; + this.settledBalance = settledBalance; + this.openInitiator = openInitiator; + this.closeType = closeType; + } + + public Transaction getOpeningTransaction() { + return openingTransaction; + } + + public Transaction getClosingTransaction() { + return closingTransaction; + } + + public String getRemotePubkey() { + return remotePubkey; + } + + public Coins getSettledBalance() { + return settledBalance; + } + + public Initiator getOpenInitiator() { + return openInitiator; + } + + public String getChannelAddress() { + return closingTransaction.getInputs().get(0).getAddress(); + } + + public CloseType getCloseType() { + return closeType; + } + + public boolean isValid() { + return BITCOIN_GENESIS_BLOCK_HASH.equals(chainHash) + && !openingTransaction.isInvalid() + && !closingTransaction.isInvalid() + && closingTransaction.getInputs().size() == 1; + } + + public ClosedChannelBuilder toBuilder() { + return builder() + .withChainHash(chainHash) + .withOpeningTransaction(openingTransaction) + .withClosingTransaction(closingTransaction) + .withRemotePubkey(remotePubkey) + .withSettledBalance(settledBalance) + .withOpenInitiator(openInitiator) + .withCloseType(closeType); + } + + public static ClosedChannelBuilder builder() { + return new ClosedChannelBuilder(); + } + + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + public Optional getSettlementAddress() { + List candidates = closingTransaction.getOutputs().stream() + .filter(output -> settledBalance.equals(output.getValue())) + .collect(Collectors.toList()); + if (candidates.size() == 1) { + return Optional.of(candidates.get(0).getAddress()); + } + return Optional.empty(); + } + + @Override + public String toString() { + return "ClosedChannel{" + + "chainHash='" + chainHash + '\'' + + ", openingTransaction=" + openingTransaction + + ", closingTransaction=" + closingTransaction + + ", remotePubkey='" + remotePubkey + '\'' + + ", settledBalance=" + settledBalance + + ", openInitiator=" + openInitiator + + ", closeType=" + closeType + + '}'; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + ClosedChannel that = (ClosedChannel) other; + return Objects.equals(chainHash, that.chainHash) + && Objects.equals(openingTransaction, that.openingTransaction) + && Objects.equals(closingTransaction, that.closingTransaction) + && Objects.equals(remotePubkey, that.remotePubkey) + && Objects.equals(settledBalance, that.settledBalance) + && openInitiator == that.openInitiator + && closeType == that.closeType; + } + + @Override + public int hashCode() { + return Objects.hash( + chainHash, + openingTransaction, + closingTransaction, + remotePubkey, + settledBalance, + openInitiator, + closeType + ); + } + + public static class ClosedChannelBuilder { + private String chainHash = ""; + private Transaction openingTransaction = Transaction.UNKNOWN; + private Transaction closingTransaction = Transaction.UNKNOWN; + private String remotePubkey = ""; + private Coins settledBalance = Coins.NONE; + private Initiator openInitiator = Initiator.UNKNOWN; + + @Nullable + private CloseType closeType; + + private ClosedChannelBuilder() { + } + + public ClosedChannelBuilder withChainHash(String chainHash) { + this.chainHash = chainHash; + return this; + } + + public ClosedChannelBuilder withOpeningTransaction(Transaction openingTransaction) { + this.openingTransaction = openingTransaction; + return this; + } + + public ClosedChannelBuilder withClosingTransaction(Transaction closingTransaction) { + this.closingTransaction = closingTransaction; + return this; + } + + public ClosedChannelBuilder withRemotePubkey(String remotePubkey) { + this.remotePubkey = remotePubkey; + return this; + } + + public ClosedChannelBuilder withSettledBalance(Coins settledBalance) { + this.settledBalance = settledBalance; + return this; + } + + public ClosedChannelBuilder withOpenInitiator(Initiator openInitiator) { + this.openInitiator = openInitiator; + return this; + } + + public ClosedChannelBuilder withCloseType(CloseType closeType) { + this.closeType = closeType; + return this; + } + + public ClosedChannel build() { + return new ClosedChannel( + chainHash, + openingTransaction, + closingTransaction, + remotePubkey, + settledBalance, + openInitiator, + Objects.requireNonNull(closeType) + ); + } + } +} diff --git a/lnd/src/main/java/de/cotto/bitbook/lnd/model/Initiator.java b/lnd/src/main/java/de/cotto/bitbook/lnd/model/Initiator.java new file mode 100644 index 00000000..62260ee4 --- /dev/null +++ b/lnd/src/main/java/de/cotto/bitbook/lnd/model/Initiator.java @@ -0,0 +1,21 @@ +package de.cotto.bitbook.lnd.model; + +import java.util.Locale; + +public enum Initiator { + LOCAL, REMOTE, UNKNOWN; + + public static Initiator fromString(String string) { + if ("INITIATOR_LOCAL".equals(string)) { + return LOCAL; + } else if ("INITIATOR_REMOTE".equals(string)) { + return REMOTE; + } + return UNKNOWN; + } + + @Override + public String toString() { + return name().toLowerCase(Locale.US); + } +} diff --git a/lnd/src/test/java/de/cotto/bitbook/lnd/LndServiceTest.java b/lnd/src/test/java/de/cotto/bitbook/lnd/LndServiceTest.java index bf1cef5d..4f10f5d3 100644 --- a/lnd/src/test/java/de/cotto/bitbook/lnd/LndServiceTest.java +++ b/lnd/src/test/java/de/cotto/bitbook/lnd/LndServiceTest.java @@ -2,8 +2,12 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import de.cotto.bitbook.backend.transaction.TransactionService; +import de.cotto.bitbook.backend.transaction.model.Coins; +import de.cotto.bitbook.lnd.features.ClosedChannelsService; import de.cotto.bitbook.lnd.features.SweepTransactionsService; import de.cotto.bitbook.lnd.features.UnspentOutputsService; +import de.cotto.bitbook.lnd.model.ClosedChannel; import de.cotto.bitbook.ownership.AddressOwnershipService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -14,7 +18,13 @@ import java.util.Set; +import static de.cotto.bitbook.backend.transaction.model.TransactionFixtures.TRANSACTION_HASH; +import static de.cotto.bitbook.backend.transaction.model.TransactionFixtures.TRANSACTION_HASH_2; +import static de.cotto.bitbook.lnd.model.ClosedChannelFixtures.CLOSED_CHANNEL; +import static de.cotto.bitbook.lnd.model.ClosedChannelFixtures.CLOSING_TRANSACTION; +import static de.cotto.bitbook.lnd.model.ClosedChannelFixtures.OPENING_TRANSACTION; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -22,9 +32,15 @@ class LndServiceTest { private LndService lndService; + @Mock + private TransactionService transactionService; + @Mock private AddressOwnershipService addressOwnershipService; + @Mock + private ClosedChannelsService closedChannelsService; + @Mock private UnspentOutputsService unspentOutputsService; @@ -37,8 +53,10 @@ void setUp() { new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); lndService = new LndService( objectMapper, + closedChannelsService, unspentOutputsService, - sweepTransactionsService + sweepTransactionsService, + transactionService ); } @@ -143,4 +161,119 @@ private void assertFailure(String json) { verifyNoInteractions(addressOwnershipService); } } + + @Nested + class AddFromClosedChannels { + @Test + void empty_json() { + assertFailure(""); + } + + @Test + void not_json() { + assertFailure("---"); + } + + @Test + void empty_json_object() { + assertFailure("{}"); + } + + @Test + void no_channels() { + assertFailure("{\"foo\": 1}"); + } + + @Test + void not_array() { + String json = "{\"channels\":1}"; + assertFailure(json); + } + + @Test + void skips_channels_with_unconfirmed_close_transactions() { + int closeHeight = 0; + String json = "{\"channels\": [" + + "{" + + "\"channel_point\": \"" + TRANSACTION_HASH + ":123\"" + + ",\"closing_tx_hash\": \"" + TRANSACTION_HASH_2 + "\"" + + ",\"remote_pubkey\": \"pubkey\"" + + ",\"chain_hash\": \"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f\"" + + ",\"settled_balance\": \"123\"" + + ",\"close_height\": " + closeHeight + + ",\"close_type\": \"COOPERATIVE_CLOSE\"" + + ",\"open_initiator\": \"INITIATOR_REMOTE\"" + + ",\"close_initiator\": \"INITIATOR_REMOTE\"" + + ",\"resolutions\": []" + + "}" + + "]}"; + + lndService.addFromClosedChannels(json); + + verify(closedChannelsService).addFromClosedChannels(Set.of()); + verifyNoInteractions(transactionService); + } + + @Test + void skips_channels_with_unknown_close_transactions() { + String closingTransactionHash = "0000000000000000000000000000000000000000000000000000000000000000"; + String json = "{\"channels\": [" + + "{" + + "\"channel_point\": \"" + TRANSACTION_HASH + ":123\"" + + ",\"closing_tx_hash\": \"" + closingTransactionHash + "\"" + + ",\"remote_pubkey\": \"pubkey\"" + + ",\"chain_hash\": \"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f\"" + + ",\"settled_balance\": \"123\"" + + ",\"close_height\": 123" + + ",\"close_type\": \"COOPERATIVE_CLOSE\"" + + ",\"open_initiator\": \"INITIATOR_REMOTE\"" + + ",\"close_initiator\": \"INITIATOR_REMOTE\"" + + ",\"resolutions\": []" + + "}" + + "]}"; + + lndService.addFromClosedChannels(json); + + verify(closedChannelsService).addFromClosedChannels(Set.of()); + verifyNoInteractions(transactionService); + } + + @Test + void success() { + when(transactionService.getTransactionDetails(TRANSACTION_HASH)).thenReturn(OPENING_TRANSACTION); + when(transactionService.getTransactionDetails(TRANSACTION_HASH_2)).thenReturn(CLOSING_TRANSACTION); + ClosedChannel closedChannel2 = CLOSED_CHANNEL.toBuilder().withSettledBalance(Coins.ofSatoshis(500)).build(); + when(closedChannelsService.addFromClosedChannels(Set.of(CLOSED_CHANNEL, closedChannel2))).thenReturn(2L); + + long result = lndService.addFromClosedChannels( + "{\"channels\": [" + + getJsonSingleClosedChannel(CLOSED_CHANNEL.getSettledBalance()) + + "," + + getJsonSingleClosedChannel(closedChannel2.getSettledBalance()) + + "]}" + ); + + assertThat(result).isEqualTo(2); + } + + private String getJsonSingleClosedChannel(Coins settledBalance) { + return "{" + + "\"channel_point\": \"" + TRANSACTION_HASH + ":123\"," + + "\"closing_tx_hash\": \"" + TRANSACTION_HASH_2 + "\"," + + "\"remote_pubkey\": \"pubkey\"," + + "\"chain_hash\": \"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f\"," + + "\"settled_balance\": \"" + settledBalance.getSatoshis() + "\"," + + "\"close_height\": 123," + + "\"close_type\": \"COOPERATIVE_CLOSE\"," + + "\"open_initiator\": \"INITIATOR_REMOTE\"," + + "\"close_initiator\": \"INITIATOR_REMOTE\"," + + "\"resolutions\": []" + + "}"; + } + + private void assertFailure(String json) { + assertThat(lndService.addFromClosedChannels(json)).isEqualTo(0); + verifyNoInteractions(addressOwnershipService); + } + } } \ No newline at end of file diff --git a/lnd/src/test/java/de/cotto/bitbook/lnd/features/ClosedChannelsServiceTest.java b/lnd/src/test/java/de/cotto/bitbook/lnd/features/ClosedChannelsServiceTest.java new file mode 100644 index 00000000..07793047 --- /dev/null +++ b/lnd/src/test/java/de/cotto/bitbook/lnd/features/ClosedChannelsServiceTest.java @@ -0,0 +1,160 @@ +package de.cotto.bitbook.lnd.features; + +import de.cotto.bitbook.backend.AddressDescriptionService; +import de.cotto.bitbook.backend.TransactionDescriptionService; +import de.cotto.bitbook.lnd.model.CloseType; +import de.cotto.bitbook.lnd.model.ClosedChannel; +import de.cotto.bitbook.ownership.AddressOwnershipService; +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.Set; + +import static de.cotto.bitbook.backend.transaction.model.TransactionFixtures.TRANSACTION; +import static de.cotto.bitbook.lnd.model.ClosedChannelFixtures.AMBIGUOUS_SETTLEMENT_ADDRESS; +import static de.cotto.bitbook.lnd.model.ClosedChannelFixtures.CLOSED_CHANNEL; +import static de.cotto.bitbook.lnd.model.Initiator.LOCAL; +import static de.cotto.bitbook.lnd.model.Initiator.REMOTE; +import static de.cotto.bitbook.lnd.model.Initiator.UNKNOWN; +import static de.cotto.bitbook.ownership.OwnershipStatus.OWNED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ClosedChannelsServiceTest { + private static final String DEFAULT_DESCRIPTION = "lnd"; + + @InjectMocks + private ClosedChannelsService closedChannelsService; + + @Mock + private AddressDescriptionService addressDescriptionService; + + @Mock + private TransactionDescriptionService transactionDescriptionService; + + @Mock + private AddressOwnershipService addressOwnershipService; + + @Test + void no_channel() { + assertThat(closedChannelsService.addFromClosedChannels(Set.of())).isEqualTo(0); + } + + @Test + void two_channels() { + Set channels = Set.of(CLOSED_CHANNEL, AMBIGUOUS_SETTLEMENT_ADDRESS); + assertThat(closedChannelsService.addFromClosedChannels(channels)).isEqualTo(2); + } + + @Test + void marks_channel_address_as_foreign_for_initiator_remote() { + load(CLOSED_CHANNEL.toBuilder().withOpenInitiator(REMOTE).build()); + verify(addressOwnershipService).setAddressAsForeign(CLOSED_CHANNEL.getChannelAddress()); + } + + @Test + void does_not_change_channel_address_ownership_if_already_marked_as_owned() { + when(addressOwnershipService.getOwnershipStatus(CLOSED_CHANNEL.getChannelAddress())).thenReturn(OWNED); + load(CLOSED_CHANNEL.toBuilder().withOpenInitiator(REMOTE).build()); + verify(addressOwnershipService, never()).setAddressAsForeign(CLOSED_CHANNEL.getChannelAddress()); + } + + @Test + void marks_channel_address_as_owned_for_initiator_local() { + load(CLOSED_CHANNEL.toBuilder().withOpenInitiator(LOCAL).build()); + verify(addressOwnershipService).setAddressAsOwned(CLOSED_CHANNEL.getChannelAddress()); + } + + @Test + void does_not_set_channel_ownership_for_initiator_unknown() { + load(CLOSED_CHANNEL.toBuilder().withOpenInitiator(UNKNOWN).build()); + verify(addressOwnershipService, never()).setAddressAsOwned(CLOSED_CHANNEL.getChannelAddress()); + verify(addressOwnershipService, never()).setAddressAsForeign(CLOSED_CHANNEL.getChannelAddress()); + } + + @Test + void includes_pubkey_in_closing_transaction_description() { + String remotePubKey = "foobar"; + ClosedChannel closedChannel = CLOSED_CHANNEL.toBuilder() + .withCloseType(CloseType.COOPERATIVE_REMOTE) + .withRemotePubkey(remotePubKey) + .build(); + + load(closedChannel); + + verify(transactionDescriptionService).set( + CLOSED_CHANNEL.getClosingTransaction().getHash(), + "Closing Channel with %s (cooperative remote)".formatted(remotePubKey) + ); + } + + @Test + void includes_type_in_closing_transaction_description() { + load(CLOSED_CHANNEL.toBuilder().withCloseType(CloseType.COOPERATIVE).build()); + verify(transactionDescriptionService).set( + CLOSED_CHANNEL.getClosingTransaction().getHash(), + "Closing Channel with pubkey (cooperative)" + ); + } + + @Test + void sets_channel_address_description() { + load(CLOSED_CHANNEL); + String remotePubKey = CLOSED_CHANNEL.getRemotePubkey(); + verify(addressDescriptionService).set( + CLOSED_CHANNEL.getChannelAddress(), + "Lightning-Channel with %s".formatted(remotePubKey) + ); + } + + @Test + void sets_opening_transaction_description_with_initiator() { + load(CLOSED_CHANNEL.toBuilder().withOpenInitiator(REMOTE).build()); + verify(transactionDescriptionService).set( + CLOSED_CHANNEL.getOpeningTransaction().getHash(), + "Opening Channel with pubkey (remote)" + ); + } + + @Test + void marks_settlement_address_as_owned() { + load(CLOSED_CHANNEL); + verify(addressOwnershipService).setAddressAsOwned(CLOSED_CHANNEL.getSettlementAddress().orElseThrow()); + } + + @Test + void adds_description_for_settlement_address() { + load(CLOSED_CHANNEL); + verify(addressDescriptionService).set(CLOSED_CHANNEL.getSettlementAddress().orElseThrow(), DEFAULT_DESCRIPTION); + } + + @Test + void does_not_add_description_for_ambigous_settlement_address() { + load(AMBIGUOUS_SETTLEMENT_ADDRESS); + verify(addressDescriptionService, never()).set(any(), eq(DEFAULT_DESCRIPTION)); + } + + @Test + void does_not_mark_settlement_address_as_owned_if_not_unique() { + load(CLOSED_CHANNEL.toBuilder().withClosingTransaction(TRANSACTION).build()); + verify(addressOwnershipService, never()).setAddressAsOwned(any()); + } + + @Test + void does_not_set_description_for_settled_balance_receive_address_if_not_unique() { + load(CLOSED_CHANNEL.toBuilder().withClosingTransaction(TRANSACTION).build()); + verify(addressDescriptionService, never()).set(any(), any()); + } + + private void load(ClosedChannel closedChannel) { + closedChannelsService.addFromClosedChannels(Set.of(closedChannel)); + } +} diff --git a/lnd/src/test/java/de/cotto/bitbook/lnd/model/CloseTypeTest.java b/lnd/src/test/java/de/cotto/bitbook/lnd/model/CloseTypeTest.java new file mode 100644 index 00000000..ecd945b7 --- /dev/null +++ b/lnd/src/test/java/de/cotto/bitbook/lnd/model/CloseTypeTest.java @@ -0,0 +1,67 @@ +package de.cotto.bitbook.lnd.model; + +import org.junit.jupiter.api.Test; + +import static de.cotto.bitbook.lnd.model.CloseType.COOPERATIVE; +import static de.cotto.bitbook.lnd.model.CloseType.COOPERATIVE_LOCAL; +import static de.cotto.bitbook.lnd.model.CloseType.COOPERATIVE_REMOTE; +import static de.cotto.bitbook.lnd.model.CloseType.FORCE_LOCAL; +import static de.cotto.bitbook.lnd.model.CloseType.FORCE_REMOTE; +import static org.assertj.core.api.Assertions.assertThat; + +class CloseTypeTest { + @Test + void testToString_cooperative() { + assertThat(COOPERATIVE).hasToString("cooperative"); + } + + @Test + void testToString_cooperative_remote() { + assertThat(COOPERATIVE_REMOTE).hasToString("cooperative remote"); + } + + @Test + void testToString_cooperative_local() { + assertThat(COOPERATIVE_LOCAL).hasToString("cooperative local"); + } + + @Test + void testToString_force_remote() { + assertThat(FORCE_REMOTE).hasToString("force remote"); + } + + @Test + void testToString_force_local() { + assertThat(FORCE_LOCAL).hasToString("force local"); + } + + @Test + void fromStringAndInitiator_cooperative_unknown() { + assertThat(CloseType.fromStringAndInitiator("COOPERATIVE_CLOSE", "INITIATOR_UNKNOWN")) + .isEqualTo(COOPERATIVE); + } + + @Test + void fromStringAndInitiator_cooperative_local() { + assertThat(CloseType.fromStringAndInitiator("COOPERATIVE_CLOSE", "INITIATOR_LOCAL")) + .isEqualTo(COOPERATIVE_LOCAL); + } + + @Test + void fromStringAndInitiator_cooperative_remote() { + assertThat(CloseType.fromStringAndInitiator("COOPERATIVE_CLOSE", "INITIATOR_REMOTE")) + .isEqualTo(COOPERATIVE_REMOTE); + } + + @Test + void fromStringAndInitiator_force_remote() { + assertThat(CloseType.fromStringAndInitiator("REMOTE_FORCE_CLOSE", "INITIATOR_REMOTE")) + .isEqualTo(FORCE_REMOTE); + } + + @Test + void fromStringAndInitiator_force_local() { + assertThat(CloseType.fromStringAndInitiator("LOCAL_FORCE_CLOSE", "INITIATOR_LOCAL")) + .isEqualTo(FORCE_LOCAL); + } +} \ No newline at end of file diff --git a/lnd/src/test/java/de/cotto/bitbook/lnd/model/ClosedChannelTest.java b/lnd/src/test/java/de/cotto/bitbook/lnd/model/ClosedChannelTest.java new file mode 100644 index 00000000..e68576ff --- /dev/null +++ b/lnd/src/test/java/de/cotto/bitbook/lnd/model/ClosedChannelTest.java @@ -0,0 +1,112 @@ +package de.cotto.bitbook.lnd.model; + +import de.cotto.bitbook.backend.transaction.model.Coins; +import de.cotto.bitbook.backend.transaction.model.Transaction; +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; + +import static de.cotto.bitbook.backend.transaction.model.TransactionFixtures.TRANSACTION; +import static de.cotto.bitbook.lnd.model.CloseType.COOPERATIVE_REMOTE; +import static de.cotto.bitbook.lnd.model.ClosedChannelFixtures.AMBIGUOUS_SETTLEMENT_ADDRESS; +import static de.cotto.bitbook.lnd.model.ClosedChannelFixtures.CHANNEL_ADDRESS; +import static de.cotto.bitbook.lnd.model.ClosedChannelFixtures.CLOSED_CHANNEL; +import static de.cotto.bitbook.lnd.model.ClosedChannelFixtures.CLOSING_TRANSACTION; +import static de.cotto.bitbook.lnd.model.ClosedChannelFixtures.OPENING_TRANSACTION; +import static de.cotto.bitbook.lnd.model.ClosedChannelFixtures.SETTLEMENT_ADDRESS; +import static org.assertj.core.api.Assertions.assertThat; + +public class ClosedChannelTest { + @Test + void invalid_for_other_genesis_block_hash() { + ClosedChannel closedChannel = CLOSED_CHANNEL.toBuilder().withChainHash("xxx").build(); + assertThat(closedChannel.isValid()).isFalse(); + } + + @Test + void invalid_for_more_than_one_input() { + ClosedChannel closedChannel = CLOSED_CHANNEL.toBuilder().withClosingTransaction(TRANSACTION).build(); + assertThat(closedChannel.isValid()).isFalse(); + } + + @Test + void invalid_for_unknown_open_transaction() { + ClosedChannel closedChannel = CLOSED_CHANNEL.toBuilder().withOpeningTransaction(Transaction.UNKNOWN).build(); + assertThat(closedChannel.isValid()).isFalse(); + } + + @Test + void invalid_for_unknown_close_transaction() { + ClosedChannel closedChannel = CLOSED_CHANNEL.toBuilder().withClosingTransaction(Transaction.UNKNOWN).build(); + assertThat(closedChannel.isValid()).isFalse(); + } + + @Test + void valid() { + assertThat(CLOSED_CHANNEL.isValid()).isTrue(); + } + + @Test + void getChannelAddress() { + assertThat(CLOSED_CHANNEL.getChannelAddress()).contains(CHANNEL_ADDRESS); + } + + @Test + void getSettlementAddress() { + assertThat(CLOSED_CHANNEL.getSettlementAddress()).contains(SETTLEMENT_ADDRESS); + } + + @Test + void getSettlementAddress_two_candidates() { + assertThat(AMBIGUOUS_SETTLEMENT_ADDRESS.getSettlementAddress()).isEmpty(); + } + + @Test + void getOpeningTransaction() { + assertThat(CLOSED_CHANNEL.getOpeningTransaction()).isEqualTo(OPENING_TRANSACTION); + } + + @Test + void getClosingTransaction() { + assertThat(CLOSED_CHANNEL.getClosingTransaction()).isEqualTo(CLOSING_TRANSACTION); + } + + @Test + void getRemotePubKey() { + assertThat(CLOSED_CHANNEL.getRemotePubkey()).isEqualTo("pubkey"); + } + + @Test + void getSettledBalance() { + assertThat(CLOSED_CHANNEL.getSettledBalance()).isEqualTo(Coins.ofSatoshis(400)); + } + + @Test + void getOpenInitiator() { + assertThat(CLOSED_CHANNEL.getOpenInitiator()).isEqualTo(Initiator.REMOTE); + } + + @Test + void getCloseType() { + assertThat(CLOSED_CHANNEL.getCloseType()).isEqualTo(COOPERATIVE_REMOTE); + } + + @Test + void testToString() { + assertThat(CLOSED_CHANNEL).hasToString( + "ClosedChannel{" + + "chainHash='000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f', " + + "openingTransaction=" + OPENING_TRANSACTION + ", " + + "closingTransaction=" + CLOSING_TRANSACTION + ", " + + "remotePubkey='pubkey', " + + "settledBalance= 0.000004 , " + + "openInitiator=remote, " + + "closeType=cooperative remote" + + "}" + ); + } + + @Test + void testEquals() { + EqualsVerifier.forClass(ClosedChannel.class).usingGetClass().verify(); + } +} diff --git a/lnd/src/test/java/de/cotto/bitbook/lnd/model/InitiatorTest.java b/lnd/src/test/java/de/cotto/bitbook/lnd/model/InitiatorTest.java new file mode 100644 index 00000000..93f7cdd0 --- /dev/null +++ b/lnd/src/test/java/de/cotto/bitbook/lnd/model/InitiatorTest.java @@ -0,0 +1,37 @@ +package de.cotto.bitbook.lnd.model; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class InitiatorTest { + @Test + void testToString_remote() { + assertThat(Initiator.REMOTE).hasToString("remote"); + } + + @Test + void testToString_local() { + assertThat(Initiator.LOCAL).hasToString("local"); + } + + @Test + void testToString_unknown() { + assertThat(Initiator.UNKNOWN).hasToString("unknown"); + } + + @Test + void fromString_local() { + assertThat(Initiator.fromString("INITIATOR_LOCAL")).isEqualTo(Initiator.LOCAL); + } + + @Test + void fromString_remote() { + assertThat(Initiator.fromString("INITIATOR_REMOTE")).isEqualTo(Initiator.REMOTE); + } + + @Test + void fromString_unknown() { + assertThat(Initiator.fromString("INITIATOR_UNKNOWN")).isEqualTo(Initiator.UNKNOWN); + } +} \ No newline at end of file diff --git a/lnd/src/testFixtures/java/de/cotto/bitbook/lnd/model/ClosedChannelFixtures.java b/lnd/src/testFixtures/java/de/cotto/bitbook/lnd/model/ClosedChannelFixtures.java new file mode 100644 index 00000000..8ca0df87 --- /dev/null +++ b/lnd/src/testFixtures/java/de/cotto/bitbook/lnd/model/ClosedChannelFixtures.java @@ -0,0 +1,73 @@ +package de.cotto.bitbook.lnd.model; + +import de.cotto.bitbook.backend.transaction.model.Coins; +import de.cotto.bitbook.backend.transaction.model.Input; +import de.cotto.bitbook.backend.transaction.model.Output; +import de.cotto.bitbook.backend.transaction.model.Transaction; + +import java.util.List; + +import static de.cotto.bitbook.backend.transaction.model.InputFixtures.INPUT_ADDRESS_1; +import static de.cotto.bitbook.backend.transaction.model.InputFixtures.INPUT_ADDRESS_2; +import static de.cotto.bitbook.backend.transaction.model.OutputFixtures.OUTPUT_ADDRESS_1; +import static de.cotto.bitbook.backend.transaction.model.OutputFixtures.OUTPUT_ADDRESS_2; +import static de.cotto.bitbook.backend.transaction.model.TransactionFixtures.BLOCK_HEIGHT; +import static de.cotto.bitbook.backend.transaction.model.TransactionFixtures.DATE_TIME; +import static de.cotto.bitbook.backend.transaction.model.TransactionFixtures.TRANSACTION_HASH; +import static de.cotto.bitbook.backend.transaction.model.TransactionFixtures.TRANSACTION_HASH_2; + +public class ClosedChannelFixtures { + public static final String BITCOIN_GENESIS_BLOCK_HASH + = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + + public static final Coins CHANNEL_CAPACITY = Coins.ofSatoshis(500); + public static final String CHANNEL_ADDRESS = "bc1channel"; + public static final String SETTLEMENT_ADDRESS = OUTPUT_ADDRESS_1; + private static final Coins SETTLED_BALANCE = Coins.ofSatoshis(400); + + public static final Transaction OPENING_TRANSACTION = new Transaction( + TRANSACTION_HASH, + BLOCK_HEIGHT, + DATE_TIME, + Coins.ofSatoshis(100), + List.of( + new Input(Coins.ofSatoshis(550), INPUT_ADDRESS_1), + new Input(Coins.ofSatoshis(50), INPUT_ADDRESS_2) + ), + List.of(new Output(CHANNEL_CAPACITY, CHANNEL_ADDRESS)) + ); + public static final Transaction CLOSING_TRANSACTION = new Transaction( + TRANSACTION_HASH_2, + BLOCK_HEIGHT, + DATE_TIME, + Coins.ofSatoshis(50), + List.of(new Input(CHANNEL_CAPACITY, CHANNEL_ADDRESS)), + List.of(new Output(SETTLED_BALANCE, SETTLEMENT_ADDRESS), new Output(Coins.ofSatoshis(50), OUTPUT_ADDRESS_2)) + ); + private static final String REMOTE_PUBKEY = "pubkey"; + private static final Initiator OPEN_INITIATOR = Initiator.REMOTE; + + public static final ClosedChannel CLOSED_CHANNEL = ClosedChannel.builder() + .withChainHash(BITCOIN_GENESIS_BLOCK_HASH) + .withOpeningTransaction(OPENING_TRANSACTION) + .withClosingTransaction(CLOSING_TRANSACTION) + .withRemotePubkey(REMOTE_PUBKEY) + .withSettledBalance(SETTLED_BALANCE) + .withOpenInitiator(OPEN_INITIATOR) + .withCloseType(CloseType.COOPERATIVE_REMOTE) + .build(); + + public static final ClosedChannel AMBIGUOUS_SETTLEMENT_ADDRESS = CLOSED_CHANNEL.toBuilder() + .withClosingTransaction(new Transaction( + TRANSACTION_HASH_2, + BLOCK_HEIGHT, + DATE_TIME, + Coins.NONE, + List.of(new Input(Coins.ofSatoshis(2 * SETTLED_BALANCE.getSatoshis()), CHANNEL_ADDRESS)), + List.of( + new Output(SETTLED_BALANCE, SETTLEMENT_ADDRESS), + new Output(SETTLED_BALANCE, OUTPUT_ADDRESS_2) + ) + )) + .build(); +}