├── test ├── mocha.opts ├── accounts.json ├── mocknet.toml ├── 1-scenarios.js └── 0-stackstarter.js ├── .vscode ├── extensions.json └── launch.json ├── package.json ├── src ├── util.js ├── stacksmocknet.js ├── stacksnodeapi.js └── stackstarterclient.js ├── LICENSE ├── .gitignore ├── README.md └── contracts └── stackstarter.clar /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --extension js,clar 2 | --timeout 0 3 | --recursive 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "blockstack.clarity" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "test", 11 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stackstarter", 3 | "version": "1.0.0", 4 | "author": { 5 | "name": "Marvin Janssen", 6 | "url": "https://marvinjanssen.me/" 7 | }, 8 | "engines": { 9 | "node": ">=10" 10 | }, 11 | "scripts": { 12 | "test": "mocha" 13 | }, 14 | "devDependencies": { 15 | "chai": "^4.2.0", 16 | "mocha": "^6.1.4" 17 | }, 18 | "dependencies": { 19 | "bn.js": "^5.1.3", 20 | "@blockstack/clarity": "^0.1.16", 21 | "@blockstack/clarity-native-bin": "^0.1.16", 22 | "@blockstack/stacks-transactions": "^0.6.0-test.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/accounts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "secretKey": "b8d99fd45da58038d630d9855d3ca2466e8e0f89d3894c4724f0efc9ff4b51f001", 4 | "publicKey": "", 5 | "stacksAddress": "ST2ZRX0K27GW0SP3GJCEMHD95TQGJMKB7G9Y0X1MH" 6 | }, 7 | { 8 | "secretKey": "3a4e84abb8abe0c1ba37cef4b604e73c82b1fe8d99015cb36b029a65099d373601", 9 | "publicKey": "", 10 | "stacksAddress": "ST26FVX16539KKXZKJN098Q08HRX3XBAP541MFS0P" 11 | }, 12 | { 13 | "secretKey": "052cc5b8f25b1e44a65329244066f76c8057accd5316c889f476d0ea0329632c01", 14 | "publicKey": "", 15 | "stacksAddress": "ST3CECAKJ4BH08JYY7W53MC81BYDT4YDA5M7S5F53" 16 | }, 17 | { 18 | "secretKey": "9aef533e754663a453984b69d36f109be817e9940519cc84979419e2be00864801", 19 | "publicKey": "", 20 | "stacksAddress": "ST31HHVBKYCYQQJ5AQ25ZHA6W2A548ZADDQ6S16GP" 21 | } 22 | ] -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const 2 | { 3 | serializeCV 4 | } = require('@blockstack/stacks-transactions'); 5 | 6 | const BN = require('bn.js'); 7 | 8 | /** 9 | * Converts a hex string to BN. Removes '0x' at the start of the string if found. 10 | * @param {string} hex 11 | * @throws {Error} If hex is not a string. 12 | * @return {BN} 13 | */ 14 | function hex2bn(hex) 15 | { 16 | if (typeof hex !== 'string') 17 | throw new Error('toBN only accepts a string'); 18 | return new BN(hex[0] === '0' && hex[1] === 'x' ? hex.substr(2) : hex,16); 19 | } 20 | 21 | /** 22 | * Converts a CV to hex string 23 | * @param {ClarityValue} cv 24 | * @return {string} 25 | */ 26 | function cv2hex(cv) 27 | { 28 | return '0x'+serializeCV(cv).toString('hex'); 29 | } 30 | 31 | module.exports = { 32 | hex2bn, 33 | cv2hex 34 | }; -------------------------------------------------------------------------------- /test/mocknet.toml: -------------------------------------------------------------------------------- 1 | [node] 2 | name = "helium-node" 3 | rpc_bind = "127.0.0.1:20443" 4 | 5 | [burnchain] 6 | chain = "bitcoin" 7 | mode = "mocknet" 8 | commit_anchor_block_within = 5000 9 | 10 | [[mstx_balance]] 11 | # Private key: b8d99fd45da58038d630d9855d3ca2466e8e0f89d3894c4724f0efc9ff4b51f001 12 | address = "ST2ZRX0K27GW0SP3GJCEMHD95TQGJMKB7G9Y0X1MH" 13 | amount = 1000000000 14 | 15 | [[mstx_balance]] 16 | # Private key: 3a4e84abb8abe0c1ba37cef4b604e73c82b1fe8d99015cb36b029a65099d373601 17 | address = "ST26FVX16539KKXZKJN098Q08HRX3XBAP541MFS0P" 18 | amount = 1000000000 19 | 20 | [[mstx_balance]] 21 | # Private key: 052cc5b8f25b1e44a65329244066f76c8057accd5316c889f476d0ea0329632c01 22 | address = "ST3CECAKJ4BH08JYY7W53MC81BYDT4YDA5M7S5F53" 23 | amount = 1000000000 24 | 25 | [[mstx_balance]] 26 | # Private key: 9aef533e754663a453984b69d36f109be817e9940519cc84979419e2be00864801 27 | address = "ST31HHVBKYCYQQJ5AQ25ZHA6W2A548ZADDQ6S16GP" 28 | amount = 1000000000 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Marvin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | .env.test 68 | 69 | # parcel-bundler cache (https://parceljs.org/) 70 | .cache 71 | 72 | # next.js build output 73 | .next 74 | 75 | # nuxt.js build output 76 | .nuxt 77 | 78 | # vuepress build output 79 | .vuepress/dist 80 | 81 | # Serverless directories 82 | .serverless/ 83 | 84 | # FuseBox cache 85 | .fusebox/ 86 | 87 | # DynamoDB Local files 88 | .dynamodb/ 89 | -------------------------------------------------------------------------------- /src/stacksmocknet.js: -------------------------------------------------------------------------------- 1 | const BLOCK_TIME = 5000; 2 | 3 | const {readFileSync} = require('fs'); 4 | 5 | const StacksNodeApi = require('./stacksnodeapi'); 6 | 7 | const 8 | { 9 | makeContractDeploy, 10 | StacksTestnet, 11 | broadcastTransaction 12 | } = require('@blockstack/stacks-transactions'); 13 | 14 | const 15 | { 16 | hex2bn, 17 | cv2hex 18 | } = require('../src/util'); 19 | 20 | const BN = require('bn.js'); 21 | 22 | const wait = ms => new Promise(resolve => setTimeout(resolve,ms)); 23 | 24 | /** 25 | * Simple Mocknet interface that exposes some helper functions. 26 | */ 27 | function StacksMocknet() 28 | { 29 | this.network = new StacksTestnet(); 30 | this.network.coreApiUrl = 'http://127.0.0.1:20443'; 31 | this.api = new StacksNodeApi(this.network); 32 | } 33 | 34 | /** 35 | * Returns the balance of a principal as a BN. Returns BN(0) for nonexistent principals. 36 | * @param {string} principal 37 | * @throws {Error} If the principal format is invalid. 38 | * @return {BN} 39 | */ 40 | StacksMocknet.prototype.balance = async function(principal) 41 | { 42 | try 43 | { 44 | return hex2bn((await this.api.account(principal.stacksAddress || principal)).balance); 45 | } 46 | catch (error) 47 | { 48 | if (error.message === '400 Bad Request') 49 | return new BN('0'); 50 | throw error; 51 | } 52 | }; 53 | 54 | /** 55 | * Deploys a contract and waits for confirmation. 56 | * @param {string} file path to the Clarity file. 57 | * @param {string} name name to use for deploymeny. 58 | * @param {object} account principal deploying the contract. 59 | * @throws {Error} If the call fails or if the deployment times out (20 seconds). 60 | * @return {bool} 61 | */ 62 | StacksMocknet.prototype.deploy_contract = async function(file,name,account) 63 | { 64 | const tx_options = { 65 | contractName: name, 66 | codeBody: readFileSync(file,'utf8'), 67 | senderKey: account.secretKey, 68 | network: this.network 69 | }; 70 | const transaction = await makeContractDeploy(tx_options); 71 | const txid = await broadcastTransaction(transaction,this.network); 72 | const timeout = +new Date() + 20000; 73 | while (+new Date() <= timeout) 74 | { 75 | await wait(250); 76 | try 77 | { 78 | return (await this.api.source(account.stacksAddress,tx_options.contractName)) === tx_options.codeBody; 79 | } 80 | catch (error) 81 | { 82 | if (error.message.substr(0,25) !== 'Unchecked(NoSuchContract(' && error.message.substr(0,3) !== '404') 83 | throw error; 84 | continue; 85 | } 86 | } 87 | throw new Error('deploy_contract timeout'); 88 | }; 89 | 90 | StacksMocknet.prototype.wait_n_blocks = async function(n,hide_progress) 91 | { 92 | var target_height = (await this.api.info()).stacks_tip_height + n; 93 | var timeout = +new Date() + (n+5)*BLOCK_TIME; 94 | while (+new Date() <= timeout) 95 | { 96 | let current_height = (await this.api.info()).stacks_tip_height; 97 | if (!hide_progress) 98 | process.stdout.write('\033[0Gwaiting for block '+(n-(target_height-current_height))+'/'+n); 99 | if (current_height >= target_height) 100 | { 101 | if (!hide_progress) 102 | process.stdout.write('\033[0G \033[0G'); 103 | return true; 104 | } 105 | await wait(500); 106 | } 107 | throw new Error('wait_n_blocks timeout'); 108 | }; 109 | 110 | module.exports = StacksMocknet; 111 | -------------------------------------------------------------------------------- /src/stacksnodeapi.js: -------------------------------------------------------------------------------- 1 | const 2 | { 3 | deserializeCV 4 | } = require('@blockstack/stacks-transactions'); 5 | 6 | function StacksNodeApi(network) 7 | { 8 | this.network = network; 9 | } 10 | 11 | /** 12 | * Returns the stacks node info object 13 | * @throws {Error} If the call fails. 14 | * @return {object} 15 | */ 16 | StacksNodeApi.prototype.info = async function() 17 | { 18 | let response = await fetch(`${this.network.coreApiUrl}/v2/info`,{method:'get',headers:{'content-type':'application/json'}}); 19 | if (response.ok) 20 | return (await response.json()); 21 | throw new Error(response.status+' '+response.statusText); 22 | }; 23 | 24 | /** 25 | * Reads account information and returns it as an object. 26 | * @param {string} principal 27 | * @throws {Error} If the call to the stacks node or JSON parsing fails. 28 | * @return {object} 29 | */ 30 | StacksNodeApi.prototype.account = async function(principal) 31 | { 32 | const response = await fetch(this.network.getAccountApiUrl(principal.stacksAddress || principal),{method:'get',headers:{'content-type':'application/json'}}); 33 | if (response.ok) 34 | return response.json(); 35 | throw new Error(response.status+' '+response.statusText); 36 | }; 37 | 38 | /** 39 | * Calls a read-only Clarity function 40 | * @param {string} address 41 | * @param {string} contract_name 42 | * @param {string} function_name 43 | * @param {object} args {sender:'tx-sender',arguments:[list of ClarityValue objects]} 44 | * @throws {Error} If the call fails. 45 | * @return {object} Deserialized result 46 | */ 47 | StacksNodeApi.prototype.call_read = async function(address,contract_name,function_name,args) 48 | { 49 | let response = await fetch(this.network.getReadOnlyFunctionCallApiUrl(address.stacksAddress || address,contract_name,function_name),{method:'post',headers:{'content-type':'application/json'},body:JSON.stringify(args || {sender:address.stacksAddress || address,arguments:[]})}); 50 | if (response.ok) 51 | { 52 | response = await response.json(); 53 | if (response.okay) 54 | return deserializeCV(Buffer.from(response.result.substr(2),'hex')); 55 | throw new Error(response.cause); 56 | } 57 | throw new Error(response.status+' '+response.statusText); 58 | }; 59 | 60 | /** 61 | * Returns the source code of a Clarity contract. 62 | * @param {string} address 63 | * @param {string} contract_name 64 | * @throws {Error} If the call fails. 65 | * @return {string} 66 | */ 67 | StacksNodeApi.prototype.source = async function(address,contract_name) 68 | { 69 | let response = await fetch(`${this.network.coreApiUrl}/v2/contracts/source/${address.stacksAddress || address}/${contract_name}?proof=0`,{method:'get',headers:{'content-type':'application/json'}}); 70 | if (response.ok) 71 | return (await response.json()).source; 72 | throw new Error(response.status+' '+response.statusText); 73 | }; 74 | 75 | /** 76 | * Reads a map entry of a smart contract for a specific key. 77 | * @param {string} address 78 | * @param {string} contract_name 79 | * @param {string} map_name 80 | * @param {object} key 81 | * @throws {Error} If the call fails. 82 | * @return {ClarityValue} 83 | */ 84 | StacksNodeApi.prototype.map_entry = async function(address,contract_name,map_name,key) 85 | { 86 | let response = await fetch(`${this.network.coreApiUrl}/v2/map_entry/source/${address.stacksAddress || address}/${contract_name}?proof=0`,{method:'post',headers:{'content-type':'application/json'},body:JSON.stringify(key)}); 87 | if (response.ok) 88 | return deserializeCV(Buffer.from(response.data.substr(2),'hex')); 89 | throw new Error(response.status+' '+response.statusText); 90 | }; 91 | 92 | module.exports = StacksNodeApi; 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stackstarter 2 | 3 | Stackstarter is a Clarity smart contract for crowdfunding on the STX blockchain. When a campaign is created, the fundraiser sets a goal in STX and a duration in blocks. In order for a campaign to be successfully funded, the campaign needs to receive investments matching or exceeding the funding goal before the target block-height is reached. The fundraiser can then collect the funds. 4 | 5 | Investors can request a refund at any point while the campaign is still active. This ensures that the investors stay in control of their STX until the funding goal is reached. If the campaign is unsuccessful, investors can likewise request a refund. 6 | 7 | In order to allow for nuanced investments and crowdfunding rewards, campaigns consist of tiers that the investors will choose from. Each tier has its own name, short description, and minimum cost. 8 | 9 | Some campaign information is stored on-chain. It is conceivable that this information could be moved to Gaia end-points controlled by the fundraisers at some point. 10 | 11 | A basic client implementation can be found in `src/stackstarterclient.js`. 12 | 13 | This is a submission for the Clarity 2.0 hackathon. 14 | 15 | ## Features 16 | 17 | The smart contract implements all features you would expect for online crowdfunding: 18 | 19 | - Users can start campaigns and add a name, short description, a link, funding goal, and duration. 20 | - The campaign owner can update the short description and link. 21 | - The campaign owner creates tiers, each having a name, short description, and cost. 22 | - Investors choose one or more tiers to invest. They are required to pay at least the tier cost in order for the investment to be successful. 23 | - Investors can take their investment out as long as the campaign has not reached its funding goal. 24 | - The campaign owner can collect the funds once the campaign is successfully funded. 25 | 26 | ## Read-only functions 27 | 28 | - `get-campaign-id-nonce` 29 | - Returns the current campaign ID nonce of the smart contract. 30 | - `get-total-campaigns-funded` 31 | - Returns the amount of campaigns that were successfully funded. 32 | - `get-total-investments` 33 | - Returns the total amount of investments. This number may go up and down a bit depending on refunds. 34 | - `get-total-investment-value` 35 | - Returns the total amount of STX invested. This number may go up and down a bit depending on refunds. 36 | - `get-campaign(campaign-id uint)` 37 | - Returns the campaign data for the specified campaign. 38 | - `get-campaign-information(campaign-id uint)` 39 | - Returns the campaign information for the specified campaign (short description and link). 40 | - `get-campaign-totals(campaign-id uint)` 41 | - Returns the campaign totals for the specified campaign (total investors and total investment). 42 | - `get-campaign-status(campaign-id uint)` 43 | - Returns the current campaign status (whether the goal was reached, the block height it was reached if successful, and whether the campaign owner has collected the funds). 44 | - `get-is-active-campaign(campaign-id uint)` 45 | - Returns whether the campaign is currently active (not expired and the funding goal has not yet been reached). 46 | - `get-campaign-tier-nonce(campaign-id uint)` 47 | - Returns the current tier ID nonce for the specified campaign. 48 | - `get-campaign-tier(campaign-id uint, tier-id uint)` 49 | - Returns the tier information for the specified tier. 50 | - `get-campaign-tier-totals(campaign-id uint, tier-id uint)` 51 | - Returns the campaign tier totals for the specified tier (total investors and total investment). 52 | - `get-campaign-tier-investment-amount(campaign-id uint, tier-id uint, investor principal)` 53 | - Returns the invested amount of an investor for the specified tier, defaults to `u0` if the investor has not invested. 54 | 55 | ## Public functions 56 | 57 | - `create-campaign (name buff, description buff, link buff, goal uint, duration uint)` 58 | - Creates a new campaign with the provided information. Returns the campaign ID if successful. 59 | - `update-campaign-information(campaign-id uint, description buff, link buff)` 60 | - Updates campaign information for the specified campaign. Only the owner can do this. Returns `u1` if successful. 61 | - `add-tier (campaign-id uint, name buff, description buff, cost uint)` 62 | - Adds an investment tier to the specified campaign. Only the owner can do this. Returns the tier ID if successful. 63 | - `invest(campaign-id uint, tier-id uint, amount uint)` 64 | - Invest an amount in the specified tier. This will transfer STX from the `tx-sender` to the contract. An investment is only successful if the campaign is still active and the investment amount is equal or larger than the tier cost. 65 | - `refund(campaign-id uint, tier-id uint)` 66 | - Request a refund for the specified tier. This will transfer STX from the contract to the `tx-sender`. A refund will only be processed if the campaign is still active or expired (unsuccessful), and if the `tx-sender` has indeed invested in the specified tier. 67 | - `collect(campaign-id uint)` 68 | - Collect the raised funds. This will transfer STX from the contract to the `tx-sender`. The campaign owner is the only one that can do this, and only if the campaign was successfully funded. 69 | 70 | # Testing 71 | 72 | All tests are ran on mocknet to get around limitations of the current Clarity JS SDK (STX transfers and advancing blocks). A `mocknet.toml` file is provided in the `test` folder to make things easier. Download and build [stacks-blockchain](https://github.com/blockstack/stacks-blockchain), then run a mocknet node using the provided file: 73 | 74 | ```bash 75 | cargo testnet start --config='/path/to/test/mocknet.toml' 76 | ``` 77 | 78 | There are two sets of tests: the "basic tests" cover specific function calls and the "scenarios" simulate two campaigns with multiple tiers and investors---one that is successful and one that is not). 79 | 80 | To run all tests: 81 | 82 | ```bash 83 | npm test 84 | ``` 85 | 86 | To run a particular test, specify the file: 87 | 88 | ```bash 89 | npm test test/0-stackstarter.js 90 | ``` 91 | 92 | One can query the contract balance [using the local node](http://127.0.0.1:20443/v2/accounts/ST2ZRX0K27GW0SP3GJCEMHD95TQGJMKB7G9Y0X1MH.stackstarter?proof=0). Be sure to restart the mocknet node if you want to rerun the tests. 93 | 94 | Since the tests rely on mocknet, they have to wait for blocks to be mined. It means that tests are slow and time-sensitive. Sit back and relax. -------------------------------------------------------------------------------- /test/1-scenarios.js: -------------------------------------------------------------------------------- 1 | const {assert} = require("chai"); 2 | 3 | const {readFileSync} = require('fs'); 4 | 5 | const 6 | { 7 | cvToString, 8 | getCVTypeString, 9 | ClarityType 10 | } = require('@blockstack/stacks-transactions'); 11 | 12 | const BN = require('bn.js'); 13 | 14 | const StackstarterClient = require('../src/stackstarterclient'); 15 | const StacksMocknet = require('../src/stacksmocknet'); 16 | 17 | const accounts = require('./accounts.json'); 18 | 19 | const contract_name = 'stackstarter'; 20 | 21 | const CONSOLE_LOG = false; 22 | 23 | describe('stackstarter scenarios',async () => 24 | { 25 | let contract_owner = accounts[0]; 26 | let user_a = accounts[1]; 27 | let user_b = accounts[2]; 28 | let user_c = accounts[3]; 29 | let mocknet; 30 | let stacks_node_api; 31 | let client_a; 32 | let client_b; 33 | let client_c; 34 | 35 | let current_campaign_id; 36 | let current_tier1_id; 37 | let current_tier2_id; 38 | let current_tier3_id; 39 | 40 | const campaign_goal = new BN('50000'); 41 | const tier1_cost = new BN('10000'); 42 | const tier2_cost = new BN('20000'); 43 | const tier3_cost = new BN('30000'); 44 | 45 | let campaign_counter = 0; 46 | 47 | let duration = 10; // this has to increase if the tests grow in size. 48 | 49 | before(async () => 50 | { 51 | mocknet = new StacksMocknet(); 52 | stacks_node_api = mocknet.api; 53 | client_a = new StackstarterClient(contract_owner.stacksAddress,user_a,stacks_node_api); 54 | client_b = new StackstarterClient(contract_owner.stacksAddress,user_b,stacks_node_api); 55 | client_c = new StackstarterClient(contract_owner.stacksAddress,user_c,stacks_node_api); 56 | await mocknet.deploy_contract('./contracts/stackstarter.clar',contract_name,contract_owner); 57 | }); 58 | 59 | beforeEach(async () => 60 | { 61 | ++campaign_counter; 62 | CONSOLE_LOG && console.log('Creating campaign'); 63 | const campaign = { 64 | name: 'test campaign '+campaign_counter, 65 | description: 'test campaign description '+campaign_counter, 66 | link: 'https://test-campaign'+campaign_counter+'.local', 67 | goal: campaign_goal, 68 | duration: duration 69 | }; 70 | await client_a.create_campaign(campaign); 71 | await mocknet.wait_n_blocks(1); 72 | current_campaign_id = await client_a.get_total_campaigns(); 73 | const tier1 = { 74 | campaign_id: current_campaign_id, 75 | name: 'test tier 1', 76 | description: 'test tier 1 description', 77 | cost: tier1_cost 78 | }; 79 | const tier2 = { 80 | campaign_id: current_campaign_id, 81 | name: 'test tier 2', 82 | description: 'test tier 2 description', 83 | cost: tier2_cost 84 | }; 85 | const tier3 = { 86 | campaign_id: current_campaign_id, 87 | name: 'test tier 3', 88 | description: 'test tier 3 description', 89 | cost: tier3_cost 90 | }; 91 | CONSOLE_LOG && console.log('Creating tiers'); 92 | await client_a.add_tier(tier1); 93 | await mocknet.wait_n_blocks(1); 94 | current_tier1_id = await client_a.get_total_campaign_tiers(current_campaign_id); 95 | await client_a.add_tier(tier2); 96 | await mocknet.wait_n_blocks(1); 97 | current_tier2_id = await client_a.get_total_campaign_tiers(current_campaign_id); 98 | await client_a.add_tier(tier3); 99 | await mocknet.wait_n_blocks(1); 100 | current_tier3_id = await client_a.get_total_campaign_tiers(current_campaign_id); 101 | }); 102 | 103 | it('successfully funded campaign with multiple tiers and investors',async () => 104 | { 105 | await client_b.invest(current_campaign_id,current_tier1_id,tier1_cost); 106 | await mocknet.wait_n_blocks(1); 107 | await client_b.invest(current_campaign_id,current_tier2_id,tier2_cost); 108 | await mocknet.wait_n_blocks(1); 109 | await client_c.invest(current_campaign_id,current_tier3_id,tier3_cost); 110 | await mocknet.wait_n_blocks(1); 111 | let expected_total_investment = (new BN('0')).add(tier1_cost).add(tier2_cost).add(tier3_cost); 112 | 113 | const totals = await client_a.get_campaign_totals(current_campaign_id); 114 | let data = totals.value.value.data; 115 | assert.equal(cvToString(data['total-investment']),'u'+expected_total_investment.toString(),'campaign total does not match'); 116 | assert.equal(cvToString(data['total-investors']),'u3','total investors does not match'); 117 | 118 | const previous_balance = await mocknet.balance(user_a); 119 | 120 | CONSOLE_LOG && console.log('waiting to reach target height'); 121 | 122 | while (await client_a.get_is_active_campaign(current_campaign_id)) 123 | await mocknet.wait_n_blocks(1); 124 | 125 | CONSOLE_LOG && console.log('target height reached'); 126 | 127 | await client_a.collect(current_campaign_id); 128 | await mocknet.wait_n_blocks(1); 129 | 130 | const new_balance = await mocknet.balance(user_a); 131 | assert.isTrue(new_balance.gte(previous_balance),'fundraiser did not receive funds'); 132 | }); 133 | 134 | it('failed campaign with multiple tiers and investors, investors can get their money back',async () => 135 | { 136 | await client_b.invest(current_campaign_id,current_tier1_id,tier1_cost); 137 | await mocknet.wait_n_blocks(1); 138 | await client_c.invest(current_campaign_id,current_tier2_id,tier2_cost); 139 | await mocknet.wait_n_blocks(1); 140 | let expected_total_investment = (new BN('0')).add(tier1_cost).add(tier2_cost); 141 | 142 | const totals = await client_a.get_campaign_totals(current_campaign_id); 143 | let data = totals.value.value.data; 144 | assert.equal(cvToString(data['total-investment']),'u'+expected_total_investment.toString(),'campaign total does not match'); 145 | assert.equal(cvToString(data['total-investors']),'u2','total investors does not match'); 146 | 147 | const owner_previous_balance = await mocknet.balance(user_a); 148 | const investor1_previous_balance = await mocknet.balance(user_b); 149 | const investor2_previous_balance = await mocknet.balance(user_c); 150 | 151 | CONSOLE_LOG && console.log('waiting to reach target height'); 152 | 153 | while (await client_a.get_is_active_campaign(current_campaign_id)) 154 | await mocknet.wait_n_blocks(1); 155 | 156 | CONSOLE_LOG && console.log('target height reached'); 157 | 158 | await client_a.collect(current_campaign_id); 159 | await mocknet.wait_n_blocks(1); 160 | 161 | const owner_new_balance = await mocknet.balance(user_a); 162 | assert.isTrue(owner_new_balance.lte(owner_previous_balance),'fundraiser should not have received funds'); 163 | 164 | await client_b.refund(current_campaign_id,current_tier1_id); 165 | await mocknet.wait_n_blocks(1); 166 | 167 | await client_c.refund(current_campaign_id,current_tier2_id); 168 | await mocknet.wait_n_blocks(1); 169 | 170 | const investor1_new_balance = await mocknet.balance(user_b); 171 | const investor2_new_balance = await mocknet.balance(user_c); 172 | 173 | assert.isTrue(investor1_new_balance.gt(investor1_previous_balance),'investor 1 did not receive refund'); 174 | assert.isTrue(investor2_new_balance.gt(investor2_previous_balance),'investor 2 did not receive refund'); 175 | }); 176 | }).timeout(0); 177 | -------------------------------------------------------------------------------- /src/stackstarterclient.js: -------------------------------------------------------------------------------- 1 | const 2 | { 3 | bufferCVFromString, 4 | uintCV, 5 | //principalCV, // not exported by @blockstack/stacks-transactions 6 | standardPrincipalCV, 7 | contractPrincipalCV, 8 | makeContractCall, 9 | deserializeCV, 10 | broadcastTransaction, 11 | makeStandardSTXPostCondition, 12 | makeContractSTXPostCondition, 13 | FungibleConditionCode, 14 | ClarityType 15 | } = require('@blockstack/stacks-transactions'); 16 | 17 | const BN = require('bn.js'); 18 | 19 | const 20 | { 21 | hex2bn, 22 | cv2hex 23 | } = require('../src/util'); 24 | 25 | function principalCV(principal) 26 | { 27 | if (!principal.includes('.')) 28 | return standardPrincipalCV(principal); 29 | const [address,contractName] = principal.split('.'); 30 | return contractPrincipalCV(address,contractName); 31 | } 32 | 33 | function StackstarterClient(contract_address,account,stacks_node_api) 34 | { 35 | this.contract_name = 'stackstarter'; 36 | this.contract_address = contract_address; 37 | this.account = account || {}; 38 | this.api = stacks_node_api; 39 | this.network = stacks_node_api.network; 40 | this.validate_with_abi = true; 41 | } 42 | 43 | StackstarterClient.prototype.get_campaign = async function(campaign_id) 44 | { 45 | return await this.api.call_read(this.contract_address,this.contract_name,'get-campaign',{sender:this.account.stacksAddress,arguments:[cv2hex(uintCV(campaign_id))]}); 46 | }; 47 | 48 | StackstarterClient.prototype.get_campaign_information = async function(campaign_id) 49 | { 50 | return await this.api.call_read(this.contract_address,this.contract_name,'get-campaign-information',{sender:this.account.stacksAddress,arguments:[cv2hex(uintCV(campaign_id))]}); 51 | }; 52 | 53 | StackstarterClient.prototype.get_campaign_status = async function(campaign_id) 54 | { 55 | return await this.api.call_read(this.contract_address,this.contract_name,'get-campaign-status',{sender:this.account.stacksAddress,arguments:[cv2hex(uintCV(campaign_id))]}); 56 | }; 57 | 58 | StackstarterClient.prototype.get_campaign_totals = async function(campaign_id) 59 | { 60 | return await this.api.call_read(this.contract_address,this.contract_name,'get-campaign-totals',{sender:this.account.stacksAddress,arguments:[cv2hex(uintCV(campaign_id))]}); 61 | }; 62 | 63 | async function read_only_uint(function_name,args,def) 64 | { 65 | try 66 | { 67 | let response = await this.api.call_read(this.contract_address,this.contract_name,function_name,{sender:this.account.stacksAddress,arguments:args || []}); 68 | if (response && response.type === ClarityType.ResponseOk && response.value.type === ClarityType.UInt) 69 | return response.value.value; 70 | return def || (new BN('0')); 71 | } 72 | catch (error) 73 | { 74 | if (error.message === '400 Bad Request') 75 | return def || (new BN('0')); 76 | throw error; 77 | } 78 | } 79 | 80 | StackstarterClient.prototype.get_total_campaigns = async function() 81 | { 82 | return await read_only_uint.call(this,'get-campaign-id-nonce'); 83 | }; 84 | 85 | StackstarterClient.prototype.get_total_campaigns_funded = async function() 86 | { 87 | return await read_only_uint.call(this,'get-total-campaigns-funded'); 88 | }; 89 | 90 | StackstarterClient.prototype.get_total_investments = async function() 91 | { 92 | return await read_only_uint.call(this,'get-total-investments'); 93 | }; 94 | 95 | StackstarterClient.prototype.get_total_investment_value = async function() 96 | { 97 | return await read_only_uint.call(this,'get-total-investment-value'); 98 | }; 99 | 100 | StackstarterClient.prototype.get_total_campaign_tiers = async function(campaign_id) 101 | { 102 | return await read_only_uint.call(this,'get-campaign-tier-nonce',[cv2hex(uintCV(campaign_id))]); 103 | }; 104 | 105 | StackstarterClient.prototype.get_campaign_tier = async function(campaign_id,tier_id) 106 | { 107 | return await this.api.call_read(this.contract_address,this.contract_name,'get-campaign-tier',{sender:this.account.stacksAddress,arguments:[cv2hex(uintCV(campaign_id)),cv2hex(uintCV(tier_id))]}); 108 | }; 109 | 110 | StackstarterClient.prototype.get_campaign_tier_totals = async function(campaign_id,tier_id) 111 | { 112 | return await this.api.call_read(this.contract_address,this.contract_name,'get-campaign-tier-totals',{sender:this.account.stacksAddress,arguments:[cv2hex(uintCV(campaign_id)),cv2hex(uintCV(tier_id))]}); 113 | }; 114 | 115 | StackstarterClient.prototype.get_campaign_tier_investment_amount = async function(campaign_id,tier_id,who) 116 | { 117 | return await read_only_uint.call(this,'get-campaign-tier-investment-amount',[cv2hex(uintCV(campaign_id)),cv2hex(uintCV(tier_id)),cv2hex(principalCV(who.stacksAddress || who))]); 118 | }; 119 | 120 | StackstarterClient.prototype.get_is_active_campaign = async function(campaign_id) 121 | { 122 | let response = await this.api.call_read(this.contract_address,this.contract_name,'get-is-active-campaign',{sender:this.account.stacksAddress,arguments:[cv2hex(uintCV(campaign_id))]}); 123 | if (response && response.type === ClarityType.ResponseOk && (response.value.type === ClarityType.BoolTrue || response.value.type === ClarityType.BoolFalse)) 124 | return response.value.type === ClarityType.BoolTrue; 125 | return false; 126 | }; 127 | 128 | async function broadcast_contract_call(function_name,args,post_conditions) 129 | { 130 | const txo = { 131 | contractAddress: this.contract_address, 132 | contractName: this.contract_name, 133 | functionName: function_name, 134 | functionArgs: args, 135 | senderKey: this.account.secretKey, 136 | validateWithAbi: this.validate_with_abi, 137 | network: this.network, 138 | postConditions: post_conditions || undefined 139 | }; 140 | const tx = await makeContractCall(txo); 141 | return await broadcastTransaction(tx,this.network); 142 | } 143 | 144 | StackstarterClient.prototype.create_campaign = async function(campaign) 145 | { 146 | if (typeof campaign !== 'object' || !campaign.name || !campaign.description || !campaign.link || !campaign.goal || !campaign.duration) 147 | return null; 148 | return await broadcast_contract_call.call(this,'create-campaign',[bufferCVFromString(campaign.name),bufferCVFromString(campaign.description),bufferCVFromString(campaign.link),uintCV(campaign.goal),uintCV(campaign.duration)]); 149 | }; 150 | 151 | StackstarterClient.prototype.update_campaign_information = async function(campaign_id,new_information) 152 | { 153 | if (typeof new_information !== 'object' || !new_information.description || !new_information.link) 154 | return null; 155 | return await broadcast_contract_call.call(this,'update-campaign-information',[uintCV(campaign_id),bufferCVFromString(new_information.description),bufferCVFromString(new_information.link)]); 156 | }; 157 | 158 | StackstarterClient.prototype.add_tier = async function(tier) 159 | { 160 | if (typeof tier !== 'object' || !tier.campaign_id || !tier.name || !tier.description || !tier.cost) 161 | return null; 162 | return await broadcast_contract_call.call(this,'add-tier',[uintCV(tier.campaign_id),bufferCVFromString(tier.name),bufferCVFromString(tier.description),uintCV(tier.cost)]); 163 | }; 164 | 165 | StackstarterClient.prototype.invest = async function(campaign_id,tier_id,amount) 166 | { 167 | const condition = makeStandardSTXPostCondition(this.account.stacksAddress,FungibleConditionCode.Equal,amount); 168 | return await broadcast_contract_call.call(this,'invest',[uintCV(campaign_id),uintCV(tier_id),uintCV(amount)],[condition]); 169 | }; 170 | 171 | StackstarterClient.prototype.refund = async function(campaign_id,tier_id) 172 | { 173 | const condition = makeContractSTXPostCondition(this.contract_address,this.contract_name,FungibleConditionCode.Greater,new BN('0')); 174 | return await broadcast_contract_call.call(this,'refund',[uintCV(campaign_id),uintCV(tier_id)],[condition]); 175 | }; 176 | 177 | StackstarterClient.prototype.collect = async function(campaign_id) 178 | { 179 | const condition = makeContractSTXPostCondition(this.contract_address,this.contract_name,FungibleConditionCode.Greater,new BN('0')); 180 | return await broadcast_contract_call.call(this,'collect',[uintCV(campaign_id)],[condition]); 181 | }; 182 | 183 | module.exports = StackstarterClient; 184 | -------------------------------------------------------------------------------- /contracts/stackstarter.clar: -------------------------------------------------------------------------------- 1 | ;; Stackstarter version 1 2 | ;; By Marvin Janssen (2020) 3 | 4 | ;; contract version for testing 5 | (define-constant contract-version u1) 6 | 7 | ;; contract owner 8 | ;; for testnet phase 3 9 | ;; (define-constant contract-owner tx-sender) 10 | 11 | ;; error constants 12 | (define-constant error-general (err u1)) 13 | (define-constant error-not-owner (err u2)) 14 | (define-constant error-campaign-has-investors (err u3)) 15 | (define-constant error-campaign-does-not-exist (err u4)) 16 | (define-constant error-campaign-inactive (err u5)) 17 | (define-constant error-invest-amount-insufficient (err u6)) 18 | (define-constant error-invest-stx-transfer-failed (err u7)) 19 | (define-constant error-no-investment (err u8)) 20 | (define-constant error-campaign-already-funded (err u9)) 21 | (define-constant error-refund-stx-transfer-failed (err u10)) 22 | (define-constant error-target-not-reached (err u11)) 23 | (define-constant error-funding-stx-transfer-failed (err u12)) 24 | (define-constant error-already-funded (err u13)) 25 | 26 | ;; current campaign ID nonce 27 | (define-data-var campaign-id-nonce uint u0) 28 | 29 | ;; general information 30 | (define-data-var total-campaigns-funded uint u0) 31 | (define-data-var total-investments uint u0) 32 | (define-data-var total-investment-value uint u0) 33 | 34 | ;; optional collection fee for contract-owner 35 | ;; (define-data-var contract-owner-collection-fee u0) 36 | 37 | ;; campaign information map 38 | (define-map campaigns ((campaign-id uint)) 39 | ( 40 | (name (buff 64)) ;; human-readable campaign name 41 | (fundraiser principal) ;; the address that is fundraising (could be a contract?) 42 | (goal uint) ;; funding goal 43 | (target-block-height uint) ;; target block height 44 | )) 45 | 46 | ;; campaign information 47 | ;; could at some point be moved to Gaia 48 | (define-map campaign-information ((campaign-id uint)) 49 | ( 50 | (description (buff 280)) ;; campaign short description 51 | (link (buff 150)) ;; campaign URL 52 | )) 53 | 54 | ;; campaign aggregates 55 | (define-map campaign-totals ((campaign-id uint)) 56 | ( 57 | (total-investment uint) 58 | (total-investors uint) 59 | )) 60 | 61 | ;; campaign status, whether the target was reached and at what block height 62 | (define-map campaign-status ((campaign-id uint)) 63 | ( 64 | (target-reached bool) ;; was the target reached? 65 | (target-reached-height uint);; block-height when it was reached 66 | (funded bool) ;; did the fundraiser collect the funds? 67 | )) 68 | 69 | ;; tier ID nonce per campaign 70 | (define-map tier-id-nonce ((campaign-id uint)) 71 | ( 72 | (nonce uint) 73 | )) 74 | 75 | ;; fundraising tiers per campaign 76 | (define-map tiers ((campaign-id uint) (tier-id uint)) 77 | ( 78 | (name (buff 32)) ;; human-readable tier name 79 | (description (buff 200)) ;; tier short description 80 | (cost uint) ;; tier minimum pledge cost 81 | )) 82 | 83 | ;; tier aggregates 84 | (define-map tier-totals ((campaign-id uint) (tier-id uint)) 85 | ( 86 | (total-investment uint) 87 | (total-investors uint) 88 | )) 89 | 90 | ;; tier investment by principal 91 | (define-map tier-investments ((campaign-id uint) (tier-id uint) (investor principal)) 92 | ( 93 | (amount uint) 94 | )) 95 | 96 | ;; get the campaign ID nonce 97 | (define-read-only (get-campaign-id-nonce) 98 | (ok (var-get campaign-id-nonce)) 99 | ) 100 | 101 | ;; get total campaigns funded 102 | (define-read-only (get-total-campaigns-funded) 103 | (ok (var-get total-campaigns-funded)) 104 | ) 105 | 106 | ;; get total campaign investments 107 | (define-read-only (get-total-investments) 108 | (ok (var-get total-investments)) 109 | ) 110 | 111 | ;; get total campaign investment value 112 | (define-read-only (get-total-investment-value) 113 | (ok (var-get total-investment-value)) 114 | ) 115 | 116 | ;; get campaign 117 | (define-read-only (get-campaign (campaign-id uint)) 118 | (ok (map-get? campaigns ((campaign-id campaign-id)))) 119 | ) 120 | 121 | ;; get campaign information 122 | (define-read-only (get-campaign-information (campaign-id uint)) 123 | (ok (map-get? campaign-information ((campaign-id campaign-id)))) 124 | ) 125 | 126 | ;; get campaign totals 127 | (define-read-only (get-campaign-totals (campaign-id uint)) 128 | (ok (map-get? campaign-totals ((campaign-id campaign-id)))) 129 | ) 130 | 131 | ;; get campaign status 132 | (define-read-only (get-campaign-status (campaign-id uint)) 133 | (ok (map-get? campaign-status ((campaign-id campaign-id)))) 134 | ) 135 | 136 | ;; get if a campaign is active 137 | (define-read-only (get-is-active-campaign (campaign-id uint)) 138 | (let ( 139 | (campaign (unwrap! (map-get? campaigns ((campaign-id campaign-id))) (ok false))) 140 | (status (unwrap! (map-get? campaign-status ((campaign-id campaign-id))) (ok false))) 141 | ) 142 | (ok (and (< block-height (get target-block-height campaign)) (not (get target-reached status)))) 143 | ) 144 | ) 145 | 146 | ;; get campaign tier ID nonce 147 | (define-read-only (get-campaign-tier-nonce (campaign-id uint)) 148 | (ok (default-to u0 (get nonce (map-get? tier-id-nonce ((campaign-id campaign-id)))))) 149 | ) 150 | 151 | ;; get campaign tier information 152 | (define-read-only (get-campaign-tier (campaign-id uint) (tier-id uint)) 153 | (ok (map-get? tiers ((campaign-id campaign-id) (tier-id tier-id)))) 154 | ) 155 | 156 | ;; get campaign tier totals 157 | (define-read-only (get-campaign-tier-totals (campaign-id uint) (tier-id uint)) 158 | (ok (map-get? tier-totals ((campaign-id campaign-id) (tier-id tier-id)))) 159 | ) 160 | 161 | ;; get the campaign tier invested amount for a principal 162 | (define-read-only (get-campaign-tier-investment-amount (campaign-id uint) (tier-id uint) (investor principal)) 163 | (ok (default-to u0 (get amount (map-get? tier-investments ((campaign-id campaign-id) (tier-id tier-id) (investor investor)))))) 164 | ) 165 | 166 | ;; create a new campaign for fundraising 167 | ;; it stores a little bit of information in the contract so that 168 | ;; there is a single source of truth. 169 | ;; a fundraiser should set a campaign name, description, goal in mSTX, 170 | ;; and duration in blocks. 171 | (define-public (create-campaign (name (buff 64)) (description (buff 280)) (link (buff 150)) (goal uint) (duration uint)) 172 | (let ((campaign-id (+ (var-get campaign-id-nonce) u1))) 173 | (if (and 174 | (map-set campaigns ((campaign-id campaign-id)) 175 | ( 176 | (name name) 177 | (fundraiser tx-sender) 178 | (goal goal) 179 | (target-block-height (+ duration block-height)) 180 | )) 181 | (map-set campaign-information ((campaign-id campaign-id)) 182 | ( 183 | (description description) 184 | (link link) 185 | )) 186 | (map-set campaign-totals ((campaign-id campaign-id)) 187 | ( 188 | (total-investment u0) 189 | (total-investors u0) 190 | )) 191 | (map-set campaign-status ((campaign-id campaign-id)) 192 | ( 193 | (target-reached false) 194 | (target-reached-height u0) 195 | (funded false) 196 | )) 197 | ) 198 | (begin 199 | (var-set campaign-id-nonce campaign-id) 200 | (ok campaign-id)) 201 | error-general ;; else 202 | ) 203 | ) 204 | ) 205 | 206 | ;; updates campaign information (description and link) 207 | ;; owner only 208 | (define-public (update-campaign-information (campaign-id uint) (description (buff 280)) (link (buff 150))) 209 | (let ((campaign (unwrap! (map-get? campaigns ((campaign-id campaign-id))) error-campaign-does-not-exist))) 210 | (asserts! (is-eq (get fundraiser campaign) tx-sender) error-not-owner) 211 | (map-set campaign-information ((campaign-id campaign-id)) 212 | ( 213 | (description description) 214 | (link link) 215 | )) 216 | (ok u1) 217 | ) 218 | ) 219 | 220 | ;; adds a funding tier to the campaign 221 | ;; owner only 222 | (define-public (add-tier (campaign-id uint) (name (buff 32)) (description (buff 200)) (cost uint)) 223 | (let ((campaign (unwrap! (map-get? campaigns ((campaign-id campaign-id))) error-campaign-does-not-exist))) 224 | (asserts! (is-eq (get fundraiser campaign) tx-sender) error-not-owner) 225 | (let ((tier-id (+ (unwrap-panic (get-campaign-tier-nonce campaign-id)) u1))) 226 | (if (and 227 | (map-set tiers ((campaign-id campaign-id) (tier-id tier-id)) 228 | ( 229 | (name name) 230 | (description description) 231 | (cost cost) 232 | )) 233 | (map-set tier-totals ((campaign-id campaign-id) (tier-id tier-id)) 234 | ( 235 | (total-investment u0) 236 | (total-investors u0) 237 | )) 238 | ) 239 | (begin 240 | (map-set tier-id-nonce ((campaign-id campaign-id)) ((nonce tier-id))) 241 | (ok tier-id)) 242 | error-general ;; else 243 | ) 244 | ) 245 | ) 246 | ) 247 | 248 | ;; invest in a campaign 249 | ;; transfers stx from tx-sender to the contract 250 | (define-public (invest (campaign-id uint) (tier-id uint) (amount uint)) 251 | (let ( 252 | (campaign (unwrap! (map-get? campaigns ((campaign-id campaign-id))) error-campaign-does-not-exist)) 253 | (status (unwrap-panic (map-get? campaign-status ((campaign-id campaign-id))))) 254 | (total (unwrap-panic (map-get? campaign-totals ((campaign-id campaign-id))))) 255 | (tier (unwrap-panic (map-get? tiers ((campaign-id campaign-id) (tier-id tier-id))))) 256 | (tier-total (unwrap-panic (map-get? tier-totals ((campaign-id campaign-id) (tier-id tier-id))))) 257 | (prior-investment (default-to u0 (get amount (map-get? tier-investments ((campaign-id campaign-id) (tier-id tier-id) (investor tx-sender)))))) 258 | ) 259 | (asserts! (and (< block-height (get target-block-height campaign)) (not (get target-reached status))) error-campaign-inactive) 260 | (asserts! (>= amount (get cost tier)) error-invest-amount-insufficient) 261 | (unwrap! (stx-transfer? amount tx-sender (as-contract tx-sender)) error-invest-stx-transfer-failed) 262 | (let ( 263 | (new-campaign-total (+ (get total-investment total) amount)) 264 | (new-tier-total (+ (get total-investment tier-total) amount)) 265 | ) 266 | (if (and 267 | (map-set campaign-totals ((campaign-id campaign-id)) 268 | ( 269 | (total-investment new-campaign-total) 270 | (total-investors (if (> prior-investment u0) (get total-investors total) (+ (get total-investors total) u1))) 271 | )) 272 | (map-set tier-totals ((campaign-id campaign-id) (tier-id tier-id)) 273 | ( 274 | (total-investment new-tier-total) 275 | (total-investors (if (> prior-investment u0) (get total-investors tier-total) (+ (get total-investors tier-total) u1))) 276 | )) 277 | (map-set tier-investments ((campaign-id campaign-id) (tier-id tier-id) (investor tx-sender)) 278 | ( 279 | (amount (+ prior-investment amount)) 280 | )) 281 | ) 282 | (begin 283 | (var-set total-investments (+ (var-get total-investments) u1)) 284 | (var-set total-investment-value (+ (var-get total-investment-value) amount)) 285 | (if (>= new-campaign-total (get goal campaign)) 286 | (begin 287 | (map-set campaign-status ((campaign-id campaign-id)) 288 | ( 289 | (target-reached true) 290 | (target-reached-height block-height) 291 | (funded false) 292 | )) 293 | (var-set total-campaigns-funded (+ (var-get total-campaigns-funded) u1)) 294 | (ok u2) ;; funded and target reached 295 | ) 296 | (ok u1) ;; else: funded but target not yet reached 297 | ) 298 | ) 299 | error-general ;; else 300 | ) 301 | ) 302 | ) 303 | ) 304 | 305 | ;; refund an investment 306 | ;; can only refund if the investment target has not been reached 307 | ;; transfers stx from the contract to the tx-sender 308 | (define-public (refund (campaign-id uint) (tier-id uint)) 309 | (let ( 310 | (campaign (unwrap! (map-get? campaigns ((campaign-id campaign-id))) error-campaign-does-not-exist)) 311 | (status (unwrap-panic (map-get? campaign-status ((campaign-id campaign-id))))) 312 | (total (unwrap-panic (map-get? campaign-totals ((campaign-id campaign-id))))) 313 | (tier (unwrap-panic (map-get? tiers ((campaign-id campaign-id) (tier-id tier-id))))) 314 | (tier-total (unwrap-panic (map-get? tier-totals ((campaign-id campaign-id) (tier-id tier-id))))) 315 | (prior-investment (default-to u0 (get amount (map-get? tier-investments ((campaign-id campaign-id) (tier-id tier-id) (investor tx-sender)))))) 316 | (original-tx-sender tx-sender) 317 | ) 318 | (asserts! (not (get target-reached status)) error-campaign-already-funded) 319 | (asserts! (> prior-investment u0) error-no-investment) 320 | (unwrap! (as-contract (stx-transfer? prior-investment tx-sender original-tx-sender)) error-refund-stx-transfer-failed) 321 | (let ( 322 | (new-campaign-total (- (get total-investment total) prior-investment)) 323 | (new-tier-total (- (get total-investment tier-total) prior-investment)) 324 | ) 325 | (if (and 326 | (map-set campaign-totals ((campaign-id campaign-id)) 327 | ( 328 | (total-investment new-campaign-total) 329 | (total-investors (- (get total-investors total) u1)) 330 | )) 331 | (map-set tier-totals ((campaign-id campaign-id) (tier-id tier-id)) 332 | ( 333 | (total-investment new-tier-total) 334 | (total-investors (- (get total-investors tier-total) u1)) 335 | )) 336 | (map-delete tier-investments ((campaign-id campaign-id) (tier-id tier-id) (investor tx-sender))) 337 | ) 338 | (begin 339 | (var-set total-investments (- (var-get total-investments) u1)) 340 | (var-set total-investment-value (- (var-get total-investment-value) prior-investment)) 341 | (ok u1) 342 | ) 343 | error-general ;; else 344 | ) 345 | ) 346 | ) 347 | ) 348 | 349 | ;; fund a campaign 350 | ;; this sends the raised funds to the fundraiser 351 | ;; only works if the goal was reached within the specified duration 352 | ;; TODO: allow other senders to trigger the stx transfer to the fundraiser? 353 | ;; TODO: transfer optional collection fee to contract-owner 354 | (define-public (collect (campaign-id uint)) 355 | (let ( 356 | (campaign (unwrap! (map-get? campaigns ((campaign-id campaign-id))) error-campaign-does-not-exist)) 357 | (status (unwrap-panic (map-get? campaign-status ((campaign-id campaign-id))))) 358 | (total (unwrap-panic (map-get? campaign-totals ((campaign-id campaign-id))))) 359 | (original-tx-sender tx-sender) 360 | ) 361 | (asserts! (is-eq (get fundraiser campaign) tx-sender) error-not-owner) 362 | (asserts! (not (get funded status)) error-already-funded) 363 | (asserts! (get target-reached status) error-target-not-reached) 364 | (unwrap! (as-contract (stx-transfer? (get total-investment total) tx-sender original-tx-sender)) error-funding-stx-transfer-failed) 365 | (asserts! (map-set campaign-status ((campaign-id campaign-id)) 366 | ( 367 | (target-reached true) 368 | (target-reached-height (get target-reached-height status)) 369 | (funded true) 370 | )) error-general) 371 | (ok u1) 372 | ) 373 | ) 374 | -------------------------------------------------------------------------------- /test/0-stackstarter.js: -------------------------------------------------------------------------------- 1 | const {assert} = require("chai"); 2 | 3 | const {readFileSync} = require('fs'); 4 | 5 | const 6 | { 7 | cvToString, 8 | getCVTypeString, 9 | ClarityType 10 | } = require('@blockstack/stacks-transactions'); 11 | 12 | const BN = require('bn.js'); 13 | 14 | const StackstarterClient = require('../src/stackstarterclient'); 15 | const StacksMocknet = require('../src/stacksmocknet'); 16 | 17 | const accounts = require('./accounts.json'); 18 | 19 | const contract_name = 'stackstarter'; 20 | 21 | describe('stackstarter basic tests',async () => 22 | { 23 | let contract_owner = accounts[0]; 24 | let user_a = accounts[1]; 25 | let user_b = accounts[2]; 26 | let mocknet; 27 | let stacks_node_api; 28 | let client_a; 29 | let client_b; 30 | let test_campaign_id; 31 | 32 | before(async () => 33 | { 34 | mocknet = new StacksMocknet(); 35 | stacks_node_api = mocknet.api; 36 | client_a = new StackstarterClient(contract_owner.stacksAddress,user_a,stacks_node_api); 37 | client_b = new StackstarterClient(contract_owner.stacksAddress,user_b,stacks_node_api); 38 | await mocknet.deploy_contract('./contracts/stackstarter.clar',contract_name,contract_owner); 39 | const new_campaign = { 40 | name: 'test campaign', 41 | description: 'test campaign description', 42 | link: 'https://test-campaign.local', 43 | goal: new BN('10000000'), 44 | duration: new BN('20000') 45 | }; 46 | await client_a.create_campaign(new_campaign); 47 | await mocknet.wait_n_blocks(1); 48 | test_campaign_id = await client_a.get_total_campaigns(); 49 | }); 50 | 51 | it('can create a campaign',async () => 52 | { 53 | const new_campaign = { 54 | name: 'name' + Math.random(), 55 | description: 'desc' + Math.random(), 56 | link: 'https://link' + Math.random(), 57 | goal: 100000 + (~~(Math.random()*100000)), 58 | duration: 100 + (~~(Math.random()*100)) 59 | }; 60 | await client_a.create_campaign(new_campaign); 61 | await mocknet.wait_n_blocks(1); 62 | const campaign_nonce = await stacks_node_api.call_read(contract_owner,contract_name,'get-campaign-id-nonce'); 63 | assert.equal(getCVTypeString(campaign_nonce),'(responseOk uint)','campaign nonce is not a uint'); 64 | const campaign_id = campaign_nonce.value.value; 65 | const campaign = await client_a.get_campaign(campaign_id); 66 | assert.notEqual(getCVTypeString(campaign),'(responseOk (optional none))','campaign does not exist'); 67 | let data = campaign.value.value.data; 68 | assert.equal(cvToString(data.fundraiser),user_a.stacksAddress,'fundraiser address does not match'); 69 | assert.equal(cvToString(data.name),'"'+new_campaign.name+'"','campaign name does not match'); 70 | assert.equal(cvToString(data.goal),'u'+new_campaign.goal,'campaign goal does not match'); 71 | const information = await client_a.get_campaign_information(campaign_id); 72 | assert.notEqual(getCVTypeString(information),'(responseOk (optional none))','campaign information does not exist'); 73 | data = information.value.value.data; 74 | assert.equal(cvToString(data.description),'"'+new_campaign.description+'"','campaign description does not match'); 75 | assert.equal(cvToString(data.link),'"'+new_campaign.link+'"','campaign name does not match'); 76 | }); 77 | 78 | it('owner can update the campaign information',async () => 79 | { 80 | const new_information = { 81 | description: 'new desc' + Math.random(), 82 | link: 'https://newlink' + + Math.random() 83 | }; 84 | await client_a.update_campaign_information(test_campaign_id,new_information); 85 | await mocknet.wait_n_blocks(1); 86 | const information = await client_a.get_campaign_information(test_campaign_id); 87 | assert.notEqual(getCVTypeString(information),'(responseOk (optional none))','campaign information does not exist'); 88 | const data = information.value.value.data; 89 | assert.equal(cvToString(data.description),'"'+new_information.description+'"','new campaign description does not match'); 90 | assert.equal(cvToString(data.link),'"'+new_information.link+'"','new campaign link does not match'); 91 | }); 92 | 93 | it('does not allow others to update the campaign information of a campaign they do not own',async () => 94 | { 95 | const new_information = { 96 | description: 'new desc FAIL' + Math.random(), 97 | link: 'https://newlinkFAIL' + + Math.random() 98 | }; 99 | await client_b.update_campaign_information(test_campaign_id,new_information); 100 | await mocknet.wait_n_blocks(1); 101 | const information = await client_b.get_campaign_information(test_campaign_id); 102 | assert.notEqual(getCVTypeString(information),'(responseOk (optional none))','Project information does not exist'); 103 | const data = information.value.value.data; 104 | assert.notEqual(cvToString(data.description),'"'+new_information.description+'"','New campaign description matches when it should not'); 105 | assert.notEqual(cvToString(data.link),'"'+new_information.link+'"','New campaign link matches when it should not'); 106 | }); 107 | 108 | it('reports if a campaign is active',async () => 109 | { 110 | assert.isTrue(await client_a.get_is_active_campaign(test_campaign_id),'test campaign is not active'); 111 | assert.isFalse(await client_a.get_is_active_campaign(new BN('99999999999999')),'bogus campaign should not be active, or this test has ran 99999999999999 times'); 112 | }); 113 | 114 | it('gives the sum of the total campaigns',async () => 115 | { 116 | let current = await client_a.get_total_campaigns(); 117 | await client_a.create_campaign({name:'a',description:'b',link:'c',goal:100,duration:100}); 118 | await mocknet.wait_n_blocks(1); 119 | let next = await client_a.get_total_campaigns(); 120 | assert.isTrue(current.add(new BN('1')).eq(next),'campaign count did not increment'); 121 | }); 122 | 123 | it('allows the owner to add a tier',async () => 124 | { 125 | const new_tier = { 126 | campaign_id: test_campaign_id, 127 | name: 'name' + Math.random(), 128 | description: 'desc' + Math.random(), 129 | cost: 1000 + (~~(Math.random()*1000)) 130 | }; 131 | await client_a.add_tier(new_tier); 132 | await mocknet.wait_n_blocks(1); 133 | const tier_id = await client_a.get_total_campaign_tiers(test_campaign_id); 134 | const tier = await client_a.get_campaign_tier(test_campaign_id,tier_id); 135 | assert.notEqual(getCVTypeString(tier),'(responseOk (optional none))'); 136 | const data = tier.value.value.data; 137 | assert.equal(cvToString(data.name),'"'+new_tier.name+'"','Name does not match'); 138 | assert.equal(cvToString(data.cost),'u'+new_tier.cost,'cost does not match'); 139 | }); 140 | 141 | it('does not allow others to add a tier to a campaign they do not own',async () => 142 | { 143 | const current = await client_a.get_total_campaign_tiers(test_campaign_id); 144 | const new_tier = { 145 | campaign_id: test_campaign_id, 146 | name: 'nameFAIL', 147 | description: 'descFAIL', 148 | cost: 1000 + (~~(Math.random()*1000)) 149 | }; 150 | await client_b.add_tier(new_tier); 151 | await mocknet.wait_n_blocks(1); 152 | const next = await client_a.get_total_campaign_tiers(test_campaign_id); 153 | assert.isTrue(current.eq(next),'tier count did not stay the sane'); 154 | }); 155 | 156 | it('allows clients to invest in tiers',async () => 157 | { 158 | const previous_total = await client_a.get_total_investment_value(); 159 | const invest_amount = new BN('10000000'); 160 | const new_tier = { 161 | campaign_id: test_campaign_id, 162 | name: 'nameIN', 163 | description: 'descIN', 164 | cost: invest_amount 165 | }; 166 | await client_a.add_tier(new_tier); 167 | await mocknet.wait_n_blocks(1); 168 | const previous_balance = await mocknet.balance(user_b); 169 | const tier_id = await client_a.get_total_campaign_tiers(test_campaign_id); 170 | await client_b.invest(test_campaign_id,tier_id,invest_amount); 171 | await mocknet.wait_n_blocks(1); 172 | const new_total = await client_a.get_total_investment_value(); 173 | assert.isTrue(new_total.sub(previous_total).eq(invest_amount),'investment total stayed the same'); 174 | const totals = await client_a.get_campaign_tier_totals(test_campaign_id,tier_id); 175 | assert.notEqual(getCVTypeString(totals),'(responseOk (optional none))','tier totals do not exist'); 176 | const data = totals.value.value.data; 177 | assert.isTrue(data['total-investment'].value.eq(invest_amount),'total-investment did not increase'); 178 | assert.isTrue(data['total-investors'].value.eq(new BN('1')),'total-investors did not increment'); 179 | const investment = await client_b.get_campaign_tier_investment_amount(test_campaign_id,tier_id,user_b); 180 | assert.isTrue(investment.eq(invest_amount),'principal investment amount did not increase'); 181 | const new_balance = await mocknet.balance(user_b); 182 | assert.isTrue(previous_balance.sub(new_balance).gte(invest_amount),'user b balance did not decrease by at least the invest amount'); 183 | }); 184 | 185 | it('rejects the investment if the amount is too low',async () => 186 | { 187 | const previous_total = await client_a.get_total_investment_value(); 188 | const invest_amount = new BN('100'); 189 | const new_tier = { 190 | campaign_id: test_campaign_id, 191 | name: 'nameLOW', 192 | description: 'descLOW', 193 | cost: new BN('10000') 194 | }; 195 | await client_a.add_tier(new_tier); 196 | await mocknet.wait_n_blocks(1); 197 | const tier_id = await client_a.get_total_campaign_tiers(test_campaign_id); 198 | await client_b.invest(test_campaign_id,tier_id,invest_amount); 199 | await mocknet.wait_n_blocks(1); 200 | const new_total = await client_a.get_total_investment_value(); 201 | assert.isTrue(new_total.eq(previous_total),'total investment increased'); 202 | const totals = await client_a.get_campaign_tier_totals(test_campaign_id,tier_id); 203 | assert.notEqual(getCVTypeString(totals),'(responseOk (optional none))','tier totals do not exist'); 204 | const data = totals.value.value.data; 205 | assert.isTrue(data['total-investment'].value.eq(new BN('0')),'total-investment increased'); 206 | assert.isTrue(data['total-investors'].value.eq(new BN('0')),'total-investors incremented'); 207 | const investment = await client_b.get_campaign_tier_investment_amount(test_campaign_id,tier_id,user_b); 208 | assert.isTrue(investment.eq(new BN('0')),'principal investment amount increased'); 209 | }); 210 | 211 | it('allows a client to refund a prior tier investment',async () => 212 | { 213 | const new_campaign = { 214 | name: 'test campaign', 215 | description: 'test campaign description', 216 | link: 'https://test-campaign.local', 217 | goal: new BN('10000000'), 218 | duration: new BN('20000') 219 | }; 220 | await client_a.create_campaign(new_campaign); 221 | await mocknet.wait_n_blocks(1); 222 | const campaign_id = await client_a.get_total_campaigns(); 223 | const invest_amount = new BN('100000'); 224 | const new_tier = { 225 | campaign_id: campaign_id, 226 | name: 'nameRE', 227 | description: 'descRE', 228 | cost: invest_amount 229 | }; 230 | await client_a.add_tier(new_tier); 231 | await mocknet.wait_n_blocks(1); 232 | const tier_id = await client_a.get_total_campaign_tiers(campaign_id); 233 | await client_b.invest(campaign_id,tier_id,invest_amount); 234 | await mocknet.wait_n_blocks(1); 235 | const previous_balance = await mocknet.balance(user_b); 236 | await client_b.refund(campaign_id,tier_id); 237 | await mocknet.wait_n_blocks(1); 238 | const new_balance = await mocknet.balance(user_b); 239 | const totals = await client_a.get_campaign_tier_totals(campaign_id,tier_id); 240 | assert.notEqual(getCVTypeString(totals),'(responseOk (optional none))','tier totals do not exist'); 241 | const data = totals.value.value.data; 242 | assert.isTrue(data['total-investment'].value.eq(new BN('0')),'total-investment is not zero'); 243 | assert.isTrue(data['total-investors'].value.eq(new BN('0')),'total-investors is not zero'); 244 | const investment = await client_b.get_campaign_tier_investment_amount(campaign_id,tier_id,user_b); 245 | assert.isTrue(investment.eq(new BN('0')),'principal investment amount is not zero'); 246 | assert.isTrue(new_balance.gt(previous_balance),'user did not get investment back'); 247 | }); 248 | 249 | it('allows the owner to collect the raised amount when the target is reached in time',async () => 250 | { 251 | const new_campaign = { 252 | name: 'nameFUND' + Math.random(), 253 | description: 'descFUND' + Math.random(), 254 | link: 'https://link' + Math.random(), 255 | goal: new BN('1000'), 256 | duration: 5 257 | }; 258 | await client_a.create_campaign(new_campaign); 259 | await mocknet.wait_n_blocks(1); 260 | const campaign_id = await client_a.get_total_campaigns(); 261 | const invest_amount = new BN('2000'); 262 | const new_tier = { 263 | campaign_id: campaign_id, 264 | name: 'nameRE' + Math.random(), 265 | description: 'descRE' + Math.random(), 266 | cost: invest_amount 267 | }; 268 | await client_a.add_tier(new_tier); 269 | await mocknet.wait_n_blocks(1); 270 | const tier_id = await client_a.get_total_campaign_tiers(campaign_id); 271 | await client_b.invest(campaign_id,tier_id,invest_amount); 272 | await mocknet.wait_n_blocks(4); // beyond the duration 273 | const previous_balance = await mocknet.balance(contract_owner.stacksAddress+'.'+contract_name); 274 | await client_a.collect(campaign_id); 275 | await mocknet.wait_n_blocks(1); 276 | const new_balance = await mocknet.balance(contract_owner.stacksAddress+'.'+contract_name); 277 | assert.isTrue(previous_balance.gt(new BN('0')),'previous balance is zero'); 278 | assert.isTrue(previous_balance.sub(new_balance).eq(invest_amount)); 279 | const status = await client_a.get_campaign_status(campaign_id); 280 | assert.notEqual(getCVTypeString(status),'(responseOk (optional none))','campaign status does not exist'); 281 | const data = status.value.value.data; 282 | assert.equal(data['target-reached'].type,ClarityType.BoolTrue,'target-reached is false'); 283 | assert.isTrue(data['target-reached-height'].value.gt(new BN('0')),'target-reached-height is zero'); 284 | assert.equal(data['funded'].type,ClarityType.BoolTrue,'funded is false'); 285 | }); 286 | 287 | it('does not allow the owner to collected the raised amount before the goal is reached',async () => 288 | { 289 | const new_campaign = { 290 | name: 'nameLONG' + Math.random(), 291 | description: 'descLONG' + Math.random(), 292 | link: 'https://link' + Math.random(), 293 | goal: new BN('5000'), 294 | duration: 50000 295 | }; 296 | await client_a.create_campaign(new_campaign); 297 | await mocknet.wait_n_blocks(1); 298 | const campaign_id = await client_a.get_total_campaigns(); 299 | const invest_amount = new BN('2000'); 300 | const new_tier = { 301 | campaign_id: campaign_id, 302 | name: 'nameRE' + Math.random(), 303 | description: 'descRE' + Math.random(), 304 | cost: invest_amount 305 | }; 306 | await client_a.add_tier(new_tier); 307 | await mocknet.wait_n_blocks(1); 308 | const tier_id = await client_a.get_total_campaign_tiers(campaign_id); 309 | await client_b.invest(campaign_id,tier_id,invest_amount); 310 | await mocknet.wait_n_blocks(1); 311 | await client_a.collect(campaign_id); 312 | const status = await client_a.get_campaign_status(campaign_id); 313 | assert.notEqual(getCVTypeString(status),'(responseOk (optional none))','campaign status does not exist'); 314 | const data = status.value.value.data; 315 | assert.equal(data['target-reached'].type,ClarityType.BoolFalse,'target-reached is true'); 316 | assert.isFalse(data['target-reached-height'].value.gt(new BN('0')),'target-reached-height is not zero'); 317 | assert.equal(data['funded'].type,ClarityType.BoolFalse,'funded is true'); 318 | }); 319 | 320 | it('does not allow clients to refund their investment after the goal was reached',async () => 321 | { 322 | const new_campaign = { 323 | name: 'nameFUNDN' + Math.random(), 324 | description: 'descFUNDN' + Math.random(), 325 | link: 'https://link' + Math.random(), 326 | goal: new BN('1000'), 327 | duration: 5 328 | }; 329 | await client_a.create_campaign(new_campaign); 330 | await mocknet.wait_n_blocks(1); 331 | const campaign_id = await client_a.get_total_campaigns(); 332 | const invest_amount = new BN('2000'); 333 | const new_tier = { 334 | campaign_id: campaign_id, 335 | name: 'nameREN' + Math.random(), 336 | description: 'descREN' + Math.random(), 337 | cost: invest_amount 338 | }; 339 | await client_a.add_tier(new_tier); 340 | await mocknet.wait_n_blocks(1); 341 | const tier_id = await client_a.get_total_campaign_tiers(campaign_id); 342 | await client_b.invest(campaign_id,tier_id,invest_amount); 343 | await mocknet.wait_n_blocks(4); // beyond the duration 344 | const previous_balance = await mocknet.balance(user_b); 345 | const status = await client_a.get_campaign_status(campaign_id); 346 | assert.notEqual(getCVTypeString(status),'(responseOk (optional none))','campaign status does not exist'); 347 | const data = status.value.value.data; 348 | assert.equal(data['target-reached'].type,ClarityType.BoolTrue,'target-reached is false'); 349 | assert.isTrue(data['target-reached-height'].value.gt(new BN('0')),'target-reached-height is zero'); 350 | assert.equal(data['funded'].type,ClarityType.BoolFalse,'funded is true'); 351 | await client_b.refund(test_campaign_id,tier_id); 352 | await mocknet.wait_n_blocks(1); 353 | const current_balance = await mocknet.balance(user_b); 354 | assert.isTrue(current_balance.sub(previous_balance).lt(invest_amount),'user should not have received investment back'); 355 | }); 356 | }).timeout(0); 357 | --------------------------------------------------------------------------------