Added the additional Corda utility code with FSM-like transition contract checking

This commit is contained in:
Tomas Tauber 2018-05-18 18:35:00 +08:00 committed by Mike Hearn
parent acefe4261c
commit 3a9fa50799
6 changed files with 630 additions and 0 deletions

View File

@ -0,0 +1,121 @@
# Introduction
This project holds different Corda-related utility code.
## Utils
Utils.kt contains various extension functions and other short utility code that aid
development on Corda. The code is mostly self-explanatory -- the only exception may
be `StateRefHere` which can be used in situations where multiple states are produced
in one transaction, and one state needs to refer to the others, e.g. something like this:
```
val tx = TransactionBuilder(//...
// ...
tx.addOutputState(innerState, contractClassName)
val innerStateRef = StateRefHere(null, tx.outputStates().count() - 1)
tx.addOutputState(OuterState(innerStateRef = innerStateRef), contractClassName)
// ...
```
## StatusTransitions
StatusTransitions.kt contains utility code related to FSM-style defining possible transactions that can happen
with the respect to the contained status and roles of participants. Here's a simple example for illustration.
We are going to track package delivery status, so we first define all roles of participants and possible statuses
each package could have:
```
enum class PackageDeliveryRole {
Sender,
Receiver,
Courier
}
enum class DeliveryStatus {
InTransit,
Delivered,
Returned
}
```
The information about each package is held in PackageState: it contains its involved parties, status, linearId,
current location, and information related to delivery attempts:
```
import net.corda.core.contracts.CommandData
import net.corda.core.contracts.Contract
import net.corda.core.contracts.LinearState
import net.corda.core.contracts.UniqueIdentifier
import net.corda.core.identity.AbstractParty
import net.corda.core.identity.Party
import net.corda.core.transactions.LedgerTransaction
import java.time.Instant
data class PackageState(val sender: Party,
val receiver: Party,
val deliveryCompany: Party,
val currentLocation: String,
override val status: DeliveryStatus,
val deliveryAttempts: Int = 0,
val lastDeliveryAttempt: Instant? = null,
override val linearId: UniqueIdentifier): LinearState, StatusTrackingContractState<DeliveryStatus, PackageDeliveryRole> {
override fun roleToParty(role: PackageDeliveryRole): Party {
return when (role) {
PackageDeliveryRole.Sender -> sender
PackageDeliveryRole.Receiver -> receiver
PackageDeliveryRole.Courier -> deliveryCompany
}
}
override val participants: List<AbstractParty> = listOf(sender, receiver, deliveryCompany)
}
```
We can then define operations one can do with this state, who can do them and under what circumstances (i.e. from what status):
```
sealed class DeliveryCommand: CommandData {
object Send: DeliveryCommand()
object Transport: DeliveryCommand()
object ConfirmReceipt: DeliveryCommand()
object AttemptedDelivery: DeliveryCommand()
object Return: DeliveryCommand()
}
class PackageDelivery: Contract {
companion object {
val transitions = StatusTransitions(PackageState::class,
DeliveryCommand.Send.txDef(PackageDeliveryRole.Sender, null, listOf(DeliveryStatus.InTransit)),
DeliveryCommand.Transport.txDef(PackageDeliveryRole.Courier, DeliveryStatus.InTransit, listOf(DeliveryStatus.InTransit)),
DeliveryCommand.AttemptedDelivery.txDef(PackageDeliveryRole.Courier, DeliveryStatus.InTransit, listOf(DeliveryStatus.InTransit)),
DeliveryCommand.ConfirmReceipt.txDef(PackageDeliveryRole.Receiver, DeliveryStatus.InTransit, listOf(DeliveryStatus.Delivered)),
DeliveryCommand.Return.txDef(PackageDeliveryRole.Courier, DeliveryStatus.InTransit, listOf(DeliveryStatus.Returned)))
}
override fun verify(tx: LedgerTransaction) {
transitions.verify(tx)
// ...
// other checks -- linearId is preserved, attributes are updated correctly for given commands, return is only allowed after 3 attempts, etc.
}
}
```
This definition gives us some basic generic verification -- e.g. that package receipt confirmations need to be signed by package receivers.
In addition that, we could visualize the defined transitions in a PUML diagram:
```
PackageDelivery.transitions.printGraph().printedPUML
```
Which will result in:
```
@startuml
title PackageState
[*] --> InTransit : Send (by Sender)
InTransit --> InTransit : Transport (by Courier)
InTransit --> InTransit : AttemptedDelivery (by Courier)
InTransit --> Delivered : ConfirmReceipt (by Receiver)
InTransit --> Returned : Return (by Courier)
@enduml
```
![Generated PlantUML model](http://www.plantuml.com:80/plantuml/png/VSsn2i8m58NXlK-HKOM-W8DKwk8chPiunEOemIGDjoU5lhqIHP12jn_k-RZLG2rCtXMqT50dtJtr0oqrKLmsLrMMEtKCPz5Xi5HRrI8OjRfDEI3hudUSJNF5NfZtTP_4BeCz2Hy9Su2p8sHQWjyDp1lMVRXRyGqwsCYiSezpre19GbQV_FzH8PZatGi0)
## Future plans
Depending on particular use cases, this utility library may be enhanced in different ways. Here are a few ideas:
* More generic verification (e.g. verifying numbers of produced and consumed states of a particular type)
* More convenient syntax, not abusing nulls so much, etc.
* ...

View File

@ -0,0 +1,27 @@
apply plugin: 'kotlin'
apply plugin: 'idea'
sourceSets {
integrationTest {
kotlin {
compileClasspath += main.output + test.output
runtimeClasspath += main.output + test.output
srcDir file('src/integration-test/kotlin')
}
}
}
configurations {
integrationTestCompile.extendsFrom testCompile
integrationTestRuntime.extendsFrom testRuntime
}
dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
compile project(':core')
compile project(':node-api')
testCompile project(':test-utils')
testCompile project(':node-driver')
testCompile "junit:junit:$junit_version"
}

View File

@ -0,0 +1,105 @@
package io.cryptoblk.core
import net.corda.core.contracts.CommandData
import net.corda.core.contracts.ContractState
import net.corda.core.identity.Party
import net.corda.core.transactions.LedgerTransaction
import kotlin.reflect.KClass
/**
* Contract state that records changes of some [status] on the ledger and roles of parties that are participants
* in that state using [roleToParty].
*/
interface StatusTrackingContractState<out S, in R> : ContractState {
val status: S
fun roleToParty(role: R): Party
}
/**
* Definition of finite state transition: for a particular command in a TX, it defines what transitions can be done
* [from] what status [to] what statuses, and who needs to sign them ([signer]).
* If [from] is null, it means there doesn't need to be any input; if [to] is null, it mean there doesn't need to be any output.
* If [signer] is null, it means anyone can sign it.
*/
data class TransitionDef<out S, out R>(val cmd: Class<*>, val signer: R?, val from: S?, val to: List<S?>)
/**
* Holds visualized PUML graph in [printedPUML] and the relevant state class name in [stateClassName].
*/
data class PrintedTransitionGraph(val stateClassName: String, val printedPUML: String)
/**
* Shorthand for defining transitions directly from the command class
*/
fun <S, R> CommandData.txDef(signer: R? = null, from: S?, to: List<S?>):
TransitionDef<S, R> = TransitionDef(this::class.java, signer, from, to)
/**
* For a given [stateClass] that tracks a status, it holds all possible transitions in [ts].
* This can be used for generic [verify] in contract code as well as for visualizing the state transition graph in PUML ([printGraph]).
*/
class StatusTransitions<out S, in R, T : StatusTrackingContractState<S, R>>(private val stateClass: KClass<T>,
private vararg val ts: TransitionDef<S, R>) {
private val allowedCmds = ts.map { it.cmd }.toSet()
private fun matchingTransitions(input: S?, output: S?, command: CommandData): List<TransitionDef<S, R>> {
val options = ts.filter {
(it.from == input) && (output in it.to) && (it.cmd == command.javaClass)
}
if (options.isEmpty()) throw IllegalStateException("Transition [$input -(${command.javaClass.simpleName})-> $output] not allowed")
return options
}
/**
* Generic verification based on provided [TransitionDef]s
*/
fun verify(tx: LedgerTransaction) {
val relevantCmds = tx.commands.filter { allowedCmds.contains(it.value.javaClass) }
require(relevantCmds.isNotEmpty()) { "Transaction must have at least one Command relevant to its defined transitions" }
relevantCmds.forEach { cmd ->
val ins = tx.inputsOfType(stateClass.java)
val inputStates = if (ins.isEmpty()) listOf(null) else ins
val outs = tx.outputsOfType(stateClass.java)
val outputStates = if (outs.isEmpty()) listOf(null) else outs
// for each combination of in x out which should normally be at most 1...
inputStates.forEach { inp ->
outputStates.forEach { outp ->
assert((inp != null) || (outp != null))
val options = matchingTransitions(inp?.status, outp?.status, cmd.value)
val signerGroup = options.groupBy { it.signer }.entries.singleOrNull()
?: throw IllegalStateException("Cannot have different signers in StatusTransitions for the same command.")
val signer = signerGroup.key
if (signer != null) {
// which state determines who is the signer? by default the input, unless it's the initial transition
val state = (inp ?: outp)!!
val signerParty = state.roleToParty(signer)
if (!cmd.signers.contains(signerParty.owningKey))
throw IllegalStateException("Command ${cmd.value.javaClass} must be signed by $signer")
}
}
}
}
}
fun printGraph(): PrintedTransitionGraph {
val sb = StringBuilder()
sb.append("@startuml\n")
if (stateClass.simpleName != null) sb.append("title ${stateClass.simpleName}\n")
ts.forEach { txDef ->
val fromStatus = txDef.from?.toString() ?: "[*]"
txDef.to.forEach { to ->
val toStatus = (to ?: "[*]").toString()
val cmd = txDef.cmd.simpleName
val signer = txDef.signer?.toString() ?: "anyone involved"
sb.append("$fromStatus --> $toStatus : $cmd (by $signer)\n")
}
}
sb.append("@enduml")
return PrintedTransitionGraph(stateClass.simpleName ?: "", sb.toString())
}
}

View File

@ -0,0 +1,75 @@
package io.cryptoblk.core
import co.paralleluniverse.fibers.Suspendable
import net.corda.core.contracts.ContractState
import net.corda.core.contracts.StateAndRef
import net.corda.core.contracts.StateRef
import net.corda.core.crypto.SecureHash
import net.corda.core.flows.FinalityFlow
import net.corda.core.flows.FlowLogic
import net.corda.core.node.ServiceHub
import net.corda.core.node.services.queryBy
import net.corda.core.node.services.vault.QueryCriteria
import net.corda.core.serialization.CordaSerializable
import net.corda.core.transactions.SignedTransaction
import net.corda.core.transactions.TransactionBuilder
inline fun <reified T : ContractState> ServiceHub.queryStateByRef(ref: StateRef): StateAndRef<T> {
val results = vaultService.queryBy<T>(QueryCriteria.VaultQueryCriteria(stateRefs = kotlin.collections.listOf(ref)))
return results.states.firstOrNull() ?: throw IllegalArgumentException("State (type=${T::class}) corresponding to the reference $ref not found (or is spent).")
}
/**
* Shorthand when a single party signs a TX and then returns a result that uses the signed TX (e.g. includes the TX id)
*/
@Suspendable
fun <R> FlowLogic<R>.finalize(tx: TransactionBuilder, returnWithSignedTx: (stx: SignedTransaction) -> R): R {
val stx = serviceHub.signInitialTransaction(tx)
subFlow(FinalityFlow(stx)) // it'll send to all participants in the state by default
return returnWithSignedTx(stx)
}
/**
* Corda fails when it tries to store the same attachment hash twice. And it's convenient to also do nothing if no attachment is provided.
* This doesn't fix the same-attachment problem completely but should at least help in testing with the same file.
*/
fun TransactionBuilder.addAttachmentOnce(att: SecureHash?): TransactionBuilder {
if (att == null) return this
if (att !in this.attachments())
this.addAttachment(att)
return this
}
// checks the instance type, so the cast is safe
@Suppress("UNCHECKED_CAST")
inline fun <reified T : ContractState> List<StateAndRef<ContractState>>.entriesOfType(): List<StateAndRef<T>> = this.mapNotNull {
if (T::class.java.isInstance(it.state.data)) it as StateAndRef<T> else null
}
/**
* Used when multiple objects may be created in the same transaction and need to refer to each other. If a state
* contains this object as a reference to another object and txhash is null, the same txhash as of the containing/outer state
* should be used. If txhash is not null, then this works exactly like StateRef.
*
* WARNING:
* - if the outer state gets updated but its referenced state does not (in the same tx) then
* - this reference in parent state must be updated with the real txhash: [StateRefHere.copyWith]
* - otherwise it will be unresolvable (could be solved by disallowing copy on this)
*/
// do not make it a data class
@CordaSerializable
class StateRefHere(val txhash: SecureHash?, val index: Int) {
constructor(ref: StateRef) : this(ref.txhash, ref.index)
fun toStateRef(parent: SecureHash) = StateRef(txhash ?: parent, index)
// not standard copy
fun copyWith(parent: SecureHash): StateRefHere {
return StateRefHere(txhash ?: parent, index)
}
override fun equals(other: Any?): Boolean {
if (other !is StateRefHere) return false
return (this.txhash == other.txhash) && (this.index == other.index)
}
}

View File

@ -0,0 +1,301 @@
package io.cryptoblk.core
import net.corda.core.contracts.Contract
import net.corda.core.contracts.TypeOnlyCommandData
import net.corda.core.identity.AbstractParty
import net.corda.core.identity.CordaX500Name
import net.corda.core.identity.Party
import net.corda.core.transactions.LedgerTransaction
import net.corda.testing.core.TestIdentity
import net.corda.testing.node.MockServices
import net.corda.testing.node.ledger
import org.junit.Test
private val ALICE_ID = TestIdentity(CordaX500Name.parse("L=London,O=Alice Ltd,OU=Trade,C=GB"))
private val BOB_ID = TestIdentity(CordaX500Name.parse("L=London,O=Bob Ltd,OU=Trade,C=GB"))
private val BIGCORP_ID = TestIdentity(CordaX500Name.parse("L=New York,O=Bigcorp Ltd,OU=Trade,C=US"))
private val ALICE = ALICE_ID.party
private val BOB = BOB_ID.party
private val BIG_CORP = BIGCORP_ID.party
private val ALICE_PUBKEY = ALICE_ID.publicKey
private val BOB_PUBKEY = BOB_ID.publicKey
private val BIG_CORP_PUBKEY = BIGCORP_ID.publicKey
private enum class PartyRole {
Adder,
MultiplierAndRandomiser,
Randomiser
}
private class IntegerTestState(override val status: String) : StatusTrackingContractState<String, PartyRole> {
override fun roleToParty(role: PartyRole): Party {
return when (role) {
PartyRole.Adder -> BIG_CORP
PartyRole.MultiplierAndRandomiser -> BOB
PartyRole.Randomiser -> ALICE
}
}
override val participants: List<AbstractParty>
get() = listOf(ALICE, BOB, BIG_CORP)
}
private sealed class Operations: TypeOnlyCommandData() {
object Add1 : Operations()
object Add10 : Operations()
object Multiply2 : Operations()
object Randomise : Operations()
object Close : Operations()
object AnotherCommand : Operations()
}
class TestIntegerContract: Contract {
companion object {
private val fsTransitions = StatusTransitions(IntegerTestState::class,
Operations.Add1.txDef(PartyRole.Adder, null, listOf("1")),
Operations.Add1.txDef(PartyRole.Adder, "1", listOf("2")),
Operations.Add10.txDef(PartyRole.Adder, "1", listOf("11")),
Operations.Multiply2.txDef(PartyRole.MultiplierAndRandomiser, "2", listOf("4")),
Operations.Multiply2.txDef(PartyRole.MultiplierAndRandomiser, "11", listOf("22")),
Operations.Randomise.txDef(PartyRole.Randomiser, "2", listOf("8", "9", "1", "11")),
Operations.Randomise.txDef(PartyRole.Randomiser, "11", listOf("2", "11", "4")),
Operations.Randomise.txDef(PartyRole.MultiplierAndRandomiser, "11", listOf("22")),
Operations.Close.txDef(PartyRole.Randomiser, "9", listOf(null))
)
}
override fun verify(tx: LedgerTransaction) {
fsTransitions.verify(tx)
}
}
private class TestOwnedIntegerState(override val status: String): StatusTrackingContractState<String, PartyRole> {
override val participants: List<AbstractParty>
get() = listOf(ALICE, BOB)
override fun roleToParty(role: PartyRole): Party {
return if (status == "0") ALICE else BOB
}
}
class TestOwnedIntegerContract: Contract {
companion object {
private val fsTransitions = StatusTransitions(TestOwnedIntegerState::class,
Operations.Add1.txDef(PartyRole.Adder, null, listOf("0")),
Operations.Add1.txDef(PartyRole.Adder, "0", listOf("1")),
Operations.Add1.txDef(PartyRole.Adder, "1", listOf("2")),
Operations.Multiply2.txDef(PartyRole.MultiplierAndRandomiser, "10", listOf("20")),
Operations.Multiply2.txDef(PartyRole.Adder, "10", listOf("20")) // bug for the test
)
}
override fun verify(tx: LedgerTransaction) {
fsTransitions.verify(tx)
}
}
class StatusTransitionsTest {
companion object {
private val integerContract = TestIntegerContract::class.qualifiedName!!
private val ownedIntegerContract = TestOwnedIntegerContract::class.qualifiedName!!
private val ledgerServices = MockServices(ALICE_ID, BOB_ID, BIGCORP_ID)
}
@Test
fun `basic correct cases`() {
ledgerServices.ledger {
transaction {
output(integerContract, IntegerTestState("1"))
command(BIG_CORP_PUBKEY, Operations.Add1)
verifies()
}
transaction {
input(integerContract, IntegerTestState("1"))
output(integerContract, IntegerTestState("2"))
command(BIG_CORP_PUBKEY, Operations.Add1)
verifies()
}
transaction {
input(integerContract, IntegerTestState("2"))
output(integerContract, IntegerTestState("9"))
command(ALICE_PUBKEY, Operations.Randomise)
verifies()
}
transaction {
input(integerContract, IntegerTestState("9"))
command(ALICE_PUBKEY, Operations.Close)
verifies()
}
}
}
@Test
fun `disallowed output`() {
ledgerServices.ledger {
transaction {
input(integerContract, IntegerTestState("1"))
output(integerContract, IntegerTestState("3"))
command(BIG_CORP_PUBKEY, Operations.Add1)
fails()
}
}
}
@Test
fun `disallowed command`() {
ledgerServices.ledger {
transaction {
input(integerContract, IntegerTestState("1"))
output(integerContract, IntegerTestState("2"))
command(BIG_CORP_PUBKEY, Operations.Multiply2)
fails()
}
}
}
@Test
fun `disallowed signer`() {
ledgerServices.ledger {
transaction {
input(integerContract, IntegerTestState("1"))
output(integerContract, IntegerTestState("2"))
command(ALICE_PUBKEY, Operations.Add1)
fails()
}
}
}
@Test
fun `irrelevant commands fail`() {
ledgerServices.ledger {
transaction {
output(integerContract, IntegerTestState("8"))
command(ALICE_PUBKEY, Operations.AnotherCommand)
failsWith("at least one Command relevant")
}
}
}
@Test
fun `multiple relevant commands accepted`() {
ledgerServices.ledger {
transaction {
input(integerContract, IntegerTestState("11"))
output(integerContract, IntegerTestState("22"))
command(BOB_PUBKEY, Operations.Randomise)
command(BOB_PUBKEY, Operations.Multiply2)
verifies()
}
}
}
@Test
fun `multiple relevant commands failed`() {
ledgerServices.ledger {
transaction {
input(integerContract, IntegerTestState("2"))
output(integerContract, IntegerTestState("4"))
command(BOB_PUBKEY, Operations.Randomise)
command(BOB_PUBKEY, Operations.Multiply2)
fails()
}
}
}
@Test
fun `multiple inputs failed`() {
ledgerServices.ledger {
transaction {
input(integerContract, IntegerTestState("1"))
input(integerContract, IntegerTestState("2"))
output(integerContract, IntegerTestState("11"))
command(BIG_CORP_PUBKEY, Operations.Add10)
fails()
}
}
}
@Test
fun `multiple outputs failed`() {
ledgerServices.ledger {
transaction {
input(integerContract, IntegerTestState("1"))
output(integerContract, IntegerTestState("2"))
output(integerContract, IntegerTestState("11"))
command(BIG_CORP_PUBKEY, Operations.Add10)
fails()
}
}
}
@Test
fun `role change signer correct`() {
ledgerServices.ledger {
transaction {
output(ownedIntegerContract, TestOwnedIntegerState("0"))
command(ALICE_PUBKEY, Operations.Add1)
verifies()
}
transaction {
input(ownedIntegerContract, TestOwnedIntegerState("0"))
output(ownedIntegerContract, TestOwnedIntegerState("1"))
command(ALICE_PUBKEY, Operations.Add1)
verifies()
}
transaction {
input(ownedIntegerContract, TestOwnedIntegerState("1"))
output(ownedIntegerContract, TestOwnedIntegerState("2"))
command(ALICE_PUBKEY, Operations.Add1)
fails()
}
transaction {
input(ownedIntegerContract, TestOwnedIntegerState("1"))
output(ownedIntegerContract, TestOwnedIntegerState("2"))
command(BOB_PUBKEY, Operations.Add1)
verifies()
}
}
}
@Test
fun `multiple signers disallowed`() {
ledgerServices.ledger {
transaction {
input(ownedIntegerContract, TestOwnedIntegerState("10"))
output(ownedIntegerContract, TestOwnedIntegerState("20"))
command(ALICE_PUBKEY, Operations.Multiply2)
failsWith("Cannot have different signers")
}
}
}
@Test
fun `spend disallowed`() {
ledgerServices.ledger {
transaction {
input(integerContract, IntegerTestState("1"))
command(ALICE_PUBKEY, Operations.Close)
fails()
}
}
}
}

View File

@ -21,6 +21,7 @@ include 'experimental:sandbox'
include 'experimental:quasar-hook'
include 'experimental:kryo-hook'
include 'experimental:blobinspector'
include 'experimental:corda-utils'
include 'test-common'
include 'test-utils'
include 'smoke-test-utils'