Skip to main content

L{CORE} dApp Integration Guide

How to integrate your dApp with L{CORE} to access user attestation data.

Prerequisites: Your dApp must be registered with the Attestor service and have a valid API key.


Table of Contents

  1. Overview
  2. Authentication
  3. User Grant Flow
  4. Querying Attestations
  5. Verifying Decryption Proofs
  6. Aggregate Queries
  7. SDK Usage
  8. Error Handling
  9. Best Practices
  10. Example Implementations

Overview

L{CORE} is a privacy-preserving attestation data layer. As a dApp developer, you can:

  1. Request access to user attestation data (with user consent)
  2. Query attestations you've been granted access to
  3. Access aggregates for anonymous statistical insights

Architecture

dApp Integration Architecture
YOUR DAPP
Frontend
React, Vue, etc
Backend
Server
Smart Contracts
On-chain
API Key Authentication
ATTESTOR SERVICE
  • Validates API keys
  • Verifies grants
  • Decrypts L{CORE} data
  • Returns plaintext to authorized dApps
Encrypted communication
L{CORE}
  • Stores attestation data (encrypted)
  • Manages access grants
  • Provides aggregate statistics

Authentication

API Key Setup

  1. Register your dApp with the Attestor admin
  2. Receive an API key (format: dapp_key_xxxxxxxx)
  3. Store securely - treat like a password

Using API Keys

Include your API key in the X-API-Key header:

curl -X GET https://attestor.example.com/api/lcore/dapp/grants \
-H "X-API-Key: dapp_key_your_api_key_here"

TypeScript Example

const ATTESTOR_URL = 'https://attestor.example.com';
const API_KEY = process.env.LCORE_API_KEY;

async function lcoreRequest(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`${ATTESTOR_URL}${endpoint}`, {
...options,
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
...options.headers,
},
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}

return response.json();
}

User Grant Flow

Before you can access a user's attestation data, they must grant you permission.

Flow Overview

┌──────────┐     ┌──────────┐     ┌──────────┐     ┌──────────┐
│ User │ │ Your │ │ Attestor │ │ L\{CORE\} │
│ │ │ dApp │ │ │ │ │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ 1. "Connect │ │ │
│ attestations" │ │ │
│───────────────►│ │ │
│ │ │ │
│ 2. Request │ │ │
│ signature │ │ │
│◄───────────────│ │ │
│ │ │ │
│ 3. Sign │ │ │
│ grant message │ │ │
│───────────────►│ │ │
│ │ │ │
│ │ 4. Submit │ │
│ │ signed grant │ │
│ │───────────────►│ │
│ │ │ │
│ │ │ 5. Verify & │
│ │ │ store grant │
│ │ │───────────────►│
│ │ │ │
│ │ 6. Grant ID │ │
│ │◄───────────────│ │
│ │ │ │
│ 7. Access │ │ │
│ granted │ │ │
│◄───────────────│ │ │
│ │ │ │

Step 1: Get User's Attestations

First, show the user what attestations they have available:

interface UserAttestation {
id: string;
provider: string;
flowType: string;
status: 'active' | 'revoked' | 'expired';
createdAt: string;
validUntil: string;
}

async function getUserAttestations(
walletAddress: string,
signature: string,
message: string
): Promise<UserAttestation[]> {
const response = await fetch(`${ATTESTOR_URL}/api/lcore/user/attestations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Wallet-Address': walletAddress,
'X-Signature': signature,
'X-Message': message,
},
});

const data = await response.json();
return data.attestations;
}

Step 2: Create Grant Message

Generate a message for the user to sign:

interface GrantRequest {
attestationId: string;
granteeAddress: string; // Your dApp's registered address
grantType: 'full' | 'partial' | 'aggregate';
dataKeys?: string[]; // For partial grants
expiresInDays: number;
}

function createGrantMessage(request: GrantRequest): string {
const timestamp = Date.now();
const nonce = crypto.randomUUID();

return JSON.stringify({
action: 'grant_access',
attestation_id: request.attestationId,
grantee: request.granteeAddress,
grant_type: request.grantType,
data_keys: request.dataKeys || null,
expires_in_days: request.expiresInDays,
timestamp,
nonce,
});
}

Step 3: Request User Signature

// Using ethers.js
import { BrowserProvider } from 'ethers';

async function requestGrantSignature(message: string): Promise<{
signature: string;
address: string;
}> {
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const address = await signer.getAddress();

const signature = await signer.signMessage(message);

return { signature, address };
}

Step 4: Submit Grant

interface GrantResponse {
grantId: string;
attestationId: string;
expiresAt: string;
}

async function submitGrant(
walletAddress: string,
signature: string,
message: string,
request: GrantRequest
): Promise<GrantResponse> {
const response = await fetch(`${ATTESTOR_URL}/api/lcore/user/grants`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Wallet-Address': walletAddress,
'X-Signature': signature,
'X-Message': message,
},
body: JSON.stringify(request),
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.message);
}

return response.json();
}

Querying Attestations

Once you have a grant, you can query the attestation data.

Get Single Attestation

interface AttestationData {
id: string;
ownerAddress: string;
provider: string;
flowType: string;
domain: string;
validFrom: string;
validUntil: string;
freshnessScore: number;
buckets: Record<string, string>; // e.g., { balance: "25k-50k" }
data?: {
parameters?: string;
context?: string;
};
}

async function getAttestation(attestationId: string): Promise<AttestationData> {
return lcoreRequest(`/api/lcore/dapp/attestation/${attestationId}`);
}

Get All Your Grants

interface Grant {
grantId: string;
attestationId: string;
ownerAddress: string;
grantType: 'full' | 'partial' | 'aggregate';
dataKeys: string[] | null;
expiresAt: string;
status: 'active' | 'expired' | 'revoked';
}

async function getMyGrants(): Promise<Grant[]> {
const response = await lcoreRequest('/api/lcore/dapp/grants');
return response.grants;
}

Batch Query Attestations

async function getMultipleAttestations(
attestationIds: string[]
): Promise<AttestationData[]> {
const response = await lcoreRequest('/api/lcore/dapp/attestations/batch', {
method: 'POST',
body: JSON.stringify({ attestation_ids: attestationIds }),
});
return response.attestations;
}

Verifying Decryption Proofs

When the Attestor returns data from L{CORE}, it includes a decryption proof if the data was encrypted. This proof allows your dApp to cryptographically verify that:

  1. The data was decrypted by a trusted TEE (Trusted Execution Environment)
  2. The ciphertext hash matches what was received from L{CORE}
  3. The plaintext hash matches the returned data

Response Format with Proof

interface LCoreQueryResult<T> {
data: T;
wasEncrypted: boolean;
proof?: DecryptionProof;
}

interface DecryptionProof {
/** SHA256 hash of the encrypted payload (hex) */
ciphertextHash: string;
/** SHA256 hash of the decrypted plaintext JSON (hex) */
plaintextHash: string;
/** Unix timestamp when decryption occurred */
timestamp: number;
/** TEE/Attestor wallet address that performed decryption */
teeAddress: string;
/** ECDSA signature over keccak256(ciphertextHash, plaintextHash, timestamp) */
signature: string;
}

Why Verify Proofs?

Without proof verification, you're trusting the Attestor service blindly. By verifying the decryption proof, you can:

  • Ensure data integrity: The plaintext wasn't modified after decryption
  • Verify TEE authenticity: The decryption was performed by a known, trusted TEE
  • Audit trail: Keep records of who decrypted what and when

Verifying Proofs (TypeScript)

import { utils } from 'ethers';

interface DecryptionProof {
ciphertextHash: string;
plaintextHash: string;
timestamp: number;
teeAddress: string;
signature: string;
}

/**
* Verify a decryption proof from the Attestor.
*
* @param proof - The decryption proof to verify
* @param trustedTeeAddresses - List of TEE addresses you trust
* @returns true if proof is valid and from a trusted TEE
*/
function verifyDecryptionProof(
proof: DecryptionProof,
trustedTeeAddresses: string[]
): boolean {
try {
// Recreate the message hash that was signed
const messageHash = utils.keccak256(
utils.solidityPack(
['bytes32', 'bytes32', 'uint256'],
[proof.ciphertextHash, proof.plaintextHash, proof.timestamp]
)
);

// Recover the signer's address from the signature
const recoveredAddress = utils.verifyMessage(
utils.arrayify(messageHash),
proof.signature
);

// Check if recovered address matches the claimed TEE address
if (recoveredAddress.toLowerCase() !== proof.teeAddress.toLowerCase()) {
console.error('Signature does not match claimed TEE address');
return false;
}

// Check if the TEE is in our trusted list
const isTrusted = trustedTeeAddresses.some(
addr => addr.toLowerCase() === proof.teeAddress.toLowerCase()
);

if (!isTrusted) {
console.error('TEE address not in trusted list:', proof.teeAddress);
return false;
}

// Optionally check timestamp is recent (e.g., within last hour)
const oneHourAgo = Math.floor(Date.now() / 1000) - 3600;
if (proof.timestamp < oneHourAgo) {
console.warn('Decryption proof is older than 1 hour');
// Depending on your use case, you may want to reject old proofs
}

return true;
} catch (error) {
console.error('Proof verification failed:', error);
return false;
}
}

Full Example with Proof Verification

import { LCoreClient } from '@citychain/lcore-sdk';

const lcore = new LCoreClient({
attestorUrl: process.env.ATTESTOR_URL,
apiKey: process.env.LCORE_API_KEY,
});

// Trusted TEE addresses (get from Attestor registry)
const TRUSTED_TEES = [
'0x1234567890abcdef1234567890abcdef12345678',
'0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
];

async function getVerifiedAttestation(attestationId: string) {
const result = await lcore.getAttestation(attestationId);

// If data was encrypted, verify the decryption proof
if (result.wasEncrypted && result.proof) {
const isValid = verifyDecryptionProof(result.proof, TRUSTED_TEES);

if (!isValid) {
throw new Error('Decryption proof verification failed');
}

console.log('Decryption proof verified successfully');
console.log('Decrypted by TEE:', result.proof.teeAddress);
console.log('Decrypted at:', new Date(result.proof.timestamp * 1000));
}

return result.data;
}

On-Chain Proof Verification (Solidity)

For high-security use cases, you can verify proofs on-chain:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract DecryptionProofVerifier {
using ECDSA for bytes32;

mapping(address => bool) public trustedTees;

event ProofVerified(
bytes32 indexed ciphertextHash,
bytes32 indexed plaintextHash,
address indexed teeAddress,
uint256 timestamp
);

function verifyDecryptionProof(
bytes32 ciphertextHash,
bytes32 plaintextHash,
uint256 timestamp,
address teeAddress,
bytes memory signature
) external view returns (bool) {
// Recreate the message hash
bytes32 messageHash = keccak256(
abi.encodePacked(ciphertextHash, plaintextHash, timestamp)
);

// Convert to Ethereum signed message hash
bytes32 ethSignedHash = messageHash.toEthSignedMessageHash();

// Recover signer
address recoveredSigner = ethSignedHash.recover(signature);

// Verify signer matches claimed TEE and is trusted
return recoveredSigner == teeAddress && trustedTees[teeAddress];
}
}

Best Practices for Proof Verification

  1. Always verify for sensitive operations: Loan approvals, access grants, etc.
  2. Maintain a trusted TEE registry: Keep an updated list of trusted TEE addresses
  3. Check proof freshness: Reject proofs older than your security threshold
  4. Log verification results: Keep audit trails for compliance
  5. Handle verification failures gracefully: Don't expose internal errors to users

Aggregate Queries

Aggregate queries don't require grants - they return anonymized statistics.

Count by Bucket

interface BucketCount {
bucketValue: string;
count: number;
}

interface AggregateResponse {
bucketKey: string;
counts: BucketCount[];
total: number;
kAnonymityThreshold: number;
}

async function getCountByBucket(
domain: string,
bucketKey: string,
provider?: string
): Promise<AggregateResponse> {
const params = new URLSearchParams({ domain, bucket_key: bucketKey });
if (provider) params.append('provider', provider);

// No API key required for aggregates
const response = await fetch(
`${ATTESTOR_URL}/api/lcore/aggregates/count-by-bucket?${params}`
);
return response.json();
}

// Example usage
const balanceDistribution = await getCountByBucket('lending', 'balance', 'chase');
// Returns:
// {
// bucketKey: "balance",
// counts: [
// { bucketValue: "<1k", count: 123 },
// { bucketValue: "1k-5k", count: 456 },
// { bucketValue: "5k-10k", count: 234 },
// ...
// ],
// total: 1000,
// kAnonymityThreshold: 5
// }

Count by Provider

interface ProviderCount {
provider: string;
flowType: string;
count: number;
}

async function getCountByProvider(domain: string): Promise<ProviderCount[]> {
const response = await fetch(
`${ATTESTOR_URL}/api/lcore/aggregates/count-by-provider?domain=${domain}`
);
const data = await response.json();
return data.counts;
}

K-Anonymity

Aggregate results are protected by k-anonymity:

  • Buckets with fewer than k users are suppressed
  • Default threshold is k=5
  • This prevents identifying individuals
// Example: A bucket with 3 users won't be shown
// Instead, you'll see: { bucketValue: "hidden", count: "< 5" }

SDK Usage

For TypeScript/JavaScript projects, use the L{CORE} SDK.

Installation

npm install @citychain/lcore-sdk

Initialization

import { LCoreClient } from '@citychain/lcore-sdk';

const lcore = new LCoreClient({
attestorUrl: 'https://attestor.example.com',
apiKey: process.env.LCORE_API_KEY,
});

Full Example

import { LCoreClient, GrantType } from '@citychain/lcore-sdk';

const lcore = new LCoreClient({
attestorUrl: process.env.ATTESTOR_URL,
apiKey: process.env.LCORE_API_KEY,
});

// Get user's attestations (requires user signature)
const attestations = await lcore.getUserAttestations({
walletAddress: userAddress,
signature: userSignature,
message: signedMessage,
});

// Request a grant
const grant = await lcore.requestGrant({
attestationId: attestations[0].id,
grantType: GrantType.Full,
expiresInDays: 30,
userSignature: grantSignature,
userAddress: userAddress,
});

// Query the attestation data
const data = await lcore.getAttestation(grant.attestationId);

console.log('User balance bucket:', data.buckets.balance);
// Output: "25k-50k"

// Get aggregate statistics (no auth needed)
const stats = await lcore.getAggregates('lending', 'balance');
console.log('Distribution:', stats.counts);

Error Handling

Error Response Format

interface ErrorResponse {
error: string;
code: string;
details?: Record<string, unknown>;
}

Common Errors

CodeHTTP StatusDescriptionSolution
UNAUTHORIZED401Invalid or missing API keyCheck your API key
FORBIDDEN403No grant for this attestationRequest user grant
GRANT_EXPIRED403Grant has expiredRequest new grant
GRANT_REVOKED403User revoked the grantRequest new grant
NOT_FOUND404Attestation doesn't existVerify attestation ID
RATE_LIMITED429Too many requestsImplement backoff
LCORE_UNAVAILABLE503L{CORE} service is downRetry with backoff

Error Handling Example

async function safeGetAttestation(attestationId: string) {
try {
return await lcore.getAttestation(attestationId);
} catch (error) {
if (error.code === 'GRANT_EXPIRED') {
// Prompt user to re-grant
await promptForNewGrant(attestationId);
return await lcore.getAttestation(attestationId);
}

if (error.code === 'RATE_LIMITED') {
// Wait and retry
await sleep(error.details?.retryAfter || 60000);
return safeGetAttestation(attestationId);
}

throw error;
}
}

Best Practices

1. Minimize Data Access

Request only the data you need:

// Bad: Request full access when you only need balance
const grant = await requestGrant({
grantType: 'full', // Requests everything
// ...
});

// Good: Request partial access with specific keys
const grant = await requestGrant({
grantType: 'partial',
dataKeys: ['balance'], // Only what you need
// ...
});

2. Handle Grant Expiration

async function ensureValidGrant(attestationId: string): Promise<Grant> {
const grants = await lcore.getMyGrants();
const grant = grants.find(g => g.attestationId === attestationId);

if (!grant) {
throw new Error('No grant exists for this attestation');
}

if (new Date(grant.expiresAt) < new Date()) {
throw new Error('Grant has expired');
}

// Warn if expiring soon
const daysUntilExpiry = (new Date(grant.expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24);
if (daysUntilExpiry < 7) {
console.warn(`Grant expires in ${Math.floor(daysUntilExpiry)} days`);
}

return grant;
}

3. Cache Appropriately

// Cache aggregate results (they change slowly)
const aggregateCache = new Map<string, { data: any; expires: number }>();

async function getCachedAggregates(domain: string, bucketKey: string) {
const cacheKey = `${domain}:${bucketKey}`;
const cached = aggregateCache.get(cacheKey);

if (cached && cached.expires > Date.now()) {
return cached.data;
}

const data = await lcore.getAggregates(domain, bucketKey);
aggregateCache.set(cacheKey, {
data,
expires: Date.now() + 5 * 60 * 1000, // 5 minute cache
});

return data;
}

// Don't cache individual attestation data - it may change

4. Respect User Privacy

// Don't log sensitive data
function logAttestation(attestation: AttestationData) {
console.log('Attestation:', {
id: attestation.id,
provider: attestation.provider,
// Don't log: ownerAddress, buckets, data
});
}

// Don't store more than you need
async function processLoanApplication(attestationId: string) {
const attestation = await lcore.getAttestation(attestationId);

// Extract only what you need
const balanceBucket = attestation.buckets.balance;

// Don't store the full attestation
await saveLoanDecision({
qualifies: balanceBucket !== '<1k',
processedAt: new Date(),
// attestation: attestation, // Don't do this
});
}

5. Implement Retry Logic

async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
backoffMs = 1000
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries - 1) throw error;
if (error.code === 'RATE_LIMITED' || error.code === 'LCORE_UNAVAILABLE') {
await sleep(backoffMs * Math.pow(2, attempt));
continue;
}
throw error;
}
}
throw new Error('Unreachable');
}

// Usage
const attestation = await withRetry(() => lcore.getAttestation(id));

Example Implementations

Lending Platform Integration

// Full example: Lending platform checking user creditworthiness

import { LCoreClient, GrantType } from '@citychain/lcore-sdk';

const lcore = new LCoreClient({
attestorUrl: process.env.ATTESTOR_URL,
apiKey: process.env.LCORE_API_KEY,
});

interface LoanEligibility {
eligible: boolean;
maxAmount: number;
reason?: string;
}

async function checkLoanEligibility(
userAddress: string,
attestationId: string
): Promise<LoanEligibility> {
try {
// Get the attestation (requires valid grant)
const attestation = await lcore.getAttestation(attestationId);

// Check provider is trusted
const trustedProviders = ['chase', 'wellsfargo', 'bankofamerica'];
if (!trustedProviders.includes(attestation.provider)) {
return {
eligible: false,
maxAmount: 0,
reason: 'Provider not accepted',
};
}

// Check attestation freshness
if (attestation.freshnessScore < 0.5) {
return {
eligible: false,
maxAmount: 0,
reason: 'Attestation too old - please refresh',
};
}

// Determine loan amount based on balance bucket
const balanceBucket = attestation.buckets.balance;
const loanLimits: Record<string, number> = {
'<1k': 0,
'1k-5k': 1000,
'5k-10k': 2500,
'10k-25k': 5000,
'25k-50k': 10000,
'50k-100k': 25000,
'100k-500k': 50000,
'>500k': 100000,
};

const maxAmount = loanLimits[balanceBucket] || 0;

return {
eligible: maxAmount > 0,
maxAmount,
};
} catch (error) {
if (error.code === 'FORBIDDEN') {
return {
eligible: false,
maxAmount: 0,
reason: 'No access to attestation - please grant access',
};
}
throw error;
}
}

Dashboard with Aggregates

// Example: Display aggregate statistics on a dashboard

import { useEffect, useState } from 'react';

interface DashboardStats {
totalUsers: number;
balanceDistribution: Array<{ bucket: string; count: number }>;
providerBreakdown: Array<{ provider: string; count: number }>;
}

function useLCoreStats(domain: string): DashboardStats | null {
const [stats, setStats] = useState<DashboardStats | null>(null);

useEffect(() => {
async function fetchStats() {
// These don't require authentication
const [balanceResponse, providerResponse] = await Promise.all([
fetch(`${ATTESTOR_URL}/api/lcore/aggregates/count-by-bucket?domain=${domain}&bucket_key=balance`),
fetch(`${ATTESTOR_URL}/api/lcore/aggregates/count-by-provider?domain=${domain}`),
]);

const balance = await balanceResponse.json();
const providers = await providerResponse.json();

setStats({
totalUsers: balance.total,
balanceDistribution: balance.counts.map((c: any) => ({
bucket: c.bucketValue,
count: c.count,
})),
providerBreakdown: providers.counts.map((c: any) => ({
provider: c.provider,
count: c.count,
})),
});
}

fetchStats();
// Refresh every 5 minutes
const interval = setInterval(fetchStats, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [domain]);

return stats;
}

// Usage in component
function Dashboard() {
const stats = useLCoreStats('lending');

if (!stats) return <Loading />;

return (
<div>
<h1>Lending Pool Statistics</h1>
<p>Total verified users: {stats.totalUsers}</p>

<h2>Balance Distribution</h2>
<BarChart data={stats.balanceDistribution} />

<h2>Providers</h2>
<PieChart data={stats.providerBreakdown} />
</div>
);
}

Document History

VersionDateChanges
1.12025-01-12Added decryption proof verification section
1.02025-01-12Initial dApp integration guide