diff --git a/contracts/src/main/kotlin/com/r3corda/contracts/CrowdFund.kt b/contracts/src/main/kotlin/com/r3corda/contracts/CrowdFund.kt deleted file mode 100644 index 8112248316..0000000000 --- a/contracts/src/main/kotlin/com/r3corda/contracts/CrowdFund.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.r3corda.contracts - -import com.r3corda.contracts.cash.Cash -import com.r3corda.contracts.cash.sumCash -import com.r3corda.contracts.cash.sumCashBy -import com.r3corda.core.* -import com.r3corda.core.contracts.* -import com.r3corda.core.crypto.Party -import com.r3corda.core.crypto.SecureHash -import java.security.PublicKey -import java.time.Instant -import java.util.* - -val CROWDFUND_PROGRAM_ID = CrowdFund() - -/** - * 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). - * - * 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 Campaign( - val owner: PublicKey, - val name: String, - val target: Amount, - val closingTime: Instant - ) { - override fun toString() = "Crowdsourcing($target sought by $owner by $closingTime)" - } - - data class State( - val campaign: Campaign, - override val notary: Party, - val closed: Boolean = false, - val pledges: List = ArrayList() - ) : ContractState { - override val contract = CROWDFUND_PROGRAM_ID - - val pledgedAmount: Amount get() = pledges.map { it.amount }.sumOrZero(campaign.target.token) - } - - data class Pledge( - val owner: PublicKey, - val amount: Amount - ) - - - interface Commands : CommandData { - class Register : TypeOnlyCommandData(), Commands - class Pledge : TypeOnlyCommandData(), Commands - class Close : TypeOnlyCommandData(), Commands - } - - override fun verify(tx: TransactionForVerification) { - // 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.commands.getTimestampByName("Notary Service")?.midpoint - if (time == null) throw IllegalArgumentException("must be timestamped") - - 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.campaign.owner)) - "the output registration is empty of pledges" by (outputCrowdFund.pledges.isEmpty()) - "the output registration has a non-zero target" by (outputCrowdFund.campaign.target.quantity > 0) - "the output registration has a name" by (outputCrowdFund.campaign.name.isNotBlank()) - "the output registration has a closing time in the future" by (time < outputCrowdFund.campaign.closingTime) - "the output registration has an open state" by (!outputCrowdFund.closed) - } - } - - is Commands.Pledge -> { - val inputCrowdFund: CrowdFund.State = tx.inStates.filterIsInstance().single() - val pledgedCash = outputCash.sumCashBy(inputCrowdFund.campaign.owner) - requireThat { - "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.token == outputCrowdFund.campaign.target.token) - "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() - - fun checkReturns(inputCrowdFund: CrowdFund.State, outputCash: List): Boolean { - for (pledge in inputCrowdFund.pledges) { - if (outputCash.none { it.amount == pledge.amount && it.owner == pledge.owner }) return false - } - return true - } - - requireThat { - "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 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.pledgedAmount == inputCrowdFund.pledgedAmount) - "the pledges are unchanged" by (outputCrowdFund.pledges == inputCrowdFund.pledges) - } - } - - else -> throw IllegalArgumentException("Unrecognised command") - } - } - - /** - * 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 generateRegister(owner: PartyAndReference, fundingTarget: Amount, fundingName: String, closingTime: Instant, notary: Party): TransactionBuilder { - val campaign = Campaign(owner = owner.party.owningKey, name = fundingName, target = fundingTarget, closingTime = closingTime) - val state = State(campaign, notary) - return TransactionBuilder().withItems(state, Command(Commands.Register(), owner.party.owningKey)) - } - - /** - * Updates the given partial transaction with an input/output/command to fund the opportunity. - */ - fun generatePledge(tx: TransactionBuilder, campaign: StateAndRef, subscriber: PublicKey) { - tx.addInputState(campaign.ref) - tx.addOutputState(campaign.state.copy( - pledges = campaign.state.pledges + CrowdFund.Pledge(subscriber, 1000.DOLLARS) - )) - tx.addCommand(Commands.Pledge(), subscriber) - } - - fun generateClose(tx: TransactionBuilder, campaign: StateAndRef, wallet: List>) { - tx.addInputState(campaign.ref) - tx.addOutputState(campaign.state.copy(closed = true)) - tx.addCommand(Commands.Close(), campaign.state.campaign.owner) - // If campaign target has not been met, compose cash returns - if (campaign.state.pledgedAmount < campaign.state.campaign.target) { - for (pledge in campaign.state.pledges) { - Cash().generateSpend(tx, pledge.amount, pledge.owner, wallet) - } - } - } - - override val legalContractReference: SecureHash = SecureHash.sha256("Crowdsourcing") -} diff --git a/contracts/src/main/kotlin/com/r3corda/contracts/testing/TestUtils.kt b/contracts/src/main/kotlin/com/r3corda/contracts/testing/TestUtils.kt index cf033f45fb..5e27d7ae51 100644 --- a/contracts/src/main/kotlin/com/r3corda/contracts/testing/TestUtils.kt +++ b/contracts/src/main/kotlin/com/r3corda/contracts/testing/TestUtils.kt @@ -18,7 +18,6 @@ val TEST_PROGRAM_MAP: Map> = mapOf( CASH_PROGRAM_ID to Cash::class.java, CP_PROGRAM_ID to CommercialPaper::class.java, JavaCommercialPaper.JCP_PROGRAM_ID to JavaCommercialPaper::class.java, - CROWDFUND_PROGRAM_ID to CrowdFund::class.java, DUMMY_PROGRAM_ID to DummyContract::class.java, IRS_PROGRAM_ID to InterestRateSwap::class.java ) diff --git a/contracts/src/test/kotlin/com/r3corda/contracts/CrowdFundTests.kt b/contracts/src/test/kotlin/com/r3corda/contracts/CrowdFundTests.kt deleted file mode 100644 index 5f837282af..0000000000 --- a/contracts/src/test/kotlin/com/r3corda/contracts/CrowdFundTests.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.r3corda.contracts - -import com.r3corda.contracts.cash.Cash -import com.r3corda.contracts.testing.CASH -import com.r3corda.contracts.testing.`owned by` -import com.r3corda.core.contracts.* -import com.r3corda.core.crypto.SecureHash -import com.r3corda.core.days -import com.r3corda.core.node.services.testing.MockStorageService -import com.r3corda.core.seconds -import com.r3corda.core.testing.* -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( - campaign = CrowdFund.Campaign( - owner = MINI_CORP_PUBKEY, - name = "kickstart me", - target = 1000.DOLLARS, - closingTime = TEST_TX_TIME + 7.days - ), - closed = false, - pledges = ArrayList(), - notary = DUMMY_NOTARY - ) - - val attachments = MockStorageService().attachments - - @Test - fun `key mismatch at issue`() { - transactionGroup { - transaction { - output { CF_1 } - arg(DUMMY_PUBKEY_1) { CrowdFund.Commands.Register() } - timestamp(TEST_TX_TIME) - } - - expectFailureOfTx(1, "the transaction is signed by the owner of the crowdsourcing") - } - } - - @Test - fun `closing time not in the future`() { - transactionGroup { - transaction { - output { CF_1.copy(campaign = CF_1.campaign.copy(closingTime = TEST_TX_TIME - 1.days)) } - arg(MINI_CORP_PUBKEY) { CrowdFund.Commands.Register() } - timestamp(TEST_TX_TIME) - } - - expectFailureOfTx(1, "the output registration has a closing time in the future") - } - } - - @Test - fun ok() { - raiseFunds().verify() - } - - private fun raiseFunds(): TransactionGroupDSL { - return transactionGroupFor { - roots { - transaction(1000.DOLLARS.CASH `owned by` ALICE_PUBKEY label "alice's $1000") - } - - // 1. Create the funding opportunity - transaction { - output("funding opportunity") { CF_1 } - arg(MINI_CORP_PUBKEY) { CrowdFund.Commands.Register() } - timestamp(TEST_TX_TIME) - } - - // 2. Place a pledge - transaction { - input ("funding opportunity") - input("alice's $1000") - output ("pledged opportunity") { - CF_1.copy( - pledges = CF_1.pledges + CrowdFund.Pledge(ALICE_PUBKEY, 1000.DOLLARS) - ) - } - output { 1000.DOLLARS.CASH `owned by` MINI_CORP_PUBKEY } - arg(ALICE_PUBKEY) { Cash.Commands.Move() } - arg(ALICE_PUBKEY) { CrowdFund.Commands.Pledge() } - timestamp(TEST_TX_TIME) - } - - // 3. Close the opportunity, assuming the target has been met - transaction { - input ("pledged opportunity") - output ("funded and closed") { "pledged opportunity".output.copy(closed = true) } - arg(MINI_CORP_PUBKEY) { CrowdFund.Commands.Close() } - timestamp(time = TEST_TX_TIME + 8.days) - } - } - } - - fun cashOutputsToWallet(vararg states: Cash.State): Pair>> { - val ltx = LedgerTransaction(emptyList(), emptyList(), listOf(*states), emptyList(), SecureHash.randomSHA256()) - return Pair(ltx, states.mapIndexed { index, state -> StateAndRef(state, StateRef(ltx.id, index)) }) - } - - @Test - 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().generateRegister(MINI_CORP.ref(123), 1000.DOLLARS, "crowd funding", TEST_TX_TIME + 7.days, DUMMY_NOTARY).apply { - setTime(TEST_TX_TIME, DUMMY_NOTARY, 30.seconds) - signWith(MINI_CORP_KEY) - signWith(DUMMY_NOTARY_KEY) - } - ptx.toSignedTransaction().verifyToLedgerTransaction(MOCK_IDENTITY_SERVICE, attachments) - } - - // let's give Alice some funds that she can invest - val (aliceWalletTX, aliceWallet) = cashOutputsToWallet( - 200.DOLLARS.CASH `owned by` ALICE_PUBKEY, - 500.DOLLARS.CASH `owned by` ALICE_PUBKEY, - 300.DOLLARS.CASH `owned by` ALICE_PUBKEY - ) - - // Alice pays $1000 to MiniCorp to fund their campaign. - val pledgeTX: LedgerTransaction = run { - val ptx = TransactionBuilder() - CrowdFund().generatePledge(ptx, registerTX.outRef(0), ALICE_PUBKEY) - Cash().generateSpend(ptx, 1000.DOLLARS, MINI_CORP_PUBKEY, aliceWallet) - ptx.setTime(TEST_TX_TIME, DUMMY_NOTARY, 30.seconds) - ptx.signWith(ALICE_KEY) - ptx.signWith(DUMMY_NOTARY_KEY) - // this verify passes - the transaction contains an output cash, necessary to verify the fund command - ptx.toSignedTransaction().verifyToLedgerTransaction(MOCK_IDENTITY_SERVICE, attachments) - } - - // 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. - fun makeFundedTX(time: Instant): LedgerTransaction { - val ptx = TransactionBuilder() - ptx.setTime(time, DUMMY_NOTARY, 30.seconds) - CrowdFund().generateClose(ptx, pledgeTX.outRef(0), miniCorpWallet) - ptx.signWith(MINI_CORP_KEY) - ptx.signWith(DUMMY_NOTARY_KEY) - return ptx.toSignedTransaction().verifyToLedgerTransaction(MOCK_IDENTITY_SERVICE, attachments) - } - - 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() - } - assertTrue(e.cause!!.message!!.contains("the closing date has past")) - - // This verification passes - TransactionGroup(setOf(registerTX, pledgeTX, validClose), setOf(aliceWalletTX)).verify() - } -} \ No newline at end of file