Improve the contracts API and Cash contract a bit, and beef up the unit tests.

Better usage of generics in groupStates so the group exposes the grouping key that was used, this avoids constructs like `val issuer = outputs[0].deposit.party` which is a bit ugly.
This commit is contained in:
Mike Hearn 2016-04-14 15:33:45 +02:00
parent 804b8bdc6a
commit fcc36c472a
5 changed files with 102 additions and 66 deletions

View File

@ -138,13 +138,13 @@ public class JavaCommercialPaper implements Contract {
// Issuance, trading (aka moving in this prototype) and redeeming.
// Each command has it's own set of restrictions which the verify function ... verifies.
List<InOutGroup<State>> groups = tx.groupStates(State.class, State::withoutOwner);
List<InOutGroup<State, State>> groups = tx.groupStates(State.class, State::withoutOwner);
// Find the command that instructs us what to do and check there's exactly one.
AuthenticatedObject<CommandData> cmd = requireSingleCommand(tx.getCommands(), JavaCommercialPaper.Commands.class);
for (InOutGroup<State> group : groups) {
for (InOutGroup<State, State> group : groups) {
List<State> inputs = group.getInputs();
List<State> outputs = group.getOutputs();
@ -164,7 +164,7 @@ public class JavaCommercialPaper implements Contract {
Instant time = timestampCommand.getBefore();
if (!time.isBefore(output.maturityDate)) {
if (time == null || !time.isBefore(output.maturityDate)) {
throw new IllegalStateException("Failed Requirement: the maturity date is not in the past");
}
@ -197,7 +197,7 @@ public class JavaCommercialPaper implements Contract {
if (!received.equals(input.getFaceValue()))
throw new IllegalStateException("Failed Requirement: received amount equals the face value");
if (time.isBefore(input.getMaturityDate()))
if (time == null || time.isBefore(input.getMaturityDate()))
throw new IllegalStateException("Failed requirement: the paper must have matured");
if (!input.getFaceValue().equals(received))
throw new IllegalStateException("Failed requirement: the received amount equals the face value");

View File

@ -82,53 +82,50 @@ class Cash : Contract {
override fun verify(tx: TransactionForVerification) {
// Each group is a set of input/output states with distinct (deposit, currency) attributes. These types
// of cash are not fungible and must be kept separated for bookkeeping purposes.
val groups = tx.groupStates<Cash.State>() { Pair(it.deposit, it.amount.currency) }
val groups = tx.groupStates() { it: Cash.State -> Pair(it.deposit, it.amount.currency) }
for ((inputs, outputs, key) in groups) {
// Either inputs or outputs could be empty.
val (deposit, currency) = key
val issuer = deposit.party
for ((inputs, outputs) in groups) {
requireThat {
"all outputs represent at least one penny" by outputs.none { it.amount.pennies == 0L }
"there are no zero sized outputs" by outputs.none { it.amount.pennies == 0L }
}
val issueCommand = tx.commands.select<Commands.Issue>().singleOrNull()
if (issueCommand != null) {
if (issueCommand != null && outputs.isNotEmpty()) {
// If we have an issue command, perform special processing: the group is allowed to have no inputs,
// and the output states must have a deposit reference owned by the signer. Note that this means
// literally anyone with access to the network can issue cash claims of arbitrary amounts! It is up
// to the recipient to decide if the backing party is trustworthy or not, via some
// and the output states must have a deposit reference owned by the signer.
//
// Whilst the transaction *may* have no inputs, it can have them, and in this case the outputs must
// sum to more than the inputs. An issuance of zero size is not allowed.
//
// Note that this means literally anyone with access to the network can issue cash claims of arbitrary
// amounts! It is up to the recipient to decide if the backing party is trustworthy or not, via some
// as-yet-unwritten identity service. See ADP-22 for discussion.
val outputsInstitution = outputs.map { it.deposit.party }.distinct().singleOrNull()
if (outputsInstitution != null) {
requireThat {
"the issue command has a nonce" by (issueCommand.value.nonce != 0L)
"output deposits are owned by a command signer" by
outputs.all { issueCommand.signingParties.contains(it.deposit.party) }
"there are no inputs in this group" by inputs.isEmpty()
}
continue
} else {
// There was an issue command, but it wasn't signed for this group. It may apply to other
// groups.
// The grouping ensures that all outputs have the same deposit reference and currency.
val inputAmount = inputs.sumCashOrZero(currency)
val outputAmount = outputs.sumCash()
requireThat {
"the issue command has a nonce" by (issueCommand.value.nonce != 0L)
"output deposits are owned by a command signer" by (issuer in issueCommand.signingParties)
"output values sum to more than the inputs" by (outputAmount > inputAmount)
}
continue
}
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(currency)
val deposit = inputs.first().deposit
// If we want to remove cash from the ledger, that must be signed for by the issuer.
// A mis-signed or duplicated exit command will just be ignored here and result in the exit amount being zero.
val exitCommand = tx.commands.select<Commands.Exit>(party = issuer).singleOrNull()
val amountExitingLedger = exitCommand?.value?.amount ?: Amount(0, currency)
requireThat {
"there is at least one cash input" by inputs.isNotEmpty()
"there are no zero sized inputs" by inputs.none { it.amount.pennies == 0L }
"there are no zero sized outputs" by outputs.none { it.amount.pennies == 0L }
"all outputs in this group use the currency of the inputs" by
outputs.all { it.amount.currency == inputAmount.currency }
}
val exitCommand = tx.commands.select<Commands.Exit>(party = deposit.party).singleOrNull()
val amountExitingLedger = exitCommand?.value?.amount ?: Amount(0, inputAmount.currency)
requireThat {
"for deposit ${deposit.reference} at issuer ${deposit.party.name} the amounts balance" by
(inputAmount == outputAmount + amountExitingLedger)
}

View File

@ -67,7 +67,7 @@ class CommercialPaper : Contract {
override fun verify(tx: TransactionForVerification) {
// Group by everything except owner: any modification to the CP at all is considered changing it fundamentally.
val groups = tx.groupStates<State>() { it.withoutOwner() }
val groups = tx.groupStates() { it: State -> it.withoutOwner() }
// There are two possible things that can be done with this CP. The first is trading it. The second is redeeming
// it for cash on or after the maturity date.
@ -78,39 +78,43 @@ class CommercialPaper : Contract {
// who or what is a trusted authority.
val timestamp: TimestampCommand? = tx.commands.getTimestampByName("Mock Company 0", "Timestamping Service", "Bank A")
for (group in groups) {
for ((inputs, outputs, key) in groups) {
when (command.value) {
is Commands.Move -> {
val input = group.inputs.single()
val input = inputs.single()
requireThat {
"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)
"the transaction is signed by the owner of the CP" by (input.owner in command.signers)
"the state is propagated" by (outputs.size == 1)
// Don't need to check anything else, as if outputs.size == 1 then the output is equal to
// the input ignoring the owner field due to the grouping.
}
}
// Redemption of the paper requires movement of on-ledger cash.
is Commands.Redeem -> {
val input = group.inputs.single()
val input = inputs.single()
val received = tx.outStates.sumCashBy(input.owner)
val time = timestamp?.after ?: throw IllegalArgumentException("Redemptions must be timestamped")
requireThat {
"the paper must have matured" by (time > input.maturityDate)
"the paper must have matured" by (time >= input.maturityDate)
"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))
"the paper must be destroyed" by outputs.isEmpty()
"the transaction is signed by the owner of the CP" by (input.owner in command.signers)
}
}
is Commands.Issue -> {
val output = group.outputs.single()
val output = outputs.single()
val time = timestamp?.before ?: throw IllegalArgumentException("Issuances must be timestamped")
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.party.owningKey))
(output.issuance.party.owningKey in command.signers)
"the face value is not zero" by (output.faceValue.pennies > 0)
"the maturity date is not in the past" by (time < output.maturityDate)
// Don't allow an existing CP state to be replaced by this issuance.
"there is no input state" by group.inputs.isEmpty()
// TODO: Consider how to handle the case of mistaken issuances, or other need to patch.
"there is no input state" by inputs.isEmpty()
}
}

View File

@ -95,7 +95,7 @@ data class TransactionForVerification(val inStates: List<ContractState>,
* up on both sides of the transaction, but the values must be summed independently per currency. Grouping can
* be used to simplify this logic.
*/
data class InOutGroup<T : ContractState>(val inputs: List<T>, val outputs: List<T>)
data class InOutGroup<T : ContractState, K : Any>(val inputs: List<T>, val outputs: List<T>, val groupingKey: K)
/** Simply calls [commands.getTimestampBy] as a shortcut to make code completion more intuitive. */
fun getTimestampBy(timestampingAuthority: Party): TimestampCommand? = commands.getTimestampBy(timestampingAuthority)
@ -114,38 +114,38 @@ data class TransactionForVerification(val inStates: List<ContractState>,
* currency. To solve this, you would use groupStates with a type of Cash.State and a selector that returns the
* currency field: the resulting list can then be iterated over to perform the per-currency calculation.
*/
fun <T : ContractState> groupStates(ofType: Class<T>, selector: (T) -> Any): List<InOutGroup<T>> {
fun <T : ContractState, K : Any> groupStates(ofType: Class<T>, selector: (T) -> K): List<InOutGroup<T, K>> {
val inputs = inStates.filterIsInstance(ofType)
val outputs = outStates.filterIsInstance(ofType)
val inGroups = inputs.groupBy(selector)
val outGroups = outputs.groupBy(selector)
val inGroups: Map<K, List<T>> = inputs.groupBy(selector)
val outGroups: Map<K, List<T>> = outputs.groupBy(selector)
@Suppress("DEPRECATION")
return groupStatesInternal(inGroups, outGroups)
}
/** See the documentation for the reflection-based version of [groupStates] */
inline fun <reified T : ContractState> groupStates(selector: (T) -> Any): List<InOutGroup<T>> {
inline fun <reified T : ContractState, K : Any> groupStates(selector: (T) -> K): List<InOutGroup<T, K>> {
val inputs = inStates.filterIsInstance<T>()
val outputs = outStates.filterIsInstance<T>()
val inGroups = inputs.groupBy(selector)
val outGroups = outputs.groupBy(selector)
val inGroups: Map<K, List<T>> = inputs.groupBy(selector)
val outGroups: Map<K, List<T>> = outputs.groupBy(selector)
@Suppress("DEPRECATION")
return groupStatesInternal(inGroups, outGroups)
}
@Deprecated("Do not use this directly: exposed as public only due to function inlining")
fun <T : ContractState> groupStatesInternal(inGroups: Map<Any, List<T>>, outGroups: Map<Any, List<T>>): List<InOutGroup<T>> {
val result = ArrayList<InOutGroup<T>>()
fun <T : ContractState, K : Any> groupStatesInternal(inGroups: Map<K, List<T>>, outGroups: Map<K, List<T>>): List<InOutGroup<T, K>> {
val result = ArrayList<InOutGroup<T, K>>()
for ((k, v) in inGroups.entries)
result.add(InOutGroup(v, outGroups[k] ?: emptyList()))
result.add(InOutGroup(v, outGroups[k] ?: emptyList(), k))
for ((k, v) in outGroups.entries) {
if (inGroups[k] == null)
result.add(InOutGroup(emptyList(), v))
result.add(InOutGroup(emptyList(), v, k))
}
return result

View File

@ -99,6 +99,7 @@ class CashTests {
this.accepts()
}
// Test generation works.
val ptx = TransactionBuilder()
Cash().generateIssue(ptx, 100.DOLLARS, MINI_CORP.ref(12, 34), owner = DUMMY_PUBKEY_1)
assertTrue(ptx.inputStates().isEmpty())
@ -108,6 +109,40 @@ class CashTests {
assertEquals(DUMMY_PUBKEY_1, s.owner)
assertTrue(ptx.commands()[0].value is Cash.Commands.Issue)
assertEquals(MINI_CORP_PUBKEY, ptx.commands()[0].signers[0])
// We can consume $1000 in a transaction and output $2000 as long as it's signed by an issuer.
transaction {
input { inState }
output { inState.copy(amount = inState.amount * 2) }
// Move fails: not allowed to summon money.
tweak {
arg(DUMMY_PUBKEY_1) { Cash.Commands.Move() }
this `fails requirement` "at issuer MegaCorp the amounts balance"
}
// Issue works.
tweak {
arg(MEGA_CORP_PUBKEY) { Cash.Commands.Issue() }
this.accepts()
}
}
// Can't use an issue command to lower the amount.
transaction {
input { inState }
output { inState.copy(amount = inState.amount / 2) }
arg(MEGA_CORP_PUBKEY) { Cash.Commands.Issue() }
this `fails requirement` "output values sum to more than the inputs"
}
// Can't have an issue command that doesn't actually issue money.
transaction {
input { inState }
output { inState }
arg(MEGA_CORP_PUBKEY) { Cash.Commands.Issue() }
this `fails requirement` "output values sum to more than the inputs"
}
}
@Test
@ -135,16 +170,21 @@ class CashTests {
this.accepts()
}
}
}
@Test
fun zeroSizedInputs() {
fun zeroSizedValues() {
transaction {
input { inState }
input { inState.copy(amount = 0.DOLLARS) }
this `fails requirement` "zero sized inputs"
}
transaction {
input { inState }
output { inState }
output { inState.copy(amount = 0.DOLLARS) }
this `fails requirement` "zero sized outputs"
}
}
@Test
@ -265,11 +305,6 @@ class CashTests {
arg(DUMMY_PUBKEY_1) { Cash.Commands.Move() }
this.accepts()
}
transaction {
input { inState }
input { inState }
}
}
@Test