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.
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
| Field | Type | Purpose |
|---|---|---|
id | string | Primary key (UUID); stable identifier for cross-table joins |
name | string | Display name shown in UI; user-editable |
email | string | Unique; used for login and notifications |
emailVerified | boolean | Email confirmation status; gates KYC submission |
image | string | null | Profile avatar URL (optional) |
createdAt | Date | Account creation timestamp for audit trails |
updatedAt | Date | Last modification time; updated on profile changes |
role | UserRole | null | 'admin' for platform operators, 'user' for investors; defaults to 'user' |
banned | boolean | null | Ban status for regulatory enforcement |
banReason | string | null | Explanation displayed to banned users |
banExpires | Date | null | Automatic unban timestamp (null = permanent) |
wallet | Address | null | Connected Ethereum address (checksummed); links to Account.id on-chain |
lastLoginAt | Date | null | Session tracking for security monitoring |
pincodeEnabled | boolean | PIN-based MFA enabled (mobile-friendly) |
pincodeVerificationId | string | null | Reference to pending PIN verification |
twoFactorEnabled | boolean | TOTP authenticator enabled |
twoFactorVerificationId | string | null | Reference to pending 2FA verification |
secretCodesConfirmed | boolean | Backup recovery codes acknowledged |
secretCodeVerificationId | string | null | Reference to backup code setup |
Indexes:
- Primary key:
id - Unique constraint:
email(prevents duplicate accounts)
Relationships:
sessions(1:N) — Active login sessions tracked for securityaccounts(1:N) — OAuth provider links (Google, GitHub, etc.)kycProfiles(1:1) — Identity verification data (see below)apikeys(1:N) — Programmatic access for integrationspasskeys(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
createdAttimestamps to measure user growth - Email verification lag — Alert when
emailVerifiedremainsfalse>24h aftercreatedAt - Wallet connection rate — Percentage of users with non-null
walletfield - Ban actions — Audit log of
bannedflag 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
| Field | Type | Purpose |
|---|---|---|
id | string | Primary key (UUID) |
userId | string | Foreign key to user.id (unique constraint enforces 1:1) |
firstName | string | Legal first name from government ID |
lastName | string | Legal last name; combined with firstName for identity claims |
dob | Date | Date of birth; used for age verification rules |
country | string | ISO 3166-1 alpha-2 code (e.g., "US", "GB"); determines registry eligibility |
residencyStatus | ResidencyStatus | 'citizen', 'permanent_resident', or 'temporary_resident'; gates investment limits |
nationalId | string | National ID number (encrypted at rest); for audit purposes only |
createdAt | Date | KYC submission timestamp |
updatedAt | Date | Last 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.idwithonDelete: 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:
- Admin approves KYC submission (sets
updatedAtto current time) - Backend job detects approval and calls identity factory contract
- New identity contract deployed with
user.walletas owner - Identity registered in country-specific registry using
kycProfiles.country - Subgraph indexes
IdentityandRegisteredIdentityentities - User can now pass transfer restriction checks for country-specific tokens
Observability: Monitor KYC processing with:
- Approval latency — Time between
createdAtandupdatedAtfor approved profiles - Pending KYC queue depth — Count of records where
updatedAt == createdAt(not yet reviewed) - Country distribution — Breakdown of
countryvalues 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
| Field | Type | Purpose |
|---|---|---|
id | Bytes! | Ethereum address (lowercase); primary key |
isContract | Boolean! | true for smart contracts, false for EOAs |
contractName | String | Human-readable name if address is a known contract (e.g., "BondVault") |
balances | [TokenBalance!]! | Token holdings across all asset types |
stats | AccountStatsState | Aggregated 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
identitiesentries (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
| Field | Type | Purpose |
|---|---|---|
id | Bytes! | Identity contract address; primary key |
account | Account! | Owner account (the investor's wallet) |
keys | [IdentityKey!]! | ERC-734 keys for management and actions |
claims | [IdentityClaim!]! | ERC-735 claims attached by issuers |
isVerified | Boolean! | Verification status (claimed by trusted issuer) |
country | Int | ISO 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: truebefore 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.countrymatches allowed jurisdictions
Observability: Track identity contract deployment and usage:
- Identity creation rate — Monitor
Identityentity count growth over time - Verification lag — Time between identity contract deployment and
isVerifiedchanging totrue - Key rotation events — Alert on
IdentityKeyadditions/removals for security auditing - Claim attestations — Track
IdentityClaimcount 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
| Field | Type | Purpose |
|---|---|---|
id | Bytes! | Composite key: registryAddress + accountAddress |
account | Account! | Registered investor account |
identity | Identity! | Linked identity contract |
country | Int! | ISO 3166-1 numeric country code (registry-specific) |
registeredAt | BigInt! | Block timestamp of registration |
updatedAt | BigInt! | Last modification time (for re-verification events) |
isVerified | Boolean! | 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.isVerifiedbefore allowing transfers - DvP eligibility — Settlement logic verifies both parties have
RegisteredIdentityentries in the token's registry - Yield distribution — Interest payment contracts iterate
RegisteredIdentityentries to build recipient lists
Observability: Monitor registry operations:
- Registration rate — Track new
RegisteredIdentityentities per day - Revocation events — Alert when
isVerifiedchanges fromtruetofalse - Country distribution — Breakdown of
countryvalues to track geographic expansion - Stale registrations — Identify entries where
updatedAtis 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
| Field | Type | Purpose |
|---|---|---|
id | Bytes! | Composite key: identityAddress + keyHash |
identity | Identity! | Identity contract this key controls |
key | Bytes! | Key hash (typically keccak256(address) for Ethereum keys) |
purposes | [BigInt!]! | Array of purpose codes (see below) |
keyType | BigInt! | Key type (1 = ECDSA, 2 = RSA, etc.) |
addedAt | BigInt! | Block timestamp when key was added |
removedAt | BigInt | Block timestamp when key was revoked (null if still active) |
isActive | Boolean! | true if key is currently valid |
Key purposes:
| Code | Purpose | Description |
|---|---|---|
| 1 | MANAGEMENT | Can add/remove other keys |
| 2 | ACTION | Can execute actions (e.g., token transfers, vault operations) |
| 3 | CLAIM | Can sign claims for other identities |
| 4 | ENCRYPTION | Can 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
IdentityKeyadditions and removals over time - Permission escalation — Alert when MANAGEMENT keys are added (requires admin review)
- Dormant keys — Identify keys with
isActive: truebut oldaddedAttimestamps (potential cleanup candidates) - Key type distribution — Track
keyTypeusage 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:
Timeline (typical):
- User registers →
userrecord created,emailVerified: false - Email verification (1-5 min) → Sets
emailVerified: true, enables wallet connection - Wallet connection (immediate) → Populates
user.walletfield - KYC submission (user action) → Creates
kycProfilesrecord withcreatedAttimestamp - Admin approval (manual, 1-24h) → Sets
kycProfiles.updatedAtto approval time - Identity deployment (automated, ~1 min) → Backend job deploys identity
contract, fires
IdentityCreatedevent - Registry registration (automated, ~30s) → Backend calls
identityRegistry.registerIdentity() - Subgraph indexing (2-5 blocks) →
IdentityandRegisteredIdentityentities created - 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