mirror of
https://github.com/corda/corda.git
synced 2024-12-18 20:47:57 +00:00
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:
parent
8f0ea714b3
commit
e2f112c3a8
@ -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() })
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
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)
|
||||
}
|
||||
} else states
|
||||
|
||||
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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user