diff --git a/core-deterministic/build.gradle b/core-deterministic/build.gradle index f7cdb65e9c..a8162e2c18 100644 --- a/core-deterministic/build.gradle +++ b/core-deterministic/build.gradle @@ -51,6 +51,7 @@ task patchCore(type: Zip, dependsOn: coreJarTask) { exclude 'net/corda/core/internal/*ToggleField*.class' exclude 'net/corda/core/serialization/*SerializationFactory*.class' exclude 'net/corda/core/serialization/internal/CheckpointSerializationFactory*.class' + exclude 'net/corda/core/internal/rules/*.class' } reproducibleFileOrder = true diff --git a/core-deterministic/src/main/kotlin/net/corda/core/internal/rules/TargetVersionDependentRules.kt b/core-deterministic/src/main/kotlin/net/corda/core/internal/rules/TargetVersionDependentRules.kt new file mode 100644 index 0000000000..87fa115261 --- /dev/null +++ b/core-deterministic/src/main/kotlin/net/corda/core/internal/rules/TargetVersionDependentRules.kt @@ -0,0 +1,12 @@ +package net.corda.core.internal.rules + +import net.corda.core.contracts.ContractState + +// This file provides rules that depend on the targetVersion of the current Contract or Flow. +// In core, this is determined by means which are unavailable in the DJVM, +// so we must provide deterministic alternatives here. + +@Suppress("unused") +object StateContractValidationEnforcementRule { + fun shouldEnforce(state: ContractState): Boolean = true +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/corda/core/contracts/TransactionVerificationException.kt b/core/src/main/kotlin/net/corda/core/contracts/TransactionVerificationException.kt index 3b57766508..18531dfe67 100644 --- a/core/src/main/kotlin/net/corda/core/contracts/TransactionVerificationException.kt +++ b/core/src/main/kotlin/net/corda/core/contracts/TransactionVerificationException.kt @@ -149,7 +149,7 @@ abstract class TransactionVerificationException(val txId: SecureHash, message: S "is not satisfied. Encumbered states should also be referenced as an encumbrance of another state to form " + "a full cycle. Offending indices $nonMatching", null) - /** + /**HEAD * All encumbered states should be assigned to the same notary. This is due to the fact that multi-notary * transactions are not supported and thus two encumbered states with different notaries cannot be consumed * in the same transaction. @@ -159,6 +159,36 @@ abstract class TransactionVerificationException(val txId: SecureHash, message: S : TransactionVerificationException(txId, "Encumbered output states assigned to different notaries found. " + "Output state with index $encumberedIndex is assigned to notary [$encumberedNotary], while its encumbrance with index $encumbranceIndex is assigned to notary [$encumbranceNotary]", null) + /** + * If a state is identified as belonging to a contract, either because the state class is defined as an inner class + * of the contract class or because the state class is annotated with [BelongsToContract], then it must not be + * bundled in a [TransactionState] with a different contract. + * + * @param state The [TransactionState] whose bundled state and contract are in conflict. + * @param requiredContractClassName The class name of the contract to which the state belongs. + */ + @KeepForDJVM + class TransactionContractConflictException(txId: SecureHash, state: TransactionState, requiredContractClassName: String) + : TransactionVerificationException(txId, + """ + State of class ${state.data::class.java.typeName} belongs to contract $requiredContractClassName, but + is bundled in TransactionState with ${state.contract}. + + For details see: https://docs.corda.net/api-contract-constraints.html#contract-state-agreement + """.trimIndent().replace('\n', ' '), null) + + // TODO: add reference to documentation + @KeepForDJVM + class TransactionRequiredContractUnspecifiedException(txId: SecureHash, state: TransactionState) + : TransactionVerificationException(txId, + """ + State of class ${state.data::class.java.typeName} does not have a specified owning contract. + Add the @BelongsToContract annotation to this class to ensure that it can only be bundled in a TransactionState + with the correct contract. + + For details see: https://docs.corda.net/api-contract-constraints.html#contract-state-agreement + """.trimIndent(), null) + /** Whether the inputs or outputs list contains an encumbrance issue, see [TransactionMissingEncumbranceException]. */ @CordaSerializable @KeepForDJVM diff --git a/core/src/main/kotlin/net/corda/core/internal/rules/TargetVersionDependentRules.kt b/core/src/main/kotlin/net/corda/core/internal/rules/TargetVersionDependentRules.kt new file mode 100644 index 0000000000..5cb3cc5ada --- /dev/null +++ b/core/src/main/kotlin/net/corda/core/internal/rules/TargetVersionDependentRules.kt @@ -0,0 +1,55 @@ +package net.corda.core.internal.rules + +import net.corda.core.contracts.ContractState +import net.corda.core.utilities.contextLogger +import net.corda.core.utilities.warnOnce +import org.slf4j.LoggerFactory +import java.net.URL +import java.util.concurrent.ConcurrentHashMap +import java.util.jar.JarInputStream +import java.util.jar.Manifest + +// This file provides rules that depend on the targetVersion of the current Contract or Flow. +// Rules defined in this package are automatically removed from the DJVM in core-deterministic, +// and must be replaced by a deterministic alternative defined within that module. + +/** + * Rule which determines whether [ContractState]s must declare the [Contract] to which they belong (e.g. via the + * [BelongsToContract] annotation), and must be bundled together with that contract in any [TransactionState]. + * + * This rule is consulted during validation by [LedgerTransaction]. + */ +object StateContractValidationEnforcementRule { + + private val logger = LoggerFactory.getLogger(StateContractValidationEnforcementRule::class.java) + + private val targetVersionCache = ConcurrentHashMap() + + fun shouldEnforce(state: ContractState): Boolean { + val jarLocation = state::class.java.protectionDomain.codeSource.location + + if (jarLocation == null) { + logger.warnOnce(""" + Unable to determine JAR location for contract state class ${state::class.java.name}, + and consequently unable to determine target platform version. + Enforcing state/contract agreement validation by default. + + For details see: https://docs.corda.net/api-contract-constraints.html#contract-state-agreement + """.trimIndent().replace("\n", " ")) + return true + } + + val targetVersion = targetVersionCache.computeIfAbsent(jarLocation) { + jarLocation.openStream().use { inputStream -> + JarInputStream(inputStream).manifest?.targetPlatformVersion ?: 1 + } + } + + return targetVersion >= 4 + } +} + +private val Manifest.targetPlatformVersion: Int get() { + val minPlatformVersion = mainAttributes.getValue("Min-Platform-Version")?.toInt() ?: 1 + return mainAttributes.getValue("Target-Platform-Version")?.toInt() ?: minPlatformVersion +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/corda/core/transactions/LedgerTransaction.kt b/core/src/main/kotlin/net/corda/core/transactions/LedgerTransaction.kt index 90e09c86b8..c347df08c0 100644 --- a/core/src/main/kotlin/net/corda/core/transactions/LedgerTransaction.kt +++ b/core/src/main/kotlin/net/corda/core/transactions/LedgerTransaction.kt @@ -3,10 +3,14 @@ package net.corda.core.transactions import net.corda.core.CordaInternal import net.corda.core.KeepForDJVM import net.corda.core.contracts.* +import net.corda.core.contracts.TransactionVerificationException.TransactionContractConflictException +import net.corda.core.contracts.TransactionVerificationException.TransactionRequiredContractUnspecifiedException import net.corda.core.crypto.SecureHash import net.corda.core.crypto.isFulfilledBy import net.corda.core.identity.Party import net.corda.core.internal.* +import net.corda.core.internal.rules.StateContractValidationEnforcementRule +import net.corda.core.internal.uncheckedCast import net.corda.core.node.NetworkParameters import net.corda.core.serialization.ConstructorForDeserialization import net.corda.core.serialization.CordaSerializable @@ -133,7 +137,37 @@ private constructor( } /** - * Verify that package ownership is respected. + * For all input and output [TransactionState]s, validates that the wrapped [ContractState] matches up with the + * wrapped [Contract], as declared by the [BelongsToContract] annotation on the [ContractState]'s class. + * + * If the target platform version of the current CorDapp is lower than 4.0, a warning will be written to the log + * if any mismatch is detected. If it is 4.0 or later, then [TransactionContractConflictException] will be thrown. + */ + private fun validateStatesAgainstContract(internalTx: LedgerTransaction) = + internalTx.allStates.forEach(::validateStateAgainstContract) + + private fun validateStateAgainstContract(state: TransactionState) { + val shouldEnforce = StateContractValidationEnforcementRule.shouldEnforce(state.data) + + val requiredContractClassName = state.data.requiredContractClassName ?: + if (shouldEnforce) throw TransactionRequiredContractUnspecifiedException(id, state) + else return + + if (state.contract != requiredContractClassName) + if (shouldEnforce) { + throw TransactionContractConflictException(id, state, requiredContractClassName) + } else { + logger.warnOnce(""" + State of class ${state.data::class.java.typeName} belongs to contract $requiredContractClassName, but + is bundled in TransactionState with ${state.contract}. + + For details see: https://docs.corda.net/api-contract-constraints.html#contract-state-agreement + """.trimIndent().replace('\n', ' ')) + } + } + + /** + * Verify that for each contract the network wide package owner is respected. * * TODO - revisit once transaction contains network parameters. */ @@ -156,24 +190,6 @@ private constructor( } } - /** - * For all input and output [TransactionState]s, validates that the wrapped [ContractState] matches up with the - * wrapped [Contract], as declared by the [BelongsToContract] annotation on the [ContractState]'s class. - * - * A warning will be written to the log if any mismatch is detected. - */ - private fun validateStatesAgainstContract(internalTx: LedgerTransaction) = internalTx.allStates.forEach { validateStateAgainstContract(it) } - - private fun validateStateAgainstContract(state: TransactionState) { - state.data.requiredContractClassName?.let { requiredContractClassName -> - if (state.contract != requiredContractClassName) - logger.warnOnce(""" - State of class ${state.data::class.java.typeName} belongs to contract $requiredContractClassName, but - is bundled in TransactionState with ${state.contract}. - """.trimIndent().replace('\n', ' ')) - } - } - /** * Enforces the validity of the actual constraints. * * Constraints should be one of the valid supported ones. diff --git a/core/src/test/kotlin/net/corda/core/flows/WithReferencedStatesFlowTests.kt b/core/src/test/kotlin/net/corda/core/flows/WithReferencedStatesFlowTests.kt index deb07d0089..e40bb4453f 100644 --- a/core/src/test/kotlin/net/corda/core/flows/WithReferencedStatesFlowTests.kt +++ b/core/src/test/kotlin/net/corda/core/flows/WithReferencedStatesFlowTests.kt @@ -14,8 +14,6 @@ import net.corda.core.transactions.TransactionBuilder import net.corda.core.utilities.getOrThrow import net.corda.node.VersionInfo import net.corda.testing.common.internal.testNetworkParameters -import net.corda.testing.contracts.DummyContract -import net.corda.testing.contracts.DummyState import net.corda.testing.node.internal.InternalMockNetwork import net.corda.testing.node.internal.InternalMockNodeParameters import net.corda.testing.node.internal.cordappsForPackages @@ -106,16 +104,16 @@ internal class UseRefState(private val linearId: UniqueIdentifier) : FlowLogic(query).states.single() + val stx = serviceHub.signInitialTransaction(TransactionBuilder(notary = notary).apply { addReferenceState(referenceState.referenced()) - addOutputState(DummyState(), DummyContract.PROGRAM_ID) - addCommand(DummyContract.Commands.Create(), listOf(ourIdentity.owningKey)) + addOutputState(RefState.State(ourIdentity), RefState.CONTRACT_ID) + addCommand(RefState.Create(), listOf(ourIdentity.owningKey)) }) return subFlow(FinalityFlow(stx, emptyList())) } } - class WithReferencedStatesFlowTests { companion object { @JvmStatic diff --git a/core/src/test/kotlin/net/corda/core/transactions/ReferenceInputStateTests.kt b/core/src/test/kotlin/net/corda/core/transactions/ReferenceInputStateTests.kt index 8507de2834..913057168a 100644 --- a/core/src/test/kotlin/net/corda/core/transactions/ReferenceInputStateTests.kt +++ b/core/src/test/kotlin/net/corda/core/transactions/ReferenceInputStateTests.kt @@ -58,10 +58,21 @@ class ReferenceStateTests { // check might not be present in other contracts, like Cash, for example. Cash might have a command // called "Share" that allows a party to prove to another that they own over a certain amount of cash. // As such, cash can be added to the references list with a "Share" command. + @BelongsToContract(ExampleContract::class) data class ExampleState(val creator: Party, val data: String) : ContractState { override val participants: List get() = listOf(creator) } + // This state has only been created to serve reference data so it cannot ever be used as an input or + // output when it is being referred to. However, we might want all states to be referable, so this + // check might not be present in other contracts, like Cash, for example. Cash might have a command + // called "Share" that allows a party to prove to another that they own over a certain amount of cash. + // As such, cash can be added to the references list with a "Share" command. + @BelongsToContract(Cash::class) + data class ExampleCashState(val creator: Party, val data: String) : ContractState { + override val participants: List get() = listOf(creator) + } + class ExampleContract : Contract { interface Commands : CommandData class Create : Commands @@ -169,7 +180,7 @@ class ReferenceStateTests { transaction { input("REF DATA") command(ALICE_PUBKEY, ExampleContract.Update()) - output(Cash.PROGRAM_ID, "UPDATED REF DATA", "REF DATA".output().copy(data = "NEW STUFF!")) + output(ExampleContract::class.java.typeName, "UPDATED REF DATA", "REF DATA".output().copy(data = "NEW STUFF!")) verifies() } // Try to use the old one. diff --git a/docs/source/api-contract-constraints.rst b/docs/source/api-contract-constraints.rst index d288ef5eb1..90f9992822 100644 --- a/docs/source/api-contract-constraints.rst +++ b/docs/source/api-contract-constraints.rst @@ -62,6 +62,58 @@ consumes notary and ledger resources, and is just in general more complex. .. _implicit_constraint_types: +Contract/State Agreement +------------------------ + +Starting with Corda 4, ``ContractState``s must explicitly indicate which ``Contract`` they belong to. When a transaction is +verified, the contract bundled with each state in the transaction must be its "owning" contract, otherwise we cannot guarantee that +the transition of the ``ContractState`` will be verified against the business rules that should apply to it. + +There are two mechanisms for indicating ownership. One is to annotate the ``ContractState`` with the ``BelongsToContract`` annotation, +indicating the ``Contract`` class to which it is tied: + +.. sourcecode:: java + + @BelongToContract(MyContract.class) + public class MyState implements ContractState { + // implementation goes here + } + + +.. sourcecode:: kotlin + + @BelongsToContract(MyContract::class) + data class MyState(val value: Int) : ContractState { + // implementation goes here + } + + +The other is to define the ``ContractState`` class as an inner class of the ``Contract`` class + +.. sourcecode:: java + + public class MyContract implements Contract { + + public static class MyState implements ContractState { + // state implementation goes here + } + + // contract implementation goes here + } + + +.. sourcecode:: kotlin + + class MyContract : Contract { + data class MyState(val value: Int) : ContractState + } + + +If a ``ContractState``'s owning ``Contract`` cannot be identified by either of these mechanisms, and the ``targetVersion`` of the +CorDapp is 4 or greater, then transaction verification will fail with a ``TransactionRequiredContractUnspecifiedException``. If +the owning ``Contract`` _can_ be identified, but the ``ContractState`` has been bundled with a different contract, then +transaction verification will fail with a ``TransactionContractConflictException``. + How constraints work -------------------- diff --git a/finance/src/test/kotlin/net/corda/finance/contracts/asset/CashTests.kt b/finance/src/test/kotlin/net/corda/finance/contracts/asset/CashTests.kt index a271d13af2..f3afb354d2 100644 --- a/finance/src/test/kotlin/net/corda/finance/contracts/asset/CashTests.kt +++ b/finance/src/test/kotlin/net/corda/finance/contracts/asset/CashTests.kt @@ -160,12 +160,17 @@ class CashTests { } } + @BelongsToContract(Cash::class) + object DummyState: ContractState { + override val participants: List = emptyList() + } + @Test fun `issue by move`() { // Check we can't "move" money into existence. transaction { attachment(Cash.PROGRAM_ID) - input(Cash.PROGRAM_ID, DummyState()) + input(Cash.PROGRAM_ID, DummyState) output(Cash.PROGRAM_ID, outState) command(miniCorp.publicKey, Cash.Commands.Move()) this `fails with` "there is at least one cash input for this group" diff --git a/finance/src/test/kotlin/net/corda/finance/contracts/asset/ObligationTests.kt b/finance/src/test/kotlin/net/corda/finance/contracts/asset/ObligationTests.kt index 4b1bd358ef..59f209e2fe 100644 --- a/finance/src/test/kotlin/net/corda/finance/contracts/asset/ObligationTests.kt +++ b/finance/src/test/kotlin/net/corda/finance/contracts/asset/ObligationTests.kt @@ -21,7 +21,6 @@ import net.corda.finance.contracts.NetType import net.corda.finance.contracts.asset.Obligation.Lifecycle import net.corda.node.services.api.IdentityServiceInternal import net.corda.testing.contracts.DummyContract -import net.corda.testing.contracts.DummyState import net.corda.testing.core.* import net.corda.testing.dsl.* import net.corda.testing.internal.TEST_TX_TIME @@ -145,12 +144,17 @@ class ObligationTests { } } + @BelongsToContract(DummyContract::class) + object DummyState: ContractState { + override val participants: List = emptyList() + } + @Test fun `issue debt`() { // Check we can't "move" debt into existence. transaction { attachments(DummyContract.PROGRAM_ID, Obligation.PROGRAM_ID) - input(DummyContract.PROGRAM_ID, DummyState()) + input(DummyContract.PROGRAM_ID, DummyState) output(Obligation.PROGRAM_ID, outState) command(MINI_CORP_PUBKEY, Obligation.Commands.Move()) this `fails with` "at least one obligation input" diff --git a/samples/simm-valuation-demo/contracts-states/src/main/kotlin/net/corda/vega/contracts/IRSState.kt b/samples/simm-valuation-demo/contracts-states/src/main/kotlin/net/corda/vega/contracts/IRSState.kt index 73537a02d3..e4dd6a7291 100644 --- a/samples/simm-valuation-demo/contracts-states/src/main/kotlin/net/corda/vega/contracts/IRSState.kt +++ b/samples/simm-valuation-demo/contracts-states/src/main/kotlin/net/corda/vega/contracts/IRSState.kt @@ -1,9 +1,6 @@ package net.corda.vega.contracts -import net.corda.core.contracts.Command -import net.corda.core.contracts.ContractClassName -import net.corda.core.contracts.StateAndContract -import net.corda.core.contracts.UniqueIdentifier +import net.corda.core.contracts.* import net.corda.core.identity.AbstractParty import net.corda.core.identity.Party import net.corda.core.transactions.TransactionBuilder @@ -16,6 +13,7 @@ const val IRS_PROGRAM_ID: ContractClassName = "net.corda.vega.contracts.OGTrade" * * TODO: Merge with the existing demo IRS code. */ +@BelongsToContract(OGTrade::class) data class IRSState(val swap: SwapData, val buyer: AbstractParty, val seller: AbstractParty, diff --git a/samples/simm-valuation-demo/contracts-states/src/main/kotlin/net/corda/vega/contracts/PortfolioState.kt b/samples/simm-valuation-demo/contracts-states/src/main/kotlin/net/corda/vega/contracts/PortfolioState.kt index 119bc683e4..02daba646a 100644 --- a/samples/simm-valuation-demo/contracts-states/src/main/kotlin/net/corda/vega/contracts/PortfolioState.kt +++ b/samples/simm-valuation-demo/contracts-states/src/main/kotlin/net/corda/vega/contracts/PortfolioState.kt @@ -17,6 +17,7 @@ const val PORTFOLIO_SWAP_PROGRAM_ID = "net.corda.vega.contracts.PortfolioSwap" * Represents an aggregate set of trades agreed between two parties and a possible valuation of that portfolio at a * given point in time. This state can be consumed to create a new state with a mutated valuation or portfolio. */ +@BelongsToContract(PortfolioSwap::class) data class PortfolioState(val portfolio: List, val _parties: Pair, val valuationDate: LocalDate, diff --git a/testing/test-utils/src/main/kotlin/net/corda/testing/internal/vault/VaultFiller.kt b/testing/test-utils/src/main/kotlin/net/corda/testing/internal/vault/VaultFiller.kt index baba1b0cfe..af7f452444 100644 --- a/testing/test-utils/src/main/kotlin/net/corda/testing/internal/vault/VaultFiller.kt +++ b/testing/test-utils/src/main/kotlin/net/corda/testing/internal/vault/VaultFiller.kt @@ -310,6 +310,7 @@ class VaultFiller @JvmOverloads constructor( /** A state representing a commodity claim against some party */ +@BelongsToContract(Obligation::class) data class CommodityState( override val amount: Amount>,