Skip to content

feat: Hyperledger Identus integration — DID creation + VC issuance for every verification step #5

Description

@AgrimTawani

What this is (read this first)

Hyperledger Identus is a system that gives every CryptoBazaar user a digital identity (called a DID) and issues them tamper-proof certificates (called Verifiable Credentials, or VCs) every time they pass a verification step. Think of it like a passport stamp — but one that is mathematically impossible to forge, lives on a blockchain, and belongs to the user, not to CryptoBazaar.

Why we need it:

  • Instead of CryptoBazaar storing your Aadhaar/PAN data, it issues you a VC that says "this person passed KYC on this date" — signed with our private key. We never store the raw data.
  • If a user's bank is frozen, they can prove they were a verified legitimate trader at the time using their DID credentials — without needing CryptoBazaar to produce records.
  • It's structural DPDPA compliance: the personal data is processed in-flight and discarded. Only the DID lives in the DB.

The 3 credentials this integration issues:

Credential Issued when DB field
KYCCredential Didit KYC webhook returns Approved User.kycCredentialId
EDDCredential Bank statement uploaded and accepted User.eddCredentialId
InterviewCredential AI questionnaire submitted and accepted User.interviewCredId

Codebase map — what already exists

Before you write a single line, read these files:

  • frontend/src/lib/identus.ts — stub file with createDIDForUser() already written. Needs to be extended with VC issuance, VC verification, and VC revocation functions.
  • frontend/prisma/schema.prisma — the User model already has these fields (no migration needed):
    • did String? @unique — stores the user's DID
    • kycCredentialId String? — stores the Identus credential record ID for KYC
    • eddCredentialId String? — stores the Identus credential record ID for EDD
    • interviewCredId String? — stores the Identus credential record ID for interview
  • frontend/src/app/api/verification/link-wallet/route.ts — this is where DID creation should be triggered (wallet link = first permanent identity action)
  • frontend/src/app/api/webhooks/didit/route.ts — KYC VC issuance should be called here after passed === true
  • frontend/src/app/api/verification/analyze-statement/route.ts — EDD VC issuance should be called here after bank statement is accepted
  • frontend/src/app/api/verification/submit-questionnaire/route.ts — Interview VC issuance should be called here after questionnaire is submitted
  • frontend/src/app/api/orders/[id]/route.ts — VC verification should be checked here before the lock action (buyer locks an order = starts a trade)

Part 0 — Set up Identus Cloud Agent locally

Identus runs as a separate service (a Cloud Agent) that your Next.js app talks to over HTTP. You need to run it locally for development.

Step 1 — Install Docker

If you don't have Docker: https://docs.docker.com/get-docker/

Step 2 — Run the Identus Cloud Agent via Docker Compose

Create a file called identus-docker-compose.yml anywhere on your machine (do NOT put it inside the frontend/ folder):

```yaml
version: "3.8"
services:
db:
image: postgres:15
environment:
POSTGRES_DB: identus
POSTGRES_USER: identus
POSTGRES_PASSWORD: identus
ports:
- "5433:5432"

prism-node:
image: ghcr.io/input-output-hk/prism-node:2.3.0
environment:
NODE_PSQL_HOST: db
NODE_PSQL_DATABASE: identus
NODE_PSQL_USERNAME: identus
NODE_PSQL_PASSWORD: identus
depends_on:
- db

cloud-agent:
image: ghcr.io/hyperledger/identus-cloud-agent:1.38.0
environment:
POLLUX_DB_HOST: db
POLLUX_DB_PORT: 5432
POLLUX_DB_NAME: identus
POLLUX_DB_USER: identus
POLLUX_DB_PASSWORD: identus
CONNECT_DB_HOST: db
CONNECT_DB_PORT: 5432
CONNECT_DB_NAME: identus
CONNECT_DB_USER: identus
CONNECT_DB_PASSWORD: identus
AGENT_DB_HOST: db
AGENT_DB_PORT: 5432
AGENT_DB_NAME: identus
AGENT_DB_USER: identus
AGENT_DB_PASSWORD: identus
PRISM_NODE_HOST: prism-node
PRISM_NODE_PORT: 50053
API_KEY_ENABLED: "true"
DEFAULT_WALLET_ENABLED: "true"
DEFAULT_WALLET_AUTH_API_KEY: "my-local-api-key"
SECRET_STORAGE_BACKEND: vault
ports:
- "8080:8080"
depends_on:
- db
- prism-node
```

Run it:
```bash
docker-compose -f identus-docker-compose.yml up -d
```

Wait ~30 seconds, then check it's running:
```bash
curl http://localhost:8080/cloud-agent/v1/_health

Should return: {"status":"ok"}

```

Step 3 — Add env vars to frontend/.env.local

```
IDENTUS_AGENT_URL=http://localhost:8080
IDENTUS_API_KEY=my-local-api-key
```

Step 4 — Create CryptoBazaar's issuer DID (one-time setup)

CryptoBazaar itself needs a DID so it can sign credentials. Run this once after the agent starts:

```bash
curl -X POST http://localhost:8080/cloud-agent/v1/did-registrar/dids
-H "Content-Type: application/json"
-H "apikey: my-local-api-key"
-d '{
"documentTemplate": {
"publicKeys": [
{ "id": "auth-key", "purpose": "authentication" },
{ "id": "issue-key", "purpose": "assertionMethod" }
],
"services": []
}
}'
```

Copy the longFormDid from the response and add it to .env.local:
```
IDENTUS_ISSUER_DID=did:prism:abc123... (paste your longFormDid here)
```

Important: In production, this issuer DID is created once on the production Cloud Agent and stored permanently. Do not recreate it — all existing credentials would become unverifiable.


Part 1 — Extend frontend/src/lib/identus.ts

The file currently only has createDIDForUser(). Add these four functions:

1a — issueVerifiableCredential()

```ts
export async function issueVerifiableCredential(
subjectDid: string,
credentialType: "KYCCredential" | "EDDCredential" | "InterviewCredential",
validityDays: number = 180
): Promise {
// Returns the Identus credential record ID (store this in DB as kycCredentialId etc.)

if (!process.env.IDENTUS_AGENT_URL || !process.env.IDENTUS_ISSUER_DID) {
// Stub mode — return a fake ID for local dev without the agent
return stub-${credentialType}-${Date.now()}
}

const issuedAt = new Date().toISOString()
const validUntil = new Date(Date.now() + validityDays * 86400000).toISOString()

const response = await fetch(
${IDENTUS_AGENT_URL}/cloud-agent/v1/issue-credentials/credential-offers,
{
method: "POST",
headers: { "Content-Type": "application/json", apikey: IDENTUS_API_KEY },
body: JSON.stringify({
credentialFormat: "JWT",
claims: {
type: credentialType,
platform: "CryptoBazaar",
issuedAt,
validUntil,
},
issuingDID: process.env.IDENTUS_ISSUER_DID,
subjectId: subjectDid,
automaticIssuance: true,
}),
}
)

if (!response.ok) {
throw new Error(Identus VC issuance failed: ${response.status})
}

const data = await response.json()
return data.recordId as string // this is what you store in DB
}
```

1b — verifyUserCredentials()

```ts
export async function verifyUserCredentials(userDid: string): Promise<{
kycValid: boolean
eddValid: boolean
interviewValid: boolean
allValid: boolean
}> {
// Stub mode — if no agent configured, assume valid (don't block trades in dev)
if (!process.env.IDENTUS_AGENT_URL || !process.env.IDENTUS_ISSUER_DID) {
return { kycValid: true, eddValid: true, interviewValid: true, allValid: true }
}

// Fetch all credentials for this DID from the agent
const response = await fetch(
${IDENTUS_AGENT_URL}/cloud-agent/v1/issue-credentials/records?subjectId=${encodeURIComponent(userDid)},
{ headers: { apikey: IDENTUS_API_KEY } }
)

if (!response.ok) return { kycValid: false, eddValid: false, interviewValid: false, allValid: false }

const data = await response.json()
const records: Array<{ claims: { type: string; validUntil: string }; protocolState: string }> =
data.contents ?? []

const now = new Date()

const isValid = (type: string) =>
records.some(
(r) =>
r.claims?.type === type &&
r.protocolState === "CredentialSent" &&
new Date(r.claims.validUntil) > now
)

const kycValid = isValid("KYCCredential")
const eddValid = isValid("EDDCredential")
const interviewValid = isValid("InterviewCredential")

return { kycValid, eddValid, interviewValid, allValid: kycValid && eddValid && interviewValid }
}
```

1c — revokeCredential()

```ts
export async function revokeCredential(recordId: string): Promise {
if (!process.env.IDENTUS_AGENT_URL) return // stub mode

await fetch(
${IDENTUS_AGENT_URL}/cloud-agent/v1/issue-credentials/records/${recordId}/problem-report,
{
method: "POST",
headers: { "Content-Type": "application/json", apikey: IDENTUS_API_KEY },
body: JSON.stringify({ code: "credential.revoked" }),
}
)
}
```


Part 2 — Trigger DID creation on wallet link

File: frontend/src/app/api/verification/link-wallet/route.ts

After the db.user.update(...) call that saves the wallet address (around line 32), add:

```ts
import { createDIDForUser } from "@/lib/identus"

// After db.user.update(...)
// Create a DID for the user if they don't have one yet
if (!user.did) {
try {
const did = await createDIDForUser(user.id)
await db.user.update({
where: { id: user.id },
data: { did },
})
} catch (err) {
// DID creation failing should not block wallet linking
// Log it but let the user proceed
console.error("[link-wallet] DID creation failed:", err)
}
}
```

Why wallet link? The wallet is the user's permanent on-chain identity anchor. Creating the DID here ties the DID lifecycle to the wallet — if the wallet changes, the user restarts verification and gets a new DID.


Part 3 — Issue KYC credential in the Didit webhook

File: frontend/src/app/api/webhooks/didit/route.ts

After the db.user.update(...) at line ~87 that sets kycVerifiedAt, add:

```ts
import { issueVerifiableCredential } from "@/lib/identus"

// After db.user.update({ kycVerifiedAt, status: "WALLET_PENDING" })
// Issue KYC credential to the user's DID
try {
const updatedUser = await db.user.findUnique({ where: { id: userId }, select: { did: true } })
if (updatedUser?.did) {
const recordId = await issueVerifiableCredential(updatedUser.did, "KYCCredential")
await db.user.update({
where: { id: userId },
data: { kycCredentialId: recordId },
})
}
} catch (err) {
console.error("[webhook/didit] KYC VC issuance failed:", err)
// Don't fail the webhook — KYC is still marked as passed
}
```


Part 4 — Issue EDD credential after bank statement

File: frontend/src/app/api/verification/analyze-statement/route.ts

After the db.user.update(...) that sets status to "ONBOARDING_PENDING" (around line 261), add:

```ts
import { issueVerifiableCredential } from "@/lib/identus"

// After db.user.update(...)
try {
const freshUser = await db.user.findUnique({ where: { id: user.id }, select: { did: true } })
if (freshUser?.did) {
const recordId = await issueVerifiableCredential(freshUser.did, "EDDCredential")
await db.user.update({
where: { id: user.id },
data: { eddCredentialId: recordId },
})
}
} catch (err) {
console.error("[analyze-statement] EDD VC issuance failed:", err)
}
```


Part 5 — Issue Interview credential after questionnaire

File: frontend/src/app/api/verification/submit-questionnaire/route.ts

After the db.user.update(...) that sets status to "ONBOARDING_PENDING" (around line 107), add:

```ts
import { issueVerifiableCredential } from "@/lib/identus"

// After db.user.update(...)
try {
const freshUser = await db.user.findUnique({ where: { id: user.id }, select: { did: true } })
if (freshUser?.did) {
const recordId = await issueVerifiableCredential(freshUser.did, "InterviewCredential")
await db.user.update({
where: { id: user.id },
data: { interviewCredId: recordId },
})
}
} catch (err) {
console.error("[submit-questionnaire] Interview VC issuance failed:", err)
}
```


Part 6 — Verify credentials before a trade starts

File: frontend/src/app/api/orders/[id]/route.ts

In the PATCH handler, inside the case "lock": block (around line 96), add a credential check before the db.order.update(...):

```ts
import { verifyUserCredentials } from "@/lib/identus"

case "lock": {
if (isSeller)
return NextResponse.json({ error: "Seller cannot lock own order" }, { status: 403 })
if (order.status !== "LISTED")
return NextResponse.json({ error: "Order not available" }, { status: 400 })
if (user.status !== "VERIFIED")
return NextResponse.json({ error: "Must be verified to buy" }, { status: 403 })

// NEW: verify the buyer's VCs are still valid and unexpired
if (user.did) {
const creds = await verifyUserCredentials(user.did)
if (!creds.allValid) {
return NextResponse.json(
{
error: "Your verification credentials have expired. Please complete re-verification before trading.",
credentialStatus: creds,
},
{ status: 403 }
)
}
}

// existing db.order.update(...) continues here
```

Also verify the seller's credentials when the buyer locks (the seller should also be currently verified):

```ts
// Also verify seller's credentials
const sellerFull = await db.user.findUnique({ where: { id: order.sellerId }, select: { did: true } })
if (sellerFull?.did) {
const sellerCreds = await verifyUserCredentials(sellerFull.did)
if (!sellerCreds.allValid) {
return NextResponse.json(
{ error: "Seller's verification has expired. This order cannot be locked." },
{ status: 403 }
)
}
}
```


Part 7 — Environment variables

Local development (frontend/.env.local)

```
IDENTUS_AGENT_URL=http://localhost:8080
IDENTUS_API_KEY=my-local-api-key
IDENTUS_ISSUER_DID=did:prism:YOUR_ISSUER_DID_HERE
```

Vercel (production)

Add the same three variables in the Vercel dashboard under Settings → Environment Variables:

  • IDENTUS_AGENT_URL — URL of your production Cloud Agent (could be self-hosted on a VPS or a managed Identus instance)
  • IDENTUS_API_KEY — the API key for your production agent
  • IDENTUS_ISSUER_DID — the production issuer DID (created once, never recreated)

Stub mode: If IDENTUS_AGENT_URL is not set, all Identus calls fall back to stub behaviour (fake IDs returned, verification always passes). This means the app works fully without Identus configured — useful for development and staging environments where you don't want to run the full agent stack.


Part 8 — How to test it

Test 1 — DID creation

  1. Start the Cloud Agent: docker-compose -f identus-docker-compose.yml up -d
  2. Set IDENTUS_AGENT_URL=http://localhost:8080 and IDENTUS_API_KEY=my-local-api-key in .env.local
  3. Go through the onboarding flow → connect a wallet
  4. After connecting, check the DB: SELECT did FROM users WHERE clerk_id = 'your_clerk_id';
  5. Expected: a did:prism:... string is now stored

Test 2 — VC issuance

  1. Complete bank statement upload (or use the [DEV] Skip button in development)
  2. Check the DB: SELECT edd_credential_id FROM users WHERE clerk_id = 'your_clerk_id';
  3. Expected: a non-null recordId string from Identus
  4. Also verify via the Cloud Agent directly:
    ```bash
    curl http://localhost:8080/cloud-agent/v1/issue-credentials/records
    -H "apikey: my-local-api-key"
    ```
    You should see a record with protocolState: "CredentialSent" for each step completed.

Test 3 — VC verification at trade time

  1. Complete full verification for two test accounts (buyer and seller)
  2. Post a sell order from the seller account
  3. Try to lock it from the buyer account
  4. Expected: order locks successfully
  5. Now manually set eddCredentialId = null in the DB for the buyer and retry
  6. Expected: API returns 403 with "Your verification credentials have expired"

Test 4 — Stub mode (no agent)

  1. Remove IDENTUS_AGENT_URL from .env.local
  2. Go through the whole flow
  3. Expected: everything works, did field in DB gets a did:prism:stub-... value, trades work normally
  4. This confirms that Identus is non-blocking — its failure should never stop the core trade flow

Acceptance criteria

  • frontend/src/lib/identus.ts exports createDIDForUser, issueVerifiableCredential, verifyUserCredentials, revokeCredential
  • All four functions have stub mode (no-op / fake return) when IDENTUS_AGENT_URL is not set
  • DID is created and saved to User.did when a wallet is linked (link-wallet route)
  • KYCCredential issued and kycCredentialId saved after Didit webhook Approved
  • EDDCredential issued and eddCredentialId saved after bank statement accepted
  • InterviewCredential issued and interviewCredId saved after questionnaire submitted
  • Both buyer and seller credentials verified before a trade is locked (case "lock" in orders PATCH)
  • Credential failures return 403 with a human-readable message — they do not crash the app
  • All Identus calls are wrapped in try/catch — a failing Identus agent never breaks the core onboarding flow
  • No Prisma migration needed — all DB fields already exist
  • Local Docker Compose setup documented (this issue) is confirmed working
  • Three env vars documented: IDENTUS_AGENT_URL, IDENTUS_API_KEY, IDENTUS_ISSUER_DID

Stack context

  • Framework: Next.js 15 App Router, TypeScript
  • DB: Prisma + PostgreSQL. Fields User.did, User.kycCredentialId, User.eddCredentialId, User.interviewCredId already exist — no migration needed
  • Identus SDK: The integration uses direct REST API calls to the Cloud Agent (fetch), not the npm SDK. This keeps the dependency surface small and avoids SDK version conflicts.
  • DID method: did:prism (Cardano-anchored, managed by the Cloud Agent)
  • Credential format: JWT VCs (W3C standard)
  • Existing stub: frontend/src/lib/identus.ts already has createDIDForUser() with stub fallback — extend this file, don't create a new one
  • Non-blocking design: Every Identus call must be wrapped in try/catch. A dead Cloud Agent should log an error but never prevent a user from completing onboarding or trading

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions