KYC & Identity
✅ Built — deploy/QA pending
SumSub integration, per-pool jurisdiction gating (jurisdiction_whitelist + enforce_jurisdiction; KYC/KYB level gating was removed in migration 0063), cross-VASP reusable KYC (v3-17), the reverify-sweep, the SBT mint queue, and the expiry dashboard banner are all implemented across DB, handlers, contract, and FE.
SumSub integration for KYC/KYB verification, Soulbound Token issuance, per-pool gating, and rejection criteria.
Soulbound Token (SBT)
🪪 SBT Overview
SumSub integration for KYC/KYB. On approval, a non-transferable ERC-721 (PlatformKYCSoulbound) is minted on-chain — a global contract (not per-pool). kyc_status = APPROVED required before any investment. SBT is burned if KYC is revoked.
The SBT stores: KYC level (INDIVIDUAL/INSTITUTION), jurisdiction (ISO 3166-1 alpha-3 country code), issued/expiry timestamps, and revocation status. US-person status is derived from the country code — there is no separate attestation flag. Per-pool deposit checks read jurisdiction + validity from the SBT; the level is stored but no longer gates deposits (pool-level KYC/KYB gating was removed in migration 0063).
Revocation vs redemption (resolved, v3-19): the on-chain kycStateOf is the exit-gate source of truth with four states — NONE / VALID / EXPIRED / REVOKED. EXPIRED blocks new deposits but still allows redemptions (canRedeem returns true), so an investor whose KYC simply lapsed can always exit. REVOKED (an ongoing-AML hit, set via on-chain revoke()) blocks both deposit and redemption while keeping the token on-chain for audit.
All four states at the exit gate. canRedeem is VALID || EXPIRED (PlatformKYCSoulbound.sol:371-373), so NONE is refused exactly like REVOKED — this was previously left unstated and read as if only REVOKED blocked an exit. NONE covers a wallet that never held an SBT and one whose SBT was burned; either way there is no token to read a validity from, so the gate cannot pass it. In practice a holder reaches NONE only through a burn, since depositing required a VALID SBT in the first place.
The refusal surfaces as two different reverts depending on the path, which matters for anything mapping error names to copy:
| Path | Guard | Revert |
|---|---|---|
Instant settlement (approveRedemption, partner top-up auto-settle) | RedemptionLib.sol:544 | RedemptionBlockedByKyc |
Permissionless fallback (claimRedemptionFallback) | RedemptionLib.sol:416 | RedemptionBlockedByKyc |
Epoch claim (claimRedemption) | RedemptionLib.sol:1049 | KYCRequired |
Cancelling an epoch request inherits the epoch-claim gate rather than carrying its own: cancelRedemption reverts ClaimBeforeCancel while any part has settled (:628), so a blocked holder cannot complete step 1 and therefore cannot reach the cancel. The unwind route for that case is administrative (Part A).
SBT Minting & Recovery (queue-based)
All on-chain SBT operations — mint, burn, revoke — run on a single platform signer wallet, so they are funneled through one FIFO SQS queue with a single message group. The consumer drains them one at a time, which makes nonce collisions impossible (a separate queue per op would reintroduce the race against the mint consumer).
🔁 Async mint (v3 queue migration)
On a GREEN review the SBT mint is enqueued, not minted inline — kyc_status flips to APPROVED immediately, while the mint completes asynchronously on the consumer. Clients (investor card, admin KYC page) poll sbt_status (NOT_MINTED → MINTED / FAILED) instead of reading a tx hash from the request. POST /kyc/mint-sbt therefore returns 202 { status: 'queued', userId }.
One mint in flight per user, enforced by enqueueSbtMint for every caller (the endpoint's plain and force paths, the GREEN review, the reconcile sweep). It is a conditional UPDATE that claims the sbt_mint_queued_at slot — atomic, so two concurrent callers cannot both win it, and the claim resets the previous outcome (sbt_status/sbt_error, plus the token mirror on a force re-mint) in the same write. A refused claim returns 409 from the endpoint and is skipped by the sweep. The guard used to sit in one branch of the endpoint, so the plain path could queue a second mint that burns the token the first one just minted.
🩺 Recovery — webhook-loss sweep + queue retries
Two independent safety nets:
- Queue redrive — a mint/burn/revoke whose transaction reverts or times out is retried 5×; after that it lands in the DLQ (14-day retention), which triggers a CloudWatch alarm → ops email for manual investigation (signer balance, chain config, contract pause).
- Reconcile sweep (every 5 min) — polls SumSub for users stuck in
IN_REVIEW(lost webhooks) and re-queues stuck mints (APPROVED+NOT_MINTED/FAILED). Only rows untouched for 10 min are swept, so it never races an in-flight webhook/consumer; every path is idempotent (a GREEN for an alreadyAPPROVED+MINTEDuser is a no-op).
A true webhook loss (SumSub gave up, or our endpoint was down) leaves no message to retry, so the pull-based sweep — not SQS — is the only backstop for it.
🔴 The mint refuses an unverified country
mintKYCSBT takes countryCode as a required parameter with no default. On-chain that one string decides two gates — the jurisdiction whitelist (PlatformPool → jurisdictionAllowed[kycContract.jurisdictionHashOf(investor)]) and the US-person rule (PlatformKYCSoulbound._isUsCountry) — so a fallback value would issue a credential asserting a jurisdiction the investor was never verified in, and the on-chain deposit gate would honour it. It previously defaulted to 'KOR'.
mintSbtForApprovedUser resolves the country through resolveInvestorCountry (business/kyc-gating.ts) — the same helper the off-chain deposit gate uses, so both sides read one country (institutions: company_country ?? country). When the mirror on users is empty it pulls the applicant from SumSub once and persists it; if the country still cannot be established the mint fails closed (sbt_status = FAILED, sbt_error = 'Investor country could not be verified', retryable) rather than guessing. Resolution happens before the re-certification burn, so a refusal never leaves the holder with no credential at all.
Login-time reconcile — DB against the chain
POST /auth/verify reads the holder's on-chain state and reconciles the users row against it (auth/sbt-sync.ts → resolveSbtSync, pure + unit-tested; the handler applies the write).
It reads the four-state kycStateOf, not the boolean isValidKYC. That boolean returns false for EXPIRED and REVOKED alike, and collapsing those into "no SBT" is what previously let a single login (a) reset an AML-REJECTED holder to NOT_STARTED, clearing the rejection that isFinalRejected reads and re-opening KYC for them, and (b) drop an EXPIRED holder to GUEST/NOT_STARTED, which the off-chain value-out gates read as "no KYC" and which therefore blocked a withdrawal the on-chain canRedeem explicitly allows.
| On-chain | DB write | Why |
|---|---|---|
read failed (null) | none | Unknown ≠ absent. A node blip must not reset a legitimate investor. |
VALID | advance a lagging row to APPROVED / MINTED / INVESTOR | The chain is the KYC source of truth. Never applied to a REJECTED row: a queued AML revoke still reads VALID for a moment, and healing it would undo the compliance decision. |
EXPIRED | none | The credential exists. Expiry blocks new deposits, never the holder's own exit (v3-28 "no indefinite trap"). Renewal is driven by sbt_expires_at, not by this write. |
REVOKED | kyc_status = REJECTED only | Tightens a row that still claims APPROVED (a revoke can land out-of-band, e.g. an operator revoking on chain). sbt_status stays MINTED and the token id is kept — the token exists, just revoked. |
NONE | sbt_status = NOT_MINTED, role = GUEST (+ kyc_status = NOT_STARTED) | Definitive absence, and only when the row still claims a token. A REJECTED verdict is preserved: resetting it to "never started" would re-open the FINAL-rejection retry ban. |
GET /kyc/status applies the same VALID-only healing rule (and the same REJECTED exclusion) via kyc/self-heal.ts, but it only reads the chain when the row already records a minted token — it is the 10s KYC poll, and a holder with no credential gets no wallet lookup and no RPC call. A row lagging an externally issued SBT is therefore healed at sign-in (above) or by the reconcile sweep, not by the poll.
Per-Pool KYC Gating (v3.0)
Level gating removed (migration 0063)
Pools no longer gate by KYC/KYB level (individual vs institution). kyc_level_required and requires_institutional were dropped in migration 0063 — current pool gating is jurisdiction + valid SBT only. On top of that, the non-US Reg S qualified-investor model is implemented backend-side (migration 0072, no on-chain change): each pool has an eligibility_mode (STATUS = Status Gate / MIN_TICKET = Ticket Gate) and each user an investor_status (RETAIL/PROFESSIONAL) + qualification_* fields. checkKycGating enforces the mode and returns reason codes (NOT_PROFESSIONAL, BELOW_MIN_TICKET, KYC_EXPIRED, …). See 24-field-governance and the KYC/KYB & investor-tier spec (v3-74).
Each pool declares its jurisdiction rules on the pools table:
| Dimension | Type | Meaning |
|---|---|---|
enforce_jurisdiction | BOOLEAN | Master switch (0064). Off = no country restriction; on = the whitelist applies. |
jurisdiction_whitelist | TEXT[] | ISO 3166-1 alpha-3 country codes allowed ("KOR", not "KR" — v3-60). Empty array + enforce on = block all. (renamed from kyc_jurisdiction_whitelist, migration 0070) |
allows_us_persons | BOOLEAN | When false (default), US-country investors are rejected (US-person derived from the SBT country code). |
The gate runs in two places: the backend checkKycGating() (pre-check in pools.get.eligibility + deposits.post.create) and the on-chain _checkEntryKyc (PlatformPool), which requires a valid non-revoked SBT and — if enforceJurisdiction — the investor's country hash in the on-chain whitelist. If the investor's SBT doesn't satisfy the pool's gating, the deposit reverts with a clear error.
Examples
- Retail pool, open jurisdictions:
enforce_jurisdiction = false,allows_us_persons = false→ any KYC'd non-US investor. - Korea/Japan/Singapore pool:
enforce_jurisdiction = true,jurisdiction_whitelist = ['KOR', 'JPN', 'SGP'],allows_us_persons = false.
See Pool Models → Compliance Dimensions for the full configuration story.
KYB (Institution Onboarding)
🚫 Dropped from the platform — individual track only (v3-98)
KYB (legal-entity onboarding) is out of scope. Decided 2026-07-29: the platform onboards individuals only. This is not a "later phase" — there is no target date, and the August 2026 SumSub Enterprise KYB subscription the previous plan depended on is not being taken.
How to read the rest of this section:
- Only the INDIVIDUAL track exists end to end.
VITE_KYB_ENABLEDstays off, and the institution option inKYCModalis not a shippable path. - The
kyc_levelenum (INDIVIDUAL/INSTITUTION) andSUMSUB_LEVEL_MAPstay in place — the SBT stores a level, and migration 0063 already removed level-based pool gating, so the dormant INSTITUTION branch is harmless. Its presence is not evidence that KYB works. - The policy table below is retained as research, not as a roadmap. It cost real jurisdiction work and stays for whenever entity onboarding is revisited; nothing in it is being built.
- Corollary open item — investor-facing copy. A "coming soon" message for a dropped feature is a promise the product will not keep. See v3-98.
Retained KYB research — not a build plan (v3-75, superseded by v3-98; benchmark = Notion "KYB 관할권 벤치마크 & 결정"):
| Axis | Policy |
|---|---|
| Target jurisdictions | Same as the KYC/Reg S allowlist (SG·HK·EU·JP·UAE·CH·UK·TH·MY) — no separate KYB list. Indonesia is a separate OJK-sandbox track. |
| Entity eligibility | Regulated / institutional entities auto-qualify as PROFESSIONAL; other corporates pass a "large-undertaking 2-of-3" test (balance sheet 20M / turnover 40M / own funds 2M — currency per jurisdiction; ADGM own funds $1M, SG S$10M, HK HK$8M/40M, JP ¥500M or QII, TH THB 100/200M, MY RM10M). |
| UBO (beneficial owner) | Always resolve to natural persons. Collect anyone with ≥20% ownership/voting + anyone exercising control (20% = Malaysia's floor, also covers 25% jurisdictions). Determination is per-jurisdiction: standard 25% + control + senior-managing-official fallback; Japan cascade (>50% sole / else >25% / else control / else representative director); Malaysia 20%. Threshold is a per-jurisdiction/risk config (high-risk 10–15% supported). Register-based UBO lookup is unreliable (most registers non-public) → self-declaration + verification. |
| Documents | Standard set (incorporation cert · constitution/M&A · directors/shareholders registers · registered-address proof · board resolution/PoA · UBO declaration) + jurisdiction extras (SG ACRA Bizfile, HK NAR1, JP registered-matters cert). |
| Representative | A non-director signer needs a board resolution or PoA; identity (liveness) alone is insufficient — the person must be linked to the company record. |
| SumSub | Absorbs collection, registry lookup, UBO mapping, document OCR, and sanctions/PEP screening (Enterprise tier). Final accept/reject and legal liability stay with Aset. |
Open (BD/legal sign-off): whether Aset is a "reporting institution" per jurisdiction (which AML rulebook binds us); final launch countries + exact threshold values (placeholders until then); Indonesia DFA-vs-security characterization; SumSub non-US preset scope.
Cross-VASP KYC (v3-17)
When onboarding users from partner platforms (e.g., Tokocrypto secondary market, Binance.SG), Aset adopts a two-track strategy to minimize friction without sacrificing regulatory compliance.
Track 1 — SumSub Reusable KYC (preferred when partner uses SumSub)
Partner user → "Sign up with my Tokocrypto verification" on Aset
↓
Aset backend: POST /sharetokens to SumSub
↓
User consent modal (per GDPR/PDPA): "Share Tokocrypto KYC with Aset?"
↓
SumSub transfers verified applicant data → Aset's SumSub environment
↓
Aset's SumSub re-runs recipient-level checks:
- jurisdiction_whitelist match (v3-10)
- Expiry, revocation, sanctions screening (always re-run)
↓
Pass → PlatformKYCSoulbound mint → INVESTOR
↓
User-facing time: ~minutesRequirements:
- Aset and partner are both SumSub clients
- Reusable KYC partnership agreement signed (handled at the SumSub admin level)
- User opt-in (data-sharing consent required by regulation)
Track 2 — Fast re-KYC (fallback when partner does NOT use SumSub)
Partner user → "Are you a Tokocrypto user?" on Aset
↓ Yes
SumSub returning-user flow (skip already-uploaded docs if user has existing SumSub identity)
↓
User provides incremental data: jurisdiction / country confirmation (US-person derived from country)
↓
Standard SumSub adjudication
↓
Pass → SBT mint
↓
User-facing time: ~5-10 minutesNo partner integration needed. Works regardless of partner's KYC provider.
Decision Tree
| Question | Track |
|---|---|
| Partner uses SumSub? Yes → enable Reusable KYC partnership? Yes | Track 1 |
| Partner uses SumSub but Reusable KYC partnership not signed | Track 2 (until partnership) |
| Partner uses non-SumSub provider (Onfido, Jumio, Veriff, etc.) | Track 2 |
| No partner relationship — fresh user | Standard SumSub flow (~15 min) |
Why NOT Shortcut KYC Entirely
Some platforms attempt to skip KYC if "user verified elsewhere." This is not allowed for VASPs:
- MAS Singapore, OJK Indonesia, EU MiCA all require each VASP to do its own KYC
- Liability stays with each VASP regardless of upstream verification
- Reusable KYC sidesteps this by re-running our checks on shared data — not by skipping checks
- The "Binance forces fresh KYC at each regional entity" pattern is high-friction but legally clean; Reusable KYC gives same compliance with lower friction
Travel Rule (Separate Concern)
FATF Travel Rule handles transaction-level VASP-to-VASP data (originator/beneficiary info via IVMS101), not user-level KYC.
| Cross-VASP KYC (v3-17) | Travel Rule | |
|---|---|---|
| What's shared | User identity verification data | Transaction sender/receiver info |
| When | At onboarding | Per transaction above threshold |
| Threshold | None | Indonesia $1K, Singapore SGD 1.5K |
| Standard | SumSub Reusable KYC | IVMS101 (Notabene, Sygna, etc.) |
Travel Rule integration is required when funds move between Aset Pool and partner wallets above thresholds. Handled as a separate operational integration — not part of v3-17 scope.
Implementation Phases
| Phase | When | Action |
|---|---|---|
| Phase 1 | Now | Aset SumSub KYC running standalone. UI hint for "existing user" path. |
| Phase 2 | Tokocrypto agreement signed | Confirm Tokocrypto KYC stack → enable Reusable KYC partnership if applicable. |
| Phase 3 | Multi-partner expansion | Reusable KYC supports unlimited partners — same infra for Binance.SG, Coinbase, etc. |
Launch status (2026-06-18, onboarding PRD §7)
- Returning-user fast-path — shipped.
/kyc/access-tokenreturns areturningflag and the KYC modal surfaces a "reusing your existing documents" notice (Track 2 returning flow). Included at launch. - Cross-VASP partner-import entry — deferred (G2). The "import my partner (e.g. Tokocrypto) verification" UI entry stays dormant until a partner deal closes; the backend (
kyc.post.reuse) is kept ready. No partner ⇒ nothing to import. - Sponsor-event re-verification — deferred (G6). There is no admin-initiated re-KYC UI for post-mint AML/sanction flags; only the T-30 expiry sweep (
reverify-sweep) exists. Deferred until the re-verification operations policy is finalized.
Decision records: Notion Decision Log — G2 Cross-VASP 진입점, G6 Sponsor-event 재검증. (5-state holder model deferral is tracked in 21-holder-verification — Phase 2.5.)
KYC Status Flow
🔴 IN_REVIEW means submitted, not started
Opening the SDK is not entry into review. POST /kyc/access-token records only kyc_level; the status moves on SumSub's own signal, through the single writer kyc/enter-review.ts → markApplicantInReview, from either the applicantPending / applicantOnHold webhook or POST /kyc/sync reading the applicant's reviewStatus back (isApplicantSubmitted: anything other than init).
It used to be written at token issuance, which shipped a lockout: a holder who pressed Start and closed the tab was IN_REVIEW with nothing submitted, the status card's PENDING branch has no CTA, and no sweep could clear it because SumSub returns no review result for an applicant that was never submitted. The status claimed a fact the provider had never reported.
Three things close the submitted → recorded gap, so no single failure strands a holder:
- The SDK's
idCheck.onApplicantSubmittedawaitsPOST /kyc/syncbefore the modal closes (server re-readsreviewStatusfrom SumSub, so a client event is a prompt to check, never the evidence). - The
applicantPendingwebhook, authoritative and independent of the browser. - The reconcile sweep's started-but-unrecorded scan:
kyc_level IS NOT NULL AND kyc_status = 'NOT_STARTED', started 10 min to 24 h ago.kyc_levelis written only by the SDK-token path, so that predicate is exactly "a verification was started and no review was ever recorded". Bounded to 24 h because most of that set is holders who walked away, and SumSub answersinitfor them forever.
Rejection Criteria: RETRY vs FINAL
SumSub auto-decides based on rejection reason code. System maps reason → reject_type. Admin cannot override FINAL decisions (compliance requirement).
| Sumsub Reason | reject_type | Examples |
|---|---|---|
| Document quality issues | ⚠️ RETRY | Blurry photo, glare, cropped ID, expired document |
| Information mismatch | ⚠️ RETRY | Name mismatch, wrong document type submitted |
| Sanctioned country | 🔴 FINAL | OFAC / EU sanctions list match |
| Fraud detected | 🔴 FINAL | Forged document, stolen identity |
| Underage | 🔴 FINAL | Applicant under 18 |
| Duplicate applicant | 🔴 FINAL | Same person already verified under different account |
Rules
📋 KYC Rules
- RETRY: No limit on retry attempts (SumSub tracks internally). Investor prompted to re-upload.
- FINAL: Account permanently blocked from investing. Admin can view rejection details but cannot override (compliance requirement).
- Admin KYC detail panel shows: SumSub rejection reason, reject_type (RETRY/FINAL), attempt history.
- Investor KYC status page shows: sanitized rejection reason + retry prompt (RETRY) or permanent block notice (FINAL).