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).
This commit is contained in:
Mike Hearn 2015-11-03 13:49:28 +01:00
parent ce3d339c62
commit 12f5ddb0aa
3 changed files with 201 additions and 115 deletions

View File

@ -1,11 +1,12 @@
import java.security.PublicKey import java.security.PublicKey
import java.util.*
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// //
// Cash // 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: Does multi-currency also make sense? Probably?
// TODO: Implement a generate function. // TODO: Implement a generate function.
@ -35,47 +36,52 @@ class ExitCashCommand(val amount: Amount) : Command
class CashContract : Contract { class CashContract : Contract {
override fun verify(inStates: List<ContractState>, outStates: List<ContractState>, args: List<VerifiedSignedCommand>) { override fun verify(inStates: List<ContractState>, outStates: List<ContractState>, args: List<VerifiedSignedCommand>) {
// Select all input states that are cash states and ensure they are all denominated in the same currency and val cashInputs = inStates.filterIsInstance<CashState>()
// issued by the same issuer.
val inputs = inStates.filterIsInstance<CashState>()
val inputMoney = inputs.sumBy { it.amount.pennies }
requireThat { requireThat {
"there is at least one cash input" by inputs.isNotEmpty() "there is at least one cash input" by cashInputs.isNotEmpty()
"all inputs use the same currency" by (inputs.groupBy { it.amount.currency }.size == 1) "there are no zero sized inputs" by cashInputs.none { it.amount.pennies == 0 }
"all inputs come from the same issuer" by (inputs.groupBy { it.issuingInstitution }.size == 1) "all inputs use the same currency" by (cashInputs.groupBy { it.amount.currency }.size == 1)
"some money is actually moving" by (inputMoney > 0)
} }
val issuer = inputs.first().issuingInstitution val currency = cashInputs.first().amount.currency
val currency = inputs.first().amount.currency
val depositReference = inputs.first().depositReference
// Select all the output states that are cash states. There may be zero if all money is being withdrawn. // 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 cashOutputs = outStates.filterIsInstance<CashState>()
val outputs = outStates.filterIsInstance<CashState>()
val outputMoney = outputs.sumBy { it.amount.pennies }
requireThat { requireThat {
"all outputs use the currency of the inputs" by outputs.all { it.amount.currency == currency } "all outputs use the currency of the inputs" by cashOutputs.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 }
} }
// If we have any commands, find the one that came from the issuer of the original cash deposit and // For each issuer that's represented in the inputs, group the inputs together and verify that the outputs
// check if it's an exit command. // balance, taking into account a possible exit command from that issuer.
val issuerCommand = args.find { it.signingInstitution == issuer }?.command as? ExitCashCommand var outputsLeft = cashOutputs.size
val amountExitingLedger = issuerCommand?.amount?.pennies ?: 0 for ((issuer, inputs) in cashInputs.groupBy { it.issuingInstitution }) {
requireThat("the value exiting the ledger is not more than the input value", amountExitingLedger <= outputMoney) val outputs = cashOutputs.filter { it.issuingInstitution == issuer }
outputsLeft -= outputs.size
// Verify the books balance.
requireThat("the amounts balance", inputMoney == outputMoney + amountExitingLedger) 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 // 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 // see a signature from each of those keys. The actual signatures have been verified against the transaction
// data by the platform before execution. // 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() 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. // Accept.
} }

View File

@ -8,14 +8,11 @@ import kotlin.test.fail
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// //
// REQUIREMENTS // 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: // To understand how requireThat works, read the section "type safe builders" on the Kotlin website:
// //
// https://kotlinlang.org/docs/reference/type-safe-builders.html // https://kotlinlang.org/docs/reference/type-safe-builders.html
object Requirements { object Requirements {
infix fun String.by(expr: Boolean) { infix fun String.by(expr: Boolean) {
if (!expr) throw IllegalArgumentException("Failed requirement: $this") if (!expr) throw IllegalArgumentException("Failed requirement: $this")

View File

@ -1,5 +1,10 @@
import org.junit.Test 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 { class CashTests {
val inState = CashState( val inState = CashState(
issuingInstitution = MEGA_CORP, issuingInstitution = MEGA_CORP,
@ -7,117 +12,195 @@ class CashTests {
amount = 1000.DOLLARS, amount = 1000.DOLLARS,
owner = DUMMY_PUBKEY_1 owner = DUMMY_PUBKEY_1
) )
val inState2 = inState.copy(
amount = 150.POUNDS,
owner = DUMMY_PUBKEY_2
)
val outState = inState.copy(owner = DUMMY_PUBKEY_2) val outState = inState.copy(owner = DUMMY_PUBKEY_2)
val contract = CashContract()
@Test @Test
fun trivial() { fun trivial() {
CashContract().let { transaction {
transaction { contract `fails requirement` "there is at least one cash input"
it `fails requirement` "there is at least one cash input"
} input { inState }
transaction { contract `fails requirement` "the amounts balance"
input { inState.copy(amount = 0.DOLLARS) }
it `fails requirement` "some money is actually moving"
}
transaction { transaction {
input { inState } output { outState.copy(amount = 2000.DOLLARS )}
it `fails requirement` "the amounts balance" contract `fails requirement` "the amounts balance"
}
transaction { transaction {
output { outState.copy(amount = 2000.DOLLARS )} output { outState }
it `fails requirement` "the amounts balance" // No command arguments
} contract `fails requirement` "the owning keys are the same as the signing keys"
transaction { }
output { outState } transaction {
// No command arguments output { outState }
it `fails requirement` "the owning keys are the same as the signing keys" arg(DUMMY_PUBKEY_2) { MoveCashCommand() }
} contract `fails requirement` "the owning keys are the same as the signing keys"
transaction { }
output { outState } transaction {
arg(DUMMY_PUBKEY_2) { MoveCashCommand() } output { outState }
it `fails requirement` "the owning keys are the same as the signing keys" output { outState.copy(issuingInstitution = MINI_CORP) }
} contract `fails requirement` "no output states are unaccounted for"
transaction { }
output { outState } // Simple reallocation works.
arg(DUMMY_PUBKEY_1) { MoveCashCommand() } transaction {
it.accepts() output { outState }
} arg(DUMMY_PUBKEY_1) { MoveCashCommand() }
contract.accepts()
} }
} }
} }
@Test @Test
fun mismatches() { fun testMergeSplit() {
CashContract().let { // Splitting value works.
transaction {
arg(DUMMY_PUBKEY_1) { MoveCashCommand() }
transaction { transaction {
input { inState } input { inState }
output { outState.copy(issuingInstitution = MINI_CORP) } for (i in 1..4) output { inState.copy(amount = inState.amount / 4) }
it `fails requirement` "all outputs claim against the issuer of the inputs" contract.accepts()
} }
// Merging 4 inputs into 2 outputs works.
transaction { transaction {
input { inState } for (i in 1..4) input { inState.copy(amount = inState.amount / 4) }
output { outState.copy(issuingInstitution = MEGA_CORP) } output { inState.copy(amount = inState.amount / 2) }
output { outState.copy(issuingInstitution = MINI_CORP) } output { inState.copy(amount = inState.amount / 2) }
it `fails requirement` "all outputs claim against the issuer of the inputs" contract.accepts()
} }
// Merging 2 inputs into 1 works.
transaction { transaction {
input { inState } input { inState.copy(amount = inState.amount / 2) }
output { outState.copy(depositReference = byteArrayOf(0)) } input { inState.copy(amount = inState.amount / 2) }
output { outState.copy(depositReference = byteArrayOf(1)) } output { inState }
it `fails requirement` "all outputs use the same deposit reference as the inputs" 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" @Test
} fun zeroSizedInputs() {
transaction { transaction {
input { inState } input { inState }
input { inState2 } input { inState.copy(amount = 0.DOLLARS) }
output { outState.copy(amount = 1150.DOLLARS) } contract `fails requirement` "zero sized inputs"
it `fails requirement` "all inputs use the same currency" }
} }
transaction {
input { inState } @Test
input { inState.copy(issuingInstitution = MINI_CORP) } fun trivialMismatches() {
output { outState } // Can't change issuer.
it `fails requirement` "all inputs come from the same 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 @Test
fun exitLedger() { fun exitLedger() {
CashContract().let { // Single input/output straightforward case.
transaction {
input { inState }
output { outState.copy(amount = inState.amount - 200.DOLLARS) }
transaction { transaction {
input { inState } arg(MEGA_CORP_KEY) { ExitCashCommand(100.DOLLARS) }
output { outState.copy(amount = inState.amount - 200.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 { transaction {
arg(MEGA_CORP_KEY) { arg(DUMMY_PUBKEY_1) { MoveCashCommand() }
ExitCashCommand(100.DOLLARS) contract.accepts()
}
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()
}
} }
} }
} }
// 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 }
}
} }
} }