From 4e933f6d514baa0098191d3e43bc29526800876f Mon Sep 17 00:00:00 2001 From: jamescarlyle Date: Fri, 27 Nov 2015 23:55:47 +0000 Subject: [PATCH] Added basic Crowdfunding contract. --- src/contracts/CrowdFund.kt | 175 ++++++++++++++++++++++++++++++ src/core/serialization/Kryo.kt | 7 ++ tests/contracts/CrowdFundTests.kt | 132 ++++++++++++++++++++++ tests/core/testutils/TestUtils.kt | 1 + 4 files changed, 315 insertions(+) create mode 100644 src/contracts/CrowdFund.kt create mode 100644 tests/contracts/CrowdFundTests.kt diff --git a/src/contracts/CrowdFund.kt b/src/contracts/CrowdFund.kt new file mode 100644 index 0000000000..355929bb8f --- /dev/null +++ b/src/contracts/CrowdFund.kt @@ -0,0 +1,175 @@ +package contracts + +import core.* +import core.serialization.SerializeableWithKryo +import java.security.PublicKey +import java.time.Instant +import java.util.* + +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) + */ +class CrowdFund : Contract { + + data class State( + val owner: PublicKey, + val fundingName: String, + val fundingTarget: Amount, + val closingTime: Instant, + 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($fundingTarget sought by $owner by $closingTime)" + } + + data class Pledge ( + val owner: PublicKey, + val amount: Amount + ) : SerializeableWithKryo + + + interface Commands : Command { + object Register : Commands + object Fund : Commands + object Funded : Commands + object Unfunded : Commands + } + + 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. + val command = tx.commands.requireSingleCommand() + + val outputCrowdFund: CrowdFund.State = tx.outStates.filterIsInstance().single() + val outputCash: List = tx.outStates.filterIsInstance() + + when (command.value) { + is Commands.Register -> { + requireThat { + "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 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 closing time in the future" by (outputCrowdFund.closingTime > tx.time) + "the output registration has an open state" by (!outputCrowdFund.closed) + } + } + + is Commands.Fund -> { + val inputCrowdFund: CrowdFund.State = tx.inStates.filterIsInstance().single() + val inputCash: List = tx.inStates.filterIsInstance() + val pledge = outputCrowdFund.pledges.last() + val pledgedCash = outputCash.single() + requireThat { + "the funding is still open" by (inputCrowdFund.closingTime >= tx.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 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 output registration has an open state" by (!outputCrowdFund.closed) + } + } + + is Commands.Unfunded -> { + val inputCrowdFund: CrowdFund.State = tx.inStates.filterIsInstance().single() + // TODO how can this be made smarter? feels wrong as a 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 + } + return true + } + + requireThat { + "the closing date has past" by (tx.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() + requireThat { + "the closing date has past" by (tx.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) + "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 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: InstitutionReference, fundingTarget: Amount, fundingName: String, closingTime: Instant): PartialTransaction { + val state = State(owner = owner.institution.owningKey, fundingName = fundingName, fundingTarget = fundingTarget, closingTime = closingTime) + return PartialTransaction(state, WireCommand(CrowdFund.Commands.Register, owner.institution.owningKey)) + } + + /** + * Updates the given partial transaction with an input/output/command to fund the opportunity. + */ + fun craftFund(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)) + } + + fun craftFunded(tx: PartialTransaction, campaign: StateAndRef) { + tx.addInputState(campaign.ref) + tx.addOutputState(campaign.state.copy(closed = true)) + tx.addArg(WireCommand(CrowdFund.Commands.Funded, campaign.state.owner)) + } + + override val legalContractReference: SecureHash = SecureHash.sha256("Crowdsourcing") +} diff --git a/src/core/serialization/Kryo.kt b/src/core/serialization/Kryo.kt index 04d184d38c..6773c4a031 100644 --- a/src/core/serialization/Kryo.kt +++ b/src/core/serialization/Kryo.kt @@ -8,6 +8,7 @@ import com.esotericsoftware.kryo.io.Output import com.esotericsoftware.kryo.serializers.JavaSerializer import contracts.Cash import contracts.CommercialPaper +import contracts.CrowdFund import core.* import java.io.ByteArrayOutputStream import java.lang.reflect.InvocationTargetException @@ -230,6 +231,12 @@ fun createKryo(): Kryo { register(CommercialPaper.Commands.Move.javaClass) register(CommercialPaper.Commands.Redeem.javaClass) register(CommercialPaper.Commands.Issue.javaClass) + // Added for + registerDataClass() + registerDataClass() + register(CrowdFund.Commands.Register.javaClass) + register(CrowdFund.Commands.Fund.javaClass) + register(CrowdFund.Commands.Funded.javaClass) // And for unit testing ... registerDataClass() diff --git a/tests/contracts/CrowdFundTests.kt b/tests/contracts/CrowdFundTests.kt new file mode 100644 index 0000000000..9fbd6c5ef2 --- /dev/null +++ b/tests/contracts/CrowdFundTests.kt @@ -0,0 +1,132 @@ +package contracts + +import core.* +import core.testutils.* +import org.junit.Test +import java.util.* + +class CrowdFundTests { + val CF_1 = CrowdFund.State( + owner = MINI_CORP_PUBKEY, + fundingName = "kickstart me", + fundingTarget = 1000.DOLLARS, + pledgeTotal = 0.DOLLARS, + pledgeCount = 0, + closingTime = TEST_TX_TIME + 7.days, + closed = false, + pledges = ArrayList() + ) + + @Test + fun `key mismatch at issue`() { + transactionGroup { + transaction { + output { CF_1 } + arg(DUMMY_PUBKEY_1) { CrowdFund.Commands.Register } + } + + 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(closingTime = TEST_TX_TIME - 1.days) } + arg(MINI_CORP_PUBKEY) { CrowdFund.Commands.Register } + } + + expectFailureOfTx(1, "the output registration has a closing time in the future") + } + } + + @Test + fun ok() { + raiseFunds().verify() + } + + private fun raiseFunds(): TransactionGroupForTest { + return transactionGroupFor { + roots { + transaction(1000.DOLLARS.CASH `owned by` ALICE label "alice's $1000") + } + + // 1. Create the funding opportunity + transaction { + output("funding opportunity") { CF_1 } + arg(MINI_CORP_PUBKEY) { CrowdFund.Commands.Register } + } + + // 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, 1000.DOLLARS), + pledgeCount = CF_1.pledgeCount + 1, + pledgeTotal = CF_1.pledgeTotal + 1000.DOLLARS + ) } + output { 1000.DOLLARS.CASH `owned by` MINI_CORP_PUBKEY } + arg(ALICE) { Cash.Commands.Move } + arg(ALICE) { CrowdFund.Commands.Fund } + } + + // 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 } + } + } + } + + fun cashOutputsToWallet(vararg states: Cash.State): Pair>> { + val ltx = LedgerTransaction(emptyList(), listOf(*states), emptyList(), TEST_TX_TIME, SecureHash.randomSHA256()) + return Pair(ltx, states.mapIndexed { index, state -> StateAndRef(state, ContractStateRef(ltx.hash, index)) }) + } + + @Test + fun `raise more funds`() { + // MiniCorp registers a crowdfunding of $1,000, to close in 30 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) + ptx.signWith(MINI_CORP_KEY) + val stx = ptx.toSignedTransaction() + stx.verify().toLedgerTransaction(TEST_TX_TIME, TEST_KEYS_TO_CORP_MAP, SecureHash.randomSHA256()) + } + + // let's give Alice some funds that she can invest + val (aliceWalletTX, aliceWallet) = cashOutputsToWallet( + 200.DOLLARS.CASH `owned by` ALICE, + 500.DOLLARS.CASH `owned by` ALICE, + 300.DOLLARS.CASH `owned by` ALICE + ) + + // Alice pays $1000 to MiniCorp to fund their campaign. + val pledgeTX: LedgerTransaction = run { + val ptx = PartialTransaction() + CrowdFund().craftFund(ptx, registerTX.outRef(0), ALICE) + Cash().craftSpend(ptx, 1000.DOLLARS, MINI_CORP_PUBKEY, aliceWallet) + ptx.signWith(ALICE_KEY) + val stx = ptx.toSignedTransaction() + // this verify passes - the transaction contains an output cash, necessary to verify the fund command + stx.verify().toLedgerTransaction(TEST_TX_TIME, TEST_KEYS_TO_CORP_MAP, SecureHash.randomSHA256()) + } + + // MiniCorp closes their campaign. + val fundedTX: LedgerTransaction = run { + val ptx = PartialTransaction() + CrowdFund().craftFunded(ptx, pledgeTX.outRef(0)) + ptx.signWith(MINI_CORP_KEY) + val stx = ptx.toSignedTransaction() + stx.verify().toLedgerTransaction(TEST_TX_TIME + 8.days, TEST_KEYS_TO_CORP_MAP, SecureHash.randomSHA256()) + } + + // This verification passes + TransactionGroup(setOf(registerTX, pledgeTX, fundedTX), setOf(aliceWalletTX)).verify(TEST_PROGRAM_MAP) + + } + + } \ No newline at end of file diff --git a/tests/core/testutils/TestUtils.kt b/tests/core/testutils/TestUtils.kt index faed264157..327aeba97e 100644 --- a/tests/core/testutils/TestUtils.kt +++ b/tests/core/testutils/TestUtils.kt @@ -44,6 +44,7 @@ val TEST_TX_TIME = Instant.parse("2015-04-17T12:00:00.00Z") val TEST_PROGRAM_MAP: Map = mapOf( CASH_PROGRAM_ID to Cash(), CP_PROGRAM_ID to CommercialPaper(), + CROWDFUND_PROGRAM_ID to CrowdFund(), DUMMY_PROGRAM_ID to DummyContract )