From 0b02f64f3dd0c843437cdabb13acbd3eaafcd733 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Thu, 12 Nov 2015 22:58:47 +0100 Subject: [PATCH] Contracts: a bit more work on the CP implementation, add a unit test for redemption at time. --- .gitignore | 2 + src/contracts/ComedyPaper.kt | 12 +++-- src/core/ContractsDSL.kt | 7 +-- src/core/TestUtils.kt | 75 ++++++++++++++++++++--------- src/core/Transactions.kt | 1 - tests/contracts/ComedyPaperTests.kt | 33 ++++++++++--- 6 files changed, 94 insertions(+), 36 deletions(-) diff --git a/.gitignore b/.gitignore index 2754cd6046..9af7e47f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +TODO + # Created by .ignore support plugin (hsz.mobi) .gradle diff --git a/src/contracts/ComedyPaper.kt b/src/contracts/ComedyPaper.kt index 03dae9825b..dc2dfd6082 100644 --- a/src/contracts/ComedyPaper.kt +++ b/src/contracts/ComedyPaper.kt @@ -50,17 +50,23 @@ object ComedyPaper : Contract { // For now do not allow multiple pieces of CP to trade in a single transaction. Study this more! val input = inStates.filterIsInstance().single() + requireThat { + "the transaction is signed by the owner of the CP" by (command.signers.contains(input.owner)) + } + when (command.value) { is Commands.Move -> requireThat { val output = outStates.filterIsInstance().single() - "the transaction is signed by the owner of the CP" by (command.signers.contains(input.owner)) "the output state is the same as the input state except for owner" by (input.withoutOwner() == output.withoutOwner()) } is Commands.Redeem -> requireThat { - val received = outStates.sumCash() + val received = try { + outStates.sumCash() + } catch (e: UnsupportedOperationException) { + throw IllegalStateException("invalid cash outputs") + } // Do we need to check the signature of the issuer here too? - "the transaction is signed by the owner of the CP" by (command.signers.contains(input.owner)) "the paper must have matured" by (input.maturityDate < time) "the received amount equals the face value" by (received == input.faceValue) "the paper must be destroyed" by outStates.filterIsInstance().none() diff --git a/src/core/ContractsDSL.kt b/src/core/ContractsDSL.kt index 3479b8804d..73099e8a93 100644 --- a/src/core/ContractsDSL.kt +++ b/src/core/ContractsDSL.kt @@ -26,7 +26,7 @@ inline fun List>.requireSingleComm select().single() } catch (e: NoSuchElementException) { // Better error message. - throw IllegalStateException("Required ${T::class.simpleName} command") + throw IllegalStateException("Required ${T::class.qualifiedName} command") } // endregion @@ -51,13 +51,14 @@ val Double.SWISS_FRANCS: Amount get() = Amount((this * 100).toInt(), USD) // region Requirements /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -object Requirements { +class Requirements { infix fun String.by(expr: Boolean) { if (!expr) throw IllegalArgumentException("Failed requirement: $this") } } +val R = Requirements() inline fun requireThat(body: Requirements.() -> Unit) { - Requirements.body() + R.body() } // endregion diff --git a/src/core/TestUtils.kt b/src/core/TestUtils.kt index 3bdf3f09ae..f2b22035df 100644 --- a/src/core/TestUtils.kt +++ b/src/core/TestUtils.kt @@ -60,43 +60,48 @@ val TEST_PROGRAM_MAP: Map = mapOf( // TODO: Make it impossible to forget to test either a failure or an accept for each transaction{} block // Corresponds to the args to Contract.verify -data class TransactionForTest( - private val inStates: MutableList = arrayListOf(), - private val outStates: MutableList = arrayListOf(), +class TransactionForTest() { + private val inStates = arrayListOf() + + class LabeledOutput(val label: String?, val state: ContractState) { + override fun toString() = state.toString() + (if (label != null) " ($label)" else "") + override fun equals(other: Any?) = other is LabeledOutput && state.equals(other.state) + override fun hashCode(): Int = state.hashCode() + } + private val outStates = arrayListOf() private val args: MutableList> = arrayListOf() -) { - fun input(s: () -> ContractState) = inStates.add(s()) - fun output(s: () -> ContractState) = outStates.add(s()) - fun arg(key: PublicKey, c: () -> Command) = args.add(VerifiedSigned(listOf(key), TEST_KEYS_TO_CORP_MAP[key].let { if (it != null) listOf(it) else emptyList() }, c())) - private fun run() = TransactionForVerification(inStates, outStates, args, TEST_TX_TIME).verify(TEST_PROGRAM_MAP) - - infix fun `fails requirement`(msg: String) { - try { - run() - } catch(e: Exception) { - val m = e.message - if (m == null) - fail("Threw exception without a message") - else - if (!m.toLowerCase().contains(msg.toLowerCase())) throw AssertionError("Error was actually: $m", e) - } + constructor(inStates: List, outStates: List, args: List>) : this() { + this.inStates.addAll(inStates) + this.outStates.addAll(outStates.map { LabeledOutput(null, it) }) + this.args.addAll(args) } + fun input(s: () -> ContractState) = inStates.add(s()) + fun output(label: String? = null, s: () -> ContractState) = outStates.add(LabeledOutput(label, s())) + fun arg(vararg key: PublicKey, c: () -> Command) { + val keys = listOf(*key) + // TODO: replace map->filterNotNull once upgraded to next Kotlin + args.add(VerifiedSigned(keys, keys.map { TEST_KEYS_TO_CORP_MAP[it] }.filterNotNull(), c())) + } + + private fun run(time: Instant) = TransactionForVerification(inStates, outStates.map { it.state }, args, time).verify(TEST_PROGRAM_MAP) + + infix fun `fails requirement`(msg: String) = rejects(msg) // which is uglier?? :) fun fails_requirement(msg: String) = this.`fails requirement`(msg) - fun accepts() = run() - fun rejects(withMessage: String? = null) { + fun accepts(time: Instant = TEST_TX_TIME) = run(time) + fun rejects(withMessage: String? = null, time: Instant = TEST_TX_TIME) { val r = try { - run() + run(time) false } catch (e: Exception) { val m = e.message if (m == null) fail("Threw exception without a message") else - if (withMessage != null && !m.contains(withMessage)) throw AssertionError("Error was actually: $m", e) + if (withMessage != null && !m.toLowerCase().contains(withMessage.toLowerCase())) throw AssertionError("Error was actually: $m", e) true } if (!r) throw AssertionError("Expected exception but didn't get one") @@ -112,6 +117,21 @@ data class TransactionForTest( return tx } + // Use this to create transactions where the output of this transaction is automatically used as an input of + // the next. + fun chain(vararg outputLabels: String, body: TransactionForTest.() -> Unit) { + val states = outStates.filter { + val l = it.label + if (l != null) + outputLabels.contains(l) + else + false + }.map { it.state } // TODO: replace with mapNotNull after next Kotlin upgrade + val tx = TransactionForTest() + tx.inStates.addAll(states) + tx.body() + } + override fun toString(): String { return """transaction { inputs: $inStates @@ -119,6 +139,15 @@ data class TransactionForTest( args: $args }""" } + + override fun equals(other: Any?) = this === other || (other is TransactionForTest && inStates == other.inStates && outStates == other.outStates && args == other.args) + + override fun hashCode(): Int { + var result = inStates.hashCode() + result += 31 * result + outStates.hashCode() + result += 31 * result + args.hashCode() + return result + } } fun transaction(body: TransactionForTest.() -> Unit): TransactionForTest { diff --git a/src/core/Transactions.kt b/src/core/Transactions.kt index 227042531d..9ddc36a193 100644 --- a/src/core/Transactions.kt +++ b/src/core/Transactions.kt @@ -41,7 +41,6 @@ class TransactionForVerification( val args: List>, val time: Instant ) { - fun verify(programMap: Map) { // For each input and output state, locate the program to run. Then execute the verification function. If any // throws an exception, the entire transaction is invalid. diff --git a/tests/contracts/ComedyPaperTests.kt b/tests/contracts/ComedyPaperTests.kt index cd8dd3cb59..70c1d8b42e 100644 --- a/tests/contracts/ComedyPaperTests.kt +++ b/tests/contracts/ComedyPaperTests.kt @@ -14,12 +14,18 @@ class ComedyPaperTests { ) val PAPER_2 = PAPER_1.copy(owner = DUMMY_PUBKEY_2) + val CASH_1 = Cash.State(InstitutionReference(MINI_CORP, OpaqueBytes.of(1)), 1000.DOLLARS, DUMMY_PUBKEY_1) + val CASH_2 = CASH_1.copy(owner = DUMMY_PUBKEY_2) + val CASH_3 = CASH_1.copy(owner = DUMMY_PUBKEY_1) + @Test fun move() { - // One entity sells the paper to another (e.g. the issuer sells it to a first time buyer) transaction { + // One entity sells the paper to another (e.g. the issuer sells it to a first time buyer) input { PAPER_1 } - output { PAPER_2 } + input { CASH_1 } + output("a") { PAPER_2 } + output { CASH_2 } this.rejects() @@ -28,10 +34,25 @@ class ComedyPaperTests { this `fails requirement` "is signed by the owner" } - transaction { - arg(DUMMY_PUBKEY_1) { ComedyPaper.Commands.Move() } - this.accepts() - } + arg(DUMMY_PUBKEY_1) { ComedyPaper.Commands.Move() } + arg(DUMMY_PUBKEY_1) { Cash.Commands.Move() } + this.accepts() + }.chain("a") { + arg(DUMMY_PUBKEY_2, MINI_CORP_KEY) { ComedyPaper.Commands.Redeem() } + + // No cash output, can't redeem like that! + this.rejects("invalid cash outputs") + + input { CASH_3 } + output { CASH_2 } + + arg(DUMMY_PUBKEY_1) { Cash.Commands.Move() } + + // Time passes, but not enough. An attempt to redeem is made. + this.rejects("must have matured") + + // Try again at the right time. + this.accepts(TEST_TX_TIME + 10.days) } } } \ No newline at end of file