Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add basic information for closed channels #54

Merged
merged 1 commit into from
Apr 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions documentation/lnd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pubkey> (<type>)"
- type is one out of "cooperative", "cooperative local", "cooperative remote", "force local", "force remote"
- sets the input address description to "Lightning-Channel with <pubkey>"
- for addresses which are used to return channel funds, marks these as owned
- for the opening transaction:
- sets the description to "Opening Channel with <pubkey> (<type>)"
- 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
```
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 1 addition & 12 deletions lnd/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
}
84 changes: 83 additions & 1 deletion lnd/src/main/java/de/cotto/bitbook/lnd/LndService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -38,6 +53,11 @@ public long addFromUnspentOutputs(String json) {
return unspentOutputsService.addFromUnspentOutputs(addresses);
}

public long addFromClosedChannels(String json) {
Set<ClosedChannel> closedChannels = parse(json, this::parseClosedChannels);
return closedChannelsService.addFromClosedChannels(closedChannels);
}

private <T> Set<T> parse(String json, Function<JsonNode, Set<T>> parseFunction) {
try (JsonParser parser = objectMapper.createParser(json)) {
JsonNode rootNode = parser.getCodec().readTree(parser);
Expand Down Expand Up @@ -84,4 +104,66 @@ private Set<String> parseAddressesFromUnspentOutputs(JsonNode rootNode) {
}
return addresses;
}

public Set<ClosedChannel> parseClosedChannels(JsonNode jsonNode) {
JsonNode channels = jsonNode.get("channels");
if (channels == null || !channels.isArray()) {
return Set.of();
}
preloadTransactionHashes(channels);
Set<ClosedChannel> result = new LinkedHashSet<>();
for (JsonNode channelNode : channels) {
if (getValidTransactionHashes(channelNode).isEmpty()) {
continue;
}
result.add(parseClosedChannel(channelNode));
}
return result;
}

private void preloadTransactionHashes(JsonNode channels) {
Set<String> allTransactionHashes = new LinkedHashSet<>();
for (JsonNode channelNode : channels) {
allTransactionHashes.addAll(getValidTransactionHashes(channelNode));
}
allTransactionHashes.parallelStream().forEach(transactionService::getTransactionDetails);
}

private Set<String> getValidTransactionHashes(JsonNode channelNode) {
int closeHeight = channelNode.get("close_height").intValue();
if (closeHeight == 0) {
return Set.of();
}
Set<String> 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(':'));
}
}
Original file line number Diff line number Diff line change
@@ -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<ClosedChannel> 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);
}
}
37 changes: 37 additions & 0 deletions lnd/src/main/java/de/cotto/bitbook/lnd/model/CloseType.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading