Skip to content

Start typing to search the documentation.

Developer Guide

Getting Started

Installation and Setup

Install the required packages for Self Connect integration:

npm install @celo/identity @celo/abis viem

Required Packages

PackagePurpose
@celo/identityODIS integration and identifier utilities
@celo/abisContract ABIs for FederatedAttestations
viemModern 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)

ContractAddress
FederatedAttestations0x0aD5b1d0C25ecF6266Dd951403723B2687d6aff2
OdisPayments0x645170cdB6B5c1bc80847bb728dBa56C50a20a49
StableToken (cUSD)0x765DE816845861e75A25fCA122bb6898B8B1282a

Alfajores Testnet

ContractAddress
FederatedAttestations0x70F9314aF173c246669cFb0EEe79F9Cfd9C34ee3
OdisPayments0x645170cdB6B5c1bc80847bb728dBa56C50a20a49
StableToken (cUSD)0x874069Fa1Eb16D44d622F2e0Ca25eeA172369bC1

Example Repositories

ExampleDescriptionLink
Phone Number (Next.js)Web app demonstrating phone verification with Viememisianto
TemplateNext.js template for Self Connectsocialconnect-template
React NativeMobile app demoSelfConnect-ReactNative-Demo
Twitter (Client-side)Twitter handle verificationSelfConnect-Twitter
Twitter (Server-side)Server-side Twitter verificationSelfConnect-Twitter-Server-Side
MiniPay NexusMiniPay integration templatenexus

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