ENT-2320 state contract identification (#4285)

* Enforce state/contract agreement validation

* Fix some broken tests

* Ascertain targetVersion by inspecting the jar source of the ContractState

* Docs added and rebased against master

* contextLogger doesn't work here

* Java examples in docs

* Label IRSState with owning contract

* Fix rst formatting

* Add @BelongsToContract annotation to PortfolioState
This commit is contained in:
Dominic Fox 2018-11-26 16:02:32 +00:00 committed by GitHub
parent 5d43f3139e
commit 88fbb47f67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 217 additions and 33 deletions

View File

@ -51,6 +51,7 @@ task patchCore(type: Zip, dependsOn: coreJarTask) {
exclude 'net/corda/core/internal/*ToggleField*.class' exclude 'net/corda/core/internal/*ToggleField*.class'
exclude 'net/corda/core/serialization/*SerializationFactory*.class' exclude 'net/corda/core/serialization/*SerializationFactory*.class'
exclude 'net/corda/core/serialization/internal/CheckpointSerializationFactory*.class' exclude 'net/corda/core/serialization/internal/CheckpointSerializationFactory*.class'
exclude 'net/corda/core/internal/rules/*.class'
} }
reproducibleFileOrder = true reproducibleFileOrder = true

View File

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

View File

@ -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 " + "is not satisfied. Encumbered states should also be referenced as an encumbrance of another state to form " +
"a full cycle. Offending indices $nonMatching", null) "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 * 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 * transactions are not supported and thus two encumbered states with different notaries cannot be consumed
* in the same transaction. * 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. " + : 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) "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<ContractState>, 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<ContractState>)
: 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]. */ /** Whether the inputs or outputs list contains an encumbrance issue, see [TransactionMissingEncumbranceException]. */
@CordaSerializable @CordaSerializable
@KeepForDJVM @KeepForDJVM

View File

@ -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<URL, Int>()
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
}

View File

@ -3,10 +3,14 @@ package net.corda.core.transactions
import net.corda.core.CordaInternal import net.corda.core.CordaInternal
import net.corda.core.KeepForDJVM import net.corda.core.KeepForDJVM
import net.corda.core.contracts.* 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.SecureHash
import net.corda.core.crypto.isFulfilledBy import net.corda.core.crypto.isFulfilledBy
import net.corda.core.identity.Party import net.corda.core.identity.Party
import net.corda.core.internal.* 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.node.NetworkParameters
import net.corda.core.serialization.ConstructorForDeserialization import net.corda.core.serialization.ConstructorForDeserialization
import net.corda.core.serialization.CordaSerializable 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<ContractState>) {
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. * 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<ContractState>) {
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. * Enforces the validity of the actual constraints.
* * Constraints should be one of the valid supported ones. * * Constraints should be one of the valid supported ones.

View File

@ -14,8 +14,6 @@ import net.corda.core.transactions.TransactionBuilder
import net.corda.core.utilities.getOrThrow import net.corda.core.utilities.getOrThrow
import net.corda.node.VersionInfo import net.corda.node.VersionInfo
import net.corda.testing.common.internal.testNetworkParameters 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.InternalMockNetwork
import net.corda.testing.node.internal.InternalMockNodeParameters import net.corda.testing.node.internal.InternalMockNodeParameters
import net.corda.testing.node.internal.cordappsForPackages import net.corda.testing.node.internal.cordappsForPackages
@ -106,16 +104,16 @@ internal class UseRefState(private val linearId: UniqueIdentifier) : FlowLogic<S
relevancyStatus = Vault.RelevancyStatus.ALL relevancyStatus = Vault.RelevancyStatus.ALL
) )
val referenceState = serviceHub.vaultService.queryBy<ContractState>(query).states.single() val referenceState = serviceHub.vaultService.queryBy<ContractState>(query).states.single()
val stx = serviceHub.signInitialTransaction(TransactionBuilder(notary = notary).apply { val stx = serviceHub.signInitialTransaction(TransactionBuilder(notary = notary).apply {
addReferenceState(referenceState.referenced()) addReferenceState(referenceState.referenced())
addOutputState(DummyState(), DummyContract.PROGRAM_ID) addOutputState(RefState.State(ourIdentity), RefState.CONTRACT_ID)
addCommand(DummyContract.Commands.Create(), listOf(ourIdentity.owningKey)) addCommand(RefState.Create(), listOf(ourIdentity.owningKey))
}) })
return subFlow(FinalityFlow(stx, emptyList())) return subFlow(FinalityFlow(stx, emptyList()))
} }
} }
class WithReferencedStatesFlowTests { class WithReferencedStatesFlowTests {
companion object { companion object {
@JvmStatic @JvmStatic

View File

@ -58,10 +58,21 @@ class ReferenceStateTests {
// check might not be present in other contracts, like Cash, for example. Cash might have a command // 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. // 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. // 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 { data class ExampleState(val creator: Party, val data: String) : ContractState {
override val participants: List<AbstractParty> get() = listOf(creator) override val participants: List<AbstractParty> 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<AbstractParty> get() = listOf(creator)
}
class ExampleContract : Contract { class ExampleContract : Contract {
interface Commands : CommandData interface Commands : CommandData
class Create : Commands class Create : Commands
@ -169,7 +180,7 @@ class ReferenceStateTests {
transaction { transaction {
input("REF DATA") input("REF DATA")
command(ALICE_PUBKEY, ExampleContract.Update()) command(ALICE_PUBKEY, ExampleContract.Update())
output(Cash.PROGRAM_ID, "UPDATED REF DATA", "REF DATA".output<ExampleState>().copy(data = "NEW STUFF!")) output(ExampleContract::class.java.typeName, "UPDATED REF DATA", "REF DATA".output<ExampleState>().copy(data = "NEW STUFF!"))
verifies() verifies()
} }
// Try to use the old one. // Try to use the old one.

View File

@ -62,6 +62,58 @@ consumes notary and ledger resources, and is just in general more complex.
.. _implicit_constraint_types: .. _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 How constraints work
-------------------- --------------------

View File

@ -160,12 +160,17 @@ class CashTests {
} }
} }
@BelongsToContract(Cash::class)
object DummyState: ContractState {
override val participants: List<AbstractParty> = emptyList()
}
@Test @Test
fun `issue by move`() { fun `issue by move`() {
// Check we can't "move" money into existence. // Check we can't "move" money into existence.
transaction { transaction {
attachment(Cash.PROGRAM_ID) attachment(Cash.PROGRAM_ID)
input(Cash.PROGRAM_ID, DummyState()) input(Cash.PROGRAM_ID, DummyState)
output(Cash.PROGRAM_ID, outState) output(Cash.PROGRAM_ID, outState)
command(miniCorp.publicKey, Cash.Commands.Move()) command(miniCorp.publicKey, Cash.Commands.Move())
this `fails with` "there is at least one cash input for this group" this `fails with` "there is at least one cash input for this group"

View File

@ -21,7 +21,6 @@ import net.corda.finance.contracts.NetType
import net.corda.finance.contracts.asset.Obligation.Lifecycle import net.corda.finance.contracts.asset.Obligation.Lifecycle
import net.corda.node.services.api.IdentityServiceInternal import net.corda.node.services.api.IdentityServiceInternal
import net.corda.testing.contracts.DummyContract import net.corda.testing.contracts.DummyContract
import net.corda.testing.contracts.DummyState
import net.corda.testing.core.* import net.corda.testing.core.*
import net.corda.testing.dsl.* import net.corda.testing.dsl.*
import net.corda.testing.internal.TEST_TX_TIME import net.corda.testing.internal.TEST_TX_TIME
@ -145,12 +144,17 @@ class ObligationTests {
} }
} }
@BelongsToContract(DummyContract::class)
object DummyState: ContractState {
override val participants: List<AbstractParty> = emptyList()
}
@Test @Test
fun `issue debt`() { fun `issue debt`() {
// Check we can't "move" debt into existence. // Check we can't "move" debt into existence.
transaction { transaction {
attachments(DummyContract.PROGRAM_ID, Obligation.PROGRAM_ID) attachments(DummyContract.PROGRAM_ID, Obligation.PROGRAM_ID)
input(DummyContract.PROGRAM_ID, DummyState()) input(DummyContract.PROGRAM_ID, DummyState)
output(Obligation.PROGRAM_ID, outState) output(Obligation.PROGRAM_ID, outState)
command(MINI_CORP_PUBKEY, Obligation.Commands.Move()) command(MINI_CORP_PUBKEY, Obligation.Commands.Move())
this `fails with` "at least one obligation input" this `fails with` "at least one obligation input"

View File

@ -1,9 +1,6 @@
package net.corda.vega.contracts package net.corda.vega.contracts
import net.corda.core.contracts.Command import net.corda.core.contracts.*
import net.corda.core.contracts.ContractClassName
import net.corda.core.contracts.StateAndContract
import net.corda.core.contracts.UniqueIdentifier
import net.corda.core.identity.AbstractParty import net.corda.core.identity.AbstractParty
import net.corda.core.identity.Party import net.corda.core.identity.Party
import net.corda.core.transactions.TransactionBuilder 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. * TODO: Merge with the existing demo IRS code.
*/ */
@BelongsToContract(OGTrade::class)
data class IRSState(val swap: SwapData, data class IRSState(val swap: SwapData,
val buyer: AbstractParty, val buyer: AbstractParty,
val seller: AbstractParty, val seller: AbstractParty,

View File

@ -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 * 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. * 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<StateRef>, data class PortfolioState(val portfolio: List<StateRef>,
val _parties: Pair<AbstractParty, AbstractParty>, val _parties: Pair<AbstractParty, AbstractParty>,
val valuationDate: LocalDate, val valuationDate: LocalDate,

View File

@ -310,6 +310,7 @@ class VaultFiller @JvmOverloads constructor(
/** A state representing a commodity claim against some party */ /** A state representing a commodity claim against some party */
@BelongsToContract(Obligation::class)
data class CommodityState( data class CommodityState(
override val amount: Amount<Issued<Commodity>>, override val amount: Amount<Issued<Commodity>>,