Add a concept of token size to Amount<T> 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
This commit is contained in:
Matthew Nesbit 2017-03-28 11:41:13 +01:00
parent f116c02149
commit cedfc4e1ad
8 changed files with 520 additions and 77 deletions

View File

@ -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<Currency> = Amount(amount.toLong() * 100, USD)
fun DOLLARS(amount: Double): Amount<Currency> = Amount((amount * 100).toLong(), USD)
fun POUNDS(amount: Int): Amount<Currency> = Amount(amount.toLong() * 100, GBP)
fun SWISS_FRANCS(amount: Int): Amount<Currency> = Amount(amount.toLong() * 100, CHF)
fun FCOJ(amount: Int): Amount<Commodity> = Amount(amount.toLong() * 100, FCOJ)
fun <T: Any>AMOUNT(amount: Int, token: T): Amount<T> = Amount.fromDecimal(BigDecimal.valueOf(amount.toLong()), token)
fun <T: Any>AMOUNT(amount: Double, token: T): Amount<T> = Amount.fromDecimal(BigDecimal.valueOf(amount), token)
fun DOLLARS(amount: Int): Amount<Currency> = AMOUNT(amount, USD)
fun DOLLARS(amount: Double): Amount<Currency> = AMOUNT(amount, USD)
fun POUNDS(amount: Int): Amount<Currency> = AMOUNT(amount, GBP)
fun SWISS_FRANCS(amount: Int): Amount<Currency> = AMOUNT(amount, CHF)
fun FCOJ(amount: Int): Amount<Commodity> = AMOUNT(amount, FCOJ)
val Int.DOLLARS: Amount<Currency> get() = DOLLARS(this)
val Double.DOLLARS: Amount<Currency> get() = DOLLARS(this)
@ -48,7 +51,7 @@ infix fun Commodity.`issued by`(deposit: PartyAndReference) = issuedBy(deposit)
infix fun Amount<Currency>.`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<Currency>.issuedBy(deposit: PartyAndReference) = Amount(quantity, token.issuedBy(deposit))
infix fun Amount<Currency>.issuedBy(deposit: PartyAndReference) = Amount(quantity, displayTokenSize, token.issuedBy(deposit))
//// Requirements /////////////////////////////////////////////////////////////////////////////////////////////////////

View File

@ -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<T: Any>(val quantity: Long, val token: T) : Comparable<Amount<T>> {
data class Amount<T: Any>(val quantity: Long, val displayTokenSize: BigDecimal,val token: T) : Comparable<Amount<T>> {
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<Currency>.toDecimal
* @throws ArithmeticException if the intermediate calculations cannot be converted to an unsigned 63-bit token amount.
*/
fun fromDecimal(quantity: BigDecimal, currency: Currency) : Amount<Currency> {
val longQuantity = quantity.movePointRight(currency.defaultFractionDigits).toLong()
return Amount(longQuantity, currency)
@JvmStatic
@JvmOverloads
fun <T : Any> fromDecimal(displayQuantity: BigDecimal, token: T, rounding: RoundingMode = RoundingMode.FLOOR): Amount<T> {
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<T>
*/
@JvmStatic
fun <T : Any> zero(token: T): Amount<T> {
val tokenSize = getDisplayTokenSize(token)
return Amount(0L, tokenSize, token)
}
/**
* Determines the representation of one Token quantity in BigDecimal. For Currency and Issued<Currency>
* 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<String, Currency> = mapOf(
@ -111,44 +157,93 @@ data class Amount<T: Any>(val quantity: Long, val token: T) : Comparable<Amount<
}
init {
// Negative amounts are of course a vital part of any ledger, but negative values are only valid in certain
// contexts: you cannot send a negative amount of cash, but you can (sometimes) have a negative balance.
// If you want to express a negative amount, for now, use a long.
// Amount represents a static balance of physical assets as managed by the distributed ledger and is not allowed
// to become negative a rule further maintained by the Contract verify method.
// N.B. If concepts such as an account overdraft are required this should be modelled separately via Obligations,
// or similar second order smart contract concepts.
require(quantity >= 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<T>): Amount<T> {
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<T>): Amount<T> {
checkToken(other)
return Amount(Math.subtractExact(quantity, other.quantity), token)
return Amount(Math.subtractExact(quantity, other.quantity), displayTokenSize, token)
}
private fun checkToken(other: Amount<T>) {
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<T> = Amount(quantity / other, token)
operator fun times(other: Long): Amount<T> = Amount(Math.multiplyExact(quantity, other), token)
operator fun div(other: Int): Amount<T> = Amount(quantity / other, token)
operator fun times(other: Int): Amount<T> = 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<T> = Amount(Math.multiplyExact(quantity, other), displayTokenSize, token)
operator fun times(other: Int): Amount<T> = 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<Amount<T>> {
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<T>): Int {
@ -157,17 +252,207 @@ data class Amount<T: Any>(val quantity: Long, val token: T) : Comparable<Amount<
}
}
/**
* Convert a currency [Amount] to a decimal representation. For example, with an amount with a quantity
* of "1234" GBP, returns "12.34".
*
* @see Amount.Companion.fromDecimal
*/
fun Amount<Currency>.toDecimal() : BigDecimal = BigDecimal(quantity).movePointLeft(token.defaultFractionDigits)
fun <T: Any> Iterable<Amount<T>>.sumOrNull() = if (!iterator().hasNext()) null else sumOrThrow()
fun <T: Any> Iterable<Amount<T>>.sumOrThrow() = reduce { left, right -> left + right }
fun <T: Any> Iterable<Amount<T>>.sumOrZero(currency: T) = if (iterator().hasNext()) sumOrThrow() else Amount(0, currency)
fun <T: Any> Iterable<Amount<T>>.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<T : Any, P : Any>(val source: P, val amount: Amount<T>, 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<T> 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<T : Any, P : Any>(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 <T : Any, P : Any> fromDecimal(displayQuantityDelta: BigDecimal,
token: T,
source: P,
destination: P,
rounding: RoundingMode = RoundingMode.DOWN): AmountTransfer<T, P> {
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 <T : Any, P : Any> zero(token: T,
source: P,
destination: P): AmountTransfer<T, P> = 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<T, P>): AmountTransfer<T, P> {
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<T, P> = 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<T, P>, AmountTransfer<T, P>> = 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<SourceAndAmount<T, P>>, newRef: Any? = null): List<SourceAndAmount<T, P>> {
val (payer, payee) = if (quantityDelta >= 0L) Pair(source, destination) else Pair(destination, source)
val transfer = Math.abs(quantityDelta)
var residual = transfer
val outputs = mutableListOf<SourceAndAmount<T, P>>()
var remaining: SourceAndAmount<T, P>? = null
var newAmount: SourceAndAmount<T, P>? = 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

View File

@ -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<SourceAndAmount<Currency, String>, Pair<String, Currency>, 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)])
}
}

View File

@ -11,7 +11,8 @@ The `Amount <api/net.corda.core.contracts/-amount/index.html>`_ 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<Obligation.Terms<P>>
``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<SourceAndAmount>`` 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:

View File

@ -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)
}
/**

View File

@ -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"

View File

@ -12,6 +12,6 @@ import java.util.*
object AmountFormatter {
// TODO replace this once we settled on how we do formatting
val boring = object : Formatter<Amount<Currency>> {
override fun format(value: Amount<Currency>) = "${value.quantity} ${value.token}"
override fun format(value: Amount<Currency>) = value.toString()
}
}

View File

@ -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)