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) }