From b6524a796810bd3e579732d02dca8ba18b1aff3c Mon Sep 17 00:00:00 2001 From: suzanabel Date: Tue, 19 Aug 2025 13:13:57 +0100 Subject: [PATCH] Project Funding Smart Contract --- funding/.gitignore | 4 + funding/.vscode/settings.json | 4 + funding/.vscode/tasks.json | 18 +++ funding/Clarinet.toml | 11 ++ funding/contracts/project-funds.clar | 192 +++++++++++++++++++++++++++ funding/settings/Devnet.toml | 127 ++++++++++++++++++ funding/tests/project-funds_test.ts | 26 ++++ 7 files changed, 382 insertions(+) create mode 100644 funding/.gitignore create mode 100644 funding/.vscode/settings.json create mode 100644 funding/.vscode/tasks.json create mode 100644 funding/Clarinet.toml create mode 100644 funding/contracts/project-funds.clar create mode 100644 funding/settings/Devnet.toml create mode 100644 funding/tests/project-funds_test.ts diff --git a/funding/.gitignore b/funding/.gitignore new file mode 100644 index 0000000..f18b582 --- /dev/null +++ b/funding/.gitignore @@ -0,0 +1,4 @@ + +settings/Mainnet.toml +settings/Testnet.toml +history.txt diff --git a/funding/.vscode/settings.json b/funding/.vscode/settings.json new file mode 100644 index 0000000..02e21eb --- /dev/null +++ b/funding/.vscode/settings.json @@ -0,0 +1,4 @@ + +{ + "deno.enable": true, +} diff --git a/funding/.vscode/tasks.json b/funding/.vscode/tasks.json new file mode 100644 index 0000000..22af91c --- /dev/null +++ b/funding/.vscode/tasks.json @@ -0,0 +1,18 @@ + +{ + "version": "2.0.0", + "tasks": [ + { + "label": "check contracts", + "group": "test", + "type": "shell", + "command": "clarinet check" + }, + { + "label": "test contracts", + "group": "test", + "type": "shell", + "command": "clarinet test" + } + ] +} diff --git a/funding/Clarinet.toml b/funding/Clarinet.toml new file mode 100644 index 0000000..1b5c8c1 --- /dev/null +++ b/funding/Clarinet.toml @@ -0,0 +1,11 @@ +[project] +name = "funding" +authors = [] +description = "" +telemetry = true +requirements = [] +analysis = ["check_checker"] +costs_version = 2 +[contracts.project-funds] +path = "contracts/project-funds.clar" +depends_on = [] diff --git a/funding/contracts/project-funds.clar b/funding/contracts/project-funds.clar new file mode 100644 index 0000000..6894b75 --- /dev/null +++ b/funding/contracts/project-funds.clar @@ -0,0 +1,192 @@ +;; Define the contract +(define-data-var contract-admin principal tx-sender) + +;; Define constants for errors +(define-constant ERR-UNAUTHORIZED-ACCESS (err u100)) +(define-constant ERR-DUPLICATE-ENTRY (err u101)) +(define-constant ERR-ENTRY-NOT-FOUND (err u102)) +(define-constant ERR-BALANCE-INSUFFICIENT (err u103)) +(define-constant ERR-PROJECT-NOT-FOUND (err u104)) +(define-constant ERR-EXPENSE-NOT-FOUND (err u105)) +(define-constant ERR-INVALID-PARAMETERS (err u106)) + +;; Define constants for access levels +(define-constant ACCESS-OWNER u1) +(define-constant ACCESS-MANAGER u2) +(define-constant ACCESS-PARTICIPANT u3) + +;; Define data maps +(define-map access-levels { account: principal } { level: uint }) + +(define-map projects + { id: uint } + { + title: (string-utf8 50), + details: (string-utf8 255), + funding-goal: uint, + funds-raised: uint, + state: (string-ascii 20) + }) + +(define-map contributions + { id: uint } + { contributor: principal, project-id: uint, value: uint, block-time: uint }) + +(define-map expenditures + { id: uint } + { + project-id: uint, + phase: uint, + details: (string-utf8 255), + value: uint, + state: (string-ascii 20) + }) + +;; Define data variables +(define-data-var project-counter uint u0) +(define-data-var contribution-counter uint u0) +(define-data-var expenditure-counter uint u0) + +;; Helper functions +(define-private (has-access (account principal) (minimum-level uint)) + (let ((access-data (default-to { level: u0 } (map-get? access-levels { account: account })))) + (>= (get level access-data) minimum-level))) + +(define-private (get-latest-phase (project-id uint)) + (var-get expenditure-counter)) + +;; Access management functions +(define-public (assign-access (account principal) (new-level uint)) + (let ((current-level (default-to u0 (get level (map-get? access-levels { account: account }))))) + (if (and + (is-eq tx-sender (var-get contract-admin)) + (<= new-level ACCESS-PARTICIPANT) + (not (is-eq account tx-sender)) ;; Ensure account is not setting their own access + (or (is-eq new-level ACCESS-OWNER) + (is-eq new-level ACCESS-MANAGER) + (is-eq new-level ACCESS-PARTICIPANT))) + (ok (map-set access-levels { account: account } { level: new-level })) + ERR-UNAUTHORIZED-ACCESS))) + +(define-public (revoke-access (account principal)) + (if (and + (is-eq tx-sender (var-get contract-admin)) + (is-some (map-get? access-levels { account: account })) + (not (is-eq account tx-sender))) ;; Ensure account is not removing their own access + (ok (map-delete access-levels { account: account })) + ERR-UNAUTHORIZED-ACCESS)) + +;; Main functions +(define-public (create-project (title (string-utf8 50)) (details (string-utf8 255)) (funding-goal uint)) + (let + ((project-id (+ (var-get project-counter) u1))) + (if (and (has-access tx-sender ACCESS-MANAGER) + (> (len title) u0) + (> (len details) u0) + (> funding-goal u0)) + (begin + (map-set projects + { id: project-id } + { + title: title, + details: details, + funding-goal: funding-goal, + funds-raised: u0, + state: "active" + }) + (var-set project-counter project-id) + (ok project-id)) + ERR-INVALID-PARAMETERS))) + +(define-read-only (get-project (id uint)) + (match (map-get? projects { id: id }) + project (ok project) + ERR-PROJECT-NOT-FOUND)) + +(define-public (fund-project (project-id uint) (value uint)) + (let + ((project (unwrap! (get-project project-id) ERR-PROJECT-NOT-FOUND))) + (if (and (> value u0) + (< project-id (var-get project-counter)) ;; Check if project-id is valid + (is-some (map-get? projects { id: project-id }))) + (match (stx-transfer? value tx-sender (as-contract tx-sender)) + success (begin + (map-set projects + { id: project-id } + (merge project { funds-raised: (+ (get funds-raised project) value) })) + (map-set contributions + { id: (+ (var-get contribution-counter) u1) } + { contributor: tx-sender, project-id: project-id, value: value, block-time: block-height }) + (var-set contribution-counter (+ (var-get contribution-counter) u1)) + (ok true)) + error ERR-BALANCE-INSUFFICIENT) + ERR-INVALID-PARAMETERS))) + +(define-public (record-expenditure (project-id uint) (details (string-utf8 255)) (value uint)) + (let + ((project (unwrap! (get-project project-id) ERR-PROJECT-NOT-FOUND))) + (if (and (has-access tx-sender ACCESS-OWNER) + (> (len details) u0) + (> value u0) + (< project-id (var-get project-counter))) ;; Check if project-id is valid + (let + ((phase (+ (get-latest-phase project-id) u1)) + (expenditure-id (+ (var-get expenditure-counter) u1))) + (begin + (map-set expenditures + { id: expenditure-id } + { + project-id: project-id, + phase: phase, + details: details, + value: value, + state: "pending" + }) + (var-set expenditure-counter expenditure-id) + (ok phase))) + ERR-INVALID-PARAMETERS))) + +(define-public (validate-expenditure (project-id uint) (phase uint)) + (let + ((expenditure-entry (unwrap! (map-get? expenditures { id: phase }) ERR-EXPENSE-NOT-FOUND)) + (project (unwrap! (get-project project-id) ERR-PROJECT-NOT-FOUND))) + (if (and (has-access tx-sender ACCESS-OWNER) + (is-eq (get project-id expenditure-entry) project-id) + (< project-id (var-get project-counter)) ;; Check if project-id is valid + (< phase (var-get expenditure-counter))) ;; Check if phase is valid + (if (<= (get value expenditure-entry) (get funds-raised project)) + (begin + (map-set expenditures + { id: phase } + (merge expenditure-entry { state: "approved" })) + (ok true)) + ERR-BALANCE-INSUFFICIENT) + ERR-UNAUTHORIZED-ACCESS))) + +;; Get a single contribution by ID +(define-read-only (get-contribution-by-id (contribution-id uint)) + (match (map-get? contributions { id: contribution-id }) + contribution (ok contribution) + ERR-ENTRY-NOT-FOUND)) + +;; Get a single expenditure entry by ID +(define-read-only (get-expenditure-by-id (expenditure-id uint)) + (match (map-get? expenditures { id: expenditure-id }) + expense (ok expense) + ERR-ENTRY-NOT-FOUND)) + +;; Get the total number of contributions +(define-read-only (get-contribution-total) + (ok (var-get contribution-counter))) + +;; Get the total number of expenditure entries +(define-read-only (get-expenditure-total) + (ok (var-get expenditure-counter))) + +;; Contract initialization +(define-private (setup-contract) + (begin + (map-set access-levels { account: tx-sender } { level: ACCESS-OWNER }) + (var-set contract-admin tx-sender))) + +(setup-contract) \ No newline at end of file diff --git a/funding/settings/Devnet.toml b/funding/settings/Devnet.toml new file mode 100644 index 0000000..8a5ff75 --- /dev/null +++ b/funding/settings/Devnet.toml @@ -0,0 +1,127 @@ +[network] +name = "devnet" +deployment_fee_rate = 10 + +[accounts.deployer] +mnemonic = "twice kind fence tip hidden tilt action fragile skin nothing glory cousin green tomorrow spring wrist shed math olympic multiply hip blue scout claw" +balance = 100_000_000_000_000 +# secret_key: 753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a601 +# stx_address: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM +# btc_address: mqVnk6NPRdhntvfm4hh9vvjiRkFDUuSYsH + +[accounts.wallet_1] +mnemonic = "sell invite acquire kitten bamboo drastic jelly vivid peace spawn twice guilt pave pen trash pretty park cube fragile unaware remain midnight betray rebuild" +balance = 100_000_000_000_000 +# secret_key: 7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801 +# stx_address: ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5 +# btc_address: mr1iPkD9N3RJZZxXRk7xF9d36gffa6exNC + +[accounts.wallet_2] +mnemonic = "hold excess usual excess ring elephant install account glad dry fragile donkey gaze humble truck breeze nation gasp vacuum limb head keep delay hospital" +balance = 100_000_000_000_000 +# secret_key: 530d9f61984c888536871c6573073bdfc0058896dc1adfe9a6a10dfacadc209101 +# stx_address: ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG +# btc_address: muYdXKmX9bByAueDe6KFfHd5Ff1gdN9ErG + +[accounts.wallet_3] +mnemonic = "cycle puppy glare enroll cost improve round trend wrist mushroom scorpion tower claim oppose clever elephant dinosaur eight problem before frozen dune wagon high" +balance = 100_000_000_000_000 +# secret_key: d655b2523bcd65e34889725c73064feb17ceb796831c0e111ba1a552b0f31b3901 +# stx_address: ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC +# btc_address: mvZtbibDAAA3WLpY7zXXFqRa3T4XSknBX7 + +[accounts.wallet_4] +mnemonic = "board list obtain sugar hour worth raven scout denial thunder horse logic fury scorpion fold genuine phrase wealth news aim below celery when cabin" +balance = 100_000_000_000_000 +# secret_key: f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701 +# stx_address: ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND +# btc_address: mg1C76bNTutiCDV3t9nWhZs3Dc8LzUufj8 + +[accounts.wallet_5] +mnemonic = "hurry aunt blame peanut heavy update captain human rice crime juice adult scale device promote vast project quiz unit note reform update climb purchase" +balance = 100_000_000_000_000 +# secret_key: 3eccc5dac8056590432db6a35d52b9896876a3d5cbdea53b72400bc9c2099fe801 +# stx_address: ST2REHHS5J3CERCRBEPMGH7921Q6PYKAADT7JP2VB +# btc_address: mweN5WVqadScHdA81aATSdcVr4B6dNokqx + +[accounts.wallet_6] +mnemonic = "area desk dutch sign gold cricket dawn toward giggle vibrant indoor bench warfare wagon number tiny universe sand talk dilemma pottery bone trap buddy" +balance = 100_000_000_000_000 +# secret_key: 7036b29cb5e235e5fd9b09ae3e8eec4404e44906814d5d01cbca968a60ed4bfb01 +# stx_address: ST3AM1A56AK2C1XAFJ4115ZSV26EB49BVQ10MGCS0 +# btc_address: mzxXgV6e4BZSsz8zVHm3TmqbECt7mbuErt + +[accounts.wallet_7] +mnemonic = "prevent gallery kind limb income control noise together echo rival record wedding sense uncover school version force bleak nuclear include danger skirt enact arrow" +balance = 100_000_000_000_000 +# secret_key: b463f0df6c05d2f156393eee73f8016c5372caa0e9e29a901bb7171d90dc4f1401 +# stx_address: ST3PF13W7Z0RRM42A8VZRVFQ75SV1K26RXEP8YGKJ +# btc_address: n37mwmru2oaVosgfuvzBwgV2ysCQRrLko7 + +[accounts.wallet_8] +mnemonic = "female adjust gallery certain visit token during great side clown fitness like hurt clip knife warm bench start reunion globe detail dream depend fortune" +balance = 100_000_000_000_000 +# secret_key: 6a1a754ba863d7bab14adbbc3f8ebb090af9e871ace621d3e5ab634e1422885e01 +# stx_address: ST3NBRSFKX28FQ2ZJ1MAKX58HKHSDGNV5N7R21XCP +# btc_address: n2v875jbJ4RjBnTjgbfikDfnwsDV5iUByw + +[accounts.wallet_9] +mnemonic = "shadow private easily thought say logic fault paddle word top book during ignore notable orange flight clock image wealth health outside kitten belt reform" +balance = 100_000_000_000_000 +# secret_key: de433bdfa14ec43aa1098d5be594c8ffb20a31485ff9de2923b2689471c401b801 +# stx_address: STNHKEPYEPJ8ET55ZZ0M5A34J0R3N5FM2CMMMAZ6 +# btc_address: mjSrB3wS4xab3kYqFktwBzfTdPg367ZJ2d + +[devnet] +disable_bitcoin_explorer = true +# disable_stacks_explorer = true +# disable_stacks_api = true +# working_dir = "tmp/devnet" +# stacks_node_events_observers = ["host.docker.internal:8002"] +# miner_mnemonic = "twice kind fence tip hidden tilt action fragile skin nothing glory cousin green tomorrow spring wrist shed math olympic multiply hip blue scout claw" +# miner_derivation_path = "m/44'/5757'/0'/0/0" +# orchestrator_port = 20445 +# bitcoin_node_p2p_port = 18444 +# bitcoin_node_rpc_port = 18443 +# bitcoin_node_username = "devnet" +# bitcoin_node_password = "devnet" +# bitcoin_controller_port = 18442 +# bitcoin_controller_block_time = 30_000 +# stacks_node_rpc_port = 20443 +# stacks_node_p2p_port = 20444 +# stacks_api_port = 3999 +# stacks_api_events_port = 3700 +# bitcoin_explorer_port = 8001 +# stacks_explorer_port = 8000 +# postgres_port = 5432 +# postgres_username = "postgres" +# postgres_password = "postgres" +# postgres_database = "postgres" +# bitcoin_node_image_url = "quay.io/hirosystems/bitcoind:devnet" +# stacks_node_image_url = "localhost:5000/stacks-node:devnet" +# stacks_api_image_url = "blockstack/stacks-blockchain-api:latest" +# stacks_explorer_image_url = "blockstack/explorer:latest" +# bitcoin_explorer_image_url = "quay.io/hirosystems/bitcoin-explorer:devnet" +# postgres_image_url = "postgres:alpine" + +# Send some stacking orders +[[devnet.pox_stacking_orders]] +start_at_cycle = 3 +duration = 12 +wallet = "wallet_1" +slots = 2 +btc_address = "mr1iPkD9N3RJZZxXRk7xF9d36gffa6exNC" + +[[devnet.pox_stacking_orders]] +start_at_cycle = 3 +duration = 12 +wallet = "wallet_2" +slots = 1 +btc_address = "muYdXKmX9bByAueDe6KFfHd5Ff1gdN9ErG" + +[[devnet.pox_stacking_orders]] +start_at_cycle = 3 +duration = 12 +wallet = "wallet_3" +slots = 1 +btc_address = "mvZtbibDAAA3WLpY7zXXFqRa3T4XSknBX7" diff --git a/funding/tests/project-funds_test.ts b/funding/tests/project-funds_test.ts new file mode 100644 index 0000000..9a18ae0 --- /dev/null +++ b/funding/tests/project-funds_test.ts @@ -0,0 +1,26 @@ + +import { Clarinet, Tx, Chain, Account, types } from 'https://deno.land/x/clarinet@v0.14.0/index.ts'; +import { assertEquals } from 'https://deno.land/std@0.90.0/testing/asserts.ts'; + +Clarinet.test({ + name: "Ensure that <...>", + async fn(chain: Chain, accounts: Map) { + let block = chain.mineBlock([ + /* + * Add transactions with: + * Tx.contractCall(...) + */ + ]); + assertEquals(block.receipts.length, 0); + assertEquals(block.height, 2); + + block = chain.mineBlock([ + /* + * Add transactions with: + * Tx.contractCall(...) + */ + ]); + assertEquals(block.receipts.length, 0); + assertEquals(block.height, 3); + }, +});