mirror of
https://github.com/corda/corda.git
synced 2025-04-07 11:27:01 +00:00
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:
parent
804b8bdc6a
commit
fcc36c472a
@ -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");
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user