From ce5f38104b0e4e94cd49b36d7854e14d5dd62356 Mon Sep 17 00:00:00 2001 From: Andrius Dagys Date: Thu, 9 Aug 2018 18:33:51 +0100 Subject: [PATCH] ENT-2168: Add a shell command to check for an existing transaction (#3762) * ENT-2168: Add a shell command to check for an existing transaction When a double-spend occurs the notary returns the hash of the consuming transaction id. I've added a 'hash-lookup' shell command that matches any recorded transactions on the node against this id hash to determine whether the state has been consumed by this node (that could happen in certain race conditions). --- .../net/corda/core/flows/NotaryError.kt | 6 +- .../tools/shell/HashLookupCommandTest.kt | 96 +++++++++++++++++++ .../tools/shell/HashLookupShellCommand.java | 59 ++++++++++++ .../net/corda/tools/shell/InteractiveShell.kt | 1 + 4 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 tools/shell/src/integration-test/kotlin/net/corda/tools/shell/HashLookupCommandTest.kt create mode 100644 tools/shell/src/main/java/net/corda/tools/shell/HashLookupShellCommand.java diff --git a/core/src/main/kotlin/net/corda/core/flows/NotaryError.kt b/core/src/main/kotlin/net/corda/core/flows/NotaryError.kt index 03298f1751..91a8075b6d 100644 --- a/core/src/main/kotlin/net/corda/core/flows/NotaryError.kt +++ b/core/src/main/kotlin/net/corda/core/flows/NotaryError.kt @@ -27,9 +27,9 @@ sealed class NotaryError { /** Specifies which states have already been consumed in another transaction. */ val consumedStates: Map ) : NotaryError() { - override fun toString() = "Conflict notarising transaction $txId. " + - "Input states have been used in another transactions, count: ${consumedStates.size}, " + - "content: ${consumedStates.asSequence().joinToString(limit = 5) { it.key.toString() + "->" + it.value }}" + override fun toString() = "One or more input states have already been used in other transactions. Conflicting state count: ${consumedStates.size}, consumption details:\n" + + "${consumedStates.asSequence().joinToString(",\n", limit = 5) { it.key.toString() + " -> " + it.value }}.\n" + + "To find out if any of the conflicting transactions have been generated by this node you can use the hashLookup Corda shell command." } /** Occurs when time specified in the [TimeWindow] command is outside the allowed tolerance. */ diff --git a/tools/shell/src/integration-test/kotlin/net/corda/tools/shell/HashLookupCommandTest.kt b/tools/shell/src/integration-test/kotlin/net/corda/tools/shell/HashLookupCommandTest.kt new file mode 100644 index 0000000000..e679c53002 --- /dev/null +++ b/tools/shell/src/integration-test/kotlin/net/corda/tools/shell/HashLookupCommandTest.kt @@ -0,0 +1,96 @@ +package net.corda.tools.shell + +import co.paralleluniverse.fibers.Suspendable +import com.google.common.io.Files +import com.jcraft.jsch.ChannelExec +import com.jcraft.jsch.JSch +import com.jcraft.jsch.Session +import net.corda.core.crypto.SecureHash +import net.corda.core.crypto.sha256 +import net.corda.core.flows.FlowLogic +import net.corda.core.flows.StartableByRPC +import net.corda.core.identity.Party +import net.corda.core.messaging.startFlow +import net.corda.core.utilities.getOrThrow +import net.corda.node.services.Permissions +import net.corda.testing.contracts.DummyContract +import net.corda.testing.core.ALICE_NAME +import net.corda.testing.driver.DriverParameters +import net.corda.testing.driver.NodeHandle +import net.corda.testing.driver.driver +import net.corda.testing.node.User +import org.bouncycastle.util.io.Streams +import org.junit.Ignore +import org.junit.Test +import kotlin.test.assertTrue + +class HashLookupCommandTest { + @Ignore + @Test + fun `hash lookup command returns correct response`() { + val user = User("u", "p", setOf(Permissions.all())) + + driver(DriverParameters(notarySpecs = emptyList(), extraCordappPackagesToScan = listOf("net.corda.testing.contracts"))) { + val nodeFuture = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user), startInSameProcess = true) + val node = nodeFuture.getOrThrow() + val txId = issueTransaction(node) + + val session = connectToShell(user, node) + + testCommand(session, command = "hashLookup ${txId.sha256()}", expected = "Found a matching transaction with Id: $txId") + testCommand(session, command = "hashLookup ${SecureHash.randomSHA256()}", expected = "No matching transaction found") + + session.disconnect() + } + } + + private fun testCommand(session: Session, command: String, expected: String) { + val channel = session.openChannel("exec") as ChannelExec + channel.setCommand(command) + channel.connect(5000) + + assertTrue(channel.isConnected) + + val response = String(Streams.readAll(channel.inputStream)) + val matchFound = response.lines().any { line -> + line.contains(expected) + } + channel.disconnect() + assertTrue(matchFound) + } + + private fun connectToShell(user: User, node: NodeHandle): Session { + val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(), + user = user.username, password = user.password, + hostAndPort = node.rpcAddress, + sshdPort = 2224) + + InteractiveShell.startShell(conf) + InteractiveShell.nodeInfo() + + val session = JSch().getSession("u", "localhost", 2224) + session.setConfig("StrictHostKeyChecking", "no") + session.setPassword("p") + session.connect() + + assertTrue(session.isConnected) + return session + } + + private fun issueTransaction(node: NodeHandle): SecureHash { + return node.rpc.startFlow(::DummyIssue).returnValue.get() + } + + @StartableByRPC + internal class DummyIssue : FlowLogic() { + @Suspendable + override fun call(): SecureHash { + val me = serviceHub.myInfo.legalIdentities.first().ref(0) + val fakeNotary = me.party + val builder = DummyContract.generateInitial(1, fakeNotary as Party, me) + val stx = serviceHub.signInitialTransaction(builder) + serviceHub.recordTransactions(stx) + return stx.id + } + } +} \ No newline at end of file diff --git a/tools/shell/src/main/java/net/corda/tools/shell/HashLookupShellCommand.java b/tools/shell/src/main/java/net/corda/tools/shell/HashLookupShellCommand.java new file mode 100644 index 0000000000..038e60ce0d --- /dev/null +++ b/tools/shell/src/main/java/net/corda/tools/shell/HashLookupShellCommand.java @@ -0,0 +1,59 @@ +package net.corda.tools.shell; + +import net.corda.core.crypto.SecureHash; +import net.corda.core.messaging.CordaRPCOps; +import net.corda.core.messaging.StateMachineTransactionMapping; +import org.crsh.cli.Argument; +import org.crsh.cli.Command; +import org.crsh.cli.Man; +import org.crsh.cli.Usage; +import org.crsh.text.Color; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Optional; + +public class HashLookupShellCommand extends InteractiveShellCommand { + private static Logger logger = LoggerFactory.getLogger(HashLookupShellCommand.class); + + @Command + @Man("Checks if a transaction matching a specified Id hash value is recorded on this node.\n\n" + + "This is mainly intended to be used for troubleshooting notarisation issues when a\n" + + "state is claimed to be already consumed by another transaction.\n\n" + + "Example usage: hash-lookup E470FD8A6350A74217B0A99EA5FB71F091C84C64AD0DE0E72ECC10421D03AAC9" + ) + public void main(@Usage("A hexadecimal SHA-256 hash value representing the hashed transaction Id") @Argument(unquote = false) String txIdHash) { + logger.info("Executing command \"hash-lookup\"."); + + if (txIdHash == null) { + out.println("Please provide a hexadecimal transaction Id hash value, see 'man hash-lookup'", Color.red); + return; + } + + CordaRPCOps proxy = ops(); + List mapping = proxy.stateMachineRecordedTransactionMappingSnapshot(); + + SecureHash txIdHashParsed; + try { + txIdHashParsed = SecureHash.parse(txIdHash); + } catch (IllegalArgumentException e) { + out.println("The provided string is not a valid hexadecimal SHA-256 hash value", Color.red); + return; + } + + Optional match = mapping.stream() + .map(StateMachineTransactionMapping::getTransactionId) + .filter( + txId -> SecureHash.sha256(txId.getBytes()).equals(txIdHashParsed) + ) + .findFirst(); + + if (match.isPresent()) { + SecureHash found = match.get(); + out.println("Found a matching transaction with Id: " + found.toString()); + } else { + out.println("No matching transaction found", Color.red); + } + } +} diff --git a/tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt b/tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt index 21814df176..fc90960866 100644 --- a/tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt +++ b/tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt @@ -132,6 +132,7 @@ object InteractiveShell { ExternalResolver.INSTANCE.addCommand("run", "Runs a method from the CordaRPCOps interface on the node.", RunShellCommand::class.java) ExternalResolver.INSTANCE.addCommand("flow", "Commands to work with flows. Flows are how you can change the ledger.", FlowShellCommand::class.java) ExternalResolver.INSTANCE.addCommand("start", "An alias for 'flow start'", StartShellCommand::class.java) + ExternalResolver.INSTANCE.addCommand("hashLookup", "Checks if a transaction with matching Id hash exists.", HashLookupShellCommand::class.java) shell = ShellLifecycle(configuration.commandsDirectory).start(config, configuration.user, configuration.password) }