diff --git a/contracts/src/main/kotlin/com/r3corda/contracts/asset/Cash.kt b/contracts/src/main/kotlin/com/r3corda/contracts/asset/Cash.kt index 5ee3216674..f2915f8ded 100644 --- a/contracts/src/main/kotlin/com/r3corda/contracts/asset/Cash.kt +++ b/contracts/src/main/kotlin/com/r3corda/contracts/asset/Cash.kt @@ -125,6 +125,75 @@ class Cash : ClauseVerifier() { data class Exit(override val amount: Amount>) : Commands, FungibleAsset.Commands.Exit } + /** + * Generate an transaction exiting cash from the ledger. + * + * @param tx transaction builder to add states and commands to. + * @param amountIssued the amount to be exited, represented as a quantity of issued currency. + * @param changeKey the key to send any change to. This needs to be explicitly stated as the input states are not + * necessarily owned by us. + * @param cashStates the cash states to take funds from. No checks are done about ownership of these states, it is + * the responsibility of the caller to check that they do not exit funds held by others. + * @return the public key of the cash issuer, who must sign the transaction for it to be valid. + */ + // TODO: I'm not at all sure we should support exiting funds others hold, but that's a bigger discussion around the + // contract logic, not just this function. + fun generateExit(tx: TransactionBuilder, amountIssued: Amount>, + changeKey: PublicKey, cashStates: List>): PublicKey { + val currency = amountIssued.token.product + val issuer = amountIssued.token.issuer.party + val amount = Amount(amountIssued.quantity, currency) + var acceptableCoins = cashStates.filter { ref -> ref.state.data.amount.token == amountIssued.token } + val notary = acceptableCoins.firstOrNull()?.state?.notary + // TODO: We should be prepared to produce multiple transactions exiting inputs from + // different notaries, or at least group states by notary and take the set with the + // highest total value + acceptableCoins = acceptableCoins.filter { it.state.notary == notary } + + val (gathered, gatheredAmount) = gatherCoins(acceptableCoins, Amount(amount.quantity, currency)) + val takeChangeFrom = gathered.lastOrNull() + val change = if (takeChangeFrom != null && gatheredAmount > amount) { + Amount>(gatheredAmount.quantity - amount.quantity, takeChangeFrom.state.data.issuanceDef) + } else { + null + } + + val outputs: List> = if (change != null) { + // Add a change output and adjust the last output downwards. + listOf(TransactionState(State(amountIssued.token.issuer, Amount(change.quantity, currency), changeKey), + notary!!)) + } else emptyList() + + for (state in gathered) tx.addInputState(state) + for (state in outputs) tx.addOutputState(state) + tx.addCommand(Commands.Exit(amountIssued), amountIssued.token.issuer.party.owningKey) + return amountIssued.token.issuer.party.owningKey + } + + /** + * Gather coins from the given list of states, sufficient to match or exceed the given amount. + * + * @param acceptableCoins list of states to use as inputs. + * @param amount the amount to gather states up to. + * @throws InsufficientBalanceException if there isn't enough value in the states to cover the requested amount. + */ + @Throws(InsufficientBalanceException::class) + private fun gatherCoins(acceptableCoins: List>, + amount: Amount): Pair>, Amount> { + val gathered = arrayListOf>() + var gatheredAmount = Amount(0, amount.token) + for (c in acceptableCoins) { + if (gatheredAmount >= amount) break + gathered.add(c) + gatheredAmount += Amount(c.state.data.amount.quantity, amount.token) + } + + if (gatheredAmount < amount) + throw InsufficientBalanceException(amount - gatheredAmount) + + return Pair(gathered, gatheredAmount) + } + /** * Puts together an issuance transaction from the given template, that starts out being owned by the given pubkey. */ @@ -192,19 +261,8 @@ class Cash : ClauseVerifier() { ofCurrency } - val gathered = arrayListOf>() - var gatheredAmount = Amount(0, currency) - var takeChangeFrom: StateAndRef? = null - for (c in acceptableCoins) { - if (gatheredAmount >= amount) break - gathered.add(c) - gatheredAmount += Amount(c.state.data.amount.quantity, currency) - takeChangeFrom = c - } - - if (gatheredAmount < amount) - throw InsufficientBalanceException(amount - gatheredAmount) - + val (gathered, gatheredAmount) = gatherCoins(acceptableCoins, amount) + val takeChangeFrom = gathered.firstOrNull() val change = if (takeChangeFrom != null && gatheredAmount > amount) { Amount>(gatheredAmount.quantity - amount.quantity, takeChangeFrom.state.data.issuanceDef) } else { diff --git a/contracts/src/test/kotlin/com/r3corda/contracts/asset/CashTests.kt b/contracts/src/test/kotlin/com/r3corda/contracts/asset/CashTests.kt index 9a4d3728fc..dee6add98a 100644 --- a/contracts/src/test/kotlin/com/r3corda/contracts/asset/CashTests.kt +++ b/contracts/src/test/kotlin/com/r3corda/contracts/asset/CashTests.kt @@ -380,12 +380,70 @@ class CashTests { makeCash(80.SWISS_FRANCS, MINI_CORP, 2) ) + /** + * Generate an exit transaction, removing some amount of cash from the ledger. + */ + fun makeExit(amount: Amount, corp: Party, depositRef: Byte = 1): WireTransaction { + val tx = TransactionType.General.Builder() + Cash().generateExit(tx, Amount(amount.quantity, Issued(corp.ref(depositRef), amount.token)), OUR_PUBKEY_1, WALLET) + return tx.toWireTransaction() + } + fun makeSpend(amount: Amount, dest: PublicKey): WireTransaction { val tx = TransactionType.General.Builder() Cash().generateSpend(tx, amount, dest, WALLET) return tx.toWireTransaction() } + /** + * Try exiting an amount which matches a single state. + */ + @Test + fun generateSimpleExit() { + val wtx = makeExit(100.DOLLARS, MEGA_CORP, 1) + assertEquals(WALLET[0].ref, wtx.inputs[0]) + assertEquals(0, wtx.outputs.size) + + val expected = Cash.Commands.Exit(Amount(10000, Issued(MEGA_CORP.ref(1), USD))) + val actual = wtx.commands.single().value + assertEquals(expected, actual) + } + + /** + * Try exiting an amount smaller than the smallest available input state, and confirm change is generated correctly. + */ + @Test + fun generatePartialExit() { + val wtx = makeExit(50.DOLLARS, MEGA_CORP, 1) + assertEquals(WALLET[0].ref, wtx.inputs[0]) + assertEquals(1, wtx.outputs.size) + assertEquals(WALLET[0].state.data.copy(amount = WALLET[0].state.data.amount / 2), wtx.outputs[0].data) + } + + /** + * Try exiting a currency we don't have. + */ + @Test + fun generateAbsentExit() { + assertFailsWith { makeExit(100.POUNDS, MEGA_CORP, 1) } + } + + /** + * Try exiting with a reference mis-match. + */ + @Test + fun generateInvalidReferenceExit() { + assertFailsWith { makeExit(100.POUNDS, MEGA_CORP, 2) } + } + + /** + * Try exiting an amount greater than the maximum available. + */ + @Test + fun generateInsufficientExit() { + assertFailsWith { makeExit(1000.DOLLARS, MEGA_CORP, 1) } + } + @Test fun generateSimpleDirectSpend() { val wtx = makeSpend(100.DOLLARS, THEIR_PUBKEY_1)