Add support for multi-spends to OnLedgerAsset and Cash. Multi-spends let you request multiple payments to different parties be calculated simultaneously. (#1342)

This commit is contained in:
Mike Hearn 2017-08-28 20:13:53 +02:00 committed by Roger Willis
parent 8f0ea714b3
commit e2f112c3a8
3 changed files with 156 additions and 40 deletions

View File

@ -4,6 +4,7 @@ package net.corda.finance.contracts.asset
import co.paralleluniverse.fibers.Suspendable
import net.corda.finance.contracts.asset.cash.selection.CashSelectionH2Impl
import net.corda.core.contracts.*
import net.corda.core.contracts.Amount.Companion.sumOrThrow
import net.corda.core.crypto.entropyToKeyPair
import net.corda.core.crypto.testing.NULL_PARTY
import net.corda.core.crypto.toBase58String
@ -290,13 +291,42 @@ class Cash : OnLedgerAsset<Currency, Cash.Commands, Cash.State>() {
amount: Amount<Currency>,
to: AbstractParty,
onlyFromParties: Set<AbstractParty> = emptySet()): Pair<TransactionBuilder, List<PublicKey>> {
return generateSpend(services, tx, listOf(PartyAndAmount(to, amount)), onlyFromParties)
}
/**
* Generate a transaction that moves an amount of currency to the given pubkey.
*
* Note: an [Amount] of [Currency] is only fungible for a given Issuer Party within a [FungibleAsset]
*
* @param services The [ServiceHub] to provide access to the database session.
* @param tx A builder, which may contain inputs, outputs and commands already. The relevant components needed
* to move the cash will be added on top.
* @param amount How much currency to send.
* @param to a key of the recipient.
* @param onlyFromParties if non-null, the asset states will be filtered to only include those issued by the set
* of given parties. This can be useful if the party you're trying to pay has expectations
* about which type of asset claims they are willing to accept.
* @return A [Pair] of the same transaction builder passed in as [tx], and the list of keys that need to sign
* the resulting transaction for it to be valid.
* @throws InsufficientBalanceException when a cash spending transaction fails because
* there is insufficient quantity for a given currency (and optionally set of Issuer Parties).
*/
@JvmStatic
@Throws(InsufficientBalanceException::class)
@Suspendable
fun generateSpend(services: ServiceHub,
tx: TransactionBuilder,
payments: List<PartyAndAmount<Currency>>,
onlyFromParties: Set<AbstractParty> = emptySet()): Pair<TransactionBuilder, List<PublicKey>> {
fun deriveState(txState: TransactionState<Cash.State>, amt: Amount<Issued<Currency>>, owner: AbstractParty)
= txState.copy(data = txState.data.copy(amount = amt, owner = owner))
// Retrieve unspent and unlocked cash states that meet our spending criteria.
val acceptableCoins = CashSelection.getInstance({services.jdbcSession().metaData}).unconsumedCashStatesForSpending(services, amount, onlyFromParties, tx.notary, tx.lockId)
return OnLedgerAsset.generateSpend(tx, amount, to, acceptableCoins,
val totalAmount = payments.map { it.amount }.sumOrThrow()
val cashSelection = CashSelection.getInstance({ services.jdbcSession().metaData })
val acceptableCoins = cashSelection.unconsumedCashStatesForSpending(services, totalAmount, onlyFromParties, tx.notary, tx.lockId)
return OnLedgerAsset.generateSpend(tx, payments, acceptableCoins,
{ state, quantity, owner -> deriveState(state, quantity, owner) },
{ Cash().generateMoveCommand() })
}

View File

@ -2,6 +2,7 @@ package net.corda.finance.contracts.asset
import net.corda.core.contracts.*
import net.corda.core.contracts.Amount.Companion.sumOrThrow
import net.corda.core.contracts.Amount.Companion.zero
import net.corda.core.identity.AbstractParty
import net.corda.core.transactions.TransactionBuilder
import net.corda.core.utilities.loggerFor
@ -14,6 +15,9 @@ import java.util.*
// Generic contract for assets on a ledger
//
/** A simple holder for a (possibly anonymous) [AbstractParty] and a quantity of tokens */
data class PartyAndAmount<T : Any>(val party: AbstractParty, val amount: Amount<T>)
/**
* An asset transaction may split and merge assets represented by a set of (issuer, depositRef) pairs, across multiple
* input and output states. Imagine a Bitcoin transaction but in which all UTXOs had a colour (a blend of
@ -56,6 +60,38 @@ abstract class OnLedgerAsset<T : Any, C : CommandData, S : FungibleAsset<T>> : C
acceptableStates: List<StateAndRef<S>>,
deriveState: (TransactionState<S>, Amount<Issued<T>>, AbstractParty) -> TransactionState<S>,
generateMoveCommand: () -> CommandData): Pair<TransactionBuilder, List<PublicKey>> {
return generateSpend(tx, listOf(PartyAndAmount(to, amount)), acceptableStates, deriveState, generateMoveCommand)
}
/**
* Adds to the given transaction states that move amounts of a fungible asset to the given parties, using only
* the provided acceptable input states to find a solution (not all of them may be used in the end). A change
* output will be generated if the state amounts don't exactly fit.
*
* The fungible assets must all be of the same type and the amounts must be summable i.e. amounts of the same
* token.
*
* @param tx A builder, which may contain inputs, outputs and commands already. The relevant components needed
* to move the cash will be added on top.
* @param amount How much currency to send.
* @param to a key of the recipient.
* @param acceptableStates a list of acceptable input states to use.
* @param deriveState a function to derive an output state based on an input state, amount for the output
* and public key to pay to.
* @param T A type representing a token
* @param S A fungible asset state type
* @return A [Pair] of the same transaction builder passed in as [tx], and the list of keys that need to sign
* the resulting transaction for it to be valid.
* @throws InsufficientBalanceException when a cash spending transaction fails because
* there is insufficient quantity for a given currency (and optionally set of Issuer Parties).
*/
@Throws(InsufficientBalanceException::class)
@JvmStatic
fun <S : FungibleAsset<T>, T: Any> generateSpend(tx: TransactionBuilder,
payments: List<PartyAndAmount<T>>,
acceptableStates: List<StateAndRef<S>>,
deriveState: (TransactionState<S>, Amount<Issued<T>>, AbstractParty) -> TransactionState<S>,
generateMoveCommand: () -> CommandData): Pair<TransactionBuilder, List<PublicKey>> {
// Discussion
//
// This code is analogous to the Wallet.send() set of methods in bitcoinj, and has the same general outline.
@ -80,43 +116,69 @@ abstract class OnLedgerAsset<T : Any, C : CommandData, S : FungibleAsset<T>> : C
// different notaries, or at least group states by notary and take the set with the
// highest total value.
// notary may be associated with locked state only
// TODO: Check that re-running this on the same transaction multiple times does the right thing.
// The notary may be associated with a locked state only.
tx.notary = acceptableStates.firstOrNull()?.state?.notary
val (gathered, gatheredAmount) = gatherCoins(acceptableStates, amount)
val takeChangeFrom = gathered.firstOrNull()
val change = if (takeChangeFrom != null && gatheredAmount > amount) {
Amount(gatheredAmount.quantity - amount.quantity, takeChangeFrom.state.data.amount.token)
} else {
null
}
// Calculate the total amount we're sending (they must be all of a compatible token).
val totalSendAmount = payments.map { it.amount }.sumOrThrow()
// Select a subset of the available states we were given that sums up to >= totalSendAmount.
val (gathered, gatheredAmount) = gatherCoins(acceptableStates, totalSendAmount)
check(gatheredAmount >= totalSendAmount)
val keysUsed = gathered.map { it.state.data.owner.owningKey }
val states = gathered.groupBy { it.state.data.amount.token.issuer }.map {
val coins = it.value
val totalAmount = coins.map { it.state.data.amount }.sumOrThrow()
deriveState(coins.first().state, totalAmount, to)
}.sortedBy { it.data.amount.quantity }
val outputs = if (change != null) {
// Just copy a key across as the change key. In real life of course, this works but leaks private data.
// In bitcoinj we derive a fresh key here and then shuffle the outputs to ensure it's hard to follow
// value flows through the transaction graph.
val existingOwner = gathered.first().state.data.owner
// Add a change output and adjust the last output downwards.
states.subList(0, states.lastIndex) +
states.last().let {
val spent = it.data.amount.withoutIssuer() - change.withoutIssuer()
deriveState(it, Amount(spent.quantity, it.data.amount.token), it.data.owner)
} +
states.last().let {
deriveState(it, Amount(change.quantity, it.data.amount.token), existingOwner)
// Now calculate the output states. This is complicated by the fact that a single payment may require
// multiple output states, due to the need to keep states separated by issuer. We start by figuring out
// how much we've gathered for each issuer: this map will keep track of how much we've used from each
// as we work our way through the payments.
val statesGroupedByIssuer = gathered.groupBy { it.state.data.amount.token }
val remainingFromEachIssuer= statesGroupedByIssuer
.mapValues {
it.value.map {
it.state.data.amount
}.sumOrThrow()
}.toList().toMutableList()
val outputStates = mutableListOf<TransactionState<S>>()
for ((party, paymentAmount) in payments) {
var remainingToPay= paymentAmount.quantity
while (remainingToPay > 0) {
val (token, remainingFromCurrentIssuer) = remainingFromEachIssuer.last()
val templateState = statesGroupedByIssuer[token]!!.first().state
val delta = remainingFromCurrentIssuer.quantity - remainingToPay
when {
delta > 0 -> {
// The states from the current issuer more than covers this payment.
outputStates += deriveState(templateState, Amount(remainingToPay, token), party)
remainingFromEachIssuer[0] = Pair(token, Amount(delta, token))
remainingToPay = 0
}
} else states
delta == 0L -> {
// The states from the current issuer exactly covers this payment.
outputStates += deriveState(templateState, Amount(remainingToPay, token), party)
remainingFromEachIssuer.removeAt(remainingFromEachIssuer.lastIndex)
remainingToPay = 0
}
delta < 0 -> {
// The states from the current issuer don't cover this payment, so we'll have to use >1 output
// state to cover this payment.
outputStates += deriveState(templateState, remainingFromCurrentIssuer, party)
remainingFromEachIssuer.removeAt(remainingFromEachIssuer.lastIndex)
remainingToPay -= remainingFromCurrentIssuer.quantity
}
}
}
}
// Whatever values we have left over for each issuer must become change outputs.
val myself = gathered.first().state.data.owner
for ((token, amount) in remainingFromEachIssuer) {
val templateState = statesGroupedByIssuer[token]!!.first().state
outputStates += deriveState(templateState, amount, myself)
}
for (state in gathered) tx.addInputState(state)
for (state in outputs) tx.addOutputState(state)
for (state in outputStates) tx.addOutputState(state)
// What if we already have a move command with the right keys? Filter it out here or in platform code?
tx.addCommand(generateMoveCommand(), keysUsed)

View File

@ -59,6 +59,7 @@ class CashTests : TestDependencyInjectionBase() {
database = databaseAndServices.first
miniCorpServices = databaseAndServices.second
// Create some cash. Any attempt to spend >$500 will require multiple issuers to be involved.
database.transaction {
miniCorpServices.fillWithSomeTestCash(howMuch = 100.DOLLARS, atLeastThisManyStates = 1, atMostThisManyStates = 1,
ownedBy = OUR_IDENTITY_1, issuedBy = MEGA_CORP.ref(1), issuerServices = megaCorpServices)
@ -447,6 +448,7 @@ class CashTests : TestDependencyInjectionBase() {
val OUR_IDENTITY_1: AbstractParty get() = AnonymousParty(OUR_KEY.public)
val THEIR_IDENTITY_1 = AnonymousParty(MINI_CORP_PUBKEY)
val THEIR_IDENTITY_2 = AnonymousParty(CHARLIE_PUBKEY)
fun makeCash(amount: Amount<Currency>, corp: Party, depositRef: Byte = 1) =
StateAndRef(
@ -464,13 +466,13 @@ class CashTests : TestDependencyInjectionBase() {
/**
* Generate an exit transaction, removing some amount of cash from the ledger.
*/
fun makeExit(amount: Amount<Currency>, corp: Party, depositRef: Byte = 1): WireTransaction {
private fun makeExit(amount: Amount<Currency>, corp: Party, depositRef: Byte = 1): WireTransaction {
val tx = TransactionBuilder(DUMMY_NOTARY)
Cash().generateExit(tx, Amount(amount.quantity, Issued(corp.ref(depositRef), amount.token)), WALLET)
return tx.toWireTransaction()
}
fun makeSpend(amount: Amount<Currency>, dest: AbstractParty): WireTransaction {
private fun makeSpend(amount: Amount<Currency>, dest: AbstractParty): WireTransaction {
val tx = TransactionBuilder(DUMMY_NOTARY)
database.transaction {
Cash.generateSpend(miniCorpServices, tx, amount, dest)
@ -627,16 +629,14 @@ class CashTests : TestDependencyInjectionBase() {
wtx
}
database.transaction {
@Suppress("UNCHECKED_CAST")
val vaultState0 = vaultStatesUnconsumed.elementAt(0)
val vaultState1 = vaultStatesUnconsumed.elementAt(1)
@Suppress("UNCHECKED_CAST")
val vaultState2 = vaultStatesUnconsumed.elementAt(2)
val vaultState0: StateAndRef<Cash.State> = vaultStatesUnconsumed.elementAt(0)
val vaultState1: StateAndRef<Cash.State> = vaultStatesUnconsumed.elementAt(1)
val vaultState2: StateAndRef<Cash.State> = vaultStatesUnconsumed.elementAt(2)
assertEquals(vaultState0.ref, wtx.inputs[0])
assertEquals(vaultState1.ref, wtx.inputs[1])
assertEquals(vaultState2.ref, wtx.inputs[2])
assertEquals(vaultState0.state.data.copy(owner = THEIR_IDENTITY_1, amount = 500.DOLLARS `issued by` defaultIssuer), wtx.outputs[1].data)
assertEquals(vaultState2.state.data.copy(owner = THEIR_IDENTITY_1), wtx.getOutput(0))
assertEquals(vaultState2.state.data.copy(owner = THEIR_IDENTITY_1), wtx.outputs[0].data)
assertEquals(OUR_IDENTITY_1.owningKey, wtx.commands.single { it.value is Cash.Commands.Move }.signers[0])
}
}
@ -774,4 +774,28 @@ class CashTests : TestDependencyInjectionBase() {
this.verifies()
}
}
@Test
fun multiSpend() {
initialiseTestSerialization()
val tx = TransactionBuilder(DUMMY_NOTARY)
database.transaction {
val payments = listOf(
PartyAndAmount(THEIR_IDENTITY_1, 400.DOLLARS),
PartyAndAmount(THEIR_IDENTITY_2, 150.DOLLARS)
)
Cash.generateSpend(miniCorpServices, tx, payments)
}
val wtx = tx.toWireTransaction()
fun out(i: Int) = wtx.getOutput(i) as Cash.State
assertEquals(4, wtx.outputs.size)
assertEquals(80.DOLLARS, out(0).amount.withoutIssuer())
assertEquals(320.DOLLARS, out(1).amount.withoutIssuer())
assertEquals(150.DOLLARS, out(2).amount.withoutIssuer())
assertEquals(30.DOLLARS, out(3).amount.withoutIssuer())
assertEquals(MINI_CORP, out(0).amount.token.issuer.party)
assertEquals(MEGA_CORP, out(1).amount.token.issuer.party)
assertEquals(MEGA_CORP, out(2).amount.token.issuer.party)
assertEquals(MEGA_CORP, out(3).amount.token.issuer.party)
}
}