From 12f5ddb0aa3abae94a1b6485f27d82e55efbc44b Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Tue, 3 Nov 2015 13:49:28 +0100 Subject: [PATCH] Cash contract: multi-issuer support You can now take deposits from multiple different institutions and move/combine/split them appropriately. The issuers are kept separate, you cannot merge 3 different input states from 3 different institutions down to one, but you can merge/split within that specific issuer. Deposit refs are not currently being kept separate, but they should be also (this is coming next). --- src/Cash.kt | 62 +++++++----- src/Utils.kt | 7 +- tests/CashTests.kt | 247 ++++++++++++++++++++++++++++++--------------- 3 files changed, 201 insertions(+), 115 deletions(-) diff --git a/src/Cash.kt b/src/Cash.kt index 3817daaed4..2a82dcf191 100644 --- a/src/Cash.kt +++ b/src/Cash.kt @@ -1,11 +1,12 @@ import java.security.PublicKey +import java.util.* ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // Cash -// TODO: Implement multi-issuer case. +// TODO: Think about state merging: when does it make sense to merge multiple cash states from the same issuer? // TODO: Does multi-currency also make sense? Probably? // TODO: Implement a generate function. @@ -35,47 +36,52 @@ class ExitCashCommand(val amount: Amount) : Command class CashContract : Contract { override fun verify(inStates: List, outStates: List, args: List) { - // Select all input states that are cash states and ensure they are all denominated in the same currency and - // issued by the same issuer. - val inputs = inStates.filterIsInstance() - val inputMoney = inputs.sumBy { it.amount.pennies } + val cashInputs = inStates.filterIsInstance() requireThat { - "there is at least one cash input" by inputs.isNotEmpty() - "all inputs use the same currency" by (inputs.groupBy { it.amount.currency }.size == 1) - "all inputs come from the same issuer" by (inputs.groupBy { it.issuingInstitution }.size == 1) - "some money is actually moving" by (inputMoney > 0) + "there is at least one cash input" by cashInputs.isNotEmpty() + "there are no zero sized inputs" by cashInputs.none { it.amount.pennies == 0 } + "all inputs use the same currency" by (cashInputs.groupBy { it.amount.currency }.size == 1) } - val issuer = inputs.first().issuingInstitution - val currency = inputs.first().amount.currency - val depositReference = inputs.first().depositReference + val currency = cashInputs.first().amount.currency // Select all the output states that are cash states. There may be zero if all money is being withdrawn. - // If there are any though, check that the currencies and issuers match the inputs. - val outputs = outStates.filterIsInstance() - val outputMoney = outputs.sumBy { it.amount.pennies } + val cashOutputs = outStates.filterIsInstance() requireThat { - "all outputs use the currency of the inputs" by outputs.all { it.amount.currency == currency } - "all outputs claim against the issuer of the inputs" by outputs.all { it.issuingInstitution == issuer } - "all outputs use the same deposit reference as the inputs" by outputs.all { it.depositReference == depositReference } + "all outputs use the currency of the inputs" by cashOutputs.all { it.amount.currency == currency } } - // If we have any commands, find the one that came from the issuer of the original cash deposit and - // check if it's an exit command. - val issuerCommand = args.find { it.signingInstitution == issuer }?.command as? ExitCashCommand - val amountExitingLedger = issuerCommand?.amount?.pennies ?: 0 - requireThat("the value exiting the ledger is not more than the input value", amountExitingLedger <= outputMoney) - - // Verify the books balance. - requireThat("the amounts balance", inputMoney == outputMoney + amountExitingLedger) + // For each issuer that's represented in the inputs, group the inputs together and verify that the outputs + // balance, taking into account a possible exit command from that issuer. + var outputsLeft = cashOutputs.size + for ((issuer, inputs) in cashInputs.groupBy { it.issuingInstitution }) { + val outputs = cashOutputs.filter { it.issuingInstitution == issuer } + outputsLeft -= outputs.size + + val inputAmount = inputs.map { it.amount }.sum() + val outputAmount = outputs.map { it.amount }.sumOrZero(currency) + + val issuerCommand = args.filter { it.signingInstitution == issuer }.map { it.command as? ExitCashCommand }.filterNotNull().singleOrNull() + val amountExitingLedger = issuerCommand?.amount ?: Amount(0, inputAmount.currency) + + val depositReference = inputs.first().depositReference + + requireThat { + "for issuer ${issuer.name} the amounts balance" by (inputAmount == outputAmount + amountExitingLedger) + // TODO: Introduce a byte array wrapper that makes == do what we expect (Kotlin does not do this for us) + "for issuer ${issuer.name} the deposit references are the same" by outputs.all { Arrays.equals(it.depositReference, depositReference) } + } + } + + requireThat { "no output states are unaccounted for" by (outputsLeft == 0) } // Now check the digital signatures on the move commands. Every input has an owning public key, and we must // see a signature from each of those keys. The actual signatures have been verified against the transaction // data by the platform before execution. - val owningPubKeys = inputs.map { it.owner }.toSortedSet() + val owningPubKeys = cashInputs.map { it.owner }.toSortedSet() val keysThatSigned = args.filter { it.command is MoveCashCommand }.map { it.signer }.toSortedSet() - requireThat("the owning keys are the same as the signing keys", owningPubKeys == keysThatSigned) + requireThat { "the owning keys are the same as the signing keys" by (owningPubKeys == keysThatSigned) } // Accept. } diff --git a/src/Utils.kt b/src/Utils.kt index 54287b7843..5aaa119ae5 100644 --- a/src/Utils.kt +++ b/src/Utils.kt @@ -8,14 +8,11 @@ import kotlin.test.fail //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // REQUIREMENTS - -fun requireThat(message: String, expression: Boolean) { - if (!expression) throw IllegalArgumentException(message) -} - +// // To understand how requireThat works, read the section "type safe builders" on the Kotlin website: // // https://kotlinlang.org/docs/reference/type-safe-builders.html + object Requirements { infix fun String.by(expr: Boolean) { if (!expr) throw IllegalArgumentException("Failed requirement: $this") diff --git a/tests/CashTests.kt b/tests/CashTests.kt index ffed374830..2c7391a0cb 100644 --- a/tests/CashTests.kt +++ b/tests/CashTests.kt @@ -1,5 +1,10 @@ import org.junit.Test +// TODO: Some basic invariants should be enforced by the platform before contract execution: +// 1. No duplicate input states +// 2. There must be at least one input state (note: not "one of the type the contract wants") + + class CashTests { val inState = CashState( issuingInstitution = MEGA_CORP, @@ -7,117 +12,195 @@ class CashTests { amount = 1000.DOLLARS, owner = DUMMY_PUBKEY_1 ) - val inState2 = inState.copy( - amount = 150.POUNDS, - owner = DUMMY_PUBKEY_2 - ) val outState = inState.copy(owner = DUMMY_PUBKEY_2) + + val contract = CashContract() @Test fun trivial() { - CashContract().let { - transaction { - it `fails requirement` "there is at least one cash input" - } - transaction { - input { inState.copy(amount = 0.DOLLARS) } - it `fails requirement` "some money is actually moving" - } + transaction { + contract `fails requirement` "there is at least one cash input" + + input { inState } + contract `fails requirement` "the amounts balance" transaction { - input { inState } - it `fails requirement` "the amounts balance" - - transaction { - output { outState.copy(amount = 2000.DOLLARS )} - it `fails requirement` "the amounts balance" - } - transaction { - output { outState } - // No command arguments - it `fails requirement` "the owning keys are the same as the signing keys" - } - transaction { - output { outState } - arg(DUMMY_PUBKEY_2) { MoveCashCommand() } - it `fails requirement` "the owning keys are the same as the signing keys" - } - transaction { - output { outState } - arg(DUMMY_PUBKEY_1) { MoveCashCommand() } - it.accepts() - } + output { outState.copy(amount = 2000.DOLLARS )} + contract `fails requirement` "the amounts balance" + } + transaction { + output { outState } + // No command arguments + contract `fails requirement` "the owning keys are the same as the signing keys" + } + transaction { + output { outState } + arg(DUMMY_PUBKEY_2) { MoveCashCommand() } + contract `fails requirement` "the owning keys are the same as the signing keys" + } + transaction { + output { outState } + output { outState.copy(issuingInstitution = MINI_CORP) } + contract `fails requirement` "no output states are unaccounted for" + } + // Simple reallocation works. + transaction { + output { outState } + arg(DUMMY_PUBKEY_1) { MoveCashCommand() } + contract.accepts() } } } @Test - fun mismatches() { - CashContract().let { + fun testMergeSplit() { + // Splitting value works. + transaction { + arg(DUMMY_PUBKEY_1) { MoveCashCommand() } transaction { input { inState } - output { outState.copy(issuingInstitution = MINI_CORP) } - it `fails requirement` "all outputs claim against the issuer of the inputs" + for (i in 1..4) output { inState.copy(amount = inState.amount / 4) } + contract.accepts() } + // Merging 4 inputs into 2 outputs works. transaction { - input { inState } - output { outState.copy(issuingInstitution = MEGA_CORP) } - output { outState.copy(issuingInstitution = MINI_CORP) } - it `fails requirement` "all outputs claim against the issuer of the inputs" + for (i in 1..4) input { inState.copy(amount = inState.amount / 4) } + output { inState.copy(amount = inState.amount / 2) } + output { inState.copy(amount = inState.amount / 2) } + contract.accepts() } + // Merging 2 inputs into 1 works. transaction { - input { inState } - output { outState.copy(depositReference = byteArrayOf(0)) } - output { outState.copy(depositReference = byteArrayOf(1)) } - it `fails requirement` "all outputs use the same deposit reference as the inputs" + input { inState.copy(amount = inState.amount / 2) } + input { inState.copy(amount = inState.amount / 2) } + output { inState } + contract.accepts() } - transaction { - input { inState } - output { outState.copy(amount = 800.DOLLARS) } - output { outState.copy(amount = 200.POUNDS) } - it `fails requirement` "all outputs use the currency of the inputs" - } - transaction { - input { inState } - input { inState2 } - output { outState.copy(amount = 1150.DOLLARS) } - it `fails requirement` "all inputs use the same currency" - } - transaction { - input { inState } - input { inState.copy(issuingInstitution = MINI_CORP) } - output { outState } - it `fails requirement` "all inputs come from the same issuer" + } + + } + + @Test + fun zeroSizedInputs() { + transaction { + input { inState } + input { inState.copy(amount = 0.DOLLARS) } + contract `fails requirement` "zero sized inputs" + } + } + + @Test + fun trivialMismatches() { + // Can't change issuer. + transaction { + input { inState } + output { outState.copy(issuingInstitution = MINI_CORP) } + contract `fails requirement` "for issuer MegaCorp the amounts balance" + } + // Can't change deposit reference when splitting. + transaction { + input { inState } + output { outState.copy(depositReference = byteArrayOf(0), amount = inState.amount / 2) } + output { outState.copy(depositReference = byteArrayOf(1), amount = inState.amount / 2) } + contract `fails requirement` "the deposit references are the same" + } + // Can't mix currencies. + transaction { + input { inState } + output { outState.copy(amount = 800.DOLLARS) } + output { outState.copy(amount = 200.POUNDS) } + contract `fails requirement` "all outputs use the currency of the inputs" + } + transaction { + input { inState } + input { + inState.copy( + amount = 150.POUNDS, + owner = DUMMY_PUBKEY_2 + ) } + output { outState.copy(amount = 1150.DOLLARS) } + contract `fails requirement` "all inputs use the same currency" + } + transaction { + input { inState } + input { inState.copy(issuingInstitution = MINI_CORP) } + output { outState } + contract `fails requirement` "for issuer MiniCorp the amounts balance" } } @Test fun exitLedger() { - CashContract().let { + // Single input/output straightforward case. + transaction { + input { inState } + output { outState.copy(amount = inState.amount - 200.DOLLARS) } + transaction { - input { inState } - output { outState.copy(amount = inState.amount - 200.DOLLARS) } + arg(MEGA_CORP_KEY) { ExitCashCommand(100.DOLLARS) } + contract `fails requirement` "the amounts balance" + } + + transaction { + arg(MEGA_CORP_KEY) { ExitCashCommand(200.DOLLARS) } + contract `fails requirement` "the owning keys are the same as the signing keys" // No move command. transaction { - arg(MEGA_CORP_KEY) { - ExitCashCommand(100.DOLLARS) - } - it `fails requirement` "the amounts balance" - } - - transaction { - arg(MEGA_CORP_KEY) { - ExitCashCommand(200.DOLLARS) - } - it `fails requirement` "the owning keys are the same as the signing keys" // No move command. - - transaction { - arg(DUMMY_PUBKEY_1) { MoveCashCommand() } - it.accepts() - } + arg(DUMMY_PUBKEY_1) { MoveCashCommand() } + contract.accepts() } } } + // Multi-issuer case. + transaction { + input { inState } + input { inState.copy(issuingInstitution = MINI_CORP) } + + output { inState.copy(issuingInstitution = MINI_CORP, amount = inState.amount - 200.DOLLARS) } + output { inState.copy(amount = inState.amount - 200.DOLLARS) } + + arg(DUMMY_PUBKEY_1) { MoveCashCommand() } + + contract `fails requirement` "for issuer MegaCorp the amounts balance" + + arg(MEGA_CORP_KEY) { ExitCashCommand(200.DOLLARS) } + contract `fails requirement` "for issuer MiniCorp the amounts balance" + + arg(MINI_CORP_KEY) { ExitCashCommand(200.DOLLARS) } + contract.accepts() + } + } + + @Test + fun multiIssuer() { + transaction { + // Gather 2000 dollars from two different issuers. + input { inState } + input { inState.copy(issuingInstitution = MINI_CORP) } + + // Can't merge them together. + transaction { + output { inState.copy(owner = DUMMY_PUBKEY_2, amount = 2000.DOLLARS) } + contract `fails requirement` "for issuer MegaCorp the amounts balance" + } + // Missing MiniCorp deposit + transaction { + output { inState.copy(owner = DUMMY_PUBKEY_2) } + output { inState.copy(owner = DUMMY_PUBKEY_2) } + contract `fails requirement` "for issuer MegaCorp the amounts balance" + } + + // This works. + output { inState.copy(owner = DUMMY_PUBKEY_2) } + output { inState.copy(issuingInstitution = MINI_CORP, owner = DUMMY_PUBKEY_2) } + arg(DUMMY_PUBKEY_1) { MoveCashCommand() } + contract.accepts() + } + + transaction { + input { inState } + input { inState } + } } }