Split CashFlow into three flows

Split CashFlow into independent CashIssueFlow, CashExitFlow and CashPaymentFlow,
so that users can be given access to one but not the other(s).

Signed-off-by: Ross Nicoll <ross.nicoll@r3.com>
This commit is contained in:
Ross Nicoll 2017-01-30 18:34:48 +00:00 committed by Chris Rankin
parent 521994ce23
commit 7dc6f47b3d
25 changed files with 357 additions and 254 deletions

View File

@ -8,8 +8,8 @@ import net.corda.core.messaging.startFlow
import net.corda.core.node.services.ServiceInfo
import net.corda.core.random63BitValue
import net.corda.core.serialization.OpaqueBytes
import net.corda.flows.CashCommand
import net.corda.flows.CashFlow
import net.corda.flows.CashIssueFlow
import net.corda.flows.CashPaymentFlow
import net.corda.node.internal.Node
import net.corda.node.services.User
import net.corda.node.services.config.configureTestSSL
@ -23,7 +23,10 @@ import org.junit.Before
import org.junit.Test
class CordaRPCClientTest : NodeBasedTest() {
private val rpcUser = User("user1", "test", permissions = setOf(startFlowPermission<CashFlow>()))
private val rpcUser = User("user1", "test", permissions = setOf(
startFlowPermission<CashIssueFlow>(),
startFlowPermission<CashPaymentFlow>()
))
private lateinit var node: Node
private lateinit var client: CordaRPCClient
@ -60,8 +63,8 @@ class CordaRPCClientTest : NodeBasedTest() {
val proxy = client.proxy()
println("Starting flow")
val flowHandle = proxy.startFlow(
::CashFlow,
CashCommand.IssueCash(20.DOLLARS, OpaqueBytes.of(0), node.info.legalIdentity, node.info.legalIdentity))
::CashIssueFlow,
20.DOLLARS, OpaqueBytes.of(0), node.info.legalIdentity, node.info.legalIdentity)
println("Started flow, waiting on result")
flowHandle.progress.subscribe {
println("PROGRESS $it")
@ -73,10 +76,10 @@ class CordaRPCClientTest : NodeBasedTest() {
fun `FlowException thrown by flow`() {
client.start(rpcUser.username, rpcUser.password)
val proxy = client.proxy()
val handle = proxy.startFlow(::CashFlow, CashCommand.PayCash(
amount = 100.DOLLARS.issuedBy(node.info.legalIdentity.ref(1)),
recipient = node.info.legalIdentity
))
val handle = proxy.startFlow(::CashPaymentFlow,
100.DOLLARS.issuedBy(node.info.legalIdentity.ref(1)),
node.info.legalIdentity
)
// TODO Restrict this to CashException once RPC serialisation has been fixed
assertThatExceptionOfType(FlowException::class.java).isThrownBy {
handle.returnValue.getOrThrow()

View File

@ -19,7 +19,6 @@ import net.corda.core.node.services.StateMachineTransactionMapping
import net.corda.core.node.services.Vault
import net.corda.core.serialization.OpaqueBytes
import net.corda.core.transactions.SignedTransaction
import net.corda.flows.CashCommand
import net.corda.flows.CashFlow
import net.corda.node.driver.DriverBasedTest
import net.corda.node.driver.driver
@ -94,7 +93,7 @@ class NodeMonitorModelTest : DriverBasedTest() {
@Test
fun `cash issue works end to end`() {
rpc.startFlow(::CashFlow, CashCommand.IssueCash(
rpc.startFlow(::CashFlow, CashFlow.Command.IssueCash(
amount = Amount(100, USD),
issueRef = OpaqueBytes(ByteArray(1, { 1 })),
recipient = aliceNode.legalIdentity,
@ -119,14 +118,14 @@ class NodeMonitorModelTest : DriverBasedTest() {
@Test
fun `cash issue and move`() {
rpc.startFlow(::CashFlow, CashCommand.IssueCash(
rpc.startFlow(::CashFlow, CashFlow.Command.IssueCash(
amount = Amount(100, USD),
issueRef = OpaqueBytes(ByteArray(1, { 1 })),
recipient = aliceNode.legalIdentity,
notary = notaryNode.notaryIdentity
)).returnValue.getOrThrow()
rpc.startFlow(::CashFlow, CashCommand.PayCash(
rpc.startFlow(::CashFlow, CashFlow.Command.PayCash(
amount = Amount(100, Issued(PartyAndReference(aliceNode.legalIdentity, OpaqueBytes(ByteArray(1, { 1 }))), USD)),
recipient = aliceNode.legalIdentity
))

View File

@ -5,7 +5,7 @@ import net.corda.core.contracts.*
import net.corda.core.crypto.Party
import net.corda.core.serialization.OpaqueBytes
import net.corda.core.transactions.TransactionBuilder
import net.corda.flows.CashCommand
import net.corda.flows.CashFlow
import java.util.*
/**
@ -64,7 +64,7 @@ class EventGenerator(
val issueCashGenerator =
amountGenerator.combine(partyGenerator, issueRefGenerator) { amount, to, issueRef ->
CashCommand.IssueCash(
CashFlow.Command.IssueCash(
amount,
issueRef,
to,
@ -76,7 +76,7 @@ class EventGenerator(
amountIssuedGenerator.combine(
partyGenerator
) { amountIssued, recipient ->
CashCommand.PayCash(
CashFlow.Command.PayCash(
amount = amountIssued,
recipient = recipient
)
@ -84,7 +84,7 @@ class EventGenerator(
val exitCashGenerator =
amountIssuedGenerator.map {
CashCommand.ExitCash(
CashFlow.Command.ExitCash(
it.withoutIssuer(),
it.token.issuer.reference
)

View File

@ -9,7 +9,7 @@ import net.corda.core.messaging.startFlow
import net.corda.core.node.services.ServiceInfo
import net.corda.core.node.services.Vault
import net.corda.core.serialization.OpaqueBytes
import net.corda.flows.CashCommand
import net.corda.core.toFuture
import net.corda.flows.CashFlow
import net.corda.node.driver.driver
import net.corda.node.services.User
@ -56,7 +56,7 @@ class IntegrationTestingTutorial {
val issueRef = OpaqueBytes.of(0)
for (i in 1 .. 10) {
thread {
aliceProxy.startFlow(::CashFlow, CashCommand.IssueCash(
aliceProxy.startFlow(::CashFlow, CashFlow.Command.IssueCash(
amount = i.DOLLARS,
issueRef = issueRef,
recipient = bob.nodeInfo.legalIdentity,
@ -82,7 +82,7 @@ class IntegrationTestingTutorial {
// START 5
for (i in 1 .. 10) {
val flowHandle = bobProxy.startFlow(::CashFlow, CashCommand.PayCash(
val flowHandle = bobProxy.startFlow(::CashFlow, CashFlow.Command.PayCash(
amount = i.DOLLARS.issuedBy(alice.nodeInfo.legalIdentity.ref(issueRef)),
recipient = alice.nodeInfo.legalIdentity
))
@ -102,4 +102,4 @@ class IntegrationTestingTutorial {
}
}
}
// END 5
// END 5

View File

@ -12,8 +12,9 @@ import net.corda.core.node.CordaPluginRegistry
import net.corda.core.node.services.ServiceInfo
import net.corda.core.serialization.OpaqueBytes
import net.corda.core.transactions.SignedTransaction
import net.corda.flows.CashCommand
import net.corda.flows.CashFlow
import net.corda.flows.CashExitFlow
import net.corda.flows.CashIssueFlow
import net.corda.flows.CashPaymentFlow
import net.corda.node.driver.driver
import net.corda.node.services.User
import net.corda.node.services.startFlowPermission
@ -41,7 +42,9 @@ fun main(args: Array<String>) {
val printOrVisualise = PrintOrVisualise.valueOf(args[0])
val baseDirectory = Paths.get("build/rpc-api-tutorial")
val user = User("user", "password", permissions = setOf(startFlowPermission<CashFlow>()))
val user = User("user", "password", permissions = setOf(startFlowPermission<CashIssueFlow>(),
startFlowPermission<CashPaymentFlow>(),
startFlowPermission<CashExitFlow>()))
driver(driverDirectory = baseDirectory) {
startNode("Notary", advertisedServices = setOf(ServiceInfo(ValidatingNotaryService.type)))
@ -114,14 +117,14 @@ fun generateTransactions(proxy: CordaRPCOps) {
val n = random.nextDouble()
if (ownedQuantity > 10000 && n > 0.8) {
val quantity = Math.abs(random.nextLong()) % 2000
proxy.startFlow(::CashFlow, CashCommand.ExitCash(Amount(quantity, USD), issueRef))
proxy.startFlow(::CashExitFlow, Amount(quantity, USD), issueRef)
ownedQuantity -= quantity
} else if (ownedQuantity > 1000 && n < 0.7) {
val quantity = Math.abs(random.nextLong() % Math.min(ownedQuantity, 2000))
proxy.startFlow(::CashFlow, CashCommand.PayCash(Amount(quantity, Issued(meAndRef, USD)), me))
proxy.startFlow(::CashPaymentFlow, Amount(quantity, Issued(meAndRef, USD)), me)
} else {
val quantity = Math.abs(random.nextLong() % 1000)
proxy.startFlow(::CashFlow, CashCommand.IssueCash(Amount(quantity, USD), issueRef, me, notary))
proxy.startFlow(::CashIssueFlow, Amount(quantity, USD), issueRef, me, notary)
ownedQuantity += quantity
}
}

View File

@ -1,6 +1,5 @@
package net.corda.docs
import net.corda.core.crypto.Party
import net.corda.core.contracts.*
import net.corda.core.getOrThrow
import net.corda.core.node.services.ServiceInfo
@ -8,9 +7,8 @@ import net.corda.core.serialization.OpaqueBytes
import net.corda.core.toFuture
import net.corda.core.utilities.DUMMY_NOTARY
import net.corda.core.utilities.DUMMY_NOTARY_KEY
import net.corda.flows.CashCommand
import net.corda.flows.CashFlow
import net.corda.core.node.ServiceEntry
import net.corda.flows.CashIssueFlow
import net.corda.flows.CashPaymentFlow
import net.corda.node.services.network.NetworkMapService
import net.corda.node.services.transactions.ValidatingNotaryService
import net.corda.node.utilities.databaseTransaction
@ -51,19 +49,19 @@ class FxTransactionBuildTutorialTest {
@Test
fun `Run ForeignExchangeFlow to completion`() {
// Use NodeA as issuer and create some dollars
val flowHandle1 = nodeA.services.startFlow(CashFlow(CashCommand.IssueCash(DOLLARS(1000),
val flowHandle1 = nodeA.services.startFlow(CashIssueFlow(DOLLARS(1000),
OpaqueBytes.of(0x01),
nodeA.info.legalIdentity,
notaryNode.info.notaryIdentity)))
notaryNode.info.notaryIdentity))
// Wait for the flow to stop and print
flowHandle1.resultFuture.getOrThrow()
printBalances()
// Using NodeB as Issuer create some pounds.
val flowHandle2 = nodeB.services.startFlow(CashFlow(CashCommand.IssueCash(POUNDS(1000),
val flowHandle2 = nodeB.services.startFlow(CashIssueFlow(POUNDS(1000),
OpaqueBytes.of(0x01),
nodeB.info.legalIdentity,
notaryNode.info.notaryIdentity)))
notaryNode.info.notaryIdentity))
// Wait for flow to come to an end and print
flowHandle2.resultFuture.getOrThrow()
printBalances()
@ -107,4 +105,4 @@ class FxTransactionBuildTutorialTest {
println("BalanceB\n" + nodeB.services.vaultService.cashBalances)
}
}
}
}

View File

@ -259,8 +259,8 @@ Launch the Explorer application to visualize the issuance and transfer of cash f
Using the following login details:
- For the Bank of Corda node: localhost / port 10004 / username user1 / password test
- For the Big Corporation node: localhost / port 10006 / username user1 / password test
- For the Bank of Corda node: localhost / port 10004 / username bankUser / password test
- For the Big Corporation node: localhost / port 10006 / username bigCorpUser / password test
See https://docs.corda.net/node-explorer.html for further details on usage.

View File

@ -0,0 +1,32 @@
package net.corda.flows
import co.paralleluniverse.fibers.Suspendable
import net.corda.core.crypto.Party
import net.corda.core.flows.FlowException
import net.corda.core.flows.FlowLogic
import net.corda.core.transactions.SignedTransaction
import net.corda.core.utilities.ProgressTracker
/**
* Initiates a flow that produces an Issue/Move or Exit Cash transaction.
*/
abstract class AbstractCashFlow(override val progressTracker: ProgressTracker) : FlowLogic<SignedTransaction>() {
companion object {
object GENERATING_TX : ProgressTracker.Step("Generating transaction")
object SIGNING_TX : ProgressTracker.Step("Signing transaction")
object FINALISING_TX : ProgressTracker.Step("Finalising transaction")
fun tracker() = ProgressTracker(GENERATING_TX, SIGNING_TX, FINALISING_TX)
}
@Suspendable
internal fun finaliseTx(participants: Set<Party>, tx: SignedTransaction, message: String) {
try {
subFlow(FinalityFlow(tx, participants))
} catch (e: NotaryException) {
throw CashException(message, e)
}
}
}
class CashException(message: String, cause: Throwable) : FlowException(message, cause)

View File

@ -0,0 +1,67 @@
package net.corda.flows
import co.paralleluniverse.fibers.Suspendable
import net.corda.contracts.asset.Cash
import net.corda.core.contracts.*
import net.corda.core.crypto.Party
import net.corda.core.serialization.OpaqueBytes
import net.corda.core.transactions.SignedTransaction
import net.corda.core.transactions.TransactionBuilder
import net.corda.core.utilities.ProgressTracker
import java.util.*
/**
* Initiates a flow that produces an cash exit transaction.
*
* @param amount the amount of a currency to remove from the ledger.
* @param issuerRef the reference on the issued currency. Added to the node's legal identity to determine the
* issuer.
*/
class CashExitFlow(val amount: Amount<Currency>, val issueRef: OpaqueBytes, progressTracker: ProgressTracker) : AbstractCashFlow(progressTracker) {
constructor(amount: Amount<Currency>, issueRef: OpaqueBytes) : this(amount, issueRef, tracker())
companion object {
fun tracker() = ProgressTracker(GENERATING_TX, SIGNING_TX, FINALISING_TX)
}
@Suspendable
@Throws(CashException::class)
override fun call(): SignedTransaction {
progressTracker.currentStep = GENERATING_TX
val builder: TransactionBuilder = TransactionType.General.Builder(null)
val issuer = serviceHub.myInfo.legalIdentity.ref(issueRef)
try {
Cash().generateExit(
builder,
amount.issuedBy(issuer),
serviceHub.vaultService.currentVault.statesOfType<Cash.State>().filter { it.state.data.owner == issuer.party.owningKey })
} catch (e: InsufficientBalanceException) {
throw CashException("Exiting more cash than exists", e)
}
progressTracker.currentStep = SIGNING_TX
val myKey = serviceHub.legalIdentityKey
builder.signWith(myKey)
// Work out who the owners of the burnt states were
val inputStatesNullable = serviceHub.vaultService.statesForRefs(builder.inputStates())
val inputStates = inputStatesNullable.values.filterNotNull().map { it.data }
if (inputStatesNullable.size != inputStates.size) {
val unresolvedStateRefs = inputStatesNullable.filter { it.value == null }.map { it.key }
throw IllegalStateException("Failed to resolve input StateRefs: $unresolvedStateRefs")
}
// TODO: Is it safe to drop participants we don't know how to contact? Does not knowing how to contact them
// count as a reason to fail?
val participants: Set<Party> = inputStates
.filterIsInstance<Cash.State>()
.map { serviceHub.identityService.partyFromKey(it.owner) }
.filterNotNull()
.toSet()
// Commit the transaction
val tx = builder.toSignedTransaction(checkSufficientSignatures = false)
progressTracker.currentStep = FINALISING_TX
finaliseTx(participants, tx, "Unable to notarise exit")
return tx
}
}

View File

@ -1,19 +1,13 @@
package net.corda.flows
import co.paralleluniverse.fibers.Suspendable
import net.corda.contracts.asset.Cash
import net.corda.core.contracts.*
import net.corda.core.crypto.AnonymousParty
import net.corda.core.contracts.Amount
import net.corda.core.contracts.Issued
import net.corda.core.crypto.Party
import net.corda.core.crypto.keys
import net.corda.core.crypto.toStringShort
import net.corda.core.flows.FlowException
import net.corda.core.flows.FlowLogic
import net.corda.core.serialization.OpaqueBytes
import net.corda.core.transactions.SignedTransaction
import net.corda.core.transactions.TransactionBuilder
import net.corda.core.utilities.ProgressTracker
import java.security.KeyPair
import java.util.*
/**
@ -21,8 +15,8 @@ import java.util.*
*
* @param command Indicates what Cash transaction to create with what parameters.
*/
class CashFlow(val command: CashCommand, override val progressTracker: ProgressTracker) : FlowLogic<SignedTransaction>() {
constructor(command: CashCommand) : this(command, tracker())
class CashFlow(val command: CashFlow.Command, override val progressTracker: ProgressTracker) : FlowLogic<SignedTransaction>() {
constructor(command: CashFlow.Command) : this(command, tracker())
companion object {
object ISSUING : ProgressTracker.Step("Issuing cash")
@ -36,133 +30,38 @@ class CashFlow(val command: CashCommand, override val progressTracker: ProgressT
@Throws(CashException::class)
override fun call(): SignedTransaction {
return when (command) {
is CashCommand.IssueCash -> issueCash(command)
is CashCommand.PayCash -> initiatePayment(command)
is CashCommand.ExitCash -> exitCash(command)
is CashFlow.Command.IssueCash -> subFlow(CashIssueFlow(command.amount, command.issueRef, command.recipient, command.notary))
is CashFlow.Command.PayCash -> subFlow(CashPaymentFlow(command.amount, command.recipient))
is CashFlow.Command.ExitCash -> subFlow(CashExitFlow(command.amount, command.issueRef))
}
}
// TODO check with the recipient if they want to accept the cash.
@Suspendable
private fun initiatePayment(req: CashCommand.PayCash): SignedTransaction {
progressTracker.currentStep = PAYING
val builder: TransactionBuilder = TransactionType.General.Builder(null)
// TODO: Have some way of restricting this to states the caller controls
val (spendTX, keysForSigning) = try {
serviceHub.vaultService.generateSpend(
builder,
req.amount.withoutIssuer(),
req.recipient.owningKey,
setOf(req.amount.token.issuer.party))
} catch (e: InsufficientBalanceException) {
throw CashException("Insufficent cash for spend", e)
}
/**
* A command to initiate the Cash flow with.
*/
sealed class Command {
/**
* A command to initiate the Cash flow with.
*/
class IssueCash(val amount: Amount<Currency>,
val issueRef: OpaqueBytes,
val recipient: Party,
val notary: Party) : CashFlow.Command()
keysForSigning.keys.forEach {
val key = serviceHub.keyManagementService.keys[it] ?: throw IllegalStateException("Could not find signing key for ${it.toStringShort()}")
builder.signWith(KeyPair(it, key))
}
/**
* Pay cash to someone else.
*
* @param amount the amount of currency to issue on to the ledger.
* @param recipient the party to issue the cash to.
*/
class PayCash(val amount: Amount<Issued<Currency>>, val recipient: Party) : CashFlow.Command()
val tx = spendTX.toSignedTransaction(checkSufficientSignatures = false)
finaliseTx(setOf(req.recipient), tx, "Unable to notarise spend")
return tx
}
@Suspendable
private fun exitCash(req: CashCommand.ExitCash): SignedTransaction {
progressTracker.currentStep = EXITING
val builder: TransactionBuilder = TransactionType.General.Builder(null)
val issuer = serviceHub.myInfo.legalIdentity.ref(req.issueRef)
try {
Cash().generateExit(
builder,
req.amount.issuedBy(issuer),
serviceHub.vaultService.currentVault.statesOfType<Cash.State>().filter { it.state.data.owner == issuer.party.owningKey })
} catch (e: InsufficientBalanceException) {
throw CashException("Exiting more cash than exists", e)
}
val myKey = serviceHub.legalIdentityKey
builder.signWith(myKey)
// Work out who the owners of the burnt states were
val inputStatesNullable = serviceHub.vaultService.statesForRefs(builder.inputStates())
val inputStates = inputStatesNullable.values.filterNotNull().map { it.data }
if (inputStatesNullable.size != inputStates.size) {
val unresolvedStateRefs = inputStatesNullable.filter { it.value == null }.map { it.key }
throw IllegalStateException("Failed to resolve input StateRefs: $unresolvedStateRefs")
}
// TODO: Is it safe to drop participants we don't know how to contact? Does not knowing how to contact them
// count as a reason to fail?
val participants: Set<Party> = inputStates
.filterIsInstance<Cash.State>()
.map { serviceHub.identityService.partyFromKey(it.owner) }
.filterNotNull()
.toSet()
// Commit the transaction
val tx = builder.toSignedTransaction(checkSufficientSignatures = false)
finaliseTx(participants, tx, "Unable to notarise exit")
return tx
}
@Suspendable
private fun finaliseTx(participants: Set<Party>, tx: SignedTransaction, message: String) {
try {
subFlow(FinalityFlow(tx, participants))
} catch (e: NotaryException) {
throw CashException(message, e)
}
}
// TODO This doesn't throw any exception so it might be worth splitting the three cash commands into separate flows
@Suspendable
private fun issueCash(req: CashCommand.IssueCash): SignedTransaction {
progressTracker.currentStep = ISSUING
val builder: TransactionBuilder = TransactionType.General.Builder(notary = null)
val issuer = serviceHub.myInfo.legalIdentity.ref(req.issueRef)
Cash().generateIssue(builder, req.amount.issuedBy(issuer), req.recipient.owningKey, req.notary)
val myKey = serviceHub.legalIdentityKey
builder.signWith(myKey)
val tx = builder.toSignedTransaction()
subFlow(FinalityFlow(tx))
return tx
/**
* Exit cash from the ledger.
*
* @param amount the amount of currency to exit from the ledger.
* @param issueRef the reference previously specified on the issuance.
*/
class ExitCash(val amount: Amount<Currency>, val issueRef: OpaqueBytes) : CashFlow.Command()
}
}
/**
* A command to initiate the Cash flow with.
*/
sealed class CashCommand {
/**
* Issue cash state objects.
*
* @param amount the amount of currency to issue on to the ledger.
* @param issueRef the reference to specify on the issuance, used to differentiate pools of cash. Convention is
* to use the single byte "0x01" as a default.
* @param recipient the party to issue the cash to.
* @param notary the notary to use for this transaction.
*/
class IssueCash(val amount: Amount<Currency>,
val issueRef: OpaqueBytes,
val recipient: Party,
val notary: Party) : CashCommand()
/**
* Pay cash to someone else.
*
* @param amount the amount of currency to issue on to the ledger.
* @param recipient the party to issue the cash to.
*/
class PayCash(val amount: Amount<Issued<Currency>>, val recipient: Party) : CashCommand()
/**
* Exit cash from the ledger.
*
* @param amount the amount of currency to exit from the ledger.
* @param issueRef the reference previously specified on the issuance.
*/
class ExitCash(val amount: Amount<Currency>, val issueRef: OpaqueBytes) : CashCommand()
}
class CashException(message: String, cause: Throwable) : FlowException(message, cause)

View File

@ -0,0 +1,46 @@
package net.corda.flows
import net.corda.contracts.asset.Cash
import net.corda.core.contracts.Amount
import net.corda.core.contracts.PartyAndReference
import net.corda.core.contracts.TransactionType
import net.corda.core.contracts.issuedBy
import net.corda.core.crypto.Party
import net.corda.core.serialization.OpaqueBytes
import net.corda.core.transactions.SignedTransaction
import net.corda.core.transactions.TransactionBuilder
import net.corda.core.utilities.ProgressTracker
import java.util.*
/**
* Initiates a flow that produces cash issuance transaction.
*
* @param amount the amount of currency to issue.
* @param issueRef a reference to put on the issued currency.
* @param recipient the party who should own the currency after it is issued.
* @param notary the notary to set on the output states.
*/
class CashIssueFlow(val amount: Amount<Currency>,
val issueRef: OpaqueBytes,
val recipient: Party,
val notary: Party,
progressTracker: ProgressTracker) : AbstractCashFlow(progressTracker) {
constructor(amount: Amount<Currency>,
issueRef: OpaqueBytes,
recipient: Party,
notary: Party) : this(amount, issueRef, recipient, notary, tracker())
override fun call(): SignedTransaction {
progressTracker.currentStep = GENERATING_TX
val builder: TransactionBuilder = TransactionType.General.Builder(notary = null)
val issuer = serviceHub.myInfo.legalIdentity.ref(issueRef)
Cash().generateIssue(builder, amount.issuedBy(issuer), recipient.owningKey, notary)
progressTracker.currentStep = SIGNING_TX
val myKey = serviceHub.legalIdentityKey
builder.signWith(myKey)
val tx = builder.toSignedTransaction()
progressTracker.currentStep = FINALISING_TX
subFlow(FinalityFlow(tx))
return tx
}
}

View File

@ -0,0 +1,49 @@
package net.corda.flows
import co.paralleluniverse.fibers.Suspendable
import net.corda.core.contracts.*
import net.corda.core.crypto.Party
import net.corda.core.crypto.keys
import net.corda.core.crypto.toStringShort
import net.corda.core.transactions.SignedTransaction
import net.corda.core.transactions.TransactionBuilder
import net.corda.core.utilities.ProgressTracker
import java.security.KeyPair
import java.util.*
/**
* Initiates a flow that produces an cash move transaction.
*
* @param amount the amount of a currency to pay to the recipient.
* @param recipient the party to pay the currency to.
*/
open class CashPaymentFlow(val amount: Amount<Issued<Currency>>, val recipient: Party, progressTracker: ProgressTracker) : AbstractCashFlow(progressTracker) {
constructor(amount: Amount<Issued<Currency>>, recipient: Party) : this(amount, recipient, tracker())
@Suspendable
override fun call(): SignedTransaction {
progressTracker.currentStep = GENERATING_TX
val builder: TransactionBuilder = TransactionType.General.Builder(null)
// TODO: Have some way of restricting this to states the caller controls
val (spendTX, keysForSigning) = try {
serviceHub.vaultService.generateSpend(
builder,
amount.withoutIssuer(),
recipient.owningKey,
setOf(amount.token.issuer.party))
} catch (e: InsufficientBalanceException) {
throw CashException("Insufficent cash for spend", e)
}
progressTracker.currentStep = SIGNING_TX
keysForSigning.keys.forEach {
val key = serviceHub.keyManagementService.keys[it] ?: throw IllegalStateException("Could not find signing key for ${it.toStringShort()}")
builder.signWith(KeyPair(it, key))
}
progressTracker.currentStep = FINALISING_TX
val tx = spendTX.toSignedTransaction(checkSufficientSignatures = false)
finaliseTx(setOf(recipient), tx, "Unable to notarise spend")
return tx
}
}

View File

@ -79,7 +79,7 @@ object IssuerFlow {
// invoke Cash subflow to issue Asset
progressTracker.currentStep = ISSUING
val bankOfCordaParty = serviceHub.myInfo.legalIdentity
val issueCashFlow = CashFlow(CashCommand.IssueCash(amount, issuerPartyRef, bankOfCordaParty, notaryParty))
val issueCashFlow = CashIssueFlow(amount, issuerPartyRef, bankOfCordaParty, notaryParty)
val issueTx = subFlow(issueCashFlow)
// NOTE: issueCashFlow performs a Broadcast (which stores a local copy of the txn to the ledger)
// short-circuit when issuing to self
@ -87,7 +87,7 @@ object IssuerFlow {
return issueTx
// now invoke Cash subflow to Move issued assetType to issue requester
progressTracker.currentStep = TRANSFERRING
val moveCashFlow = CashFlow(CashCommand.PayCash(amount.issuedBy(bankOfCordaParty.ref(issuerPartyRef)), issueTo))
val moveCashFlow = CashPaymentFlow(amount.issuedBy(bankOfCordaParty.ref(issuerPartyRef)), issueTo)
val moveTx = subFlow(moveCashFlow)
// NOTE: CashFlow PayCash calls FinalityFlow which performs a Broadcast (which stores a local copy of the txn to the ledger)
return moveTx

View File

@ -11,8 +11,9 @@ import net.corda.core.messaging.StateMachineUpdate
import net.corda.core.messaging.startFlow
import net.corda.core.node.NodeInfo
import net.corda.core.serialization.OpaqueBytes
import net.corda.flows.CashCommand
import net.corda.flows.CashFlow
import net.corda.core.transactions.SignedTransaction
import net.corda.flows.CashIssueFlow
import net.corda.flows.CashPaymentFlow
import net.corda.node.driver.DriverBasedTest
import net.corda.node.driver.NodeHandle
import net.corda.node.driver.driver
@ -35,7 +36,10 @@ class DistributedServiceTests : DriverBasedTest() {
override fun setup() = driver {
// Start Alice and 3 notaries in a RAFT cluster
val clusterSize = 3
val testUser = User("test", "test", permissions = setOf(startFlowPermission<CashFlow>()))
val testUser = User("test", "test", permissions = setOf(
startFlowPermission<CashIssueFlow>(),
startFlowPermission<CashPaymentFlow>())
)
val aliceFuture = startNode("Alice", rpcUsers = listOf(testUser))
val notariesFuture = startNotaryCluster(
"Notary",
@ -135,15 +139,15 @@ class DistributedServiceTests : DriverBasedTest() {
private fun issueCash(amount: Amount<Currency>) {
val issueHandle = aliceProxy.startFlow(
::CashFlow,
CashCommand.IssueCash(amount, OpaqueBytes.of(0), alice.nodeInfo.legalIdentity, raftNotaryIdentity))
::CashIssueFlow,
amount, OpaqueBytes.of(0), alice.nodeInfo.legalIdentity, raftNotaryIdentity)
issueHandle.returnValue.getOrThrow()
}
private fun paySelf(amount: Amount<Currency>) {
val payHandle = aliceProxy.startFlow(
::CashFlow,
CashCommand.PayCash(amount.issuedBy(alice.nodeInfo.legalIdentity.ref(0)), alice.nodeInfo.legalIdentity))
::CashPaymentFlow,
amount.issuedBy(alice.nodeInfo.legalIdentity.ref(0)), alice.nodeInfo.legalIdentity)
payHandle.returnValue.getOrThrow()
}
}
}

View File

@ -6,6 +6,8 @@ import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import com.google.common.util.concurrent.SettableFuture
import net.corda.core.*
import net.corda.core.contracts.Amount
import net.corda.core.contracts.PartyAndReference
import net.corda.core.crypto.Party
import net.corda.core.crypto.X509Utilities
import net.corda.core.flows.FlowLogic
@ -16,14 +18,12 @@ import net.corda.core.messaging.SingleMessageRecipient
import net.corda.core.node.*
import net.corda.core.node.services.*
import net.corda.core.node.services.NetworkMapCache.MapChange
import net.corda.core.serialization.OpaqueBytes
import net.corda.core.serialization.SingletonSerializeAsToken
import net.corda.core.serialization.deserialize
import net.corda.core.serialization.serialize
import net.corda.core.transactions.SignedTransaction
import net.corda.flows.CashCommand
import net.corda.flows.CashFlow
import net.corda.flows.FinalityFlow
import net.corda.flows.sendRequest
import net.corda.flows.*
import net.corda.node.services.api.*
import net.corda.node.services.config.NodeConfiguration
import net.corda.node.services.config.configureWithDevSSLCertificate
@ -51,7 +51,6 @@ import net.corda.node.utilities.databaseTransaction
import org.apache.activemq.artemis.utils.ReusableLatch
import org.jetbrains.exposed.sql.Database
import org.slf4j.Logger
import java.io.File
import java.nio.file.FileAlreadyExistsException
import java.nio.file.Path
import java.security.KeyPair
@ -82,11 +81,12 @@ abstract class AbstractNode(open val configuration: NodeConfiguration,
val PUBLIC_IDENTITY_FILE_NAME = "identity-public"
val defaultFlowWhiteList: Map<Class<out FlowLogic<*>>, Set<Class<*>>> = mapOf(
CashFlow::class.java to setOf(
CashCommand.IssueCash::class.java,
CashCommand.PayCash::class.java,
CashCommand.ExitCash::class.java
),
CashFlow::class.java to setOf(CashFlow.Command.IssueCash::class.java,
CashFlow.Command.PayCash::class.java,
CashFlow.Command.ExitCash::class.java),
CashExitFlow::class.java to setOf(Amount::class.java, PartyAndReference::class.java),
CashIssueFlow::class.java to setOf(Amount::class.java, OpaqueBytes::class.java, Party::class.java),
CashPaymentFlow::class.java to setOf(Amount::class.java, Party::class.java),
FinalityFlow::class.java to emptySet()
)
}

View File

@ -9,8 +9,8 @@ import net.corda.core.node.services.ServiceInfo
import net.corda.core.node.services.Vault
import net.corda.core.serialization.OpaqueBytes
import net.corda.core.transactions.SignedTransaction
import net.corda.flows.CashCommand
import net.corda.flows.CashFlow
import net.corda.flows.CashIssueFlow
import net.corda.flows.CashPaymentFlow
import net.corda.node.internal.CordaRPCOpsImpl
import net.corda.node.services.User
import net.corda.node.services.messaging.CURRENT_RPC_USER
@ -48,7 +48,10 @@ class CordaRPCOpsImplTest {
aliceNode = network.createNode(networkMapAddress = networkMap.info.address)
notaryNode = network.createNode(advertisedServices = ServiceInfo(SimpleNotaryService.type), networkMapAddress = networkMap.info.address)
rpc = CordaRPCOpsImpl(aliceNode.services, aliceNode.smm, aliceNode.database)
CURRENT_RPC_USER.set(User("user", "pwd", permissions = setOf(startFlowPermission<CashFlow>())))
CURRENT_RPC_USER.set(User("user", "pwd", permissions = setOf(
startFlowPermission<CashIssueFlow>(),
startFlowPermission<CashPaymentFlow>()
)))
databaseTransaction(aliceNode.database) {
stateMachineUpdates = rpc.stateMachinesAndUpdates().second
@ -69,8 +72,7 @@ class CordaRPCOpsImplTest {
// Tell the monitoring service node to issue some cash
val recipient = aliceNode.info.legalIdentity
val outEvent = CashCommand.IssueCash(Amount(quantity, GBP), ref, recipient, notaryNode.info.notaryIdentity)
rpc.startFlow(::CashFlow, outEvent)
rpc.startFlow(::CashIssueFlow, Amount(quantity, GBP), ref, recipient, notaryNode.info.notaryIdentity)
network.runNetwork()
val expectedState = Cash.State(Amount(quantity,
@ -107,19 +109,19 @@ class CordaRPCOpsImplTest {
@Test
fun `issue and move`() {
rpc.startFlow(::CashFlow, CashCommand.IssueCash(
amount = Amount(100, USD),
issueRef = OpaqueBytes(ByteArray(1, { 1 })),
recipient = aliceNode.info.legalIdentity,
notary = notaryNode.info.notaryIdentity
))
rpc.startFlow(::CashIssueFlow,
Amount(100, USD),
OpaqueBytes(ByteArray(1, { 1 })),
aliceNode.info.legalIdentity,
notaryNode.info.notaryIdentity
)
network.runNetwork()
rpc.startFlow(::CashFlow, CashCommand.PayCash(
amount = Amount(100, Issued(PartyAndReference(aliceNode.info.legalIdentity, OpaqueBytes(ByteArray(1, { 1 }))), USD)),
recipient = aliceNode.info.legalIdentity
))
rpc.startFlow(::CashPaymentFlow,
Amount(100, Issued(PartyAndReference(aliceNode.info.legalIdentity, OpaqueBytes(ByteArray(1, { 1 }))), USD)),
aliceNode.info.legalIdentity
)
network.runNetwork()
@ -188,12 +190,12 @@ class CordaRPCOpsImplTest {
fun `cash command by user not permissioned for cash`() {
CURRENT_RPC_USER.set(User("user", "pwd", permissions = emptySet()))
assertThatExceptionOfType(PermissionException::class.java).isThrownBy {
rpc.startFlow(::CashFlow, CashCommand.IssueCash(
amount = Amount(100, USD),
issueRef = OpaqueBytes(ByteArray(1, { 1 })),
recipient = aliceNode.info.legalIdentity,
notary = notaryNode.info.notaryIdentity
))
rpc.startFlow(::CashIssueFlow,
Amount(100, USD),
OpaqueBytes(ByteArray(1, { 1 })),
aliceNode.info.legalIdentity,
notaryNode.info.notaryIdentity
)
}
}
}

View File

@ -24,8 +24,8 @@ import net.corda.core.serialization.deserialize
import net.corda.core.utilities.unwrap
import net.corda.core.transactions.SignedTransaction
import net.corda.core.transactions.TransactionBuilder
import net.corda.flows.CashCommand
import net.corda.flows.CashFlow
import net.corda.flows.CashIssueFlow
import net.corda.flows.CashPaymentFlow
import net.corda.flows.FinalityFlow
import net.corda.flows.NotaryFlow
import net.corda.node.services.persistence.checkpoints
@ -320,16 +320,16 @@ class StateMachineManagerTests {
@Test
fun `different notaries are picked when addressing shared notary identity`() {
assertEquals(notary1.info.notaryIdentity, notary2.info.notaryIdentity)
node1.services.startFlow(CashFlow(CashCommand.IssueCash(
node1.services.startFlow(CashIssueFlow(
DOLLARS(2000),
OpaqueBytes.of(0x01),
node1.info.legalIdentity,
notary1.info.notaryIdentity)))
notary1.info.notaryIdentity))
// We pay a couple of times, the notary picking should go round robin
for (i in 1 .. 3) {
node1.services.startFlow(CashFlow(CashCommand.PayCash(
node1.services.startFlow(CashPaymentFlow(
DOLLARS(500).issuedBy(node1.info.legalIdentity.ref(0x01)),
node2.info.legalIdentity)))
node2.info.legalIdentity))
net.runNetwork()
}
val endpoint = net.messagingNetwork.endpoint(notary1.net.myAddress as InMemoryMessagingNetwork.PeerHandle)!!

View File

@ -41,9 +41,9 @@ Testing of the Bank of Corda application is demonstrated at two levels:
Security
The RPC API requires a client to pass in user credentials:
client.start("user1","test")
client.start("bankUser","test")
which are validated on the Bank of Corda node against those configured at node startup:
User("user1", "test", permissions = setOf(startFlowPermission<IssuerFlow.IssuanceRequester>()))
User("bankUser", "test", permissions = setOf(startFlowPermission<IssuerFlow.IssuanceRequester>()))
startNode("BankOfCorda", rpcUsers = listOf(user))
Notary

View File

@ -77,9 +77,9 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['build']) {
webPort 10005
cordapps = []
rpcUsers = [
['user' : "user1",
['user' : "bankUser",
'password' : "test",
'permissions' : ["StartFlow.net.corda.flows.CashFlow",
'permissions' : ["StartFlow.net.corda.flows.CashPaymentFlow",
"StartFlow.net.corda.flows.IssuerFlow\$IssuanceRequester"]]
]
}
@ -91,9 +91,9 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['build']) {
webPort 10007
cordapps = []
rpcUsers = [
['user' : "user1",
['user' : "bigCorpUser",
'password' : "test",
'permissions' : ["StartFlow.net.corda.flows.CashFlow"]]
'permissions' : ["StartFlow.net.corda.flows.CashPaymentFlow"]]
]
}
}

View File

@ -8,7 +8,7 @@ import net.corda.flows.IssuerFlow
import net.corda.core.node.services.ServiceInfo
import net.corda.core.node.services.ServiceType
import net.corda.core.transactions.SignedTransaction
import net.corda.flows.CashFlow
import net.corda.flows.CashPaymentFlow
import net.corda.node.driver.driver
import net.corda.node.services.User
import net.corda.node.services.startFlowPermission
@ -51,8 +51,8 @@ private class BankOfCordaDriver {
val role = options.valueOf(roleArg)!!
if (role == Role.ISSUER) {
driver(dsl = {
val bankUser = User(BANK_USERNAME, "test", permissions = setOf(startFlowPermission<CashFlow>(), startFlowPermission<IssuerFlow.IssuanceRequester>()))
val bigCorpUser = User(BIGCORP_USERNAME, "test", permissions = setOf(startFlowPermission<CashFlow>()))
val bankUser = User(BANK_USERNAME, "test", permissions = setOf(startFlowPermission<CashPaymentFlow>(), startFlowPermission<IssuerFlow.IssuanceRequester>()))
val bigCorpUser = User(BIGCORP_USERNAME, "test", permissions = setOf(startFlowPermission<CashPaymentFlow>()))
startNode("Notary", setOf(ServiceInfo(SimpleNotaryService.type)))
val bankOfCorda = startNode("BankOfCorda", rpcUsers = listOf(bankUser), advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("issuer.USD"))))
startNode("BigCorporation", rpcUsers = listOf(bigCorpUser))

View File

@ -32,7 +32,7 @@ class BankOfCordaClientApi(val hostAndPort: HostAndPort) {
fun requestRPCIssue(params: IssueRequestParams): SignedTransaction {
val client = CordaRPCClient(hostAndPort, configureTestSSL())
// TODO: privileged security controls required
client.start("user1", "test")
client.start("bankUser", "test")
val proxy = client.proxy()
// Resolve parties via RPC

View File

@ -28,7 +28,6 @@ import net.corda.explorer.model.ReportingCurrencyModel
import net.corda.explorer.views.bigDecimalFormatter
import net.corda.explorer.views.byteFormatter
import net.corda.explorer.views.stringConverter
import net.corda.flows.CashCommand
import net.corda.flows.CashFlow
import net.corda.flows.IssuerFlow.IssuanceRequester
import org.controlsfx.dialog.ExceptionDialog
@ -88,7 +87,7 @@ class NewTransaction : Fragment() {
}
dialog.show()
runAsync {
val handle = if (it is CashCommand.IssueCash) {
val handle = if (it is CashFlow.Command.IssueCash) {
myIdentity.value?.let { myIdentity ->
rpcProxy.value!!.startFlow(::IssuanceRequester,
it.amount,
@ -111,9 +110,9 @@ class NewTransaction : Fragment() {
Alert.AlertType.ERROR to response.message
} else {
val type = when (command) {
is CashCommand.IssueCash -> "Cash Issued"
is CashCommand.ExitCash -> "Cash Exited"
is CashCommand.PayCash -> "Cash Paid"
is CashFlow.Command.IssueCash -> "Cash Issued"
is CashFlow.Command.ExitCash -> "Cash Exited"
is CashFlow.Command.PayCash -> "Cash Paid"
}
Alert.AlertType.INFORMATION to "$type \nTransaction ID : ${(response as SignedTransaction).id}"
}
@ -128,7 +127,7 @@ class NewTransaction : Fragment() {
}
}
private fun dialog(window: Window) = Dialog<CashCommand>().apply {
private fun dialog(window: Window) = Dialog<CashFlow.Command>().apply {
dialogPane = root
initOwner(window)
setResultConverter {
@ -137,10 +136,10 @@ class NewTransaction : Fragment() {
when (it) {
executeButton -> when (transactionTypeCB.value) {
CashTransaction.Issue -> {
CashCommand.IssueCash(Amount(amount.value, currencyChoiceBox.value), issueRef, partyBChoiceBox.value.legalIdentity, notaries.first().notaryIdentity)
CashFlow.Command.IssueCash(Amount(amount.value, currencyChoiceBox.value), issueRef, partyBChoiceBox.value.legalIdentity, notaries.first().notaryIdentity)
}
CashTransaction.Pay -> CashCommand.PayCash(Amount(amount.value, Issued(PartyAndReference(issuerChoiceBox.value, issueRef), currencyChoiceBox.value)), partyBChoiceBox.value.legalIdentity)
CashTransaction.Exit -> CashCommand.ExitCash(Amount(amount.value, currencyChoiceBox.value), issueRef)
CashTransaction.Pay -> CashFlow.Command.PayCash(Amount(amount.value, Issued(PartyAndReference(issuerChoiceBox.value, issueRef), currencyChoiceBox.value)), partyBChoiceBox.value.legalIdentity)
CashTransaction.Exit -> CashFlow.Command.ExitCash(Amount(amount.value, currencyChoiceBox.value), issueRef)
else -> null
}
else -> null

View File

@ -12,7 +12,8 @@ import net.corda.core.flows.FlowException
import net.corda.core.getOrThrow
import net.corda.core.messaging.startFlow
import net.corda.core.serialization.OpaqueBytes
import net.corda.flows.CashCommand
import net.corda.core.toFuture
import net.corda.flows.CashException
import net.corda.flows.CashFlow
import net.corda.loadtest.LoadTest
import net.corda.loadtest.NodeHandle
@ -28,18 +29,18 @@ private val log = LoggerFactory.getLogger("CrossCash")
*/
data class CrossCashCommand(
val command: CashCommand,
val command: CashFlow.Command,
val node: NodeHandle
) {
override fun toString(): String {
return when (command) {
is CashCommand.IssueCash -> {
is CashFlow.Command.IssueCash -> {
"ISSUE ${node.info.legalIdentity} -> ${command.recipient} : ${command.amount}"
}
is CashCommand.PayCash -> {
is CashFlow.Command.PayCash -> {
"MOVE ${node.info.legalIdentity} -> ${command.recipient} : ${command.amount}"
}
is CashCommand.ExitCash -> {
is CashFlow.Command.ExitCash -> {
"EXIT ${node.info.legalIdentity} : ${command.amount}"
}
}
@ -145,7 +146,7 @@ val crossCashTest = LoadTest<CrossCashCommand, CrossCashState>(
interpret = { state, command ->
when (command.command) {
is CashCommand.IssueCash -> {
is CashFlow.Command.IssueCash -> {
val newDiffQueues = state.copyQueues()
val originators = newDiffQueues.getOrPut(command.command.recipient, { HashMap() })
val issuer = command.node.info.legalIdentity
@ -155,7 +156,7 @@ val crossCashTest = LoadTest<CrossCashCommand, CrossCashState>(
queue.add(Pair(issuer, quantity))
CrossCashState(state.nodeVaults, newDiffQueues)
}
is CashCommand.PayCash -> {
is CashFlow.Command.PayCash -> {
val newNodeVaults = state.copyVaults()
val newDiffQueues = state.copyQueues()
val recipientOriginators = newDiffQueues.getOrPut(command.command.recipient, { HashMap() })
@ -182,7 +183,7 @@ val crossCashTest = LoadTest<CrossCashCommand, CrossCashState>(
recipientQueue.add(Pair(issuer, quantity))
CrossCashState(newNodeVaults, newDiffQueues)
}
is CashCommand.ExitCash -> {
is CashFlow.Command.ExitCash -> {
val newNodeVaults = state.copyVaults()
val issuer = command.node.info.legalIdentity
val quantity = command.command.amount.quantity

View File

@ -8,7 +8,7 @@ import net.corda.core.contracts.PartyAndReference
import net.corda.core.crypto.AnonymousParty
import net.corda.core.crypto.Party
import net.corda.core.serialization.OpaqueBytes
import net.corda.flows.CashCommand
import net.corda.flows.CashFlow
import java.util.*
fun generateIssue(
@ -16,12 +16,12 @@ fun generateIssue(
currency: Currency,
notary: Party,
possibleRecipients: List<Party>
): Generator<CashCommand.IssueCash> {
): Generator<CashFlow.Command.IssueCash> {
return generateAmount(0, max, Generator.pure(currency)).combine(
Generator.pure(OpaqueBytes.of(0)),
Generator.pickOne(possibleRecipients)
) { amount, ref, recipient ->
CashCommand.IssueCash(amount, ref, recipient, notary)
CashFlow.Command.IssueCash(amount, ref, recipient, notary)
}
}
@ -30,19 +30,19 @@ fun generateMove(
currency: Currency,
issuer: AnonymousParty,
possibleRecipients: List<Party>
): Generator<CashCommand.PayCash> {
): Generator<CashFlow.Command.PayCash> {
return generateAmount(1, max, Generator.pure(Issued(PartyAndReference(issuer, OpaqueBytes.of(0)), currency))).combine(
Generator.pickOne(possibleRecipients)
) { amount, recipient ->
CashCommand.PayCash(amount, recipient)
CashFlow.Command.PayCash(amount, recipient)
}
}
fun generateExit(
max: Long,
currency: Currency
): Generator<CashCommand.ExitCash> {
): Generator<CashFlow.Command.ExitCash> {
return generateAmount(1, max, Generator.pure(currency)).map { amount ->
CashCommand.ExitCash(amount, OpaqueBytes.of(0))
CashFlow.Command.ExitCash(amount, OpaqueBytes.of(0))
}
}

View File

@ -11,7 +11,8 @@ import net.corda.core.crypto.Party
import net.corda.core.flows.FlowException
import net.corda.core.getOrThrow
import net.corda.core.messaging.startFlow
import net.corda.flows.CashCommand
import net.corda.core.toFuture
import net.corda.flows.CashException
import net.corda.flows.CashFlow
import net.corda.loadtest.LoadTest
import net.corda.loadtest.NodeHandle
@ -22,7 +23,7 @@ private val log = LoggerFactory.getLogger("SelfIssue")
// DOCS START 1
data class SelfIssueCommand(
val command: CashCommand.IssueCash,
val command: CashFlow.Command.IssueCash,
val node: NodeHandle
)