Skip to content

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:

PathGuardRevert
Instant settlement (approveRedemption, partner top-up auto-settle)RedemptionLib.sol:544RedemptionBlockedByKyc
Permissionless fallback (claimRedemptionFallback)RedemptionLib.sol:416RedemptionBlockedByKyc
Epoch claim (claimRedemption)RedemptionLib.sol:1049KYCRequired

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 inlinekyc_status flips to APPROVED immediately, while the mint completes asynchronously on the consumer. Clients (investor card, admin KYC page) poll sbt_status (NOT_MINTEDMINTED / 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 ; 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 already APPROVED+MINTED user 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 (PlatformPooljurisdictionAllowed[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.tsresolveSbtSync, 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-chainDB writeWhy
read failed (null)noneUnknown ≠ absent. A node blip must not reset a legitimate investor.
VALIDadvance a lagging row to APPROVED / MINTED / INVESTORThe 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.
EXPIREDnoneThe 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.
REVOKEDkyc_status = REJECTED onlyTightens 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.
NONEsbt_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:

DimensionTypeMeaning
enforce_jurisdictionBOOLEANMaster switch (0064). Off = no country restriction; on = the whitelist applies.
jurisdiction_whitelistTEXT[]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_personsBOOLEANWhen 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_ENABLED stays off, and the institution option in KYCModal is not a shippable path.
  • The kyc_level enum (INDIVIDUAL/INSTITUTION) and SUMSUB_LEVEL_MAP stay 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 관할권 벤치마크 & 결정"):

AxisPolicy
Target jurisdictionsSame 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 eligibilityRegulated / 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.
DocumentsStandard 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).
RepresentativeA non-director signer needs a board resolution or PoA; identity (liveness) alone is insufficient — the person must be linked to the company record.
SumSubAbsorbs 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: ~minutes

Requirements:

  • 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 minutes

No partner integration needed. Works regardless of partner's KYC provider.

Decision Tree

QuestionTrack
Partner uses SumSub? Yes → enable Reusable KYC partnership? YesTrack 1
Partner uses SumSub but Reusable KYC partnership not signedTrack 2 (until partnership)
Partner uses non-SumSub provider (Onfido, Jumio, Veriff, etc.)Track 2
No partner relationship — fresh userStandard 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 sharedUser identity verification dataTransaction sender/receiver info
WhenAt onboardingPer transaction above threshold
ThresholdNoneIndonesia $1K, Singapore SGD 1.5K
StandardSumSub Reusable KYCIVMS101 (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

PhaseWhenAction
Phase 1NowAset SumSub KYC running standalone. UI hint for "existing user" path.
Phase 2Tokocrypto agreement signedConfirm Tokocrypto KYC stack → enable Reusable KYC partnership if applicable.
Phase 3Multi-partner expansionReusable 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-token returns a returning flag 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 LogG2 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.tsmarkApplicantInReview, 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:

  1. The SDK's idCheck.onApplicantSubmitted awaits POST /kyc/sync before the modal closes (server re-reads reviewStatus from SumSub, so a client event is a prompt to check, never the evidence).
  2. The applicantPending webhook, authoritative and independent of the browser.
  3. 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_level is 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 answers init for 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 Reasonreject_typeExamples
Document quality issues⚠️ RETRYBlurry photo, glare, cropped ID, expired document
Information mismatch⚠️ RETRYName mismatch, wrong document type submitted
Sanctioned country🔴 FINALOFAC / EU sanctions list match
Fraud detected🔴 FINALForged document, stolen identity
Underage🔴 FINALApplicant under 18
Duplicate applicant🔴 FINALSame 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).