diff --git a/src/main/kotlin/contracts/CrowdFund.kt b/src/main/kotlin/contracts/CrowdFund.kt index 78d9765738..5fd068dadc 100644 --- a/src/main/kotlin/contracts/CrowdFund.kt +++ b/src/main/kotlin/contracts/CrowdFund.kt @@ -19,22 +19,46 @@ val CROWDFUND_PROGRAM_ID = SecureHash.sha256("crowdsourcing") /** * This is a basic crowd funding contract. It allows a party to create a funding opportunity, then for others to * pledge during the funding period , and then for the party to either accept the funding (if the target has been reached) - * return the funds to the pledge-makers (if the target has not been reached) + * return the funds to the pledge-makers (if the target has not been reached). + * + * Discussion + * ---------- + * + * This method of modelling a crowdfund is similar to how it'd be done in Ethereum. The state is essentially a database + * in which transactions evolve it over time. The state transition model we are using here though means it's possible + * to do it in a different approach, with some additional (not yet implemented) extensions to the model. In the UTXO + * model you can do something more like the Lighthouse application (https://www.vinumeris.com/lighthouse) in which + * the campaign data and people's pledges are transmitted out of band, with a pledge being a partially signed + * transaction which is valid only when merged with other transactions. The pledges can then be combined by the project + * owner at the point at which sufficient amounts of money have been gathered, and this creates a valid transaction + * that claims the money. + * + * TODO: Prototype this second variant of crowdfunding once the core model has been sufficiently extended. + * TODO: Experiment with the use of the javax.validation API to simplify the validation logic by annotating state members. + * + * See JIRA bug PD-21 for further discussion and followup. + * + * @author James Carlyle */ class CrowdFund : Contract { - data class State( + data class Campaign( val owner: PublicKey, - val campaignName: String, - val campaignTarget: Amount, - val closingTime: Instant, + val name: String, + val target: Amount, + val closingTime: Instant + ) : SerializeableWithKryo { + override fun toString() = "Crowdsourcing($target sought by $owner by $closingTime)" + } + + data class State( + val campaign: Campaign, val closed: Boolean = false, - val pledgeTotal: Amount = 0.DOLLARS, - val pledgeCount: Int = 0, val pledges: List = ArrayList() ) : ContractState { override val programRef = CROWDFUND_PROGRAM_ID - override fun toString() = "Crowdsourcing($campaignTarget sought by $owner by $closingTime)" + + val pledgedAmount: Amount get() = pledges.map { it.amount }.sumOrZero(campaign.target.currency) } data class Pledge( @@ -50,91 +74,68 @@ class CrowdFund : Contract { } override fun verify(tx: TransactionForVerification) { - // There are two possible things that can be done with Crowdsourcing. - // The first is creating it. The second is funding it with cash - // 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. + // There are three possible things that can be done with Crowdsourcing. + // The first is creating it. The second is funding it with cash. 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 transaction is signed by the owner of the crowdsourcing" by (command.signers.contains(outputCrowdFund.campaign.owner)) "the output registration is empty of pledges" by (outputCrowdFund.pledges.isEmpty()) - "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.campaignName.isNotBlank()) - "the output registration has a closing time in the future" by (outputCrowdFund.closingTime > tx.time) + "the output registration has a non-zero target" by (outputCrowdFund.campaign.target.pennies > 0) + "the output registration has a name" by (outputCrowdFund.campaign.name.isNotBlank()) + "the output registration has a closing time in the future" by (outputCrowdFund.campaign.closingTime > tx.time) "the output registration has an open state" by (!outputCrowdFund.closed) } } 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 pledgedCash = outputCash.sumCashBy(inputCrowdFund.campaign.owner) if (time == null) throw IllegalArgumentException("Redemption transactions must be timestamped") - requireThatAttributesUnchanged(inputCrowdFund, outputCrowdFund) requireThat { - "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.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 campaign owner" by (pledgedCash.owner == inputCrowdFund.owner) + "campaign details have not changed" by (inputCrowdFund.campaign == outputCrowdFund.campaign) + "the campaign is still open" by (inputCrowdFund.campaign.closingTime >= time) + "the pledge must be in the same currency as the goal" by (pledgedCash.currency == outputCrowdFund.campaign.target.currency) + "the pledged total has increased by the value of the pledge" by (outputCrowdFund.pledgedAmount == inputCrowdFund.pledgedAmount + pledgedCash) "the output registration has an open state" by (!outputCrowdFund.closed) } } is Commands.Close -> { val inputCrowdFund: CrowdFund.State = tx.inStates.filterIsInstance().single() - // 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 + if (outputCash.none { it.amount == pledge.amount && it.owner == pledge.owner }) return false } return true } if (time == null) throw IllegalArgumentException("Redemption transactions must be timestamped") - requireThatAttributesUnchanged(inputCrowdFund, outputCrowdFund) + requireThat { - "the closing date has past" by (time >= outputCrowdFund.closingTime) + "campaign details have not changed" by (inputCrowdFund.campaign == outputCrowdFund.campaign) + "the closing date has past" by (time >= outputCrowdFund.campaign.closingTime) "the input has an open state" by (!inputCrowdFund.closed) "the output registration has a closed state" by (outputCrowdFund.closed) - // 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) + // Now check whether the target was met, and if so, return cash + if (inputCrowdFund.pledgedAmount < inputCrowdFund.campaign.target) { + "the output cash returns equal the pledge total, if the target is not reached" by (outputCash.sumCash() == inputCrowdFund.pledgedAmount) "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 pledged total is unchanged" by (outputCrowdFund.pledgedAmount == inputCrowdFund.pledgedAmount) "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") } } @@ -144,7 +145,8 @@ class CrowdFund : Contract { * 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, campaignName = fundingName, campaignTarget = fundingTarget, closingTime = closingTime) + val campaign = Campaign(owner = owner.party.owningKey, name = fundingName, target = fundingTarget, closingTime = closingTime) + val state = State(campaign) return PartialTransaction(state, WireCommand(Commands.Register(), owner.party.owningKey)) } @@ -154,9 +156,7 @@ class CrowdFund : Contract { 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 + pledges = campaign.state.pledges + CrowdFund.Pledge(subscriber, 1000.DOLLARS) )) tx.addArg(WireCommand(Commands.Pledge(), subscriber)) } @@ -164,9 +164,9 @@ class CrowdFund : Contract { fun craftClose(tx: PartialTransaction, campaign: StateAndRef, wallet: List>) { tx.addInputState(campaign.ref) tx.addOutputState(campaign.state.copy(closed = true)) - tx.addArg(WireCommand(Commands.Close(), campaign.state.owner)) + tx.addArg(WireCommand(Commands.Close(), campaign.state.campaign.owner)) // If campaign target has not been met, compose cash returns - if (campaign.state.pledgeTotal < campaign.state.campaignTarget) { + if (campaign.state.pledgedAmount < campaign.state.campaign.target) { for (pledge in campaign.state.pledges) { Cash().craftSpend(tx, pledge.amount, pledge.owner, wallet) } diff --git a/src/main/kotlin/core/serialization/Kryo.kt b/src/main/kotlin/core/serialization/Kryo.kt index d9ab37f14b..f358803eab 100644 --- a/src/main/kotlin/core/serialization/Kryo.kt +++ b/src/main/kotlin/core/serialization/Kryo.kt @@ -263,6 +263,7 @@ fun createKryo(): Kryo { register(CommercialPaper.Commands.Issue::class.java) registerDataClass() registerDataClass() + registerDataClass() register(CrowdFund.Commands.Register::class.java) register(CrowdFund.Commands.Pledge::class.java) register(CrowdFund.Commands.Close::class.java) diff --git a/src/test/kotlin/contracts/CrowdFundTests.kt b/src/test/kotlin/contracts/CrowdFundTests.kt index 4ae6c8887a..6efb208ab7 100644 --- a/src/test/kotlin/contracts/CrowdFundTests.kt +++ b/src/test/kotlin/contracts/CrowdFundTests.kt @@ -18,12 +18,12 @@ import kotlin.test.assertTrue class CrowdFundTests { val CF_1 = CrowdFund.State( - owner = MINI_CORP_PUBKEY, - campaignName = "kickstart me", - campaignTarget = 1000.DOLLARS, - pledgeTotal = 0.DOLLARS, - pledgeCount = 0, - closingTime = TEST_TX_TIME + 7.days, + campaign = CrowdFund.Campaign( + owner = MINI_CORP_PUBKEY, + name = "kickstart me", + target = 1000.DOLLARS, + closingTime = TEST_TX_TIME + 7.days + ), closed = false, pledges = ArrayList() ) @@ -44,7 +44,7 @@ class CrowdFundTests { fun `closing time not in the future`() { transactionGroup { transaction { - output { CF_1.copy(closingTime = TEST_TX_TIME - 1.days) } + output { CF_1.copy(campaign = CF_1.campaign.copy(closingTime = TEST_TX_TIME - 1.days)) } arg(MINI_CORP_PUBKEY) { CrowdFund.Commands.Register() } } @@ -58,7 +58,7 @@ class CrowdFundTests { } private fun raiseFunds(): TransactionGroupDSL { - return transactionGroupFor { + return transactionGroupFor { roots { transaction(1000.DOLLARS.CASH `owned by` ALICE label "alice's $1000") } @@ -75,9 +75,7 @@ class CrowdFundTests { input("alice's $1000") output ("pledged opportunity") { CF_1.copy( - pledges = CF_1.pledges + CrowdFund.Pledge(ALICE, 1000.DOLLARS), - pledgeCount = CF_1.pledgeCount + 1, - pledgeTotal = CF_1.pledgeTotal + 1000.DOLLARS + pledges = CF_1.pledges + CrowdFund.Pledge(ALICE, 1000.DOLLARS) ) } output { 1000.DOLLARS.CASH `owned by` MINI_CORP_PUBKEY }