diff --git a/notebooks/01_github_issues.ipynb b/notebooks/01_github_issues.ipynb index e6db440..d90c1e6 100644 --- a/notebooks/01_github_issues.ipynb +++ b/notebooks/01_github_issues.ipynb @@ -24,14 +24,14 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 2, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Fetched 47 issues from TheSoftwareDevGuild/TheGuildGenesis\n" + "Fetched 49 issues from TheSoftwareDevGuild/TheGuildGenesis\n" ] }, { @@ -69,18 +69,54 @@ " \n", " \n", " 0\n", + " 154\n", + " Description field of badges should be bytes no...\n", + " open\n", + " 2025-12-14T10:45:06Z\n", + " 2025-12-15T16:57:10Z\n", + " joelamouche\n", + " V-Vaal\n", + " good first issue,solidity,40pts,foundry,web3\n", + " https://github.com/TheSoftwareDevGuild/TheGuil...\n", + " \n", + " \n", + " 1\n", + " 153\n", + " Allow batch attestation creation in the front end\n", + " open\n", + " 2025-12-14T10:39:31Z\n", + " 2025-12-14T10:39:31Z\n", + " joelamouche\n", + " \n", + " good first issue,front end,react,ux,typescript\n", + " https://github.com/TheSoftwareDevGuild/TheGuil...\n", + " \n", + " \n", + " 2\n", + " 150\n", + " Implement JWT - Front end flow\n", + " open\n", + " 2025-12-08T10:36:57Z\n", + " 2025-12-08T12:40:22Z\n", + " joelamouche\n", + " tusharshah21\n", + " front end,react,typescript\n", + " https://github.com/TheSoftwareDevGuild/TheGuil...\n", + " \n", + " \n", + " 3\n", " 146\n", " Figure out how to admin delete profile\n", " open\n", " 2025-12-03T17:26:37Z\n", - " 2025-12-03T17:26:37Z\n", + " 2025-12-07T06:52:27Z\n", " joelamouche\n", " \n", " good first issue,back-end,planning\n", " https://github.com/TheSoftwareDevGuild/TheGuil...\n", " \n", " \n", - " 1\n", + " 4\n", " 145\n", " I think we should allow duplicate attestations\n", " open\n", @@ -92,7 +128,7 @@ " https://github.com/TheSoftwareDevGuild/TheGuil...\n", " \n", " \n", - " 2\n", + " 5\n", " 144\n", " Implement Upgradable pattern for TheGuildAttes...\n", " open\n", @@ -104,7 +140,7 @@ " https://github.com/TheSoftwareDevGuild/TheGuil...\n", " \n", " \n", - " 3\n", + " 6\n", " 143\n", " TheGuildAttestationResolver should have admin ...\n", " open\n", @@ -116,31 +152,31 @@ " https://github.com/TheSoftwareDevGuild/TheGuil...\n", " \n", " \n", - " 4\n", + " 7\n", " 142\n", " Add contributor leaderboard\n", " open\n", " 2025-11-24T15:04:07Z\n", - " 2025-11-24T15:04:42Z\n", + " 2025-12-13T17:03:24Z\n", " joelamouche\n", - " \n", + " adityagupta0251\n", " good first issue,front end,react,typescript,80pts\n", " https://github.com/TheSoftwareDevGuild/TheGuil...\n", " \n", " \n", - " 5\n", + " 8\n", " 141\n", " Add a new leaderboard page, with link in the s...\n", " open\n", " 2025-11-24T15:01:00Z\n", - " 2025-11-24T15:01:00Z\n", + " 2025-12-14T10:34:19Z\n", " joelamouche\n", - " \n", + " adityagupta0251\n", " good first issue,front end,react,typescript,20...\n", " https://github.com/TheSoftwareDevGuild/TheGuil...\n", " \n", " \n", - " 6\n", + " 9\n", " 140\n", " Create top badge owner leaderboard\n", " open\n", @@ -151,82 +187,46 @@ " good first issue,front end,react,typescript,40pts\n", " https://github.com/TheSoftwareDevGuild/TheGuil...\n", " \n", - " \n", - " 7\n", - " 138\n", - " Public contributor leaderboard\n", - " open\n", - " 2025-11-17T13:58:58Z\n", - " 2025-11-24T15:04:19Z\n", - " joelamouche\n", - " joelamouche\n", - " enhancement,front end,planning\n", - " https://github.com/TheSoftwareDevGuild/TheGuil...\n", - " \n", - " \n", - " 8\n", - " 137\n", - " Figure out sharing new badges on X\n", - " open\n", - " 2025-11-17T13:58:23Z\n", - " 2025-11-17T13:58:28Z\n", - " joelamouche\n", - " joelamouche\n", - " planning\n", - " https://github.com/TheSoftwareDevGuild/TheGuil...\n", - " \n", - " \n", - " 9\n", - " 135\n", - " Add projects to the Backend\n", - " open\n", - " 2025-11-17T12:38:30Z\n", - " 2025-11-27T12:15:32Z\n", - " joelamouche\n", - " pheobeayo\n", - " good first issue,rust,back-end,160pts,db,hackt...\n", - " https://github.com/TheSoftwareDevGuild/TheGuil...\n", - " \n", " \n", "\n", "" ], "text/plain": [ " number title state \\\n", - "0 146 Figure out how to admin delete profile open \n", - "1 145 I think we should allow duplicate attestations open \n", - "2 144 Implement Upgradable pattern for TheGuildAttes... open \n", - "3 143 TheGuildAttestationResolver should have admin ... open \n", - "4 142 Add contributor leaderboard open \n", - "5 141 Add a new leaderboard page, with link in the s... open \n", - "6 140 Create top badge owner leaderboard open \n", - "7 138 Public contributor leaderboard open \n", - "8 137 Figure out sharing new badges on X open \n", - "9 135 Add projects to the Backend open \n", + "0 154 Description field of badges should be bytes no... open \n", + "1 153 Allow batch attestation creation in the front end open \n", + "2 150 Implement JWT - Front end flow open \n", + "3 146 Figure out how to admin delete profile open \n", + "4 145 I think we should allow duplicate attestations open \n", + "5 144 Implement Upgradable pattern for TheGuildAttes... open \n", + "6 143 TheGuildAttestationResolver should have admin ... open \n", + "7 142 Add contributor leaderboard open \n", + "8 141 Add a new leaderboard page, with link in the s... open \n", + "9 140 Create top badge owner leaderboard open \n", "\n", - " created_at updated_at user assignees \\\n", - "0 2025-12-03T17:26:37Z 2025-12-03T17:26:37Z joelamouche \n", - "1 2025-12-03T15:18:42Z 2025-12-03T15:18:42Z joelamouche \n", - "2 2025-12-03T10:28:34Z 2025-12-03T10:28:55Z joelamouche \n", - "3 2025-12-03T10:27:07Z 2025-12-03T10:27:51Z joelamouche \n", - "4 2025-11-24T15:04:07Z 2025-11-24T15:04:42Z joelamouche \n", - "5 2025-11-24T15:01:00Z 2025-11-24T15:01:00Z joelamouche \n", - "6 2025-11-24T14:59:35Z 2025-11-24T15:02:16Z joelamouche \n", - "7 2025-11-17T13:58:58Z 2025-11-24T15:04:19Z joelamouche joelamouche \n", - "8 2025-11-17T13:58:23Z 2025-11-17T13:58:28Z joelamouche joelamouche \n", - "9 2025-11-17T12:38:30Z 2025-11-27T12:15:32Z joelamouche pheobeayo \n", + " created_at updated_at user assignees \\\n", + "0 2025-12-14T10:45:06Z 2025-12-15T16:57:10Z joelamouche V-Vaal \n", + "1 2025-12-14T10:39:31Z 2025-12-14T10:39:31Z joelamouche \n", + "2 2025-12-08T10:36:57Z 2025-12-08T12:40:22Z joelamouche tusharshah21 \n", + "3 2025-12-03T17:26:37Z 2025-12-07T06:52:27Z joelamouche \n", + "4 2025-12-03T15:18:42Z 2025-12-03T15:18:42Z joelamouche \n", + "5 2025-12-03T10:28:34Z 2025-12-03T10:28:55Z joelamouche \n", + "6 2025-12-03T10:27:07Z 2025-12-03T10:27:51Z joelamouche \n", + "7 2025-11-24T15:04:07Z 2025-12-13T17:03:24Z joelamouche adityagupta0251 \n", + "8 2025-11-24T15:01:00Z 2025-12-14T10:34:19Z joelamouche adityagupta0251 \n", + "9 2025-11-24T14:59:35Z 2025-11-24T15:02:16Z joelamouche \n", "\n", " labels \\\n", - "0 good first issue,back-end,planning \n", - "1 solidity,planning \n", - "2 good first issue,solidity,foundry \n", - "3 good first issue,solidity,foundry \n", - "4 good first issue,front end,react,typescript,80pts \n", - "5 good first issue,front end,react,typescript,20... \n", - "6 good first issue,front end,react,typescript,40pts \n", - "7 enhancement,front end,planning \n", - "8 planning \n", - "9 good first issue,rust,back-end,160pts,db,hackt... \n", + "0 good first issue,solidity,40pts,foundry,web3 \n", + "1 good first issue,front end,react,ux,typescript \n", + "2 front end,react,typescript \n", + "3 good first issue,back-end,planning \n", + "4 solidity,planning \n", + "5 good first issue,solidity,foundry \n", + "6 good first issue,solidity,foundry \n", + "7 good first issue,front end,react,typescript,80pts \n", + "8 good first issue,front end,react,typescript,20... \n", + "9 good first issue,front end,react,typescript,40pts \n", "\n", " url \n", "0 https://github.com/TheSoftwareDevGuild/TheGuil... \n", @@ -241,7 +241,7 @@ "9 https://github.com/TheSoftwareDevGuild/TheGuil... " ] }, - "execution_count": 19, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } @@ -817,6 +817,229 @@ "cell_type": "markdown", "metadata": {}, "source": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Generate contribution tokens from issues" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Fetch closed tickets" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Fetched 44 total issues (closed)\n" + ] + } + ], + "source": [ + "import json\n", + "import re\n", + "from datetime import datetime\n", + "from collections import defaultdict\n", + "\n", + "# Fetch all issues (only closed) to get complete picture\n", + "all_issues = fetch_issues(state=\"closed\", per_page=100, max_pages=10)\n", + "print(f\"Fetched {len(all_issues)} total issues (closed)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Extract contribution labels" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "============================================================\n", + "ISSUE-BASED GITHUB CONTRIBUTION TOKENS (13):\n", + "============================================================\n", + "- tusharshah21: 40pts -- \"Github handle errors\"\n", + "- tusharshah21: 40pts -- \"Implement SIWE for the front end\"\n", + "- pheobeayo: 80pts -- \"Feature: add a github handle to profile in Front end\"\n", + "- pheobeayo: 40pts -- \"Add doc for indexer\"\n", + "- PeterOche: 80pts -- \"Discord Bot foundation\"\n", + "- rainwaters11: 80pts -- \"Add GitHub handles to backend database (associated with profiles)\"\n", + "- joelamouche: 160pts -- \"Add a backend for the contribution points (api, db)\"\n", + "- Teddy1792: 20pts -- \"Improve landing page design with animated background\"\n", + "- pheobeayo: 40pts -- \"On the profile, there should be a section with the badges issued by that profile\"\n", + "- tusharshah21: 20pts -- \"Add copy to clipboard icons next to all ethereum addresses\"\n", + "- tusharshah21: 40pts -- \"Add description on the profile page\"\n", + "- tusharshah21: 320pts -- \"Improve SIWE logic to use dynamic, user-specific nonce - backend\"\n", + "- yash-1104github: 80pts -- \"Improve loading UX for lists\"\n", + "============================================================\n", + "\n" + ] + } + ], + "source": [ + "# Extract contribution labels that match the pattern \"xpts\"\n", + "# Extract contribution labels that match the pattern \"xpts\"\n", + "contributions = []\n", + "\n", + "for issue in all_issues:\n", + " issue_title = issue.get(\"title\", \"\")\n", + " for label in issue.get(\"labels\", []):\n", + " label_name = label.get(\"name\", \"\")\n", + " # Match labels like \"10pts\", \"5pts\", etc.\n", + " points_match = re.match(r\"^(\\d+)pts$\", label_name)\n", + " if points_match:\n", + " amount = int(points_match.group(1))\n", + " user = None\n", + " # Try to get GitHub handle from assignee or user\n", + " if issue.get(\"assignee\"):\n", + " user = issue[\"assignee\"].get(\"login\", \"\")\n", + " else:\n", + " # fallback: creator of the issue\n", + " user = issue.get(\"user\", {}).get(\"login\", \"\")\n", + " justification = issue_title\n", + " if user:\n", + " contributions.append({\n", + " \"amount\": amount,\n", + " \"githandle\": user,\n", + " \"justification\": justification,\n", + " })\n", + "\n", + "print(f\"\\n{'='*60}\")\n", + "print(f\"ISSUE-BASED GITHUB CONTRIBUTION TOKENS ({len(contributions)}):\")\n", + "print(f\"{'='*60}\")\n", + "for c in contributions:\n", + " print(f\"- {c['githandle']}: {c['amount']}pts -- \\\"{c['justification']}\\\"\")\n", + "print(f\"{'='*60}\\n\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### get github handles from API" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "https://theguild-backend-e2df290d177e.herokuapp.com/\n", + "Loaded 6 GitHub username -> address mappings from backend API\n", + "\n", + "Warning: 3 assignees without address mapping:\n", + " - peteroche\n", + " - rainwaters11\n", + " - yash-1104github\n", + "These will be skipped in attestations.\n", + "\n" + ] + } + ], + "source": [ + "# Optionally fetch profiles from backend API to map GitHub usernames to addresses\n", + "BACKEND_API_URL = os.getenv(\"BACKEND_API_URL\", \"http://localhost:3000\")\n", + "print(BACKEND_API_URL)\n", + "github_to_address = {}\n", + "\n", + "try:\n", + " profiles_resp = requests.get(f\"{BACKEND_API_URL}/profiles\", timeout=5)\n", + " if profiles_resp.status_code == 200:\n", + " profiles = profiles_resp.json()\n", + " for profile in profiles:\n", + " if profile.get(\"github_login\"):\n", + " github_to_address[profile[\"github_login\"].lower()] = profile[\"address\"]\n", + " print(f\"Loaded {len(github_to_address)} GitHub username -> address mappings from backend API\")\n", + " else:\n", + " print(f\"Backend API not available (status {profiles_resp.status_code}), will need manual address mapping\")\n", + "except Exception as e:\n", + " print(f\"Could not fetch profiles from backend API ({e}), will need manual address mapping\")\n", + "\n", + "# Show unmapped assignees\n", + "all_assignees = set()\n", + "for issue in all_issues:\n", + " for assignee in issue.get(\"assignees\", []):\n", + " username = assignee.get(\"login\", \"\").lower()\n", + " if username:\n", + " all_assignees.add(username)\n", + "\n", + "unmapped = [u for u in all_assignees if u not in github_to_address]\n", + "if unmapped:\n", + " print(f\"\\nWarning: {len(unmapped)} assignees without address mapping:\")\n", + " for u in sorted(unmapped):\n", + " print(f\" - {u}\")\n", + " print(\"These will be skipped in attestations.\\n\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Generate contributions" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Wrote 10 mints to contribution-tokens-latest.json\n" + ] + } + ], + "source": [ + "import json\n", + "\n", + "# Prepare mints list in expected JSON format\n", + "mints = []\n", + "for c in contributions:\n", + " github_login = c['githandle'].lower()\n", + " address = github_to_address.get(github_login)\n", + " if not address:\n", + " continue # skip if we don't have an address\n", + " amount = c['amount']\n", + " justification = c['justification']\n", + " # Prepare reason as bytes and convert to hex string (\"0x...\")\n", + " # Handles possible utf-8 and encodes to hex\n", + " reason_bytes = justification.encode(\"utf-8\")\n", + " reason_hex = \"0x\" + reason_bytes.hex()\n", + " mints.append({\n", + " \"recipient\": address,\n", + " \"amount\": int(amount),\n", + " \"reason\": reason_hex,\n", + " })\n", + "\n", + "out_path = \"contribution-tokens-latest.json\"\n", + "with open(out_path, \"w\") as f:\n", + " json.dump({\"mints\": mints}, f, indent=2)\n", + "print(f\"Wrote {len(mints)} mints to {out_path}\")\n" + ] } ], "metadata": { diff --git a/scripts/test_projects.sh b/scripts/test_projects.sh new file mode 100755 index 0000000..7b20984 --- /dev/null +++ b/scripts/test_projects.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Test project API endpoints: +# - List projects (public) +# - Create a project (protected) using signature headers (no JWT) +# Requirements: curl, node, npm. Installs ethers locally into /tmp by default. +# +# Inputs (env): +# PUBLIC_ADDRESS (required) - wallet address +# PRIVATE_KEY (required) - wallet private key (0x-prefixed) +# API_URL (optional) - defaults to http://localhost:3001 +# PROJECT_NAME (optional) - defaults to "Shell Project" +# PROJECT_DESC (optional) - defaults to "Created via script" +# PROJECT_STATUS (optional) - defaults to "proposal" (proposal|ongoing|rejected) + +API_URL="${API_URL:-http://localhost:3001}" +ADDRESS="${PUBLIC_ADDRESS:-}" +PRIVATE_KEY="${PRIVATE_KEY:-}" + +# If not provided via env, prompt interactively (input hidden for private key). +if [[ -z "${ADDRESS}" ]]; then + read -r -p "Enter PUBLIC_ADDRESS (0x...): " ADDRESS +fi +if [[ -z "${PRIVATE_KEY}" ]]; then + read -r -s -p "Enter PRIVATE_KEY (0x..., hidden): " PRIVATE_KEY + echo +fi +if [[ -z "${ADDRESS}" || -z "${PRIVATE_KEY}" ]]; then + echo "PUBLIC_ADDRESS and PRIVATE_KEY are required. Aborting." + exit 1 +fi + +PROJECT_NAME="${PROJECT_NAME:-Shell Project}" +PROJECT_DESC="${PROJECT_DESC:-Created via script}" +PROJECT_STATUS="${PROJECT_STATUS:-proposal}" + +# Ensure we have ethers available without polluting the repo. +TOOLS_DIR="${TOOLS_DIR:-/tmp/theguildgenesis-login}" +export NODE_PATH="${TOOLS_DIR}/node_modules${NODE_PATH:+:${NODE_PATH}}" +export PATH="${TOOLS_DIR}/node_modules/.bin:${PATH}" +if ! node -e "require('ethers')" >/dev/null 2>&1; then + echo "Installing ethers@6 to ${TOOLS_DIR}..." + mkdir -p "${TOOLS_DIR}" + npm install --prefix "${TOOLS_DIR}" ethers@6 >/dev/null +fi + +echo "Fetching nonce for ${ADDRESS}..." +nonce_resp="$(curl -sS "${API_URL}/auth/nonce/${ADDRESS}")" +echo "Nonce response: ${nonce_resp}" +# Parse nonce safely +nonce="$(RESP="${nonce_resp}" python3 - <<'PY' +import json, os +data = json.loads(os.environ["RESP"]) +print(data["nonce"]) +PY +)" +if [[ -z "${nonce}" ]]; then + echo "Failed to parse nonce from response" + exit 1 +fi + +message=$'Sign this message to authenticate with The Guild.\n\nNonce: '"${nonce}" + +echo "Signing nonce..." +signature="$( + ADDRESS="${ADDRESS}" PRIVATE_KEY="${PRIVATE_KEY}" MESSAGE="${message}" \ + node - <<'NODE' +const { Wallet } = require('ethers'); + +const address = process.env.ADDRESS; +const pk = process.env.PRIVATE_KEY; +const message = process.env.MESSAGE; + +if (!address || !pk || !message) { + console.error("Missing ADDRESS, PRIVATE_KEY or MESSAGE"); + process.exit(1); +} + +const wallet = new Wallet(pk); +if (wallet.address.toLowerCase() !== address.toLowerCase()) { + console.error(`Private key does not match address. Wallet: ${wallet.address}, Provided: ${address}`); + process.exit(1); +} + +(async () => { + const sig = await wallet.signMessage(message); + console.log(sig); +})(); +NODE +)" + +echo "Signature: ${signature}" + +echo "Listing projects (public)..." +list_tmp="$(mktemp)" +list_status="$(curl -sS -o "${list_tmp}" -w "%{http_code}" \ + "${API_URL}/projects")" +list_resp="$(cat "${list_tmp}")" +rm -f "${list_tmp}" +echo "List HTTP ${list_status}: ${list_resp}" +if [[ "${list_status}" != "200" ]]; then + echo "List projects failed with status ${list_status}" + exit 1 +fi + +create_payload=$(cat < \ + --broadcast +``` + +The script logs both the proxy (TGC) address and the implementation address. + +#### Batch minting TGC from JSON + +Use `script/MintTGCFromJson.s.sol` to batch-mint TGC using `mintWithReason` from a JSON file. + +JSON format: + +```json +{ + "mints": [ + { + "recipient": "0x...", + "amount": "1000000000000000000", + "reason": "0x..." + } + ] +} +``` + +- `recipient`: recipient address +- `amount`: amount as a uint256 (string-encoded in JSON) +- `reason`: ABI-encoded bytes explaining the reason (e.g. `abi.encodePacked("issue-123")`) + +Usage: + +```shell +export PRIVATE_KEY=your_private_key +export TGC_PROXY_ADDRESS=0xYourTGCProxy + +# Optional: override JSON path (default: tgc-mints.json) +export JSON_PATH=contribution-tokens-latest.json + +# Dry run +export DRY_RUN=true +forge script script/MintTGCFromJson.s.sol:MintTGCFromJson \ + --rpc-url + +# Production run +unset DRY_RUN +forge script script/MintTGCFromJson.s.sol:MintTGCFromJson \ + --rpc-url \ + --broadcast +``` + +Environment variables: + +- `PRIVATE_KEY`: signer that owns the TGC proxy +- `TGC_PROXY_ADDRESS`: address of the deployed TGC proxy +- `JSON_PATH`: path to the JSON file (default: `contribution-tokens-latest.json`) +- `DRY_RUN`: set to `true` to simulate without broadcasting (default: `false`) + +#### Upgrading the TGC implementation + +Use `script/UpgradeTGCImplementation.s.sol` to deploy a new implementation and upgrade the existing proxy. + +```shell +export PRIVATE_KEY=your_private_key +export TGC_PROXY_ADDRESS=0xYourTGCProxy + +forge script script/UpgradeTGCImplementation.s.sol:UpgradeTGCImplementation \ + --rpc-url \ + --broadcast +``` + +The script: +- Deploys a new `TheGuildContributionToken` implementation +- Calls `upgradeToAndCall` on the proxy (with empty data) +- Logs the proxy and new implementation addresses + ### Badge Ranking `TheGuildBadgeRanking` enables voting/ranking of badges for relevancy. Features: diff --git a/the-guild-smart-contracts/contribution-tokens-latest.json b/the-guild-smart-contracts/contribution-tokens-latest.json new file mode 100644 index 0000000..b3e657c --- /dev/null +++ b/the-guild-smart-contracts/contribution-tokens-latest.json @@ -0,0 +1,54 @@ +{ + "mints": [ + { + "recipient": "0x2b33E4D2bD2f34310956dCb462d58413d3dCcdf8", + "amount": 40, + "reason": "Github handle errors" + }, + { + "recipient": "0x2b33E4D2bD2f34310956dCb462d58413d3dCcdf8", + "amount": 40, + "reason": "Implement SIWE for the front end" + }, + { + "recipient": "0x63E5a246937549b3ECcBB410AF42da54F999D172", + "amount": 80, + "reason": "Feature: add a github handle to profile in Front end" + }, + { + "recipient": "0x63E5a246937549b3ECcBB410AF42da54F999D172", + "amount": 40, + "reason": "Add doc for indexer" + }, + { + "recipient": "0x0BAd9DaD98143b2E946e8A40E4f27537be2f55E2", + "amount": 160, + "reason": "Add a backend for the contribution points (api, db)" + }, + { + "recipient": "0xB66442A4Bf0636B6b533D607dB6066AD987368FE", + "amount": 20, + "reason": "Improve landing page design with animated background" + }, + { + "recipient": "0x63E5a246937549b3ECcBB410AF42da54F999D172", + "amount": 40, + "reason": "On the profile, there should be a section with the badges issued by that profile" + }, + { + "recipient": "0x2b33E4D2bD2f34310956dCb462d58413d3dCcdf8", + "amount": 20, + "reason": "Add copy to clipboard icons next to all ethereum addresses" + }, + { + "recipient": "0x2b33E4D2bD2f34310956dCb462d58413d3dCcdf8", + "amount": 40, + "reason": "Add description on the profile page" + }, + { + "recipient": "0x2b33E4D2bD2f34310956dCb462d58413d3dCcdf8", + "amount": 320, + "reason": "Improve SIWE logic to use dynamic, user-specific nonce - backend" + } + ] +} diff --git a/the-guild-smart-contracts/run_batch_contribution_tokens.sh b/the-guild-smart-contracts/run_batch_contribution_tokens.sh new file mode 100644 index 0000000..8d41688 --- /dev/null +++ b/the-guild-smart-contracts/run_batch_contribution_tokens.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +# Helper script for running TheGuild batch contribution token mint script +# Usage: ./run_batch_contribution_tokens.sh [json_file] [dry_run] +# json_file: Path to JSON file with contribution token mints (default: contribution-tokens-latest.json) +# dry_run: Set to 'true' for dry run (default: false) + +set -e + +# Source .env file if it exists +if [ -f .env ]; then + source .env +fi + +# Parse arguments - JSON file is optional, defaults to contribution-tokens-latest.json +if [ $# -eq 0 ]; then + # No arguments: use default JSON file + JSON_FILE="contribution-tokens-latest.json" + DRY_RUN="false" +elif [ $# -eq 1 ]; then + # One argument: could be JSON file or dry_run flag + if [ "$1" = "true" ] || [ "$1" = "false" ]; then + # It's a dry_run flag + JSON_FILE="contribution-tokens-latest.json" + DRY_RUN="$1" + else + # It's a JSON file path + JSON_FILE="$1" + DRY_RUN="false" + fi +else + # Two arguments: JSON file and dry_run flag + JSON_FILE="$1" + DRY_RUN="$2" +fi + +if [ ! -f "$JSON_FILE" ]; then + echo "Error: JSON file '$JSON_FILE' not found" + exit 1 +fi + +# Set JSON file path +export JSON_PATH="$JSON_FILE" + +# Set dry run mode +if [ "$DRY_RUN" = "true" ]; then + export DRY_RUN=true + echo "Running in DRY RUN mode..." +else + unset DRY_RUN + echo "Running in PRODUCTION mode..." +fi + +# Check for required environment variables +if [ -z "$PRIVATE_KEY" ]; then + echo "Error: PRIVATE_KEY environment variable not set" + exit 1 +fi + +if [ -z "$RPC_URL" ]; then + echo "Error: RPC_URL environment variable not set" + exit 1 +fi + +if [ -z "$TGC_PROXY_ADDRESS" ]; then + echo "Error: TGC_PROXY_ADDRESS environment variable not set" + exit 1 +fi + +# Run the script +if [ "$DRY_RUN" = "true" ]; then + forge script script/MintTGCFromJson.s.sol:MintTGCFromJson \ + --rpc-url "$RPC_URL" +else + forge script script/MintTGCFromJson.s.sol:MintTGCFromJson \ + --rpc-url "$RPC_URL" \ + --broadcast +fi + + diff --git a/the-guild-smart-contracts/script/DeployTGC.s.sol b/the-guild-smart-contracts/script/DeployTGC.s.sol new file mode 100644 index 0000000..7ac83e0 --- /dev/null +++ b/the-guild-smart-contracts/script/DeployTGC.s.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script} from "forge-std/Script.sol"; +import {console} from "forge-std/console.sol"; +import {TheGuildContributionToken} from "../src/TheGuildContributionToken.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +/// @notice Deploys the upgradable TGC token (implementation + ERC1967/UUPS proxy). +/// @dev Uses CREATE2 with a fixed salt for deterministic deployment, similar to `FullDeploymentScript`. +contract DeployTGC is Script { + function run() public { + // Use the same pattern as FullDeploymentScript for deterministic addresses. + // Update this salt string when you intentionally want a new deployment address. + bytes32 salt = bytes32("theguild_tgc_v_0.1.2"); + + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + vm.startBroadcast(pk); + + console.log( + "Deploying TheGuildContributionToken (TGC) implementation..." + ); + TheGuildContributionToken implementation = new TheGuildContributionToken{ + salt: salt + }(); + + console.log("Deploying ERC1967 proxy for TGC..."); + // Deploy proxy without initialization data so we can call initialize + // directly from the deployer EOA. This ensures Ownable's owner is the + // deployer (and not the CREATE2 factory address). + ERC1967Proxy proxy = new ERC1967Proxy{salt: salt}( + address(implementation), + "" + ); + + console.log("Initializing TGC proxy..."); + TheGuildContributionToken(address(proxy)).initialize(); + + console.log("=== TGC Deployment Summary ==="); + console.logBytes32(salt); + console.log("Proxy (TGC) address:", address(proxy)); + console.log("Deployer (and initial owner):", deployer); + console.log("Implementation address:", address(implementation)); + + vm.stopBroadcast(); + } +} diff --git a/the-guild-smart-contracts/script/MintTGCFromJson.s.sol b/the-guild-smart-contracts/script/MintTGCFromJson.s.sol new file mode 100644 index 0000000..a80c99c --- /dev/null +++ b/the-guild-smart-contracts/script/MintTGCFromJson.s.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, stdJson} from "forge-std/Script.sol"; +import {console} from "forge-std/console.sol"; +import {TheGuildContributionToken} from "../src/TheGuildContributionToken.sol"; + +/// @notice Batch-mints TGC to recipients defined in a JSON file using mintWithReason. +/// @dev JSON format: +/// { +/// "mints": [ +/// { +/// "recipient": "0x...", +/// "amount": "1000000000000000000", +/// "reason": "Some human readable text" +/// } +/// ] +/// } +contract MintTGCFromJson is Script { + using stdJson for string; + + uint256 constant MAX_ENTRIES = 1000; + + struct MintData { + address recipient; + uint256 amount; + bytes reason; + } + + function run() public { + bool isDryRun = vm.envOr("DRY_RUN", false); + + console.log("=== TheGuild TGC Mint-From-JSON Script ==="); + console.log("Dry run mode:", isDryRun ? "ENABLED" : "DISABLED"); + + address proxyAddress = vm.envAddress("TGC_PROXY_ADDRESS"); + require(proxyAddress != address(0), "TGC_PROXY_ADDRESS not set"); + TheGuildContributionToken tgc = TheGuildContributionToken(proxyAddress); + + // Read JSON file + string memory jsonPath = vm.envOr( + "JSON_PATH", + string("contribution-tokens-latest.json") + ); + console.log("Reading JSON from:", jsonPath); + + string memory jsonData = vm.readFile(jsonPath); + MintData[] memory mints = parseAndValidateJson(jsonData); + + console.log( + string( + abi.encodePacked( + "Parsed ", + vm.toString(mints.length), + " mint entries from JSON" + ) + ) + ); + + if (isDryRun) { + for (uint256 i = 0; i < mints.length; i++) { + console.log( + string( + abi.encodePacked( + "Mint ", + vm.toString(i + 1), + ": to=", + vm.toString(mints[i].recipient), + ", amount=", + vm.toString(mints[i].amount) + ) + ) + ); + } + console.log("Dry run completed successfully!"); + return; + } + + executeMints(tgc, mints); + } + + function parseAndValidateJson( + string memory jsonData + ) internal view returns (MintData[] memory) { + console.log("Parsing JSON mints array..."); + + MintData[] memory tempMints = new MintData[](MAX_ENTRIES); + uint256 count = 0; + + for (uint256 i = 0; i < MAX_ENTRIES; i++) { + string memory basePath = string( + abi.encodePacked(".mints[", vm.toString(i), "]") + ); + + // Detect existence via recipient + bytes memory recipientRaw = vm.parseJson( + jsonData, + string(abi.encodePacked(basePath, ".recipient")) + ); + if (recipientRaw.length == 0) break; + + address recipient = abi.decode(recipientRaw, (address)); + + uint256 amount = abi.decode( + vm.parseJson( + jsonData, + string(abi.encodePacked(basePath, ".amount")) + ), + (uint256) + ); + + // Parse reason as a normal UTF-8 string and convert to bytes. + // This makes the JSON human-readable for non-technical contributors. + string memory reasonStr = jsonData.readString( + string(abi.encodePacked(basePath, ".reason")) + ); + bytes memory reason = bytes(reasonStr); + + tempMints[count] = MintData({ + recipient: recipient, + amount: amount, + reason: reason + }); + + count++; + } + + MintData[] memory mints = new MintData[](count); + for (uint256 i = 0; i < count; i++) { + mints[i] = tempMints[i]; + } + + console.log("Found", mints.length, " mint entries in JSON"); + + require(mints.length > 0, "JSON must contain at least 1 mint entry"); + require( + mints.length <= MAX_ENTRIES, + "Too many mint entries, max 1000 allowed" + ); + + for (uint256 i = 0; i < mints.length; i++) { + require( + mints[i].recipient != address(0), + string( + abi.encodePacked( + "Mint ", + vm.toString(i), + ": invalid recipient address" + ) + ) + ); + require( + mints[i].amount > 0, + string( + abi.encodePacked( + "Mint ", + vm.toString(i), + ": amount must be > 0" + ) + ) + ); + } + + return mints; + } + + function executeMints( + TheGuildContributionToken tgc, + MintData[] memory mints + ) internal { + uint256 pk = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(pk); + + console.log("Starting TGC minting..."); + + for (uint256 i = 0; i < mints.length; i++) { + MintData memory m = mints[i]; + tgc.mintWithReason(m.recipient, m.amount, m.reason); + console.log( + string( + abi.encodePacked( + "Minted ", + vm.toString(m.amount), + " TGC to ", + vm.toString(m.recipient) + ) + ) + ); + } + + vm.stopBroadcast(); + + console.log("=== TGC Mint Summary ==="); + console.log("Proxy (TGC) address:", address(tgc)); + console.log("Total mints processed:", mints.length); + console.log("Execution completed successfully!"); + } +} diff --git a/the-guild-smart-contracts/script/UpgradeTGCImplementation.s.sol b/the-guild-smart-contracts/script/UpgradeTGCImplementation.s.sol new file mode 100644 index 0000000..39776ff --- /dev/null +++ b/the-guild-smart-contracts/script/UpgradeTGCImplementation.s.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script} from "forge-std/Script.sol"; +import {console} from "forge-std/console.sol"; +import {TheGuildContributionToken} from "../src/TheGuildContributionToken.sol"; + +/// @notice Deploys a new TGC implementation and upgrades an existing UUPS proxy to it. +/// @dev Requires PRIVATE_KEY to be the current owner of the proxy. +contract UpgradeTGCImplementation is Script { + function run() public { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address proxyAddress = vm.envAddress("TGC_PROXY_ADDRESS"); + + require(proxyAddress != address(0), "TGC_PROXY_ADDRESS not set"); + + vm.startBroadcast(pk); + + console.log( + "Deploying new TheGuildContributionToken implementation..." + ); + TheGuildContributionToken newImplementation = new TheGuildContributionToken(); + console.log("New implementation address:", address(newImplementation)); + + console.log("Upgrading proxy to new implementation..."); + // OZ UUPS v5 only exposes upgradeToAndCall; use it with empty data. + TheGuildContributionToken(proxyAddress).upgradeToAndCall( + address(newImplementation), + bytes("") + ); + + console.log("=== TGC Upgrade Summary ==="); + console.log("Proxy (TGC) address:", proxyAddress); + console.log("New implementation address:", address(newImplementation)); + + vm.stopBroadcast(); + } +} diff --git a/the-guild-smart-contracts/src/TheGuildContributionToken.sol b/the-guild-smart-contracts/src/TheGuildContributionToken.sol new file mode 100644 index 0000000..8cd39c2 --- /dev/null +++ b/the-guild-smart-contracts/src/TheGuildContributionToken.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +/// @title TheGuildContributionToken (TGC) +/// @notice Upgradable ERC20 token used to reward contributions within The Guild ecosystem. +/// Deployer is the initial owner and sole minter by default. +contract TheGuildContributionToken is + ERC20Upgradeable, + OwnableUpgradeable, + UUPSUpgradeable +{ + /// @notice Emitted when tokens are minted with an associated reason. + /// @param to The recipient of the minted tokens. + /// @param amount The amount of tokens minted. + /// @param reason Arbitrary bytes describing why the user received contribution tokens. + event MintedWithReason( + address indexed to, + uint256 indexed amount, + bytes indexed reason + ); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + // Prevent initializing the implementation contract directly + _disableInitializers(); + } + + /// @notice Initialize the upgradable token (called via proxy). + /// @dev This replaces the constructor for upgradeable contracts. + function initialize() public initializer { + __ERC20_init("The Guild Contribution Token", "TGC"); + __Ownable_init(msg.sender); + __UUPSUpgradeable_init(); + } + + /// @notice Mint tokens to a recipient. + /// @dev Only the owner (deployer/admin) can mint. + function mint(address to, uint256 amount) external onlyOwner { + _mint(to, amount); + } + + /// @notice Mint tokens to a recipient with an associated reason. + /// @param to Recipient address. + /// @param amount Token amount to mint. + /// @param reason Arbitrary bytes describing why the user received tokens. + function mintWithReason( + address to, + uint256 amount, + bytes calldata reason + ) external onlyOwner { + _mint(to, amount); + emit MintedWithReason(to, amount, reason); + } + + /// @notice Batch mint tokens to multiple recipients. + /// @param recipients Array of recipient addresses. + /// @param amounts Array of token amounts to mint. + function batchMint( + address[] calldata recipients, + uint256[] calldata amounts + ) external onlyOwner { + uint256 length = recipients.length; + require(length == amounts.length, "LENGTH_MISMATCH"); + + for (uint256 i = 0; i < length; i++) { + _mint(recipients[i], amounts[i]); + } + } + + /// @notice Batch mint tokens with reasons to multiple recipients. + /// @param recipients Array of recipient addresses. + /// @param amounts Array of token amounts to mint. + /// @param reasons Array of reasons (bytes) for each mint. + function batchMintWithReason( + address[] calldata recipients, + uint256[] calldata amounts, + bytes[] calldata reasons + ) external onlyOwner { + uint256 length = recipients.length; + require(length == amounts.length, "LENGTH_MISMATCH"); + require(length == reasons.length, "REASONS_LENGTH_MISMATCH"); + + for (uint256 i = 0; i < length; i++) { + _mint(recipients[i], amounts[i]); + emit MintedWithReason(recipients[i], amounts[i], reasons[i]); + } + } + + /// @notice Override decimals to keep normal ERC20 18 decimals. + function decimals() public pure override returns (uint8) { + return 18; + } + + /// @dev Authorize contract upgrades. Only the owner can upgrade. + function _authorizeUpgrade( + address newImplementation + ) internal override onlyOwner {} +} diff --git a/the-guild-smart-contracts/test/TheGuildContributionToken.t.sol b/the-guild-smart-contracts/test/TheGuildContributionToken.t.sol new file mode 100644 index 0000000..7eeacd7 --- /dev/null +++ b/the-guild-smart-contracts/test/TheGuildContributionToken.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {TheGuildContributionToken} from "../src/TheGuildContributionToken.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract TheGuildContributionTokenTest is Test { + TheGuildContributionToken private token; + + address private owner = address(this); + address private user1 = address(0xBEEF); + address private user2 = address(0xCAFE); + + function setUp() public { + // Deploy implementation (cannot be initialized due to _disableInitializers) + TheGuildContributionToken implementation = new TheGuildContributionToken(); + + // Deploy proxy and initialize it + ERC1967Proxy proxy = new ERC1967Proxy( + address(implementation), + abi.encodeWithSelector( + TheGuildContributionToken.initialize.selector + ) + ); + + token = TheGuildContributionToken(address(proxy)); + } + + function test_Metadata() public view { + assertEq(token.name(), "The Guild Contribution Token", "name mismatch"); + assertEq(token.symbol(), "TGC", "symbol mismatch"); + assertEq(token.decimals(), 18, "decimals mismatch"); + } + + function test_OwnerIsInitializer() public view { + assertEq(token.owner(), owner, "owner should be test contract"); + } + + function test_MintByOwner() public { + token.mint(user1, 1e18); + + assertEq(token.balanceOf(user1), 1e18, "balance mismatch"); + assertEq(token.totalSupply(), 1e18, "totalSupply mismatch"); + } + + function test_RevertMintIfNotOwner() public { + vm.prank(user1); + vm.expectRevert(); + token.mint(user1, 1e18); + } + + function test_MintWithReason_EmitsEvent() public { + bytes memory reason = bytes("contribution:issue-123"); + + vm.expectEmit(true, false, false, true); + emit TheGuildContributionToken.MintedWithReason(user1, 5e17, reason); + + token.mintWithReason(user1, 5e17, reason); + + assertEq(token.balanceOf(user1), 5e17, "balance mismatch"); + assertEq(token.totalSupply(), 5e17, "totalSupply mismatch"); + } + + function test_BatchMint() public { + address[] memory recipients = new address[](2); + recipients[0] = user1; + recipients[1] = user2; + + uint256[] memory amounts = new uint256[](2); + amounts[0] = 1e18; + amounts[1] = 2e18; + + token.batchMint(recipients, amounts); + + assertEq(token.balanceOf(user1), 1e18, "user1 balance mismatch"); + assertEq(token.balanceOf(user2), 2e18, "user2 balance mismatch"); + assertEq(token.totalSupply(), 3e18, "totalSupply mismatch"); + } + + function test_BatchMintWithReason() public { + address[] memory recipients = new address[](2); + recipients[0] = user1; + recipients[1] = user2; + + uint256[] memory amounts = new uint256[](2); + amounts[0] = 1e18; + amounts[1] = 2e18; + + bytes[] memory reasons = new bytes[](2); + reasons[0] = bytes("reason-1"); + reasons[1] = bytes("reason-2"); + + // Expect two MintedWithReason events + vm.expectEmit(true, false, false, true); + emit TheGuildContributionToken.MintedWithReason( + recipients[0], + amounts[0], + reasons[0] + ); + vm.expectEmit(true, false, false, true); + emit TheGuildContributionToken.MintedWithReason( + recipients[1], + amounts[1], + reasons[1] + ); + + token.batchMintWithReason(recipients, amounts, reasons); + + assertEq(token.balanceOf(user1), 1e18, "user1 balance mismatch"); + assertEq(token.balanceOf(user2), 2e18, "user2 balance mismatch"); + assertEq(token.totalSupply(), 3e18, "totalSupply mismatch"); + } +}