From fee6b0e159267b0a7fcd3483c1b997642120d3cf Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Wed, 5 Nov 2025 05:48:03 +0000 Subject: [PATCH 1/3] script for batch attestation creation from CSV using EAS multiAttest() --- the-guild-smart-contracts/.env.example | 8 +- the-guild-smart-contracts/README.md | 68 +++++ the-guild-smart-contracts/attestations.csv | 3 + .../run_batch_attestations.sh | 54 ++++ .../script/EmitAttestationsCsv.s.sol | 250 ++++++++++++++++++ .../test/EmitAttestationsCsv.t.sol | 47 ++++ 6 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 the-guild-smart-contracts/attestations.csv create mode 100755 the-guild-smart-contracts/run_batch_attestations.sh create mode 100644 the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol create mode 100644 the-guild-smart-contracts/test/EmitAttestationsCsv.t.sol diff --git a/the-guild-smart-contracts/.env.example b/the-guild-smart-contracts/.env.example index edc8719..96d246b 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 +# Load CSV data: export CSV_DATA=$(cat attestations.csv) +CSV_DATA= +SCHEMA_ID=0xb167f07504166f717f2a2710dbcfbfdf8fad6e8c6128c1a7fa80768f61b1d0b2 +DRY_RUN=false \ No newline at end of file diff --git a/the-guild-smart-contracts/README.md b/the-guild-smart-contracts/README.md index bcf37a2..9a11ba0 100644 --- a/the-guild-smart-contracts/README.md +++ b/the-guild-smart-contracts/README.md @@ -203,6 +203,74 @@ forge script script/TheGuildBadgeRanking.s.sol:TheGuildBadgeRankingScript \ --broadcast ``` +### Batch Attestations + +The `EmitAttestationsCsv.s.sol` script allows batch creation of attestations from CSV data using EAS's `multiAttest()` function for gas efficiency. + +#### CSV Format + +Prepare your CSV data in the following format: +```csv +address,badgeName,distributionId +0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Rust,dist-001 +0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Solidity,dist-001 +``` + +- `address`: Ethereum address of the recipient (checksum format preferred) +- `badgeName`: Name of the badge to attest (will be converted to bytes32) +- `distributionId`: Optional identifier for tracking the distribution batch + +#### 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.csv true + +# Production run +./run_batch_attestations.sh attestations.csv false + +# Manual approach +# Load CSV data into environment variable +export CSV_DATA=$(cat attestations.csv) + +# Set other environment variables +export PRIVATE_KEY=your_private_key +export SCHEMA_ID=0xb167f07504166f717f2a2710dbcfbfdf8fad6e8c6128c1a7fa80768f61b1d0b2 + +# 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 + +For manual usage: +- `PRIVATE_KEY`: Private key for transaction signing +- `CSV_DATA`: CSV content as a string (load from file using `$(cat file.csv)`) +- `SCHEMA_ID`: EAS schema ID to use (default: Amoy dev schema) +- `DRY_RUN`: Set to `true` for validation without broadcasting (default: `false`) + +For helper script usage: +- `PRIVATE_KEY`: Private key for transaction signing +- `RPC_URL`: RPC endpoint URL for the target network + +The script will: +1. Parse the CSV data from environment variable +2. Create `MultiAttestationRequest` with all attestations +3. Call EAS `multiAttest()` for gas-efficient batch processing +4. Log all attestation UIDs upon completion + ### Cast ```shell diff --git a/the-guild-smart-contracts/attestations.csv b/the-guild-smart-contracts/attestations.csv new file mode 100644 index 0000000..964d886 --- /dev/null +++ b/the-guild-smart-contracts/attestations.csv @@ -0,0 +1,3 @@ +0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Rust,dist-001 +0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Solidity,dist-001 +0x742d35Cc6634C0532925a3b844Bc454e4438f44e,TypeScript,dist-001 \ No newline at end of file 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..57666f4 --- /dev/null +++ b/the-guild-smart-contracts/run_batch_attestations.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +# Helper script for running TheGuild batch attestation script +# Usage: ./run_batch_attestations.sh [dry_run] + +set -e + +if [ $# -lt 1 ]; then + echo "Usage: $0 [dry_run]" + echo " csv_file: Path to CSV file with attestations" + echo " dry_run: Set to 'true' for dry run (default: false)" + exit 1 +fi + +CSV_FILE="$1" +DRY_RUN="${2:-false}" + +if [ ! -f "$CSV_FILE" ]; then + echo "Error: CSV file '$CSV_FILE' not found" + exit 1 +fi + +# Load CSV data into environment variable +export CSV_DATA=$(cat "$CSV_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..389b080 --- /dev/null +++ b/the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script} 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 { + // Configuration constants + uint256 constant MAX_BATCH_SIZE = 50; // Limit batch size to prevent gas issues + uint256 constant MAX_CSV_LINES = 1000; // Reasonable limit for CSV processing + + struct AttestationData { + address recipient; + bytes32 badgeName; + string distributionId; + 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 CSV from environment variable + string memory csvData = vm.envString("CSV_DATA"); + console.log("Reading CSV data from environment variable"); + + AttestationData[] memory attestations = parseAndValidateCsv(csvData); + + console.log(string(abi.encodePacked("Parsed ", vm.toString(attestations.length), " attestations from CSV"))); + + // Validate distribution ID for idempotency (if provided) + string memory distributionId = getDistributionId(attestations); + if (bytes(distributionId).length > 0) { + console.log("Distribution ID:", distributionId); + // TODO: In production, check against on-chain storage for duplicates + } + + // Get schema ID + bytes32 schemaId = vm.envOr("SCHEMA_ID", bytes32(0xb167f07504166f717f2a2710dbcfbfdf8fad6e8c6128c1a7fa80768f61b1d0b2)); + 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 parseAndValidateCsv(string memory csvData) internal pure returns (AttestationData[] memory) { + string[] memory lines = split(csvData, "\n"); + require(lines.length >= 2, "CSV must have at least header + 1 data row"); + require(lines.length <= MAX_CSV_LINES + 1, "CSV too large, max 1000 data rows allowed"); + + uint256 dataLines = lines.length - 1; // Skip header + AttestationData[] memory attestations = new AttestationData[](dataLines); + + for (uint256 i = 1; i < lines.length; i++) { + if (bytes(lines[i]).length == 0) continue; // Skip empty lines + + string[] memory fields = split(lines[i], ","); + require(fields.length >= 2, string(abi.encodePacked("Line ", vm.toString(i), ": must have at least address,badgeName"))); + + address recipient = parseAddress(fields[0]); + require(recipient != address(0), string(abi.encodePacked("Line ", vm.toString(i), ": invalid recipient address"))); + + bytes32 badgeName = parseBytes32(fields[1]); + require(badgeName != bytes32(0), string(abi.encodePacked("Line ", vm.toString(i), ": badgeName cannot be empty"))); + + string memory distributionId = fields.length >= 3 ? fields[2] : ""; + + bytes memory justification = bytes(distributionId).length > 0 + ? abi.encodePacked("Distribution: ", distributionId) + : bytes("Batch attestation"); + + attestations[i-1] = AttestationData({ + recipient: recipient, + badgeName: badgeName, + distributionId: distributionId, + justification: justification + }); + } + + return attestations; + } + + function getDistributionId(AttestationData[] memory attestations) internal pure returns (string memory) { + if (attestations.length == 0) return ""; + + string memory firstId = attestations[0].distributionId; + for (uint256 i = 1; i < attestations.length; i++) { + require( + keccak256(bytes(attestations[i].distributionId)) == keccak256(bytes(firstId)), + "All attestations must have the same distributionId" + ); + } + return firstId; + } + + 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!"); + } + + + // Helper function to parse address from string + function parseAddress(string memory addrStr) public pure returns (address) { + bytes memory addrBytes = bytes(addrStr); + require(addrBytes.length == 42, "Invalid address length"); // 0x + 40 hex chars + require(addrBytes[0] == '0' && (addrBytes[1] == 'x' || addrBytes[1] == 'X'), "Address must start with 0x"); + + uint160 addr; + for (uint256 i = 2; i < 42; i++) { + uint8 digit; + if (uint8(addrBytes[i]) >= 48 && uint8(addrBytes[i]) <= 57) { + digit = uint8(addrBytes[i]) - 48; + } else if (uint8(addrBytes[i]) >= 65 && uint8(addrBytes[i]) <= 70) { + digit = uint8(addrBytes[i]) - 55; + } else if (uint8(addrBytes[i]) >= 97 && uint8(addrBytes[i]) <= 102) { + digit = uint8(addrBytes[i]) - 87; + } else { + revert("Invalid hex character in address"); + } + addr = addr * 16 + digit; + } + return address(addr); + } + + // Helper function to parse bytes32 from string + function parseBytes32(string memory str) public pure returns (bytes32) { + return bytes32(abi.encodePacked(str)); + } + + // Simple string splitting function + function split(string memory str, string memory delimiter) public pure returns (string[] memory) { + bytes memory strBytes = bytes(str); + bytes memory delimBytes = bytes(delimiter); + + uint256 count = 1; + for (uint256 i = 0; i < strBytes.length; i++) { + if (strBytes[i] == delimBytes[0]) { + count++; + } + } + + string[] memory result = new string[](count); + uint256 lastIndex = 0; + uint256 resultIndex = 0; + + for (uint256 i = 0; i <= strBytes.length; i++) { + if (i == strBytes.length || strBytes[i] == delimBytes[0]) { + bytes memory slice = new bytes(i - lastIndex); + for (uint256 j = 0; j < slice.length; j++) { + slice[j] = strBytes[lastIndex + j]; + } + result[resultIndex] = string(slice); + resultIndex++; + lastIndex = i + 1; + } + } + + return result; + } +} \ 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..355b52e --- /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_ParseAddress() public view { + address expected = 0x742d35Cc6634C0532925a3b844Bc454e4438f44e; + address result = script.parseAddress("0x742d35Cc6634C0532925a3b844Bc454e4438f44e"); + assertEq(result, expected); + } + + function test_ParseBytes32() public view { + bytes32 expected = bytes32(abi.encodePacked("Rust")); + bytes32 result = script.parseBytes32("Rust"); + assertEq(result, expected); + } + + function test_SplitString() public view { + string memory input = "a,b,c"; + string[] memory result = script.split(input, ","); + assertEq(result.length, 3); + assertEq(result[0], "a"); + assertEq(result[1], "b"); + assertEq(result[2], "c"); + } + + function test_ParseCsvToAttestations() public view { + string memory csvData = "address,badgeName,distributionId\n0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Rust,dist-001\n0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Solidity,dist-001"; + + // We can't directly call internal functions from tests, so we'll test the components + // This is a limitation of Solidity testing - internal functions aren't directly testable + + // Test the split function with newlines + string[] memory lines = script.split(csvData, "\n"); + assertEq(lines.length, 3); // header + 2 data lines + assertEq(lines[0], "address,badgeName,distributionId"); + assertEq(lines[1], "0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Rust,dist-001"); + } +} \ No newline at end of file From 8b02c3c38f3c189204c1c355f5ec98d11f7a3ac5 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Fri, 28 Nov 2025 05:55:36 +0000 Subject: [PATCH 2/3] refactor: switch batch attestation script from CSV to JSON format --- the-guild-smart-contracts/.env.example | 5 +- the-guild-smart-contracts/README.md | 65 ++++--- the-guild-smart-contracts/attestations.json | 14 ++ the-guild-smart-contracts/foundry.toml | 2 + .../run_batch_attestations.sh | 16 +- .../script/EmitAttestationsCsv.s.sol | 169 ++++++------------ .../test/EmitAttestationsCsv.t.sol | 60 +++---- 7 files changed, 144 insertions(+), 187 deletions(-) create mode 100644 the-guild-smart-contracts/attestations.json diff --git a/the-guild-smart-contracts/.env.example b/the-guild-smart-contracts/.env.example index 96d246b..91adcc8 100644 --- a/the-guild-smart-contracts/.env.example +++ b/the-guild-smart-contracts/.env.example @@ -19,7 +19,6 @@ ETHERSCAN_API_KEY= PRIVATE_KEY= # Batch attestation script -# Load CSV data: export CSV_DATA=$(cat attestations.csv) -CSV_DATA= -SCHEMA_ID=0xb167f07504166f717f2a2710dbcfbfdf8fad6e8c6128c1a7fa80768f61b1d0b2 +JSON_PATH=./attestations.json +SCHEMA_ID=0xbcd7561083784f9b5a1c2b3ddb7aa9db263d43c58f7374cfa4875646824a47de DRY_RUN=false \ No newline at end of file diff --git a/the-guild-smart-contracts/README.md b/the-guild-smart-contracts/README.md index 9a11ba0..a238d9f 100644 --- a/the-guild-smart-contracts/README.md +++ b/the-guild-smart-contracts/README.md @@ -205,20 +205,31 @@ forge script script/TheGuildBadgeRanking.s.sol:TheGuildBadgeRankingScript \ ### Batch Attestations -The `EmitAttestationsCsv.s.sol` script allows batch creation of attestations from CSV data using EAS's `multiAttest()` function for gas efficiency. - -#### CSV Format - -Prepare your CSV data in the following format: -```csv -address,badgeName,distributionId -0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Rust,dist-001 -0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Solidity,dist-001 +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" + } + ] +} ``` -- `address`: Ethereum address of the recipient (checksum format preferred) +- `recipient`: Ethereum address of the recipient - `badgeName`: Name of the badge to attest (will be converted to bytes32) -- `distributionId`: Optional identifier for tracking the distribution batch +- `justification`: Text explanation or link justifying the attestation #### Usage @@ -228,18 +239,16 @@ export PRIVATE_KEY=your_private_key export RPC_URL=https://polygon-amoy.drpc.org # Dry run first -./run_batch_attestations.sh attestations.csv true +./run_batch_attestations.sh attestations.json true # Production run -./run_batch_attestations.sh attestations.csv false +./run_batch_attestations.sh attestations.json false # Manual approach -# Load CSV data into environment variable -export CSV_DATA=$(cat attestations.csv) - -# Set other environment variables +# Set environment variables export PRIVATE_KEY=your_private_key -export SCHEMA_ID=0xb167f07504166f717f2a2710dbcfbfdf8fad6e8c6128c1a7fa80768f61b1d0b2 +export JSON_PATH=./attestations.json +export SCHEMA_ID=0xbcd7561083784f9b5a1c2b3ddb7aa9db263d43c58f7374cfa4875646824a47de # Dry run (recommended first) export DRY_RUN=true @@ -255,21 +264,19 @@ forge script script/EmitAttestationsCsv.s.sol:EmitAttestationsCsv \ #### Environment Variables -For manual usage: - `PRIVATE_KEY`: Private key for transaction signing -- `CSV_DATA`: CSV content as a string (load from file using `$(cat file.csv)`) -- `SCHEMA_ID`: EAS schema ID to use (default: Amoy dev schema) +- `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`) - -For helper script usage: -- `PRIVATE_KEY`: Private key for transaction signing -- `RPC_URL`: RPC endpoint URL for the target network +- `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. Parse the CSV data from environment variable -2. Create `MultiAttestationRequest` with all attestations -3. Call EAS `multiAttest()` for gas-efficient batch processing -4. Log all attestation UIDs upon completion +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 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 index 57666f4..5200842 100755 --- a/the-guild-smart-contracts/run_batch_attestations.sh +++ b/the-guild-smart-contracts/run_batch_attestations.sh @@ -1,27 +1,27 @@ #!/bin/bash # Helper script for running TheGuild batch attestation script -# Usage: ./run_batch_attestations.sh [dry_run] +# Usage: ./run_batch_attestations.sh [dry_run] set -e if [ $# -lt 1 ]; then - echo "Usage: $0 [dry_run]" - echo " csv_file: Path to CSV file with attestations" + 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 -CSV_FILE="$1" +JSON_FILE="$1" DRY_RUN="${2:-false}" -if [ ! -f "$CSV_FILE" ]; then - echo "Error: CSV file '$CSV_FILE' not found" +if [ ! -f "$JSON_FILE" ]; then + echo "Error: JSON file '$JSON_FILE' not found" exit 1 fi -# Load CSV data into environment variable -export CSV_DATA=$(cat "$CSV_FILE") +# Set JSON file path +export JSON_PATH="$JSON_FILE" # Set dry run mode if [ "$DRY_RUN" = "true" ]; then diff --git a/the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol b/the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol index 389b080..87704c3 100644 --- a/the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol +++ b/the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol @@ -1,21 +1,22 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -import {Script} from "forge-std/Script.sol"; +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_CSV_LINES = 1000; // Reasonable limit for CSV processing + uint256 constant MAX_ATTESTATIONS = 1000; // Reasonable limit for processing struct AttestationData { address recipient; bytes32 badgeName; - string distributionId; bytes justification; } @@ -29,23 +30,17 @@ contract EmitAttestationsCsv is Script { EAS eas = EAS(EASUtils.getEASAddress(vm)); console.log("EAS Address:", address(eas)); - // Read and validate CSV from environment variable - string memory csvData = vm.envString("CSV_DATA"); - console.log("Reading CSV data from environment variable"); - - AttestationData[] memory attestations = parseAndValidateCsv(csvData); + // Read and validate JSON file + string memory jsonPath = vm.envOr("JSON_PATH", string("attestations.json")); + console.log("Reading JSON from:", jsonPath); - console.log(string(abi.encodePacked("Parsed ", vm.toString(attestations.length), " attestations from CSV"))); + string memory jsonData = vm.readFile(jsonPath); + AttestationData[] memory attestations = parseAndValidateJson(jsonData); - // Validate distribution ID for idempotency (if provided) - string memory distributionId = getDistributionId(attestations); - if (bytes(distributionId).length > 0) { - console.log("Distribution ID:", distributionId); - // TODO: In production, check against on-chain storage for duplicates - } + console.log(string(abi.encodePacked("Parsed ", vm.toString(attestations.length), " attestations from JSON"))); // Get schema ID - bytes32 schemaId = vm.envOr("SCHEMA_ID", bytes32(0xb167f07504166f717f2a2710dbcfbfdf8fad6e8c6128c1a7fa80768f61b1d0b2)); + bytes32 schemaId = vm.envOr("SCHEMA_ID", bytes32(0xbcd7561083784f9b5a1c2b3ddb7aa9db263d43c58f7374cfa4875646824a47de)); console.log("Using Schema ID:", vm.toString(schemaId)); // Create batch requests @@ -64,54 +59,52 @@ contract EmitAttestationsCsv is Script { executeAttestations(eas, requests); } - function parseAndValidateCsv(string memory csvData) internal pure returns (AttestationData[] memory) { - string[] memory lines = split(csvData, "\n"); - require(lines.length >= 2, "CSV must have at least header + 1 data row"); - require(lines.length <= MAX_CSV_LINES + 1, "CSV too large, max 1000 data rows allowed"); - - uint256 dataLines = lines.length - 1; // Skip header - AttestationData[] memory attestations = new AttestationData[](dataLines); - - for (uint256 i = 1; i < lines.length; i++) { - if (bytes(lines[i]).length == 0) continue; // Skip empty lines - - string[] memory fields = split(lines[i], ","); - require(fields.length >= 2, string(abi.encodePacked("Line ", vm.toString(i), ": must have at least address,badgeName"))); - - address recipient = parseAddress(fields[0]); - require(recipient != address(0), string(abi.encodePacked("Line ", vm.toString(i), ": invalid recipient address"))); - - bytes32 badgeName = parseBytes32(fields[1]); - require(badgeName != bytes32(0), string(abi.encodePacked("Line ", vm.toString(i), ": badgeName cannot be empty"))); - - string memory distributionId = fields.length >= 3 ? fields[2] : ""; - - bytes memory justification = bytes(distributionId).length > 0 - ? abi.encodePacked("Distribution: ", distributionId) - : bytes("Batch attestation"); - - attestations[i-1] = AttestationData({ - recipient: recipient, - badgeName: badgeName, - distributionId: distributionId, - justification: justification + 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 + + tempAttestations[count] = AttestationData({ + recipient: abi.decode(testPath, (address)), + badgeName: bytes32(abi.encodePacked(abi.decode(vm.parseJson(jsonData, string(abi.encodePacked(basePath, ".badgeName"))), (string)))), + justification: abi.decode(vm.parseJson(jsonData, string(abi.encodePacked(basePath, ".justification"))), (bytes)) }); + + 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"); - return attestations; - } - - function getDistributionId(AttestationData[] memory attestations) internal pure returns (string memory) { - if (attestations.length == 0) return ""; + require(attestations.length > 0, "JSON must contain at least 1 attestation"); + require(attestations.length <= MAX_ATTESTATIONS, "Too many attestations, max 1000 allowed"); - string memory firstId = attestations[0].distributionId; - for (uint256 i = 1; i < attestations.length; i++) { - require( - keccak256(bytes(attestations[i].distributionId)) == keccak256(bytes(firstId)), - "All attestations must have the same distributionId" - ); + // 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 firstId; + + return attestations; } function createBatchRequests( @@ -189,62 +182,4 @@ contract EmitAttestationsCsv is Script { } - // Helper function to parse address from string - function parseAddress(string memory addrStr) public pure returns (address) { - bytes memory addrBytes = bytes(addrStr); - require(addrBytes.length == 42, "Invalid address length"); // 0x + 40 hex chars - require(addrBytes[0] == '0' && (addrBytes[1] == 'x' || addrBytes[1] == 'X'), "Address must start with 0x"); - - uint160 addr; - for (uint256 i = 2; i < 42; i++) { - uint8 digit; - if (uint8(addrBytes[i]) >= 48 && uint8(addrBytes[i]) <= 57) { - digit = uint8(addrBytes[i]) - 48; - } else if (uint8(addrBytes[i]) >= 65 && uint8(addrBytes[i]) <= 70) { - digit = uint8(addrBytes[i]) - 55; - } else if (uint8(addrBytes[i]) >= 97 && uint8(addrBytes[i]) <= 102) { - digit = uint8(addrBytes[i]) - 87; - } else { - revert("Invalid hex character in address"); - } - addr = addr * 16 + digit; - } - return address(addr); - } - - // Helper function to parse bytes32 from string - function parseBytes32(string memory str) public pure returns (bytes32) { - return bytes32(abi.encodePacked(str)); - } - - // Simple string splitting function - function split(string memory str, string memory delimiter) public pure returns (string[] memory) { - bytes memory strBytes = bytes(str); - bytes memory delimBytes = bytes(delimiter); - - uint256 count = 1; - for (uint256 i = 0; i < strBytes.length; i++) { - if (strBytes[i] == delimBytes[0]) { - count++; - } - } - - string[] memory result = new string[](count); - uint256 lastIndex = 0; - uint256 resultIndex = 0; - - for (uint256 i = 0; i <= strBytes.length; i++) { - if (i == strBytes.length || strBytes[i] == delimBytes[0]) { - bytes memory slice = new bytes(i - lastIndex); - for (uint256 j = 0; j < slice.length; j++) { - slice[j] = strBytes[lastIndex + j]; - } - result[resultIndex] = string(slice); - resultIndex++; - lastIndex = i + 1; - } - } - - return result; - } } \ 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 index 355b52e..979e70d 100644 --- a/the-guild-smart-contracts/test/EmitAttestationsCsv.t.sol +++ b/the-guild-smart-contracts/test/EmitAttestationsCsv.t.sol @@ -11,37 +11,37 @@ contract EmitAttestationsCsvTest is Test { script = new EmitAttestationsCsv(); } - function test_ParseAddress() public view { - address expected = 0x742d35Cc6634C0532925a3b844Bc454e4438f44e; - address result = script.parseAddress("0x742d35Cc6634C0532925a3b844Bc454e4438f44e"); - assertEq(result, expected); + 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_ParseBytes32() public view { - bytes32 expected = bytes32(abi.encodePacked("Rust")); - bytes32 result = script.parseBytes32("Rust"); - assertEq(result, expected); - } - - function test_SplitString() public view { - string memory input = "a,b,c"; - string[] memory result = script.split(input, ","); - assertEq(result.length, 3); - assertEq(result[0], "a"); - assertEq(result[1], "b"); - assertEq(result[2], "c"); - } - - function test_ParseCsvToAttestations() public view { - string memory csvData = "address,badgeName,distributionId\n0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Rust,dist-001\n0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Solidity,dist-001"; - - // We can't directly call internal functions from tests, so we'll test the components - // This is a limitation of Solidity testing - internal functions aren't directly testable - - // Test the split function with newlines - string[] memory lines = script.split(csvData, "\n"); - assertEq(lines.length, 3); // header + 2 data lines - assertEq(lines[0], "address,badgeName,distributionId"); - assertEq(lines[1], "0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Rust,dist-001"); + 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 From 4935af5fae29c648c24cfc6b208c004123be5649 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Fri, 28 Nov 2025 17:38:58 +0000 Subject: [PATCH 3/3] fix: address PR feedback for batch attestation script --- the-guild-smart-contracts/.env.example | 3 ++- the-guild-smart-contracts/attestations.csv | 3 --- .../run_batch_attestations.sh | 5 +++++ .../script/EmitAttestationsCsv.s.sol | 21 +++++++++++++++++-- 4 files changed, 26 insertions(+), 6 deletions(-) delete mode 100644 the-guild-smart-contracts/attestations.csv diff --git a/the-guild-smart-contracts/.env.example b/the-guild-smart-contracts/.env.example index 91adcc8..393a5c3 100644 --- a/the-guild-smart-contracts/.env.example +++ b/the-guild-smart-contracts/.env.example @@ -21,4 +21,5 @@ PRIVATE_KEY= # Batch attestation script JSON_PATH=./attestations.json SCHEMA_ID=0xbcd7561083784f9b5a1c2b3ddb7aa9db263d43c58f7374cfa4875646824a47de -DRY_RUN=false \ No newline at end of file +DRY_RUN=false +RPC_URL= \ No newline at end of file diff --git a/the-guild-smart-contracts/attestations.csv b/the-guild-smart-contracts/attestations.csv deleted file mode 100644 index 964d886..0000000 --- a/the-guild-smart-contracts/attestations.csv +++ /dev/null @@ -1,3 +0,0 @@ -0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Rust,dist-001 -0x742d35Cc6634C0532925a3b844Bc454e4438f44e,Solidity,dist-001 -0x742d35Cc6634C0532925a3b844Bc454e4438f44e,TypeScript,dist-001 \ No newline at end of file diff --git a/the-guild-smart-contracts/run_batch_attestations.sh b/the-guild-smart-contracts/run_batch_attestations.sh index 5200842..18615f8 100755 --- a/the-guild-smart-contracts/run_batch_attestations.sh +++ b/the-guild-smart-contracts/run_batch_attestations.sh @@ -5,6 +5,11 @@ 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" diff --git a/the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol b/the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol index 87704c3..ab312d4 100644 --- a/the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol +++ b/the-guild-smart-contracts/script/EmitAttestationsCsv.s.sol @@ -76,10 +76,27 @@ contract EmitAttestationsCsv is Script { 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: bytes32(abi.encodePacked(abi.decode(vm.parseJson(jsonData, string(abi.encodePacked(basePath, ".badgeName"))), (string)))), - justification: abi.decode(vm.parseJson(jsonData, string(abi.encodePacked(basePath, ".justification"))), (bytes)) + badgeName: badgeName, + justification: justification }); count++;