diff --git a/contracts/src/main/java/com/r3corda/contracts/JavaCommercialPaper.java b/contracts/src/main/java/com/r3corda/contracts/JavaCommercialPaper.java index 6562fa99f9..3e0cbb7f98 100644 --- a/contracts/src/main/java/com/r3corda/contracts/JavaCommercialPaper.java +++ b/contracts/src/main/java/com/r3corda/contracts/JavaCommercialPaper.java @@ -124,6 +124,12 @@ public class JavaCommercialPaper implements Contract { public List getParticipants() { return ImmutableList.of(this.owner); } + + @Nullable + @Override + public Integer getEncumbrance() { + return null; + } } public interface Clauses { diff --git a/contracts/src/main/kotlin/com/r3corda/contracts/asset/Cash.kt b/contracts/src/main/kotlin/com/r3corda/contracts/asset/Cash.kt index c0ec4a819d..4bdf930b38 100644 --- a/contracts/src/main/kotlin/com/r3corda/contracts/asset/Cash.kt +++ b/contracts/src/main/kotlin/com/r3corda/contracts/asset/Cash.kt @@ -72,12 +72,13 @@ class Cash : OnLedgerAsset() { class ConserveAmount : AbstractConserveAmount() } - /** A state representing a cash claim against some party */ + /** A state representing a cash claim against some party. */ data class State( override val amount: Amount>, - /** There must be a MoveCommand signed by this key to claim the amount */ - override val owner: PublicKey + /** There must be a MoveCommand signed by this key to claim the amount. */ + override val owner: PublicKey, + override val encumbrance: Int? = null ) : FungibleAsset { constructor(deposit: PartyAndReference, amount: Amount, owner: PublicKey) : this(Amount(amount.quantity, Issued(deposit, amount.token)), owner) diff --git a/contracts/src/test/java/com/r3corda/contracts/asset/CashTestsJava.java b/contracts/src/test/java/com/r3corda/contracts/asset/CashTestsJava.java index c1a6daba08..28bb658bf0 100644 --- a/contracts/src/test/java/com/r3corda/contracts/asset/CashTestsJava.java +++ b/contracts/src/test/java/com/r3corda/contracts/asset/CashTestsJava.java @@ -15,8 +15,8 @@ import static com.r3corda.testing.CoreTestUtils.*; public class CashTestsJava { private final OpaqueBytes defaultRef = new OpaqueBytes(new byte[]{1}); private final PartyAndReference defaultIssuer = getMEGA_CORP().ref(defaultRef); - private final Cash.State inState = new Cash.State(issuedBy(DOLLARS(1000), defaultIssuer), getDUMMY_PUBKEY_1()); - private final Cash.State outState = new Cash.State(inState.getAmount(), getDUMMY_PUBKEY_2()); + private final Cash.State inState = new Cash.State(issuedBy(DOLLARS(1000), defaultIssuer), getDUMMY_PUBKEY_1(), null); + private final Cash.State outState = new Cash.State(inState.getAmount(), getDUMMY_PUBKEY_2(), null); @Test public void trivial() { @@ -26,7 +26,7 @@ public class CashTestsJava { tx.failsWith("the amounts balance"); tx.tweak(tw -> { - tw.output(new Cash.State(issuedBy(DOLLARS(2000), defaultIssuer), getDUMMY_PUBKEY_2())); + tw.output(new Cash.State(issuedBy(DOLLARS(2000), defaultIssuer), getDUMMY_PUBKEY_2(), null)); return tw.failsWith("the amounts balance"); }); 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 3322806ee1..0d0c1c2294 100644 --- a/core/src/main/kotlin/com/r3corda/core/contracts/Structures.kt +++ b/core/src/main/kotlin/com/r3corda/core/contracts/Structures.kt @@ -113,6 +113,25 @@ interface ContractState { * list should just contain the owner. */ val participants: List + + /** + * All contract states may be _encumbered_ by up to one other state. + * + * The encumbrance state, if present, forces additional controls over the encumbered state, since the platform checks + * that the encumbrance state is present as an input in the same transaction that consumes the encumbered state, and + * the contract code and rules of the encumbrance state will also be verified during the execution of the transaction. + * For example, a cash contract state could be encumbered with a time-lock contract state; the cash state is then only + * processable in a transaction that verifies that the time specified in the encumbrance time-lock has passed. + * + * The encumbered state refers to another by index, and the referred encumbrance state + * is an output state in a particular position on the same transaction that created the encumbered state. An alternative + * implementation would be encumber by reference to a StateRef., which would allow the specification of encumbrance + * by a state created in a prior transaction. + * + * Note that an encumbered state that is being consumed must have its encumbrance consumed in the same transaction, + * otherwise the transaction is not valid. + */ + val encumbrance: Int? get() = null } /** diff --git a/core/src/main/kotlin/com/r3corda/core/contracts/TransactionTypes.kt b/core/src/main/kotlin/com/r3corda/core/contracts/TransactionTypes.kt index 3c5b4fc585..b4d3d7921d 100644 --- a/core/src/main/kotlin/com/r3corda/core/contracts/TransactionTypes.kt +++ b/core/src/main/kotlin/com/r3corda/core/contracts/TransactionTypes.kt @@ -78,6 +78,29 @@ sealed class TransactionType { throw TransactionVerificationException.ContractRejection(tx, contract, e) } } + + // Validate that all encumbrances exist within the set of input states. + tx.inputs.filter { it.state.data.encumbrance != null }.forEach { + encumberedInput -> + if (tx.inputs.none { it.ref.txhash == encumberedInput.ref.txhash && + it.ref.index == encumberedInput.state.data.encumbrance }) { + throw TransactionVerificationException.TransactionMissingEncumbranceException( + tx, encumberedInput.state.data.encumbrance!!, + TransactionVerificationException.Direction.INPUT + ) + } + } + + // Check that, in the outputs, an encumbered state does not refer to itself as the encumbrance, + // and that the number of outputs can contain the encumbrance. + for ((i, output) in tx.outputs.withIndex() ) { + val encumbranceIndex = output.data.encumbrance ?: continue + if (encumbranceIndex == i || encumbranceIndex >= tx.outputs.size) { + throw TransactionVerificationException.TransactionMissingEncumbranceException( + tx, encumbranceIndex, + TransactionVerificationException.Direction.OUTPUT) + } + } } override fun getRequiredSigners(tx: LedgerTransaction) = tx.commands.flatMap { it.signers }.toSet() diff --git a/core/src/main/kotlin/com/r3corda/core/contracts/TransactionVerification.kt b/core/src/main/kotlin/com/r3corda/core/contracts/TransactionVerification.kt index dc9052ebf6..a04f3b67e8 100644 --- a/core/src/main/kotlin/com/r3corda/core/contracts/TransactionVerification.kt +++ b/core/src/main/kotlin/com/r3corda/core/contracts/TransactionVerification.kt @@ -101,4 +101,12 @@ sealed class TransactionVerificationException(val tx: LedgerTransaction, cause: class NotaryChangeInWrongTransactionType(tx: LedgerTransaction, val outputNotary: Party) : TransactionVerificationException(tx, null) { override fun toString(): String = "Found unexpected notary change in transaction. Tx notary: ${tx.notary}, found: ${outputNotary}" } + class TransactionMissingEncumbranceException(tx: LedgerTransaction, val missing: Int, val inOut: Direction) : TransactionVerificationException(tx, null) { + override val message: String? + get() = "Missing required encumbrance ${missing} in ${inOut}" + } + enum class Direction { + INPUT, + OUTPUT + } } diff --git a/core/src/test/kotlin/com/r3corda/core/contracts/TransactionEncumbranceTests.kt b/core/src/test/kotlin/com/r3corda/core/contracts/TransactionEncumbranceTests.kt new file mode 100644 index 0000000000..f41de2521b --- /dev/null +++ b/core/src/test/kotlin/com/r3corda/core/contracts/TransactionEncumbranceTests.kt @@ -0,0 +1,155 @@ +package com.r3corda.core.contracts + +import com.r3corda.contracts.asset.Cash +import com.r3corda.core.crypto.SecureHash +import com.r3corda.core.testing.* +import org.junit.Test +import java.security.PublicKey +import java.time.Instant +import java.time.temporal.ChronoUnit + +val TEST_TIMELOCK_ID = TransactionEncumbranceTests.TestTimeLock() + +class TransactionEncumbranceTests { + val defaultIssuer = MEGA_CORP.ref(1) + val encumberedState = Cash.State( + amount = 1000.DOLLARS `issued by` defaultIssuer, + owner = DUMMY_PUBKEY_1, + encumbrance = 1 + ) + val unencumberedState = Cash.State( + amount = 1000.DOLLARS `issued by` defaultIssuer, + owner = DUMMY_PUBKEY_1 + ) + val stateWithNewOwner = encumberedState.copy(owner = DUMMY_PUBKEY_2) + val FOUR_PM = Instant.parse("2015-04-17T16:00:00.00Z") + val FIVE_PM = FOUR_PM.plus(1, ChronoUnit.HOURS) + val FIVE_PM_TIMELOCK = TestTimeLock.State(FIVE_PM) + + + class TestTimeLock : Contract { + override val legalContractReference = SecureHash.sha256("TestTimeLock") + override fun verify(tx: TransactionForContract) { + val time = tx.timestamp?.before ?: throw IllegalArgumentException("Transactions containing time-locks must be timestamped") + requireThat { + "the time specified in the time-lock has passed" by + (time >= tx.inputs.filterIsInstance().single().validFrom) + } + } + + data class State( + val validFrom: Instant + ) : ContractState { + override val participants: List = emptyList() + override val contract: Contract = TEST_TIMELOCK_ID + } + } + + @Test + fun trivial() { + // A transaction containing an input state that is encumbered must fail if the encumbrance has not been presented + // on the input states. + transaction { + input { encumberedState } + output { unencumberedState } + command(DUMMY_PUBKEY_1) { Cash.Commands.Move() } + this `fails with` "Missing required encumbrance 1 in INPUT" + } + // An encumbered state must not be encumbered by itself. + transaction { + input { unencumberedState } + input { unencumberedState } + output { unencumberedState } + // The encumbered state refers to an encumbrance in position 1, so what follows is wrong. + output { encumberedState } + command(DUMMY_PUBKEY_1) { Cash.Commands.Move() } + this `fails with` "Missing required encumbrance 1 in OUTPUT" + } + // An encumbered state must not reference an index greater than the size of the output states. + // In this test, the output encumbered state refers to an encumbrance in position 1, but there is only one output. + transaction { + input { unencumberedState } + // The encumbered state refers to an encumbrance in position 1, so there should be at least 2 outputs. + output { encumberedState } + command(DUMMY_PUBKEY_1) { Cash.Commands.Move() } + this `fails with` "Missing required encumbrance 1 in OUTPUT" + } + + } + + @Test + fun testEncumbranceEffects() { + // This test fails because the encumbered state is pointing to the ordinary cash state as the encumbrance, + // instead of the timelock by mistake, so when we try and use it the transaction fails as we didn't include the + // encumbrance cash state. + ledger { + unverifiedTransaction { + output("state encumbered by 5pm time-lock") { encumberedState } + output { unencumberedState } + output("5pm time-lock") { FIVE_PM_TIMELOCK } + } + transaction { + input("state encumbered by 5pm time-lock") + input("5pm time-lock") + output { stateWithNewOwner } + command(DUMMY_PUBKEY_1) { Cash.Commands.Move() } + timestamp(FIVE_PM) + this `fails with` "Missing required encumbrance 1 in INPUT" + } + } + // A transaction containing an input state that is encumbered must fail if the encumbrance is not in the correct + // transaction. In this test, the intended encumbrance is presented alongside the encumbered state for consumption, + // although the encumbered state always refers to the encumbrance produced in the same transaction, and the in this case + // the encumbrance was created in a separate transaction. + ledger { + unverifiedTransaction { + output("state encumbered by 5pm time-lock") { encumberedState } + output { unencumberedState } + } + unverifiedTransaction { + output("5pm time-lock") { FIVE_PM_TIMELOCK } + } + transaction { + input("state encumbered by 5pm time-lock") + input("5pm time-lock") + output { stateWithNewOwner } + command(DUMMY_PUBKEY_1) { Cash.Commands.Move() } + timestamp(FIVE_PM) + this `fails with` "Missing required encumbrance 1 in INPUT" + } + } + // A transaction with an input state that is encumbered must succeed if the rules of the encumbrance are met. + ledger { + unverifiedTransaction { + output("state encumbered by 5pm time-lock") { encumberedState } + output("5pm time-lock") { FIVE_PM_TIMELOCK } + } + // Un-encumber the output if the time of the transaction is later than the timelock. + transaction { + input("state encumbered by 5pm time-lock") + input("5pm time-lock") + output { unencumberedState } + command(DUMMY_PUBKEY_1) { Cash.Commands.Move() } + timestamp(FIVE_PM) + this.verifies() + } + } + // A transaction with an input state that is encumbered must fail if the rules of the encumbrance are not met. + // In this test, the time-lock encumbrance is being processed in a transaction before the time allowed. + ledger { + unverifiedTransaction { + output("state encumbered by 5pm time-lock") { encumberedState } + output("5pm time-lock") { FIVE_PM_TIMELOCK } + } + // The time of the transaction is earlier than the time specified in the encumbering timelock. + transaction { + input("state encumbered by 5pm time-lock") + input("5pm time-lock") + output { unencumberedState } + command(DUMMY_PUBKEY_1) { Cash.Commands.Move() } + timestamp(FOUR_PM) + this `fails with` "the time specified in the time-lock has passed" + } + } + } +} \ No newline at end of file diff --git a/docs/build/html/.buildinfo b/docs/build/html/.buildinfo index d958f98faa..b7417346d2 100644 --- a/docs/build/html/.buildinfo +++ b/docs/build/html/.buildinfo @@ -1,4 +1,4 @@ # Sphinx build info version 1 # This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. -config: 02da61908148262295ef57918c434b8d +config: 0e8996317ea97eb6f5837df02a05a760 tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs/build/html/_sources/tutorial-contract.txt b/docs/build/html/_sources/tutorial-contract.txt index c3b1b4ca75..c593eb17c9 100644 --- a/docs/build/html/_sources/tutorial-contract.txt +++ b/docs/build/html/_sources/tutorial-contract.txt @@ -719,6 +719,70 @@ considered relevant by your wallet (e.g. because you own it), then the node can of creating a transaction and taking it through the life cycle. You can learn more about this in the article ":doc:`event-scheduling`". +Encumbrances +------------ + +All contract states may be *encumbered* by up to one other state, which we call an **encumbrance**. + +The encumbrance state, if present, forces additional controls over the encumbered state, since the encumbrance state contract +will also be verified during the execution of the transaction. For example, a contract state could be encumbered +with a time-lock contract state; the state is then only processable in a transaction that verifies that the time +specified in the encumbrance time-lock has passed. + +The encumbered state refers to its encumbrance by index, and the referred encumbrance state +is an output state in a particular position on the same transaction that created the encumbered state. Note that an +encumbered state that is being consumed must have its encumbrance consumed in the same transaction, otherwise the +transaction is not valid. + +The encumbrance reference is optional in the ``ContractState`` interface: + +.. container:: codeset + + .. sourcecode:: kotlin + + val encumbrance: Int? get() = null + + .. sourcecode:: java + + @Nullable + @Override + public Integer getEncumbrance() { + return null; + } + + +The time-lock contract mentioned above can be implemented very simply: + +.. container:: codeset + + .. sourcecode:: kotlin + + class TestTimeLock : Contract { + ... + override fun verify(tx: TransactionForContract) { + val timestamp: Timestamp? = tx.timestamp + ... + requireThat { + "the time specified in the time-lock has passed" by + (time >= tx.inputs.filterIsInstance().single().validFrom) + } + } + ... + } + +We can then set up an encumbered state: + +.. container:: codeset + + .. sourcecode:: kotlin + + val encumberedState = Cash.State(amount = 1000.DOLLARS `issued by` defaultIssuer, owner = DUMMY_PUBKEY_1, encumbrance = 1) + val fourPmTimelock = TestTimeLock.State(Instant.parse("2015-04-17T16:00:00.00Z")) + +When we construct a transaction that generates the encumbered state, we must place the encumbrance in the corresponding output +position of that transaction. And when we subsequently consume that encumbered state, the same encumbrance state must be +available somewhere within the input set of states. + Clauses ------- diff --git a/docs/build/html/_static/basic.css b/docs/build/html/_static/basic.css index 65dfd7dfda..2b513f0c96 100644 --- a/docs/build/html/_static/basic.css +++ b/docs/build/html/_static/basic.css @@ -85,10 +85,6 @@ div.sphinxsidebar #searchbox input[type="text"] { width: 170px; } -div.sphinxsidebar #searchbox input[type="submit"] { - width: 30px; -} - img { border: 0; max-width: 100%; diff --git a/docs/source/tutorial-contract.rst b/docs/source/tutorial-contract.rst index c3b1b4ca75..2ee0c4b4a7 100644 --- a/docs/source/tutorial-contract.rst +++ b/docs/source/tutorial-contract.rst @@ -719,6 +719,80 @@ considered relevant by your wallet (e.g. because you own it), then the node can of creating a transaction and taking it through the life cycle. You can learn more about this in the article ":doc:`event-scheduling`". +Encumbrances +------------ + +All contract states may be *encumbered* by up to one other state, which we call an **encumbrance**. + +The encumbrance state, if present, forces additional controls over the encumbered state, since the encumbrance state contract +will also be verified during the execution of the transaction. For example, a contract state could be encumbered +with a time-lock contract state; the state is then only processable in a transaction that verifies that the time +specified in the encumbrance time-lock has passed. + +The encumbered state refers to its encumbrance by index, and the referred encumbrance state +is an output state in a particular position on the same transaction that created the encumbered state. Note that an +encumbered state that is being consumed must have its encumbrance consumed in the same transaction, otherwise the +transaction is not valid. + +The encumbrance reference is optional in the ``ContractState`` interface: + +.. container:: codeset + + .. sourcecode:: kotlin + + val encumbrance: Int? get() = null + + .. sourcecode:: java + + @Nullable + @Override + public Integer getEncumbrance() { + return null; + } + + +The time-lock contract mentioned above can be implemented very simply: + +.. container:: codeset + + .. sourcecode:: kotlin + + class TestTimeLock : Contract { + ... + override fun verify(tx: TransactionForContract) { + val time = tx.timestamp.before ?: throw IllegalStateException(...) + ... + requireThat { + "the time specified in the time-lock has passed" by + (time >= tx.inputs.filterIsInstance().single().validFrom) + } + } + ... + } + +We can then set up an encumbered state: + +.. container:: codeset + + .. sourcecode:: kotlin + + val encumberedState = Cash.State(amount = 1000.DOLLARS `issued by` defaultIssuer, owner = DUMMY_PUBKEY_1, encumbrance = 1) + val fourPmTimelock = TestTimeLock.State(Instant.parse("2015-04-17T16:00:00.00Z")) + +When we construct a transaction that generates the encumbered state, we must place the encumbrance in the corresponding output +position of that transaction. And when we subsequently consume that encumbered state, the same encumbrance state must be +available somewhere within the input set of states. + +In future, we will consider the concept of a *covenant*. This is where the encumbrance travels alongside each iteration of +the encumbered state. For example, a cash state may be encumbered with a *domicile* encumbrance, which checks the domicile of +the identity of the owner that the cash state is being moved to, in order to uphold sanction screening regulations, and prevent +cash being paid to parties domiciled in e.g. North Korea. In this case, the encumbrance should be permanently attached to +the all future cash states stemming from this one. + +We will also consider marking states that are capable of being encumbrances as such. This will prevent states being used +as encumbrances inadvertently. For example, the time-lock above would be usable as an encumbrance, but it makes no sense to +be able to encumber a cash state with another one. + Clauses -------