diff --git a/the-guild-smart-contracts/.env.example b/the-guild-smart-contracts/.env.example index edc8719..393a5c3 100644 --- a/the-guild-smart-contracts/.env.example +++ b/the-guild-smart-contracts/.env.example @@ -16,4 +16,10 @@ BASE_SEPOLIA_URL=https://base-sepolia.therpc.io ETHERSCAN_API_KEY= # For deployment -PRIVATE_KEY= \ No newline at end of file +PRIVATE_KEY= + +# Batch attestation script +JSON_PATH=./attestations.json +SCHEMA_ID=0xbcd7561083784f9b5a1c2b3ddb7aa9db263d43c58f7374cfa4875646824a47de +DRY_RUN=false +RPC_URL= \ No newline at end of file diff --git a/the-guild-smart-contracts/README.md b/the-guild-smart-contracts/README.md index bcf37a2..a238d9f 100644 --- a/the-guild-smart-contracts/README.md +++ b/the-guild-smart-contracts/README.md @@ -203,6 +203,81 @@ forge script script/TheGuildBadgeRanking.s.sol:TheGuildBadgeRankingScript \ --broadcast ``` +### Batch Attestations + +The `EmitAttestationsCsv.s.sol` script allows batch creation of attestations from JSON data using EAS's `multiAttest()` function for gas efficiency. + +#### JSON Format + +Prepare your attestations data in JSON format: +```json +{ + "attestations": [ + { + "recipient": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "badgeName": "Rust", + "justification": "Outstanding Rust contributions to the project" + }, + { + "recipient": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "badgeName": "Solidity", + "justification": "Excellent Solidity smart contract development" + } + ] +} +``` + +- `recipient`: Ethereum address of the recipient +- `badgeName`: Name of the badge to attest (will be converted to bytes32) +- `justification`: Text explanation or link justifying the attestation + +#### Usage + +```shell +# Using the helper script (recommended) +export PRIVATE_KEY=your_private_key +export RPC_URL=https://polygon-amoy.drpc.org + +# Dry run first +./run_batch_attestations.sh attestations.json true + +# Production run +./run_batch_attestations.sh attestations.json false + +# Manual approach +# Set environment variables +export PRIVATE_KEY=your_private_key +export JSON_PATH=./attestations.json +export SCHEMA_ID=0xbcd7561083784f9b5a1c2b3ddb7aa9db263d43c58f7374cfa4875646824a47de + +# Dry run (recommended first) +export DRY_RUN=true +forge script script/EmitAttestationsCsv.s.sol:EmitAttestationsCsv \ + --rpc-url + +# Production run +unset DRY_RUN +forge script script/EmitAttestationsCsv.s.sol:EmitAttestationsCsv \ + --rpc-url \ + --broadcast +``` + +#### Environment Variables + +- `PRIVATE_KEY`: Private key for transaction signing +- `JSON_PATH`: Path to the JSON file (default: `./attestations.json`) +- `SCHEMA_ID`: EAS schema ID to use (default: Amoy production schema) +- `DRY_RUN`: Set to `true` for validation without broadcasting (default: `false`) +- `EAS_ADDRESS`: EAS contract address (auto-detected for Amoy/Base Sepolia, required for other networks) +- `RPC_URL`: RPC endpoint URL (for helper script) + +The script will: +1. Read the JSON file directly using `vm.readFile()` +2. Parse and validate all attestations +3. Create `MultiAttestationRequest` batches (max 50 per batch) +4. Call EAS `multiAttest()` for gas-efficient batch processing +5. Log all attestation UIDs upon completion + ### Cast ```shell diff --git a/the-guild-smart-contracts/attestations.json b/the-guild-smart-contracts/attestations.json new file mode 100644 index 0000000..a265a06 --- /dev/null +++ b/the-guild-smart-contracts/attestations.json @@ -0,0 +1,14 @@ +{ + "attestations": [ + { + "recipient": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "badgeName": "Rust", + "justification": "Awarded for outstanding Rust contributions" + }, + { + "recipient": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "badgeName": "Solidity", + "justification": "Solidity development excellence" + } + ] +} diff --git a/the-guild-smart-contracts/foundry.toml b/the-guild-smart-contracts/foundry.toml index 3accb69..e9f7e8d 100644 --- a/the-guild-smart-contracts/foundry.toml +++ b/the-guild-smart-contracts/foundry.toml @@ -5,6 +5,8 @@ libs = ["lib"] coverage = true # Exclude scripts, libs, and tests from coverage no_match_coverage = "^(script/|lib/|test/)" +# Allow file reading for scripts +fs_permissions = [{ access = "read", path = "./" }] # Deterministic deployments settings (see: https://getfoundry.sh/guides/deterministic-deployments-using-create2) solc = "0.8.28" diff --git a/the-guild-smart-contracts/run_batch_attestations.sh b/the-guild-smart-contracts/run_batch_attestations.sh new file mode 100755 index 0000000..18615f8 --- /dev/null +++ b/the-guild-smart-contracts/run_batch_attestations.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# Helper script for running TheGuild batch attestation script +# Usage: ./run_batch_attestations.sh [dry_run] + +set -e + +# Source .env file if it exists +if [ -f .env ]; then + source .env +fi + +if [ $# -lt 1 ]; then + echo "Usage: $0 [dry_run]" + echo " json_file: Path to JSON file with attestations" + echo " dry_run: Set to 'true' for dry run (default: false)" + exit 1 +fi + +JSON_FILE="$1" +DRY_RUN="${2:-false}" + +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 + +# Run the script +if [ "$DRY_RUN" = "true" ]; then + forge script script/EmitAttestationsCsv.s.sol:EmitAttestationsCsv \ + --rpc-url "$RPC_URL" +else + forge script script/EmitAttestationsCsv.s.sol:EmitAttestationsCsv \ + --rpc-url "$RPC_URL" \ + --broadcast +fi \ No newline at end of file diff --git a/the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol b/the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol new file mode 100644 index 0000000..ab312d4 --- /dev/null +++ b/the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, stdJson} from "forge-std/Script.sol"; +import {EAS} from "eas-contracts/EAS.sol"; +import {AttestationRequestData, MultiAttestationRequest} from "eas-contracts/IEAS.sol"; +import {EASUtils} from "./utils/EASUtils.s.sol"; +import {console} from "forge-std/console.sol"; + +contract EmitAttestationsCsv is Script { + using stdJson for string; + + // Configuration constants + uint256 constant MAX_BATCH_SIZE = 50; // Limit batch size to prevent gas issues + uint256 constant MAX_ATTESTATIONS = 1000; // Reasonable limit for processing + + struct AttestationData { + address recipient; + bytes32 badgeName; + bytes justification; + } + + function run() public { + bool isDryRun = vm.envOr("DRY_RUN", false); + + console.log("=== TheGuild Batch Attestation Script ==="); + console.log("Dry run mode:", isDryRun ? "ENABLED" : "DISABLED"); + + // Get EAS address for current network + EAS eas = EAS(EASUtils.getEASAddress(vm)); + console.log("EAS Address:", address(eas)); + + // Read and validate JSON file + string memory jsonPath = vm.envOr("JSON_PATH", string("attestations.json")); + console.log("Reading JSON from:", jsonPath); + + string memory jsonData = vm.readFile(jsonPath); + AttestationData[] memory attestations = parseAndValidateJson(jsonData); + + console.log(string(abi.encodePacked("Parsed ", vm.toString(attestations.length), " attestations from JSON"))); + + // Get schema ID + bytes32 schemaId = vm.envOr("SCHEMA_ID", bytes32(0xbcd7561083784f9b5a1c2b3ddb7aa9db263d43c58f7374cfa4875646824a47de)); + console.log("Using Schema ID:", vm.toString(schemaId)); + + // Create batch requests + MultiAttestationRequest[] memory requests = createBatchRequests(attestations, schemaId); + + if (isDryRun) { + console.log(string(abi.encodePacked("DRY RUN: Would process ", vm.toString(requests.length), " batches"))); + for (uint256 i = 0; i < requests.length; i++) { + console.log(string(abi.encodePacked("Batch ", vm.toString(i), ": ", vm.toString(requests[i].data.length), " attestations"))); + } + console.log("Dry run completed successfully!"); + return; + } + + // Execute attestations + executeAttestations(eas, requests); + } + + function parseAndValidateJson(string memory jsonData) internal view returns (AttestationData[] memory) { + // JSON parseJson returns data with types inferred from JSON - addresses as address, strings as string + console.log("Parsing JSON attestations array..."); + + // Count attestations - parse the full array to get length + // We'll use a temporary storage to collect attestations + AttestationData[] memory tempAttestations = new AttestationData[](MAX_ATTESTATIONS); + uint256 count = 0; + + // Parse attestations until we run out + for (uint256 i = 0; i < MAX_ATTESTATIONS; i++) { + string memory basePath = string(abi.encodePacked(".attestations[", vm.toString(i), "]")); + + // Try to parse this attestation - if it doesn't exist, parseJson will revert + bytes memory testPath = vm.parseJson(jsonData, string(abi.encodePacked(basePath, ".recipient"))); + if (testPath.length == 0) break; // No more attestations + + // Parse badgeName with proper bytes32 conversion + string memory badgeNameStr = abi.decode( + vm.parseJson(jsonData, string(abi.encodePacked(basePath, ".badgeName"))), + (string) + ); + bytes32 badgeName; + bytes memory badgeNameBytes = bytes(badgeNameStr); + require(badgeNameBytes.length <= 32, "badgeName too long"); + assembly { + badgeName := mload(add(badgeNameBytes, 32)) + } + + bytes memory justification = abi.decode( + vm.parseJson(jsonData, string(abi.encodePacked(basePath, ".justification"))), + (bytes) + ); + + tempAttestations[count] = AttestationData({ + recipient: abi.decode(testPath, (address)), + badgeName: badgeName, + justification: justification + }); + + count++; + } + + // Copy to properly sized array + AttestationData[] memory attestations = new AttestationData[](count); + for (uint256 i = 0; i < count; i++) { + attestations[i] = tempAttestations[i]; + } + + console.log("Found", attestations.length, "attestations in JSON"); + + require(attestations.length > 0, "JSON must contain at least 1 attestation"); + require(attestations.length <= MAX_ATTESTATIONS, "Too many attestations, max 1000 allowed"); + + // Validate each attestation + for (uint256 i = 0; i < attestations.length; i++) { + require(attestations[i].recipient != address(0), + string(abi.encodePacked("Attestation ", vm.toString(i), ": invalid recipient address"))); + require(attestations[i].badgeName != bytes32(0), + string(abi.encodePacked("Attestation ", vm.toString(i), ": badgeName cannot be empty"))); + } + + return attestations; + } + + function createBatchRequests( + AttestationData[] memory attestations, + bytes32 schemaId + ) internal pure returns (MultiAttestationRequest[] memory) { + uint256 totalBatches = (attestations.length + MAX_BATCH_SIZE - 1) / MAX_BATCH_SIZE; + MultiAttestationRequest[] memory requests = new MultiAttestationRequest[](totalBatches); + + for (uint256 batchIndex = 0; batchIndex < totalBatches; batchIndex++) { + uint256 startIdx = batchIndex * MAX_BATCH_SIZE; + uint256 endIdx = startIdx + MAX_BATCH_SIZE; + if (endIdx > attestations.length) endIdx = attestations.length; + + uint256 batchSize = endIdx - startIdx; + requests[batchIndex].schema = schemaId; + requests[batchIndex].data = new AttestationRequestData[](batchSize); + + for (uint256 i = 0; i < batchSize; i++) { + AttestationData memory att = attestations[startIdx + i]; + requests[batchIndex].data[i] = AttestationRequestData({ + recipient: att.recipient, + expirationTime: 0, // No expiration + revocable: true, + refUID: bytes32(0), // No reference + data: abi.encode(att.badgeName, att.justification), + value: 0 // No ETH value + }); + } + } + + return requests; + } + + function executeAttestations(EAS eas, MultiAttestationRequest[] memory requests) internal { + uint256 pk = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(pk); + + console.log("Starting batch attestation execution..."); + + uint256 totalAttestations = 0; + for (uint256 batchIndex = 0; batchIndex < requests.length; batchIndex++) { + console.log(string(abi.encodePacked("Processing batch ", vm.toString(batchIndex + 1), "/", vm.toString(requests.length), " - ", vm.toString(requests[batchIndex].data.length), " attestations"))); + + // Create single-element array for this batch + MultiAttestationRequest[] memory singleBatch = new MultiAttestationRequest[](1); + singleBatch[0] = requests[batchIndex]; + + try eas.multiAttest(singleBatch) returns (bytes32[] memory uids) { + console.log(string(abi.encodePacked("Batch ", vm.toString(batchIndex + 1), " completed with ", vm.toString(uids.length), " attestations"))); + totalAttestations += uids.length; + + // Log first few UIDs + for (uint256 i = 0; i < uids.length && i < 3; i++) { + console.log(string(abi.encodePacked(" UID ", vm.toString(i), ": ", vm.toString(uids[i])))); + } + if (uids.length > 3) { + console.log(string(abi.encodePacked(" ... and ", vm.toString(uids.length - 3), " more"))); + } + } catch Error(string memory reason) { + console.log(string(abi.encodePacked("Batch ", vm.toString(batchIndex + 1), " failed: ", reason))); + revert(string(abi.encodePacked("Batch ", vm.toString(batchIndex + 1), " failed: ", reason))); + } catch { + console.log(string(abi.encodePacked("Batch ", vm.toString(batchIndex + 1), " failed with unknown error"))); + revert(string(abi.encodePacked("Batch ", vm.toString(batchIndex + 1), " failed with unknown error"))); + } + } + + vm.stopBroadcast(); + + console.log("=== Attestation Summary ==="); + console.log("Total batches processed:", requests.length); + console.log("Total attestations created:", totalAttestations); + console.log("Execution completed successfully!"); + } + + +} \ No newline at end of file diff --git a/the-guild-smart-contracts/test/EmitAttestationsCsv.t.sol b/the-guild-smart-contracts/test/EmitAttestationsCsv.t.sol new file mode 100644 index 0000000..979e70d --- /dev/null +++ b/the-guild-smart-contracts/test/EmitAttestationsCsv.t.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {EmitAttestationsCsv} from "../script/EmitAttestationsCsv.s.sol"; + +contract EmitAttestationsCsvTest is Test { + EmitAttestationsCsv private script; + + function setUp() public { + script = new EmitAttestationsCsv(); + } + + function test_AttestationDataStructure() public pure { + // Test that the AttestationData struct is properly defined + EmitAttestationsCsv.AttestationData memory testData = EmitAttestationsCsv.AttestationData({ + recipient: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e, + badgeName: bytes32(abi.encodePacked("Rust")), + justification: bytes("Awarded for outstanding Rust contributions") + }); + + assertEq(testData.recipient, 0x742d35Cc6634C0532925a3b844Bc454e4438f44e); + assertEq(testData.badgeName, bytes32(abi.encodePacked("Rust"))); + assertEq(testData.justification, bytes("Awarded for outstanding Rust contributions")); + } + + function test_AttestationDataArray() public pure { + // Test that we can create arrays of AttestationData + EmitAttestationsCsv.AttestationData[] memory attestations = new EmitAttestationsCsv.AttestationData[](2); + + attestations[0] = EmitAttestationsCsv.AttestationData({ + recipient: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e, + badgeName: bytes32(abi.encodePacked("Rust")), + justification: bytes("Rust expert") + }); + + attestations[1] = EmitAttestationsCsv.AttestationData({ + recipient: 0x742d35Cc6634C0532925a3b844Bc454e4438f44e, + badgeName: bytes32(abi.encodePacked("Solidity")), + justification: bytes("Solidity expert") + }); + + assertEq(attestations.length, 2); + assertEq(attestations[0].badgeName, bytes32(abi.encodePacked("Rust"))); + assertEq(attestations[1].badgeName, bytes32(abi.encodePacked("Solidity"))); + } +} \ No newline at end of file