mirror of
https://github.com/corda/corda.git
synced 2025-02-21 01:42:24 +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
|
||||
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)
|
||||
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>()
|
||||
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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() }
|
||||
|
Loading…
x
Reference in New Issue
Block a user