Merged in crowdfund-tweaks (pull request #7)

Crowdfunding contract: some cleanups and add some discussion at the top of the different possible models.
This commit is contained in:
Mike Hearn 2015-12-04 12:34:03 +00:00
commit a656e210c4
3 changed files with 68 additions and 69 deletions

View File

@ -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 * 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) * 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 { class CrowdFund : Contract {
data class State( data class Campaign(
val owner: PublicKey, val owner: PublicKey,
val campaignName: String, val name: String,
val campaignTarget: Amount, val target: Amount,
val closingTime: Instant, 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 closed: Boolean = false,
val pledgeTotal: Amount = 0.DOLLARS,
val pledgeCount: Int = 0,
val pledges: List<Pledge> = ArrayList() val pledges: List<Pledge> = ArrayList()
) : ContractState { ) : ContractState {
override val programRef = CROWDFUND_PROGRAM_ID 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( data class Pledge(
@ -50,91 +74,68 @@ class CrowdFund : Contract {
} }
override fun verify(tx: TransactionForVerification) { override fun verify(tx: TransactionForVerification) {
// There are two possible things that can be done with Crowdsourcing. // There are three possible things that can be done with Crowdsourcing.
// The first is creating it. The second is funding it with cash // The first is creating it. The second is funding it with cash. The third is closing it on or after the closing
// The third is closing it on or after the closing date, and returning funds to // date, and returning funds to pledge-makers if the target is unmet, or passing to the recipient.
// pledge-makers if the target is unmet, or passing to the recipient.
val command = tx.commands.requireSingleCommand<CrowdFund.Commands>() val command = tx.commands.requireSingleCommand<CrowdFund.Commands>()
val outputCrowdFund: CrowdFund.State = tx.outStates.filterIsInstance<CrowdFund.State>().single() val outputCrowdFund: CrowdFund.State = tx.outStates.filterIsInstance<CrowdFund.State>().single()
val outputCash: List<Cash.State> = tx.outStates.filterIsInstance<Cash.State>() val outputCash: List<Cash.State> = tx.outStates.filterIsInstance<Cash.State>()
val time = tx.time 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) { when (command.value) {
is Commands.Register -> { is Commands.Register -> {
requireThat { requireThat {
"there is no input state" by tx.inStates.filterIsInstance<CrowdFund.State>().isEmpty() "there is no input state" by tx.inStates.filterIsInstance<CrowdFund.State>().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 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 non-zero target" by (outputCrowdFund.campaign.target.pennies > 0)
"the output registration has a zero starting pledge total" by (outputCrowdFund.pledgeTotal.pennies == 0) "the output registration has a name" by (outputCrowdFund.campaign.name.isNotBlank())
"the output registration has a zero starting pledge count" by (outputCrowdFund.pledgeCount == 0) "the output registration has a closing time in the future" by (outputCrowdFund.campaign.closingTime > tx.time)
"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 an open state" by (!outputCrowdFund.closed) "the output registration has an open state" by (!outputCrowdFund.closed)
} }
} }
is Commands.Pledge -> { is Commands.Pledge -> {
val inputCrowdFund: CrowdFund.State = tx.inStates.filterIsInstance<CrowdFund.State>().single() val inputCrowdFund: CrowdFund.State = tx.inStates.filterIsInstance<CrowdFund.State>().single()
val inputCash: List<Cash.State> = tx.inStates.filterIsInstance<Cash.State>() val pledgedCash = outputCash.sumCashBy(inputCrowdFund.campaign.owner)
val pledge = outputCrowdFund.pledges.last()
val pledgedCash = outputCash.single()
if (time == null) throw IllegalArgumentException("Redemption transactions must be timestamped") if (time == null) throw IllegalArgumentException("Redemption transactions must be timestamped")
requireThatAttributesUnchanged(inputCrowdFund, outputCrowdFund)
requireThat { requireThat {
"the campaign is still open" by (inputCrowdFund.closingTime >= time) "campaign details have not changed" by (inputCrowdFund.campaign == outputCrowdFund.campaign)
// TODO "the transaction is signed by the owner of the pledge" by (command.signers.contains(inputCrowdFund.owner)) "the campaign is still open" by (inputCrowdFund.campaign.closingTime >= time)
"the transaction is signed by the pledge-maker" by (command.signers.contains(pledge.owner)) "the pledge must be in the same currency as the goal" by (pledgedCash.currency == outputCrowdFund.campaign.target.currency)
"the pledge must be for a non-zero amount" by (pledge.amount.pennies > 0) "the pledged total has increased by the value of the pledge" by (outputCrowdFund.pledgedAmount == inputCrowdFund.pledgedAmount + pledgedCash)
"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)
"the output registration has an open state" by (!outputCrowdFund.closed) "the output registration has an open state" by (!outputCrowdFund.closed)
} }
} }
is Commands.Close -> { is Commands.Close -> {
val inputCrowdFund: CrowdFund.State = tx.inStates.filterIsInstance<CrowdFund.State>().single() val inputCrowdFund: CrowdFund.State = tx.inStates.filterIsInstance<CrowdFund.State>().single()
// TODO include inline rather than as separate function
fun checkReturns(inputCrowdFund: CrowdFund.State, outputCash: List<Cash.State>): Boolean { fun checkReturns(inputCrowdFund: CrowdFund.State, outputCash: List<Cash.State>): Boolean {
for (pledge in inputCrowdFund.pledges) { 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 return true
} }
if (time == null) throw IllegalArgumentException("Redemption transactions must be timestamped") if (time == null) throw IllegalArgumentException("Redemption transactions must be timestamped")
requireThatAttributesUnchanged(inputCrowdFund, outputCrowdFund)
requireThat { 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 input has an open state" by (!inputCrowdFund.closed)
"the output registration has a closed state" by (outputCrowdFund.closed) "the output registration has a closed state" by (outputCrowdFund.closed)
// Now check whether the target was not, and if so, return cash // Now check whether the target was met, and if so, return cash
if (inputCrowdFund.pledgeTotal < inputCrowdFund.campaignTarget) { if (inputCrowdFund.pledgedAmount < inputCrowdFund.campaign.target) {
"the output cash returns equal the pledge total, if the target is not reached" by (outputCash.sumCash() == inputCrowdFund.pledgeTotal) "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 (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 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 total is unchanged" by (outputCrowdFund.pledgedAmount == inputCrowdFund.pledgedAmount)
"the pledged count is unchanged" by (outputCrowdFund.pledgeCount == inputCrowdFund.pledgeCount)
"the pledges are unchanged" by (outputCrowdFund.pledges == inputCrowdFund.pledges) "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") 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 * 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 { 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)) return PartialTransaction(state, WireCommand(Commands.Register(), owner.party.owningKey))
} }
@ -154,9 +156,7 @@ class CrowdFund : Contract {
fun craftPledge(tx: PartialTransaction, campaign: StateAndRef<State>, subscriber: PublicKey) { fun craftPledge(tx: PartialTransaction, campaign: StateAndRef<State>, subscriber: PublicKey) {
tx.addInputState(campaign.ref) tx.addInputState(campaign.ref)
tx.addOutputState(campaign.state.copy( tx.addOutputState(campaign.state.copy(
pledges = campaign.state.pledges + CrowdFund.Pledge(subscriber, 1000.DOLLARS), pledges = campaign.state.pledges + CrowdFund.Pledge(subscriber, 1000.DOLLARS)
pledgeCount = campaign.state.pledgeCount + 1,
pledgeTotal = campaign.state.pledgeTotal + 1000.DOLLARS
)) ))
tx.addArg(WireCommand(Commands.Pledge(), subscriber)) tx.addArg(WireCommand(Commands.Pledge(), subscriber))
} }
@ -164,9 +164,9 @@ class CrowdFund : Contract {
fun craftClose(tx: PartialTransaction, campaign: StateAndRef<State>, wallet: List<StateAndRef<Cash.State>>) { fun craftClose(tx: PartialTransaction, campaign: StateAndRef<State>, wallet: List<StateAndRef<Cash.State>>) {
tx.addInputState(campaign.ref) tx.addInputState(campaign.ref)
tx.addOutputState(campaign.state.copy(closed = true)) 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 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) { for (pledge in campaign.state.pledges) {
Cash().craftSpend(tx, pledge.amount, pledge.owner, wallet) Cash().craftSpend(tx, pledge.amount, pledge.owner, wallet)
} }

View File

@ -263,6 +263,7 @@ fun createKryo(): Kryo {
register(CommercialPaper.Commands.Issue::class.java) register(CommercialPaper.Commands.Issue::class.java)
registerDataClass<CrowdFund.State>() registerDataClass<CrowdFund.State>()
registerDataClass<CrowdFund.Pledge>() registerDataClass<CrowdFund.Pledge>()
registerDataClass<CrowdFund.Campaign>()
register(CrowdFund.Commands.Register::class.java) register(CrowdFund.Commands.Register::class.java)
register(CrowdFund.Commands.Pledge::class.java) register(CrowdFund.Commands.Pledge::class.java)
register(CrowdFund.Commands.Close::class.java) register(CrowdFund.Commands.Close::class.java)

View File

@ -18,12 +18,12 @@ import kotlin.test.assertTrue
class CrowdFundTests { class CrowdFundTests {
val CF_1 = CrowdFund.State( val CF_1 = CrowdFund.State(
campaign = CrowdFund.Campaign(
owner = MINI_CORP_PUBKEY, owner = MINI_CORP_PUBKEY,
campaignName = "kickstart me", name = "kickstart me",
campaignTarget = 1000.DOLLARS, target = 1000.DOLLARS,
pledgeTotal = 0.DOLLARS, closingTime = TEST_TX_TIME + 7.days
pledgeCount = 0, ),
closingTime = TEST_TX_TIME + 7.days,
closed = false, closed = false,
pledges = ArrayList<CrowdFund.Pledge>() pledges = ArrayList<CrowdFund.Pledge>()
) )
@ -44,7 +44,7 @@ class CrowdFundTests {
fun `closing time not in the future`() { fun `closing time not in the future`() {
transactionGroup { transactionGroup {
transaction { 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() } arg(MINI_CORP_PUBKEY) { CrowdFund.Commands.Register() }
} }
@ -58,7 +58,7 @@ class CrowdFundTests {
} }
private fun raiseFunds(): TransactionGroupDSL<CrowdFund.State> { private fun raiseFunds(): TransactionGroupDSL<CrowdFund.State> {
return transactionGroupFor<CrowdFund.State> { return transactionGroupFor {
roots { roots {
transaction(1000.DOLLARS.CASH `owned by` ALICE label "alice's $1000") transaction(1000.DOLLARS.CASH `owned by` ALICE label "alice's $1000")
} }
@ -75,9 +75,7 @@ class CrowdFundTests {
input("alice's $1000") input("alice's $1000")
output ("pledged opportunity") { output ("pledged opportunity") {
CF_1.copy( CF_1.copy(
pledges = CF_1.pledges + CrowdFund.Pledge(ALICE, 1000.DOLLARS), 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 } output { 1000.DOLLARS.CASH `owned by` MINI_CORP_PUBKEY }