mirror of
https://github.com/corda/corda.git
synced 2024-12-19 21:17:58 +00:00
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:
parent
ce3d339c62
commit
12f5ddb0aa
62
src/Cash.kt
62
src/Cash.kt
@ -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.
|
||||||
}
|
}
|
||||||
|
@ -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")
|
||||||
|
@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user