From 9dff903106d20ed0497926497613df5111737be9 Mon Sep 17 00:00:00 2001 From: tushar Date: Fri, 10 Oct 2025 16:18:25 +0530 Subject: [PATCH] created new erc20 token --- frontend/.astro/data-store.json | 2 +- frontend/src/components/AppWrapper.tsx | 5 +- frontend/src/lib/wagmi.ts | 30 ++-- frontend/src/pages/index.astro | 7 +- the-guild-smart-contracts/TGC_README.md | 47 ++++++ .../script/DeployAndMintTGC.s.sol | 154 ++++++++++++++++++ .../script/MintTGCExisting.s.sol | 103 ++++++++++++ the-guild-smart-contracts/script/README.md | 60 +++++++ .../script/data/initial_tgc_mints.csv | 2 + .../script/mint_from_csv.js | 56 +++++++ the-guild-smart-contracts/script/package.json | 12 ++ .../src/TheGuildContributionToken.sol | 58 +++++++ .../test/TheGuildContributionToken.t.sol | 69 ++++++++ .../test/TheGuildContributionTokenBatch.t.sol | 63 +++++++ 14 files changed, 649 insertions(+), 19 deletions(-) create mode 100644 the-guild-smart-contracts/TGC_README.md create mode 100644 the-guild-smart-contracts/script/DeployAndMintTGC.s.sol create mode 100644 the-guild-smart-contracts/script/MintTGCExisting.s.sol create mode 100644 the-guild-smart-contracts/script/README.md create mode 100644 the-guild-smart-contracts/script/data/initial_tgc_mints.csv create mode 100644 the-guild-smart-contracts/script/mint_from_csv.js create mode 100644 the-guild-smart-contracts/script/package.json create mode 100644 the-guild-smart-contracts/src/TheGuildContributionToken.sol create mode 100644 the-guild-smart-contracts/test/TheGuildContributionToken.t.sol create mode 100644 the-guild-smart-contracts/test/TheGuildContributionTokenBatch.t.sol diff --git a/frontend/.astro/data-store.json b/frontend/.astro/data-store.json index 9e8a7dc..ce76f47 100644 --- a/frontend/.astro/data-store.json +++ b/frontend/.astro/data-store.json @@ -1 +1 @@ -[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.14.1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/node\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"/Users/antoineestienne/GithubRepositories/TheGuildGenesis/frontend/node_modules/.astro/sessions\"}}}"] \ No newline at end of file +[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.14.1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/node\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"C:\\\\Users\\\\hp\\\\Desktop\\\\guild\\\\TheGuildGenesis\\\\frontend\\\\node_modules\\\\.astro\\\\sessions\"}}}"] \ No newline at end of file diff --git a/frontend/src/components/AppWrapper.tsx b/frontend/src/components/AppWrapper.tsx index 80a1421..547cf82 100644 --- a/frontend/src/components/AppWrapper.tsx +++ b/frontend/src/components/AppWrapper.tsx @@ -2,7 +2,7 @@ import "@rainbow-me/rainbowkit/styles.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { WagmiProvider } from "wagmi"; import { RainbowKitProvider } from "@rainbow-me/rainbowkit"; -import { config } from "../lib/wagmi"; +import { getWagmiConfig } from "../lib/wagmi"; import { ConnectButton } from "@rainbow-me/rainbowkit"; import { SidebarProvider, @@ -20,6 +20,9 @@ interface AppWrapperProps { } export function AppWrapper({ children }: AppWrapperProps) { + // call the config getter inside the client component so it runs in the browser + const config = getWagmiConfig(); + return ( diff --git a/frontend/src/lib/wagmi.ts b/frontend/src/lib/wagmi.ts index 6fce4e3..0505f3d 100644 --- a/frontend/src/lib/wagmi.ts +++ b/frontend/src/lib/wagmi.ts @@ -2,17 +2,19 @@ import { getDefaultConfig } from "@rainbow-me/rainbowkit"; import { http } from "wagmi"; import { polygonAmoy } from "wagmi/chains"; -const projectId = import.meta.env.PUBLIC_WALLET_CONNECT_PROJECT_ID as - | string - | undefined; -console.log(projectId); -export const config = getDefaultConfig({ - appName: "The Guild Genesis", - projectId: projectId ?? "", - chains: [polygonAmoy], - ssr: false, - syncConnectedChain: true, - transports: { - [polygonAmoy.id]: http(), - }, -}); +export function getWagmiConfig() { + const projectId = import.meta.env.PUBLIC_WALLET_CONNECT_PROJECT_ID as + | string + | undefined; + + return getDefaultConfig({ + appName: "The Guild Genesis", + projectId: projectId ?? "", + chains: [polygonAmoy], + ssr: false, + syncConnectedChain: true, + transports: { + [polygonAmoy.id]: http(), + }, + }); +} diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro index b9664ea..eca83cc 100644 --- a/frontend/src/pages/index.astro +++ b/frontend/src/pages/index.astro @@ -6,8 +6,9 @@ import HomePage from '@/components/pages/HomePage';
- - - + +
+ +
\ No newline at end of file diff --git a/the-guild-smart-contracts/TGC_README.md b/the-guild-smart-contracts/TGC_README.md new file mode 100644 index 0000000..4130090 --- /dev/null +++ b/the-guild-smart-contracts/TGC_README.md @@ -0,0 +1,47 @@ +# TheGuild Contribution Token (TGC) + +The TheGuild Contribution Token (TGC) is a simple ERC20 token used to reward contributors. It is owner-mintable and supports batch minting with a distribution ID to prevent duplicate distributions. + +## Key features + +- ERC20 token (symbol: TGC) +- Owner-only minting +- `mintWithReason(to, amount, reason)` — mints and emits `ContributionTokenMinted(to, amount, reason)` where `reason` is a `bytes32` reference (e.g., a ticket id) +- `batchMint(distributionId, recipients[], amounts[], reasons[])` — mints to many recipients in one call and marks `distributionId` as executed so it cannot be re-used. Use this to ensure a CSV or distribution is only applied once. + +## `distributionId` + +`distributionId` should be a unique identifier for a distribution batch. Recommended approaches: + +- `keccak256(bytes(csvContents))` — compute a hash of the CSV file contents and use that as the id. +- `keccak256(abi.encodePacked(fileName, timestamp))` — if you want to include a timestamp. + +The contract tracks which `distributionId`s have been executed and will revert if the same id is passed twice. + +## CSV format and scripts + +Scripts in the `script/` folder support minting via CSV input. CSV format: +``` +
,, +``` +- `address` — recipient address (0x...) +- `amount` — integer amount (the Node helper multiplies by token decimals by default) +- `hex32_reason` — a 0x-prefixed hex string up to 32 bytes. You can also provide a plain string and the Node helper will hex-encode it. + +Examples: +- `script/DeployAndMintTGC.s.sol` — deploys a new TGC and batch mints the CSV contents (computes `distributionId = keccak256(csv)`). +- `script/MintTGCExisting.s.sol` — attaches to an existing TGC address and batch mints the CSV contents. +- `script/mint_from_csv.js` — Node helper that performs batched `batchMint` calls. Useful for large lists and retries. + +## Tests + +There are unit tests under `test/` that verify `mintWithReason`, `batchMint`, and the `distributionId` guard. + +## GitHub issue workflow (example) + +1. Contributor closes issue #65 and requests reward. +2. Admin prepares a CSV with recipients, amounts, and reasons (include the issue id in reason where useful). +3. Compute `distributionId = keccak256(csvContents)` and run the deploy/mint script. +4. The contract will mark the `distributionId` executed so you cannot accidentally re-run the same distribution. + +If you want, I can add an example script which computes `distributionId` and calls the Forge script automatically, or a small GitHub Action that runs the Node helper on a CSV uploaded to the repo. diff --git a/the-guild-smart-contracts/script/DeployAndMintTGC.s.sol b/the-guild-smart-contracts/script/DeployAndMintTGC.s.sol new file mode 100644 index 0000000..8a230c4 --- /dev/null +++ b/the-guild-smart-contracts/script/DeployAndMintTGC.s.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script} from "forge-std/Script.sol"; +import {console} from "forge-std/console.sol"; +import {stdJson} from "forge-std/StdJson.sol"; +import {TheGuildContributionToken} from "../src/TheGuildContributionToken.sol"; + +contract DeployAndMintTGC is Script { + function run() public { + // CSV file path relative to project root + string memory csvPath = "script/data/initial_tgc_mints.csv"; + + vm.startBroadcast(); + + TheGuildContributionToken token = new TheGuildContributionToken(); + + // read CSV and prepare arrays for batchMint + string memory csv = vm.readFile(csvPath); + string[] memory lines = csvSplit(csv); + + // count non-empty lines + uint256 count = 0; + for (uint256 i = 0; i < lines.length; i++) if (bytes(lines[i]).length != 0) count++; + + address[] memory recipients = new address[](count); + uint256[] memory amounts = new uint256[](count); + bytes32[] memory reasons = new bytes32[](count); + + uint256 idx = 0; + for (uint256 i = 0; i < lines.length; i++) { + string memory line = lines[i]; + if (bytes(line).length == 0) continue; + (address recipient, uint256 amount, bytes32 reason) = parseLine(line); + recipients[idx] = recipient; + amounts[idx] = amount; + reasons[idx] = reason; + idx++; + } + + // compute distributionId as keccak256(csv) + bytes32 distributionId = keccak256(bytes(csv)); + + // perform batch mint + token.batchMint(distributionId, recipients, amounts, reasons); + + vm.stopBroadcast(); + } + + // --- small CSV parser helpers --- + function csvSplit(string memory s) internal pure returns (string[] memory) { + // naive split on '\n' + bytes memory b = bytes(s); + uint256 linesCount = 1; + for (uint256 i = 0; i < b.length; i++) { + if (b[i] == "\n") linesCount++; + } + string[] memory parts = new string[](linesCount); + uint256 idx = 0; + bytes memory cur; + for (uint256 i = 0; i < b.length; i++) { + if (b[i] == "\n") { + parts[idx] = string(cur); + idx++; + cur = ""; + } else { + cur = abi.encodePacked(cur, b[i]); + } + } + // last + if (cur.length != 0) { + parts[idx] = string(cur); + } + return parts; + } + + function parseLine(string memory line) internal pure returns (address, uint256, bytes32) { + // naive split by comma + bytes memory b = bytes(line); + string[] memory cols = new string[](3); + uint256 col = 0; + bytes memory cur; + for (uint256 i = 0; i < b.length; i++) { + if (b[i] == ",") { + cols[col] = string(cur); + col++; + cur = ""; + } else { + cur = abi.encodePacked(cur, b[i]); + } + } + if (cur.length != 0) cols[col] = string(cur); + + address recipient = parseAddr(cols[0]); + uint256 amount = parseUint(cols[1]); + bytes32 reason = parseBytes32Hex(cols[2]); + return (recipient, amount, reason); + } + + function parseAddr(string memory s) internal pure returns (address) { + bytes memory bb = bytes(s); + // fallback to address(0) if empty + if (bb.length == 0) return address(0); + return parseAddrFromHex(s); + } + + function parseAddrFromHex(string memory s) internal pure returns (address) { + bytes memory _s = bytes(s); + uint256 start = 0; + if (_s.length >= 2 && _s[0] == '0' && (_s[1] == 'x' || _s[1] == 'X')) start = 2; + require(_s.length - start == 40, "INVALID_ADDR_LENGTH"); + uint160 addr = 0; + for (uint256 i = start; i < _s.length; i++) { + addr <<= 4; + uint8 c = uint8(_s[i]); + if (c >= 48 && c <= 57) addr |= uint160(c - 48); + else if (c >= 65 && c <= 70) addr |= uint160(c - 55); + else if (c >= 97 && c <= 102) addr |= uint160(c - 87); + else revert("INVALID_HEX_CHAR"); + } + return address(addr); + } + + function parseUint(string memory s) internal pure returns (uint256) { + bytes memory b = bytes(s); + uint256 n = 0; + for (uint256 i = 0; i < b.length; i++) { + uint8 c = uint8(b[i]); + if (c >= 48 && c <= 57) n = n * 10 + (c - 48); + } + return n; + } + + function parseBytes32Hex(string memory s) internal pure returns (bytes32) { + bytes memory _s = bytes(s); + if (_s.length == 0) return bytes32(0); + uint256 start = 0; + if (_s.length >= 2 && _s[0] == '0' && (_s[1] == 'x' || _s[1] == 'X')) start = 2; + bytes32 res = bytes32(0); + uint256 chars = _s.length - start; + // read up to 64 hex chars (32 bytes) + uint256 toRead = chars > 64 ? 64 : chars; + for (uint256 i = 0; i < toRead; i++) { + res <<= 4; + uint8 c = uint8(_s[start + i]); + if (c >= 48 && c <= 57) res |= bytes32(uint256(c - 48)); + else if (c >= 65 && c <= 70) res |= bytes32(uint256(c - 55)); + else if (c >= 97 && c <= 102) res |= bytes32(uint256(c - 87)); + else revert("INVALID_HEX_CHAR"); + } + // shift left to make it right-aligned? Keep as parsed (big-endian) + return res; + } +} diff --git a/the-guild-smart-contracts/script/MintTGCExisting.s.sol b/the-guild-smart-contracts/script/MintTGCExisting.s.sol new file mode 100644 index 0000000..0653699 --- /dev/null +++ b/the-guild-smart-contracts/script/MintTGCExisting.s.sol @@ -0,0 +1,103 @@ +// 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"; + +contract MintTGCExisting is Script { + function run(address tokenAddress) public { + string memory csvPath = "script/data/initial_tgc_mints.csv"; + + TheGuildContributionToken token = TheGuildContributionToken(tokenAddress); + + vm.startBroadcast(); + + string memory csv = vm.readFile(csvPath); + string[] memory lines = csvSplit(csv); + + uint256 count = 0; + for (uint256 i = 0; i < lines.length; i++) if (bytes(lines[i]).length != 0) count++; + + address[] memory recipients = new address[](count); + uint256[] memory amounts = new uint256[](count); + bytes32[] memory reasons = new bytes32[](count); + + uint256 idx = 0; + for (uint256 i = 0; i < lines.length; i++) { + string memory line = lines[i]; + if (bytes(line).length == 0) continue; + (address recipient, uint256 amount, bytes32 reason) = parseLine(line); + recipients[idx] = recipient; + amounts[idx] = amount; + reasons[idx] = reason; + idx++; + } + + bytes32 distributionId = keccak256(bytes(csv)); + token.batchMint(distributionId, recipients, amounts, reasons); + + vm.stopBroadcast(); + } + + // reuse simple parsers from deploy script + function csvSplit(string memory s) internal pure returns (string[] memory) { + bytes memory b = bytes(s); + uint256 linesCount = 1; + for (uint256 i = 0; i < b.length; i++) if (b[i] == "\n") linesCount++; + string[] memory parts = new string[](linesCount); + uint256 idx = 0; + bytes memory cur; + for (uint256 i = 0; i < b.length; i++) { + if (b[i] == "\n") { parts[idx] = string(cur); idx++; cur = ""; } + else { cur = abi.encodePacked(cur, b[i]); } + } + if (cur.length != 0) parts[idx] = string(cur); + return parts; + } + + function parseLine(string memory line) internal pure returns (address, uint256, bytes32) { + bytes memory b = bytes(line); + string[] memory cols = new string[](3); + uint256 col = 0; + bytes memory cur; + for (uint256 i = 0; i < b.length; i++) { + if (b[i] == ",") { cols[col] = string(cur); col++; cur = ""; } + else { cur = abi.encodePacked(cur, b[i]); } + } + if (cur.length != 0) cols[col] = string(cur); + return (parseAddrFromHex(cols[0]), parseUint(cols[1]), parseBytes32Hex(cols[2])); + } + + function parseAddrFromHex(string memory s) internal pure returns (address) { + bytes memory _s = bytes(s); + uint256 start = 0; + if (_s.length >= 2 && _s[0] == '0' && (_s[1] == 'x' || _s[1] == 'X')) start = 2; + require(_s.length - start == 40, "INVALID_ADDR_LENGTH"); + uint160 addr = 0; + for (uint256 i = start; i < _s.length; i++) { + addr <<= 4; + uint8 c = uint8(_s[i]); + if (c >= 48 && c <= 57) addr |= uint160(c - 48); + else if (c >= 65 && c <= 70) addr |= uint160(c - 55); + else if (c >= 97 && c <= 102) addr |= uint160(c - 87); + else revert("INVALID_HEX_CHAR"); + } + return address(addr); + } + + function parseUint(string memory s) internal pure returns (uint256) { + bytes memory b = bytes(s); uint256 n = 0; for (uint256 i = 0; i < b.length; i++) { uint8 c = uint8(b[i]); if (c >= 48 && c <= 57) n = n * 10 + (c - 48); } return n; + } + + function parseBytes32Hex(string memory s) internal pure returns (bytes32) { + bytes memory _s = bytes(s); + if (_s.length == 0) return bytes32(0); + uint256 start = 0; + if (_s.length >= 2 && _s[0] == '0' && (_s[1] == 'x' || _s[1] == 'X')) start = 2; + bytes32 res = bytes32(0); + uint256 chars = _s.length - start; uint256 toRead = chars > 64 ? 64 : chars; + for (uint256 i = 0; i < toRead; i++) { res <<= 4; uint8 c = uint8(_s[start + i]); if (c >= 48 && c <= 57) res |= bytes32(uint256(c - 48)); else if (c >= 65 && c <= 70) res |= bytes32(uint256(c - 55)); else if (c >= 97 && c <= 102) res |= bytes32(uint256(c - 87)); else revert("INVALID_HEX_CHAR"); } + return res; + } +} diff --git a/the-guild-smart-contracts/script/README.md b/the-guild-smart-contracts/script/README.md new file mode 100644 index 0000000..7bab3ca --- /dev/null +++ b/the-guild-smart-contracts/script/README.md @@ -0,0 +1,60 @@ +Local usage for TGC scripts + +This folder contains scripts and helpers for deploying and minting TheGuild Contribution Token (TGC). + +Files +- DeployAndMintTGC.s.sol - Forge script that deploys TGC and mints entries from `script/data/initial_tgc_mints.csv`. +- MintTGCExisting.s.sol - Forge script that attaches to an existing TGC contract and mints entries from `script/data/initial_tgc_mints.csv`. +- data/initial_tgc_mints.csv - Example CSV data. Format: `address,amount,hex32_reason` (amount in token base units, reason as 0x-prefixed hex up to 32 bytes). +- mint_from_csv.js - (optional) Node.js script that reads a CSV and calls `mintWithReason` on an already-deployed TGC contract via ethers.js. + +Running locally + +Prerequisites +- Node.js >= 18 (for the helper script) +- Foundry (forge & cast) installed for running forge scripts +- An Ethereum RPC URL (local node, ganache, anvil, or public testnet) +- Private key with ETH for gas (or use anvil's default accounts) + +1) Run tests + +In the `the-guild-smart-contracts` folder: + +```bash +forge test -v +``` + +2) Deploy & mint using Forge scripts + +Deploy new TGC and mint using the CSV (reads `script/data/initial_tgc_mints.csv`): + +```bash +forge script script/DeployAndMintTGC.s.sol:DeployAndMintTGC --rpc-url --private-key --broadcast -vvvv +``` + +Mint to an existing deployed token address: + +```bash +forge script script/MintTGCExisting.s.sol:MintTGCExisting --rpc-url --private-key --broadcast -vvvv --sig "run(address)" --json-args '[["0xYourTokenAddress"]]' +``` + +(Forge and versions differ; `--sig`/`--json-args` usage might require different quoting depending on shell.) + +3) Off-chain helper (Node.js) + +The repo includes `mint_from_csv.js` to mint using ethers.js. You can use it instead of the on-chain CSV parser for large lists. + +Install deps and run: + +```bash +cd the-guild-smart-contracts/script +npm install +node mint_from_csv.js --rpc-url --private-key --token --csv data/initial_tgc_mints.csv +``` + +Notes & improvements +- The on-chain CSV parser in Forge scripts is simplistic and intended for small example files. For large lists, use the Node helper (or batch the calls). +- Event `ContributionTokenMinted(address,uint256,bytes32)` is emitted on mintWithReason. Consider indexing the `bytes32` reason for easier log filtering. +- If CSV amounts are in tokens (e.g., 1 means 1 TGC), you may want to multiply by 10**decimals in the helper script. + +If you'd like, I can add a NPM package manifest and install script dependencies for you, or change the Node helper to support batching and retries. \ No newline at end of file diff --git a/the-guild-smart-contracts/script/data/initial_tgc_mints.csv b/the-guild-smart-contracts/script/data/initial_tgc_mints.csv new file mode 100644 index 0000000..075b233 --- /dev/null +++ b/the-guild-smart-contracts/script/data/initial_tgc_mints.csv @@ -0,0 +1,2 @@ +0x0000000000000000000000000000000000000001,1000,0x7469636b65745f31 +0x0000000000000000000000000000000000000002,2000,0x7469636b65745f32 diff --git a/the-guild-smart-contracts/script/mint_from_csv.js b/the-guild-smart-contracts/script/mint_from_csv.js new file mode 100644 index 0000000..a26c47a --- /dev/null +++ b/the-guild-smart-contracts/script/mint_from_csv.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +const fs = require('fs'); +const {ethers} = require('ethers'); +const yargs = require('yargs'); + +const argv = yargs + .option('rpc-url', {type: 'string', demandOption: true}) + .option('private-key', {type: 'string', demandOption: true}) + .option('token', {type: 'string', demandOption: true}) + .option('csv', {type: 'string', default: 'data/initial_tgc_mints.csv'}) + .option('decimals', {type: 'number', default: 18}) + .option('batch', {type: 'number', default: 20}) + .argv; + +const abi = [ + 'function batchMint(bytes32 distributionId, address[] recipients, uint256[] amounts, bytes32[] reasons) external', + 'function decimals() view returns (uint8)' +]; + +async function main() { + const provider = new ethers.providers.JsonRpcProvider(argv['rpc-url']); + const wallet = new ethers.Wallet(argv['private-key'], provider); + const token = new ethers.Contract(argv.token, abi, wallet); + + let decimals = argv.decimals; + try { decimals = await token.decimals(); } catch (e) { console.log('could not read decimals, using', decimals); } + + const csv = fs.readFileSync(argv.csv, 'utf8'); + const lines = csv.split(/\r?\n/).filter(l => l.trim().length > 0); + const entries = lines.map(l => { + const [addr, amt, reason] = l.split(',').map(s => s.trim()); + const amount = ethers.BigNumber.from(amt).mul(ethers.BigNumber.from(10).pow(decimals)); + const r = reason && reason.startsWith('0x') ? reason : '0x' + Buffer.from(reason || '').toString('hex'); + return {addr, amount, reason: r}; + }); + + // perform batched batchMint calls with distribution id derived from CSV content + offset + for (let i=0;i b.addr); + const amounts = batch.map(b => b.amount); + const reasons = batch.map(b => { + // ensure 0x-prefixed 32-byte hex; pad/truncate if necessary + const r = b.reason.startsWith('0x') ? b.reason : '0x' + Buffer.from(b.reason || '').toString('hex'); + return r; + }); + + const distributionId = ethers.keccak256(ethers.toUtf8Bytes(argv.csv + ':' + i)); + console.log('batchMinting', recipients.length, 'recipients, distributionId', distributionId); + const tx = await token.batchMint(distributionId, recipients, amounts, reasons); + console.log('tx', tx.hash); + await tx.wait(); + } +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/the-guild-smart-contracts/script/package.json b/the-guild-smart-contracts/script/package.json new file mode 100644 index 0000000..562a7cb --- /dev/null +++ b/the-guild-smart-contracts/script/package.json @@ -0,0 +1,12 @@ +{ + "name": "tgc-scripts", + "version": "0.1.0", + "license": "MIT", + "scripts": { + "mint": "node mint_from_csv.js" + }, + "dependencies": { + "ethers": "^6.8.0", + "yargs": "^17.7.2" + } +} diff --git a/the-guild-smart-contracts/src/TheGuildContributionToken.sol b/the-guild-smart-contracts/src/TheGuildContributionToken.sol new file mode 100644 index 0000000..a646179 --- /dev/null +++ b/the-guild-smart-contracts/src/TheGuildContributionToken.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; + +/// @title TheGuildContributionToken (TGC) +/// @notice Simple ERC20 Ownable token used for rewarding contributions. +contract TheGuildContributionToken is ERC20, Ownable { + /// @notice Emitted when tokens are minted with a reason reference + event ContributionTokenMinted(address indexed recipient, uint256 amount, bytes32 reason); + + // Track distribution IDs to prevent double distributions + mapping(bytes32 => bool) private distributionExecuted; + + constructor() ERC20("TheGuild Contribution Token", "TGC") Ownable(msg.sender) {} + + /// @notice Mint tokens to a recipient. Only owner can mint. + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } + + /// @notice Mint tokens and associate a bytes32 reason (e.g. ticket id). + function mintWithReason(address to, uint256 amount, bytes32 reason) public onlyOwner { + _mint(to, amount); + emit ContributionTokenMinted(to, amount, reason); + } + + /// @notice Batch mint with a distribution id to avoid double execution. + /// @param distributionId Unique id for this distribution (e.g., keccak256 of csv file content or timestamp) + /// @param recipients Array of recipients + /// @param amounts Array of amounts + /// @param reasons Array of bytes32 reasons (one per recipient) + function batchMint( + bytes32 distributionId, + address[] calldata recipients, + uint256[] calldata amounts, + bytes32[] calldata reasons + ) external onlyOwner { + require(!distributionExecuted[distributionId], "TGC: distribution already executed"); + require(recipients.length == amounts.length, "TGC: arrays length mismatch"); + require(recipients.length == reasons.length, "TGC: recipients/reasons length mismatch"); + require(recipients.length > 0, "TGC: empty arrays"); + + distributionExecuted[distributionId] = true; + + for (uint256 i = 0; i < recipients.length; i++) { + require(recipients[i] != address(0), "TGC: cannot mint to zero address"); + // reuse mintWithReason which already emits the event + mintWithReason(recipients[i], amounts[i], reasons[i]); + } + } + + /// @notice Check if a distribution id has been executed + function isDistributionExecuted(bytes32 distributionId) external view returns (bool) { + return distributionExecuted[distributionId]; + } +} 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..169f279 --- /dev/null +++ b/the-guild-smart-contracts/test/TheGuildContributionToken.t.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import {TheGuildContributionToken} from "../src/TheGuildContributionToken.sol"; + +contract TheGuildContributionTokenTest is Test { + TheGuildContributionToken token; + address owner = address(0xABCD); + address alice = address(0x1); + + function setUp() public { + vm.prank(owner); + token = new TheGuildContributionToken(); + } + + function testMintOnlyOwner() public { + vm.prank(owner); + token.mint(alice, 1000); + assertEq(token.balanceOf(alice), 1000); + } + + function testMintWithReasonEmits() public { + bytes32 reason = bytes32("ticket_1"); + vm.prank(owner); + vm.expectEmit(true, false, false, true); + emitContributionMinted(alice, 500, reason); + token.mintWithReason(alice, 500, reason); + assertEq(token.balanceOf(alice), 500); + } + + function testBatchMintAndDistributionId() public { + address bob = address(0x2); + address carol = address(0x3); + + address[] memory recipients = new address[](2); + recipients[0] = alice; + recipients[1] = bob; + + uint256[] memory amounts = new uint256[](2); + amounts[0] = 100; + amounts[1] = 200; + + bytes32[] memory reasons = new bytes32[](2); + reasons[0] = bytes32("t1"); + reasons[1] = bytes32("t2"); + + bytes32 distributionId = keccak256(abi.encodePacked(block.timestamp, address(this))); + + vm.prank(owner); + token.batchMint(distributionId, recipients, amounts, reasons); + + assertEq(token.balanceOf(alice), 100); + assertEq(token.balanceOf(bob), 200); + assertTrue(token.isDistributionExecuted(distributionId)); + + // Replaying should revert + vm.prank(owner); + vm.expectRevert("TGC: distribution already executed"); + token.batchMint(distributionId, recipients, amounts, reasons); + } + + // helper to satisfy expectEmit signature + event ContributionTokenMinted(address indexed recipient, uint256 amount, bytes32 reason); + + function emitContributionMinted(address recipient, uint256 amount, bytes32 reason) internal { + emit ContributionTokenMinted(recipient, amount, reason); + } +} diff --git a/the-guild-smart-contracts/test/TheGuildContributionTokenBatch.t.sol b/the-guild-smart-contracts/test/TheGuildContributionTokenBatch.t.sol new file mode 100644 index 0000000..6b045d0 --- /dev/null +++ b/the-guild-smart-contracts/test/TheGuildContributionTokenBatch.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import {TheGuildContributionToken} from "../src/TheGuildContributionToken.sol"; + +contract TheGuildContributionTokenBatchTest is Test { + TheGuildContributionToken token; + address owner = address(0xABCD); + address alice = address(0x1); + address bob = address(0x2); + + function setUp() public { + vm.prank(owner); + token = new TheGuildContributionToken(); + } + + function testBatchMintSuccessAndDistributionGuard() public { + address[] memory recipients = new address[](2); + recipients[0] = alice; + recipients[1] = bob; + + uint256[] memory amounts = new uint256[](2); + amounts[0] = 100; + amounts[1] = 200; + + bytes32[] memory reasons = new bytes32[](2); + reasons[0] = bytes32("ticket1"); + reasons[1] = bytes32("ticket2"); + + bytes32 distributionId = keccak256(abi.encodePacked("dist-1")); + + vm.prank(owner); + token.batchMint(distributionId, recipients, amounts, reasons); + + assertEq(token.balanceOf(alice), 100); + assertEq(token.balanceOf(bob), 200); + assertTrue(token.isDistributionExecuted(distributionId)); + + // Re-running should revert + vm.prank(owner); + vm.expectRevert(bytes("TGC: distribution already executed")); + token.batchMint(distributionId, recipients, amounts, reasons); + } + + function testBatchMintRevertsOnLengthMismatch() public { + address[] memory recipients = new address[](1); + recipients[0] = alice; + + uint256[] memory amounts = new uint256[](2); + amounts[0] = 100; + amounts[1] = 200; + + bytes32[] memory reasons = new bytes32[](1); + reasons[0] = bytes32("ticket1"); + + bytes32 distributionId = keccak256(abi.encodePacked("dist-2")); + + vm.prank(owner); + vm.expectRevert(bytes("TGC: arrays length mismatch")); + token.batchMint(distributionId, recipients, amounts, reasons); + } +}