diff --git a/README.md b/README.md index 16b9ebb6..2d1b642b 100644 --- a/README.md +++ b/README.md @@ -13,4 +13,5 @@ Please check out existing EIPs, such as [EIP-1](eip-0001.md), to understand the | [EIP-0005](eip-0005.md) | Contract Template | | [EIP-0006](eip-0006.md) | Informal Smart Contract Protocol Specification Format | | [EIP-0017](eip-0017.md) | Proxy Contracts | +| [EIP-0018](eip-0018.md) | ErgoFund contracts | | [EIP-0022](eip-0022.md) | Auction Contract | diff --git a/eip-0018.md b/eip-0018.md new file mode 100644 index 00000000..e5ea71c4 --- /dev/null +++ b/eip-0018.md @@ -0,0 +1,387 @@ +# ErgoFund contracts + +* Author: code-for-uss, kushti +* Status: Proposed +* Created: 5-Nov-2021 +* License: CC0 +* Forking: not needed + +## Motivation + +Collecting funds in different contexts is needed to build common infrastructure and applications in the Ergo ecosystem. Therefore, this EIP is proposing contracts and standardized box formats for announcing crowdfunding campaigns and collecting funds. + +## Overall Design + +The design of the crowdfunding contracts and box templates below is centered by a seamless on-chain and off-chain code built on top of the Ergo Playgrounds, [ErgoFund scenarios via playground](https://scastie.scala-lang.org/TWlB0hncT5SdYcTax3Wj5A). + +There is an old version of ErgoFund eip [here](#old-ergofund-contracts), prepared by Kushti. + +## Tokens + +The ErgoFund has the following types of tokens. + +| Token | Issued quantity | purpose | where stored | +|---|---|---|---| +|ControlBoxNFT | 1 | Identify control box | Any | +|SellNFT | 1 | Identify sell box | Sell box | +|Registration tokens | 100 million | Identify each campaign box | Sell box
Campaign boxes | +|Campaign participant tokens | 100 million per campaign | Identify campaign and each pledge box| Capmaign box
pledge boxes | + +## Boxes + +There are a total of 3 contracts, and each corresponds to a box-type + control box. + +| Box | Quantity | Tokens | Additional registers used | Purpose | Spending transactions | +|---|---|---|---|---|---| +|Control | 1 | ControlBoxNFT | R4: Price (Long),
R5: address[script] (SigmaProp) | Control price and address to pay for campaign registration |
-
| +|Sell| 1 | SellNFT
Registration tokens |
-
| Register a new campaign | registerCampaign,
returnRegistrationToken | +|Campaign | 0 - 100 million | Registration token
Campaign participant tokens | R4: Campaign description (Coll[Byte]),
R5: Public key of project owner (SigmaProp),
R6: Campaign deadline (Int),
R7: minToRaise (Long),
R8 (optional): campaign Proposal CID (Coll[Byte]) | Store campaign data and collect funds | Donate to campaign,
Withdraw funds,
Refund donates,
Return registrationToken | +|pledge | 0 - 100 million (per campaign) | Campaign participant tokens | R4: Donate value (Long),
R5: Public key of backer (SigmaProp) | Store donate value and Backer public key | Refund donate (fundraising failure) | + +## Contracts + +### Pledge Contract + +This box can only be spent if the first input box, which is the campaign box related to this pledge. Which means the campaign has failed. +(Conditions: fund collected for the project is less than the defined minToRaise and the height of the network exceeds the fundRaisingDeadline), also, in this case, the fee of funds' returning will be deducted from the backer's fund.
+ +- Registers: + * R4: Amount of donates [donateValue] (Long) + * R5: The public key of backer [backerPubKey] (SigmaProp) +- Tokens: + 1. campaignParticipantToken NO: [1]
+ We use this token as campaignId. + +```scala +{ + val donateValue = SELF.R4[Long].get + val backerPubKey = SELF.R5[SigmaProp].get + + val campaignId = SELF.tokens(0)._1 + val campaignBox = INPUTS(0) + val refundBox = OUTPUTS(1) + val fundraisingFailure = campaignBox.tokens(0)._1 == campaignRegistrationTokenId && + campaignBox.tokens(1)._1 == campaignId && // verify campaign id + refundBox.value >= donateValue + SELF.value - minFee && // ensure full refund + refundBox.propositionBytes == backerPubKey.propBytes // propositionBytes of refundBox must equal to backerPubKey + sigmaProp(fundraisingFailure) + } +``` +### Campaign Contract +There are four scenarios to spend the boxes protected by this contract. + +- Registers: + * R4: Campaign description [validCampaignDesc] (Coll[Byte]) + * R5: The public key of the project owner [projectPubKey] (SigmaProp) + * R6: Deadline of campaign [fundRaisingDeadline] (Int) + * R7: Minimum value needed for success campaign [minToRaise] (Long) + * R8: The CID of campaign proposal on IPFS [campaignProposalCID] (Coll[Byte]) +- Tokens: + 1. registrationToken NO: [1]
+ Each campaign should buy one registrationToken from the Sell contract + 2. campaignParticipantToken NO: [This can set manually when creating each campaign]
+ After donating, Backer gets this token protected in the pledge contract. In other words, we use this token as campaignId. + + +```scala + { + val selfOut = OUTPUTS(0) + val campaignRegistrationTokenId = SELF.tokens(0)._1 + val participantToken = SELF.tokens(1) + val campaignDesc = SELF.R4[Coll[Byte]] + val projectPubKey = SELF.R5[SigmaProp].get + val fundRaisingDeadline = SELF.R6[Int].get // height + val minToRaise = SELF.R7[Long].get + val campaignProposalCID = SELF.R8[Coll[Byte]] + val raisedValue = SELF.value - minFee + + val preservedData = SELF.propositionBytes == selfOut.propositionBytes && + campaignDesc == selfOut.R4[Coll[Byte]] && + projectPubKey == selfOut.R5[SigmaProp].get && + fundRaisingDeadline == selfOut.R6[Int].get && + minToRaise == selfOut.R7[Long].get && + campaignProposalCID == selfOut.R8[Coll[Byte]] + + val campaignCondditions = if (HEIGHT <= fundRaisingDeadline) { + // Donate to campaign: + // The main spending path ensures that the box can be spent in a transaction producing at least two boxes: + // * One of the outputs, New CampaignBox, preserves data (registers) and token of registrationToken and holds the remaining campaignParticipantTokens (campaignParticipantTokenAmount - 1). Finally, the value of this box should be increased with the amount of donation if there is at least one token left in each campaign box after donation. + // * The other output, PledgeBox, with the value of minErg, stores the donation amount and the PubKey of Backer. Thus this box is protected under the Pledge contract. + + val pledgeBox = OUTPUTS(1) + val minTokenPreserved = 1L + val donateAmount = selfOut.value - SELF.value + selfOut.tokens(0)._1 == campaignRegistrationTokenId && + selfOut.tokens(1)._1 == participantToken._1 && + selfOut.tokens(1)._2 >= minTokenPreserved && + selfOut.tokens(1)._2 == participantToken._2 - 1 && + donateAmount >= minErg && + preservedData && + pledgeBox.propositionBytes == pledgePropBytes && + pledgeBox.value == minErg && + pledgeBox.tokens(0)._1 == participantToken._1 && + pledgeBox.tokens(0)._2 == 1L && + pledgeBox.R4[Long].get == donateAmount && // Donate value + pledgeBox.R5[SigmaProp].isDefined // Backer PubKey + } + else if (HEIGHT > fundRaisingDeadline && !(raisedValue == 0)) { + if( raisedValue >= minToRaise ) { + // Withdrawal of funds if the campaign is successful: + // In this scenario HEIGHT should be more than fundRaisingDeadline, and also raisedValue (SELF.value - minFee) should be more than minToRaise. + // The main spending path ensures that the box can be spent in a transaction producing at least two boxes: + // * New SellBox returns registrationToken to the Sell contract + // * ProjectBox returns funds to the projectPubKey (R5) + // The fee of transaction in this scenario must be first obtained from the campaign creator + + val inSellBox = INPUTS(1) + val outProjectBox = OUTPUTS(1) + inSellBox.tokens(0)._1 == sellNFTId && + outProjectBox.value >= raisedValue && + outProjectBox.propositionBytes == projectPubKey.propBytes + } + else { + // Funds' returning if the campaign is failed: + // In this scenario HEIGHT should be more than fundRaisingDeadline and raisedValue (SELF.value - minFee) should be less than minToRaise. + // The main spending path ensures that the box can be spent in a transaction producing at least two boxes: + // * One of the outputs, New CampaignBox, preserves data (registers) and token of registrationToken and holds the remaining campaignParticipantTokens (campaignParticipantTokenAmount + 1). + // Finally, the value of this box should be decreased with the amount of donation (donateValue R4 stored in pledge box) + // * BackerBox return funds to the backerPubKey (R5 of pledge box) + // The fee of transaction in this scenario will be deducted from the backer donation + + val inPledgeBox = INPUTS(1) + preservedData && + inPledgeBox.propositionBytes == pledgePropBytes && + selfOut.tokens(0)._1 == campaignRegistrationTokenId && + selfOut.tokens(1)._1 == participantToken._1 && + selfOut.tokens(1)._2 == participantToken._2 + 1 && + selfOut.value >= SELF.value - inPledgeBox.R4[Long].get + } + } + else { + // Return registrationToken to the Sell contract: + // In this scenario, HEIGHT should be more than fundRaisingDeadline, and also raisedValue (SELF.value - minFee) should be equal to zero. + // The main spending path ensures that the box can be spent in a transaction producing at least one boxes: + // * New SellBox with registrationTokens + 1 protected under sell contract + + val inSellBox = INPUTS(1) + raisedValue == 0 && + inSellBox.tokens(0)._1 == sellNFTId + } + sigmaProp(campaignCondditions) + } +``` + +### Sell Contract +There are two ways to spend the boxes protected by this contract: + * To register campaign, first, it is needed to pass `control box` as a **data input**. +- Tokens: + 1. sellNFT NO: [1]
+ Sell NFT used for sell box detection + 2. crowdfunding registrationToken NO: [Manually] +```scala + { + val selfOut = OUTPUTS(0) + val validNFT = selfOut.tokens(0)._1 == sellNFTId + val tokenId = selfOut.tokens(1)._1 // token the contract is selling + val validOutTokenId = tokenId == campaignRegistrationTokenId + val inTokensCount = SELF.tokens(1)._2 + val outTokensCount = selfOut.tokens(1)._2 + val validScript = SELF.propositionBytes == selfOut.propositionBytes + val validValue = selfOut.value >= SELF.value + + val inCampaignBox = INPUTS(0) + val sellConditions = if (inCampaignBox.tokens.size == 2 && inCampaignBox.tokens(0)._1 == tokenId && inCampaignBox.tokens(0)._2 == 1){ + // Return registration token after the end of the campaign (successful or unsuccessful): + // The main spending path ensures that the box can be spent in a transaction producing at least one boxes also in this case raisedValue should be equal zero: + // * A New SellBox, preserves sellNFT token, and also puts registrationToken to the box (registrationTokenAmount + 1). + // The fee of transaction in this scenario must be first obtained from the campaign creator. + + validNFT && + validOutTokenId && + (outTokensCount == inTokensCount + 1) && + validScript && + validValue + } + else { + // Register a campaign: + // The main spending path ensures that the box can be spent in a transaction producing at least three boxes: + // So to register campaign, it is needed to pass control box as a data input + // * New SellBox output preserves sellNFT token and puts remaining registrationToken to the box (registrationTokenAmount - 1). + // * The output, New CampaignBox, with minFee value(needed registers for campaign box which is explained above) and registrationToken and campaignParticipantTokens, which are protected under campaign script. + // * The output, RewardBox, with the value of the price of campaign registration (controlBox.R4[Long].get) which is protected under the ErgoFund publicKey (controlBox.R5[SigmaProp].get) + val controlBox = CONTEXT.dataInputs(0) + // Check control box NFT + val properControlBox = (controlBox.tokens(0)._1 == controlBoxNFTId) + + val price = controlBox.R4[Long].get + val script = controlBox.R5[SigmaProp].get + + val validTokens = validNFT && validOutTokenId && (outTokensCount == inTokensCount - 1) + + val rewardOut = OUTPUTS(1) + val validPayment = rewardOut.value >= price && rewardOut.propositionBytes == script.propBytes + + val campaignOut = OUTPUTS(2) + val validCampaign = campaignOut.propositionBytes == campaignPropBytes && + campaignOut.tokens.size == 2 && + campaignOut.tokens(0)._1 == tokenId && + campaignOut.value >= minFee && + campaignOut.R4[Coll[Byte]].isDefined && + campaignOut.R5[SigmaProp].isDefined && + campaignOut.R6[Int].isDefined && + campaignOut.R7[Long].isDefined + + properControlBox && validTokens && validScript && validPayment && validCampaign && validValue + } + sigmaProp(sellConditions) + } +``` + +## TO-DO +* Contracts to collect funds in SigUSD and other tokens. + + +# Old ErgoFund contracts +This is inital version of ErgoFund contracts prepared by Kushti. + +## Overall Design + +The design of the crowdfunding contracts and box templates below is centered around +efficiency of blockchain scanning by an offchain application (backend of ErgoFund service built on top of the Scanner, +https://github.com/ergoplatform/scanner ). + +## Campaign Registration + +Crowdfunding campaign registration is controlled by a control box associated with an NFT which registers R4 and R5 +contain registration price and address (script) to pay for campaign registration. + +Control box: + +Contains campaign registration price in register R4 (as long value) and script to pay for registration in register R5 +(as SigmaProp constant). + +To register a new crowdfunding campaign, crowdfunding token must be bought (to compensate expenses for scanning, +storing crowdfunding data, producing and maintaining UI). + +Sell contract: + +```scala +{ + val controlBox = CONTEXT.dataInputs(0) + + val firstUnusedCampaignId = SELF.R4[Int].get + + // check control box NFT + val properControlBox = controlBox.tokens(0)._1 == fromBase64("csP7zjJD1JHYHrVkzasWYrH41MfjEriIcM7Hm3z9QyE=") + + val price = controlBox.R4[Long].get + val script = controlBox.R5[SigmaProp].get + + + val inTokensCount = SELF.tokens(1)._2 + + val selfOut = OUTPUTS(0) + + val validNFT = selfOut.tokens(0)._1 == fromBase64("FbCuQcJCMAaf+W2susCTKFCsDCoJJNr3KjnojLzzrNU=") + val tokenId = selfOut.tokens(1)._1 // token the contract is selling + val validOutTokenId = tokenId == fromBase64("BbZrl+WAL2RHtn/jDLQFXhTWsXuxT19WPWXJYixDplk=") + val outTokensCount = selfOut.tokens(1)._2 + + val validTokens = validNFT && validOutTokenId && (outTokensCount == inTokensCount - 1) + + val validScript = SELF.propositionBytes == selfOut.propositionBytes + + val validCampaignIdUpdate = selfOut.R4[Int].get == (firstUnusedCampaignId + 1) + + val rewardOut = OUTPUTS(1) + val validPayment = rewardOut.value >= price && rewardOut.propositionBytes == script.propBytes + + val campaignOut = OUTPUTS(2) + val validCampaign = campaignOut.tokens(0)._1 == tokenId && campaignOut.R4[Int].get == firstUnusedCampaignId + + + properControlBox && validTokens && validScript && validPayment && validCampaignIdUpdate && validCampaign +} +``` + +So to register campaign, one need to pass control box as a data input, sell contract among inputs, and create a box with +campaign box data specified below in outputs, as well as an updated campaign token sale box. + + +Control box NFT id: 72c3fbce3243d491d81eb564cdab1662b1f8d4c7e312b88870cec79b7cfd4321 +Tokensale box NFT id: 15b0ae41c24230069ff96dacbac0932850ac0c2a0924daf72a39e88cbcf3acd5 +Campaign identification token: 05b66b97e5802f6447b67fe30cb4055e14d6b17bb14f5f563d65c9622c43a659 + +Tokensale box P2S address: yvNXjWe8vBvZTXwyUHemPU59CRfm8AnvnzXowRQkQ9hoiGXsS7oEUGLPof6RoYAdKXEgTWR4qTK44w3GBuNdBjXeWcHFeBvyFxHWdEBsnqnYhtaFC2S71eHRPpLEndDghZebLdx25nufFh8YFJ9D8gTxTvxqahBgzpJT7pbUAUEkE2iRSZwpiXvj5SUmA6vmTrQzMTxicBG2mGV1NLq5rjBCAxjM5FpNSJ1KgqrxeTAfBvs1QnMUZ6CTBtGPLyNUpFoPTaYUPp9PfBni7FAQsumx36tukUHA2p3NdgfhTbYYSJTDQi7eWW2YikFvczXpTCfecbCL7HA1o6Cg6DvntmpJHWfjopWnsbpDdLg6SbP4Fup7wCnQaeL6NcSTVP1K5btmK5JryZc36V8KdHkheyaVzYTMA1Gufp6bAGtZP + +## Campaign Box Data + +For registering campaign, it is enough just to create box with ErgoFund campaign token, proper campaign ID, campaign desc, script, recommended deadline, min to raise. Then offchain scanner will find it (and so UI will be able to display campaign data). + +For the scanner, there are following requirements campaign box should satisfy: + +value >= 1 ERG (1000000000 nanoERG) +script = "4MQyMKvMbnCJG3aJ" (false proposition, so no one can spend) + +Registers: +*R4* - campaign ID (Int) +*R5* - campaign desc (byte array) +*R6* - campaign script (funds collected will go to this) +*R7* - fundraising deadline (Int, # of block, exclusive) +*R8* - min value for successful fundraising (Long) + +https://explorer.ergoplatform.com/en/transactions/2e25bc0ea4d01108ab1cd76969f49022228b533a2ea50540f6cde6258029a510 + + +example: + +```json +[ + { + "address": "4MQyMKvMbnCJG3aJ", + "value": 100000000, + "assets": [ + { + "tokenId": "05b66b97e5802f6447b67fe30cb4055e14d6b17bb14f5f563d65c9622c43a659", + "amount": 1 + } + ], + "registers": { + "R4": "0400", + "R5": "0e00", + "R6": "08cd0327e65711a59378c59359c3e1d0f7abe906479eccb76094e50fe79d743ccc15e6", + "R7": "04a0be49", + "R8": "0580d0acf30e" + } + } +] +``` + +## Pledge Contract + +Pledge contract is an example of self-sovereign DeFi principle. The contract is allowing box being protected by it to be spent if a spending transaction can collect at least *minToRaise* ERGs to *projectPubKey*, and inclusion height of the transaction is less than *deadline*. Otherwise, after *deadline* height is met, funds can be claimed by *projectPubKey*. Please note that both *backerPubKey* and +*projectPubKey* can be arbitrary scripts. + +```scala +{ + val campaignId = SELF.R4[Int].get + val backerPubKey = SELF.R5[SigmaProp].get + val projectPubKey = SELF.R6[SigmaProp].get + val deadline = SELF.R7[Int].get // height + val minToRaise = SELF.R8[Long].get + + val fundraisingFailure = HEIGHT >= deadline && OUTPUTS(0).propositionBytes == backerPubKey.propBytes && OUTPUTS(0).value >= SELF.value + val enoughRaised = {(outBox: Box) => outBox.value >= minToRaise && outBox.propositionBytes == projectPubKey.propBytes && outBox.R4[Int].get == campaignId} + + val fundraisingSuccess = HEIGHT < deadline && enoughRaised(OUTPUTS(0)) + fundraisingFailure || fundraisingSuccess +} +``` + +address: XUFypmadXVvYmBWtiuwDioN1rtj6nSvqgzgWjx1yFmHAVndPaAEgnUvEvEDSkpgZPRmCYeqxewi8ZKZ4Pamp1M9DAdu8d4PgShGRDV9inwzN6TtDeefyQbFXRmKCSJSyzySrGAt16 + +*R4* - campaign ID (Int) +*R5* - backer script (SigmaProp) +*R6* - campaign script (funds collected will go to this) (SigmaProp) +*R7* - fundraising deadline (Int, # of block, inclusive) (Int) +*R8* - min value for successful fundraising (Long)