• SettleMintSettleMint
    • Introduction
    • Market pain points
    • Lifecycle platform approach
    • Platform capabilities
    • Use cases
    • Compliance & security
    • Glossary
    • Core component overview
    • Frontend layer
    • API layer
    • Blockchain layer
    • Data layer
    • Deployment layer
    • System architecture
    • Smart contracts
    • Application layer
    • Data & indexing
    • Integration & operations
    • Performance
    • Quality
    • Getting started
    • Asset issuance
    • Platform operations
    • Troubleshooting
    • Development environment
    • Code structure
    • Smart contracts
    • API integration
    • Data model
      • Data model overview
      • Data model reference
      • Token entities
      • Investor entities
      • Compliance entities
      • Corporate action entities
    • Deployment & ops
    • Testing and QA
    • Developer FAQ
Back to the application
  1. Documentation
  2. Developer guides
  3. Data model

Investor entity reference (User, KYC, Identity)

Investor entities bridge authentication, identity verification, and blockchain state. This dual-layer architecture enables compliance-aware settlement, vault custody operations, and regulatory reporting by linking off-chain KYC profiles to on-chain identity contracts.

Why investor entities span two layers

Asset tokenization platforms face a fundamental challenge: investors must exist in both regulatory (off-chain) and transaction (on-chain) contexts. A user authenticates with email and password, submits government-issued identity documents for KYC verification, then receives an on-chain identity contract that blockchain-based compliance rules can validate before every token transfer.

This dual representation enables three critical DALP lifecycle features:

  • DvP settlement — Delivery-versus-payment operations verify that both parties have verified identities before atomically exchanging tokens and stablecoin payments
  • Vault custody — Vault deposit/withdrawal operations check identity claims to enforce jurisdictional restrictions and accredited investor requirements
  • Yield distribution — Automated dividend and interest payments rely on identity registry queries to filter eligible recipients and calculate pro-rata allocations

The data model reflects this split: PostgreSQL stores authentication credentials and personally identifiable information (PII) that remain private, while the subgraph indexes public blockchain state—identity contract addresses, verification status, and compliance claims—that smart contracts use for transfer restriction checks.

Rendering chart...

Cross-layer link: user.wallet === account.id (lowercase Ethereum address)

Off-chain entities (PostgreSQL)

Off-chain tables store PII and authentication state that must remain private. The Better Auth schema handles sessions and multi-factor authentication, while the KYC schema captures verification documents and regulatory attributes.

User

The core authentication and profile table. Each user record represents a natural person who can authenticate to the platform, connect a wallet, and eventually receive an on-chain identity contract.

Schema location: kit/dapp/src/lib/db/schemas/auth.ts

Business context: The user.wallet field is the critical bridge to blockchain state. When a user connects a wallet via WalletConnect or MetaMask, that Ethereum address is stored here and later used to deploy their identity contract. The role field gates admin functions like issuing new assets or approving KYC submissions. Ban functionality supports regulatory actions—suspicious accounts can be suspended pending investigation.

Fields

FieldTypePurpose
idstringPrimary key (UUID); stable identifier for cross-table joins
namestringDisplay name shown in UI; user-editable
emailstringUnique; used for login and notifications
emailVerifiedbooleanEmail confirmation status; gates KYC submission
imagestring | nullProfile avatar URL (optional)
createdAtDateAccount creation timestamp for audit trails
updatedAtDateLast modification time; updated on profile changes
roleUserRole | null'admin' for platform operators, 'user' for investors; defaults to 'user'
bannedboolean | nullBan status for regulatory enforcement
banReasonstring | nullExplanation displayed to banned users
banExpiresDate | nullAutomatic unban timestamp (null = permanent)
walletAddress | nullConnected Ethereum address (checksummed); links to Account.id on-chain
lastLoginAtDate | nullSession tracking for security monitoring
pincodeEnabledbooleanPIN-based MFA enabled (mobile-friendly)
pincodeVerificationIdstring | nullReference to pending PIN verification
twoFactorEnabledbooleanTOTP authenticator enabled
twoFactorVerificationIdstring | nullReference to pending 2FA verification
secretCodesConfirmedbooleanBackup recovery codes acknowledged
secretCodeVerificationIdstring | nullReference to backup code setup

Indexes:

  • Primary key: id
  • Unique constraint: email (prevents duplicate accounts)

Relationships:

  • sessions (1:N) — Active login sessions tracked for security
  • accounts (1:N) — OAuth provider links (Google, GitHub, etc.)
  • kycProfiles (1:1) — Identity verification data (see below)
  • apikeys (1:N) — Programmatic access for integrations
  • passkeys (1:N) — WebAuthn credentials for passwordless login

Usage example: investor wallet lookup

import { db } from "@/lib/db";
import { user } from "@/lib/db/schemas";
import { eq } from "drizzle-orm";

// Retrieve user by wallet address for compliance checks
const investor = await db
  .select()
  .from(user)
  .where(eq(user.wallet, walletAddress))
  .get();

if (!investor) {
  throw new Error("No user associated with this wallet");
}

// Check admin privileges before allowing asset issuance
if (investor.role !== "admin") {
  throw new Error("Unauthorized: admin role required");
}

Observability: Monitor the user table for:

  • Registration rate — Track createdAt timestamps to measure user growth
  • Email verification lag — Alert when emailVerified remains false >24h after createdAt
  • Wallet connection rate — Percentage of users with non-null wallet field
  • Ban actions — Audit log of banned flag changes for compliance reporting

See the user activity dashboard (Helm chart: atk-observability) for pre-configured panels tracking these metrics.

KYC profile

Identity verification data linked 1:1 to users. The platform uses a simplified KYC model suitable for development and testing; production deployments typically integrate external KYC providers like Sumsub or Onfido via the API layer.

Schema location: kit/dapp/src/lib/db/schemas/kyc.ts

Business context: KYC profiles store the regulatory attributes that on-chain compliance rules evaluate. The country field determines which identity registries the user can register with (some tokens restrict transfers to specific jurisdictions). The residencyStatus field supports accredited investor checks—permanent residents may have different investment limits than temporary visa holders. After KYC approval, backend automation deploys an identity contract and registers it in the appropriate country-specific identity registry.

Fields

FieldTypePurpose
idstringPrimary key (UUID)
userIdstringForeign key to user.id (unique constraint enforces 1:1)
firstNamestringLegal first name from government ID
lastNamestringLegal last name; combined with firstName for identity claims
dobDateDate of birth; used for age verification rules
countrystringISO 3166-1 alpha-2 code (e.g., "US", "GB"); determines registry eligibility
residencyStatusResidencyStatus'citizen', 'permanent_resident', or 'temporary_resident'; gates investment limits
nationalIdstringNational ID number (encrypted at rest); for audit purposes only
createdAtDateKYC submission timestamp
updatedAtDateLast modification time; updated when admin approves/rejects

Indexes:

  • Primary key: id
  • Unique constraint: userId (one KYC profile per user)
  • Non-unique indexes: country, firstName, lastName (optimize compliance queries)

Foreign keys:

  • userId → user.id with onDelete: cascade (deleting a user removes their KYC data)

Usage example: country-based compliance queries

import { db } from "@/lib/db";
import { user, kycProfiles } from "@/lib/db/schemas";
import { eq } from "drizzle-orm";

// Find all verified US investors for regulatory reporting
const usInvestors = await db
  .select({
    userId: user.id,
    email: user.email,
    wallet: user.wallet,
    residency: kycProfiles.residencyStatus,
  })
  .from(user)
  .innerJoin(kycProfiles, eq(kycProfiles.userId, user.id))
  .where(eq(kycProfiles.country, "US"))
  .all();

// Filter for accredited investors (citizens and permanent residents)
const accredited = usInvestors.filter(
  (inv) => inv.residency === "citizen" || inv.residency === "permanent_resident"
);

DALP lifecycle integration: The KYC approval workflow triggers on-chain identity deployment:

  1. Admin approves KYC submission (sets updatedAt to current time)
  2. Backend job detects approval and calls identity factory contract
  3. New identity contract deployed with user.wallet as owner
  4. Identity registered in country-specific registry using kycProfiles.country
  5. Subgraph indexes Identity and RegisteredIdentity entities
  6. User can now pass transfer restriction checks for country-specific tokens

Observability: Monitor KYC processing with:

  • Approval latency — Time between createdAt and updatedAt for approved profiles
  • Pending KYC queue depth — Count of records where updatedAt == createdAt (not yet reviewed)
  • Country distribution — Breakdown of country values to identify geographic concentration
  • Residency status mix — Ratio of citizens to residents for risk assessment

The compliance dashboard (Grafana panel: kyc-metrics) visualizes these metrics in real-time.

On-chain entities (Subgraph)

On-chain entities capture blockchain state that smart contracts use for transfer restriction logic. The subgraph indexes events emitted by identity contracts, identity registries, and token contracts, maintaining a queryable graph database that the dApp uses for portfolio views and compliance checks.

Schema location: kit/subgraph/schema.graphql

Account

Represents any Ethereum address—externally owned accounts (EOAs) or smart contracts. Every address that appears in a transaction or event log gets an Account entity.

Business context: Accounts are the universal entity for all on-chain participants. When an investor receives tokens, their wallet address becomes an Account with a TokenBalance relationship. When a DvP settlement completes, both buyer and seller Account entities are updated with new balances. Vault contracts also have Account entities, enabling balance queries for custody reporting.

Fields

FieldTypePurpose
idBytes!Ethereum address (lowercase); primary key
isContractBoolean!true for smart contracts, false for EOAs
contractNameStringHuman-readable name if address is a known contract (e.g., "BondVault")
balances[TokenBalance!]!Token holdings across all asset types
statsAccountStatsStateAggregated metrics (total value, transaction count)
systemStats[AccountSystemStatsState!]!System-level activity statistics
tokenFactoryStats[AccountTokenFactoryStatsState!]!Asset issuance statistics for admins
identities[Identity!]!Identity contracts owned by this account (usually 0 or 1 for investors)
registeredIdentities[RegisteredIdentity!]!Registry entries linking account to verified identities

Relationships:

  • 1:N — balances (see Token entities)
  • 1:N — identities (identity contracts where this account is the owner)
  • 1:N — registeredIdentities (registry entries for this account)
  • 1:1 — stats (aggregated account metrics)

Usage example: portfolio query

query GetInvestorPortfolio($address: Bytes!) {
  account(id: $address) {
    id
    isContract
    balances(where: { balance_gt: "0" }) {
      token {
        name
        symbol
        assetType
      }
      balance
      valueUSD
    }
    identities {
      id
      isVerified
      country
    }
  }
}

DALP lifecycle context: DvP settlement queries both buyer and seller Account entities to verify:

  • Both accounts have identities entries (not anonymous addresses)
  • Both identities have isVerified: true (KYC approved)
  • Both identities are registered in the token's allowed countries

Vault deposit operations check the depositor's Account to ensure they have sufficient token balance before accepting custody.

Identity

On-chain identity contract implementing ERC-734 (key management) and ERC-735 (claims). Each verified investor receives one identity contract that holds management keys and regulatory claims.

Business context: Identity contracts are the on-chain representation of KYC approval. Smart contracts call identity.isVerified() before allowing token transfers. The claims array stores attestations from trusted issuers (e.g., "this address is a US accredited investor"). Keys allow delegated management—an investor can grant a financial advisor an ACTION key to trade on their behalf without transferring funds.

Fields

FieldTypePurpose
idBytes!Identity contract address; primary key
accountAccount!Owner account (the investor's wallet)
keys[IdentityKey!]!ERC-734 keys for management and actions
claims[IdentityClaim!]!ERC-735 claims attached by issuers
isVerifiedBoolean!Verification status (claimed by trusted issuer)
countryIntISO 3166-1 numeric country code if registered in a country-specific registry

Relationships:

  • N:1 — account (many identities can theoretically belong to one account, but typically 1:1)
  • 1:N — keys (management and action keys)
  • 1:N — claims (see Compliance entities)

Usage example: identity verification check

query CheckIdentityStatus($address: Bytes!) {
  identity(id: $address) {
    id
    isVerified
    country
    keys(where: { isActive: true, purposes_contains: [1] }) {
      key
      keyType
    }
    claims(where: { isActive: true }) {
      topic
      issuer {
        id
      }
      data
    }
  }
}

DALP lifecycle integration:

  • DvP pre-settlement checks — Query both buyer and seller identities to verify isVerified: true before locking funds
  • Vault access control — Vault contracts call identity.keyHasPurpose(msg.sender, ACTION) to allow delegated withdrawals
  • Yield eligibility — Interest distribution logic filters recipients where identity.country matches allowed jurisdictions

Observability: Track identity contract deployment and usage:

  • Identity creation rate — Monitor Identity entity count growth over time
  • Verification lag — Time between identity contract deployment and isVerified changing to true
  • Key rotation events — Alert on IdentityKey additions/removals for security auditing
  • Claim attestations — Track IdentityClaim count per identity to measure verification depth

The identity verification dashboard (Grafana panel: identity-metrics) visualizes these metrics.

RegisteredIdentity

Identity registered in the system's identity registry. While Identity entities can exist independently, RegisteredIdentity entries indicate formal registration in a country-specific registry that tokens reference for compliance checks.

Business context: Token contracts don't check identities directly—they query identity registries. A US corporate bond token's compliance module calls usIdentityRegistry.isVerified(investorAddress), which looks up the RegisteredIdentity entry. This indirection allows token issuers to revoke access (by removing the registry entry) without modifying the investor's identity contract.

Fields

FieldTypePurpose
idBytes!Composite key: registryAddress + accountAddress
accountAccount!Registered investor account
identityIdentity!Linked identity contract
countryInt!ISO 3166-1 numeric country code (registry-specific)
registeredAtBigInt!Block timestamp of registration
updatedAtBigInt!Last modification time (for re-verification events)
isVerifiedBoolean!Current verification status (can be revoked)

Relationships:

  • N:1 — account (one account can be registered in multiple country registries)
  • N:1 — identity (one identity can appear in multiple registries)

Usage example: registry-filtered investor query

query GetVerifiedInvestors($registryAddress: Bytes!) {
  registeredIdentities(
    where: { isVerified: true, id_starts_with: $registryAddress }
    orderBy: registeredAt
    orderDirection: desc
  ) {
    account {
      id
      balances(where: { token_: { registry: $registryAddress } }) {
        token {
          name
          symbol
        }
        balance
      }
    }
    identity {
      id
      country
    }
    registeredAt
  }
}

DALP lifecycle context:

  • Token transfer validation — Transfer restriction contracts query RegisteredIdentity.isVerified before allowing transfers
  • DvP eligibility — Settlement logic verifies both parties have RegisteredIdentity entries in the token's registry
  • Yield distribution — Interest payment contracts iterate RegisteredIdentity entries to build recipient lists

Observability: Monitor registry operations:

  • Registration rate — Track new RegisteredIdentity entities per day
  • Revocation events — Alert when isVerified changes from true to false
  • Country distribution — Breakdown of country values to track geographic expansion
  • Stale registrations — Identify entries where updatedAt is very old (potential re-verification candidates)

The compliance registry dashboard (Grafana panel: registry-metrics) provides real-time visibility.

IdentityKey

ERC-734 key for identity management and delegation. Keys enable multi-party control of an identity contract—investors can grant advisors, custodians, or automated systems specific permissions without sharing private keys.

Business context: Keys support operational workflows beyond simple wallet ownership. An institutional investor might grant their compliance officer a MANAGEMENT key to update claims, while giving their trading desk an ACTION key to initiate transfers. The purposes array defines what each key can do, enabling fine-grained access control.

Fields

FieldTypePurpose
idBytes!Composite key: identityAddress + keyHash
identityIdentity!Identity contract this key controls
keyBytes!Key hash (typically keccak256(address) for Ethereum keys)
purposes[BigInt!]!Array of purpose codes (see below)
keyTypeBigInt!Key type (1 = ECDSA, 2 = RSA, etc.)
addedAtBigInt!Block timestamp when key was added
removedAtBigIntBlock timestamp when key was revoked (null if still active)
isActiveBoolean!true if key is currently valid

Key purposes:

CodePurposeDescription
1MANAGEMENTCan add/remove other keys
2ACTIONCan execute actions (e.g., token transfers, vault operations)
3CLAIMCan sign claims for other identities
4ENCRYPTIONCan encrypt/decrypt data (off-chain use)

Note: A single key can have multiple purposes (e.g., [1, 2] for combined management and action permissions).

Usage example: key audit query

query GetIdentityKeys($identityAddress: Bytes!) {
  identity(id: $identityAddress) {
    id
    keys(orderBy: addedAt, orderDirection: desc) {
      key
      purposes
      keyType
      addedAt
      removedAt
      isActive
    }
  }
}

DALP lifecycle integration:

  • Vault withdrawal delegation — Vault contracts check keyHasPurpose(msg.sender, ACTION) to allow non-owner withdrawals
  • DvP automation — Institutional participants grant ACTION keys to settlement engines for atomic trades
  • Compliance updates — Issuers grant CLAIM keys to third-party KYC providers for automated re-verification

Observability: Track key lifecycle events:

  • Key rotation frequency — Monitor IdentityKey additions and removals over time
  • Permission escalation — Alert when MANAGEMENT keys are added (requires admin review)
  • Dormant keys — Identify keys with isActive: true but old addedAt timestamps (potential cleanup candidates)
  • Key type distribution — Track keyType usage to identify unsupported key types

The identity security dashboard (Grafana panel: key-audit-metrics) provides real-time key monitoring.

Cross-system workflows

Real-world operations require joining off-chain and on-chain data. These examples demonstrate common patterns for linking PostgreSQL user records to subgraph blockchain state.

Workflow: user portfolio with regulatory context

Scenario: Display an investor's token holdings with their KYC verification status.

import { db } from "@/lib/db";
import { user, kycProfiles } from "@/lib/db/schemas";
import { eq } from "drizzle-orm";
import { graphqlClient } from "@/lib/graphql";
import { gql } from "@apollo/client";

// Step 1: Load user and KYC data from PostgreSQL
const dbUser = await db
  .select({
    id: user.id,
    email: user.email,
    wallet: user.wallet,
    country: kycProfiles.country,
    residencyStatus: kycProfiles.residencyStatus,
  })
  .from(user)
  .leftJoin(kycProfiles, eq(kycProfiles.userId, user.id))
  .where(eq(user.id, userId))
  .get();

if (!dbUser?.wallet) {
  throw new Error("User has not connected a wallet");
}

// Step 2: Query subgraph for on-chain holdings and identity status
const { data } = await graphqlClient.query({
  query: gql`
    query GetPortfolio($address: Bytes!) {
      account(id: $address) {
        balances(where: { balance_gt: "0" }) {
          token {
            name
            symbol
            assetType
          }
          balance
          valueUSD
        }
        identities {
          id
          isVerified
          country
          claims(where: { isActive: true }) {
            topic
            issuer {
              id
            }
          }
        }
      }
    }
  `,
  variables: { address: dbUser.wallet.toLowerCase() },
});

// Step 3: Combine data for UI rendering
const portfolio = {
  user: {
    email: dbUser.email,
    kycCountry: dbUser.country,
    residencyStatus: dbUser.residencyStatus,
  },
  holdings: data.account?.balances || [],
  identity: {
    contract: data.account?.identities[0]?.id,
    verified: data.account?.identities[0]?.isVerified || false,
    claimCount: data.account?.identities[0]?.claims.length || 0,
  },
};

DALP context: This pattern supports DvP settlement pre-checks—before initiating a trade, the UI verifies the user has a verified identity and sufficient token balance.

Workflow: country-based compliance reporting

Scenario: Generate a report of all US investors and their token holdings for regulatory filing.

import { db } from "@/lib/db";
import { user, kycProfiles } from "@/lib/db/schemas";
import { eq } from "drizzle-orm";
import { graphqlClient } from "@/lib/graphql";
import { gql } from "@apollo/client";

// Step 1: Find all US investors in PostgreSQL
const usInvestors = await db
  .select({
    userId: user.id,
    email: user.email,
    wallet: user.wallet,
    firstName: kycProfiles.firstName,
    lastName: kycProfiles.lastName,
  })
  .from(user)
  .innerJoin(kycProfiles, eq(kycProfiles.userId, user.id))
  .where(eq(kycProfiles.country, "US"))
  .all();

// Step 2: Query subgraph for on-chain holdings (batch query)
const walletAddresses = usInvestors
  .map((inv) => inv.wallet?.toLowerCase())
  .filter(Boolean);

const { data } = await graphqlClient.query({
  query: gql`
    query GetAccountsByCountry($addresses: [Bytes!]!) {
      accounts(where: { id_in: $addresses }) {
        id
        balances(where: { balance_gt: "0" }) {
          token {
            name
            symbol
            assetType
          }
          balance
          valueUSD
        }
      }
    }
  `,
  variables: { addresses: walletAddresses },
});

// Step 3: Join data and generate report
const report = usInvestors.map((inv) => {
  const onChainData = data.accounts.find(
    (acc) => acc.id === inv.wallet?.toLowerCase()
  );
  return {
    name: `${inv.firstName} ${inv.lastName}`,
    email: inv.email,
    wallet: inv.wallet,
    holdings: onChainData?.balances || [],
    totalValueUSD: onChainData?.balances.reduce(
      (sum, bal) => sum + Number.parseFloat(bal.valueUSD),
      0
    ),
  };
});

Observability: This query pattern powers the compliance reporting dashboard (Grafana panel: investor-holdings-by-country), which refreshes hourly to provide up-to-date regulatory filing data.

Workflow: identity verification lifecycle

End-to-end flow from user registration to on-chain identity deployment:

Rendering chart...

Timeline (typical):

  1. User registers → user record created, emailVerified: false
  2. Email verification (1-5 min) → Sets emailVerified: true, enables wallet connection
  3. Wallet connection (immediate) → Populates user.wallet field
  4. KYC submission (user action) → Creates kycProfiles record with createdAt timestamp
  5. Admin approval (manual, 1-24h) → Sets kycProfiles.updatedAt to approval time
  6. Identity deployment (automated, ~1 min) → Backend job deploys identity contract, fires IdentityCreated event
  7. Registry registration (automated, ~30s) → Backend calls identityRegistry.registerIdentity()
  8. Subgraph indexing (2-5 blocks) → Identity and RegisteredIdentity entities created
  9. Transfer enabled → User can now receive tokens that require verified identities

Observability checkpoints:

  • Monitor approval lag (step 5) in the KYC metrics dashboard
  • Track deployment failures (step 6) via the blockchain transaction error panel
  • Verify indexing latency (step 8) in the subgraph sync status dashboard

See also

  • Token entities — Query investor token holdings and balances
  • Compliance entities — Identity claims and access control rules
  • DvP settlement — How identity verification enables atomic trades
  • Vault operations — Identity-based access control for custody
  • Data model overview — Complete data architecture reference
  • Authentication guide — User login and wallet connection flows
Token entities
Compliance entities
llms-full.txt

On this page

Why investor entities span two layersOff-chain entities (PostgreSQL)UserFieldsUsage example: investor wallet lookupKYC profileFieldsUsage example: country-based compliance queriesOn-chain entities (Subgraph)AccountFieldsUsage example: portfolio queryIdentityFieldsUsage example: identity verification checkRegisteredIdentityFieldsUsage example: registry-filtered investor queryIdentityKeyFieldsUsage example: key audit queryCross-system workflowsWorkflow: user portfolio with regulatory contextWorkflow: country-based compliance reportingWorkflow: identity verification lifecycleSee also