Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions api/src/controllers/verification.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,20 @@ import { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import {
getVerificationProof,
getVerificationStatus,
revokeVerification,
submitVerification,
VerificationLevel,
VerificationProvider,
} from '../services/verification.service';

const execAsync = promisify(exec);

const VALID_LEVELS: VerificationLevel[] = ['basic', 'enhanced', 'institutional'];
const VALID_PROVIDERS: VerificationProvider[] = ['civic', 'fractal_id', 'mock'];

/**
* Verify contract against source code
*/
Expand Down Expand Up @@ -60,3 +71,70 @@ export const verifyContract = async (req: Request, res: Response): Promise<void>
});
}
};

export const submitKycVerification = async (req: Request, res: Response): Promise<void> => {
try {
const { userAddress, provider = 'mock', level = 'basic', jurisdiction, proofHash } = req.body ?? {};

if (!userAddress || typeof userAddress !== 'string') {
res.status(400).json({ error: 'userAddress is required' });
return;
}
if (!VALID_PROVIDERS.includes(provider)) {
res.status(400).json({ error: 'provider must be civic, fractal_id, or mock' });
return;
}
if (!VALID_LEVELS.includes(level)) {
res.status(400).json({ error: 'level must be basic, enhanced, or institutional' });
return;
}

const attestation = await submitVerification({
userAddress,
provider,
level,
jurisdiction,
proofHash,
});

res.status(attestation.status === 'verified' ? 201 : 202).json(attestation);
} catch (error) {
console.error('KYC verification error:', error);
res.status(500).json({ error: 'KYC verification failed' });
}
};

export const getKycStatus = async (req: Request, res: Response): Promise<void> => {
try {
const { userAddress } = req.params;
const status = await getVerificationStatus(userAddress);
if (!status) {
res.status(404).json({ error: 'Verification attestation not found' });
return;
}
res.json(status);
} catch (error) {
console.error('KYC status error:', error);
res.status(500).json({ error: 'Unable to read verification status' });
}
};

export const revokeKycAttestation = async (req: Request, res: Response): Promise<void> => {
try {
const { userAddress } = req.params;
const revoked = await revokeVerification(userAddress);
if (!revoked) {
res.status(404).json({ error: 'Verification attestation not found' });
return;
}
res.json(revoked);
} catch (error) {
console.error('KYC revoke error:', error);
res.status(500).json({ error: 'Unable to revoke verification attestation' });
}
};

export const getPrivacyProof = (req: Request, res: Response): void => {
const { userAddress } = req.params;
res.json(getVerificationProof(userAddress));
};
53 changes: 40 additions & 13 deletions api/src/routes/verification.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,51 @@ const router: Router = Router();
* responses:
* 200:
* description: Verification result
* content:
* application/json:
* schema:
* type: object
* properties:
* verified:
* type: boolean
* contractId:
* type: string
* network:
* type: string
* message:
* type: string
* 400:
* description: Bad request
* 500:
* description: Verification error
*/
router.get('/', verificationController.verifyContract);

/**
* @openapi
* /verification/kyc:
* post:
* summary: Submit KYC/AML verification and create an attestation
* tags:
* - Verification
*/
router.post('/kyc', verificationController.submitKycVerification);

/**
* @openapi
* /verification/kyc/{userAddress}:
* get:
* summary: Get KYC/AML verification status for a user
* tags:
* - Verification
*/
router.get('/kyc/:userAddress', verificationController.getKycStatus);

/**
* @openapi
* /verification/kyc/{userAddress}/proof:
* get:
* summary: Return a privacy-preserving proof summary without PII
* tags:
* - Verification
*/
router.get('/kyc/:userAddress/proof', verificationController.getPrivacyProof);

/**
* @openapi
* /verification/kyc/{userAddress}/revoke:
* post:
* summary: Revoke an existing verification attestation
* tags:
* - Verification
*/
router.post('/kyc/:userAddress/revoke', verificationController.revokeKycAttestation);

export default router;
122 changes: 122 additions & 0 deletions api/src/services/verification.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import crypto from 'crypto';

export type VerificationLevel = 'basic' | 'enhanced' | 'institutional';
export type VerificationProvider = 'civic' | 'fractal_id' | 'mock';
export type VerificationStatus = 'pending' | 'verified' | 'rejected' | 'revoked' | 'expired';

export interface VerificationRequest {
userAddress: string;
provider: VerificationProvider;
level: VerificationLevel;
jurisdiction?: string;
proofHash?: string;
}

export interface VerificationAttestation {
userAddress: string;
provider: VerificationProvider;
level: VerificationLevel;
status: VerificationStatus;
amlScreened: boolean;
watchlistHit: boolean;
attestationHash: string;
proofHash?: string;
jurisdiction?: string;
issuedAt: string;
expiresAt: string;
revokedAt?: string;
}

const attestations = new Map<string, VerificationAttestation>();
const WATCHLIST_TERMS = ['sanctioned', 'blocked', 'watchlist'];

function expiryForLevel(level: VerificationLevel): Date {
const now = new Date();
// Basic and institutional attestations require annual re-verification;
// enhanced attestations are reviewed biannually.
const months = level === 'enhanced' ? 6 : 12;
now.setMonth(now.getMonth() + months);
return now;
}

function buildAttestationHash(request: VerificationRequest, issuedAt: string): string {
const input = [
request.userAddress,
request.provider,
request.level,
request.jurisdiction ?? '',
request.proofHash ?? '',
issuedAt,
].join(':');

// Privacy preserving: no PII is stored in the proof. Production providers can
// replace this with a provider-signed on-chain attestation transaction hash.
return `att_${crypto.createHash('sha256').update(input).digest('hex')}`;
}

function screenAml(request: VerificationRequest): { amlScreened: boolean; watchlistHit: boolean } {
const haystack = `${request.userAddress} ${request.jurisdiction ?? ''} ${request.proofHash ?? ''}`.toLowerCase();
return {
amlScreened: true,
watchlistHit: WATCHLIST_TERMS.some((term) => haystack.includes(term)),
};
}

export async function submitVerification(request: VerificationRequest): Promise<VerificationAttestation> {
const { amlScreened, watchlistHit } = screenAml(request);
const issuedAt = new Date().toISOString();
const expiresAt = expiryForLevel(request.level).toISOString();
const attestation: VerificationAttestation = {
userAddress: request.userAddress,
provider: request.provider,
level: request.level,
status: watchlistHit ? 'rejected' : 'verified',
amlScreened,
watchlistHit,
attestationHash: buildAttestationHash(request, issuedAt),
proofHash: request.proofHash,
jurisdiction: request.jurisdiction,
issuedAt,
expiresAt,
};

attestations.set(request.userAddress, attestation);
return attestation;
}

export async function getVerificationStatus(userAddress: string): Promise<VerificationAttestation | null> {
const attestation = attestations.get(userAddress);
if (!attestation) return null;

if (attestation.status === 'verified' && Date.now() > Date.parse(attestation.expiresAt)) {
const expired = { ...attestation, status: 'expired' as const };
attestations.set(userAddress, expired);
return expired;
}

return attestation;
}

export async function revokeVerification(userAddress: string): Promise<VerificationAttestation | null> {
const attestation = attestations.get(userAddress);
if (!attestation) return null;

const revoked: VerificationAttestation = {
...attestation,
status: 'revoked',
revokedAt: new Date().toISOString(),
};
attestations.set(userAddress, revoked);
return revoked;
}

export function getVerificationProof(userAddress: string): { verified: boolean; level?: VerificationLevel; attestationHash?: string } {
const attestation = attestations.get(userAddress);
if (!attestation || attestation.status !== 'verified') return { verified: false };

return {
verified: true,
level: attestation.level,
attestationHash: attestation.attestationHash,
};
}
37 changes: 37 additions & 0 deletions stellar-lend/benchmarks/gas-dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Gas Benchmark Dashboard

This dashboard documents how to read and maintain StellarLend gas benchmark results.

## CI gate

The workflow at `.github/workflows/gas-benchmarks.yml` runs the benchmark binary, uploads `benchmark-results.json`, and fails the build when an operation exceeds its configured gas budget. A practical regression policy is:

- fail when any function exceeds its explicit budget;
- review any operation whose CPU instruction cost increases by more than 10% from the committed baseline;
- update `benchmarks/baseline.json` only after an intentional optimization or feature change is reviewed.

## Result fields

Each benchmark result should include:

- `contract`
- `operation`
- `instructions`
- `memory_bytes`
- `budget`
- `within_budget`
- `storage_reads`
- `storage_writes`
- `cross_contract_calls`

## Optimization checklist

- Prefer packed storage records over many small keys when values are read together.
- Cache config records loaded more than once inside the same entrypoint.
- Avoid repeated cross-contract calls in loops.
- Emit compact events and avoid duplicating data already present in storage.
- Add a focused benchmark before and after any storage-layout change.

## Storage slot analysis

When a benchmark regresses, inspect whether the operation added persistent keys, duplicate reads, or larger serialized values. Cross-contract accounting should be called out separately because token/oracle calls can dominate protocol-level gas.
Loading
Loading