diff --git a/src/contracts/CrowdFund.kt b/src/contracts/CrowdFund.kt index 3b69aeb508..ec7096f2f1 100644 --- a/src/contracts/CrowdFund.kt +++ b/src/contracts/CrowdFund.kt @@ -21,8 +21,8 @@ class CrowdFund : Contract { data class State( val owner: PublicKey, - val fundingName: String, - val fundingTarget: Amount, + val campaignName: String, + val campaignTarget: Amount, val closingTime: Instant, val closed: Boolean = false, val pledgeTotal: Amount = 0.DOLLARS, @@ -30,7 +30,7 @@ class CrowdFund : Contract { val pledges: List = ArrayList() ) : ContractState { override val programRef = CROWDFUND_PROGRAM_ID - override fun toString() = "Crowdsourcing($fundingTarget sought by $owner by $closingTime)" + override fun toString() = "Crowdsourcing($campaignTarget sought by $owner by $closingTime)" } data class Pledge( @@ -41,9 +41,8 @@ class CrowdFund : Contract { interface Commands : Command { class Register : TypeOnlyCommand(), Commands - class Fund : TypeOnlyCommand(), Commands - class Funded : TypeOnlyCommand(), Commands - class Unfunded : TypeOnlyCommand(), Commands + class Pledge : TypeOnlyCommand(), Commands + class Close : TypeOnlyCommand(), Commands } override fun verify(tx: TransactionForVerification) { @@ -52,54 +51,60 @@ class CrowdFund : Contract { // The third is closing it on or after the closing date, and returning funds to // pledge-makers if the target is unmet, or passing to the recipient. val command = tx.commands.requireSingleCommand() - val outputCrowdFund: CrowdFund.State = tx.outStates.filterIsInstance().single() val outputCash: List = tx.outStates.filterIsInstance() + val time = tx.time + + fun requireThatAttributesUnchanged(inputCrowdFund: CrowdFund.State, outputCrowdFund: CrowdFund.State) { + requireThat { + "the owner hasn't changed" by (outputCrowdFund.owner == inputCrowdFund.owner) + "the funding name has not changed" by (outputCrowdFund.campaignName == inputCrowdFund.campaignName) + "the funding target has not changed" by (outputCrowdFund.campaignTarget == inputCrowdFund.campaignTarget) + "the closing time has not changed" by (outputCrowdFund.closingTime == inputCrowdFund.closingTime) + "the pledged total currency is unchanged" by (outputCrowdFund.pledgeTotal.currency == inputCrowdFund.pledgeTotal.currency) + } + } when (command.value) { is Commands.Register -> { requireThat { + "there is no input state" by tx.inStates.filterIsInstance().isEmpty() "the transaction is signed by the owner of the crowdsourcing" by (command.signers.contains(outputCrowdFund.owner)) "the output registration is empty of pledges" by (outputCrowdFund.pledges.isEmpty()) - "the output registration has a non-zero target" by (outputCrowdFund.fundingTarget.pennies > 0) + "the output registration has a non-zero target" by (outputCrowdFund.campaignTarget.pennies > 0) "the output registration has a zero starting pledge total" by (outputCrowdFund.pledgeTotal.pennies == 0) "the output registration has a zero starting pledge count" by (outputCrowdFund.pledgeCount == 0) "the output registration has a funding currency" by (outputCrowdFund.pledgeTotal.currency.currencyCode.isNotBlank()) // TODO is this necessary? currency is not nullable - "the output registration has a name" by (outputCrowdFund.fundingName.isNotBlank()) + "the output registration has a name" by (outputCrowdFund.campaignName.isNotBlank()) "the output registration has a closing time in the future" by (outputCrowdFund.closingTime > tx.time) "the output registration has an open state" by (!outputCrowdFund.closed) } } - is Commands.Fund -> { + is Commands.Pledge -> { val inputCrowdFund: CrowdFund.State = tx.inStates.filterIsInstance().single() val inputCash: List = tx.inStates.filterIsInstance() val pledge = outputCrowdFund.pledges.last() val pledgedCash = outputCash.single() - val time = tx.time ?: throw IllegalStateException("Transaction must be timestamped") + if (time == null) throw IllegalArgumentException("Redemption transactions must be timestamped") + requireThatAttributesUnchanged(inputCrowdFund, outputCrowdFund) requireThat { - "the funding is still open" by (time <= inputCrowdFund.closingTime) + "the campaign is still open" by (inputCrowdFund.closingTime >= time) // TODO "the transaction is signed by the owner of the pledge" by (command.signers.contains(inputCrowdFund.owner)) "the transaction is signed by the pledge-maker" by (command.signers.contains(pledge.owner)) "the pledge must be for a non-zero amount" by (pledge.amount.pennies > 0) - "the pledge must be in the same currency as the goal" by (pledge.amount.currency == outputCrowdFund.fundingTarget.currency) + "the pledge must be in the same currency as the goal" by (pledge.amount.currency == outputCrowdFund.campaignTarget.currency) "the number of pledges must have increased by one" by (outputCrowdFund.pledgeCount == inputCrowdFund.pledgeCount + 1) "the pledged total has increased by the value of the pledge" by (outputCrowdFund.pledgeTotal.pennies == inputCrowdFund.pledgeTotal.pennies + inputCash.sumCash().pennies) "the pledge has been added to the list of pledges" by (outputCrowdFund.pledges.size == outputCrowdFund.pledgeCount) - "the cash input has been assigned to the funding owner" by (pledgedCash.owner == inputCrowdFund.owner) - // TODO how to simplify the boilerplate associated with unchanged elements - "the owner hasn't changed" by (outputCrowdFund.owner == inputCrowdFund.owner) - "the funding name has not changed" by (outputCrowdFund.fundingName == inputCrowdFund.fundingName) - "the funding target has not changed" by (outputCrowdFund.fundingTarget == inputCrowdFund.fundingTarget) - "the closing time has not changed" by (outputCrowdFund.closingTime == inputCrowdFund.closingTime) - "the pledged total currency is unchanged" by (outputCrowdFund.pledgeTotal.currency == inputCrowdFund.pledgeTotal.currency) + "the cash input has been assigned to the campaign owner" by (pledgedCash.owner == inputCrowdFund.owner) "the output registration has an open state" by (!outputCrowdFund.closed) } } - is Commands.Unfunded -> { + is Commands.Close -> { val inputCrowdFund: CrowdFund.State = tx.inStates.filterIsInstance().single() - // TODO how can this be made smarter? feels wrong as a separate function + // TODO include inline rather than as separate function fun checkReturns(inputCrowdFund: CrowdFund.State, outputCash: List): Boolean { for (pledge in inputCrowdFund.pledges) { if (outputCash.filter { it.amount == pledge.amount && it.owner == pledge.owner }.isEmpty()) return false @@ -107,75 +112,61 @@ class CrowdFund : Contract { return true } - val time = tx.time ?: throw IllegalStateException("Transaction must be timestamped") - + if (time == null) throw IllegalArgumentException("Redemption transactions must be timestamped") + requireThatAttributesUnchanged(inputCrowdFund, outputCrowdFund) requireThat { "the closing date has past" by (time >= outputCrowdFund.closingTime) - "the pledges did not meet the target" by (inputCrowdFund.pledgeTotal < inputCrowdFund.fundingTarget) - "the output cash returns equal the pledge total, if the target is not reached" by (outputCash.sumCash() == inputCrowdFund.pledgeTotal) - "the output cash is distributed to the pledge-makers, if the target is not reached" by (checkReturns(inputCrowdFund, outputCash)) - "the output cash is distributed to the pledge-makers, if the target is not reached" by (outputCash.map { it.amount }.containsAll(inputCrowdFund.pledges.map { it.amount })) "the input has an open state" by (!inputCrowdFund.closed) "the output registration has a closed state" by (outputCrowdFund.closed) - // TODO how to simplify the boilerplate associated with unchanged elements - "the owner hasn't changed" by (outputCrowdFund.owner == inputCrowdFund.owner) - "the funding name has not changed" by (outputCrowdFund.fundingName == inputCrowdFund.fundingName) - "the funding target has not changed" by (outputCrowdFund.fundingTarget == inputCrowdFund.fundingTarget) - "the closing time has not changed" by (outputCrowdFund.closingTime == inputCrowdFund.closingTime) - "the pledged total is unchanged" by (outputCrowdFund.pledgeTotal == inputCrowdFund.pledgeTotal) - "the pledged count is unchanged" by (outputCrowdFund.pledgeCount == inputCrowdFund.pledgeCount) - "the pledges are unchanged" by (outputCrowdFund.pledges == inputCrowdFund.pledges) - } - } - - is Commands.Funded -> { - val inputCrowdFund: CrowdFund.State = tx.inStates.filterIsInstance().single() - val time = tx.time ?: throw IllegalStateException("Transaction must be timestamped") - requireThat { - "the closing date has past" by (time >= outputCrowdFund.closingTime) - "the input has an open state" by (!inputCrowdFund.closed) - "the output registration has a closed state" by (outputCrowdFund.closed) - // TODO how to simplify the boilerplate associated with unchanged elements - "the owner hasn't changed" by (outputCrowdFund.owner == inputCrowdFund.owner) - "the funding name has not changed" by (outputCrowdFund.fundingName == inputCrowdFund.fundingName) - "the funding target has not changed" by (outputCrowdFund.fundingTarget == inputCrowdFund.fundingTarget) - "the closing time has not changed" by (outputCrowdFund.closingTime == inputCrowdFund.closingTime) + // Now check whether the target was not, and if so, return cash + if (inputCrowdFund.pledgeTotal < inputCrowdFund.campaignTarget) { + "the output cash returns equal the pledge total, if the target is not reached" by (outputCash.sumCash() == inputCrowdFund.pledgeTotal) + "the output cash is distributed to the pledge-makers, if the target is not reached" by (checkReturns(inputCrowdFund, outputCash)) + "the output cash is distributed to the pledge-makers, if the target is not reached" by (outputCash.map { it.amount }.containsAll(inputCrowdFund.pledges.map { it.amount })) + } "the pledged total is unchanged" by (outputCrowdFund.pledgeTotal == inputCrowdFund.pledgeTotal) "the pledged count is unchanged" by (outputCrowdFund.pledgeCount == inputCrowdFund.pledgeCount) "the pledges are unchanged" by (outputCrowdFund.pledges == inputCrowdFund.pledges) } } + // TODO: Think about how to evolve contracts over time with new commands. else -> throw IllegalArgumentException("Unrecognised command") } } /** - * Returns a transaction that registers a crowd-funding campaing, owned by the issuing parties key. Does not update + * Returns a transaction that registers a crowd-funding campaing, owned by the issuing institution's key. Does not update * an existing transaction because it's not possible to register multiple campaigns in a single transaction */ fun craftRegister(owner: PartyReference, fundingTarget: Amount, fundingName: String, closingTime: Instant): PartialTransaction { - val state = State(owner = owner.party.owningKey, fundingName = fundingName, fundingTarget = fundingTarget, closingTime = closingTime) - return PartialTransaction(state, WireCommand(CrowdFund.Commands.Register(), owner.party.owningKey)) + val state = State(owner = owner.party.owningKey, campaignName = fundingName, campaignTarget = fundingTarget, closingTime = closingTime) + return PartialTransaction(state, WireCommand(Commands.Register(), owner.party.owningKey)) } /** * Updates the given partial transaction with an input/output/command to fund the opportunity. */ - fun craftFund(tx: PartialTransaction, campaign: StateAndRef, subscriber: PublicKey) { + fun craftPledge(tx: PartialTransaction, campaign: StateAndRef, subscriber: PublicKey) { tx.addInputState(campaign.ref) tx.addOutputState(campaign.state.copy( pledges = campaign.state.pledges + CrowdFund.Pledge(subscriber, 1000.DOLLARS), pledgeCount = campaign.state.pledgeCount + 1, pledgeTotal = campaign.state.pledgeTotal + 1000.DOLLARS )) - tx.addArg(WireCommand(CrowdFund.Commands.Fund(), subscriber)) + tx.addArg(WireCommand(Commands.Pledge(), subscriber)) } - fun craftFunded(tx: PartialTransaction, campaign: StateAndRef) { + fun craftClose(tx: PartialTransaction, campaign: StateAndRef, wallet: List>) { tx.addInputState(campaign.ref) tx.addOutputState(campaign.state.copy(closed = true)) - tx.addArg(WireCommand(CrowdFund.Commands.Funded(), campaign.state.owner)) + tx.addArg(WireCommand(Commands.Close(), campaign.state.owner)) + // If campaign target has not been met, compose cash returns + if (campaign.state.pledgeTotal < campaign.state.campaignTarget) { + for (pledge in campaign.state.pledges) { + Cash().craftSpend(tx, pledge.amount, pledge.owner, wallet) + } + } } override val legalContractReference: SecureHash = SecureHash.sha256("Crowdsourcing") diff --git a/src/core/serialization/Kryo.kt b/src/core/serialization/Kryo.kt index 7b1b7c1f9f..64a553d36e 100644 --- a/src/core/serialization/Kryo.kt +++ b/src/core/serialization/Kryo.kt @@ -260,8 +260,8 @@ fun createKryo(): Kryo { registerDataClass() registerDataClass() register(CrowdFund.Commands.Register::class.java) - register(CrowdFund.Commands.Fund::class.java) - register(CrowdFund.Commands.Funded::class.java) + register(CrowdFund.Commands.Pledge::class.java) + register(CrowdFund.Commands.Close::class.java) // And for unit testing ... registerDataClass() diff --git a/tests/contracts/CrowdFundTests.kt b/tests/contracts/CrowdFundTests.kt index dcf0787757..cf332baa00 100644 --- a/tests/contracts/CrowdFundTests.kt +++ b/tests/contracts/CrowdFundTests.kt @@ -7,13 +7,16 @@ package contracts import core.* import core.testutils.* import org.junit.Test +import java.time.Instant import java.util.* +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue class CrowdFundTests { val CF_1 = CrowdFund.State( owner = MINI_CORP_PUBKEY, - fundingName = "kickstart me", - fundingTarget = 1000.DOLLARS, + campaignName = "kickstart me", + campaignTarget = 1000.DOLLARS, pledgeTotal = 0.DOLLARS, pledgeCount = 0, closingTime = TEST_TX_TIME + 7.days, @@ -75,14 +78,14 @@ class CrowdFundTests { } output { 1000.DOLLARS.CASH `owned by` MINI_CORP_PUBKEY } arg(ALICE) { Cash.Commands.Move() } - arg(ALICE) { CrowdFund.Commands.Fund() } + arg(ALICE) { CrowdFund.Commands.Pledge() } } // 3. Close the opportunity, assuming the target has been met transaction(TEST_TX_TIME + 8.days) { input ("pledged opportunity") output ("funded and closed") { "pledged opportunity".output.copy(closed = true) } - arg(MINI_CORP_PUBKEY) { CrowdFund.Commands.Funded() } + arg(MINI_CORP_PUBKEY) { CrowdFund.Commands.Close() } } } } @@ -93,8 +96,8 @@ class CrowdFundTests { } @Test - fun `raise more funds`() { - // MiniCorp registers a crowdfunding of $1,000, to close in 30 days. + fun `raise more funds using output-state generation functions`() { + // MiniCorp registers a crowdfunding of $1,000, to close in 7 days. val registerTX: LedgerTransaction = run { // craftRegister returns a partial transaction val ptx = CrowdFund().craftRegister(MINI_CORP.ref(123), 1000.DOLLARS, "crowd funding", TEST_TX_TIME + 7.days) @@ -113,7 +116,7 @@ class CrowdFundTests { // Alice pays $1000 to MiniCorp to fund their campaign. val pledgeTX: LedgerTransaction = run { val ptx = PartialTransaction() - CrowdFund().craftFund(ptx, registerTX.outRef(0), ALICE) + CrowdFund().craftPledge(ptx, registerTX.outRef(0), ALICE) Cash().craftSpend(ptx, 1000.DOLLARS, MINI_CORP_PUBKEY, aliceWallet) ptx.signWith(ALICE_KEY) val stx = ptx.toSignedTransaction() @@ -121,17 +124,30 @@ class CrowdFundTests { stx.verify().toLedgerTransaction(TEST_TX_TIME, TEST_KEYS_TO_CORP_MAP, SecureHash.randomSHA256()) } + // Won't be validated. + val (miniCorpWalletTx, miniCorpWallet) = cashOutputsToWallet( + 900.DOLLARS.CASH `owned by` MINI_CORP_PUBKEY, + 400.DOLLARS.CASH `owned by` MINI_CORP_PUBKEY + ) // MiniCorp closes their campaign. - val fundedTX: LedgerTransaction = run { + fun makeFundedTX(time: Instant): LedgerTransaction { val ptx = PartialTransaction() - CrowdFund().craftFunded(ptx, pledgeTX.outRef(0)) + CrowdFund().craftClose(ptx, pledgeTX.outRef(0), miniCorpWallet) ptx.signWith(MINI_CORP_KEY) val stx = ptx.toSignedTransaction() - stx.verify().toLedgerTransaction(TEST_TX_TIME + 8.days, TEST_KEYS_TO_CORP_MAP, SecureHash.randomSHA256()) + return stx.verify().toLedgerTransaction(time, TEST_KEYS_TO_CORP_MAP, SecureHash.randomSHA256()) } + val tooEarlyClose = makeFundedTX(TEST_TX_TIME + 6.days) + val validClose = makeFundedTX(TEST_TX_TIME + 8.days) + + val e = assertFailsWith(TransactionVerificationException::class) { + TransactionGroup(setOf(registerTX, pledgeTX, tooEarlyClose), setOf(miniCorpWalletTx, aliceWalletTX)).verify(TEST_PROGRAM_MAP) + } + assertTrue(e.cause!!.message!!.contains("the closing date has past")) + // This verification passes - TransactionGroup(setOf(registerTX, pledgeTX, fundedTX), setOf(aliceWalletTX)).verify(TEST_PROGRAM_MAP) + TransactionGroup(setOf(registerTX, pledgeTX, validClose), setOf(aliceWalletTX)).verify(TEST_PROGRAM_MAP) }