mirror of
https://github.com/corda/corda.git
synced 2024-12-29 09:18:58 +00:00
State and Contract for Cash and CommercialPaper copied to perftestflows
This commit is contained in:
parent
e22570a81d
commit
8ae92850c9
43
perftestflows/build.gradle
Normal file
43
perftestflows/build.gradle
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
apply plugin: 'kotlin'
|
||||||
|
// Java Persistence API support: create no-arg constructor
|
||||||
|
// see: http://stackoverflow.com/questions/32038177/kotlin-with-jpa-default-constructor-hell
|
||||||
|
apply plugin: 'kotlin-jpa'
|
||||||
|
apply plugin: CanonicalizerPlugin
|
||||||
|
apply plugin: 'net.corda.plugins.publish-utils'
|
||||||
|
apply plugin: 'net.corda.plugins.quasar-utils'
|
||||||
|
apply plugin: 'net.corda.plugins.cordformation'
|
||||||
|
//apply plugin: 'com.jfrog.artifactory'
|
||||||
|
|
||||||
|
description 'Corda performance test modules'
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Note the :perftestflows module is a CorDapp in its own right
|
||||||
|
// and CorDapps using :perftestflows features should use 'cordapp' not 'compile' linkage.
|
||||||
|
cordaCompile project(':core')
|
||||||
|
cordaCompile project(':confidential-identities')
|
||||||
|
|
||||||
|
testCompile project(':test-utils')
|
||||||
|
testCompile project(path: ':core', configuration: 'testArtifacts')
|
||||||
|
testCompile "junit:junit:$junit_version"
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations {
|
||||||
|
testArtifacts.extendsFrom testRuntime
|
||||||
|
}
|
||||||
|
|
||||||
|
task testJar(type: Jar) {
|
||||||
|
classifier "tests"
|
||||||
|
from sourceSets.test.output
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts {
|
||||||
|
testArtifacts testJar
|
||||||
|
}
|
||||||
|
|
||||||
|
jar {
|
||||||
|
baseName 'corda-ptflows'
|
||||||
|
}
|
||||||
|
|
||||||
|
publish {
|
||||||
|
name jar.baseName
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
package net.corda.ptflows.contracts;
|
||||||
|
|
||||||
|
|
||||||
|
import net.corda.core.contracts.Amount;
|
||||||
|
import net.corda.core.contracts.ContractState;
|
||||||
|
import net.corda.core.contracts.Issued;
|
||||||
|
import net.corda.core.identity.AbstractParty;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Currency;
|
||||||
|
|
||||||
|
/* This is an interface solely created to demonstrate that the same kotlin tests can be run against
|
||||||
|
* either a Java implementation of the CommercialPaper or a kotlin implementation.
|
||||||
|
* Normally one would not duplicate an implementation in different languages for obvious reasons, but it demonstrates that
|
||||||
|
* ultimately either language can be used against a common test framework (and therefore can be used for real).
|
||||||
|
*/
|
||||||
|
public interface IPtCommercialPaperState extends ContractState {
|
||||||
|
IPtCommercialPaperState withOwner(AbstractParty newOwner);
|
||||||
|
|
||||||
|
IPtCommercialPaperState withFaceValue(Amount<Issued<Currency>> newFaceValue);
|
||||||
|
|
||||||
|
IPtCommercialPaperState withMaturityDate(Instant newMaturityDate);
|
||||||
|
}
|
@ -0,0 +1,197 @@
|
|||||||
|
package net.corda.ptflows.contracts
|
||||||
|
|
||||||
|
|
||||||
|
import co.paralleluniverse.fibers.Suspendable
|
||||||
|
import net.corda.core.contracts.*
|
||||||
|
import net.corda.core.crypto.NullKeys.NULL_PARTY
|
||||||
|
import net.corda.core.utilities.toBase58String
|
||||||
|
import net.corda.core.identity.AbstractParty
|
||||||
|
import net.corda.core.identity.Party
|
||||||
|
import net.corda.core.identity.PartyAndCertificate
|
||||||
|
import net.corda.core.internal.Emoji
|
||||||
|
import net.corda.core.node.ServiceHub
|
||||||
|
import net.corda.core.schemas.MappedSchema
|
||||||
|
import net.corda.core.schemas.PersistentState
|
||||||
|
import net.corda.core.schemas.QueryableState
|
||||||
|
import net.corda.core.transactions.LedgerTransaction
|
||||||
|
import net.corda.core.transactions.TransactionBuilder
|
||||||
|
import net.corda.ptflows.contracts.asset.PtCash
|
||||||
|
import net.corda.ptflows.schemas.PtCommercialPaperSchemaV1
|
||||||
|
import net.corda.ptflows.utils.sumCashBy
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is an ultra-trivial implementation of commercial paper, which is essentially a simpler version of a corporate
|
||||||
|
* bond. It can be seen as a company-specific currency. A company issues CP with a particular face value, say $100,
|
||||||
|
* but sells it for less, say $90. The paper can be redeemed for cash at a given date in the future. Thus this example
|
||||||
|
* would have a 10% interest rate with a single repayment. Commercial paper is often rolled over (the maturity date
|
||||||
|
* is adjusted as if the paper was redeemed and immediately repurchased, but without having to front the cash).
|
||||||
|
*
|
||||||
|
* This contract is not intended to realistically model CP. It is here only to act as a next step up above cash in
|
||||||
|
* the prototyping phase. It is thus very incomplete.
|
||||||
|
*
|
||||||
|
* Open issues:
|
||||||
|
* - In this model, you cannot merge or split CP. Can you do this normally? We could model CP as a specialised form
|
||||||
|
* of cash, or reuse some of the cash code? Waiting on response from Ayoub and Rajar about whether CP can always
|
||||||
|
* be split/merged or only in secondary markets. Even if current systems can't do this, would it be a desirable
|
||||||
|
* feature to have anyway?
|
||||||
|
* - The funding steps of CP is totally ignored in this model.
|
||||||
|
* - No attention is paid to the existing roles of custodians, funding banks, etc.
|
||||||
|
* - There are regional variations on the CP concept, for instance, American CP requires a special "CUSIP number"
|
||||||
|
* which may need to be tracked. That, in turn, requires validation logic (there is a bean validator that knows how
|
||||||
|
* to do this in the Apache BVal project).
|
||||||
|
*/
|
||||||
|
|
||||||
|
val CP_PROGRAM_ID = "net.corda.ptflows.contracts.PtCommercialPaper"
|
||||||
|
|
||||||
|
// TODO: Generalise the notion of an owned instrument into a superclass/supercontract. Consider composition vs inheritance.
|
||||||
|
class PtCommercialPaper : Contract {
|
||||||
|
companion object {
|
||||||
|
const val CP_PROGRAM_ID: ContractClassName = "net.corda.ptflows.contracts.PtCommercialPaper"
|
||||||
|
}
|
||||||
|
data class State(
|
||||||
|
val issuance: PartyAndReference,
|
||||||
|
override val owner: AbstractParty,
|
||||||
|
val faceValue: Amount<Issued<Currency>>,
|
||||||
|
val maturityDate: Instant
|
||||||
|
) : OwnableState, QueryableState, IPtCommercialPaperState{
|
||||||
|
override val participants = listOf(owner)
|
||||||
|
|
||||||
|
override fun withNewOwner(newOwner: AbstractParty) = CommandAndState(Commands.Move(), copy(owner = newOwner))
|
||||||
|
fun withoutOwner() = copy(owner = NULL_PARTY)
|
||||||
|
override fun toString() = "${Emoji.newspaper}CommercialPaper(of $faceValue redeemable on $maturityDate by '$issuance', owned by $owner)"
|
||||||
|
|
||||||
|
// Although kotlin is smart enough not to need these, as we are using the ICommercialPaperState, we need to declare them explicitly for use later,
|
||||||
|
override fun withOwner(newOwner: AbstractParty): IPtCommercialPaperState = copy(owner = newOwner)
|
||||||
|
|
||||||
|
override fun withFaceValue(newFaceValue: Amount<Issued<Currency>>): IPtCommercialPaperState = copy(faceValue = newFaceValue)
|
||||||
|
override fun withMaturityDate(newMaturityDate: Instant): IPtCommercialPaperState = copy(maturityDate = newMaturityDate)
|
||||||
|
|
||||||
|
/** Object Relational Mapping support. */
|
||||||
|
override fun supportedSchemas(): Iterable<MappedSchema> = listOf(PtCommercialPaperSchemaV1)
|
||||||
|
/** Additional used schemas would be added here (eg. CommercialPaperV2, ...) */
|
||||||
|
|
||||||
|
/** Object Relational Mapping support. */
|
||||||
|
override fun generateMappedObject(schema: MappedSchema): PersistentState {
|
||||||
|
return when (schema) {
|
||||||
|
is PtCommercialPaperSchemaV1 -> PtCommercialPaperSchemaV1.PersistentCommercialPaperState(
|
||||||
|
issuanceParty = this.issuance.party.owningKey.toBase58String(),
|
||||||
|
issuanceRef = this.issuance.reference.bytes,
|
||||||
|
owner = this.owner.owningKey.toBase58String(),
|
||||||
|
maturity = this.maturityDate,
|
||||||
|
faceValue = this.faceValue.quantity,
|
||||||
|
currency = this.faceValue.token.product.currencyCode,
|
||||||
|
faceValueIssuerParty = this.faceValue.token.issuer.party.owningKey.toBase58String(),
|
||||||
|
faceValueIssuerRef = this.faceValue.token.issuer.reference.bytes
|
||||||
|
)
|
||||||
|
/** Additional schema mappings would be added here (eg. CommercialPaperV2, ...) */
|
||||||
|
else -> throw IllegalArgumentException("Unrecognised schema $schema")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @suppress */ infix fun `owned by`(owner: AbstractParty) = copy(owner = owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Commands : CommandData {
|
||||||
|
class Move : TypeOnlyCommandData(), Commands
|
||||||
|
|
||||||
|
class Redeem : TypeOnlyCommandData(), Commands
|
||||||
|
// We don't need a nonce in the issue command, because the issuance.reference field should already be unique per CP.
|
||||||
|
// However, nothing in the platform enforces that uniqueness: it's up to the issuer.
|
||||||
|
class Issue : TypeOnlyCommandData(), Commands
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun verify(tx: LedgerTransaction) {
|
||||||
|
// Group by everything except owner: any modification to the CP at all is considered changing it fundamentally.
|
||||||
|
val groups = tx.groupStates(State::withoutOwner)
|
||||||
|
|
||||||
|
// There are two possible things that can be done with this CP. The first is trading it. The second is redeeming
|
||||||
|
// it for cash on or after the maturity date.
|
||||||
|
val command = tx.commands.requireSingleCommand<PtCommercialPaper.Commands>()
|
||||||
|
val timeWindow: TimeWindow? = tx.timeWindow
|
||||||
|
|
||||||
|
// Suppress compiler warning as 'key' is an unused variable when destructuring 'groups'.
|
||||||
|
@Suppress("UNUSED_VARIABLE")
|
||||||
|
for ((inputs, outputs, key) in groups) {
|
||||||
|
when (command.value) {
|
||||||
|
is Commands.Move -> {
|
||||||
|
val input = inputs.single()
|
||||||
|
requireThat {
|
||||||
|
"the transaction is signed by the owner of the CP" using (input.owner.owningKey in command.signers)
|
||||||
|
"the state is propagated" using (outputs.size == 1)
|
||||||
|
// Don't need to check anything else, as if outputs.size == 1 then the output is equal to
|
||||||
|
// the input ignoring the owner field due to the grouping.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Commands.Redeem -> {
|
||||||
|
// Redemption of the paper requires movement of on-ledger cash.
|
||||||
|
val input = inputs.single()
|
||||||
|
val received = tx.outputStates.sumCashBy(input.owner)
|
||||||
|
val time = timeWindow?.fromTime ?: throw IllegalArgumentException("Redemptions must have a time-window")
|
||||||
|
requireThat {
|
||||||
|
"the paper must have matured" using (time >= input.maturityDate)
|
||||||
|
"the received amount equals the face value" using (received == input.faceValue)
|
||||||
|
"the paper must be destroyed" using outputs.isEmpty()
|
||||||
|
"the transaction is signed by the owner of the CP" using (input.owner.owningKey in command.signers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Commands.Issue -> {
|
||||||
|
val output = outputs.single()
|
||||||
|
val time = timeWindow?.untilTime ?: throw IllegalArgumentException("Issuances have a time-window")
|
||||||
|
requireThat {
|
||||||
|
// Don't allow people to issue commercial paper under other entities identities.
|
||||||
|
"output states are issued by a command signer" using
|
||||||
|
(output.issuance.party.owningKey in command.signers)
|
||||||
|
"output values sum to more than the inputs" using (output.faceValue.quantity > 0)
|
||||||
|
"the maturity date is not in the past" using (time < output.maturityDate)
|
||||||
|
// Don't allow an existing CP state to be replaced by this issuance.
|
||||||
|
// TODO: Consider how to handle the case of mistaken issuances, or other need to patch.
|
||||||
|
"output values sum to more than the inputs" using inputs.isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Think about how to evolve contracts over time with new commands.
|
||||||
|
else -> throw IllegalArgumentException("Unrecognised command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a transaction that issues commercial paper, owned by the issuing parties key. Does not update
|
||||||
|
* an existing transaction because you aren't able to issue multiple pieces of CP in a single transaction
|
||||||
|
* at the moment: this restriction is not fundamental and may be lifted later.
|
||||||
|
*/
|
||||||
|
fun generateIssue(issuance: PartyAndReference, faceValue: Amount<Issued<Currency>>, maturityDate: Instant,
|
||||||
|
notary: Party): TransactionBuilder {
|
||||||
|
val state = State(issuance, issuance.party, faceValue, maturityDate)
|
||||||
|
return TransactionBuilder(notary = notary).withItems(StateAndContract(state, CP_PROGRAM_ID), Command(Commands.Issue(), issuance.party.owningKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the given partial transaction with an input/output/command to reassign ownership of the paper.
|
||||||
|
*/
|
||||||
|
fun generateMove(tx: TransactionBuilder, paper: StateAndRef<State>, newOwner: AbstractParty) {
|
||||||
|
tx.addInputState(paper)
|
||||||
|
tx.addOutputState(paper.state.data.withOwner(newOwner), CP_PROGRAM_ID)
|
||||||
|
tx.addCommand(Commands.Move(), paper.state.data.owner.owningKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intended to be called by the issuer of some commercial paper, when an owner has notified us that they wish
|
||||||
|
* to redeem the paper. We must therefore send enough money to the key that owns the paper to satisfy the face
|
||||||
|
* value, and then ensure the paper is removed from the ledger.
|
||||||
|
*
|
||||||
|
* @throws InsufficientBalanceException if the vault doesn't contain enough money to pay the redeemer.
|
||||||
|
*/
|
||||||
|
@Throws(InsufficientBalanceException::class)
|
||||||
|
@Suspendable
|
||||||
|
fun generateRedeem(tx: TransactionBuilder, paper: StateAndRef<State>, services: ServiceHub, ourIdentity: PartyAndCertificate) {
|
||||||
|
// Add the cash movement using the states in our vault.
|
||||||
|
PtCash.generateSpend(services, tx, paper.state.data.faceValue.withoutIssuer(), ourIdentity, paper.state.data.owner)
|
||||||
|
tx.addInputState(paper)
|
||||||
|
tx.addCommand(Commands.Redeem(), paper.state.data.owner.owningKey)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,415 @@
|
|||||||
|
// So the static extension functions get put into a class with a better name than CashKt
|
||||||
|
package net.corda.ptflows.contracts.asset
|
||||||
|
|
||||||
|
import co.paralleluniverse.fibers.Suspendable
|
||||||
|
import net.corda.core.contracts.*
|
||||||
|
import net.corda.core.contracts.Amount.Companion.sumOrThrow
|
||||||
|
import net.corda.core.crypto.NullKeys.NULL_PARTY
|
||||||
|
import net.corda.core.crypto.entropyToKeyPair
|
||||||
|
import net.corda.core.identity.AbstractParty
|
||||||
|
import net.corda.core.identity.CordaX500Name
|
||||||
|
import net.corda.core.identity.Party
|
||||||
|
import net.corda.core.identity.PartyAndCertificate
|
||||||
|
import net.corda.core.internal.Emoji
|
||||||
|
import net.corda.core.node.ServiceHub
|
||||||
|
import net.corda.core.schemas.MappedSchema
|
||||||
|
import net.corda.core.schemas.PersistentState
|
||||||
|
import net.corda.core.schemas.QueryableState
|
||||||
|
import net.corda.core.transactions.LedgerTransaction
|
||||||
|
import net.corda.core.transactions.TransactionBuilder
|
||||||
|
import net.corda.core.utilities.OpaqueBytes
|
||||||
|
import net.corda.core.utilities.toBase58String
|
||||||
|
import net.corda.ptflows.schemas.PtCashSchemaV1
|
||||||
|
import net.corda.ptflows.utils.sumCash
|
||||||
|
import net.corda.ptflows.utils.sumCashOrNull
|
||||||
|
import net.corda.ptflows.utils.sumCashOrZero
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.security.PublicKey
|
||||||
|
import java.sql.DatabaseMetaData
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
//
|
||||||
|
// Cash
|
||||||
|
//
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pluggable interface to allow for different cash selection provider implementations
|
||||||
|
* Default implementation [CashSelectionH2Impl] uses H2 database and a custom function within H2 to perform aggregation.
|
||||||
|
* Custom implementations must implement this interface and declare their implementation in
|
||||||
|
* META-INF/services/net.corda.contracts.asset.CashSelection
|
||||||
|
*/
|
||||||
|
interface PtCashSelection {
|
||||||
|
companion object {
|
||||||
|
val instance = AtomicReference<PtCashSelection>()
|
||||||
|
|
||||||
|
fun getInstance(metadata: () -> java.sql.DatabaseMetaData): PtCashSelection {
|
||||||
|
return instance.get() ?: {
|
||||||
|
val _metadata = metadata()
|
||||||
|
val cashSelectionAlgos = ServiceLoader.load(PtCashSelection::class.java).toList()
|
||||||
|
val cashSelectionAlgo = cashSelectionAlgos.firstOrNull { it.isCompatible(_metadata) }
|
||||||
|
cashSelectionAlgo?.let {
|
||||||
|
instance.set(cashSelectionAlgo)
|
||||||
|
cashSelectionAlgo
|
||||||
|
} ?: throw ClassNotFoundException("\nUnable to load compatible cash selection algorithm implementation for JDBC driver ($_metadata)." +
|
||||||
|
"\nPlease specify an implementation in META-INF/services/net.corda.finance.contracts.asset.CashSelection")
|
||||||
|
}.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upon dynamically loading configured Cash Selection algorithms declared in META-INF/services
|
||||||
|
* this method determines whether the loaded implementation is compatible and usable with the currently
|
||||||
|
* loaded JDBC driver.
|
||||||
|
* Note: the first loaded implementation to pass this check will be used at run-time.
|
||||||
|
*/
|
||||||
|
fun isCompatible(metadata: DatabaseMetaData): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query to gather Cash states that are available
|
||||||
|
* @param services The service hub to allow access to the database session
|
||||||
|
* @param amount The amount of currency desired (ignoring issues, but specifying the currency)
|
||||||
|
* @param onlyFromIssuerParties If empty the operation ignores the specifics of the issuer,
|
||||||
|
* otherwise the set of eligible states wil be filtered to only include those from these issuers.
|
||||||
|
* @param notary If null the notary source is ignored, if specified then only states marked
|
||||||
|
* with this notary are included.
|
||||||
|
* @param lockId The FlowLogic.runId.uuid of the flow, which is used to soft reserve the states.
|
||||||
|
* Also, previous outputs of the flow will be eligible as they are implicitly locked with this id until the flow completes.
|
||||||
|
* @param withIssuerRefs If not empty the specific set of issuer references to match against.
|
||||||
|
* @return The matching states that were found. If sufficient funds were found these will be locked,
|
||||||
|
* otherwise what is available is returned unlocked for informational purposes.
|
||||||
|
*/
|
||||||
|
@Suspendable
|
||||||
|
fun unconsumedCashStatesForSpending(services: ServiceHub,
|
||||||
|
amount: Amount<Currency>,
|
||||||
|
onlyFromIssuerParties: Set<AbstractParty> = emptySet(),
|
||||||
|
notary: Party? = null,
|
||||||
|
lockId: UUID,
|
||||||
|
withIssuerRefs: Set<OpaqueBytes> = emptySet()): List<StateAndRef<PtCash.State>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cash transaction may split and merge money 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 issuer+depositRef) and you couldn't merge outputs of two colours together, but you COULD put them in
|
||||||
|
* the same transaction.
|
||||||
|
*
|
||||||
|
* The goal of this design is to ensure that money can be withdrawn from the ledger easily: if you receive some money
|
||||||
|
* via this contract, you always know where to go in order to extract it from the R3 ledger, no matter how many hands
|
||||||
|
* it has passed through in the intervening time.
|
||||||
|
*
|
||||||
|
* At the same time, other contracts that just want money and don't care much who is currently holding it in their
|
||||||
|
* vaults can ignore the issuer/depositRefs and just examine the amount fields.
|
||||||
|
*/
|
||||||
|
class PtCash : PtOnLedgerAsset<Currency, PtCash.Commands, PtCash.State>() {
|
||||||
|
override fun extractCommands(commands: Collection<CommandWithParties<CommandData>>): List<CommandWithParties<Commands>>
|
||||||
|
= commands.select<Commands>()
|
||||||
|
|
||||||
|
// DOCSTART 1
|
||||||
|
/** A state representing a cash claim against some party. */
|
||||||
|
data class State(
|
||||||
|
override val amount: Amount<Issued<Currency>>,
|
||||||
|
|
||||||
|
/** There must be a MoveCommand signed by this key to claim the amount. */
|
||||||
|
override val owner: AbstractParty
|
||||||
|
) : FungibleAsset<Currency>, QueryableState {
|
||||||
|
constructor(deposit: PartyAndReference, amount: Amount<Currency>, owner: AbstractParty)
|
||||||
|
: this(Amount(amount.quantity, Issued(deposit, amount.token)), owner)
|
||||||
|
|
||||||
|
override val exitKeys = setOf(owner.owningKey, amount.token.issuer.party.owningKey)
|
||||||
|
override val participants = listOf(owner)
|
||||||
|
|
||||||
|
override fun withNewOwnerAndAmount(newAmount: Amount<Issued<Currency>>, newOwner: AbstractParty): FungibleAsset<Currency>
|
||||||
|
= copy(amount = amount.copy(newAmount.quantity), owner = newOwner)
|
||||||
|
|
||||||
|
override fun toString() = "${Emoji.bagOfCash}Cash($amount at ${amount.token.issuer} owned by $owner)"
|
||||||
|
|
||||||
|
override fun withNewOwner(newOwner: AbstractParty) = CommandAndState(Commands.Move(), copy(owner = newOwner))
|
||||||
|
fun ownedBy(owner: AbstractParty) = copy(owner = owner)
|
||||||
|
fun issuedBy(party: AbstractParty) = copy(amount = Amount(amount.quantity, amount.token.copy(issuer = amount.token.issuer.copy(party = party))))
|
||||||
|
fun issuedBy(deposit: PartyAndReference) = copy(amount = Amount(amount.quantity, amount.token.copy(issuer = deposit)))
|
||||||
|
fun withDeposit(deposit: PartyAndReference): State = copy(amount = amount.copy(token = amount.token.copy(issuer = deposit)))
|
||||||
|
|
||||||
|
/** Object Relational Mapping support. */
|
||||||
|
override fun generateMappedObject(schema: MappedSchema): PersistentState {
|
||||||
|
return when (schema) {
|
||||||
|
is PtCashSchemaV1 -> PtCashSchemaV1.PersistentCashState(
|
||||||
|
owner = this.owner,
|
||||||
|
pennies = this.amount.quantity,
|
||||||
|
currency = this.amount.token.product.currencyCode,
|
||||||
|
issuerParty = this.amount.token.issuer.party.owningKey.toBase58String(),
|
||||||
|
issuerRef = this.amount.token.issuer.reference.bytes
|
||||||
|
)
|
||||||
|
/** Additional schema mappings would be added here (eg. CashSchemaV2, CashSchemaV3, ...) */
|
||||||
|
else -> throw IllegalArgumentException("Unrecognised schema $schema")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Object Relational Mapping support. */
|
||||||
|
override fun supportedSchemas(): Iterable<MappedSchema> = listOf(PtCashSchemaV1)
|
||||||
|
/** Additional used schemas would be added here (eg. CashSchemaV2, CashSchemaV3, ...) */
|
||||||
|
}
|
||||||
|
// DOCEND 1
|
||||||
|
|
||||||
|
// Just for grouping
|
||||||
|
interface Commands : CommandData {
|
||||||
|
/**
|
||||||
|
* A command stating that money has been moved, optionally to fulfil another contract.
|
||||||
|
*
|
||||||
|
* @param contract the contract this move is for the attention of. Only that contract's verify function
|
||||||
|
* should take the moved states into account when considering whether it is valid. Typically this will be
|
||||||
|
* null.
|
||||||
|
*/
|
||||||
|
data class Move(override val contract: Class<out Contract>? = null) : MoveCommand
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows new cash states to be issued into existence.
|
||||||
|
*/
|
||||||
|
class Issue : TypeOnlyCommandData()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command stating that money has been withdrawn from the shared ledger and is now accounted for
|
||||||
|
* in some other way.
|
||||||
|
*/
|
||||||
|
data class Exit(val amount: Amount<Issued<Currency>>) : CommandData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts together an issuance transaction from the given template, that starts out being owned by the given pubkey.
|
||||||
|
*/
|
||||||
|
fun generateIssue(tx: TransactionBuilder, tokenDef: Issued<Currency>, pennies: Long, owner: AbstractParty, notary: Party)
|
||||||
|
= generateIssue(tx, Amount(pennies, tokenDef), owner, notary)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts together an issuance transaction for the specified amount that starts out being owned by the given pubkey.
|
||||||
|
*/
|
||||||
|
fun generateIssue(tx: TransactionBuilder, amount: Amount<Issued<Currency>>, owner: AbstractParty, notary: Party)
|
||||||
|
= generateIssue(tx, TransactionState(State(amount, owner), PROGRAM_ID, notary), Commands.Issue())
|
||||||
|
|
||||||
|
override fun deriveState(txState: TransactionState<State>, amount: Amount<Issued<Currency>>, owner: AbstractParty)
|
||||||
|
= txState.copy(data = txState.data.copy(amount = amount, owner = owner))
|
||||||
|
|
||||||
|
override fun generateExitCommand(amount: Amount<Issued<Currency>>) = Commands.Exit(amount)
|
||||||
|
override fun generateMoveCommand() = Commands.Move()
|
||||||
|
|
||||||
|
override fun verify(tx: LedgerTransaction) {
|
||||||
|
// Each group is a set of input/output states with distinct (reference, currency) attributes. These types
|
||||||
|
// of cash are not fungible and must be kept separated for bookkeeping purposes.
|
||||||
|
val groups = tx.groupStates { it: State -> it.amount.token }
|
||||||
|
|
||||||
|
for ((inputs, outputs, key) in groups) {
|
||||||
|
// Either inputs or outputs could be empty.
|
||||||
|
val issuer = key.issuer
|
||||||
|
val currency = key.product
|
||||||
|
|
||||||
|
requireThat {
|
||||||
|
"there are no zero sized outputs" using (outputs.none { it.amount.quantity == 0L })
|
||||||
|
}
|
||||||
|
|
||||||
|
val issueCommand = tx.commands.select<Commands.Issue>().firstOrNull()
|
||||||
|
if (issueCommand != null) {
|
||||||
|
verifyIssueCommand(inputs, outputs, tx, issueCommand, currency, issuer)
|
||||||
|
} else {
|
||||||
|
val inputAmount = inputs.sumCashOrNull() ?: throw IllegalArgumentException("there is at least one cash input for this group")
|
||||||
|
val outputAmount = outputs.sumCashOrZero(Issued(issuer, currency))
|
||||||
|
|
||||||
|
// If we want to remove cash from the ledger, that must be signed for by the issuer.
|
||||||
|
// A mis-signed or duplicated exit command will just be ignored here and result in the exit amount being zero.
|
||||||
|
val exitKeys: Set<PublicKey> = inputs.flatMap { it.exitKeys }.toSet()
|
||||||
|
val exitCommand = tx.commands.select<Commands.Exit>(parties = null, signers = exitKeys).filter { it.value.amount.token == key }.singleOrNull()
|
||||||
|
val amountExitingLedger = exitCommand?.value?.amount ?: Amount(0, Issued(issuer, currency))
|
||||||
|
|
||||||
|
requireThat {
|
||||||
|
"there are no zero sized inputs" using inputs.none { it.amount.quantity == 0L }
|
||||||
|
"for reference ${issuer.reference} at issuer ${issuer.party} the amounts balance: ${inputAmount.quantity} - ${amountExitingLedger.quantity} != ${outputAmount.quantity}" using
|
||||||
|
(inputAmount == outputAmount + amountExitingLedger)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyMoveCommand<Commands.Move>(inputs, tx.commands)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyIssueCommand(inputs: List<State>,
|
||||||
|
outputs: List<State>,
|
||||||
|
tx: LedgerTransaction,
|
||||||
|
issueCommand: CommandWithParties<Commands.Issue>,
|
||||||
|
currency: Currency,
|
||||||
|
issuer: PartyAndReference) {
|
||||||
|
// If we have an issue command, perform special processing: the group is allowed to have no inputs,
|
||||||
|
// and the output states must have a deposit reference owned by the signer.
|
||||||
|
//
|
||||||
|
// Whilst the transaction *may* have no inputs, it can have them, and in this case the outputs must
|
||||||
|
// sum to more than the inputs. An issuance of zero size is not allowed.
|
||||||
|
//
|
||||||
|
// Note that this means literally anyone with access to the network can issue cash claims of arbitrary
|
||||||
|
// amounts! It is up to the recipient to decide if the backing party is trustworthy or not, via some
|
||||||
|
// as-yet-unwritten identity service. See ADP-22 for discussion.
|
||||||
|
|
||||||
|
// The grouping ensures that all outputs have the same deposit reference and currency.
|
||||||
|
val inputAmount = inputs.sumCashOrZero(Issued(issuer, currency))
|
||||||
|
val outputAmount = outputs.sumCash()
|
||||||
|
val cashCommands = tx.commands.select<Commands.Issue>()
|
||||||
|
requireThat {
|
||||||
|
// TODO: This doesn't work with the trader demo, so use the underlying key instead
|
||||||
|
// "output states are issued by a command signer" by (issuer.party in issueCommand.signingParties)
|
||||||
|
"output states are issued by a command signer" using (issuer.party.owningKey in issueCommand.signers)
|
||||||
|
"output values sum to more than the inputs" using (outputAmount > inputAmount)
|
||||||
|
"there is only a single issue command" using (cashCommands.count() == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PROGRAM_ID: ContractClassName = "net.corda.finance.contracts.asset.Cash"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a transaction that moves an amount of currency to the given party, and sends any change back to
|
||||||
|
* sole identity of the calling node. Fails for nodes with multiple identities.
|
||||||
|
*
|
||||||
|
* 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 the recipient party.
|
||||||
|
* @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
|
||||||
|
@Deprecated("Our identity should be specified", replaceWith = ReplaceWith("generateSpend(services, tx, amount, to, ourIdentity, onlyFromParties)"))
|
||||||
|
fun generateSpend(services: ServiceHub,
|
||||||
|
tx: TransactionBuilder,
|
||||||
|
amount: Amount<Currency>,
|
||||||
|
to: AbstractParty,
|
||||||
|
onlyFromParties: Set<AbstractParty> = emptySet()) = generateSpend(services, tx, listOf(PartyAndAmount(to, amount)), services.myInfo.legalIdentitiesAndCerts.single(), onlyFromParties)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a transaction that moves an amount of currency to the given party.
|
||||||
|
*
|
||||||
|
* 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 the recipient party.
|
||||||
|
* @param ourIdentity well known identity to create a new confidential identity from, for sending change to.
|
||||||
|
* @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,
|
||||||
|
amount: Amount<Currency>,
|
||||||
|
ourIdentity: PartyAndCertificate,
|
||||||
|
to: AbstractParty,
|
||||||
|
onlyFromParties: Set<AbstractParty> = emptySet()): Pair<TransactionBuilder, List<PublicKey>> {
|
||||||
|
return generateSpend(services, tx, listOf(PartyAndAmount(to, amount)), ourIdentity, onlyFromParties)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a transaction that moves money of the given amounts to the recipients specified, and sends any change
|
||||||
|
* back to sole identity of the calling node. Fails for nodes with multiple identities.
|
||||||
|
*
|
||||||
|
* 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 payments A list of amounts to pay, and the party to send the payment to.
|
||||||
|
* @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
|
||||||
|
@Deprecated("Our identity should be specified", replaceWith = ReplaceWith("generateSpend(services, tx, amount, to, ourIdentity, onlyFromParties)"))
|
||||||
|
fun generateSpend(services: ServiceHub,
|
||||||
|
tx: TransactionBuilder,
|
||||||
|
payments: List<PartyAndAmount<Currency>>,
|
||||||
|
onlyFromParties: Set<AbstractParty> = emptySet()) = generateSpend(services, tx, payments, services.myInfo.legalIdentitiesAndCerts.single(), onlyFromParties)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a transaction that moves money of the given amounts to the recipients specified.
|
||||||
|
*
|
||||||
|
* 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 payments A list of amounts to pay, and the party to send the payment to.
|
||||||
|
* @param ourIdentity well known identity to create a new confidential identity from, for sending change to.
|
||||||
|
* @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>>,
|
||||||
|
ourIdentity: PartyAndCertificate,
|
||||||
|
onlyFromParties: Set<AbstractParty> = emptySet()): Pair<TransactionBuilder, List<PublicKey>> {
|
||||||
|
fun deriveState(txState: TransactionState<State>, amt: Amount<Issued<Currency>>, owner: AbstractParty)
|
||||||
|
= txState.copy(data = txState.data.copy(amount = amt, owner = owner))
|
||||||
|
|
||||||
|
// Retrieve unspent and unlocked cash states that meet our spending criteria.
|
||||||
|
val totalAmount = payments.map { it.amount }.sumOrThrow()
|
||||||
|
val cashSelection = PtCashSelection.getInstance({ services.jdbcSession().metaData })
|
||||||
|
val acceptableCoins = cashSelection.unconsumedCashStatesForSpending(services, totalAmount, onlyFromParties, tx.notary, tx.lockId)
|
||||||
|
val revocationEnabled = false // Revocation is currently unsupported
|
||||||
|
// Generate a new identity that change will be sent to for confidentiality purposes. This means that a
|
||||||
|
// third party with a copy of the transaction (such as the notary) cannot identify who the change was
|
||||||
|
// sent to
|
||||||
|
val changeIdentity = services.keyManagementService.freshKeyAndCert(ourIdentity, revocationEnabled)
|
||||||
|
return PtOnLedgerAsset.generateSpend(tx, payments, acceptableCoins,
|
||||||
|
changeIdentity.party.anonymise(),
|
||||||
|
{ state, quantity, owner -> deriveState(state, quantity, owner) },
|
||||||
|
{ PtCash().generateMoveCommand() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small DSL extensions.
|
||||||
|
|
||||||
|
/** @suppress */ infix fun PtCash.State.`owned by`(owner: AbstractParty) = ownedBy(owner)
|
||||||
|
/** @suppress */ infix fun PtCash.State.`issued by`(party: AbstractParty) = issuedBy(party)
|
||||||
|
/** @suppress */ infix fun PtCash.State.`issued by`(deposit: PartyAndReference) = issuedBy(deposit)
|
||||||
|
/** @suppress */ infix fun PtCash.State.`with deposit`(deposit: PartyAndReference): PtCash.State = withDeposit(deposit)
|
||||||
|
|
||||||
|
// Unit testing helpers. These could go in a separate file but it's hardly worth it for just a few functions.
|
||||||
|
|
||||||
|
/** A randomly generated key. */
|
||||||
|
val DUMMY_CASH_ISSUER_KEY by lazy { entropyToKeyPair(BigInteger.valueOf(10)) }
|
||||||
|
/** A dummy, randomly generated issuer party by the name of "Snake Oil Issuer" */
|
||||||
|
val DUMMY_CASH_ISSUER by lazy { Party(CordaX500Name(organisation = "Snake Oil Issuer", locality = "London", country = "GB"), DUMMY_CASH_ISSUER_KEY.public).ref(1) }
|
||||||
|
/** An extension property that lets you write 100.DOLLARS.CASH */
|
||||||
|
val Amount<Currency>.CASH: PtCash.State get() = PtCash.State(Amount(quantity, Issued(DUMMY_CASH_ISSUER, token)), NULL_PARTY)
|
||||||
|
/** An extension property that lets you get a cash state from an issued token, under the [NULL_PARTY] */
|
||||||
|
val Amount<Issued<Currency>>.STATE: PtCash.State get() = PtCash.State(this, NULL_PARTY)
|
@ -0,0 +1,323 @@
|
|||||||
|
package net.corda.ptflows.contracts.asset
|
||||||
|
|
||||||
|
|
||||||
|
import net.corda.core.contracts.*
|
||||||
|
import net.corda.core.contracts.Amount.Companion.sumOrThrow
|
||||||
|
import net.corda.core.identity.AbstractParty
|
||||||
|
import net.corda.core.transactions.TransactionBuilder
|
||||||
|
import net.corda.core.utilities.loggerFor
|
||||||
|
import net.corda.core.utilities.trace
|
||||||
|
import java.security.PublicKey
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
//
|
||||||
|
// Generic contract for assets on a ledger
|
||||||
|
//
|
||||||
|
|
||||||
|
/** A simple holder for a (possibly anonymous) [AbstractParty] and a quantity of tokens */
|
||||||
|
data class PartyAndAmount<T : Any>(val party: AbstractParty, val amount: Amount<T>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An asset transaction may split and merge assets represented by a set of (issuer, depositRef) pairs, across multiple
|
||||||
|
* input and output states. Imagine a Bitcoin transaction but in which all UTXOs had a colour (a blend of
|
||||||
|
* issuer+depositRef) and you couldn't merge outputs of two colours together, but you COULD put them in the same
|
||||||
|
* transaction.
|
||||||
|
*
|
||||||
|
* The goal of this design is to ensure that assets can be withdrawn from the ledger easily: if you receive some asset
|
||||||
|
* via this contract, you always know where to go in order to extract it from the R3 ledger, no matter how many hands
|
||||||
|
* it has passed through in the intervening time.
|
||||||
|
*
|
||||||
|
* At the same time, other contracts that just want assets and don't care much who is currently holding it can ignore
|
||||||
|
* the issuer/depositRefs and just examine the amount fields.
|
||||||
|
*/
|
||||||
|
abstract class PtOnLedgerAsset<T : Any, C : CommandData, S : FungibleAsset<T>> : Contract {
|
||||||
|
companion object {
|
||||||
|
val log = loggerFor<PtOnLedgerAsset<*, *, *>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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 payChangeTo party to pay any change to; this is normally a confidential identity of the calling
|
||||||
|
* party.
|
||||||
|
* @param deriveState a function to derive an output state based on an input state, amount for the output
|
||||||
|
* and public key to pay to.
|
||||||
|
* @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,
|
||||||
|
amount: Amount<T>,
|
||||||
|
to: AbstractParty,
|
||||||
|
acceptableStates: List<StateAndRef<S>>,
|
||||||
|
payChangeTo: AbstractParty,
|
||||||
|
deriveState: (TransactionState<S>, Amount<Issued<T>>, AbstractParty) -> TransactionState<S>,
|
||||||
|
generateMoveCommand: () -> CommandData): Pair<TransactionBuilder, List<PublicKey>> {
|
||||||
|
return generateSpend(tx, listOf(PartyAndAmount(to, amount)), acceptableStates, payChangeTo, 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 payChangeTo party to pay any change to; this is normally a confidential identity of the calling
|
||||||
|
* party. We use a new confidential identity here so that the recipient is not identifiable.
|
||||||
|
* @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>>,
|
||||||
|
payChangeTo: AbstractParty,
|
||||||
|
deriveState: (TransactionState<S>, Amount<Issued<T>>, AbstractParty) -> TransactionState<S>,
|
||||||
|
generateMoveCommand: () -> CommandData): Pair<TransactionBuilder, List<PublicKey>> {
|
||||||
|
// Discussion
|
||||||
|
//
|
||||||
|
// This code is analogous to the Wallet.send() set of methods in bitcoinj, and has the same general outline.
|
||||||
|
//
|
||||||
|
// First we must select a set of asset states (which for convenience we will call 'coins' here, as in bitcoinj).
|
||||||
|
// The input states can be considered our "vault", and may consist of different products, and with different
|
||||||
|
// issuers and deposits.
|
||||||
|
//
|
||||||
|
// Coin selection is a complex problem all by itself and many different approaches can be used. It is easily
|
||||||
|
// possible for different actors to use different algorithms and approaches that, for example, compete on
|
||||||
|
// privacy vs efficiency (number of states created). Some spends may be artificial just for the purposes of
|
||||||
|
// obfuscation and so on.
|
||||||
|
//
|
||||||
|
// Having selected input states of the correct asset, we must craft output states for the amount we're sending and
|
||||||
|
// the "change", which goes back to us. The change is required to make the amounts balance. We may need more
|
||||||
|
// than one change output in order to avoid merging assets from different deposits. The point of this design
|
||||||
|
// is to ensure that ledger entries are immutable and globally identifiable.
|
||||||
|
//
|
||||||
|
// Finally, we add the states to the provided partial transaction.
|
||||||
|
|
||||||
|
// TODO: We should be prepared to produce multiple transactions spending inputs from
|
||||||
|
// different notaries, or at least group states by notary and take the set with the
|
||||||
|
// highest total value.
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Calculate the total amount we're sending (they must be all of a compatible token).
|
||||||
|
val totalSendAmount = payments.map { it.amount }.sumOrThrow()
|
||||||
|
// Select a subset of the available states we were given that sums up to >= totalSendAmount.
|
||||||
|
val (gathered, gatheredAmount) = gatherCoins(acceptableStates, totalSendAmount)
|
||||||
|
check(gatheredAmount >= totalSendAmount)
|
||||||
|
val keysUsed = gathered.map { it.state.data.owner.owningKey }
|
||||||
|
|
||||||
|
// Now calculate the output states. This is complicated by the fact that a single payment may require
|
||||||
|
// multiple output states, due to the need to keep states separated by issuer. We start by figuring out
|
||||||
|
// how much we've gathered for each issuer: this map will keep track of how much we've used from each
|
||||||
|
// as we work our way through the payments.
|
||||||
|
val statesGroupedByIssuer = gathered.groupBy { it.state.data.amount.token }
|
||||||
|
val remainingFromEachIssuer = statesGroupedByIssuer
|
||||||
|
.mapValues {
|
||||||
|
it.value.map {
|
||||||
|
it.state.data.amount
|
||||||
|
}.sumOrThrow()
|
||||||
|
}.toList().toMutableList()
|
||||||
|
val outputStates = mutableListOf<TransactionState<S>>()
|
||||||
|
for ((party, paymentAmount) in payments) {
|
||||||
|
var remainingToPay = paymentAmount.quantity
|
||||||
|
while (remainingToPay > 0) {
|
||||||
|
val (token, remainingFromCurrentIssuer) = remainingFromEachIssuer.last()
|
||||||
|
val templateState = statesGroupedByIssuer[token]!!.first().state
|
||||||
|
val delta = remainingFromCurrentIssuer.quantity - remainingToPay
|
||||||
|
when {
|
||||||
|
delta > 0 -> {
|
||||||
|
// The states from the current issuer more than covers this payment.
|
||||||
|
outputStates += deriveState(templateState, Amount(remainingToPay, token), party)
|
||||||
|
remainingFromEachIssuer[0] = Pair(token, Amount(delta, token))
|
||||||
|
remainingToPay = 0
|
||||||
|
}
|
||||||
|
delta == 0L -> {
|
||||||
|
// The states from the current issuer exactly covers this payment.
|
||||||
|
outputStates += deriveState(templateState, Amount(remainingToPay, token), party)
|
||||||
|
remainingFromEachIssuer.removeAt(remainingFromEachIssuer.lastIndex)
|
||||||
|
remainingToPay = 0
|
||||||
|
}
|
||||||
|
delta < 0 -> {
|
||||||
|
// The states from the current issuer don't cover this payment, so we'll have to use >1 output
|
||||||
|
// state to cover this payment.
|
||||||
|
outputStates += deriveState(templateState, remainingFromCurrentIssuer, party)
|
||||||
|
remainingFromEachIssuer.removeAt(remainingFromEachIssuer.lastIndex)
|
||||||
|
remainingToPay -= remainingFromCurrentIssuer.quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever values we have left over for each issuer must become change outputs.
|
||||||
|
for ((token, amount) in remainingFromEachIssuer) {
|
||||||
|
val templateState = statesGroupedByIssuer[token]!!.first().state
|
||||||
|
outputStates += deriveState(templateState, amount, payChangeTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (state in gathered) tx.addInputState(state)
|
||||||
|
for (state in outputStates) tx.addOutputState(state)
|
||||||
|
|
||||||
|
// What if we already have a move command with the right keys? Filter it out here or in platform code?
|
||||||
|
tx.addCommand(generateMoveCommand(), keysUsed)
|
||||||
|
|
||||||
|
return Pair(tx, keysUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gather assets from the given list of states, sufficient to match or exceed the given amount.
|
||||||
|
*
|
||||||
|
* @param acceptableCoins list of states to use as inputs.
|
||||||
|
* @param amount the amount to gather states up to.
|
||||||
|
* @throws InsufficientBalanceException if there isn't enough value in the states to cover the requested amount.
|
||||||
|
*/
|
||||||
|
@Throws(InsufficientBalanceException::class)
|
||||||
|
private fun <S : FungibleAsset<T>, T : Any> gatherCoins(acceptableCoins: Collection<StateAndRef<S>>,
|
||||||
|
amount: Amount<T>): Pair<ArrayList<StateAndRef<S>>, Amount<T>> {
|
||||||
|
require(amount.quantity > 0) { "Cannot gather zero coins" }
|
||||||
|
val gathered = arrayListOf<StateAndRef<S>>()
|
||||||
|
var gatheredAmount = Amount(0, amount.token)
|
||||||
|
for (c in acceptableCoins) {
|
||||||
|
if (gatheredAmount >= amount) break
|
||||||
|
gathered.add(c)
|
||||||
|
gatheredAmount += Amount(c.state.data.amount.quantity, amount.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gatheredAmount < amount) {
|
||||||
|
log.trace { "Insufficient balance: requested $amount, available $gatheredAmount" }
|
||||||
|
throw InsufficientBalanceException(amount - gatheredAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.trace { "Gathered coins: requested $amount, available $gatheredAmount, change: ${gatheredAmount - amount}" }
|
||||||
|
|
||||||
|
return Pair(gathered, gatheredAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an transaction exiting fungible assets from the ledger.
|
||||||
|
*
|
||||||
|
* @param tx transaction builder to add states and commands to.
|
||||||
|
* @param amountIssued the amount to be exited, represented as a quantity of issued currency.
|
||||||
|
* @param assetStates the asset states to take funds from. No checks are done about ownership of these states, it is
|
||||||
|
* the responsibility of the caller to check that they do not attempt to exit funds held by others.
|
||||||
|
* @return the public keys which must sign the transaction for it to be valid.
|
||||||
|
*/
|
||||||
|
@Throws(InsufficientBalanceException::class)
|
||||||
|
@JvmStatic
|
||||||
|
fun <S : FungibleAsset<T>, T: Any> generateExit(tx: TransactionBuilder, amountIssued: Amount<Issued<T>>,
|
||||||
|
assetStates: List<StateAndRef<S>>,
|
||||||
|
deriveState: (TransactionState<S>, Amount<Issued<T>>, AbstractParty) -> TransactionState<S>,
|
||||||
|
generateMoveCommand: () -> CommandData,
|
||||||
|
generateExitCommand: (Amount<Issued<T>>) -> CommandData): Set<PublicKey> {
|
||||||
|
val owner = assetStates.map { it.state.data.owner }.toSet().singleOrNull() ?: throw InsufficientBalanceException(amountIssued)
|
||||||
|
val currency = amountIssued.token.product
|
||||||
|
val amount = Amount(amountIssued.quantity, currency)
|
||||||
|
var acceptableCoins = assetStates.filter { ref -> ref.state.data.amount.token == amountIssued.token }
|
||||||
|
tx.notary = acceptableCoins.firstOrNull()?.state?.notary
|
||||||
|
// TODO: We should be prepared to produce multiple transactions exiting inputs from
|
||||||
|
// different notaries, or at least group states by notary and take the set with the
|
||||||
|
// highest total value
|
||||||
|
acceptableCoins = acceptableCoins.filter { it.state.notary == tx.notary }
|
||||||
|
|
||||||
|
val (gathered, gatheredAmount) = gatherCoins(acceptableCoins, amount)
|
||||||
|
val takeChangeFrom = gathered.lastOrNull()
|
||||||
|
val change = if (takeChangeFrom != null && gatheredAmount > amount) {
|
||||||
|
Amount(gatheredAmount.quantity - amount.quantity, takeChangeFrom.state.data.amount.token)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val outputs = if (change != null) {
|
||||||
|
// Add a change output and adjust the last output downwards.
|
||||||
|
listOf(deriveState(gathered.last().state, change, owner))
|
||||||
|
} else emptyList()
|
||||||
|
|
||||||
|
for (state in gathered) tx.addInputState(state)
|
||||||
|
for (state in outputs) tx.addOutputState(state)
|
||||||
|
val moveKeys = gathered.map { it.state.data.owner.owningKey }
|
||||||
|
val exitKeys = gathered.flatMap { it.state.data.exitKeys }
|
||||||
|
tx.addCommand(generateMoveCommand(), moveKeys)
|
||||||
|
tx.addCommand(generateExitCommand(amountIssued), exitKeys)
|
||||||
|
return (moveKeys + exitKeys).toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts together an issuance transaction for the specified state. Normally contracts will provide convenient
|
||||||
|
* wrappers around this function, which build the state for you, and those should be used in preference.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun <S : FungibleAsset<T>, T: Any> generateIssue(tx: TransactionBuilder,
|
||||||
|
transactionState: TransactionState<S>,
|
||||||
|
issueCommand: CommandData): Set<PublicKey> {
|
||||||
|
check(tx.inputStates().isEmpty())
|
||||||
|
check(tx.outputStates().map { it.data }.filterIsInstance(transactionState.javaClass).isEmpty())
|
||||||
|
require(transactionState.data.amount.quantity > 0)
|
||||||
|
val at = transactionState.data.amount.token.issuer
|
||||||
|
val commandSigner = at.party.owningKey
|
||||||
|
tx.addOutputState(transactionState)
|
||||||
|
tx.addCommand(issueCommand, commandSigner)
|
||||||
|
return setOf(commandSigner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun extractCommands(commands: Collection<CommandWithParties<CommandData>>): Collection<CommandWithParties<C>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an transaction exiting assets from the ledger.
|
||||||
|
*
|
||||||
|
* @param tx transaction builder to add states and commands to.
|
||||||
|
* @param amountIssued the amount to be exited, represented as a quantity of issued currency.
|
||||||
|
* @param assetStates the asset states to take funds from. No checks are done about ownership of these states, it is
|
||||||
|
* the responsibility of the caller to check that they do not exit funds held by others.
|
||||||
|
* @return the public keys which must sign the transaction for it to be valid.
|
||||||
|
*/
|
||||||
|
@Throws(InsufficientBalanceException::class)
|
||||||
|
fun generateExit(tx: TransactionBuilder, amountIssued: Amount<Issued<T>>,
|
||||||
|
assetStates: List<StateAndRef<S>>): Set<PublicKey> {
|
||||||
|
return generateExit(
|
||||||
|
tx,
|
||||||
|
amountIssued,
|
||||||
|
assetStates,
|
||||||
|
deriveState = { state, amount, owner -> deriveState(state, amount, owner) },
|
||||||
|
generateMoveCommand = { -> generateMoveCommand() },
|
||||||
|
generateExitCommand = { amount -> generateExitCommand(amount) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun generateExitCommand(amount: Amount<Issued<T>>): CommandData
|
||||||
|
abstract fun generateMoveCommand(): MoveCommand
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a new transaction state based on the given example, with amount and owner modified. This allows concrete
|
||||||
|
* implementations to have fields in their state which we don't know about here, and we simply leave them untouched
|
||||||
|
* when sending out "change" from spending/exiting.
|
||||||
|
*/
|
||||||
|
abstract fun deriveState(txState: TransactionState<S>, amount: Amount<Issued<T>>, owner: AbstractParty): TransactionState<S>
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
package net.corda.ptflows.schemas
|
||||||
|
|
||||||
|
|
||||||
|
import net.corda.core.identity.AbstractParty
|
||||||
|
import net.corda.core.schemas.MappedSchema
|
||||||
|
import net.corda.core.schemas.PersistentState
|
||||||
|
import net.corda.core.serialization.CordaSerializable
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.Index
|
||||||
|
import javax.persistence.Table
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object used to fully qualify the [CashSchema] family name (i.e. independent of version).
|
||||||
|
*/
|
||||||
|
object PtCashSchema
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First version of a cash contract ORM schema that maps all fields of the [Cash] contract state as it stood
|
||||||
|
* at the time of writing.
|
||||||
|
*/
|
||||||
|
@CordaSerializable
|
||||||
|
object PtCashSchemaV1 : MappedSchema(schemaFamily = PtCashSchema.javaClass, version = 1, mappedTypes = listOf(PersistentCashState::class.java)) {
|
||||||
|
@Entity
|
||||||
|
@Table(name = "contract_cash_states",
|
||||||
|
indexes = arrayOf(Index(name = "ccy_code_idx", columnList = "ccy_code"),
|
||||||
|
Index(name = "pennies_idx", columnList = "pennies")))
|
||||||
|
class PersistentCashState(
|
||||||
|
/** X500Name of owner party **/
|
||||||
|
@Column(name = "owner_name")
|
||||||
|
var owner: AbstractParty,
|
||||||
|
|
||||||
|
@Column(name = "pennies")
|
||||||
|
var pennies: Long,
|
||||||
|
|
||||||
|
@Column(name = "ccy_code", length = 3)
|
||||||
|
var currency: String,
|
||||||
|
|
||||||
|
@Column(name = "issuer_key")
|
||||||
|
var issuerParty: String,
|
||||||
|
|
||||||
|
@Column(name = "issuer_ref")
|
||||||
|
var issuerRef: ByteArray
|
||||||
|
) : PersistentState()
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
package net.corda.ptflows.schemas
|
||||||
|
import net.corda.core.schemas.MappedSchema
|
||||||
|
import net.corda.core.schemas.PersistentState
|
||||||
|
import net.corda.core.serialization.CordaSerializable
|
||||||
|
import java.time.Instant
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.Index
|
||||||
|
import javax.persistence.Table
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object used to fully qualify the [CommercialPaperSchema] family name (i.e. independent of version).
|
||||||
|
*/
|
||||||
|
object PtCommercialPaperSchema
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First version of a commercial paper contract ORM schema that maps all fields of the [CommercialPaper] contract state
|
||||||
|
* as it stood at the time of writing.
|
||||||
|
*/
|
||||||
|
@CordaSerializable
|
||||||
|
object PtCommercialPaperSchemaV1 : MappedSchema(schemaFamily = PtCommercialPaperSchema.javaClass, version = 1, mappedTypes = listOf(PersistentCommercialPaperState::class.java)) {
|
||||||
|
@Entity
|
||||||
|
@Table(name = "cp_states",
|
||||||
|
indexes = arrayOf(Index(name = "ccy_code_index", columnList = "ccy_code"),
|
||||||
|
Index(name = "maturity_index", columnList = "maturity_instant"),
|
||||||
|
Index(name = "face_value_index", columnList = "face_value")))
|
||||||
|
class PersistentCommercialPaperState(
|
||||||
|
@Column(name = "issuance_key")
|
||||||
|
var issuanceParty: String,
|
||||||
|
|
||||||
|
@Column(name = "issuance_ref")
|
||||||
|
var issuanceRef: ByteArray,
|
||||||
|
|
||||||
|
@Column(name = "owner_key")
|
||||||
|
var owner: String,
|
||||||
|
|
||||||
|
@Column(name = "maturity_instant")
|
||||||
|
var maturity: Instant,
|
||||||
|
|
||||||
|
@Column(name = "face_value")
|
||||||
|
var faceValue: Long,
|
||||||
|
|
||||||
|
@Column(name = "ccy_code", length = 3)
|
||||||
|
var currency: String,
|
||||||
|
|
||||||
|
@Column(name = "face_value_issuer_key")
|
||||||
|
var faceValueIssuerParty: String,
|
||||||
|
|
||||||
|
@Column(name = "face_value_issuer_ref")
|
||||||
|
var faceValueIssuerRef: ByteArray
|
||||||
|
) : PersistentState()
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
package net.corda.ptflows.utils
|
||||||
|
|
||||||
|
import net.corda.core.contracts.Amount
|
||||||
|
import net.corda.core.contracts.Amount.Companion.sumOrNull
|
||||||
|
import net.corda.core.contracts.Amount.Companion.sumOrThrow
|
||||||
|
import net.corda.core.contracts.Amount.Companion.sumOrZero
|
||||||
|
import net.corda.core.contracts.ContractState
|
||||||
|
import net.corda.core.contracts.FungibleAsset
|
||||||
|
import net.corda.core.contracts.Issued
|
||||||
|
import net.corda.core.identity.AbstractParty
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sums the cash states in the list belonging to a single owner, throwing an exception
|
||||||
|
* if there are none, or if any of the cash states cannot be added together (i.e. are
|
||||||
|
* different currencies or issuers).
|
||||||
|
*/
|
||||||
|
fun Iterable<ContractState>.sumCashBy(owner: AbstractParty): Amount<Issued<Currency>> = filterIsInstance<net.corda.ptflows.contracts.asset.PtCash.State>().filter { it.owner == owner }.map { it.amount }.sumOrThrow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sums the cash states in the list, throwing an exception if there are none, or if any of the cash
|
||||||
|
* states cannot be added together (i.e. are different currencies or issuers).
|
||||||
|
*/
|
||||||
|
fun Iterable<ContractState>.sumCash(): Amount<Issued<Currency>> = filterIsInstance<net.corda.ptflows.contracts.asset.PtCash.State>().map { it.amount }.sumOrThrow()
|
||||||
|
|
||||||
|
/** Sums the cash states in the list, returning null if there are none. */
|
||||||
|
fun Iterable<ContractState>.sumCashOrNull(): Amount<Issued<Currency>>? = filterIsInstance<net.corda.ptflows.contracts.asset.PtCash.State>().map { it.amount }.sumOrNull()
|
||||||
|
|
||||||
|
/** Sums the cash states in the list, returning zero of the given currency+issuer if there are none. */
|
||||||
|
fun Iterable<ContractState>.sumCashOrZero(currency: Issued<Currency>): Amount<Issued<Currency>> {
|
||||||
|
return filterIsInstance<net.corda.ptflows.contracts.asset.PtCash.State>().map { it.amount }.sumOrZero(currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sums the asset states in the list, returning null if there are none. */
|
||||||
|
fun <T : Any> Iterable<ContractState>.sumFungibleOrNull() = filterIsInstance<FungibleAsset<T>>().map { it.amount }.sumOrNull()
|
||||||
|
|
||||||
|
/** Sums the asset states in the list, returning zero of the given token if there are none. */
|
||||||
|
fun <T : Any> Iterable<ContractState>.sumFungibleOrZero(token: Issued<T>) = filterIsInstance<FungibleAsset<T>>().map { it.amount }.sumOrZero(token)
|
||||||
|
|
@ -46,4 +46,5 @@ include 'doorman'
|
|||||||
include 'verify-enclave'
|
include 'verify-enclave'
|
||||||
include 'sgx-jvm/hsm-tool'
|
include 'sgx-jvm/hsm-tool'
|
||||||
include 'signing-server'
|
include 'signing-server'
|
||||||
|
include 'perftestflows'
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user