Developer Guide

Getting Started

Installation and Setup

Install the required packages for Self Connect integration:

npm install @celo/identity @celo/abis viem

Required Packages

Package
Purpose

@celo/identity

ODIS integration and identifier utilities

@celo/abis

Contract ABIs for FederatedAttestations

viem

Modern Ethereum library for blockchain interactions

Why Viem?

Self Connect uses Viem as the recommended library for blockchain interactions because:

  • Modern & Type-Safe: Built with TypeScript for excellent type inference

  • Lightweight: Smaller bundle size compared to legacy libraries

  • Modular: Import only what you need

  • Better DX: Cleaner API and better error messages

  • Active Maintenance: Well-maintained with regular updates

  • EIP-1193 Support: Native support for wallet providers

Note: ContractKit is deprecated and no longer recommended for new projects.

Network Configuration

Mainnet:

import { celo } from "viem/chains";

const RPC_URL = "https://forno.celo.org";
const ODIS_CONTEXT = OdisContextName.MAINNET;

Alfajores Testnet:

import { celoAlfajores } from "viem/chains";

const RPC_URL = "https://alfajores-forno.celo-testnet.org";
const ODIS_CONTEXT = OdisContextName.ALFAJORES;

Becoming an Issuer

Issuer Setup

An issuer needs:

  1. A funded wallet account

  2. ODIS quota for identifier obfuscation

  3. Verification infrastructure

  4. Optional: Data Encryption Key (DEK) for enhanced authentication

Step 1: Set Up Issuer Account

import { createWalletClient, http, parseEther } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { celoAlfajores } from "viem/chains";

// Issuer private key - KEEP SECURE
const ISSUER_PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY;
const account = privateKeyToAccount(ISSUER_PRIVATE_KEY);

// Create Viem client
const walletClient = createWalletClient({
  account,
  transport: http(),
  chain: celoAlfajores
});

const issuerAddress = account.address;
console.log("Issuer Address:", issuerAddress);

Step 2: Authentication Methods

Self Connect supports multiple authentication methods for ODIS:

Wallet Key Authentication

import { OdisUtils } from "@celo/identity";
import { AuthSigner } from "@celo/identity/lib/odis/query";

const authSigner: AuthSigner = {
  authenticationMethod: OdisUtils.Query.AuthenticationMethod.WALLET_KEY,
  sign191: ({ message, account }) => walletClient.signMessage({ 
    message, 
    account 
  })
};

Encryption Key (DEK) Authentication

const authSigner: AuthSigner = {
  authenticationMethod: OdisUtils.Query.AuthenticationMethod.ENCRYPTION_KEY,
  rawKey: process.env.DEK_PRIVATE_KEY
};

Step 3: Configure ODIS Service Context

import { OdisContextName } from "@celo/identity/lib/odis/query";

const serviceContext = OdisUtils.Query.getServiceContext(
  OdisContextName.ALFAJORES // or OdisContextName.MAINNET
);

console.log("ODIS Endpoint:", serviceContext.odisUrl);
console.log("ODIS Public Key:", serviceContext.odisPubKey);

ODIS Quota Management

Check Current Quota

const { remainingQuota } = await OdisUtils.Quota.getPnpQuotaStatus(
  issuerAddress,
  authSigner,
  serviceContext
);

console.log("Remaining ODIS Quota:", remainingQuota);

Purchase Quota

import { getContract } from "viem";
import { stableTokenABI, odisPaymentsABI } from "@celo/abis";

// Contract addresses (Alfajores testnet)
const STABLE_TOKEN_ADDRESS = "0x874069Fa1Eb16D44d622F2e0Ca25eeA172369bC1"; // cUSD
const ODIS_PAYMENTS_ADDRESS = "0x645170cdB6B5c1bc80847bb728dBa56C50a20a49";

// Amount to pay (0.01 cUSD = 100 queries)
const ONE_CENT_CUSD = parseEther("0.01");

// Approve ODIS Payments to spend cUSD
const stableToken = getContract({
  address: STABLE_TOKEN_ADDRESS,
  abi: stableTokenABI,
  client: walletClient
});

const approveHash = await stableToken.write.approve([
  ODIS_PAYMENTS_ADDRESS,
  ONE_CENT_CUSD
]);

await walletClient.waitForTransactionReceipt({ hash: approveHash });

// Pay for quota
const odisPayments = getContract({
  address: ODIS_PAYMENTS_ADDRESS,
  abi: odisPaymentsABI,
  client: walletClient
});

const paymentHash = await odisPayments.write.payInCUSD([
  issuerAddress,
  ONE_CENT_CUSD
]);

await walletClient.waitForTransactionReceipt({ hash: paymentHash });

console.log("ODIS quota purchased successfully");

Verification Responsibilities

As an issuer, you must verify user ownership of identifiers. Implementation depends on identifier type:

Phone Number Verification

// Example using Twilio
import twilio from "twilio";

const client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN);

async function verifyPhoneNumber(phoneNumber: string): Promise<boolean> {
  // Send verification code
  await client.verify.v2
    .services(TWILIO_VERIFY_SERVICE_SID)
    .verifications.create({ to: phoneNumber, channel: "sms" });
  
  // User enters code (from your UI)
  const verificationCode = await getUserInput();
  
  // Check verification
  const verification = await client.verify.v2
    .services(TWILIO_VERIFY_SERVICE_SID)
    .verificationChecks.create({ to: phoneNumber, code: verificationCode });
  
  return verification.status === "approved";
}

Twitter Verification

// Example using Twitter OAuth
async function verifyTwitterHandle(handle: string, userAddress: string): Promise<boolean> {
  // Implement OAuth flow
  const oauth = await initiateTwitterOAuth(userAddress);
  
  // User authenticates with Twitter
  const twitterUser = await completeOAuthFlow(oauth);
  
  // Verify handle matches
  return twitterUser.username === handle;
}

Email Verification

// Example verification flow
async function verifyEmail(email: string, userAddress: string): Promise<boolean> {
  // Generate verification token
  const token = generateSecureToken();
  
  // Store token with expiry
  await storeVerificationToken(email, userAddress, token);
  
  // Send verification email
  await sendEmail(email, {
    subject: "Verify your email",
    body: `Click here to verify: ${BASE_URL}/verify/${token}`
  });
  
  // User clicks link, returns true if token is valid
  return await checkTokenVerified(token);
}

SDK Integration with Viem

Complete Registration Example

import { 
  createWalletClient, 
  createPublicClient, 
  http,
  parseEther,
  type Address,
  type Hex 
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { celoAlfajores } from "viem/chains";
import { OdisUtils } from "@celo/identity";
import { OdisContextName } from "@celo/identity/lib/odis/query";
import type { AuthSigner } from "@celo/identity/lib/odis/query";
import { getContract } from "viem";
import { federatedAttestationsABI, odisPaymentsABI, stableTokenABI } from "@celo/abis";

// Configuration
const ISSUER_PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY as Hex;
const FEDERATED_ATTESTATIONS_ADDRESS = "0x70F9314aF173c246669cFb0EEe79F9Cfd9C34ee3" as Address;
const ODIS_PAYMENTS_ADDRESS = "0x645170cdB6B5c1bc80847bb728dBa56C50a20a49" as Address;
const STABLE_TOKEN_ADDRESS = "0x874069Fa1Eb16D44d622F2e0Ca25eeA172369bC1" as Address;

// Setup
const account = privateKeyToAccount(ISSUER_PRIVATE_KEY);
const walletClient = createWalletClient({
  account,
  transport: http(),
  chain: celoAlfajores
});

const publicClient = createPublicClient({
  transport: http(),
  chain: celoAlfajores
});

const issuerAddress = account.address;

// User information (provided by user after verification)
const userPlaintextIdentifier = "+12345678910";
const userAccountAddress = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb" as Address;
const attestationVerifiedTime = BigInt(Math.floor(Date.now() / 1000));

async function registerAttestation() {
  // 1. Setup authentication
  const authSigner: AuthSigner = {
    authenticationMethod: OdisUtils.Query.AuthenticationMethod.WALLET_KEY,
    sign191: ({ message, account }) => 
      walletClient.signMessage({ message, account })
  };

  const serviceContext = OdisUtils.Query.getServiceContext(
    OdisContextName.ALFAJORES
  );

  // 2. Check and top up ODIS quota if needed
  const { remainingQuota } = await OdisUtils.Quota.getPnpQuotaStatus(
    issuerAddress,
    authSigner,
    serviceContext
  );

  console.log("Remaining quota:", remainingQuota);

  if (remainingQuota < 1) {
    console.log("Purchasing ODIS quota...");
    
    // Get contract instances
    const stableToken = getContract({
      address: STABLE_TOKEN_ADDRESS,
      abi: stableTokenABI,
      client: { public: publicClient, wallet: walletClient }
    });
    
    const odisPayments = getContract({
      address: ODIS_PAYMENTS_ADDRESS,
      abi: odisPaymentsABI,
      client: { public: publicClient, wallet: walletClient }
    });
    
    const ONE_CENT_CUSD = parseEther("0.01");
    
    // Approve ODIS Payments to spend cUSD
    const approveHash = await stableToken.write.approve([
      ODIS_PAYMENTS_ADDRESS,
      ONE_CENT_CUSD
    ]);
    await publicClient.waitForTransactionReceipt({ hash: approveHash });
    
    // Pay for quota
    const paymentHash = await odisPayments.write.payInCUSD([
      issuerAddress,
      ONE_CENT_CUSD
    ]);
    await publicClient.waitForTransactionReceipt({ hash: paymentHash });
    
    console.log("ODIS quota purchased successfully");
  }

  // 3. Get obfuscated identifier from ODIS
  console.log("Getting obfuscated identifier...");
  const { obfuscatedIdentifier } = await OdisUtils.Identifier.getObfuscatedIdentifier(
    userPlaintextIdentifier,
    OdisUtils.Identifier.IdentifierPrefix.PHONE_NUMBER,
    issuerAddress,
    authSigner,
    serviceContext
  );

  console.log("Obfuscated Identifier:", obfuscatedIdentifier);

  // 4. Register attestation on-chain
  console.log("Registering attestation...");
  const federatedAttestations = getContract({
    address: FEDERATED_ATTESTATIONS_ADDRESS,
    abi: federatedAttestationsABI,
    client: { public: publicClient, wallet: walletClient }
  });

  const hash = await federatedAttestations.write.registerAttestationAsIssuer([
    obfuscatedIdentifier as Hex,
    userAccountAddress,
    attestationVerifiedTime
  ]);

  const receipt = await publicClient.waitForTransactionReceipt({ hash });
  
  console.log("Attestation registered!");
  console.log("Transaction:", receipt.transactionHash);
  
  return {
    obfuscatedIdentifier,
    transactionHash: receipt.transactionHash
  };
}

// Execute
registerAttestation().catch(console.error);

Complete Lookup Example

import { createPublicClient, http, type Address, type Hex } from "viem";
import { celoAlfajores } from "viem/chains";
import { OdisUtils } from "@celo/identity";
import { OdisContextName } from "@celo/identity/lib/odis/query";
import { getContract } from "viem";
import { federatedAttestationsABI } from "@celo/abis";

const FEDERATED_ATTESTATIONS_ADDRESS = "0x70F9314aF173c246669cFb0EEe79F9Cfd9C34ee3" as Address;

const publicClient = createPublicClient({
  transport: http(),
  chain: celoAlfajores
});

async function lookupIdentifier(
  plaintextIdentifier: string,
  identifierType: string,
  trustedIssuers: Address[]
): Promise<Address[]> {
  // 1. Setup authentication for lookup
  // For read-only operations, use a zero address
  const lookupAddress = "0x0000000000000000000000000000000000000000" as Address;
  
  const authSigner = {
    authenticationMethod: OdisUtils.Query.AuthenticationMethod.WALLET_KEY,
    sign191: async () => "0x" as Hex
  };

  const serviceContext = OdisUtils.Query.getServiceContext(
    OdisContextName.ALFAJORES
  );

  // 2. Get obfuscated identifier
  const { obfuscatedIdentifier } = await OdisUtils.Identifier.getObfuscatedIdentifier(
    plaintextIdentifier,
    identifierType,
    lookupAddress,
    authSigner,
    serviceContext
  );

  console.log("Looking up:", obfuscatedIdentifier);

  // 3. Query FederatedAttestations
  const federatedAttestations = getContract({
    address: FEDERATED_ATTESTATIONS_ADDRESS,
    abi: federatedAttestationsABI,
    client: publicClient
  });

  const attestations = await federatedAttestations.read.lookupAttestations([
    obfuscatedIdentifier as Hex,
    trustedIssuers
  ]);

  const [countsPerIssuer, accounts, signers, issuedOns, publishedOns] = attestations;

  // 4. Process results
  console.log("Found attestations:");
  
  let accountIndex = 0;
  for (let i = 0; i < trustedIssuers.length; i++) {
    const count = Number(countsPerIssuer[i]);
    console.log(`\nIssuer: ${trustedIssuers[i]}`);
    console.log(`Attestation count: ${count}`);
    
    for (let j = 0; j < count; j++) {
      console.log(`  Account: ${accounts[accountIndex]}`);
      console.log(`  Signer: ${signers[accountIndex]}`);
      console.log(`  Issued: ${new Date(Number(issuedOns[accountIndex]) * 1000).toISOString()}`);
      console.log(`  Published: ${new Date(Number(publishedOns[accountIndex]) * 1000).toISOString()}`);
      accountIndex++;
    }
  }

  return accounts as Address[];
}

// Example usage
const trustedIssuers: Address[] = [
  "0x6549aF2688e07907C1b821cA44d6d65872737f05", // Kaala
  "0x388612590F8cC6577F19c9b61811475Aa432CB44"  // Libera
];

lookupIdentifier(
  "+12345678910",
  OdisUtils.Identifier.IdentifierPrefix.PHONE_NUMBER,
  trustedIssuers
).catch(console.error);

Viem Best Practices

Use Type-Safe Contract Interactions

import { getContract, type Address } from "viem";
import { federatedAttestationsABI } from "@celo/abis";

// Type-safe contract instance
const contract = getContract({
  address: FEDERATED_ATTESTATIONS_ADDRESS,
  abi: federatedAttestationsABI,
  client: { public: publicClient, wallet: walletClient }
});

// TypeScript knows the exact function signatures
const hash = await contract.write.registerAttestationAsIssuer([
  obfuscatedIdentifier as `0x${string}`,
  userAddress as `0x${string}`,
  timestamp
]);

Handle Hex Types Properly

Viem uses strict Hex types for type safety:

import type { Hex, Address } from "viem";

// Correct
const privateKey: Hex = process.env.PRIVATE_KEY as Hex;
const address: Address = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb" as Address;

// Type assertion for obfuscated identifiers
const obfuscatedIdentifier: Hex = result.obfuscatedIdentifier as Hex;

Use Proper Error Handling

import { 
  ContractFunctionExecutionError,
  TransactionExecutionError 
} from "viem";

try {
  const hash = await contract.write.registerAttestationAsIssuer([...]);
} catch (error) {
  if (error instanceof ContractFunctionExecutionError) {
    console.error("Contract error:", error.shortMessage);
  } else if (error instanceof TransactionExecutionError) {
    console.error("Transaction failed:", error.message);
  } else {
    console.error("Unknown error:", error);
  }
}

Optimize with Public Actions

For read-only operations, use public client directly:

import { createPublicClient, http } from "viem";
import { celo } from "viem/chains";

const publicClient = createPublicClient({
  chain: celo,
  transport: http()
});

// No wallet needed for reads
const attestations = await publicClient.readContract({
  address: FEDERATED_ATTESTATIONS_ADDRESS,
  abi: federatedAttestationsABI,
  functionName: "lookupAttestations",
  args: [obfuscatedIdentifier, trustedIssuers]
});

Web/Browser

Browser environments require careful handling of wallet connections:

// Check for wallet
if (window.ethereum) {
  const accounts = await window.ethereum.request({
    method: "eth_requestAccounts"
  });
  
  // Create client with injected provider
  const walletClient = createWalletClient({
    account: accounts[0],
    transport: custom(window.ethereum),
    chain: celoAlfajores
  });
}

Next.js Example:

// pages/api/register-attestation.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { OdisUtils } from "@celo/identity";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { phoneNumber, userAddress } = req.body;

  try {
    // Verify phone number (your verification logic)
    const isVerified = await verifyPhoneNumber(phoneNumber);
    
    if (!isVerified) {
      return res.status(400).json({ error: "Verification failed" });
    }

    // Register attestation
    const result = await registerAttestation(phoneNumber, userAddress);
    
    res.status(200).json(result);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
}

Custom Identifier Types

Create custom identifier types for your use case:

// Define custom prefix
const CUSTOM_PREFIX = "custom-app";

async function registerCustomIdentifier(
  customId: string,
  userAddress: string
) {
  const { obfuscatedIdentifier } = await OdisUtils.Identifier.getObfuscatedIdentifier(
    customId,
    CUSTOM_PREFIX, // Custom prefix
    issuerAddress,
    authSigner,
    serviceContext
  );

  // Register as usual
  await registerAttestation(obfuscatedIdentifier, userAddress);
}

Best Practices for Custom Identifiers:

  • Use descriptive prefixes (e.g., myapp:// not ma://)

  • Document your prefix for ecosystem adoption

  • Consider standardization if widely applicable

  • Ensure identifiers are unique and verifiable

Reference & Resources

Contract Addresses

Mainnet (Celo)

Contract
Address

FederatedAttestations

0x0aD5b1d0C25ecF6266Dd951403723B2687d6aff2

OdisPayments

0x645170cdB6B5c1bc80847bb728dBa56C50a20a49

StableToken (cUSD)

0x765DE816845861e75A25fCA122bb6898B8B1282a

Alfajores Testnet

Contract
Address

FederatedAttestations

0x70F9314aF173c246669cFb0EEe79F9Cfd9C34ee3

OdisPayments

0x645170cdB6B5c1bc80847bb728dBa56C50a20a49

StableToken (cUSD)

0x874069Fa1Eb16D44d622F2e0Ca25eeA172369bC1

Example Repositories

Example
Description
Link

Phone Number (Next.js)

Web app demonstrating phone verification with Viem

Template

Next.js template for Self Connect

React Native

Mobile app demo

Twitter (Client-side)

Twitter handle verification

Twitter (Server-side)

Server-side Twitter verification

MiniPay Nexus

MiniPay integration template

Troubleshooting

ODIS Quota Issues

Problem: "Insufficient quota" error

Solution:

// Check quota
const { remainingQuota } = await OdisUtils.Quota.getPnpQuotaStatus(
  issuerAddress,
  authSigner,
  serviceContext
);

console.log("Remaining:", remainingQuota);

// Purchase more if needed (10 cUSD = 10,000 queries)

Rate Limiting

Problem: Too many requests to ODIS

Solution:

  • Implement request queuing

  • Cache obfuscated identifiers

  • Use batch operations when possible

  • Monitor quota usage

Transaction Failures

Problem: "Transaction reverted" or gas estimation failed

Solution:

// Ensure sufficient gas
const hash = await contract.write.registerAttestationAsIssuer(
  [identifier, address, timestamp],
  {
    gas: 200000n // Explicit gas limit
  }
);

// Check if attestation already exists
const existing = await contract.read.lookupAttestations([
  identifier,
  [issuerAddress]
]);

if (existing.accounts.length > 0) {
  console.log("Attestation already exists");
}

Network Issues

Problem: RPC connection failures

Solution:

// Use fallback RPCs
const transport = fallback([
  http("https://forno.celo.org"),
  http("https://rpc.ankr.com/celo"),
  http("https://1rpc.io/celo")
]);

const client = createPublicClient({
  chain: celo,
  transport
});

FAQ

Q: Do I need to pay gas for lookups?

A: No, lookups are read-only operations that don't require gas. You only need ODIS quota.

Q: Can users register themselves?

A: Users can submit the registration transaction if the issuer provides a signed attestation, but the issuer must still verify ownership and provide the obfuscated identifier.

Q: How much does it cost to register a user?

A: With 10 cUSD of ODIS quota, you can register 10,000 users. Gas costs for on-chain registration are typically <0.01 cUSD per transaction.

Q: Can I trust attestations from any issuer?

A: No, you should only trust issuers whose verification standards you trust. Each issuer is responsible for their own verification quality.

Q: What happens if an issuer goes offline?

A: Existing attestations remain on-chain and accessible. Users can register with other issuers for redundancy.

Q: Can I verify multiple identifier types for the same user?

A: Yes, you can register multiple attestations (phone + Twitter + email) for the same address, each with its own prefix.

Q: Is E.164 format required for phone numbers?

A: Yes, the SDK's getObfuscatedIdentifier function only accepts E.164 formatted phone numbers (e.g., +12345678901).

Q: How do I map attestation results to issuers?

A: The lookupAttestations return arrays are ordered by the trustedIssuers input array. Use countsPerIssuer to determine how many attestations belong to each issuer.

Q: Can I use Self Connect on other EVM chains?

A: Self Connect is currently designed for Celo. The contracts and ODIS infrastructure are Celo-specific.

Q: Why use Viem instead of Web3.js or Ethers.js?

A: Viem offers better TypeScript support, smaller bundle size, modular architecture, and is actively maintained. It's the recommended library for modern Web3 development on Celo.

Q: Do I need to use different RPC endpoints for mainnet vs testnet?

A: Yes, Viem's chain configurations handle this automatically:

  • Mainnet: Use celo chain from viem/chains

  • Testnet: Use celoAlfajores chain from viem/chains

Last updated