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:
| Scenario | Example | Issue |
|---|---|---|
| Read-only display | Joob legacy RPS on Kaia | User's wallet has tokens but no Aset DB row |
| Real deposit | Aset direct pool, Joob new investors | Standard deposits → portfolio_positions flow |
| Secondary market | Tokocrypto resale of LP token | Buyer 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.
| State | Label | On-chain LP balance? | SBT minted? | Aset DB row? | Can claim yield? | Can redeem? |
|---|---|---|---|---|---|---|
| A | Verified Investor | ✅ | ✅ | ✅ | ✅ | ✅ |
| B | Verified, No History | ❌ | ✅ | ❌ | — | — |
| C | Unverified Holder | ✅ | ❌ | optional | ❌ | ❌ |
| D | Identity Only | ❌ | ⏳ (queued) | ❌ | — | — |
| E | Unregistered 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 │
└────────────────────────────────────────────────────────────┘| Layer | Source | Mutable by |
|---|---|---|
| L1 Ownership | balanceOf(wallet) on LP / RPS contract | Pool contract (mint/burn) + holders (permissionless transfer, pause-gated) |
| L2 Identity | PlatformKYCSoulbound 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 Entitlement | portfolio_positions row | Aset 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_positionsrow (ortokens = 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:
- Legacy holder — pre-existing RPS holder before Joob API integration (ShardLab, Hashed)
- Secondary buyer — purchased LP on Tokocrypto without going through Aset KYC
- 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_MINTEDwithsbt_mint_queued_atset (mint enqueued) — orFAILED. (There is noPENDINGvalue:sbt_status ∈ {NOT_MINTED, MINTED, FAILED}; the queued/minting state isNOT_MINTED+ a non-nullsbt_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
applicantCreatedwebhook - D → B:
PlatformKYCSoulbound.mint()success - D → E:
kyc_status = REJECTEDwithreject_type = FINAL - B → A:
Pool.deposit()+portfolio_positionsrow created - A → B: Full redemption,
portfolio_positions.tokens = 0 - E → C / B → A: Wallet connect — reconciler scans
balanceOf(), createsportfolio_positionsrow withsourcetag - C → A: KYC complete → SBT mint → existing
portfolio_positionsrow activated - A → C: SBT burn (revocation) — position remains but actions blocked
Permission Matrix
| Action | A | B | C | D | E |
|---|---|---|---|---|---|
| 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)
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):
- For each known wallet, query on-chain
balanceOf()for every LP/RPS contract - Diff against
portfolio_positions.tokens - If on-chain > DB → create/update row (set
source— see below) - If on-chain < DB → check for redemption event (expected) or warn (unexpected loss)
- Update
last_reconciled_at
Source determination — balanceOf() 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:
source | Condition (by from) |
|---|---|
DEPOSIT | from = 0x0 (mint) and a matching deposits row exists |
LEGACY_SEEDED | holding on a legacy external contract (Joob RPS / Kaia), tagged from a migration snapshot |
SECONDARY_PURCHASE | from = known marketplace / settlement contract (e.g. Tokocrypto) |
TRANSFER_IN | from = 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_PURCHASEvsTRANSFER_INis 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 toTRANSFER_IN.- Determination cross-references: on-chain
Transferlogs + Asetdepositstable + 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
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 byportfolio.tsx) under v3-51. - [x]
portfolio_positions.sourcevalues final —DEPOSIT/TRANSFER_IN/SECONDARY_PURCHASE/LEGACY_SEEDED, enforced live as aCHECKconstraint (migration 0014). Settling the value list did not build the producers — onlyTRANSFER_INis 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
| Scenario | Without 5-State | With 5-State |
|---|---|---|
| Joob legacy RPS holder | Special "legacy" code path | State C with source = LEGACY_SEEDED |
| Aset direct deposit | Standard flow | State A via E → D → B → A |
| Joob new investor | Treated like Aset direct | Same — State A |
| Tokocrypto secondary buy | Needs new "external acquisition" path | State C with source = SECONDARY_PURCHASE, KYC unlocks A |
| Wallet receives LP transfer | Edge case to handle separately | State 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
- KYC & Identity — SumSub flow, SBT issuance, RETRY vs FINAL
- Core Concepts → LP Issuance — why LP is single source of truth
- Pool Models — per-pool KYC gating that intersects with State A permissions