Skip to main content

Verification Flow

This document provides a detailed walkthrough of the zkIdentity verification process, covering each of the 10 steps from initial user action through final on-chain attestation. It also documents what data is recorded at each stage, verification expiry policies, and troubleshooting guidance.

Overview

The verification flow transforms a user's raw KYC result into a privacy-preserving, on-chain attestation. The entire process typically completes in 1-3 minutes, depending on the KYC provider's processing time.

User ─── 1. Select Provider ──── Frontend

2. Create Session ──── Attestor ──── KYC Provider

3. Complete Verification ──── User ──── KYC Provider

4. Provider Webhook ──── KYC Provider ──── Attestor

5. zkFetch TLS Tunnel ──── Attestor ──── KYC Provider

6. Generate 14 ZK Proofs ──── Attestor (TEE)

7. Extract Non-PII Metadata ──── Attestor (TEE)

8. Submit to Cartesi ──── Attestor ──── Cartesi Rollup

9. Store Attestation ──── Cartesi Rollup ──── Arbitrum

10. User Sees "Verified" ──── Frontend ──── User

The 10 Steps

Step 1: User Selects Provider

Actor: User (via Frontend)

The user connects their wallet through Privy and is presented with available KYC providers. The frontend determines which providers to display based on the user's selected country:

  • African countries (NG, KE, ZA, GH, UG, TZ, etc.): Smile ID
  • US, CA, UK, EU: Plaid

The user selects their country and provider. The frontend derives the user's DID (did:key) from their wallet's secp256k1 public key and prepares a session request.

Data at this stage:

  • DID (derived locally, never sent to a central registry)
  • Selected provider
  • Selected country

Step 2: Attestor Creates Session

Actor: Attestor (TEE)

The frontend sends a session creation request to the attestor:

{
"did": "did:key:z6MkhRXz...",
"provider": "smile_id",
"country": "NG",
"signature": "0x..." // Wallet signature proving DID ownership
}

The attestor:

  1. Verifies the wallet signature to confirm DID ownership.
  2. Generates a unique session ID.
  3. Calls the KYC provider's API to create a verification session.
  4. Returns the session ID and provider-specific configuration (e.g., Smile ID widget URL or Plaid Link token) to the frontend.

Duration: < 1 second

Step 3: User Completes Verification

Actor: User (via KYC Provider UI)

The user interacts directly with the KYC provider's verification interface:

  • Smile ID: The user uploads a photo of their identity document (national ID, passport, or driver's license) and takes a selfie for biometric matching. SmartSelfie performs liveness detection to prevent spoofing.
  • Plaid: The user logs into their bank account through Plaid Link. Plaid retrieves identity information from the bank's records.

During this step, PII flows directly between the user and the KYC provider. The attestor does not see the user's documents, selfies, or bank credentials.

Duration: 30 seconds - 5 minutes (depends on user interaction speed and provider processing time)

Step 4: Provider Webhook

Actor: KYC Provider -> Attestor

After the provider completes processing, it sends a webhook notification to the attestor:

POST https://attestor.yourdomain.com/webhooks/smile-id/{session_id}

The webhook payload contains the verification result. The attestor:

  1. Verifies the webhook signature to confirm it came from the legitimate provider.
  2. Checks that the session ID is valid and has not expired.
  3. Records the webhook receipt timestamp.
  4. Initiates the proof generation pipeline.

Duration: Instant (provider delivery time varies)

Step 5: zkFetch TLS Tunnel

Actor: Attestor (TEE) -> KYC Provider API

Rather than relying solely on the webhook payload, the attestor independently fetches the verification result from the provider's API using Reclaim Protocol's zkFetch. This creates a cryptographic record:

  1. zkFetch establishes a TLS 1.3 connection to the provider's API.
  2. The attestor sends an authenticated API request for the verification result.
  3. The TLS session is recorded, capturing cryptographic artifacts needed for proof generation.
  4. The provider's response is received and held in TEE memory.

This step is critical because it allows ZK proofs to be generated over a fresh, verifiable TLS session rather than relying on a webhook payload that could be tampered with.

Duration: 1-3 seconds

Step 6: Generate 14 ZK Proofs

Actor: Attestor (TEE)

Using the cryptographic artifacts from the zkFetch TLS session, the attestor generates 14 ZK proofs. These proofs are organized into three categories:

CategoryProofsPurpose
TLS Authenticity#1-4Prove the response came from the real provider over genuine TLS
Content Claims#5-11Prove specific facts about the verification result without revealing the full response
Integrity#12-14Bind the proofs to the user's DID and ensure bundle consistency

Each proof is a cryptographic object that can be independently verified. Together, they form a complete attestation that the user passed KYC with a specific provider in a specific country.

See ZK Proof System for a detailed breakdown of each proof.

Duration: 10-30 seconds

Step 7: Extract Non-PII Metadata

Actor: Attestor (TEE)

The attestor extracts a minimal set of non-PII metadata from the provider's response. This metadata is the only information that will be stored on-chain:

{
"status": "verified",
"provider": "smile_id",
"country": "NG",
"documentType": "national_id",
"livenessCheck": "passed",
"timestamp": 1700000000
}

Simultaneously, the attestor encrypts any document references (CIDs pointing to provider-stored documents) using AES-256-GCM with a key derived from the user's wallet public key. The encrypted blob is uploaded to IPFS via Pinata.

Duration: 1-5 seconds (including IPFS upload)

Step 8: Submit to Cartesi Rollup

Actor: Attestor -> Cartesi Rollup

The attestor packages the attestation and submits it as an input to the Cartesi rollup:

{
"type": "attestation",
"did": "did:key:z6MkhRXz...",
"provider": "smile_id",
"country": "NG",
"status": "verified",
"documentType": "national_id",
"livenessCheck": "passed",
"timestamp": 1700000000,
"proofHashes": ["0xabc...", "0xdef...", "..."],
"ipfsCid": "QmXyz...",
"attestorSignature": "0x..."
}

The Cartesi rollup's RISC-V virtual machine:

  1. Verifies the attestor's signature (confirming it came from a genuine TEE).
  2. Validates all 14 ZK proofs.
  3. Checks the metadata is consistent with the proofs.
  4. Stores the attestation in the rollup state.
  5. Emits a notice (event) confirming the attestation.

Duration: < 1 second (rollup processing)

Step 9: Attestation Stored On-Chain

Actor: Cartesi Rollup -> Arbitrum

The attestation is now part of the Cartesi rollup state. Periodically, the rollup's state is committed to Arbitrum through the standard Cartesi rollup mechanism:

  1. The rollup node computes a state hash.
  2. The state hash is submitted to the Arbitrum rollup contract.
  3. After the dispute period, the state is finalized.

Once finalized, the attestation is anchored to Arbitrum with Ethereum-equivalent security.

Duration: State commitment happens in batches. Finality depends on the rollup's epoch configuration (typically minutes to hours).

Step 10: User Sees "Verified"

Actor: Frontend -> User

The frontend polls the attestor's status endpoint throughout the process. Once the Cartesi rollup confirms the attestation, the attestor updates the session status to "verified":

{
"sessionId": "abc-123",
"status": "verified",
"did": "did:key:z6MkhRXz...",
"provider": "smile_id",
"country": "NG",
"attestationTx": "0x...",
"explorerUrl": "https://explorer.cartesi.io/..."
}

The frontend displays the verified status along with links to view the attestation on-chain.

Duration: Instant (once rollup confirms)

What Gets Recorded

The following table summarizes what data is stored at each layer:

DataFrontendAttestor (TEE)KYC ProviderIPFSCartesi RollupArbitrum
Wallet addressSession onlySession onlyNoNoNoNo
DIDSession onlySession onlyNoNoYesState root
Name, DOB, etc.NeverNever persistedYes (their policy)NoNoNo
Document imagesNeverNever persistedYes (their policy)NoNoNo
Verification statusDisplayedSession onlyYesNoYesState root
Provider nameDisplayedSession onlyN/ANoYesState root
Country codeDisplayedSession onlyYesNoYesState root
ZK proof hashesOptional displayTransientNoNoYesState root
Encrypted doc refsNeverTransientNoYes (encrypted)CID onlyState root

Verification Expiry

Attestations do not have a built-in cryptographic expiry. However, the system supports application-level expiry policies:

Default Policy

  • Verification validity: 365 days from the verification timestamp.
  • Grace period: 30 days after expiry during which the user can re-verify without losing their attestation history.
  • Re-verification: After expiry, the user must complete the full verification flow again to obtain a new attestation.

Custom Policies

Applications consuming zkIdentity attestations can enforce their own expiry policies by checking the timestamp field in the attestation:

const attestation = await rollup.getAttestation(did);
const ageInDays = (Date.now() / 1000 - attestation.timestamp) / 86400;

if (ageInDays > 365) {
// Attestation expired -- require re-verification
}

Expiry Notifications

The frontend displays the attestation's age and upcoming expiry. Users receive a notification when their attestation is within 30 days of the default expiry.

Status States

A verification session can be in one of the following states:

StatusDescription
createdSession created, waiting for user to begin provider verification
provider_pendingUser has initiated provider verification, waiting for completion
provider_completedProvider has returned a result (success or failure)
proof_generatingzkFetch and ZK proof generation in progress
proof_generatedAll 14 ZK proofs successfully generated
submittingAttestation being submitted to Cartesi rollup
verifiedAttestation confirmed on rollup; verification successful
rejectedProvider returned a negative result; verification failed
expiredSession TTL exceeded before completion
errorAn error occurred during processing

Troubleshooting the Verification Flow

Session Stuck at "provider_pending"

Likely cause: The user has not completed the provider's verification flow, or the provider's webhook has not been delivered.

Resolution:

  1. Check if the user completed the provider flow (visible in the provider's dashboard).
  2. Verify the webhook URL is reachable from the provider's servers.
  3. Check the attestor logs for webhook receipt.
  4. If the provider shows "completed" but no webhook arrived, manually trigger a result fetch.

Session Stuck at "proof_generating"

Likely cause: ZK proof generation is taking longer than expected, or has failed.

Resolution:

  1. Check attestor logs for proof generation errors.
  2. Verify the TEE has sufficient resources (CPU and memory).
  3. Check connectivity to the provider's API (zkFetch needs to reach the provider).
  4. If proofs fail repeatedly, restart the attestor and retry.

Session Shows "error"

Likely cause: An unrecoverable error occurred during processing.

Resolution:

  1. Check the session's error details via the attestor API: GET /api/sessions/{session_id}
  2. Common error causes:
    • Provider API returned an unexpected format
    • IPFS upload failed
    • Cartesi rollup submission failed
    • Attestor wallet has insufficient funds
  3. Address the root cause and ask the user to re-verify.

Session Shows "expired"

Likely cause: The session TTL (default: 1 hour) expired before the verification flow completed.

Resolution:

  1. The user must start a new verification.
  2. If sessions frequently expire, consider increasing SESSION_TTL in the configuration.
  3. Check if the provider's processing time is unusually long.