Contracts: a bit more work on the CP implementation, add a unit test for redemption at time.

This commit is contained in:
Mike Hearn 2015-11-12 22:58:47 +01:00
parent a7bfff486a
commit 0b02f64f3d
6 changed files with 94 additions and 36 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
TODO
# Created by .ignore support plugin (hsz.mobi) # Created by .ignore support plugin (hsz.mobi)
.gradle .gradle

View File

@ -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! // For now do not allow multiple pieces of CP to trade in a single transaction. Study this more!
val input = inStates.filterIsInstance<ComedyPaper.State>().single() val input = inStates.filterIsInstance<ComedyPaper.State>().single()
requireThat {
"the transaction is signed by the owner of the CP" by (command.signers.contains(input.owner))
}
when (command.value) { when (command.value) {
is Commands.Move -> requireThat { is Commands.Move -> requireThat {
val output = outStates.filterIsInstance<ComedyPaper.State>().single() val output = outStates.filterIsInstance<ComedyPaper.State>().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()) "the output state is the same as the input state except for owner" by (input.withoutOwner() == output.withoutOwner())
} }
is Commands.Redeem -> requireThat { 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? // 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 paper must have matured" by (input.maturityDate < time)
"the received amount equals the face value" by (received == input.faceValue) "the received amount equals the face value" by (received == input.faceValue)
"the paper must be destroyed" by outStates.filterIsInstance<ComedyPaper.State>().none() "the paper must be destroyed" by outStates.filterIsInstance<ComedyPaper.State>().none()

View File

@ -26,7 +26,7 @@ inline fun <reified T : Command> List<VerifiedSigned<Command>>.requireSingleComm
select<T>().single() select<T>().single()
} catch (e: NoSuchElementException) { } catch (e: NoSuchElementException) {
// Better error message. // Better error message.
throw IllegalStateException("Required ${T::class.simpleName} command") throw IllegalStateException("Required ${T::class.qualifiedName} command")
} }
// endregion // endregion
@ -51,13 +51,14 @@ val Double.SWISS_FRANCS: Amount get() = Amount((this * 100).toInt(), USD)
// region Requirements // region Requirements
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
object Requirements { class Requirements {
infix fun String.by(expr: Boolean) { infix fun String.by(expr: Boolean) {
if (!expr) throw IllegalArgumentException("Failed requirement: $this") if (!expr) throw IllegalArgumentException("Failed requirement: $this")
} }
} }
val R = Requirements()
inline fun requireThat(body: Requirements.() -> Unit) { inline fun requireThat(body: Requirements.() -> Unit) {
Requirements.body() R.body()
} }
// endregion // endregion

View File

@ -60,43 +60,48 @@ val TEST_PROGRAM_MAP: Map<SecureHash, Contract> = mapOf(
// TODO: Make it impossible to forget to test either a failure or an accept for each transaction{} block // 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 // Corresponds to the args to Contract.verify
data class TransactionForTest( class TransactionForTest() {
private val inStates: MutableList<ContractState> = arrayListOf(), private val inStates = arrayListOf<ContractState>()
private val outStates: MutableList<ContractState> = 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<LabeledOutput>()
private val args: MutableList<VerifiedSigned<Command>> = arrayListOf() private val args: MutableList<VerifiedSigned<Command>> = 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) constructor(inStates: List<ContractState>, outStates: List<ContractState>, args: List<VerifiedSigned<Command>>) : this() {
this.inStates.addAll(inStates)
infix fun `fails requirement`(msg: String) { this.outStates.addAll(outStates.map { LabeledOutput(null, it) })
try { this.args.addAll(args)
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)
}
} }
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?? :) // which is uglier?? :)
fun fails_requirement(msg: String) = this.`fails requirement`(msg) fun fails_requirement(msg: String) = this.`fails requirement`(msg)
fun accepts() = run() fun accepts(time: Instant = TEST_TX_TIME) = run(time)
fun rejects(withMessage: String? = null) { fun rejects(withMessage: String? = null, time: Instant = TEST_TX_TIME) {
val r = try { val r = try {
run() run(time)
false false
} catch (e: Exception) { } catch (e: Exception) {
val m = e.message val m = e.message
if (m == null) if (m == null)
fail("Threw exception without a message") fail("Threw exception without a message")
else 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 true
} }
if (!r) throw AssertionError("Expected exception but didn't get one") if (!r) throw AssertionError("Expected exception but didn't get one")
@ -112,6 +117,21 @@ data class TransactionForTest(
return tx 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 { override fun toString(): String {
return """transaction { return """transaction {
inputs: $inStates inputs: $inStates
@ -119,6 +139,15 @@ data class TransactionForTest(
args: $args 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 { fun transaction(body: TransactionForTest.() -> Unit): TransactionForTest {

View File

@ -41,7 +41,6 @@ class TransactionForVerification(
val args: List<VerifiedSigned<Command>>, val args: List<VerifiedSigned<Command>>,
val time: Instant val time: Instant
) { ) {
fun verify(programMap: Map<SecureHash, Contract>) { fun verify(programMap: Map<SecureHash, Contract>) {
// For each input and output state, locate the program to run. Then execute the verification function. If any // 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. // throws an exception, the entire transaction is invalid.

View File

@ -14,12 +14,18 @@ class ComedyPaperTests {
) )
val PAPER_2 = PAPER_1.copy(owner = DUMMY_PUBKEY_2) 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 @Test
fun move() { fun move() {
// One entity sells the paper to another (e.g. the issuer sells it to a first time buyer)
transaction { transaction {
// One entity sells the paper to another (e.g. the issuer sells it to a first time buyer)
input { PAPER_1 } input { PAPER_1 }
output { PAPER_2 } input { CASH_1 }
output("a") { PAPER_2 }
output { CASH_2 }
this.rejects() this.rejects()
@ -28,10 +34,25 @@ class ComedyPaperTests {
this `fails requirement` "is signed by the owner" this `fails requirement` "is signed by the owner"
} }
transaction { arg(DUMMY_PUBKEY_1) { ComedyPaper.Commands.Move() }
arg(DUMMY_PUBKEY_1) { ComedyPaper.Commands.Move() } arg(DUMMY_PUBKEY_1) { Cash.Commands.Move() }
this.accepts() 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)
} }
} }
} }