diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe28ca..89bcba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ This document serves as the change log for the ONIXLabs Corda Core API. - Moved to new extension file naming convention for maintainability. - Added extensions to obtain single inputs, reference inputs and outputs from a `LedgerTransaction`. - Added extension to cast `Iterable>` to `List>`. +- Added `ContractID` interface which automatically binds a contract ID to a contract class. +- Added `SignedCommandData` interface which defines a contract command that must include a signature. +- Added `VerifiedCommandData` interface which verifies a ledger transaction. +- Added `SignatureData` class, which represents a digital signature, and it's unsigned counterpart. ### Workflow diff --git a/build.gradle b/build.gradle index 4d1b5c3..26cb2f5 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,7 @@ buildscript { } group 'io.onixlabs' -version '1.1.0' +version '1.2.0' subprojects { repositories { @@ -93,10 +93,10 @@ task cleanLocal(type: Exec) { task releaseLocal(type: GradleBuild) { startParameter = gradle.startParameter.newInstance() - tasks = ['cleanLocal', 'clean', 'build', 'publishToMavenLocal'] + tasks = ['clean', 'build', 'publishToMavenLocal'] } task releasePublic(type: GradleBuild) { startParameter = gradle.startParameter.newInstance() - tasks = ['cleanLocal', 'clean', 'build', 'publishToMavenLocal', 'publish'] + tasks = ['clean', 'build', 'publishToMavenLocal', 'publish'] } diff --git a/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/ContractID.kt b/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/ContractID.kt new file mode 100644 index 0000000..000ce97 --- /dev/null +++ b/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/ContractID.kt @@ -0,0 +1,12 @@ +package io.onixlabs.corda.core.contract + +import net.corda.core.contracts.ContractClassName + +/** + * Defines an interface which automatically binds a contract ID to a contract class. + * + * @property ID The ID of the contract. + */ +interface ContractID { + val ID: ContractClassName get() = this::class.java.enclosingClass.canonicalName +} diff --git a/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/DummyContract.kt b/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/DummyContract.kt index 4432a1c..ac664ff 100644 --- a/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/DummyContract.kt +++ b/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/DummyContract.kt @@ -16,23 +16,53 @@ package io.onixlabs.corda.core.contract -import net.corda.core.contracts.BelongsToContract -import net.corda.core.contracts.Contract -import net.corda.core.contracts.StateRef +import net.corda.core.contracts.* import net.corda.core.identity.AbstractParty import net.corda.core.transactions.LedgerTransaction +import java.security.PublicKey /** * Represents a dummy state and contract that will never be used. - * This exists so that Corda will load the contract into attachment storage. + * This exists for two reasons: + * 1. So that Corda will load the contract into attachment storage. + * 2. To test contract interface implementations locally. */ @Suppress("UNUSED") internal class DummyContract : Contract { - override fun verify(tx: LedgerTransaction) = Unit + + companion object : ContractID @BelongsToContract(DummyContract::class) - class DummyState : ChainState { - override val previousStateRef: StateRef? get() = null - override val participants: List get() = emptyList() + data class DummyState( + override val participants: List = emptyList(), + override val previousStateRef: StateRef? = null + ) : ChainState + + override fun verify(tx: LedgerTransaction) { + val command = tx.commands.requireSingleCommand() + when (command.value) { + is DummyCommand -> command.value.verify(tx, command.signers.toSet()) + else -> throw IllegalArgumentException("Unrecognised command: ${command.value}.") + } + } + + interface DummyContractCommand : SignedCommandData, VerifiedCommandData + + class DummyCommand(override val signature: SignatureData) : DummyContractCommand { + + companion object { + internal const val CONTRACT_RULE_COMMAND_SIGNED = + "On dummy command, the command must be signed by the dummy state participant." + } + + override fun verify(transaction: LedgerTransaction, signers: Set) = requireThat { + val state = transaction.singleOutputOfType() + val command = transaction.singleCommandOfType() + + val key = state.participants.single().owningKey + val signature = command.value.signature + + CONTRACT_RULE_COMMAND_SIGNED using (signature.verify(key)) + } } } diff --git a/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/Extensions.StateAndRef.kt b/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/Extensions.StateAndRef.kt index 9ab6394..3b762c8 100644 --- a/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/Extensions.StateAndRef.kt +++ b/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/Extensions.StateAndRef.kt @@ -24,11 +24,35 @@ import net.corda.core.contracts.TransactionState * Casts a [StateAndRef] of an unknown [ContractState] to a [StateAndRef] of type [T]. * * @param T The underlying [ContractState] type to cast to. + * @param contractStateClass The [ContractState] class to cast to. * @return Returns a [StateAndRef] of type [T]. * @throws ClassCastException if the unknown [ContractState] type cannot be cast to [T]. */ -inline fun StateAndRef<*>.cast(): StateAndRef where T : ContractState = with(state) { - StateAndRef(TransactionState(T::class.java.cast(data), contract, notary, encumbrance, constraint), ref) +fun StateAndRef<*>.cast(contractStateClass: Class): StateAndRef where T : ContractState = with(state) { + StateAndRef(TransactionState(contractStateClass.cast(data), contract, notary, encumbrance, constraint), ref) +} + +/** + * Casts a [StateAndRef] of an unknown [ContractState] to a [StateAndRef] of type [T]. + * + * @param T The underlying [ContractState] type to cast to. + * @return Returns a [StateAndRef] of type [T]. + * @throws ClassCastException if the unknown [ContractState] type cannot be cast to [T]. + */ +inline fun StateAndRef<*>.cast(): StateAndRef where T : ContractState { + return cast(T::class.java) +} + +/** + * Casts an iterable of [StateAndRef] of an unknown [ContractState] to a list of [StateAndRef] of type [T]. + * + * @param T The underlying [ContractState] type to cast to. + * @param contractStateClass The [ContractState] class to cast to. + * @return Returns a list of [StateAndRef] of type [T]. + * @throws ClassCastException if the unknown [ContractState] type cannot be cast to [T]. + */ +fun Iterable>.cast(contractStateClass: Class): List> where T : ContractState { + return map { it.cast(contractStateClass) } } /** @@ -39,5 +63,5 @@ inline fun StateAndRef<*>.cast(): StateAndRef where T : ContractS * @throws ClassCastException if the unknown [ContractState] type cannot be cast to [T]. */ inline fun Iterable>.cast(): List> where T : ContractState { - return map { it.cast() } + return cast(T::class.java) } diff --git a/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/SignatureData.kt b/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/SignatureData.kt new file mode 100644 index 0000000..b725c84 --- /dev/null +++ b/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/SignatureData.kt @@ -0,0 +1,90 @@ +package io.onixlabs.corda.core.contract + +import net.corda.core.crypto.Crypto +import net.corda.core.crypto.DigitalSignature +import net.corda.core.crypto.sign +import net.corda.core.node.ServiceHub +import net.corda.core.serialization.CordaSerializable +import net.corda.core.utilities.toBase64 +import java.security.PrivateKey +import java.security.PublicKey +import java.util.* + +/** + * Represents an array of unsigned bytes, and its signed equivalent. + * + * @property content The unsigned signature content. + * @property signature The digital signature representing the signed content. + */ +@CordaSerializable +data class SignatureData(private val content: ByteArray, private val signature: DigitalSignature) { + + companion object { + + /** + * Creates a signature from the specified content and private key. + * + * @param content The content to sign. + * @param privateKey The private key to sign the content. + * @return Returns a new signature containing the content and signed data. + */ + fun create(content: ByteArray, privateKey: PrivateKey): SignatureData { + val signature = privateKey.sign(content) + return SignatureData(content, signature) + } + + /** + * Creates a signature from the specified content by resolving the signing key from the service hub. + * + * @param content The content to sign. + * @param publicKey The public key to resolve from the service hub. + * @param serviceHub The service hub to resolve the public key. + * @return Returns a new signature containing the content and signed data. + */ + fun create(content: ByteArray, publicKey: PublicKey, serviceHub: ServiceHub): SignatureData { + val signature = serviceHub.keyManagementService.sign(content, publicKey) + return SignatureData(content, signature.withoutKey()) + } + } + + /** + * Verifies the signature data using the specified public key. + * + * @param publicKey The public key to verify against the signature. + * @return Returns true if the public key was used to sign the data; otherwise, false. + */ + fun verify(publicKey: PublicKey): Boolean { + return Crypto.isValid(publicKey, signature.bytes, content) + } + + /** + * Determines whether the specified object is equal to the current object. + * + * @param other The object to compare with the current object. + * @return Returns true if the specified object is equal to the current object; otherwise, false. + */ + override fun equals(other: Any?): Boolean { + return this === other || (other is SignatureData + && content.contentEquals(other.content) + && signature == other.signature) + } + + /** + * Serves as the default hash function. + * + * @return Returns a hash code for the current object. + */ + override fun hashCode(): Int { + return Objects.hash(signature, content.contentHashCode()) + } + + /** + * Returns a string that represents the current object. + * + * @return Returns a string that represents the current object. + */ + override fun toString(): String = buildString { + appendln("Unsigned bytes: ${content.toBase64()}") + appendln("Signed bytes: ${signature.bytes.toBase64()}") + } +} diff --git a/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/SignedCommandData.kt b/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/SignedCommandData.kt new file mode 100644 index 0000000..4ef52b4 --- /dev/null +++ b/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/SignedCommandData.kt @@ -0,0 +1,12 @@ +package io.onixlabs.corda.core.contract + +import net.corda.core.contracts.CommandData + +/** + * Defines a contract command that must include a signature. + * + * @property signature The signature to include as a payload in the command. + */ +interface SignedCommandData : CommandData { + val signature: SignatureData +} diff --git a/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/VerifiedCommandData.kt b/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/VerifiedCommandData.kt new file mode 100644 index 0000000..05aef5d --- /dev/null +++ b/onixlabs-corda-core-contract/src/main/kotlin/io/onixlabs/corda/core/contract/VerifiedCommandData.kt @@ -0,0 +1,19 @@ +package io.onixlabs.corda.core.contract + +import net.corda.core.contracts.CommandData +import net.corda.core.transactions.LedgerTransaction +import java.security.PublicKey + +/** + * Defines a contract command that can verify a ledger transaction. + */ +interface VerifiedCommandData : CommandData { + + /** + * Verifies a ledger transaction. + * + * @param transaction The ledger transaction to verify. + * @param signers The list of signers expected to sign the transaction. + */ + fun verify(transaction: LedgerTransaction, signers: Set) +} diff --git a/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/ContractTest.kt b/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/ContractTest.kt new file mode 100644 index 0000000..5dc0b5d --- /dev/null +++ b/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/ContractTest.kt @@ -0,0 +1,30 @@ +package io.onixlabs.corda.core.contract + +import net.corda.core.node.NotaryInfo +import net.corda.testing.common.internal.testNetworkParameters +import net.corda.testing.core.TestIdentity +import net.corda.testing.node.MockServices +import org.junit.jupiter.api.BeforeEach + +abstract class ContractTest { + + protected companion object { + private val cordapps = listOf("io.onixlabs.corda.core.contract") + private val contracts = listOf(DummyContract.ID) + + fun keysOf(vararg identities: TestIdentity) = identities.map { it.publicKey } + } + + private lateinit var _services: MockServices + protected val services: MockServices get() = _services + + @BeforeEach + private fun setup() { + val networkParameters = testNetworkParameters( + minimumPlatformVersion = 8, + notaries = listOf(NotaryInfo(NOTARY.party, true)) + ) + _services = MockServices(cordapps, IDENTITY_A, networkParameters, IDENTITY_B, IDENTITY_C) + contracts.forEach { _services.addMockCordapp(it) } + } +} diff --git a/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/DummyContractCommandTests.kt b/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/DummyContractCommandTests.kt new file mode 100644 index 0000000..ed066cc --- /dev/null +++ b/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/DummyContractCommandTests.kt @@ -0,0 +1,22 @@ +package io.onixlabs.corda.core.contract + +import net.corda.core.crypto.SecureHash +import net.corda.testing.node.ledger +import org.junit.jupiter.api.Test + +class DummyContractCommandTests : ContractTest() { + + @Test + fun `On dummy command, the dummy state must be signed by the state participant`() { + services.ledger { + transaction { + val state = DummyContract.DummyState(listOf(IDENTITY_A.party)) + val content = SecureHash.randomSHA256().bytes + val signature = SignatureData.create(content, IDENTITY_B.keyPair.private) + output(DummyContract.ID, state) + command(keysOf(IDENTITY_A), DummyContract.DummyCommand(signature)) + failsWith(DummyContract.DummyCommand.CONTRACT_RULE_COMMAND_SIGNED) + } + } + } +} diff --git a/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/DummyContractTests.kt b/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/DummyContractTests.kt new file mode 100644 index 0000000..b684590 --- /dev/null +++ b/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/DummyContractTests.kt @@ -0,0 +1,20 @@ +package io.onixlabs.corda.core.contract + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class DummyContractTests { + + @Test + fun `DummyContract ID should be the canonical name of the DummyContract class`() { + + // Arrange + val expected = "io.onixlabs.corda.core.contract.DummyContract" + + // Act + val actual = DummyContract.ID + + // Assert + assertEquals(expected, actual) + } +} diff --git a/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/MockData.kt b/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/MockData.kt index ecd0f0d..40f6598 100644 --- a/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/MockData.kt +++ b/onixlabs-corda-core-contract/src/test/kotlin/io/onixlabs/corda/core/contract/MockData.kt @@ -1,8 +1,10 @@ package io.onixlabs.corda.core.contract import net.corda.core.identity.CordaX500Name +import net.corda.testing.core.DUMMY_NOTARY_NAME import net.corda.testing.core.TestIdentity val IDENTITY_A = TestIdentity(CordaX500Name("PartyA", "London", "GB")) val IDENTITY_B = TestIdentity(CordaX500Name("PartyB", "New York", "US")) -val IDENTITY_C = TestIdentity(CordaX500Name("PartyC", "Paris", "FR")) \ No newline at end of file +val IDENTITY_C = TestIdentity(CordaX500Name("PartyC", "Paris", "FR")) +val NOTARY = TestIdentity(DUMMY_NOTARY_NAME)