diff --git a/contracts/src/main/kotlin/com/r3corda/contracts/tradefinance/Notice.kt b/contracts/src/main/kotlin/com/r3corda/contracts/tradefinance/Notice.kt new file mode 100644 index 0000000000..15581a7228 --- /dev/null +++ b/contracts/src/main/kotlin/com/r3corda/contracts/tradefinance/Notice.kt @@ -0,0 +1,12 @@ +package com.r3corda.contracts.tradefinance + +import java.security.PublicKey +import java.util.* + +/** + * A notice which can be attached to a receivable. + */ +sealed class Notice(val id: UUID, val owner: PublicKey) { + class OwnershipInterest(id: UUID, owner: PublicKey) : Notice(id, owner) + class Objection(id: UUID, owner: PublicKey) : Notice(id, owner) +} \ No newline at end of file diff --git a/contracts/src/main/kotlin/com/r3corda/contracts/tradefinance/Receivable.kt b/contracts/src/main/kotlin/com/r3corda/contracts/tradefinance/Receivable.kt new file mode 100644 index 0000000000..cddf5da957 --- /dev/null +++ b/contracts/src/main/kotlin/com/r3corda/contracts/tradefinance/Receivable.kt @@ -0,0 +1,305 @@ +package com.r3corda.contracts.tradefinance + +import com.r3corda.core.contracts.* +import com.r3corda.core.contracts.clauses.* +import com.r3corda.core.crypto.Party +import com.r3corda.core.crypto.SecureHash +import com.r3corda.core.isOrderedAndUnique +import com.r3corda.core.random63BitValue +import com.r3corda.core.serialization.OpaqueBytes +import com.r3corda.core.utilities.NonEmptySet +import java.security.PublicKey +import java.time.Instant +import java.time.LocalDate +import java.time.ZonedDateTime +import java.util.* + +/** + * Contract for managing lifecycle of a receivable which is recorded on the distributed ledger. These are entered by + * a third party (typically a potential creditor), and then shared by the trade finance registry, allowing others to + * attach/detach notices of ownership interest/objection. + * + * States of this contract *are not* fungible, and as such special rules apply. States must be unique within the + * inputs/outputs, and strictly ordered, in order to make it easy to verify that outputs match the inputs except where + * commands mean there are changes. + */ +class Receivable : Contract { + data class State(override val linearId: UniqueIdentifier = UniqueIdentifier(), + val created: ZonedDateTime, // When the underlying receivable was raised + val registered: Instant, // When the receivable was added to the registry + val payer: Party, + val payee: Party, + val payerRef: OpaqueBytes?, + val payeeRef: OpaqueBytes?, + val value: Amount>, + val attachments: Set, + val notices: List, + override val owner: PublicKey) : OwnableState, LinearState { + override val contract: Contract = Receivable() + override val participants: List = listOf(owner) + override fun isRelevant(ourKeys: Set): Boolean + = ourKeys.contains(payer.owningKey) || ourKeys.contains(payee.owningKey) || ourKeys.contains(owner) + override fun withNewOwner(newOwner: PublicKey): Pair + = Pair(Commands.Move(null, mapOf(Pair(linearId, newOwner))), copy(owner = newOwner)) + } + + interface Commands : CommandData { + val changed: Iterable + data class Issue(override val changed: NonEmptySet, + override val nonce: Long = random63BitValue()) : IssueCommand, Commands + data class Move(override val contractHash: SecureHash?, val changes: Map) : MoveCommand, Commands { + override val changed: Iterable = changes.keys + } + data class Note(val changes: Map>) : Commands { + override val changed: Iterable = changes.keys + } + // TODO: Write Amend clause, possibly to merge into Move + /* data class Amend(val id: UniqueIdentifier, + val payer: Party, + val payee: Party, + val payerRef: OpaqueBytes?, + val payeeRef: OpaqueBytes?, + val value: Amount>, + val attachments: Set) : Commands */ + data class Exit(override val changed: NonEmptySet) : Commands + } + + data class Diff(val added: List, val removed: List) + + interface Clauses { + /** + * Assert that each input/output state is unique within that list of states, and that states are ordered. There + * should never be the same receivable twice in a transaction. Uniqueness is also enforced by the notary, + * but we get the check as a side-effect of comparing states, so the duplication is acceptable. + */ + class StatesAreOrderedAndUnique : Clause() { + override fun verify(tx: TransactionForContract, + inputs: List, + outputs: List, + commands: List>, + groupingKey: Unit?): Set { + // Enforce that states are ordered, so that the transaction can only be assembled in one way + requireThat { + "input receivables are ordered and unique" by inputs.isOrderedAndUnique { linearId } + "output receivables are ordered and unique" by outputs.isOrderedAndUnique { linearId } + } + return emptySet() + } + } + + /** + * Check that all inputs are present as outputs, and that all owners for new outputs have signed the command. + */ + class Issue : Clause() { + override val requiredCommands: Set> = setOf(Commands.Issue::class.java) + + override fun verify(tx: TransactionForContract, + inputs: List, + outputs: List, + commands: List>, + groupingKey: Unit?): Set { + require(groupingKey == null) + // TODO: Take in matched commands as a parameter + val command = commands.requireSingleCommand() + val timestamp = tx.timestamp + + // Records for receivables are never fungible, so we just want to make sure all inputs exist as + // outputs, and there are new outputs. + requireThat { + "there are more output states than input states" by (outputs.size > inputs.size) + // TODO: Should timestamps perhaps be enforced on all receivable transactions? + "the transaction has a timestamp" by (timestamp != null) + } + + val expectedOutputs = ArrayList(inputs) + val keysThatSigned = command.signers + val owningPubKeys = HashSet() + outputs + .filter { it.linearId in command.value.changed } + .forEach { state -> + val registrationInLocalZone = state.registered.atZone(state.created.zone) + requireThat { + "the receivable is registered after it was created" by (state.created < registrationInLocalZone) + // TODO: Should narrow the window on how long ago the registration can be compared to the transaction + "the receivable is registered before the transaction date" by (state.registered < timestamp?.before) + } + owningPubKeys.add(state.owner) + expectedOutputs.add(state) + } + // Re-sort the outputs now we've finished changing them + expectedOutputs.sortBy { state -> state.linearId } + requireThat { + "the owning keys are the same as the signing keys" by keysThatSigned.containsAll(owningPubKeys) + "outputs match inputs with expected changes applied" by outputs.equals(expectedOutputs) + } + + return setOf(command.value as Commands) + } + } + + /** + * Check that inputs and outputs are exactly the same, except for ownership changes specified in the command. + * The command must be signed by the previous owners of all changed input states. + */ + class Move : Clause() { + override val requiredCommands: Set> = setOf(Commands.Move::class.java) + + override fun verify(tx: TransactionForContract, + inputs: List, + outputs: List, + commands: List>, + groupingKey: Unit?): Set { + require(groupingKey == null) + // TODO: Take in matched commands as a parameter + val moveCommand = commands.requireSingleCommand() + val changes = moveCommand.value.changes + // Rebuild the outputs we expect, then compare. Receivables are not fungible, so inputs and outputs + // must match one to one + val expectedOutputs: List = inputs.map { input -> + val newOwner = changes[input.linearId] + if (newOwner != null) { + input.copy(owner = newOwner) + } else { + input + } + } + requireThat { + "inputs are not empty" by inputs.isNotEmpty() + "outputs match inputs with expected changes applied" by outputs.equals(expectedOutputs) + } + // Do standard move command checks including the signature checks + verifyMoveCommand(inputs, commands) + return setOf(moveCommand.value as Commands) + } + } + + /** + * Add and/or remove notices on receivables. All input states must match output states, except for the + * changed notices. + */ + class Note : Clause() { + override val requiredCommands: Set> = setOf(Commands.Note::class.java) + + override fun verify(tx: TransactionForContract, + inputs: List, + outputs: List, + commands: List>, + groupingKey: Unit?): Set { + require(groupingKey == null) + // TODO: Take in matched commands as a parameter + val command = commands.requireSingleCommand() + // Rebuild the outputs we expect, then compare. Receivables are not fungible, so inputs and outputs + // must match one to one + val (expectedOutputs, owningPubKeys) = deriveOutputStates(inputs, command) + val keysThatSigned = command.signers + requireThat { + "inputs are not empty" by inputs.isNotEmpty() + "outputs match inputs with expected changes applied" by outputs.equals(expectedOutputs) + "the owning keys are the same as the signing keys" by keysThatSigned.containsAll(owningPubKeys) + } + return setOf(command.value as Commands) + } + + fun deriveOutputStates(inputs: List, + command: AuthenticatedObject): Pair, Set> { + val changes = command.value.changes + val seenNotices = HashSet() + val outputs = inputs.map { input -> + val stateChanges = changes[input.linearId] + if (stateChanges != null) { + val notices = ArrayList(input.notices) + stateChanges.added.forEach { notice -> + require(!seenNotices.contains(notice)) { "Notices can only appear once in the add and/or remove lists" } + require(!notices.contains(notice)) { "Notice is already present on the receivable" } + seenNotices.add(notice) + notices.add(notice) + } + stateChanges.removed.forEach { notice -> + require(!seenNotices.contains(notice)) { "Notices can only appear once in the add and/or remove lists" } + require(notices.remove(notice)) { "Notice is not present on the receivable" } + seenNotices.add(notice) + } + input.copy(notices = notices) + } else { + input + } + } + return Pair(outputs, seenNotices.map { it.owner }.toSet() ) + } + } + + /** + * Remove a receivable from the ledger. This can only be done once all notices have been removed. + */ + class Exit : Clause() { + override val requiredCommands: Set> = setOf(Commands.Exit::class.java) + + override fun verify(tx: TransactionForContract, + inputs: List, + outputs: List, + commands: List>, + groupingKey: Unit?): Set { + require(groupingKey == null) + // TODO: Take in matched commands as a parameter + val command = commands.requireSingleCommand() + val unmatchedIds = HashSet(command.value.changed) + val owningPubKeys = HashSet() + val expectedOutputs = inputs.filter { input -> + if (unmatchedIds.contains(input.linearId)) { + requireThat { + "there are no notices on receivables to be removed from the ledger" by input.notices.isEmpty() + } + unmatchedIds.remove(input.linearId) + owningPubKeys.add(input.owner) + false + } else { + true + } + } + val keysThatSigned = command.signers + requireThat { + "inputs are not empty" by inputs.isNotEmpty() + "outputs match inputs with expected changes applied" by outputs.equals(expectedOutputs) + "the owning keys are the same as the signing keys" by keysThatSigned.containsAll(owningPubKeys) + } + return setOf(command.value as Commands) + } + } + + // TODO: Amend clause, which replaces the Move clause + + /** + * Default clause, which checks the inputs and outputs match. Normally this wouldn't be expected to trigger, + * as other commands would handle the transaction, but this exists in case the states need to be witnessed by + * other contracts within the transaction but not modified. + */ + class InputsAndOutputsMatch : Clause() { + override fun verify(tx: TransactionForContract, + inputs: List, + outputs: List, + commands: List>, + groupingKey: Unit?): Set { + require(groupingKey == null) + require(inputs.equals(outputs)) { "Inputs and outputs must match unless commands indicate otherwise" } + return emptySet() + } + } + } + + override val legalContractReference: SecureHash = SecureHash.sha256("https://www.big-book-of-banking-law.gov/receivables.html") + fun extractCommands(commands: Collection>): List> + = commands.select() + override fun verify(tx: TransactionForContract) + = verifyClause(tx, FilterOn( + AllComposition( + Clauses.StatesAreOrderedAndUnique(), // TODO: This is varient of the LinearState.ClauseVerifier, and we should move it up there + FirstComposition( + Clauses.Issue(), + Clauses.Exit(), + Clauses.Note(), + Clauses.Move(), + Clauses.InputsAndOutputsMatch() + ) + ), { states -> states.filterIsInstance() }), + extractCommands(tx.commands)) +} diff --git a/contracts/src/test/kotlin/com/r3corda/contracts/tradefinance/ReceivableTests.kt b/contracts/src/test/kotlin/com/r3corda/contracts/tradefinance/ReceivableTests.kt new file mode 100644 index 0000000000..cc1246d961 --- /dev/null +++ b/contracts/src/test/kotlin/com/r3corda/contracts/tradefinance/ReceivableTests.kt @@ -0,0 +1,153 @@ +package com.r3corda.contracts.tradefinance + +import com.r3corda.contracts.asset.DUMMY_CASH_ISSUER +import com.r3corda.core.contracts.* +import com.r3corda.core.serialization.OpaqueBytes +import com.r3corda.core.utilities.NonEmptySet +import com.r3corda.core.utilities.TEST_TX_TIME +import com.r3corda.testing.* +import org.junit.Test +import java.time.Duration +import java.time.ZoneId +import java.util.* + +class ReceivableTests { + val inStates = arrayOf( + Receivable.State( + UniqueIdentifier.fromString("9e688c58-a548-3b8e-af69-c9e1005ad0bf"), + (TEST_TX_TIME - Duration.ofDays(2)).atZone(ZoneId.of("UTC")), + TEST_TX_TIME - Duration.ofDays(1), + ALICE, + BOB, + OpaqueBytes(ByteArray(1, { 1 })), + OpaqueBytes(ByteArray(1, { 2 })), + Amount>(1000L, USD `issued by` DUMMY_CASH_ISSUER), + emptySet(), + emptyList(), + MEGA_CORP_PUBKEY + ), + Receivable.State( + UniqueIdentifier.fromString("55a54008-ad1b-3589-aa21-0d2629c1df41"), + (TEST_TX_TIME - Duration.ofDays(2)).atZone(ZoneId.of("UTC")), + TEST_TX_TIME - Duration.ofDays(1), + ALICE, + BOB, + OpaqueBytes(ByteArray(1, { 3 })), + OpaqueBytes(ByteArray(1, { 4 })), + Amount>(2000L, GBP `issued by` DUMMY_CASH_ISSUER), + emptySet(), + emptyList(), + MEGA_CORP_PUBKEY + ) + ) + + @Test + fun trivial() { + transaction { + input { inStates[0] } + timestamp(TEST_TX_TIME) + this `fails with` "Inputs and outputs must match unless commands indicate otherwise" + + tweak { + output { inStates[0] } + verifies() + } + + tweak { + output { inStates[1] } + this `fails with` "Inputs and outputs must match unless commands indicate otherwise" + } + } + + transaction { + output { inStates[0] } + timestamp(TEST_TX_TIME) + this `fails with` "Inputs and outputs must match unless commands indicate otherwise" + } + } + + @Test + fun `order and uniqueness is enforced`() { + transaction { + input { inStates[0] } + input { inStates[1] } + output { inStates[0] } + output { inStates[1] } + timestamp(TEST_TX_TIME) + verifies() + } + + transaction { + input { inStates[1] } + input { inStates[0] } + output { inStates[0] } + output { inStates[1] } + timestamp(TEST_TX_TIME) + this `fails with` "receivables are ordered and unique" + } + + transaction { + input { inStates[0] } + input { inStates[0] } + output { inStates[0] } + output { inStates[0] } + timestamp(TEST_TX_TIME) + this `fails with` "receivables are ordered and unique" + } + } + + @Test + fun `issue`() { + // Testing that arbitrary new outputs are rejected is covered in trivial() + transaction { + output { inStates[0] } + command(MEGA_CORP_PUBKEY, Receivable.Commands.Issue(NonEmptySet(inStates[0].linearId))) + timestamp(TEST_TX_TIME) + verifies() + } + transaction { + output { inStates[0] } + command(ALICE_PUBKEY, Receivable.Commands.Issue(NonEmptySet(inStates[0].linearId))) + timestamp(TEST_TX_TIME) + this `fails with` "the owning keys are the same as the signing keys" + } + } + + @Test + fun `move`() { + transaction { + input { inStates[0] } + output { inStates[0].copy(owner = MINI_CORP_PUBKEY) } + timestamp(TEST_TX_TIME) + this `fails with` "Inputs and outputs must match unless commands indicate otherwise" + tweak { + command(MEGA_CORP_PUBKEY, Receivable.Commands.Move(null, mapOf(Pair(inStates[0].linearId, MINI_CORP_PUBKEY)))) + verifies() + } + // Test that moves enforce the correct new owner + tweak { + command(MEGA_CORP_PUBKEY, Receivable.Commands.Move(null, mapOf(Pair(inStates[0].linearId, ALICE_PUBKEY)))) + this `fails with` "outputs match inputs with expected changes applied" + } + } + } + + @Test + fun `exit`() { + // Testing that arbitrary disappearing outputs are rejected is covered in trivial() + transaction { + input { inStates[0] } + timestamp(TEST_TX_TIME) + command(MEGA_CORP_PUBKEY, Receivable.Commands.Exit(NonEmptySet(inStates[0].linearId))) + verifies() + } + transaction { + input { inStates[0] } + timestamp(TEST_TX_TIME) + command(ALICE_PUBKEY, Receivable.Commands.Exit(NonEmptySet(inStates[0].linearId))) + this `fails with` "the owning keys are the same as the signing keys" + } + } + + // TODO: Test adding and removing notices +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/r3corda/core/Utils.kt b/core/src/main/kotlin/com/r3corda/core/Utils.kt index fc2d40768b..9a81635d75 100644 --- a/core/src/main/kotlin/com/r3corda/core/Utils.kt +++ b/core/src/main/kotlin/com/r3corda/core/Utils.kt @@ -276,4 +276,21 @@ fun Observable.bufferUntilSubscribed(): Observable { val subject = UnicastSubject.create() val subscription = subscribe(subject) return subject.doOnUnsubscribe { subscription.unsubscribe() } +} + +/** + * Determine if an iterable data type's contents are ordered and unique, based on their [Comparable].compareTo + * function. + */ +fun > Iterable.isOrderedAndUnique(extractId: T.() -> I): Boolean { + var last: I? = null + return all { it -> + val lastLast = last + last = extractId(it) + if (lastLast == null) { + true + } else { + lastLast.compareTo(extractId(it)) < 0 + } + } } \ No newline at end of file diff --git a/core/src/main/kotlin/com/r3corda/core/contracts/FinanceTypes.kt b/core/src/main/kotlin/com/r3corda/core/contracts/FinanceTypes.kt index 1d91ddfee1..46f6c0fac6 100644 --- a/core/src/main/kotlin/com/r3corda/core/contracts/FinanceTypes.kt +++ b/core/src/main/kotlin/com/r3corda/core/contracts/FinanceTypes.kt @@ -448,13 +448,38 @@ data class Commodity(val commodityCode: String, /** * This class provides a truly unique identifier of a trade, state, or other business object. - * @param externalId If there is an existing weak identifer e.g. trade reference id. + * + * @param externalId If there is an existing weak identifier e.g. trade reference id. * This should be set here the first time a UniqueIdentifier identifier is created as part of an issue, * or ledger on-boarding activity. This ensure that the human readable identity is paired with the strong id. * @param id Should never be set by user code and left as default initialised. * So that the first time a state is issued this should be given a new UUID. * Subsequent copies and evolutions of a state should just copy the externalId and Id fields unmodified. */ -data class UniqueIdentifier(val externalId: String? = null, val id: UUID = UUID.randomUUID()) { +data class UniqueIdentifier(val externalId: String? = null, val id: UUID = UUID.randomUUID()) : Comparable { override fun toString(): String = if (externalId != null) "${externalId}_$id" else id.toString() -} \ No newline at end of file + companion object { + fun fromString(name: String) : UniqueIdentifier + = UniqueIdentifier(null, UUID.fromString(name)) + } + + override fun compareTo(other: UniqueIdentifier): Int { + val idCompare = id.compareTo(other.id) + return if (idCompare == 0) + compareExternalIds(other) + else + idCompare + } + + private fun compareExternalIds(other: UniqueIdentifier): Int + = if (other.externalId == null) + if (externalId == null) + 0 + else + 1 + else + if (externalId == null) + -1 + else + externalId.compareTo(externalId) +} diff --git a/core/src/main/kotlin/com/r3corda/core/contracts/Structures.kt b/core/src/main/kotlin/com/r3corda/core/contracts/Structures.kt index db426ac830..56f7f15e11 100644 --- a/core/src/main/kotlin/com/r3corda/core/contracts/Structures.kt +++ b/core/src/main/kotlin/com/r3corda/core/contracts/Structures.kt @@ -238,18 +238,18 @@ interface LinearState: ContractState { /** * Standard clause to verify the LinearState safety properties. */ - class ClauseVerifier(val stateClass: Class) : Clause() { + class ClauseVerifier() : Clause() { override fun verify(tx: TransactionForContract, - inputs: List, - outputs: List, - commands: List>, - groupingKey: Unit?): Set { - val filteredInputs = inputs.filterIsInstance(stateClass) - val inputIds = filteredInputs.map { it.linearId }.distinct() - require(inputIds.count() == filteredInputs.count()) { "LinearStates cannot be merged" } - val filteredOutputs = outputs.filterIsInstance(stateClass) - val outputIds = filteredOutputs.map { it.linearId }.distinct() - require(outputIds.count() == filteredOutputs.count()) { "LinearStates cannot be split" } + inputs: List, + outputs: List, + commands: List>, + groupingKey: Unit?): Set { + val inputIds = inputs.map { it.linearId }.distinct() + val outputIds = outputs.map { it.linearId }.distinct() + requireThat { + "LinearStates are not merged" by (inputIds.count() == inputs.count()) + "LinearStates are not split" by (outputIds.count() == outputs.count()) + } return emptySet() } } diff --git a/core/src/main/kotlin/com/r3corda/core/contracts/clauses/FirstComposition.kt b/core/src/main/kotlin/com/r3corda/core/contracts/clauses/FirstComposition.kt index 100234ed32..aace2abae5 100644 --- a/core/src/main/kotlin/com/r3corda/core/contracts/clauses/FirstComposition.kt +++ b/core/src/main/kotlin/com/r3corda/core/contracts/clauses/FirstComposition.kt @@ -24,8 +24,10 @@ class FirstComposition(val firstCla clauses.addAll(remainingClauses) } - override fun verify(tx: TransactionForContract, inputs: List, outputs: List, commands: List>, groupingKey: K?): Set - = matchedClauses(commands).single().verify(tx, inputs, outputs, commands, groupingKey) + override fun verify(tx: TransactionForContract, inputs: List, outputs: List, commands: List>, groupingKey: K?): Set { + val clause = matchedClauses(commands).singleOrNull() ?: throw IllegalStateException("No delegate clause matched in first composition") + return clause.verify(tx, inputs, outputs, commands, groupingKey) + } override fun toString() = "First: ${clauses.toList()}" } \ No newline at end of file diff --git a/test-utils/src/main/kotlin/com/r3corda/testing/DummyLinearState.kt b/test-utils/src/main/kotlin/com/r3corda/testing/DummyLinearState.kt index b5503079be..24697f0b64 100644 --- a/test-utils/src/main/kotlin/com/r3corda/testing/DummyLinearState.kt +++ b/test-utils/src/main/kotlin/com/r3corda/testing/DummyLinearState.kt @@ -2,6 +2,7 @@ package com.r3corda.testing import com.r3corda.core.contracts.* import com.r3corda.core.contracts.clauses.Clause +import com.r3corda.core.contracts.clauses.FilterOn import com.r3corda.core.contracts.clauses.verifyClause import com.r3corda.core.crypto.SecureHash import java.security.PublicKey @@ -9,9 +10,9 @@ import java.security.PublicKey class DummyLinearContract: Contract { override val legalContractReference: SecureHash = SecureHash.sha256("Test") - val clause: Clause = LinearState.ClauseVerifier(State::class.java) + val clause: Clause = LinearState.ClauseVerifier() override fun verify(tx: TransactionForContract) = verifyClause(tx, - clause, + FilterOn(clause, { states -> states.filterIsInstance() }), emptyList()) class State(