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)
.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!
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) {
is Commands.Move -> requireThat {
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())
}
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<ComedyPaper.State>().none()

View File

@ -26,7 +26,7 @@ inline fun <reified T : Command> List<VerifiedSigned<Command>>.requireSingleComm
select<T>().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

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
// Corresponds to the args to Contract.verify
data class TransactionForTest(
private val inStates: MutableList<ContractState> = arrayListOf(),
private val outStates: MutableList<ContractState> = arrayListOf(),
class TransactionForTest() {
private val inStates = arrayListOf<ContractState>()
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()
) {
constructor(inStates: List<ContractState>, outStates: List<ContractState>, args: List<VerifiedSigned<Command>>) : 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(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)
}
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 {

View File

@ -41,7 +41,6 @@ class TransactionForVerification(
val args: List<VerifiedSigned<Command>>,
val time: Instant
) {
fun verify(programMap: Map<SecureHash, Contract>) {
// 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.

View File

@ -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() }
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)
}
}
}