mirror of
https://github.com/corda/corda.git
synced 2025-02-22 18:12:53 +00:00
Contract: rewrite and finish off tests for commercial paper, using improved test DSL
This commit is contained in:
parent
c026e90067
commit
ff05cb4a4c
@ -55,20 +55,20 @@ class Cash : Contract {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Just for grouping
|
// Just for grouping
|
||||||
class Commands {
|
interface Commands : Command {
|
||||||
object Move : Command
|
object Move : Commands
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows new cash states to be issued into existence: the nonce ("number used once") ensures the transaction
|
* 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.
|
* 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
|
* A command stating that money has been withdrawn from the shared ledger and is now accounted for
|
||||||
* in some other way.
|
* in some other way.
|
||||||
*/
|
*/
|
||||||
data class Exit(val amount: Amount) : Command
|
data class Exit(val amount: Amount) : Commands
|
||||||
}
|
}
|
||||||
|
|
||||||
/** This is the function EVERYONE runs */
|
/** 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 }
|
"all outputs represent at least one penny" by outputs.none { it.amount.pennies == 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val issueCommand = tx.commands.select<Commands.Issue>().singleOrNull()
|
val issueCommand = tx.commands.select<Commands.Issue>().singleOrNull()
|
||||||
if (issueCommand != null) {
|
if (issueCommand != null) {
|
||||||
// If we have an issue command, perform special processing: the group is allowed to have no inputs,
|
// 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 inputAmount = inputs.sumCashOrNull() ?: throw IllegalArgumentException("there is at least one cash input for this group")
|
||||||
val outputAmount = outputs.sumCashOrZero(inputAmount.currency)
|
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>.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>.sumCash() = filterIsInstance<Cash.State>().map { it.amount }.sumOrThrow()
|
||||||
fun Iterable<ContractState>.sumCashOrNull() = filterIsInstance<Cash.State>().map { it.amount }.sumOrNull()
|
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)
|
fun Iterable<ContractState>.sumCashOrZero(currency: Currency) = filterIsInstance<Cash.State>().map { it.amount }.sumOrZero(currency)
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ package contracts
|
|||||||
|
|
||||||
import core.*
|
import core.*
|
||||||
import java.security.PublicKey
|
import java.security.PublicKey
|
||||||
|
import java.security.SecureRandom
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -39,9 +40,15 @@ class CommercialPaper : Contract {
|
|||||||
fun withoutOwner() = copy(owner = NullPublicKey)
|
fun withoutOwner() = copy(owner = NullPublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class Commands : Command {
|
interface Commands : Command {
|
||||||
object Move : Commands()
|
object Move : Commands
|
||||||
object Redeem : 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) {
|
override fun verify(tx: TransactionForVerification) {
|
||||||
@ -53,24 +60,41 @@ class CommercialPaper : Contract {
|
|||||||
val command = tx.commands.requireSingleCommand<CommercialPaper.Commands>()
|
val command = tx.commands.requireSingleCommand<CommercialPaper.Commands>()
|
||||||
|
|
||||||
for (group in groups) {
|
for (group in groups) {
|
||||||
|
when (command.value) {
|
||||||
|
is Commands.Move -> {
|
||||||
val input = group.inputs.single()
|
val input = group.inputs.single()
|
||||||
requireThat {
|
requireThat {
|
||||||
"the transaction is signed by the owner of the CP" by (command.signers.contains(input.owner))
|
"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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val output = group.outputs.singleOrNull()
|
|
||||||
when (command.value) {
|
|
||||||
is Commands.Move -> requireThat { "the output state is present" by (output != null) }
|
|
||||||
|
|
||||||
is Commands.Redeem -> {
|
is Commands.Redeem -> {
|
||||||
val received = tx.outStates.sumCashOrNull() ?: throw IllegalStateException("no cash being redeemed")
|
val input = group.inputs.single()
|
||||||
|
val received = tx.outStates.sumCashBy(input.owner)
|
||||||
requireThat {
|
requireThat {
|
||||||
// Do we need to check the signature of the issuer here too?
|
|
||||||
"the paper must have matured" by (input.maturityDate < tx.time)
|
"the paper must have matured" by (input.maturityDate < tx.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 (output == null)
|
"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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -229,6 +229,7 @@ fun createKryo(): Kryo {
|
|||||||
registerDataClass<CommercialPaper.State>()
|
registerDataClass<CommercialPaper.State>()
|
||||||
register(CommercialPaper.Commands.Move.javaClass)
|
register(CommercialPaper.Commands.Move.javaClass)
|
||||||
register(CommercialPaper.Commands.Redeem.javaClass)
|
register(CommercialPaper.Commands.Redeem.javaClass)
|
||||||
|
registerDataClass<CommercialPaper.Commands.Issue>()
|
||||||
|
|
||||||
// And for unit testing ...
|
// And for unit testing ...
|
||||||
registerDataClass<DummyPublicKey>()
|
registerDataClass<DummyPublicKey>()
|
||||||
|
@ -1,72 +1,128 @@
|
|||||||
package contracts
|
package contracts
|
||||||
|
|
||||||
import core.DOLLARS
|
import core.*
|
||||||
import core.InstitutionReference
|
|
||||||
import core.OpaqueBytes
|
|
||||||
import core.days
|
|
||||||
import core.testutils.*
|
import core.testutils.*
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.time.Instant
|
||||||
// TODO: Finish this off.
|
|
||||||
|
|
||||||
class CommercialPaperTests {
|
class CommercialPaperTests {
|
||||||
val PAPER_1 = CommercialPaper.State(
|
val PAPER_1 = CommercialPaper.State(
|
||||||
issuance = InstitutionReference(MEGA_CORP, OpaqueBytes.of(123)),
|
issuance = InstitutionReference(MEGA_CORP, OpaqueBytes.of(123)),
|
||||||
owner = DUMMY_PUBKEY_1,
|
owner = MEGA_CORP_KEY,
|
||||||
faceValue = 1000.DOLLARS,
|
faceValue = 1000.DOLLARS,
|
||||||
maturityDate = TEST_TX_TIME + 7.days
|
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
|
@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 {
|
transactionGroup {
|
||||||
transaction {
|
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
|
@Test
|
||||||
fun move() {
|
fun `face value is not zero`() {
|
||||||
|
transactionGroup {
|
||||||
transaction {
|
transaction {
|
||||||
// One entity sells the paper to another (e.g. the issuer sells it to a first time buyer)
|
output { PAPER_1.copy(faceValue = 0.DOLLARS) }
|
||||||
input { PAPER_1 }
|
arg(MEGA_CORP_KEY) { CommercialPaper.Commands.Issue() }
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
arg(DUMMY_PUBKEY_1) { CommercialPaper.Commands.Move }
|
expectFailureOfTx(1, "face value is not zero")
|
||||||
arg(DUMMY_PUBKEY_1) { Cash.Commands.Move }
|
}
|
||||||
this.accepts()
|
}
|
||||||
}.chain("a") {
|
|
||||||
arg(DUMMY_PUBKEY_2, MINI_CORP_KEY) { CommercialPaper.Commands.Redeem }
|
|
||||||
|
|
||||||
// No cash output, can't redeem like that!
|
@Test
|
||||||
this.rejects("no cash being redeemed")
|
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 }
|
expectFailureOfTx(1, "maturity date is not in the past")
|
||||||
output { CASH_2 }
|
}
|
||||||
arg(DUMMY_PUBKEY_1) { Cash.Commands.Move }
|
}
|
||||||
|
|
||||||
// Time passes, but not enough. An attempt to redeem is made.
|
fun `issue cannot replace an existing state`() {
|
||||||
this.rejects("must have matured")
|
transactionGroup {
|
||||||
|
transaction {
|
||||||
|
input("paper")
|
||||||
|
output { PAPER_1 }
|
||||||
|
arg(MEGA_CORP_KEY) { CommercialPaper.Commands.Issue() }
|
||||||
|
}
|
||||||
|
|
||||||
// Try again at the right time.
|
expectFailureOfTx(1, "there is no input state")
|
||||||
this.accepts(TEST_TX_TIME + 10.days)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -8,6 +8,8 @@ import java.security.KeyPairGenerator
|
|||||||
import java.security.PublicKey
|
import java.security.PublicKey
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
import kotlin.test.fail
|
import kotlin.test.fail
|
||||||
|
|
||||||
object TestUtils {
|
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
|
// 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 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) {
|
class LabeledOutput(val label: String?, val state: ContractState) {
|
||||||
override fun toString() = state.toString() + (if (label != null) " ($label)" else "")
|
override fun toString() = state.toString() + (if (label != null) " ($label)" else "")
|
||||||
@ -220,11 +225,30 @@ class TransactionGroupForTest {
|
|||||||
@Deprecated("Does not nest ", level = DeprecationLevel.ERROR)
|
@Deprecated("Does not nest ", level = DeprecationLevel.ERROR)
|
||||||
fun transactionGroup(body: TransactionGroupForTest.() -> Unit) {}
|
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() {
|
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() }
|
fun transactionGroup(body: TransactionGroupForTest.() -> Unit) = TransactionGroupForTest().apply { this.body() }
|
||||||
|
Loading…
x
Reference in New Issue
Block a user