mirror of
https://github.com/corda/corda.git
synced 2025-04-07 19:34:41 +00:00
Added basic Crowdfunding contract.
This commit is contained in:
parent
dacfe299f8
commit
4e933f6d51
175
src/contracts/CrowdFund.kt
Normal file
175
src/contracts/CrowdFund.kt
Normal file
@ -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<Pledge> = 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<CrowdFund.Commands>()
|
||||
|
||||
val outputCrowdFund: CrowdFund.State = tx.outStates.filterIsInstance<CrowdFund.State>().single()
|
||||
val outputCash: List<Cash.State> = tx.outStates.filterIsInstance<Cash.State>()
|
||||
|
||||
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<CrowdFund.State>().single()
|
||||
val inputCash: List<Cash.State> = tx.inStates.filterIsInstance<Cash.State>()
|
||||
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<CrowdFund.State>().single()
|
||||
// TODO how can this be made smarter? feels wrong as a separate function
|
||||
fun checkReturns(inputCrowdFund: CrowdFund.State, outputCash: List<Cash.State>): 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<CrowdFund.State>().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<State>, 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<State>) {
|
||||
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")
|
||||
}
|
@ -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<CrowdFund.State>()
|
||||
registerDataClass<CrowdFund.Pledge>()
|
||||
register(CrowdFund.Commands.Register.javaClass)
|
||||
register(CrowdFund.Commands.Fund.javaClass)
|
||||
register(CrowdFund.Commands.Funded.javaClass)
|
||||
|
||||
// And for unit testing ...
|
||||
registerDataClass<DummyPublicKey>()
|
||||
|
132
tests/contracts/CrowdFundTests.kt
Normal file
132
tests/contracts/CrowdFundTests.kt
Normal file
@ -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<CrowdFund.Pledge>()
|
||||
)
|
||||
|
||||
@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<CrowdFund.State> {
|
||||
return transactionGroupFor<CrowdFund.State> {
|
||||
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<LedgerTransaction, List<StateAndRef<Cash.State>>> {
|
||||
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)
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -44,6 +44,7 @@ val TEST_TX_TIME = Instant.parse("2015-04-17T12:00:00.00Z")
|
||||
val TEST_PROGRAM_MAP: Map<SecureHash, Contract> = mapOf(
|
||||
CASH_PROGRAM_ID to Cash(),
|
||||
CP_PROGRAM_ID to CommercialPaper(),
|
||||
CROWDFUND_PROGRAM_ID to CrowdFund(),
|
||||
DUMMY_PROGRAM_ID to DummyContract
|
||||
)
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user