Contract: rewrite and finish off tests for commercial paper, using improved test DSL

This commit is contained in:
Mike Hearn 2015-11-25 17:40:27 +01:00
parent c026e90067
commit ff05cb4a4c
5 changed files with 174 additions and 68 deletions

View File

@ -55,20 +55,20 @@ class Cash : Contract {
}
// Just for grouping
class Commands {
object Move : Command
interface Commands : Command {
object Move : Commands
/**
* Allows new cash states to be issued into existence: the nonce ("number used once") ensures the transaction
* has a unique ID even when there are no inputs.
*/
data class Issue(val nonce: Long = SecureRandom.getInstanceStrong().nextLong()) : Command
data class Issue(val nonce: Long = SecureRandom.getInstanceStrong().nextLong()) : Commands
/**
* A command stating that money has been withdrawn from the shared ledger and is now accounted for
* in some other way.
*/
data class Exit(val amount: Amount) : Command
data class Exit(val amount: Amount) : Commands
}
/** This is the function EVERYONE runs */
@ -82,6 +82,7 @@ class Cash : Contract {
"all outputs represent at least one penny" by outputs.none { it.amount.pennies == 0 }
}
val issueCommand = tx.commands.select<Commands.Issue>().singleOrNull()
if (issueCommand != null) {
// If we have an issue command, perform special processing: the group is allowed to have no inputs,
@ -104,7 +105,6 @@ class Cash : Contract {
}
}
// sumCash throws if there's a currency mismatch, or if there are no items in the list.
val inputAmount = inputs.sumCashOrNull() ?: throw IllegalArgumentException("there is at least one cash input for this group")
val outputAmount = outputs.sumCashOrZero(inputAmount.currency)
@ -214,8 +214,9 @@ class Cash : Contract {
}
}
// Small DSL extension.
// Small DSL extensions.
fun Iterable<ContractState>.sumCashBy(owner: PublicKey) = filterIsInstance<Cash.State>().filter { it.owner == owner }.map { it.amount }.sumOrThrow()
fun Iterable<ContractState>.sumCash() = filterIsInstance<Cash.State>().map { it.amount }.sumOrThrow()
fun Iterable<ContractState>.sumCashOrNull() = filterIsInstance<Cash.State>().map { it.amount }.sumOrNull()
fun Iterable<ContractState>.sumCashOrZero(currency: Currency) = filterIsInstance<Cash.State>().map { it.amount }.sumOrZero(currency)

View File

@ -2,6 +2,7 @@ package contracts
import core.*
import java.security.PublicKey
import java.security.SecureRandom
import java.time.Instant
/**
@ -39,9 +40,15 @@ class CommercialPaper : Contract {
fun withoutOwner() = copy(owner = NullPublicKey)
}
sealed class Commands : Command {
object Move : Commands()
object Redeem : Commands()
interface Commands : Command {
object Move : Commands
object Redeem : Commands
/**
* Allows new cash states to be issued into existence: the nonce ("number used once") ensures the transaction
* has a unique ID even when there are no inputs.
*/
data class Issue(val nonce: Long = SecureRandom.getInstanceStrong().nextLong()) : Commands
}
override fun verify(tx: TransactionForVerification) {
@ -53,24 +60,41 @@ class CommercialPaper : Contract {
val command = tx.commands.requireSingleCommand<CommercialPaper.Commands>()
for (group in groups) {
val input = group.inputs.single()
requireThat {
"the transaction is signed by the owner of the CP" by (command.signers.contains(input.owner))
}
val output = group.outputs.singleOrNull()
when (command.value) {
is Commands.Move -> requireThat { "the output state is present" by (output != null) }
is Commands.Redeem -> {
val received = tx.outStates.sumCashOrNull() ?: throw IllegalStateException("no cash being redeemed")
is Commands.Move -> {
val input = group.inputs.single()
requireThat {
// Do we need to check the signature of the issuer here too?
"the paper must have matured" by (input.maturityDate < tx.time)
"the received amount equals the face value" by (received == input.faceValue)
"the paper must be destroyed" by (output == null)
"the transaction is signed by the owner of the CP" by (command.signers.contains(input.owner))
"the state is propagated" by (group.outputs.size == 1)
}
}
is Commands.Redeem -> {
val input = group.inputs.single()
val received = tx.outStates.sumCashBy(input.owner)
requireThat {
"the paper must have matured" by (input.maturityDate < tx.time)
"the received amount equals the face value" by (received == input.faceValue)
"the paper must be destroyed" by group.outputs.isEmpty()
"the transaction is signed by the owner of the CP" by (command.signers.contains(input.owner))
}
}
is Commands.Issue -> {
val output = group.outputs.single()
requireThat {
// Don't allow people to issue commercial paper under other entities identities.
"the issuance is signed by the claimed issuer of the paper" by
(command.signers.contains(output.issuance.institution.owningKey))
"the face value is not zero" by (output.faceValue.pennies > 0)
"the maturity date is not in the past" by (output.maturityDate > tx.time)
// Don't allow an existing CP state to be replaced by this issuance.
"there is no input state" by group.inputs.isEmpty()
}
}
// TODO: Think about how to evolve contracts over time with new commands.
else -> throw IllegalArgumentException("Unrecognised command")
}
}
}

View File

@ -229,6 +229,7 @@ fun createKryo(): Kryo {
registerDataClass<CommercialPaper.State>()
register(CommercialPaper.Commands.Move.javaClass)
register(CommercialPaper.Commands.Redeem.javaClass)
registerDataClass<CommercialPaper.Commands.Issue>()
// And for unit testing ...
registerDataClass<DummyPublicKey>()

View File

@ -1,72 +1,128 @@
package contracts
import core.DOLLARS
import core.InstitutionReference
import core.OpaqueBytes
import core.days
import core.*
import core.testutils.*
import org.junit.Test
// TODO: Finish this off.
import java.time.Instant
class CommercialPaperTests {
val PAPER_1 = CommercialPaper.State(
issuance = InstitutionReference(MEGA_CORP, OpaqueBytes.of(123)),
owner = DUMMY_PUBKEY_1,
owner = MEGA_CORP_KEY,
faceValue = 1000.DOLLARS,
maturityDate = TEST_TX_TIME + 7.days
)
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)
val A_THOUSAND_DOLLARS = Cash.State(InstitutionReference(MINI_CORP, OpaqueBytes.of(1,2,3)), 1000.DOLLARS, ALICE)
@Test
fun move2() {
fun ok() {
trade().verify()
}
@Test
fun `not matured at redemption`() {
trade(redemptionTime = TEST_TX_TIME + 2.days).expectFailureOfTx(3, "must have matured")
}
@Test
fun `key mismatch at issue`() {
transactionGroup {
transaction {
output { A_THOUSAND_DOLLARS `owned by` MINI_CORP_KEY }
output { PAPER_1 }
arg(DUMMY_PUBKEY_1) { CommercialPaper.Commands.Issue() }
}
expectFailureOfTx(1, "signed by the claimed issuer")
}
}
@Test
fun move() {
transaction {
// One entity sells the paper to another (e.g. the issuer sells it to a first time buyer)
input { PAPER_1 }
input { CASH_1 }
output("a") { PAPER_2 }
output { CASH_2 }
this.rejects()
tweak {
arg(DUMMY_PUBKEY_2) { CommercialPaper.Commands.Move }
this `fails requirement` "is signed by the owner"
fun `face value is not zero`() {
transactionGroup {
transaction {
output { PAPER_1.copy(faceValue = 0.DOLLARS) }
arg(MEGA_CORP_KEY) { CommercialPaper.Commands.Issue() }
}
arg(DUMMY_PUBKEY_1) { CommercialPaper.Commands.Move }
arg(DUMMY_PUBKEY_1) { Cash.Commands.Move }
this.accepts()
}.chain("a") {
arg(DUMMY_PUBKEY_2, MINI_CORP_KEY) { CommercialPaper.Commands.Redeem }
expectFailureOfTx(1, "face value is not zero")
}
}
// No cash output, can't redeem like that!
this.rejects("no cash being redeemed")
@Test
fun `maturity date not in the past`() {
transactionGroup {
transaction {
output { PAPER_1.copy(maturityDate = TEST_TX_TIME - 10.days) }
arg(MEGA_CORP_KEY) { CommercialPaper.Commands.Issue() }
}
input { CASH_3 }
output { CASH_2 }
arg(DUMMY_PUBKEY_1) { Cash.Commands.Move }
expectFailureOfTx(1, "maturity date is not in the past")
}
}
// Time passes, but not enough. An attempt to redeem is made.
this.rejects("must have matured")
fun `issue cannot replace an existing state`() {
transactionGroup {
transaction {
input("paper")
output { PAPER_1 }
arg(MEGA_CORP_KEY) { CommercialPaper.Commands.Issue() }
}
// Try again at the right time.
this.accepts(TEST_TX_TIME + 10.days)
expectFailureOfTx(1, "there is no input state")
}
}
@Test
fun `did not receive enough money at redemption`() {
trade(aliceGetsBack = 700.DOLLARS).expectFailureOfTx(3, "received amount equals the face value")
}
@Test
fun `paper must be destroyed by redemption`() {
trade(destroyPaperAtRedemption = false).expectFailureOfTx(3, "must be destroyed")
}
// Generate a trade lifecycle with various parameters.
private fun trade(redemptionTime: Instant = TEST_TX_TIME + 8.days,
aliceGetsBack: Amount = 1000.DOLLARS,
destroyPaperAtRedemption: Boolean = true): TransactionGroupForTest {
val someProfits = 1200.DOLLARS
return transactionGroup {
roots {
transaction(900.DOLLARS.CASH `owned by` ALICE label "alice's $900")
transaction(someProfits.CASH `owned by` MEGA_CORP_KEY label "some profits")
}
// Some CP is issued onto the ledger by MegaCorp.
transaction {
output("paper") { PAPER_1 }
arg(MEGA_CORP_KEY) { CommercialPaper.Commands.Issue() }
}
// The CP is sold to alice for her $900, $100 less than the face value. At 10% interest after only 7 days,
// that sounds a bit too good to be true!
transaction {
input("paper")
input("alice's $900")
output { 900.DOLLARS.CASH `owned by` MEGA_CORP_KEY }
output("alice's paper") { PAPER_1 `owned by` ALICE }
arg(ALICE) { Cash.Commands.Move }
arg(MEGA_CORP_KEY) { CommercialPaper.Commands.Move }
}
// Time passes, and Alice redeem's her CP for $1000, netting a $100 profit. MegaCorp has received $1200
// as a single payment from somewhere and uses it to pay Alice off, keeping the remaining $200 as change.
transaction(time = redemptionTime) {
input("alice's paper")
input("some profits")
output { aliceGetsBack.CASH `owned by` ALICE }
output { (someProfits - aliceGetsBack).CASH `owned by` MEGA_CORP_KEY }
if (!destroyPaperAtRedemption)
output { PAPER_1 `owned by` ALICE }
arg(MEGA_CORP_KEY) { Cash.Commands.Move }
arg(ALICE) { CommercialPaper.Commands.Redeem }
}
}
}
}

View File

@ -8,6 +8,8 @@ import java.security.KeyPairGenerator
import java.security.PublicKey
import java.time.Instant
import java.util.*
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.fail
object TestUtils {
@ -63,6 +65,9 @@ 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
infix fun Cash.State.`owned by`(owner: PublicKey) = this.copy(owner = owner)
infix fun CommercialPaper.State.`owned by`(owner: PublicKey) = this.copy(owner = owner)
// Allows you to write 100.DOLLARS.CASH
val Amount.CASH: Cash.State get() = Cash.State(InstitutionReference(MINI_CORP, OpaqueBytes.of(1,2,3)), this, NullPublicKey)
class LabeledOutput(val label: String?, val state: ContractState) {
override fun toString() = state.toString() + (if (label != null) " ($label)" else "")
@ -220,11 +225,30 @@ class TransactionGroupForTest {
@Deprecated("Does not nest ", level = DeprecationLevel.ERROR)
fun transactionGroup(body: TransactionGroupForTest.() -> Unit) {}
fun toTransactionGroup() = TransactionGroup(txns.map { it }.toSet(), rootTxns.toSet())
class Failed(val index: Int, cause: Throwable) : Exception("Transaction $index didn't verify", cause)
fun verify() {
toTransactionGroup().verify(TEST_PROGRAM_MAP)
val group = toTransactionGroup()
try {
group.verify(TEST_PROGRAM_MAP)
} catch (e: TransactionVerificationException) {
// Let the developer know the index of the transaction that failed.
val ltx: LedgerTransaction = txns.find { it.hash == e.tx.origHash }!!
throw Failed(txns.indexOf(ltx) + 1, e)
}
}
fun toTransactionGroup() = TransactionGroup(txns.map { it }.toSet(), rootTxns.toSet())
fun expectFailureOfTx(index: Int, message: String): Exception {
val e = assertFailsWith(Failed::class) {
verify()
}
assertEquals(index, e.index)
if (!e.cause!!.message!!.contains(message))
throw AssertionError("Exception should have said '$message' but was actually: ${e.cause.message}")
return e
}
}
fun transactionGroup(body: TransactionGroupForTest.() -> Unit) = TransactionGroupForTest().apply { this.body() }