mirror of
https://github.com/corda/corda.git
synced 2024-12-20 21:43:14 +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 co.paralleluniverse.fibers.Suspendable
|
||||||
import net.corda.finance.contracts.asset.cash.selection.CashSelectionH2Impl
|
import net.corda.finance.contracts.asset.cash.selection.CashSelectionH2Impl
|
||||||
import net.corda.core.contracts.*
|
import net.corda.core.contracts.*
|
||||||
|
import net.corda.core.contracts.Amount.Companion.sumOrThrow
|
||||||
import net.corda.core.crypto.entropyToKeyPair
|
import net.corda.core.crypto.entropyToKeyPair
|
||||||
import net.corda.core.crypto.testing.NULL_PARTY
|
import net.corda.core.crypto.testing.NULL_PARTY
|
||||||
import net.corda.core.crypto.toBase58String
|
import net.corda.core.crypto.toBase58String
|
||||||
@ -290,13 +291,42 @@ class Cash : OnLedgerAsset<Currency, Cash.Commands, Cash.State>() {
|
|||||||
amount: Amount<Currency>,
|
amount: Amount<Currency>,
|
||||||
to: AbstractParty,
|
to: AbstractParty,
|
||||||
onlyFromParties: Set<AbstractParty> = emptySet()): Pair<TransactionBuilder, List<PublicKey>> {
|
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)
|
fun deriveState(txState: TransactionState<Cash.State>, amt: Amount<Issued<Currency>>, owner: AbstractParty)
|
||||||
= txState.copy(data = txState.data.copy(amount = amt, owner = owner))
|
= txState.copy(data = txState.data.copy(amount = amt, owner = owner))
|
||||||
|
|
||||||
// Retrieve unspent and unlocked cash states that meet our spending criteria.
|
// 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)
|
val totalAmount = payments.map { it.amount }.sumOrThrow()
|
||||||
return OnLedgerAsset.generateSpend(tx, amount, to, acceptableCoins,
|
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) },
|
{ state, quantity, owner -> deriveState(state, quantity, owner) },
|
||||||
{ Cash().generateMoveCommand() })
|
{ Cash().generateMoveCommand() })
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package net.corda.finance.contracts.asset
|
|||||||
|
|
||||||
import net.corda.core.contracts.*
|
import net.corda.core.contracts.*
|
||||||
import net.corda.core.contracts.Amount.Companion.sumOrThrow
|
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.identity.AbstractParty
|
||||||
import net.corda.core.transactions.TransactionBuilder
|
import net.corda.core.transactions.TransactionBuilder
|
||||||
import net.corda.core.utilities.loggerFor
|
import net.corda.core.utilities.loggerFor
|
||||||
@ -14,6 +15,9 @@ import java.util.*
|
|||||||
// Generic contract for assets on a ledger
|
// 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
|
* 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
|
* 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>>,
|
acceptableStates: List<StateAndRef<S>>,
|
||||||
deriveState: (TransactionState<S>, Amount<Issued<T>>, AbstractParty) -> TransactionState<S>,
|
deriveState: (TransactionState<S>, Amount<Issued<T>>, AbstractParty) -> TransactionState<S>,
|
||||||
generateMoveCommand: () -> CommandData): Pair<TransactionBuilder, List<PublicKey>> {
|
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
|
// Discussion
|
||||||
//
|
//
|
||||||
// This code is analogous to the Wallet.send() set of methods in bitcoinj, and has the same general outline.
|
// 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
|
// different notaries, or at least group states by notary and take the set with the
|
||||||
// highest total value.
|
// 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
|
tx.notary = acceptableStates.firstOrNull()?.state?.notary
|
||||||
|
|
||||||
val (gathered, gatheredAmount) = gatherCoins(acceptableStates, amount)
|
// Calculate the total amount we're sending (they must be all of a compatible token).
|
||||||
|
val totalSendAmount = payments.map { it.amount }.sumOrThrow()
|
||||||
val takeChangeFrom = gathered.firstOrNull()
|
// Select a subset of the available states we were given that sums up to >= totalSendAmount.
|
||||||
val change = if (takeChangeFrom != null && gatheredAmount > amount) {
|
val (gathered, gatheredAmount) = gatherCoins(acceptableStates, totalSendAmount)
|
||||||
Amount(gatheredAmount.quantity - amount.quantity, takeChangeFrom.state.data.amount.token)
|
check(gatheredAmount >= totalSendAmount)
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val keysUsed = gathered.map { it.state.data.owner.owningKey }
|
val keysUsed = gathered.map { it.state.data.owner.owningKey }
|
||||||
|
|
||||||
val states = gathered.groupBy { it.state.data.amount.token.issuer }.map {
|
// Now calculate the output states. This is complicated by the fact that a single payment may require
|
||||||
val coins = it.value
|
// multiple output states, due to the need to keep states separated by issuer. We start by figuring out
|
||||||
val totalAmount = coins.map { it.state.data.amount }.sumOrThrow()
|
// how much we've gathered for each issuer: this map will keep track of how much we've used from each
|
||||||
deriveState(coins.first().state, totalAmount, to)
|
// as we work our way through the payments.
|
||||||
}.sortedBy { it.data.amount.quantity }
|
val statesGroupedByIssuer = gathered.groupBy { it.state.data.amount.token }
|
||||||
|
val remainingFromEachIssuer= statesGroupedByIssuer
|
||||||
val outputs = if (change != null) {
|
.mapValues {
|
||||||
// Just copy a key across as the change key. In real life of course, this works but leaks private data.
|
it.value.map {
|
||||||
// In bitcoinj we derive a fresh key here and then shuffle the outputs to ensure it's hard to follow
|
it.state.data.amount
|
||||||
// value flows through the transaction graph.
|
}.sumOrThrow()
|
||||||
val existingOwner = gathered.first().state.data.owner
|
}.toList().toMutableList()
|
||||||
// Add a change output and adjust the last output downwards.
|
val outputStates = mutableListOf<TransactionState<S>>()
|
||||||
states.subList(0, states.lastIndex) +
|
for ((party, paymentAmount) in payments) {
|
||||||
states.last().let {
|
var remainingToPay= paymentAmount.quantity
|
||||||
val spent = it.data.amount.withoutIssuer() - change.withoutIssuer()
|
while (remainingToPay > 0) {
|
||||||
deriveState(it, Amount(spent.quantity, it.data.amount.token), it.data.owner)
|
val (token, remainingFromCurrentIssuer) = remainingFromEachIssuer.last()
|
||||||
} +
|
val templateState = statesGroupedByIssuer[token]!!.first().state
|
||||||
states.last().let {
|
val delta = remainingFromCurrentIssuer.quantity - remainingToPay
|
||||||
deriveState(it, Amount(change.quantity, it.data.amount.token), existingOwner)
|
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 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?
|
// What if we already have a move command with the right keys? Filter it out here or in platform code?
|
||||||
tx.addCommand(generateMoveCommand(), keysUsed)
|
tx.addCommand(generateMoveCommand(), keysUsed)
|
||||||
|
@ -59,6 +59,7 @@ class CashTests : TestDependencyInjectionBase() {
|
|||||||
database = databaseAndServices.first
|
database = databaseAndServices.first
|
||||||
miniCorpServices = databaseAndServices.second
|
miniCorpServices = databaseAndServices.second
|
||||||
|
|
||||||
|
// Create some cash. Any attempt to spend >$500 will require multiple issuers to be involved.
|
||||||
database.transaction {
|
database.transaction {
|
||||||
miniCorpServices.fillWithSomeTestCash(howMuch = 100.DOLLARS, atLeastThisManyStates = 1, atMostThisManyStates = 1,
|
miniCorpServices.fillWithSomeTestCash(howMuch = 100.DOLLARS, atLeastThisManyStates = 1, atMostThisManyStates = 1,
|
||||||
ownedBy = OUR_IDENTITY_1, issuedBy = MEGA_CORP.ref(1), issuerServices = megaCorpServices)
|
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 OUR_IDENTITY_1: AbstractParty get() = AnonymousParty(OUR_KEY.public)
|
||||||
|
|
||||||
val THEIR_IDENTITY_1 = AnonymousParty(MINI_CORP_PUBKEY)
|
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) =
|
fun makeCash(amount: Amount<Currency>, corp: Party, depositRef: Byte = 1) =
|
||||||
StateAndRef(
|
StateAndRef(
|
||||||
@ -464,13 +466,13 @@ class CashTests : TestDependencyInjectionBase() {
|
|||||||
/**
|
/**
|
||||||
* Generate an exit transaction, removing some amount of cash from the ledger.
|
* 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)
|
val tx = TransactionBuilder(DUMMY_NOTARY)
|
||||||
Cash().generateExit(tx, Amount(amount.quantity, Issued(corp.ref(depositRef), amount.token)), WALLET)
|
Cash().generateExit(tx, Amount(amount.quantity, Issued(corp.ref(depositRef), amount.token)), WALLET)
|
||||||
return tx.toWireTransaction()
|
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)
|
val tx = TransactionBuilder(DUMMY_NOTARY)
|
||||||
database.transaction {
|
database.transaction {
|
||||||
Cash.generateSpend(miniCorpServices, tx, amount, dest)
|
Cash.generateSpend(miniCorpServices, tx, amount, dest)
|
||||||
@ -627,16 +629,14 @@ class CashTests : TestDependencyInjectionBase() {
|
|||||||
wtx
|
wtx
|
||||||
}
|
}
|
||||||
database.transaction {
|
database.transaction {
|
||||||
@Suppress("UNCHECKED_CAST")
|
val vaultState0: StateAndRef<Cash.State> = vaultStatesUnconsumed.elementAt(0)
|
||||||
val vaultState0 = vaultStatesUnconsumed.elementAt(0)
|
val vaultState1: StateAndRef<Cash.State> = vaultStatesUnconsumed.elementAt(1)
|
||||||
val vaultState1 = vaultStatesUnconsumed.elementAt(1)
|
val vaultState2: StateAndRef<Cash.State> = vaultStatesUnconsumed.elementAt(2)
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val vaultState2 = vaultStatesUnconsumed.elementAt(2)
|
|
||||||
assertEquals(vaultState0.ref, wtx.inputs[0])
|
assertEquals(vaultState0.ref, wtx.inputs[0])
|
||||||
assertEquals(vaultState1.ref, wtx.inputs[1])
|
assertEquals(vaultState1.ref, wtx.inputs[1])
|
||||||
assertEquals(vaultState2.ref, wtx.inputs[2])
|
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(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])
|
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()
|
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