Skip to content

Holder Verification (5-State Model)

🚧 Partially built — State-C producers pending

Universal Holder Verification Model designed during Joob API integration. Targets three scenarios: read-only display (Joob legacy), actual investment (Aset direct), secondary market (Tokocrypto). The state resolver is FE-complete and the portfolio_positions.source provenance schema is live, but the State-C backend producers (LEGACY_SEEDED, SECONDARY_PURCHASE, and the E→C wallet-connect reconciler) are not yet built — see Reconciler Lambda. So the read-only path works for wallets Aset already knows, but does not yet materialize positions for legacy (Joob RPS) / secondary-market holders who have never onboarded.

Problem

As Aset onboarded Joob legacy RPS holders + planned secondary market (Tokocrypto), three different "holder" scenarios emerged. Without a unified model we would maintain parallel codepaths:

ScenarioExampleIssue
Read-only displayJoob legacy RPS on KaiaUser's wallet has tokens but no Aset DB row
Real depositAset direct pool, Joob new investorsStandard depositsportfolio_positions flow
Secondary marketTokocrypto resale of LP tokenBuyer holds LP without ever depositing

These three need the same investor experience (portfolio page, yield claim, KYC prompts) — but the entry/exit mechanisms differ.

Solution: 5-State Model

One state machine. Five states. Different scenarios = different state transitions, but every wallet ends up in exactly one state at a time.

StateLabelOn-chain LP balance?SBT minted?Aset DB row?Can claim yield?Can redeem?
AVerified Investor
BVerified, No History
CUnverified Holderoptional
DIdentity Only⏳ (queued)
EUnregistered Visitor

Reading the model

"State" = combination of three orthogonal facts about the wallet. A wallet doesn't choose a state — it is in one based on (L1 balance, L2 SBT, L3 DB row). Permissions follow from state.

3-Layer Architecture

The 5 states are derived from 3 independent layers:

┌────────────────────────────────────────────────────────────┐
│ L3 Entitlement (DB)        portfolio_positions row          │
│   ↑ Determines: yield share, redemption rights, UI label    │
├────────────────────────────────────────────────────────────┤
│ L2 Identity (Contract)     PlatformKYCSoulbound SBT         │
│   ↑ Carries: level, jurisdiction (ISO alpha-3), expiry,     │
│     revocation. No US flag — US-person status is DERIVED    │
│     from the country code (see 03-kyc-identity)             │
├────────────────────────────────────────────────────────────┤
│ L1 Ownership (Contract)    LP / RPS / future tranche tokens │
│   ↑ Source of truth: balanceOf() per wallet                 │
└────────────────────────────────────────────────────────────┘
LayerSourceMutable by
L1 OwnershipbalanceOf(wallet) on LP / RPS contractPool contract (mint/burn) + holders (permissionless transfer, pause-gated)
L2 IdentityPlatformKYCSoulbound attributes — level, jurisdiction, issued/expiry, revocation. US-person status is derived from the country code, not a separate attestation flag (03-kyc-identity)SumSub webhook → Aset oracle
L3 Entitlementportfolio_positions rowAset reconciler Lambda + deposit/redemption handlers

State Definitions

State A — Verified Investor

The canonical "investor" state. Has a deposit history with Aset, KYC complete, holds LP.

  • L1: balanceOf(wallet) > 0
  • L2: SBT MINTED, not expired
  • L3: portfolio_positions.tokens > 0

Allowed actions: Deposit, redeem, claim yield, reinvest, transfer LP (permissionless — the recipient needs no verification; they simply land in State C until they KYC — v3-58).

State B — Verified, No History

Wallet completed KYC and got SBT but has no current position. Either fully redeemed or never deposited yet.

  • L1: balanceOf(wallet) == 0
  • L2: SBT MINTED, not expired
  • L3: No active portfolio_positions row (or tokens = 0)

Allowed actions: Browse pools, deposit (becomes A).

State C — Unverified Holder

Wallet holds tokens on-chain but never registered with Aset. Three sub-cases:

  1. Legacy holder — pre-existing RPS holder before Joob API integration (ShardLab, Hashed)
  2. Secondary buyer — purchased LP on Tokocrypto without going through Aset KYC
  3. Transfer recipient — got LP from another wallet (permissionless transfer — no allowlist gate; the recipient just can't redeem/claim until they KYC)
  • L1: balanceOf(wallet) > 0
  • L2: No SBT
  • L3: Optional row with source = LEGACY_SEEDED / TRANSFER_IN / SECONDARY_PURCHASE (added by reconciler)

Allowed actions: View portfolio (read-only), connect wallet, complete KYC → transitions to A.

Yield + redemption blocked in State C

Without verified identity, Aset cannot legally distribute yield or process redemption. UI shows position but blocks claim/redeem CTAs with KYC prompt modal.

State D — Identity Only

Wallet completed KYC submission but SBT mint pending (oracle delay, gas issue, retry).

  • L1: balanceOf(wallet) == 0
  • L2: SBT NOT_MINTED with sbt_mint_queued_at set (mint enqueued) — or FAILED. (There is no PENDING value: sbt_status ∈ {NOT_MINTED, MINTED, FAILED}; the queued/minting state is NOT_MINTED + a non-null sbt_mint_queued_at.)
  • L3: No row

Allowed actions: View landing/marketing pages. Investment CTAs disabled with "Verification pending" tooltip. Auto-promotes to B when SBT mints.

State E — Unregistered Visitor

No identity, no tokens. Just landed on the site.

  • L1: 0 (or not connected at all)
  • L2: No SBT
  • L3: No row

Allowed actions: Browse pools, read docs. Any investment CTA → wallet-connect prompt → KYC flow.

State Transitions

Transition trigger summary

  • E → D: SumSub applicantCreated webhook
  • D → B: PlatformKYCSoulbound.mint() success
  • D → E: kyc_status = REJECTED with reject_type = FINAL
  • B → A: Pool.deposit() + portfolio_positions row created
  • A → B: Full redemption, portfolio_positions.tokens = 0
  • E → C / B → A: Wallet connect — reconciler scans balanceOf(), creates portfolio_positions row with source tag
  • C → A: KYC complete → SBT mint → existing portfolio_positions row activated
  • A → C: SBT burn (revocation) — position remains but actions blocked

Permission Matrix

ActionABCDE
View public pools
View own portfolio✅ (read-only)
Deposit❌ (KYC prompt)❌ (wallet+KYC prompt)
Claim yield❌ (KYC prompt)
Redeem❌ (KYC prompt)
Reinvest
Transfer LP out✅ (permissionless)✅ (permissionless)
Receive LP transfer✅ (becomes C with source=TRANSFER_IN)

LP transfer is not verification-gated. Any wallet holding LP can transfer it peer-to-peer on-chain — verification is enforced only at the value boundaries (deposit, redemption, yield claim require valid KYC), never on the transfer itself. Admin pause() can still freeze all secondary transfers for emergencies. So a State C wallet can move LP but cannot redeem or claim until it completes KYC (→ State A). See v3-58.

Implementation Map

Schema (portfolio_positions)

sql
ALTER TABLE portfolio_positions ADD COLUMN source TEXT
  CHECK (source IN ('DEPOSIT', 'TRANSFER_IN', 'SECONDARY_PURCHASE', 'LEGACY_SEEDED'));
ALTER TABLE portfolio_positions ADD COLUMN entry_price NUMERIC;  -- NAV at acquisition
ALTER TABLE portfolio_positions ADD COLUMN entry_tx_hash TEXT;
ALTER TABLE portfolio_positions ADD COLUMN last_reconciled_at TIMESTAMPTZ;

The source ENUM lets us:

  • Tag legacy seeds during Joob migration (LEGACY_SEEDED)
  • Tag P2P transfers without Aset's involvement (TRANSFER_IN)
  • Tag secondary market buys (SECONDARY_PURCHASE)
  • Standard deposits get DEPOSIT

Reconciler Lambda

Two jobs: (a) keep portfolio_positions.tokens in sync with on-chain balances, and (b) tag how each holding was acquired (source).

Balance sync (periodic, initially hourly):

  1. For each known wallet, query on-chain balanceOf() for every LP/RPS contract
  2. Diff against portfolio_positions.tokens
  3. If on-chain > DB → create/update row (set source — see below)
  4. If on-chain < DB → check for redemption event (expected) or warn (unexpected loss)
  5. Update last_reconciled_at

Source determinationbalanceOf() only says a wallet has tokens, not how it got them. To set source, read the LP/RPS token's Transfer(from, to, value) logs and inspect from:

sourceCondition (by from)
DEPOSITfrom = 0x0 (mint) and a matching deposits row exists
LEGACY_SEEDEDholding on a legacy external contract (Joob RPS / Kaia), tagged from a migration snapshot
SECONDARY_PURCHASEfrom = known marketplace / settlement contract (e.g. Tokocrypto)
TRANSFER_INfrom = arbitrary EOA / other wallet (neither mint nor known marketplace)

source needs Transfer logs, not balance polling

This resolves the "polling vs event-driven" open decision below: balanceOf polling can never produce source (it carries no from). You must read Transfer event logs — either a periodic eth_getLogs scan or a real-time subscription (both expose from; that's an implementation choice, not a capability difference). The practical pattern is to index all LP Transfers into a local table, then look up each wallet's acquisition transfer to tag source.

Two limits:

  • SECONDARY_PURCHASE vs TRANSFER_IN is distinguishable only if the marketplace settles through a recognizable contract address (held in a known-address registry). An OTC wallet-to-wallet trade is indistinguishable from a plain transfer → falls back to TRANSFER_IN.
  • Determination cross-references: on-chain Transfer logs + Aset deposits table + known-address registry + migration snapshot.

⚠️ Implementation status (2026-07-01)

Only TRANSFER_IN tagging is built (indexer/writers/lp-transfer.ts), and only for wallets already registered in Aset. Not yet built: LEGACY_SEEDED (no Joob RPS migration-snapshot writer), SECONDARY_PURCHASE (no known-marketplace registry), and the E→C wallet-connect reconciler (a newly connecting wallet with an existing balance gets no position row). Until these ship, State C exists in the FE resolver but is unpopulated for the doc's headline legacy/secondary scenarios.

Frontend State Resolver

ts
function resolveHolderState(wallet, sbt, positions): HolderState {
  const hasBalance = positions.some((p) => p.tokens > 0);
  const hasSbt = sbt?.status === 'MINTED' && !sbt.expired;
  // No 'PENDING' status exists — queued = NOT_MINTED with sbt_mint_queued_at set.
  const sbtPending = (sbt?.status === 'NOT_MINTED' && sbt?.queuedAt != null) || sbt?.status === 'FAILED';

  if (hasBalance && hasSbt) return 'A';
  if (!hasBalance && hasSbt) return 'B';
  if (hasBalance && !hasSbt) return 'C';
  if (sbtPending) return 'D';
  return 'E';
}

UI components consult this resolver to gate buttons / show modals.

Open Decisions (Phase 2.5 — needs PM sign-off)

Pending decisions

  • [x] 5-state model adopted — the resolver shipped in apps/web/app/shared/lib/holder-state.ts (resolveHolderState, used by portfolio.tsx) under v3-51.
  • [x] portfolio_positions.source values final — DEPOSIT / TRANSFER_IN / SECONDARY_PURCHASE / LEGACY_SEEDED, enforced live as a CHECK constraint (migration 0014). Settling the value list did not build the producers — only TRANSFER_IN is written today; see Reconciler Lambda.
  • [ ] State B "Sync pending" UI copy/policy (immediate fetch vs delayed reconcile)
  • [ ] State C KYC prompt modal copy + retention policy
  • [ ] Reconciler cadence — hourly polling vs event-driven (LP transfer events)

Why one model for three scenarios

ScenarioWithout 5-StateWith 5-State
Joob legacy RPS holderSpecial "legacy" code pathState C with source = LEGACY_SEEDED
Aset direct depositStandard flowState A via E → D → B → A
Joob new investorTreated like Aset directSame — State A
Tokocrypto secondary buyNeeds new "external acquisition" pathState C with source = SECONDARY_PURCHASE, KYC unlocks A
Wallet receives LP transferEdge case to handle separatelyState C with source = TRANSFER_IN

Every "wallet holds tokens but Aset didn't sell them directly" case collapses into State C with appropriate source. No special-case codepaths.

References