From cedfc4e1ad7cc1ff667bb1ee155f728be031377b Mon Sep 17 00:00:00 2001 From: Matthew Nesbit Date: Tue, 28 Mar 2017 11:41:13 +0100 Subject: [PATCH] Add a concept of token size to Amount so that conversion to/from indicative and displayable BigDecimal works sensibly Add an AmountTransfer type to express the concept of asset flows. Unify the currency amount creators and fix a few old style display conversions in teh explorer cash dialogs. Modifications according to PR comments. Change TransferAmount display string as it may not always be a payment. Update docs --- .../net/corda/core/contracts/ContractsDSL.kt | 15 +- .../net/corda/core/contracts/FinanceTypes.kt | 370 ++++++++++++++++-- .../net/corda/core/contracts/AmountTests.kt | 147 ++++++- docs/source/key-concepts-financial-model.rst | 24 +- .../net/corda/contracts/asset/CashTests.kt | 24 +- .../corda/contracts/asset/ObligationTests.kt | 4 +- .../explorer/formatters/AmountFormatter.kt | 2 +- .../views/cordapps/cash/NewTransaction.kt | 11 +- 8 files changed, 520 insertions(+), 77 deletions(-) diff --git a/core/src/main/kotlin/net/corda/core/contracts/ContractsDSL.kt b/core/src/main/kotlin/net/corda/core/contracts/ContractsDSL.kt index 30eef737c8..129e1452f0 100644 --- a/core/src/main/kotlin/net/corda/core/contracts/ContractsDSL.kt +++ b/core/src/main/kotlin/net/corda/core/contracts/ContractsDSL.kt @@ -4,6 +4,7 @@ package net.corda.core.contracts import net.corda.core.crypto.CompositeKey import net.corda.core.crypto.Party +import java.math.BigDecimal import java.util.* /** @@ -31,11 +32,13 @@ fun commodity(code: String) = Commodity.getInstance(code)!! @JvmField val RUB = currency("RUB") @JvmField val FCOJ = commodity("FCOJ") // Frozen concentrated orange juice, yum! -fun DOLLARS(amount: Int): Amount = Amount(amount.toLong() * 100, USD) -fun DOLLARS(amount: Double): Amount = Amount((amount * 100).toLong(), USD) -fun POUNDS(amount: Int): Amount = Amount(amount.toLong() * 100, GBP) -fun SWISS_FRANCS(amount: Int): Amount = Amount(amount.toLong() * 100, CHF) -fun FCOJ(amount: Int): Amount = Amount(amount.toLong() * 100, FCOJ) +fun AMOUNT(amount: Int, token: T): Amount = Amount.fromDecimal(BigDecimal.valueOf(amount.toLong()), token) +fun AMOUNT(amount: Double, token: T): Amount = Amount.fromDecimal(BigDecimal.valueOf(amount), token) +fun DOLLARS(amount: Int): Amount = AMOUNT(amount, USD) +fun DOLLARS(amount: Double): Amount = AMOUNT(amount, USD) +fun POUNDS(amount: Int): Amount = AMOUNT(amount, GBP) +fun SWISS_FRANCS(amount: Int): Amount = AMOUNT(amount, CHF) +fun FCOJ(amount: Int): Amount = AMOUNT(amount, FCOJ) val Int.DOLLARS: Amount get() = DOLLARS(this) val Double.DOLLARS: Amount get() = DOLLARS(this) @@ -48,7 +51,7 @@ infix fun Commodity.`issued by`(deposit: PartyAndReference) = issuedBy(deposit) infix fun Amount.`issued by`(deposit: PartyAndReference) = issuedBy(deposit) infix fun Currency.issuedBy(deposit: PartyAndReference) = Issued(deposit, this) infix fun Commodity.issuedBy(deposit: PartyAndReference) = Issued(deposit, this) -infix fun Amount.issuedBy(deposit: PartyAndReference) = Amount(quantity, token.issuedBy(deposit)) +infix fun Amount.issuedBy(deposit: PartyAndReference) = Amount(quantity, displayTokenSize, token.issuedBy(deposit)) //// Requirements ///////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/core/src/main/kotlin/net/corda/core/contracts/FinanceTypes.kt b/core/src/main/kotlin/net/corda/core/contracts/FinanceTypes.kt index c8b2f908fb..7649778942 100644 --- a/core/src/main/kotlin/net/corda/core/contracts/FinanceTypes.kt +++ b/core/src/main/kotlin/net/corda/core/contracts/FinanceTypes.kt @@ -11,16 +11,25 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize import com.google.common.annotations.VisibleForTesting import net.corda.core.serialization.CordaSerializable import java.math.BigDecimal -import java.math.BigInteger +import java.math.RoundingMode import java.time.DayOfWeek import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.* +/** + * This interface is used by [Amount] to determine the conversion ratio from + * indicative/displayed asset amounts in [BigDecimal] to fungible tokens represented by Amount objects. + */ +interface TokenizableAssetInfo { + val displayTokenSize: BigDecimal +} + /** * Amount represents a positive quantity of some token (currency, asset, etc.), measured in quantity of the smallest - * representable units. Note that quantity is not necessarily 1/100ths of a currency unit, but are the actual smallest - * amount used in whatever underlying thing the amount represents. + * representable units. The nominal quantity represented by each individual token is equal to the [displayTokenSize]. + * The scale property of the [displayTokenSize] should correctly reflect the displayed decimal places and is used + * when rounding conversions from indicative/displayed amounts in [BigDecimal] to Amount occur via the Amount.fromDecimal method. * * Amounts of different tokens *do not mix* and attempting to add or subtract two amounts of different currencies * will throw [IllegalArgumentException]. Amounts may not be negative. Amounts are represented internally using a signed @@ -28,25 +37,62 @@ import java.util.* * multiplication are overflow checked and will throw [ArithmeticException] if the operation would have caused integer * overflow. * - * TODO: It may make sense to replace this with convenience extensions over the JSR 354 MonetaryAmount interface, - * in particular for use during calculations. This may also resolve... - * TODO: Think about how positive-only vs positive-or-negative amounts can be represented in the type system. - * TODO: Add either a scaling factor, or a variant for use in calculations. - * + * @param quantity the number of tokens as a Long value. + * @param displayTokenSize the nominal display unit size of a single token, + * potentially with trailing decimal display places if the scale parameter is non-zero. * @param T the type of the token, for example [Currency]. + * T should implement TokenizableAssetInfo if automatic conversion to/from a display format is required. + * + * TODO Proper lookup of currencies in a locale and context sensitive fashion is not supported and is left to the application. */ @CordaSerializable -data class Amount(val quantity: Long, val token: T) : Comparable> { +data class Amount(val quantity: Long, val displayTokenSize: BigDecimal,val token: T) : Comparable> { companion object { /** - * Build a currency amount from a decimal representation. For example, with an input of "12.34" GBP, - * returns an amount with a quantity of "1234". + * Build an Amount from a decimal representation. For example, with an input of "12.34 GBP", + * returns an amount with a quantity of "1234" tokens. The displayTokenSize as determined via + * getDisplayTokenSize is used to determine the conversion scaling. + * e.g. Bonds might be in nominal amounts of 100, currencies in 0.01 penny units. * * @see Amount.toDecimal + * @throws ArithmeticException if the intermediate calculations cannot be converted to an unsigned 63-bit token amount. */ - fun fromDecimal(quantity: BigDecimal, currency: Currency) : Amount { - val longQuantity = quantity.movePointRight(currency.defaultFractionDigits).toLong() - return Amount(longQuantity, currency) + @JvmStatic + @JvmOverloads + fun fromDecimal(displayQuantity: BigDecimal, token: T, rounding: RoundingMode = RoundingMode.FLOOR): Amount { + val tokenSize = getDisplayTokenSize(token) + val tokenCount = displayQuantity.divide(tokenSize).setScale(0, rounding).longValueExact() + return Amount(tokenCount, tokenSize, token) + } + + /** + * For a particular token returns a zero sized Amount + */ + @JvmStatic + fun zero(token: T): Amount { + val tokenSize = getDisplayTokenSize(token) + return Amount(0L, tokenSize, token) + } + + + /** + * Determines the representation of one Token quantity in BigDecimal. For Currency and Issued + * the definitions is taken from Currency defaultFractionDigits property e.g. 2 for USD, or 0 for JPY + * so that the automatic token size is the conventional minimum penny amount. + * For other possible token types the asset token should implement TokenizableAssetInfo to + * correctly report the designed nominal amount. + */ + fun getDisplayTokenSize(token: Any): BigDecimal { + if (token is TokenizableAssetInfo) { + return token.displayTokenSize + } + if (token is Currency) { + return BigDecimal.ONE.scaleByPowerOfTen(-token.defaultFractionDigits) + } + if (token is Issued<*>) { + return getDisplayTokenSize(token.product) + } + return BigDecimal.ONE } private val currencySymbols: Map = mapOf( @@ -111,44 +157,93 @@ data class Amount(val quantity: Long, val token: T) : Comparable= 0) { "Negative amounts are not allowed: $quantity" } } /** - * Construct the amount using the given decimal value as quantity. Any fractional part - * is discarded. To convert and use the fractional part, see [fromDecimal]. + * Automatic conversion constructor from number of tokens to an Amount using getDisplayTokenSize to determine + * the displayTokenSize. + * + * @param tokenQuantity the number of tokens represented. + * @param token the type of the token, for example a [Currency] object. */ - constructor(quantity: BigDecimal, token: T) : this(quantity.toLong(), token) - constructor(quantity: BigInteger, token: T) : this(quantity.toLong(), token) + constructor(tokenQuantity: Long, token: T) : this(tokenQuantity, getDisplayTokenSize(token), token) + /** + * A checked addition operator is supported to simplify aggregation of Amounts. + * @throws ArithmeticException if there is overflow of Amount tokens during the summation + * Mixing non-identical token types will throw [IllegalArgumentException] + */ operator fun plus(other: Amount): Amount { checkToken(other) - return Amount(Math.addExact(quantity, other.quantity), token) + return Amount(Math.addExact(quantity, other.quantity), displayTokenSize, token) } + /** + * A checked addition operator is supported to simplify netting of Amounts. + * If this leads to the Amount going negative this will throw [IllegalArgumentException]. + * @throws ArithmeticException if there is Numeric underflow + * Mixing non-identical token types will throw [IllegalArgumentException] + */ operator fun minus(other: Amount): Amount { checkToken(other) - return Amount(Math.subtractExact(quantity, other.quantity), token) + return Amount(Math.subtractExact(quantity, other.quantity), displayTokenSize, token) } private fun checkToken(other: Amount) { require(other.token == token) { "Token mismatch: ${other.token} vs $token" } + require(other.displayTokenSize == displayTokenSize) { "Token size mismatch: ${other.displayTokenSize} vs $displayTokenSize" } } - operator fun div(other: Long): Amount = Amount(quantity / other, token) - operator fun times(other: Long): Amount = Amount(Math.multiplyExact(quantity, other), token) - operator fun div(other: Int): Amount = Amount(quantity / other, token) - operator fun times(other: Int): Amount = Amount(Math.multiplyExact(quantity, other.toLong()), token) + /** + * The multiplication operator is supported to allow easy calculation for multiples of a primitive Amount. + * Note this is not a conserving operation, so it may not always be correct modelling of proper token behaviour. + * N.B. Division is not supported as fractional tokens are not representable by an Amount. + */ + operator fun times(other: Long): Amount = Amount(Math.multiplyExact(quantity, other), displayTokenSize, token) + operator fun times(other: Int): Amount = Amount(Math.multiplyExact(quantity, other.toLong()), displayTokenSize, token) + + /** + * This method provides a token conserving divide mechanism. + * @param partitions the number of amounts to divide the current quantity into. + * @result Returns [partitions] separate Amount objects which sum to the same quantity as this Amount + * and differ by no more than a single token in size. + */ + fun splitEvenly(partitions: Int): List> { + require(partitions >= 1) { "Must split amount into one, or more pieces" } + val commonTokensPerPartition = quantity.div(partitions) + val residualTokens = quantity - (commonTokensPerPartition * partitions) + val splitAmount = Amount(commonTokensPerPartition, displayTokenSize, token) + val splitAmountPlusOne = Amount(commonTokensPerPartition + 1L, displayTokenSize, token) + return (0..partitions - 1).map { if (it < residualTokens) splitAmountPlusOne else splitAmount }.toList() + } + + /** + * Convert a currency [Amount] to a decimal representation. For example, with an amount with a quantity + * of "1234" GBP, returns "12.34". The precise representation is controlled by the displayTokenSize, + * which determines the size of a single token and controls the trailing decimal places via it's scale property. + * + * @see Amount.Companion.fromDecimal + */ + fun toDecimal(): BigDecimal = BigDecimal.valueOf(quantity, 0) * displayTokenSize + + + /** + * Convert a currency [Amount] to a display string representation. + * + * For example, with an amount with a quantity of "1234" GBP, returns "12.34 GBP". + * The result of fromDecimal is used to control the numerical formatting and + * the token specifier appended is taken from token.toString. + * + * @see Amount.Companion.fromDecimal + */ override fun toString(): String { - val bd = if (token is Currency) - BigDecimal(quantity).movePointLeft(token.defaultFractionDigits) - else - BigDecimal(quantity) - return bd.toPlainString() + " " + token + return toDecimal().toPlainString() + " " + token } override fun compareTo(other: Amount): Int { @@ -157,17 +252,207 @@ data class Amount(val quantity: Long, val token: T) : Comparable.toDecimal() : BigDecimal = BigDecimal(quantity).movePointLeft(token.defaultFractionDigits) + fun Iterable>.sumOrNull() = if (!iterator().hasNext()) null else sumOrThrow() fun Iterable>.sumOrThrow() = reduce { left, right -> left + right } -fun Iterable>.sumOrZero(currency: T) = if (iterator().hasNext()) sumOrThrow() else Amount(0, currency) +fun Iterable>.sumOrZero(token: T) = if (iterator().hasNext()) sumOrThrow() else Amount.zero(token) + + +/** + * Simple data class to associate the origin, owner, or holder of a particular Amount object. + * @param source the holder of the Amount. + * @param amount the Amount of asset available. + * @param ref is an optional field used for housekeeping in the caller. + * e.g. to point back at the original Vault state objects. + * @see SourceAndAmount.apply which processes a list of SourceAndAmount objects + * and calculates the resulting Amount distribution as a new list of SourceAndAmount objects. + */ +data class SourceAndAmount(val source: P, val amount: Amount, val ref: Any? = null) + +/** + * This class represents a possibly negative transfer of tokens from one vault state to another, possibly at a future date. + * + * @param quantityDelta is a signed Long value representing the exchanged number of tokens. If positive then + * it represents the movement of Math.abs(quantityDelta) tokens away from source and receipt of Math.abs(quantityDelta) + * at the destination. If the quantityDelta is negative then the source will receive Math.abs(quantityDelta) tokens + * and the destination will lose Math.abs(quantityDelta) tokens. + * Where possible the source and destination should be coded to ensure a positive quantityDelta, + * but in various scenarios it may be more consistent to allow positive and negative values. + * For example it is common for a bank to code asset flows as gains and losses from its perspective i.e. always the destination. + * @param token represents the type of asset token as would be used to construct Amount objects. + * @param source is the [Party], [Account], [CompositeKey], or other identifier of the token source if quantityDelta is positive, + * or the token sink if quantityDelta is negative. The type P should support value equality. + * @param destination is the [Party], [Account], [CompositeKey], or other identifier of the token sink if quantityDelta is positive, + * or the token source if quantityDelta is negative. The type P should support value equality. + */ +@CordaSerializable +class AmountTransfer(val quantityDelta: Long, + val token: T, + val source: P, + val destination: P) { + companion object { + /** + * Construct an AmountTransfer object from an indicative/displayable BigDecimal source, applying rounding as specified. + * The token size is determined from the token type and is the same as for [Amount] of the same token. + * @param displayQuantityDelta is the signed amount to transfer between source and destination in displayable units. + * Positive values mean transfers from source to destination. Negative values mean transfers from destination to source. + * @param token defines the asset being represented in the transfer. The token should implement [TokenizableAssetInfo] if custom + * conversion logic is required. + * @param source The payer of the transfer if displayQuantityDelta is positive, the payee if displayQuantityDelta is negative + * @param destination The payee of the transfer if displayQuantityDelta is positive, the payer if displayQuantityDelta is negative + * @param rounding The mode of rounding to apply after scaling to integer token units. + */ + @JvmStatic + @JvmOverloads + fun fromDecimal(displayQuantityDelta: BigDecimal, + token: T, + source: P, + destination: P, + rounding: RoundingMode = RoundingMode.DOWN): AmountTransfer { + val tokenSize = Amount.getDisplayTokenSize(token) + val deltaTokenCount = displayQuantityDelta.divide(tokenSize).setScale(0, rounding).longValueExact() + return AmountTransfer(deltaTokenCount, token, source, destination) + } + + /** + * Helper to make a zero size AmountTransfer + */ + @JvmStatic + fun zero(token: T, + source: P, + destination: P): AmountTransfer = AmountTransfer(0L, token, source, destination) + } + + init { + require(source != destination) { "The source and destination cannot be the same ($source)" } + } + + /** + * Add together two [AmountTransfer] objects to produce the single equivalent net flow. + * The addition only applies to AmountTransfer objects with the same token type. + * Also the pair of parties must be aligned, although source destination may be + * swapped in the second item. + * @throws ArithmeticException if there is underflow, or overflow in the summations. + */ + operator fun plus(other: AmountTransfer): AmountTransfer { + require(other.token == token) { "Token mismatch: ${other.token} vs $token" } + require((other.source == source && other.destination == destination) + || (other.source == destination && other.destination == source)) { + "Only AmountTransfer between the same two parties can be aggregated/netted" + } + return if (other.source == source) { + AmountTransfer(Math.addExact(quantityDelta, other.quantityDelta), token, source, destination) + } else { + AmountTransfer(Math.subtractExact(quantityDelta, other.quantityDelta), token, source, destination) + } + } + + /** + * Convert the quantityDelta to a displayable format BigDecimal value. The conversion ratio is the same as for + * [Amount] of the same token type. + */ + fun toDecimal(): BigDecimal = BigDecimal.valueOf(quantityDelta, 0) * Amount.getDisplayTokenSize(token) + + fun copy(quantityDelta: Long = this.quantityDelta, + token: T = this.token, + source: P = this.source, + destination: P = this.destination): AmountTransfer = AmountTransfer(quantityDelta, token, source, destination) + + /** + * Checks value equality of AmountTransfer objects, but also matches the reversed source and destination equivalent. + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other?.javaClass != javaClass) return false + + other as AmountTransfer<*, *> + + if (token != other.token) return false + if (source == other.source) { + if (destination != other.destination) return false + if (quantityDelta != other.quantityDelta) return false + return true + } else if (source == other.destination) { + if (destination != other.source) return false + if (quantityDelta != -other.quantityDelta) return false + return true + } + + return false + } + + /** + * HashCode ensures that reversed source and destination equivalents will hash to the same value. + */ + override fun hashCode(): Int { + var result = Math.abs(quantityDelta).hashCode() // ignore polarity reversed values + result = 31 * result + token.hashCode() + result = 31 * result + (source.hashCode() xor destination.hashCode()) // XOR to ensure the same hash for swapped source and destination + return result + } + + override fun toString(): String { + return "Transfer from $source to $destination of ${this.toDecimal().toPlainString()} $token" + } + + /** + * Novation is a common financial operation in which a bilateral exchange is modified so that the same + * relative asset exchange happens, but with each party exchanging versus a central counterparty, or clearing house. + * + * @param centralParty The central party to face the exchange against. + * @return Returns two new AmountTransfers each between one of the original parties and the centralParty. + * The net total exchange is the same as in the original input. + */ + fun novate(centralParty: P): Pair, AmountTransfer> = Pair(copy(destination = centralParty), copy(source = centralParty)) + + /** + * Applies this AmountTransfer to a list of [SourceAndAmount] objects representing balances. + * The list can be heterogeneous in terms of token types and parties, so long as there is sufficient balance + * of the correct token type held with the party paying for the transfer. + * @param balances The source list of [SourceAndAmount] objects containing the funds to satisfy the exchange. + * @param newRef An optional marker object which is attached to any new [SourceAndAmount] objects created in the output. + * i.e. To the new payment destination entry and to any residual change output. + * @return The returned list is a copy of the original list, except that funds needed to cover the exchange + * will have been removed and a new output and possibly residual amount entry will be added at the end of the list. + * @throws ArithmeticException if there is underflow in the summations. + */ + fun apply(balances: List>, newRef: Any? = null): List> { + val (payer, payee) = if (quantityDelta >= 0L) Pair(source, destination) else Pair(destination, source) + val transfer = Math.abs(quantityDelta) + var residual = transfer + val outputs = mutableListOf>() + var remaining: SourceAndAmount? = null + var newAmount: SourceAndAmount? = null + for (balance in balances) { + if (balance.source != payer + || balance.amount.token != token + || residual == 0L) { + // Just copy across unmodified. + outputs += balance + } else if (balance.amount.quantity < residual) { + // Consume the payers amount and do not copy across. + residual -= balance.amount.quantity + } else { + // Calculate any residual spend left on the payers balance. + if (balance.amount.quantity > residual) { + remaining = SourceAndAmount(payer, balance.amount.copy(quantity = Math.subtractExact(balance.amount.quantity, residual)), newRef) + } + // Build the new output payment to the payee. + newAmount = SourceAndAmount(payee, balance.amount.copy(quantity = transfer), newRef) + // Clear the residual. + residual = 0L + } + } + require(residual == 0L) { "Insufficient funds. Unable to process $this" } + if (remaining != null) { + outputs += remaining + } + outputs += newAmount!! + return outputs + } +} + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // @@ -546,7 +831,10 @@ enum class NetType { @CordaSerializable data class Commodity(val commodityCode: String, val displayName: String, - val defaultFractionDigits: Int = 0) { + val defaultFractionDigits: Int = 0) : TokenizableAssetInfo { + override val displayTokenSize: BigDecimal + get() = BigDecimal.ONE.scaleByPowerOfTen(-defaultFractionDigits) + companion object { private val registry = mapOf( // Simple example commodity, as in http://www.investopedia.com/university/commodities/commodities14.asp diff --git a/core/src/test/kotlin/net/corda/core/contracts/AmountTests.kt b/core/src/test/kotlin/net/corda/core/contracts/AmountTests.kt index 59c15ff46c..81c0b5f9c8 100644 --- a/core/src/test/kotlin/net/corda/core/contracts/AmountTests.kt +++ b/core/src/test/kotlin/net/corda/core/contracts/AmountTests.kt @@ -2,7 +2,12 @@ package net.corda.core.contracts import org.junit.Test import java.math.BigDecimal +import java.util.* +import java.util.stream.Collectors import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue /** * Tests of the [Amount] class. @@ -18,10 +23,24 @@ class AmountTests { @Test fun decimalConversion() { val quantity = 1234L - val amount = Amount(quantity, GBP) - val expected = BigDecimal("12.34") - assertEquals(expected, amount.toDecimal()) - assertEquals(amount, Amount.fromDecimal(amount.toDecimal(), amount.token)) + val amountGBP = Amount(quantity, GBP) + val expectedGBP = BigDecimal("12.34") + assertEquals(expectedGBP, amountGBP.toDecimal()) + assertEquals(amountGBP, Amount.fromDecimal(amountGBP.toDecimal(), amountGBP.token)) + val amountJPY = Amount(quantity, JPY) + val expectedJPY = BigDecimal("1234") + assertEquals(expectedJPY, amountJPY.toDecimal()) + assertEquals(amountJPY, Amount.fromDecimal(amountJPY.toDecimal(), amountJPY.token)) + val testAsset = TestAsset("GB0009997999") + val amountBond = Amount(quantity, testAsset) + val expectedBond = BigDecimal("123400") + assertEquals(expectedBond, amountBond.toDecimal()) + assertEquals(amountBond, Amount.fromDecimal(amountBond.toDecimal(), amountBond.token)) + } + + data class TestAsset(val name: String) : TokenizableAssetInfo { + override val displayTokenSize: BigDecimal = BigDecimal("100") + override fun toString(): String = name } @Test @@ -39,4 +58,124 @@ class AmountTests { assertEquals("5000 JPY", Amount.parseCurrency("¥5000").toString()) assertEquals("50.12 USD", Amount.parseCurrency("$50.12").toString()) } + + @Test + fun split() { + for (baseQuantity in 0..1000) { + val baseAmount = Amount(baseQuantity.toLong(), GBP) + for (partitionCount in 1..100) { + val splits = baseAmount.splitEvenly(partitionCount) + assertEquals(partitionCount, splits.size) + assertEquals(baseAmount, splits.sumOrZero(baseAmount.token)) + val min = splits.min()!! + val max = splits.max()!! + assertTrue(max.quantity - min.quantity <= 1L, "Amount quantities should differ by at most one token") + } + } + } + + @Test + fun amountTransfersEquality() { + val partyA = "A" + val partyB = "B" + val partyC = "C" + val baseSize = BigDecimal("123.45") + val transferA = AmountTransfer.fromDecimal(baseSize, GBP, partyA, partyB) + assertEquals(baseSize, transferA.toDecimal()) + val transferB = AmountTransfer.fromDecimal(baseSize.negate(), GBP, partyB, partyA) + assertEquals(baseSize.negate(), transferB.toDecimal()) + val transferC = AmountTransfer.fromDecimal(BigDecimal("123.40"), GBP, partyA, partyB) + val transferD = AmountTransfer.fromDecimal(baseSize, USD, partyA, partyB) + val transferE = AmountTransfer.fromDecimal(baseSize, GBP, partyA, partyC) + assertEquals(transferA, transferA) + assertEquals(transferA.hashCode(), transferA.hashCode()) + assertEquals(transferA, transferB) + assertEquals(transferA.hashCode(), transferB.hashCode()) + assertNotEquals(transferC, transferA) + assertNotEquals(transferC.hashCode(), transferA.hashCode()) + assertNotEquals(transferD, transferA) + assertNotEquals(transferD.hashCode(), transferA.hashCode()) + assertNotEquals(transferE, transferA) + assertNotEquals(transferE.hashCode(), transferA.hashCode()) + } + + @Test + fun amountTransferAggregation() { + val partyA = "A" + val partyB = "B" + val partyC = "C" + val baseSize = BigDecimal("123.45") + val simpleTransfer = AmountTransfer.fromDecimal(baseSize, GBP, partyA, partyB) + val flippedTransfer = AmountTransfer.fromDecimal(baseSize.negate(), GBP, partyB, partyA) + val doubleSizeTransfer = AmountTransfer.fromDecimal(baseSize.multiply(BigDecimal("2")), GBP, partyA, partyB) + val differentTokenTransfer = AmountTransfer.fromDecimal(baseSize, USD, partyA, partyB) + val differentPartyTransfer = AmountTransfer.fromDecimal(baseSize, GBP, partyA, partyC) + val negativeTransfer = AmountTransfer.fromDecimal(baseSize.negate(), GBP, partyA, partyB) + val zeroTransfer = AmountTransfer.zero(GBP, partyA, partyB) + val sumFlipped1 = simpleTransfer + flippedTransfer + val sumFlipped2 = flippedTransfer + simpleTransfer + assertEquals(doubleSizeTransfer, sumFlipped1) + assertEquals(doubleSizeTransfer, sumFlipped2) + assertFailsWith(IllegalArgumentException::class) { + simpleTransfer + differentTokenTransfer + } + assertFailsWith(IllegalArgumentException::class) { + simpleTransfer + differentPartyTransfer + } + val sumsToZero = simpleTransfer + negativeTransfer + assertEquals(zeroTransfer, sumsToZero) + val sumFlippedToZero = flippedTransfer + negativeTransfer + assertEquals(zeroTransfer, sumFlippedToZero) + val sumUntilNegative = (flippedTransfer + negativeTransfer) + negativeTransfer + assertEquals(negativeTransfer, sumUntilNegative) + } + + @Test + fun amountTransferApply() { + val partyA = "A" + val partyB = "B" + val partyC = "C" + val sourceAccounts = listOf( + SourceAndAmount(partyA, DOLLARS(123), 1), + SourceAndAmount(partyB, DOLLARS(100), 2), + SourceAndAmount(partyC, DOLLARS(123), 3), + SourceAndAmount(partyB, DOLLARS(100), 4), + SourceAndAmount(partyA, POUNDS(256), 5), + SourceAndAmount(partyB, POUNDS(256), 6) + ) + val collector = Collectors.toMap, Pair, BigDecimal>({ Pair(it.source, it.amount.token)}, {it.amount.toDecimal()}, { x,y -> x + y}) + val originalTotals = sourceAccounts.stream().collect(collector) + + val smallTransfer = AmountTransfer.fromDecimal(BigDecimal("10"), USD, partyA, partyB) + val accountsAfterSmallTransfer = smallTransfer.apply(sourceAccounts, 10) + val newTotals = accountsAfterSmallTransfer.stream().collect(collector) + assertEquals(originalTotals[Pair(partyA, USD)]!! - BigDecimal("10.00"), newTotals[Pair(partyA, USD)]) + assertEquals(originalTotals[Pair(partyB, USD)]!! + BigDecimal("10.00"), newTotals[Pair(partyB, USD)]) + assertEquals(originalTotals[Pair(partyC, USD)], newTotals[Pair(partyC, USD)]) + assertEquals(originalTotals[Pair(partyA, GBP)], newTotals[Pair(partyA, GBP)]) + assertEquals(originalTotals[Pair(partyB, GBP)], newTotals[Pair(partyB, GBP)]) + + val largeTransfer = AmountTransfer.fromDecimal(BigDecimal("150"), USD, partyB, partyC) + val accountsAfterLargeTransfer = largeTransfer.apply(sourceAccounts, 10) + val newTotals2 = accountsAfterLargeTransfer.stream().collect(collector) + assertEquals(originalTotals[Pair(partyA, USD)], newTotals2[Pair(partyA, USD)]) + assertEquals(originalTotals[Pair(partyB, USD)]!! - BigDecimal("150.00"), newTotals2[Pair(partyB, USD)]) + assertEquals(originalTotals[Pair(partyC, USD)]!! + BigDecimal("150.00"), newTotals2[Pair(partyC, USD)]) + assertEquals(originalTotals[Pair(partyA, GBP)], newTotals2[Pair(partyA, GBP)]) + assertEquals(originalTotals[Pair(partyB, GBP)], newTotals2[Pair(partyB, GBP)]) + + val tooLargeTransfer = AmountTransfer.fromDecimal(BigDecimal("150"), USD, partyA, partyB) + assertFailsWith(IllegalArgumentException::class) { + tooLargeTransfer.apply(sourceAccounts) + } + val emptyingTransfer = AmountTransfer.fromDecimal(BigDecimal("123"), USD, partyA, partyB) + val accountsAfterEmptyingTransfer = emptyingTransfer.apply(sourceAccounts, 10) + val newTotals3 = accountsAfterEmptyingTransfer.stream().collect(collector) + assertEquals(null, newTotals3[Pair(partyA, USD)]) + assertEquals(originalTotals[Pair(partyB, USD)]!! + BigDecimal("123.00"), newTotals3[Pair(partyB, USD)]) + assertEquals(originalTotals[Pair(partyC, USD)], newTotals3[Pair(partyC, USD)]) + assertEquals(originalTotals[Pair(partyA, GBP)], newTotals3[Pair(partyA, GBP)]) + assertEquals(originalTotals[Pair(partyB, GBP)], newTotals3[Pair(partyB, GBP)]) + + } } \ No newline at end of file diff --git a/docs/source/key-concepts-financial-model.rst b/docs/source/key-concepts-financial-model.rst index ca35a0cd64..f43a5b282d 100644 --- a/docs/source/key-concepts-financial-model.rst +++ b/docs/source/key-concepts-financial-model.rst @@ -11,7 +11,8 @@ The `Amount `_ class is used to fungible asset. It is a generic class which wraps around a type used to define the underlying product, called the *token*. For instance it can be the standard JDK type ``Currency``, or an ``Issued`` instance, or this can be a more complex type such as an obligation contract issuance definition (which in turn contains a token definition -for whatever the obligation is to be settled in). +for whatever the obligation is to be settled in). Custom token types should implement ``TokenizableAssetInfo`` to allow the +``Amount`` conversion helpers ``fromDecimal`` and ``toDecimal`` to calculate the correct ``displayTokenSize``. .. note:: Fungible is used here to mean that instances of an asset is interchangeable for any other identical instance, and that they can be split/merged. For example a £5 note can reasonably be exchanged for any other £5 note, and @@ -30,18 +31,27 @@ Here are some examples: // A quantity of a product governed by specific obligation terms Amount> -``Amount`` represents quantities as integers. For currencies the quantity represents pennies, cents or whatever -else the smallest integer amount for that currency is. You cannot use ``Amount`` to represent negative quantities -or fractional quantities: if you wish to do this then you must use a different type e.g. ``BigDecimal``. ``Amount`` -defines methods to do addition and subtraction and these methods verify that the tokens on both sides of the operator -are equal (these are operator overloads in Kotlin and can be used as regular methods from Java). There are also -methods to do multiplication and division by integer amounts. +``Amount`` represents quantities as integers. You cannot use ``Amount`` to represent negative quantities, +or fractional quantities: if you wish to do this then you must use a different type, typically ``BigDecimal``. +For currencies the quantity represents pennies, cents, or whatever else is the smallest integer amount for that currency, +but for other assets it might mean anything e.g. 1000 tonnes of coal, or kilowatt-hours. The precise conversion ratio +to displayable amounts is via the ``displayTokenSize`` property, which is the ``BigDecimal`` numeric representation of +a single token as it would be written. ``Amount`` also defines methods to do overflow/underflow checked addition and subtraction +(these are operator overloads in Kotlin and can be used as regular methods from Java). More complex calculations should typically +be done in ``BigDecimal`` and converted back to ensure due consideration of rounding and to ensure token conservation. ``Issued`` refers to a product (which can be cash, a cash-like thing, assets, or generally anything else that's quantifiable with integer quantities) and an associated ``PartyAndReference`` that describes the issuer of that contract. An issued product typically follows a lifecycle which includes issuance, movement and exiting from the ledger (for example, see the ``Cash`` contract and its associated *state* and *commands*) +To represent movements of ``Amount`` tokens use the ``AmountTransfer`` type, which records the quantity and perspective +of a transfer. Positive values will indicate a movement of tokens from a ``source`` e.g. a ``Party``, or ``CompositeKey`` +to a ``destination``. Negative values can be used to indicate a retrograde motion of tokens from ``destination`` +to ``source``. ``AmountTransfer`` supports addition (as a Kotlin operator, or Java method) to provide netting +and aggregation of flows. The ``apply`` method can be used to process a list of attributed ``Amount`` objects in a +``List`` to carry out the actual transfer. + Financial states ---------------- In additional to the common state types, a number of interfaces extend ``ContractState`` to model financial state such as: diff --git a/finance/src/test/kotlin/net/corda/contracts/asset/CashTests.kt b/finance/src/test/kotlin/net/corda/contracts/asset/CashTests.kt index 7fccd4e586..9ecdfe7e2d 100644 --- a/finance/src/test/kotlin/net/corda/contracts/asset/CashTests.kt +++ b/finance/src/test/kotlin/net/corda/contracts/asset/CashTests.kt @@ -205,7 +205,7 @@ class CashTests { // Can't use an issue command to lower the amount. transaction { input { inState } - output { inState.copy(amount = inState.amount / 2) } + output { inState.copy(amount = inState.amount.splitEvenly(2).first()) } command(MEGA_CORP_PUBKEY) { Cash.Commands.Issue() } this `fails with` "output values sum to more than the inputs" } @@ -232,7 +232,7 @@ class CashTests { this `fails with` "The following commands were not matched at the end of execution" } tweak { - command(MEGA_CORP_PUBKEY) { Cash.Commands.Exit(inState.amount / 2) } + command(MEGA_CORP_PUBKEY) { Cash.Commands.Exit(inState.amount.splitEvenly(2).first()) } this `fails with` "The following commands were not matched at the end of execution" } this.verifies() @@ -265,20 +265,22 @@ class CashTests { command(DUMMY_PUBKEY_1) { Cash.Commands.Move() } tweak { input { inState } - for (i in 1..4) output { inState.copy(amount = inState.amount / 4) } + val splits4 = inState.amount.splitEvenly(4) + for (i in 0..3) output { inState.copy(amount = splits4[i]) } this.verifies() } // Merging 4 inputs into 2 outputs works. tweak { - for (i in 1..4) input { inState.copy(amount = inState.amount / 4) } - output { inState.copy(amount = inState.amount / 2) } - output { inState.copy(amount = inState.amount / 2) } + val splits2 = inState.amount.splitEvenly(2) + val splits4 = inState.amount.splitEvenly(4) + for (i in 0..3) input { inState.copy(amount = splits4[i]) } + for (i in 0..1) output { inState.copy(amount = splits2[i]) } this.verifies() } // Merging 2 inputs into 1 works. tweak { - input { inState.copy(amount = inState.amount / 2) } - input { inState.copy(amount = inState.amount / 2) } + val splits2 = inState.amount.splitEvenly(2) + for (i in 0..1) input { inState.copy(amount = splits2[i]) } output { inState } this.verifies() } @@ -310,9 +312,9 @@ class CashTests { } // Can't change deposit reference when splitting. transaction { + val splits2 = inState.amount.splitEvenly(2) input { inState } - output { outState.copy(amount = inState.amount / 2).editDepositRef(0) } - output { outState.copy(amount = inState.amount / 2).editDepositRef(1) } + for (i in 0..1) output { outState.copy(amount = splits2[i]).editDepositRef(i.toByte()) } this `fails with` "the amounts balance" } // Can't mix currencies. @@ -513,7 +515,7 @@ class CashTests { 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) + assertEquals(WALLET[0].state.data.copy(amount = WALLET[0].state.data.amount.splitEvenly(2).first()), wtx.outputs[0].data) } /** diff --git a/finance/src/test/kotlin/net/corda/contracts/asset/ObligationTests.kt b/finance/src/test/kotlin/net/corda/contracts/asset/ObligationTests.kt index 59671b79a9..299373871a 100644 --- a/finance/src/test/kotlin/net/corda/contracts/asset/ObligationTests.kt +++ b/finance/src/test/kotlin/net/corda/contracts/asset/ObligationTests.kt @@ -183,7 +183,7 @@ class ObligationTests { this `fails with` "The following commands were not matched at the end of execution" } tweak { - command(MEGA_CORP_PUBKEY) { Obligation.Commands.Exit(inState.amount / 2) } + command(MEGA_CORP_PUBKEY) { Obligation.Commands.Exit(inState.amount.splitEvenly(2).first()) } this `fails with` "The following commands were not matched at the end of execution" } this.verifies() @@ -368,7 +368,7 @@ class ObligationTests { transaction("Issuance") { input("Alice's $1,000,000 obligation to Bob") input("Bob's $1,000,000 obligation to Alice") - output("change") { (oneMillionDollars / 2).OBLIGATION between Pair(ALICE, BOB_PUBKEY) } + output("change") { (oneMillionDollars.splitEvenly(2).first()).OBLIGATION between Pair(ALICE, BOB_PUBKEY) } command(BOB_PUBKEY) { Obligation.Commands.Net(NetType.CLOSE_OUT) } timestamp(TEST_TX_TIME) this `fails with` "amounts owed on input and output must match" diff --git a/tools/explorer/src/main/kotlin/net/corda/explorer/formatters/AmountFormatter.kt b/tools/explorer/src/main/kotlin/net/corda/explorer/formatters/AmountFormatter.kt index d6d1b0a4c0..8250ac2724 100644 --- a/tools/explorer/src/main/kotlin/net/corda/explorer/formatters/AmountFormatter.kt +++ b/tools/explorer/src/main/kotlin/net/corda/explorer/formatters/AmountFormatter.kt @@ -12,6 +12,6 @@ import java.util.* object AmountFormatter { // TODO replace this once we settled on how we do formatting val boring = object : Formatter> { - override fun format(value: Amount) = "${value.quantity} ${value.token}" + override fun format(value: Amount) = value.toString() } } diff --git a/tools/explorer/src/main/kotlin/net/corda/explorer/views/cordapps/cash/NewTransaction.kt b/tools/explorer/src/main/kotlin/net/corda/explorer/views/cordapps/cash/NewTransaction.kt index 6da979b3a7..91e6d3715c 100644 --- a/tools/explorer/src/main/kotlin/net/corda/explorer/views/cordapps/cash/NewTransaction.kt +++ b/tools/explorer/src/main/kotlin/net/corda/explorer/views/cordapps/cash/NewTransaction.kt @@ -18,6 +18,7 @@ import net.corda.client.jfx.utils.isNotNull import net.corda.client.jfx.utils.map import net.corda.client.jfx.utils.unique import net.corda.core.contracts.Amount +import net.corda.core.contracts.sumOrNull import net.corda.core.contracts.withoutIssuer import net.corda.core.crypto.AbstractParty import net.corda.core.crypto.Party @@ -144,10 +145,10 @@ class NewTransaction : Fragment() { when (it) { executeButton -> when (transactionTypeCB.value) { CashTransaction.Issue -> { - CashFlowCommand.IssueCash(Amount(amount.value, currencyChoiceBox.value), issueRef, partyBChoiceBox.value.legalIdentity, notaries.first().notaryIdentity) + CashFlowCommand.IssueCash(Amount.fromDecimal(amount.value, currencyChoiceBox.value), issueRef, partyBChoiceBox.value.legalIdentity, notaries.first().notaryIdentity) } - CashTransaction.Pay -> CashFlowCommand.PayCash(Amount(amount.value, currencyChoiceBox.value), partyBChoiceBox.value.legalIdentity) - CashTransaction.Exit -> CashFlowCommand.ExitCash(Amount(amount.value, currencyChoiceBox.value), issueRef) + CashTransaction.Pay -> CashFlowCommand.PayCash(Amount.fromDecimal(amount.value, currencyChoiceBox.value), partyBChoiceBox.value.legalIdentity) + CashTransaction.Exit -> CashFlowCommand.ExitCash(Amount.fromDecimal(amount.value, currencyChoiceBox.value), issueRef) else -> null } else -> null @@ -208,8 +209,8 @@ class NewTransaction : Fragment() { availableAmount.textProperty() .bind(Bindings.createStringBinding({ val filteredCash = cash.filtered { it.token.issuer.party as AbstractParty == issuer.value && it.token.product == currencyChoiceBox.value } - .map { it.withoutIssuer().quantity } - "${filteredCash.sum()} ${currencyChoiceBox.value?.currencyCode} Available" + .map { it.withoutIssuer() }.sumOrNull() + "${filteredCash ?: "None"} Available" }, arrayOf(currencyChoiceBox.valueProperty(), issuerChoiceBox.valueProperty()))) // Amount amountLabel.visibleProperty().bind(transactionTypeCB.valueProperty().isNotNull)