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
- Overview
- Authentication
- User Grant Flow
- Querying Attestations
- Verifying Decryption Proofs
- Aggregate Queries
- SDK Usage
- Error Handling
- Best Practices
- Example Implementations
Overview
L{CORE} is a privacy-preserving attestation data layer. As a dApp developer, you can:
- Request access to user attestation data (with user consent)
- Query attestations you've been granted access to
- Access aggregates for anonymous statistical insights
Architecture
- Validates API keys
- Verifies grants
- Decrypts L{CORE} data
- Returns plaintext to authorized dApps
- Stores attestation data (encrypted)
- Manages access grants
- Provides aggregate statistics
Authentication
API Key Setup
- Register your dApp with the Attestor admin
- Receive an API key (format:
dapp_key_xxxxxxxx) - 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:
- The data was decrypted by a trusted TEE (Trusted Execution Environment)
- The ciphertext hash matches what was received from L{CORE}
- 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
- Always verify for sensitive operations: Loan approvals, access grants, etc.
- Maintain a trusted TEE registry: Keep an updated list of trusted TEE addresses
- Check proof freshness: Reject proofs older than your security threshold
- Log verification results: Keep audit trails for compliance
- 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
kusers 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
| Code | HTTP Status | Description | Solution |
|---|---|---|---|
UNAUTHORIZED | 401 | Invalid or missing API key | Check your API key |
FORBIDDEN | 403 | No grant for this attestation | Request user grant |
GRANT_EXPIRED | 403 | Grant has expired | Request new grant |
GRANT_REVOKED | 403 | User revoked the grant | Request new grant |
NOT_FOUND | 404 | Attestation doesn't exist | Verify attestation ID |
RATE_LIMITED | 429 | Too many requests | Implement backoff |
LCORE_UNAVAILABLE | 503 | L{CORE} service is down | Retry 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
| Version | Date | Changes |
|---|---|---|
| 1.1 | 2025-01-12 | Added decryption proof verification section |
| 1.0 | 2025-01-12 | Initial dApp integration guide |