Developer Guide
Getting Started
Installation and Setup
Install the required packages for Self Connect integration:
npm install @celo/identity @celo/abis viemRequired Packages
@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:
A funded wallet account
ODIS quota for identifier obfuscation
Verification infrastructure
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://notma://)Document your prefix for ecosystem adoption
Consider standardization if widely applicable
Ensure identifiers are unique and verifiable
Reference & Resources
Contract Addresses
Mainnet (Celo)
FederatedAttestations
0x0aD5b1d0C25ecF6266Dd951403723B2687d6aff2
OdisPayments
0x645170cdB6B5c1bc80847bb728dBa56C50a20a49
StableToken (cUSD)
0x765DE816845861e75A25fCA122bb6898B8B1282a
Alfajores Testnet
FederatedAttestations
0x70F9314aF173c246669cFb0EEe79F9Cfd9C34ee3
OdisPayments
0x645170cdB6B5c1bc80847bb728dBa56C50a20a49
StableToken (cUSD)
0x874069Fa1Eb16D44d622F2e0Ca25eeA172369bC1
Example Repositories
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
celochain fromviem/chainsTestnet: Use
celoAlfajoreschain fromviem/chains
Last updated