Skip to main content

Prerequisites

Before integrating Curate V2, ensure you have:
  • Node.js 18+ and npm/yarn
  • React project with TypeScript (recommended)
  • Familiarity with ethers.js v6

Setup

Install Dependencies

npm install ethers@6 @tanstack/react-query

Environment Configuration

Create a .env file:
# RPC Endpoints
NEXT_PUBLIC_RPC_ARBITRUM="https://arb1.arbitrum.io/rpc"
NEXT_PUBLIC_RPC_ARBITRUM_SEPOLIA="https://sepolia-rollup.arbitrum.io/rpc"

# Optional: For contract deployment
PRIVATE_KEY="your_private_key"
ARBISCAN_API_KEY="your_api_key"

Web3 Context Setup

Create Web3 Provider

// context/Web3Provider.tsx
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { BrowserProvider, JsonRpcSigner } from 'ethers';

interface Web3ContextType {
  provider: BrowserProvider | null;
  signer: JsonRpcSigner | null;
  address: string | null;
  chainId: number | null;
  connect: () => Promise<void>;
  disconnect: () => void;
}

const Web3Context = createContext<Web3ContextType | undefined>(undefined);

export const Web3Provider = ({ children }: { children: ReactNode }) => {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
  const [address, setAddress] = useState<string | null>(null);
  const [chainId, setChainId] = useState<number | null>(null);

  const connect = useCallback(async () => {
    if (!window.ethereum) throw new Error("No wallet found");
    
    const web3Provider = new BrowserProvider(window.ethereum);
    const web3Signer = await web3Provider.getSigner();
    const userAddress = await web3Signer.getAddress();
    const network = await web3Provider.getNetwork();

    setProvider(web3Provider);
    setSigner(web3Signer);
    setAddress(userAddress);
    setChainId(Number(network.chainId));
  }, []);

  const disconnect = useCallback(() => {
    setProvider(null);
    setSigner(null);
    setAddress(null);
    setChainId(null);
  }, []);

  return (
    <Web3Context.Provider value={{ provider, signer, address, chainId, connect, disconnect }}>
      {children}
    </Web3Context.Provider>
  );
};

export const useWeb3 = () => {
  const context = useContext(Web3Context);
  if (!context) throw new Error('useWeb3 must be used within Web3Provider');
  return context;
};

Contract Hooks

Generic Contract Hook

// hooks/useContract.ts
import { Contract } from 'ethers';
import { useMemo } from 'react';
import { useWeb3 } from '../context/Web3Provider';

export const useContract = <T extends Contract>(
  address: string | undefined,
  abi: any
): T | null => {
  const { signer, provider } = useWeb3();

  return useMemo(() => {
    if (!address) return null;
    const signerOrProvider = signer || provider;
    return signerOrProvider 
      ? new Contract(address, abi, signerOrProvider) as T 
      : null;
  }, [address, abi, signer, provider]);
};

Registry Hook

// hooks/useRegistry.ts
import { useQuery } from '@tanstack/react-query';
import { useContract } from './useContract';
import CurateABI from '../abis/CurateV2.json';
import CurateViewABI from '../abis/CurateView.json';
import { formatEther } from 'ethers';

interface RegistryConfig {
  governor: string;
  relayer: string;
  submissionDeposit: bigint;
  removalDeposit: bigint;
  submissionChallengeDeposit: bigint;
  removalChallengeDeposit: bigint;
  challengePeriod: bigint;
  arbitrationCost: bigint;
}

export const useRegistry = (curateAddress: string, viewAddress: string) => {
  const view = useContract(viewAddress, CurateViewABI);

  return useQuery({
    queryKey: ['registry', curateAddress],
    queryFn: async (): Promise<RegistryConfig> => {
      if (!view) throw new Error('View contract not ready');
      
      const config = await view.fetchArbitrable(curateAddress);
      
      return {
        governor: config.governor,
        relayer: config.relayerContract,
        submissionDeposit: config.submissionBaseDeposit,
        removalDeposit: config.removalBaseDeposit,
        submissionChallengeDeposit: config.submissionChallengeBaseDeposit,
        removalChallengeDeposit: config.removalChallengeBaseDeposit,
        challengePeriod: config.challengePeriodDuration,
        arbitrationCost: config.arbitrationCost,
      };
    },
    enabled: !!view,
    staleTime: 60000, // Cache for 1 minute
  });
};

Item Hook

// hooks/useItem.ts
import { useQuery } from '@tanstack/react-query';
import { useContract } from './useContract';
import CurateViewABI from '../abis/CurateView.json';

enum Status {
  Absent = 0,
  Registered = 1,
  RegistrationRequested = 2,
  ClearingRequested = 3
}

interface ItemData {
  status: Status;
  statusName: string;
  disputed: boolean;
  sumDeposit: bigint;
  requester: string;
}

export const useItem = (curateAddress: string, viewAddress: string, itemId: string) => {
  const view = useContract(viewAddress, CurateViewABI);

  return useQuery({
    queryKey: ['item', curateAddress, itemId],
    queryFn: async (): Promise<ItemData> => {
      if (!view) throw new Error('View contract not ready');
      
      const item = await view.getItem(curateAddress, itemId);
      const statusNames = ['Absent', 'Registered', 'RegistrationRequested', 'ClearingRequested'];
      
      return {
        status: item.status,
        statusName: statusNames[item.status],
        disputed: item.disputed,
        sumDeposit: item.sumDeposit,
        requester: item.requester,
      };
    },
    enabled: !!view && !!itemId,
    refetchInterval: 30000, // Refresh every 30 seconds
  });
};

Submission Hook

Submit Item

// hooks/useSubmitItem.ts
import { useState } from 'react';
import { keccak256, toUtf8Bytes, parseEther } from 'ethers';
import { useContract } from './useContract';
import { useRegistry } from './useRegistry';
import CurateABI from '../abis/CurateV2.json';

interface SubmitItemParams {
  data: Record<string, any>;
}

export const useSubmitItem = (curateAddress: string, viewAddress: string) => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  
  const curate = useContract(curateAddress, CurateABI);
  const { data: registry } = useRegistry(curateAddress, viewAddress);

  const submitItem = async ({ data }: SubmitItemParams) => {
    if (!curate || !registry) throw new Error('Contracts not ready');
    
    setIsLoading(true);
    setError(null);
    
    try {
      const itemString = JSON.stringify(data);
      const itemId = keccak256(toUtf8Bytes(itemString));
      
      // Calculate total cost
      const totalCost = registry.submissionDeposit + registry.arbitrationCost;
      
      // Submit item
      const tx = await curate.addItem(itemString, { value: totalCost });
      const receipt = await tx.wait();
      
      return {
        itemId,
        transactionHash: receipt.hash,
      };
    } catch (err) {
      const error = err instanceof Error ? err : new Error('Submission failed');
      setError(error);
      throw error;
    } finally {
      setIsLoading(false);
    }
  };

  return { submitItem, isLoading, error };
};

Challenge Hook

Challenge Item

// hooks/useChallengeItem.ts
import { useState } from 'react';
import { useContract } from './useContract';
import { useItem } from './useItem';
import { useRegistry } from './useRegistry';
import CurateABI from '../abis/CurateV2.json';

interface ChallengeParams {
  itemId: string;
  evidence: {
    name: string;
    description: string;
    supportingInfo?: string;
  };
}

export const useChallengeItem = (curateAddress: string, viewAddress: string) => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  
  const curate = useContract(curateAddress, CurateABI);
  const { data: registry } = useRegistry(curateAddress, viewAddress);

  const challengeItem = async ({ itemId, evidence }: ChallengeParams) => {
    if (!curate || !registry) throw new Error('Contracts not ready');
    
    setIsLoading(true);
    setError(null);
    
    try {
      // Get item to determine challenge deposit type
      const itemInfo = await curate.getItemInfo(itemId);
      const status = itemInfo[0];
      
      // Status 2 = RegistrationRequested, Status 3 = ClearingRequested
      const challengeDeposit = status === 2
        ? registry.submissionChallengeDeposit
        : registry.removalChallengeDeposit;
      
      const totalCost = challengeDeposit + registry.arbitrationCost;
      const evidenceString = JSON.stringify(evidence);
      
      const tx = await curate.challengeRequest(itemId, evidenceString, { 
        value: totalCost 
      });
      const receipt = await tx.wait();
      
      return {
        transactionHash: receipt.hash,
      };
    } catch (err) {
      const error = err instanceof Error ? err : new Error('Challenge failed');
      setError(error);
      throw error;
    } finally {
      setIsLoading(false);
    }
  };

  return { challengeItem, isLoading, error };
};

Execute Request Hook

Execute Unchallenged Request

// hooks/useExecuteRequest.ts
import { useState } from 'react';
import { useContract } from './useContract';
import { useRegistry } from './useRegistry';
import CurateABI from '../abis/CurateV2.json';

export const useExecuteRequest = (curateAddress: string, viewAddress: string) => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  
  const curate = useContract(curateAddress, CurateABI);
  const { data: registry } = useRegistry(curateAddress, viewAddress);

  const canExecute = async (itemId: string): Promise<boolean> => {
    if (!curate || !registry) return false;
    
    try {
      const [status, requestCount] = await curate.getItemInfo(itemId);
      
      // Must be in a pending state
      if (status !== 2 && status !== 3) return false;
      
      const requestInfo = await curate.getRequestInfo(itemId, requestCount - 1);
      const now = Math.floor(Date.now() / 1000);
      
      return now > Number(requestInfo.submissionTime) + Number(registry.challengePeriod);
    } catch {
      return false;
    }
  };

  const executeRequest = async (itemId: string) => {
    if (!curate) throw new Error('Contract not ready');
    
    const executable = await canExecute(itemId);
    if (!executable) throw new Error('Request cannot be executed yet');
    
    setIsLoading(true);
    setError(null);
    
    try {
      const tx = await curate.executeRequest(itemId);
      const receipt = await tx.wait();
      
      return {
        transactionHash: receipt.hash,
      };
    } catch (err) {
      const error = err instanceof Error ? err : new Error('Execution failed');
      setError(error);
      throw error;
    } finally {
      setIsLoading(false);
    }
  };

  return { executeRequest, canExecute, isLoading, error };
};

Event Monitoring

Event Listeners

// hooks/useEventListeners.ts
import { useEffect } from 'react';
import { useContract } from './useContract';
import CurateABI from '../abis/CurateV2.json';

interface EventCallbacks {
  onNewItem?: (itemId: string, data: string, addedDirectly: boolean) => void;
  onStatusChange?: (itemId: string, updatedDirectly: boolean) => void;
  onDispute?: (disputeId: bigint, requestId: bigint) => void;
  onRuling?: (disputeId: bigint, ruling: number) => void;
}

export const useEventListeners = (curateAddress: string, callbacks: EventCallbacks) => {
  const curate = useContract(curateAddress, CurateABI);

  useEffect(() => {
    if (!curate) return;

    // Listen for new items
    if (callbacks.onNewItem) {
      curate.on("NewItem", (itemID, data, addedDirectly) => {
        callbacks.onNewItem!(itemID, data, addedDirectly);
      });
    }

    // Listen for status changes
    if (callbacks.onStatusChange) {
      curate.on("ItemStatusChange", (itemID, updatedDirectly) => {
        callbacks.onStatusChange!(itemID, updatedDirectly);
      });
    }

    // Listen for disputes
    if (callbacks.onDispute) {
      curate.on("DisputeRequest", (arbitrator, disputeID, requestID, templateId) => {
        callbacks.onDispute!(disputeID, requestID);
      });
    }

    // Listen for rulings
    if (callbacks.onRuling) {
      curate.on("Ruling", (arbitrator, disputeID, ruling) => {
        callbacks.onRuling!(disputeID, Number(ruling));
      });
    }

    // Cleanup listeners on unmount
    return () => {
      curate.removeAllListeners();
    };
  }, [curate, callbacks]);
};

Usage Example

// components/RegistryMonitor.tsx
import { useEventListeners } from '../hooks/useEventListeners';

export function RegistryMonitor({ curateAddress }: { curateAddress: string }) {
  useEventListeners(curateAddress, {
    onNewItem: (itemId, data, addedDirectly) => {
      console.log('New item:', {
        itemId,
        data: JSON.parse(data),
        addedDirectly
      });
    },
    onStatusChange: (itemId, updatedDirectly) => {
      console.log(`Item ${itemId} status changed`);
    },
    onDispute: (disputeId, requestId) => {
      console.log('Dispute created:', {
        disputeId: disputeId.toString(),
        requestId: requestId.toString()
      });
    },
    onRuling: (disputeId, ruling) => {
      const outcomes = ['None', 'Requester', 'Challenger'];
      console.log(`Dispute ${disputeId} ruled: ${outcomes[ruling]}`);
    },
  });

  return <div>Monitoring registry events...</div>;
}

Complete Integration Example

// pages/CuratePage.tsx
import { useState } from 'react';
import { Web3Provider, useWeb3 } from '../context/Web3Provider';
import { useRegistry, useItem, useSubmitItem, useChallengeItem } from '../hooks';
import { formatEther } from 'ethers';

const CURATE_ADDRESS = '0x...'; // Your registry address
const VIEW_ADDRESS = '0x...';   // CurateView address

function CurateContent() {
  const { address, connect } = useWeb3();
  const [itemId, setItemId] = useState<string>('');
  
  const { data: registry, isLoading: registryLoading } = useRegistry(CURATE_ADDRESS, VIEW_ADDRESS);
  const { data: item, isLoading: itemLoading } = useItem(CURATE_ADDRESS, VIEW_ADDRESS, itemId);
  const { submitItem, isLoading: submitting } = useSubmitItem(CURATE_ADDRESS, VIEW_ADDRESS);
  const { challengeItem, isLoading: challenging } = useChallengeItem(CURATE_ADDRESS, VIEW_ADDRESS);

  if (!address) {
    return (
      <button onClick={connect}>Connect Wallet</button>
    );
  }

  return (
    <div>
      <h1>Curate V2 Integration</h1>
      
      {/* Registry Info */}
      {registry && (
        <section>
          <h2>Registry Configuration</h2>
          <p>Submission Deposit: {formatEther(registry.submissionDeposit)} ETH</p>
          <p>Challenge Period: {Number(registry.challengePeriod) / 3600} hours</p>
        </section>
      )}
      
      {/* Submit Item */}
      <section>
        <h2>Submit New Item</h2>
        <button
          onClick={async () => {
            const result = await submitItem({
              data: {
                name: "Example Token",
                address: "0x...",
                symbol: "EXT"
              }
            });
            console.log('Submitted:', result.itemId);
            setItemId(result.itemId);
          }}
          disabled={submitting}
        >
          {submitting ? 'Submitting...' : 'Submit Item'}
        </button>
      </section>
      
      {/* Item Details */}
      {item && (
        <section>
          <h2>Item Details</h2>
          <p>Status: {item.statusName}</p>
          <p>Disputed: {item.disputed ? 'Yes' : 'No'}</p>
          
          {item.status === 2 && !item.disputed && (
            <button
              onClick={async () => {
                await challengeItem({
                  itemId,
                  evidence: {
                    name: "Invalid Submission",
                    description: "This item violates the policy."
                  }
                });
              }}
              disabled={challenging}
            >
              {challenging ? 'Challenging...' : 'Challenge Item'}
            </button>
          )}
        </section>
      )}
    </div>
  );
}

export default function CuratePage() {
  return (
    <Web3Provider>
      <CurateContent />
    </Web3Provider>
  );
}

Deployment

Deploy to Testnet

npx hardhat deploy --network arbitrumSepolia

Deploy to Mainnet

npx hardhat deploy --network arbitrum

Verify Contracts

npx hardhat verify --network arbitrum DEPLOYED_ADDRESS

Resources