mirror of
https://github.com/corda/corda.git
synced 2024-12-20 13:33:12 +00:00
Merged in removecrowdfundcontract (pull request #147)
Removed the CrowdFund contract since it is written in a style that is not typical of Corda contracts.
This commit is contained in:
commit
8aa6fa156a
@ -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<Currency>,
|
|
||||||
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<Pledge> = ArrayList()
|
|
||||||
) : ContractState {
|
|
||||||
override val contract = CROWDFUND_PROGRAM_ID
|
|
||||||
|
|
||||||
val pledgedAmount: Amount<Currency> get() = pledges.map { it.amount }.sumOrZero(campaign.target.token)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Pledge(
|
|
||||||
val owner: PublicKey,
|
|
||||||
val amount: Amount<Currency>
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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<CrowdFund.Commands>()
|
|
||||||
val outputCrowdFund: CrowdFund.State = tx.outStates.filterIsInstance<CrowdFund.State>().single()
|
|
||||||
val outputCash: List<Cash.State> = tx.outStates.filterIsInstance<Cash.State>()
|
|
||||||
|
|
||||||
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<State>().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<CrowdFund.State>().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<CrowdFund.State>().single()
|
|
||||||
|
|
||||||
fun checkReturns(inputCrowdFund: CrowdFund.State, outputCash: List<Cash.State>): 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<Currency>, 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<State>, 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<State>, wallet: List<StateAndRef<Cash.State>>) {
|
|
||||||
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")
|
|
||||||
}
|
|
@ -18,7 +18,6 @@ val TEST_PROGRAM_MAP: Map<Contract, Class<out Contract>> = mapOf(
|
|||||||
CASH_PROGRAM_ID to Cash::class.java,
|
CASH_PROGRAM_ID to Cash::class.java,
|
||||||
CP_PROGRAM_ID to CommercialPaper::class.java,
|
CP_PROGRAM_ID to CommercialPaper::class.java,
|
||||||
JavaCommercialPaper.JCP_PROGRAM_ID to JavaCommercialPaper::class.java,
|
JavaCommercialPaper.JCP_PROGRAM_ID to JavaCommercialPaper::class.java,
|
||||||
CROWDFUND_PROGRAM_ID to CrowdFund::class.java,
|
|
||||||
DUMMY_PROGRAM_ID to DummyContract::class.java,
|
DUMMY_PROGRAM_ID to DummyContract::class.java,
|
||||||
IRS_PROGRAM_ID to InterestRateSwap::class.java
|
IRS_PROGRAM_ID to InterestRateSwap::class.java
|
||||||
)
|
)
|
||||||
|
@ -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<CrowdFund.Pledge>(),
|
|
||||||
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<CrowdFund.State> {
|
|
||||||
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<LedgerTransaction, List<StateAndRef<Cash.State>>> {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user