ENT-2320 Introduce BelongsToContract annotation (#1)

* ENT-2320 Introduce BelongsToContract annotation

* Update kdoc

* Eliminate duplicate warnings
This commit is contained in:
Dominic Fox 2018-08-30 10:02:18 +01:00 committed by GitHub
parent 63ebc394bf
commit 7ee946b98f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 219 additions and 94 deletions

View File

@ -453,6 +453,9 @@ public final class net.corda.core.contracts.AutomaticHashConstraint extends java
public boolean isSatisfiedBy(net.corda.core.contracts.Attachment)
public static final net.corda.core.contracts.AutomaticHashConstraint INSTANCE
##
public @interface net.corda.core.contracts.BelongsToContract
public abstract Class<? extends net.corda.core.contracts.Contract> value()
##
@CordaSerializable
public final class net.corda.core.contracts.Command extends java.lang.Object
public <init>(T, java.security.PublicKey)

View File

@ -0,0 +1,32 @@
package net.corda.core.contracts
import kotlin.reflect.KClass
/**
* This annotation is required by any [ContractState] which needs to ensure that it is only ever processed as part of a
* [TransactionState] referencing the specified [Contract]. It may be omitted in the case that the [ContractState] class
* is defined as an inner class of its owning [Contract] class, in which case the "X belongs to Y" relationship is taken
* to be implicitly declared.
*
* During verification of transactions, prior to their being written into the ledger, all input and output states are
* checked to ensure that their [ContractState]s match with their [Contract]s as specified either by this annotation, or
* by their inner/outer class relationship.
*
* The transaction will write a warning to the log if any mismatch is detected.
*
* @param value The class of the [Contract] to which states of the annotated [ContractState] belong.
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class BelongsToContract(val value: KClass<out Contract>)
/**
* Obtain the typename of the required [ContractClass] associated with the target [ContractState], using the
* [BelongsToContract] annotation by default, but falling through to checking the state's enclosing class if there is
* one and it inherits from [Contract].
*/
val ContractState.requiredContractClassName: String? get() {
val annotation = javaClass.getAnnotation(BelongsToContract::class.java)
if (annotation != null) {
return annotation.value.java.typeName
}
val enclosingClass = javaClass.enclosingClass ?: return null
return if (Contract::class.java.isAssignableFrom(enclosingClass)) enclosingClass.typeName else null
}

View File

@ -4,6 +4,7 @@ package net.corda.core.contracts
import net.corda.core.KeepForDJVM
import net.corda.core.identity.Party
import net.corda.core.serialization.CordaSerializable
import net.corda.core.utilities.loggerFor
// DOCSTART 1
typealias ContractClassName = String
@ -26,7 +27,14 @@ data class TransactionState<out T : ContractState> @JvmOverloads constructor(
* sent across, and run, from the network from within a sandbox environment.
*/
// TODO: Implement the contract sandbox loading of the contract attachments
val contract: ContractClassName,
val contract: ContractClassName = requireNotNull(data.requiredContractClassName) {
//TODO: add link to docsite page, when there is one.
"""
Unable to infer Contract class name because state class ${data::class.java.name} is not annotated with
@BelongsToContract, and does not have an enclosing class which implements Contract. Either annotate ${data::class.java.name}
with @BelongsToContract, or supply an explicit contract parameter to TransactionState().
""".trimIndent().replace('\n', ' ')
},
/** Identity of the notary that ensures the state is not used as an input to a transaction more than once */
val notary: Party,
/**
@ -50,5 +58,28 @@ data class TransactionState<out T : ContractState> @JvmOverloads constructor(
/**
* A validator for the contract attachments on the transaction.
*/
val constraint: AttachmentConstraint = AutomaticHashConstraint)
val constraint: AttachmentConstraint = AutomaticHashConstraint) {
private companion object {
val logger = loggerFor<TransactionState<*>>()
}
init {
when {
data.requiredContractClassName == null -> logger.warn(
"""
State class ${data::class.java.name} is not annotated with @BelongsToContract,
and does not have an enclosing class which implements Contract. Annotate ${data::class.java.simpleName}
with @BelongsToContract(${contract.split("\\.\\$").last()}.class) to remove this warning.
""".trimIndent().replace('\n', ' ')
)
data.requiredContractClassName != contract -> logger.warn(
"""
State class ${data::class.java.name} belongs to contract ${data.requiredContractClassName},
but is bundled with contract $contract in TransactionState. Annotate ${data::class.java.simpleName}
with @BelongsToContract(${contract.split("\\.\\$").last()}.class) to remove this warning.
""".trimIndent().replace('\n', ' ')
)
}
}
}
// DOCEND 1

View File

@ -9,9 +9,10 @@ import net.corda.core.internal.castIfPossible
import net.corda.core.internal.uncheckedCast
import net.corda.core.node.NetworkParameters
import net.corda.core.serialization.CordaSerializable
import net.corda.core.utilities.Try
import net.corda.core.utilities.loggerFor
import java.util.*
import java.util.function.Predicate
import net.corda.core.utilities.warnOnce
/**
* A LedgerTransaction is derived from a [WireTransaction]. It is the result of doing the following operations:
@ -54,22 +55,8 @@ data class LedgerTransaction @JvmOverloads constructor(
}
private companion object {
private fun contractClassFor(className: ContractClassName, classLoader: ClassLoader?): Try<Class<out Contract>> {
return Try.on {
(classLoader ?: this::class.java.classLoader)
.loadClass(className)
.asSubclass(Contract::class.java)
val logger = loggerFor<LedgerTransaction>()
}
}
private fun stateToContractClass(state: TransactionState<ContractState>): Try<Class<out Contract>> {
return contractClassFor(state.contract, state.data::class.java.classLoader)
}
}
// Input reference state contracts are not required for verification.
private val contracts: Map<ContractClassName, Try<Class<out Contract>>> = (inputs.map { it.state } + outputs)
.map { it.contract to stateToContractClass(it) }.toMap()
val inputStates: List<ContractState> get() = inputs.map { it.state.data }
val referenceStates: List<ContractState> get() = references.map { it.state.data }
@ -88,10 +75,31 @@ data class LedgerTransaction @JvmOverloads constructor(
*/
@Throws(TransactionVerificationException::class)
fun verify() {
validateStatesAgainstContract()
verifyConstraints()
verifyContracts()
}
private fun allStates() = inputs.asSequence().map { it.state } + outputs.asSequence()
/**
* For all input and output [TransactionState]s, validates that the wrapped [ContractState] matches up with the
* wrapped [Contract], as declared by the [BelongsToContract] annotation on the [ContractState]'s class.
*
* A warning will be written to the log if any mismatch is detected.
*/
private fun validateStatesAgainstContract() = allStates().forEach(::validateStateAgainstContract)
private fun validateStateAgainstContract(state: TransactionState<ContractState>) {
state.data.requiredContractClassName?.let { requiredContractClassName ->
if (state.contract != requiredContractClassName)
logger.warnOnce("""
State of class ${state.data::class.java.typeName} belongs to contract $requiredContractClassName, but
is bundled in TransactionState with ${state.contract}.
""".trimIndent().replace('\n', ' '))
}
}
/**
* Verify that all contract constraints are valid for each state before running any contract code
*
@ -102,54 +110,78 @@ data class LedgerTransaction @JvmOverloads constructor(
* @throws TransactionVerificationException if the constraints fail to verify
*/
private fun verifyConstraints() {
val contractAttachments = attachments.filterIsInstance<ContractAttachment>()
(inputs.map { it.state } + outputs).forEach { state ->
val stateAttachments = contractAttachments.filter { state.contract in it.allContracts }
if (stateAttachments.isEmpty()) throw TransactionVerificationException.MissingAttachmentRejection(id, state.contract)
val contractAttachmentsByContract = getUniqueContractAttachmentsByContract()
val uniqueAttachmentsForStateContract = stateAttachments.distinctBy { it.id }
for (state in allStates()) {
val contractAttachment = contractAttachmentsByContract[state.contract] ?:
throw TransactionVerificationException.MissingAttachmentRejection(id, state.contract)
// In case multiple attachments have been added for the same contract, fail because this transaction will not be able to be verified
// because it will break the no-overlap rule that we have implemented in our Classloaders
if (uniqueAttachmentsForStateContract.size > 1) {
throw TransactionVerificationException.ConflictingAttachmentsRejection(id, state.contract)
}
val constraintAttachment = AttachmentWithContext(contractAttachment, state.contract,
networkParameters?.whitelistedContractImplementations)
val contractAttachment = uniqueAttachmentsForStateContract.first()
val constraintAttachment = AttachmentWithContext(contractAttachment, state.contract, networkParameters?.whitelistedContractImplementations)
if (!state.constraint.isSatisfiedBy(constraintAttachment)) {
throw TransactionVerificationException.ContractConstraintRejection(id, state.contract)
}
}
}
private fun getUniqueContractAttachmentsByContract(): Map<ContractClassName, ContractAttachment> {
val result = mutableMapOf<ContractClassName, ContractAttachment>()
for (attachment in attachments) {
if (attachment !is ContractAttachment) continue
for (contract in attachment.allContracts) {
result.compute(contract) { _, previousAttachment ->
when {
previousAttachment == null -> attachment
attachment.id == previousAttachment.id -> previousAttachment
// In case multiple attachments have been added for the same contract, fail because this
// transaction will not be able to be verified because it will break the no-overlap rule
// that we have implemented in our Classloaders
else -> throw TransactionVerificationException.ConflictingAttachmentsRejection(id, contract)
}
}
}
}
return result
}
/**
* Check the transaction is contract-valid by running the verify() for each input and output state contract.
* If any contract fails to verify, the whole transaction is considered to be invalid.
*/
private fun verifyContracts() {
val contractInstances = ArrayList<Contract>(contracts.size)
for ((key, result) in contracts) {
when (result) {
is Try.Failure -> throw TransactionVerificationException.ContractCreationError(id, key, result.exception)
is Try.Success -> {
try {
contractInstances.add(result.value.newInstance())
} catch (e: Throwable) {
throw TransactionVerificationException.ContractCreationError(id, result.value.name, e)
}
}
}
}
contractInstances.forEach { contract ->
private fun verifyContracts() = allStates().forEach { ts ->
val contractClass = getContractClass(ts)
val contract = createContractInstance(contractClass)
try {
contract.verify(this)
} catch (e: Throwable) {
} catch (e: Exception) {
throw TransactionVerificationException.ContractRejection(id, contract, e)
}
}
// Obtain the contract class from the class name, wrapping any exception as a [ContractCreationError]
private fun getContractClass(ts: TransactionState<ContractState>): Class<out Contract> =
try {
(ts.data::class.java.classLoader ?: this::class.java.classLoader)
.loadClass(ts.contract)
.asSubclass(Contract::class.java)
} catch (e: Exception) {
throw TransactionVerificationException.ContractCreationError(id, ts.contract, e)
}
// Obtain an instance of the contract class, wrapping any exception as a [ContractCreationError]
private fun createContractInstance(contractClass: Class<out Contract>): Contract =
try {
contractClass.newInstance()
} catch (e: Exception) {
throw TransactionVerificationException.ContractCreationError(id, contractClass.name, e)
}
/**
* Make sure the notary has stayed the same. As we can't tell how inputs and outputs connect, if there
* are any inputs or reference inputs, all outputs must have the same notary.

View File

@ -17,6 +17,7 @@ import net.corda.core.node.services.AttachmentId
import net.corda.core.node.services.KeyManagementService
import net.corda.core.serialization.SerializationContext
import net.corda.core.serialization.SerializationFactory
import net.corda.core.utilities.loggerFor
import java.security.PublicKey
import java.time.Duration
import java.time.Instant
@ -47,6 +48,11 @@ open class TransactionBuilder @JvmOverloads constructor(
protected var privacySalt: PrivacySalt = PrivacySalt(),
protected val references: MutableList<StateRef> = arrayListOf()
) {
private companion object {
val logger = loggerFor<TransactionBuilder>()
}
private val inputsWithTransactionState = arrayListOf<TransactionState<ContractState>>()
private val referencesWithTransactionState = arrayListOf<TransactionState<ContractState>>()
@ -71,7 +77,7 @@ open class TransactionBuilder @JvmOverloads constructor(
// DOCSTART 1
/** A more convenient way to add items to this transaction that calls the add* methods for you based on type */
fun withItems(vararg items: Any): TransactionBuilder {
fun withItems(vararg items: Any) = apply {
for (t in items) {
when (t) {
is StateAndRef<*> -> addInputState(t)
@ -87,7 +93,6 @@ open class TransactionBuilder @JvmOverloads constructor(
else -> throw IllegalArgumentException("Wrong argument type: ${t.javaClass}")
}
}
return this
}
// DOCEND 1
@ -212,7 +217,7 @@ open class TransactionBuilder @JvmOverloads constructor(
* Note: Reference states are only supported on Corda networks running a minimum platform version of 4.
* [toWireTransaction] will throw an [IllegalStateException] if called in such an environment.
*/
open fun addReferenceState(referencedStateAndRef: ReferencedStateAndRef<*>): TransactionBuilder {
open fun addReferenceState(referencedStateAndRef: ReferencedStateAndRef<*>) = apply {
val stateAndRef = referencedStateAndRef.stateAndRef
referencesWithTransactionState.add(stateAndRef.state)
@ -235,34 +240,37 @@ open class TransactionBuilder @JvmOverloads constructor(
checkNotary(stateAndRef)
references.add(stateAndRef.ref)
checkForInputsAndReferencesOverlap()
return this
}
/** Adds an input [StateRef] to the transaction. */
open fun addInputState(stateAndRef: StateAndRef<*>): TransactionBuilder {
open fun addInputState(stateAndRef: StateAndRef<*>) = apply {
checkNotary(stateAndRef)
inputs.add(stateAndRef.ref)
inputsWithTransactionState.add(stateAndRef.state)
return this
}
/** Adds an attachment with the specified hash to the TransactionBuilder. */
fun addAttachment(attachmentId: SecureHash): TransactionBuilder {
fun addAttachment(attachmentId: SecureHash) = apply {
attachments.add(attachmentId)
return this
}
/** Adds an output state to the transaction. */
fun addOutputState(state: TransactionState<*>): TransactionBuilder {
fun addOutputState(state: TransactionState<*>) = apply {
outputs.add(state)
return this
}
/** Adds an output state, with associated contract code (and constraints), and notary, to the transaction. */
@JvmOverloads
fun addOutputState(
state: ContractState,
contract: ContractClassName,
contract: ContractClassName = requireNotNull(state.requiredContractClassName) {
//TODO: add link to docsite page, when there is one.
"""
Unable to infer Contract class name because state class ${state::class.java.name} is not annotated with
@BelongsToContract, and does not have an enclosing class which implements Contract. Either annotate ${state::class.java.name}
with @BelongsToContract, or supply an explicit contract parameter to addOutputState().
""".trimIndent().replace('\n', ' ')
},
notary: Party, encumbrance: Int? = null,
constraint: AttachmentConstraint = AutomaticHashConstraint
): TransactionBuilder {
@ -272,20 +280,26 @@ open class TransactionBuilder @JvmOverloads constructor(
/** A default notary must be specified during builder construction to use this method */
@JvmOverloads
fun addOutputState(
state: ContractState, contract: ContractClassName,
state: ContractState,
contract: ContractClassName = requireNotNull(state.requiredContractClassName) {
//TODO: add link to docsite page, when there is one.
"""
Unable to infer Contract class name because state class ${state::class.java.name} is not annotated with
@BelongsToContract, and does not have an enclosing class which implements Contract. Either annotate ${state::class.java.name}
with @BelongsToContract, or supply an explicit contract parameter to addOutputState().
""".trimIndent().replace('\n', ' ')
},
constraint: AttachmentConstraint = AutomaticHashConstraint
): TransactionBuilder {
) = apply {
checkNotNull(notary) {
"Need to specify a notary for the state, or set a default one on TransactionBuilder initialisation"
}
addOutputState(state, contract, notary!!, constraint = constraint)
return this
}
/** Adds a [Command] to the transaction. */
fun addCommand(arg: Command<*>): TransactionBuilder {
fun addCommand(arg: Command<*>) = apply {
commands.add(arg)
return this
}
/**
@ -301,10 +315,9 @@ open class TransactionBuilder @JvmOverloads constructor(
* transaction must then be signed by the notary service within this window of time. In this way, the notary acts as
* the Timestamp Authority.
*/
fun setTimeWindow(timeWindow: TimeWindow): TransactionBuilder {
fun setTimeWindow(timeWindow: TimeWindow) = apply {
check(notary != null) { "Only notarised transactions can have a time-window" }
window = timeWindow
return this
}
/**
@ -316,9 +329,8 @@ open class TransactionBuilder @JvmOverloads constructor(
*/
fun setTimeWindow(time: Instant, timeTolerance: Duration) = setTimeWindow(TimeWindow.withTolerance(time, timeTolerance))
fun setPrivacySalt(privacySalt: PrivacySalt): TransactionBuilder {
fun setPrivacySalt(privacySalt: PrivacySalt) = apply {
this.privacySalt = privacySalt
return this
}
/** Returns an immutable list of input [StateRef]s. */

View File

@ -135,3 +135,15 @@ fun <V> Future<V>.getOrThrow(timeout: Duration? = null): V = try {
throw e.cause!!
}
private val warnings = mutableSetOf<String>()
/**
* Utility to help log a warning message only once.
* It's not thread safe, as in the worst case the warning will be logged twice.
*/
fun Logger.warnOnce(warning: String) {
if (warning !in warnings) {
warnings.add(warning)
this.warn(warning)
}
}

View File

@ -146,6 +146,7 @@ class ContractUpgradeFlowTest : WithContracts, WithFinality {
class Move : TypeOnlyCommandData()
@BelongsToContract(CashV2::class)
data class State(override val amount: Amount<Issued<Currency>>, val owners: List<AbstractParty>) : FungibleAsset<Currency> {
override val owner: AbstractParty = owners.first()
override val exitKeys = (owners + amount.token.issuer.party).map { it.owningKey }.toSet()

View File

@ -46,11 +46,12 @@ class LedgerTransactionQueryTests {
data class Cmd3(val id: Int) : CommandData, Commands // Unused command, required for command not-present checks.
}
@BelongsToContract(DummyContract::class)
private class StringTypeDummyState(val data: String) : ContractState {
override val participants: List<AbstractParty> = emptyList()
}
@BelongsToContract(DummyContract::class)
private class IntTypeDummyState(val data: Int) : ContractState {
override val participants: List<AbstractParty> = emptyList()
}

View File

@ -250,7 +250,7 @@ public class CommercialPaperTest {
// Some CP is issued onto the ledger by MegaCorp.
l.transaction("Issuance", tx -> {
tx.output(Cash.PROGRAM_ID, "paper", getPaper());
tx.output(JCP_PROGRAM_ID, "paper", getPaper());
tx.command(megaCorp.getPublicKey(), new JavaCommercialPaper.Commands.Issue());
tx.attachments(JCP_PROGRAM_ID);
tx.timeWindow(TEST_TX_TIME);
@ -296,7 +296,7 @@ public class CommercialPaperTest {
// Some CP is issued onto the ledger by MegaCorp.
l.transaction("Issuance", tx -> {
tx.output(Cash.PROGRAM_ID, "paper", getPaper());
tx.output(JCP_PROGRAM_ID, "paper", getPaper());
tx.command(megaCorp.getPublicKey(), new JavaCommercialPaper.Commands.Issue());
tx.attachments(JCP_PROGRAM_ID);
tx.timeWindow(TEST_TX_TIME);
@ -309,7 +309,7 @@ public class CommercialPaperTest {
tx.output(Cash.PROGRAM_ID, "borrowed $900", new Cash.State(issuedBy(DOLLARS(900), issuer), megaCorp.getParty()));
JavaCommercialPaper.State inputPaper = l.retrieveOutput(JavaCommercialPaper.State.class, "paper");
tx.output(JCP_PROGRAM_ID, "alice's paper", inputPaper.withOwner(alice.getParty()));
tx.command(alice.getPublicKey(), new Cash.Commands.Move());
tx.command(alice.getPublicKey(), new Cash.Commands.Move(JavaCommercialPaper.class));
tx.command(megaCorp.getPublicKey(), new JavaCommercialPaper.Commands.Move());
return tx.verifies();
});

View File

@ -295,7 +295,7 @@ class CommercialPaperTest {
input("alice's $900")
output(Cash.PROGRAM_ID, "borrowed $900", 900.DOLLARS.CASH issuedBy issuer ownedBy megaCorp.party)
output(CP_PROGRAM_ID, "alice's paper", "paper".output<ICommercialPaperState>().withOwner(alice.party))
command(alice.publicKey, Cash.Commands.Move())
command(alice.publicKey, Cash.Commands.Move(CommercialPaper::class.java))
command(megaCorp.publicKey, CommercialPaper.Commands.Move())
verifies()
}

View File

@ -50,6 +50,7 @@ class Cash : OnLedgerAsset<Currency, Cash.Commands, Cash.State>() {
// DOCSTART 1
/** A state representing a cash claim against some party. */
@BelongsToContract(Cash::class)
data class State(
override val amount: Amount<Issued<Currency>>,

View File

@ -96,11 +96,11 @@ class ObligationTests {
group: LedgerDSL<TestTransactionDSLInterpreter, TestLedgerDSLInterpreter>
) = group.apply {
unverifiedTransaction {
attachments(Obligation.PROGRAM_ID)
attachments(Obligation.PROGRAM_ID, Cash.PROGRAM_ID)
output(Obligation.PROGRAM_ID, "Alice's $1,000,000 obligation to Bob", oneMillionDollars.OBLIGATION between Pair(ALICE, BOB))
output(Obligation.PROGRAM_ID, "Bob's $1,000,000 obligation to Alice", oneMillionDollars.OBLIGATION between Pair(BOB, ALICE))
output(Obligation.PROGRAM_ID, "MegaCorp's $1,000,000 obligation to Bob", oneMillionDollars.OBLIGATION between Pair(MEGA_CORP, BOB))
output(Obligation.PROGRAM_ID, "Alice's $1,000,000", 1000000.DOLLARS.CASH issuedBy defaultIssuer ownedBy ALICE)
output(Cash.PROGRAM_ID, "Alice's $1,000,000", 1000000.DOLLARS.CASH issuedBy defaultIssuer ownedBy ALICE)
}
}
@ -506,10 +506,10 @@ class ObligationTests {
ledgerServices.ledger(DUMMY_NOTARY) {
cashObligationTestRoots(this)
transaction("Settlement") {
attachments(Obligation.PROGRAM_ID)
attachments(Obligation.PROGRAM_ID, Cash.PROGRAM_ID)
input("Alice's $1,000,000 obligation to Bob")
input("Alice's $1,000,000")
output(Obligation.PROGRAM_ID, "Bob's $1,000,000", 1000000.DOLLARS.CASH issuedBy defaultIssuer ownedBy BOB)
output(Cash.PROGRAM_ID, "Bob's $1,000,000", 1000000.DOLLARS.CASH issuedBy defaultIssuer ownedBy BOB)
command(ALICE_PUBKEY, Obligation.Commands.Settle(Amount(oneMillionDollars.quantity, inState.amount.token)))
command(ALICE_PUBKEY, Cash.Commands.Move(Obligation::class.java))
attachment(attachment(cashContractBytes.inputStream()))
@ -525,7 +525,7 @@ class ObligationTests {
input(Obligation.PROGRAM_ID, oneMillionDollars.OBLIGATION between Pair(ALICE, BOB))
input(Cash.PROGRAM_ID, 500000.DOLLARS.CASH issuedBy defaultIssuer ownedBy ALICE)
output(Obligation.PROGRAM_ID, "Alice's $500,000 obligation to Bob", halfAMillionDollars.OBLIGATION between Pair(ALICE, BOB))
output(Obligation.PROGRAM_ID, "Bob's $500,000", 500000.DOLLARS.CASH issuedBy defaultIssuer ownedBy BOB)
output(Cash.PROGRAM_ID, "Bob's $500,000", 500000.DOLLARS.CASH issuedBy defaultIssuer ownedBy BOB)
command(ALICE_PUBKEY, Obligation.Commands.Settle(Amount(oneMillionDollars.quantity / 2, inState.amount.token)))
command(ALICE_PUBKEY, Cash.Commands.Move(Obligation::class.java))
attachment(attachment(cashContractBytes.inputStream()))
@ -540,7 +540,7 @@ class ObligationTests {
attachments(Obligation.PROGRAM_ID, Cash.PROGRAM_ID)
input(Obligation.PROGRAM_ID, defaultedObligation) // Alice's defaulted $1,000,000 obligation to Bob
input(Cash.PROGRAM_ID, 1000000.DOLLARS.CASH issuedBy defaultIssuer ownedBy ALICE)
output(Obligation.PROGRAM_ID, "Bob's $1,000,000", 1000000.DOLLARS.CASH issuedBy defaultIssuer ownedBy BOB)
output(Cash.PROGRAM_ID, "Bob's $1,000,000", 1000000.DOLLARS.CASH issuedBy defaultIssuer ownedBy BOB)
command(ALICE_PUBKEY, Obligation.Commands.Settle(Amount(oneMillionDollars.quantity, inState.amount.token)))
command(ALICE_PUBKEY, Cash.Commands.Move(Obligation::class.java))
this `fails with` "all inputs are in the normal state"
@ -554,7 +554,7 @@ class ObligationTests {
attachments(Obligation.PROGRAM_ID)
input("Alice's $1,000,000 obligation to Bob")
input("Alice's $1,000,000")
output(Obligation.PROGRAM_ID, "Bob's $1,000,000", 1000000.DOLLARS.CASH issuedBy defaultIssuer ownedBy BOB)
output(Cash.PROGRAM_ID, "Bob's $1,000,000", 1000000.DOLLARS.CASH issuedBy defaultIssuer ownedBy BOB)
command(ALICE_PUBKEY, Obligation.Commands.Settle(Amount(oneMillionDollars.quantity / 2, inState.amount.token)))
command(ALICE_PUBKEY, Cash.Commands.Move(Obligation::class.java))
attachment(attachment(cashContractBytes.inputStream()))