Open-source soulbound KYC/AML credential primitive for Solana.
A portable, non-transferable compliance credential that any Solana RWA, DeFi, DAO, or payments application can use to gate access for KYC/AML-verified wallets — without custodying user identity data and without locking integrators into a single KYC provider.
Status: reference implementation, unaudited. Devnet only. Not production code.
Compliance gating on Solana today is fragmented. Every RWA or regulated-access application either (a) re-runs KYC per user, (b) bolts on a proprietary provider, or (c) hard-codes allowlists. There is no shared, open, chain-native primitive for "this wallet has been verified against an AML/KYC standard by a recognised issuer."
ICD-Solana is that primitive. The design is adapted from the ICD credential
on Hedera (the soulbound KYC/AML governance token used by International Credit)
and reimplemented natively for Solana as an ecosystem public good under
Apache-2.0.
Three program-derived accounts:
┌──────────────────────┐
│ Registry (PDA) │ seeds = [b"registry"]
│ admin, paused, │ singleton
│ counters │
└──────────┬───────────┘
│ admin registers
▼
┌──────────────────────┐
│ Issuer (PDA) │ seeds = [b"issuer", authority]
│ authority, name, │ one per issuer authority
│ metadata_uri, │
│ active flag │
└──────────┬───────────┘
│ issuer.authority signs to issue
▼
┌──────────────────────┐
│ Credential (PDA) │ seeds = [b"credential",
│ subject, issuer, │ subject, issuer_authority]
│ issued_at, │ one per (subject, issuer) pair
│ expires_at, │
│ kyc_level (1-3), │
│ claim_hash, │
│ revoked flag │
└──────────────────────┘
Non-transferability is structural, not policy: the credential account is a PDA seeded by the subject's pubkey. There is no token, no mint, no transfer instruction. The account cannot be moved between wallets.
No PII on-chain. Only a 32-byte hash of the off-chain claim document is stored. Issuers retain the authoritative record; the chain stores proof of verification, not identity data.
Revocation. Two paths: (1) the issuing issuer revokes via
revoke_credential, (2) the registry admin revokes via
admin_revoke_credential (used when an issuer is compromised or offline).
Revoked accounts are retained — verifiers observe revoked = true rather than
account-not-found, which preserves audit trails.
Expiry. Credentials may carry an expires_at unix timestamp or 0 for no
expiry. Verification checks expiry off-chain (gating programs do the same via
CPI).
Any gating application — whether client-side JavaScript or an on-chain program invoking via CPI — runs the same five checks:
- Credential account exists at the expected PDA.
revoked == false.expires_at == 0ORexpires_at > clock.unix_timestamp.kyc_level >= required_minimum.issueris in the caller's accepted-issuer set.
The reference implementation of this logic ships in
scripts/lib.ts as verifyCredential() and is mirrored in
the browser demo (app/index.html) for client-side
read-only enforcement.
| Level | Label | Typical use |
|---|---|---|
| 1 | Basic | Tier-1 retail KYC, documentary verification |
| 2 | Enhanced | Tier-2 retail + PEP/sanctions screening |
| 3 | Institutional | Accredited / professional / institutional verification |
The scheme is deliberately compact. Issuers map their own tier models onto
these three levels at registration time (the mapping goes in the issuer's
metadata_uri).
icd-solana-demo/
├── programs/icd_solana/src/lib.rs Anchor program (Rust)
├── tests/icd_solana.ts Integration tests (mocha)
├── scripts/
│ ├── lib.ts Shared client library
│ ├── initialize.ts Initialize registry (once, post-deploy)
│ ├── register-issuer.ts Admin: register an issuer
│ ├── issue.ts Issuer: issue a credential
│ ├── verify.ts Anyone: verify a credential (CLI)
│ └── revoke.ts Issuer: revoke a credential
├── app/index.html Browser gate demo (standalone)
├── Anchor.toml
├── Cargo.toml
├── package.json
├── tsconfig.json
├── LICENSE Apache-2.0
└── README.md
- Rust 1.79+ and the Solana BPF target
- Solana CLI 1.18+
- Anchor CLI 0.30.1 (
avm install 0.30.1 && avm use 0.30.1) - Node 18+ and either npm or yarn
# Install JS dependencies
npm install
# Create a keypair for the payer/admin if you don't have one
solana-keygen new --outfile ~/.config/solana/id.json # skip if you have one
solana config set --url https://api.devnet.solana.com
solana airdrop 2anchor buildFirst build will generate the program keypair at
target/deploy/icd_solana-keypair.json. Sync the generated pubkey into
Anchor.toml and declare_id!(...) in lib.rs:
anchor keys sync
anchor build # rebuild with the synced IDanchor testThis boots a local validator, deploys the program, and runs the full mocha test suite covering:
- Registry initialization
- Issuer registration (happy path + non-admin rejection)
- Credential issuance (happy path + invalid KYC level + unregistered issuer)
- Revocation (happy path + double-revocation rejection + wrong-signer rejection)
- Pause/unpause and paused-issuance rejection
anchor build
anchor deploy --provider.cluster devnet
# Initialize the registry
ANCHOR_PROVIDER_URL=https://api.devnet.solana.com \
ANCHOR_WALLET=~/.config/solana/id.json \
npx ts-node scripts/initialize.tsmkdir -p .keys
ANCHOR_PROVIDER_URL=https://api.devnet.solana.com \
ANCHOR_WALLET=~/.config/solana/id.json \
npx ts-node scripts/register-issuer.ts \
.keys/issuer.json \
"Demo KYC Provider" \
"https://example.com/issuer-metadata.json"ANCHOR_PROVIDER_URL=https://api.devnet.solana.com \
ANCHOR_WALLET=~/.config/solana/id.json \
npx ts-node scripts/issue.ts \
.keys/issuer.json \
<subject-pubkey> \
2 \
"Demo claim: passport verified 2026-04-22" \
365ANCHOR_PROVIDER_URL=https://api.devnet.solana.com \
ANCHOR_WALLET=~/.config/solana/id.json \
npx ts-node scripts/verify.ts <subject-pubkey> <issuer-authority-pubkey> 2ANCHOR_PROVIDER_URL=https://api.devnet.solana.com \
ANCHOR_WALLET=~/.config/solana/id.json \
npx ts-node scripts/revoke.ts .keys/issuer.json <subject-pubkey>Open app/index.html in a browser (no build step). Paste the subject pubkey
and issuer authority pubkey, set a minimum KYC level, and click Verify
credential. The page reads the credential account directly from Devnet and
runs the same verification logic as the CLI and as a gating CPI program would.
A consuming program that wants to gate an instruction on credential validity should:
- Accept the credential PDA as a read-only account in its instruction context.
- Verify the PDA derivation matches
["credential", subject, accepted_issuer]for one of its accepted issuers. - Deserialize the account and check
revoked,expires_atagainstClock::sysvar, andkyc_levelagainst the caller's required minimum.
Example constraint (Anchor) for a gated instruction:
#[derive(Accounts)]
pub struct GatedInstruction<'info> {
pub user: Signer<'info>,
#[account(
seeds = [
b"credential",
user.key().as_ref(),
accepted_issuer.key().as_ref(),
],
bump = credential.bump,
seeds::program = icd_program.key(),
constraint = !credential.revoked @ MyError::CredentialRevoked,
constraint = credential.expires_at == 0
|| credential.expires_at > Clock::get()?.unix_timestamp
@ MyError::CredentialExpired,
constraint = credential.kyc_level >= 2 @ MyError::KycLevelTooLow,
)]
pub credential: Account<'info, icd_solana::Credential>,
/// CHECK: pubkey must match a pre-approved issuer in caller program state.
pub accepted_issuer: AccountInfo<'info>,
pub icd_program: Program<'info, icd_solana::program::IcdSolana>,
}| Risk | Mitigation | Residual |
|---|---|---|
| Compromised issuer authority issues fraudulent credentials | Admin can deactivate issuer (set_issuer_active(false)) and admin-revoke outstanding credentials. |
Admin is a single key in v0.1. Production: multisig or governance. |
| Compromised admin key | None in v0.1. | High. Production: transfer admin to a multisig or DAO vote. |
| Subject rotates wallets / loses keys | Credential is bound to the subject pubkey. A new credential must be issued to the new pubkey. | By design — supports non-transferability. |
| Issuer lies about off-chain claim | Out of scope on-chain. Mitigated off-chain by issuer accreditation and claim-hash audit trail. | By design — the chain stores proof-of-verification, not identity ground truth. |
| Stale expired credentials mistaken for valid | Verifiers must check expires_at. Enforced in reference verification logic. |
Depends on integrators using the reference verifier. |
| Griefing by admin-revoking valid credentials | None in v0.1. | Production: governance-gated admin revocation with timelock. |
| Account rent exhaustion | Accounts are rent-exempt at creation (Anchor init). |
None. |
Known limitations of v0.1:
- No cross-chain bridging (future work; referenced as ICD cross-ecosystem specification in the grant roadmap).
- No issuer stake/bonding mechanism.
- No credential transfer (by design — they are soulbound).
- No private-data variant (future work; ZK attestations via light-protocol or similar are a natural extension).
| Phase | Scope |
|---|---|
| v0.1 (this) | Reference implementation, CLI scripts, browser demo, tests. |
| v0.2 | Independent security review; hardened error surface; Mainnet Beta deploy. |
| v0.3 | Reference gating program (Anchor) with CPI example; Solana Pay gated-merchant example. |
| v0.4 | ZK attestation variant (selective disclosure of kyc_level / jurisdiction without revealing the full claim hash correlation). |
| v1.0 | Cross-chain specification: Hedera HTS ↔ Solana credential portability standard. |
ICD-Solana adapts the ICD (International Credit Designation) soulbound credential model — originally implemented on Hedera Token Service for the International Credit tokenised RWA platform — to Solana as a standalone public good. The on-chain semantics (issuer registry, soulbound binding via PDA seeds, claim-hash-only storage, tier model) are the same. The Solana implementation is ecosystem-native (Anchor, PDAs, Borsh) and is not dependent on the Hedera original.
Grant submission: Superteam Australia / Solana Foundation Instagrant, 2026.
Apache-2.0. See LICENSE.
All code, documentation, threat model, and integration examples are open source. The primitive is not dependent on any single issuer — any qualifying KYC/AML provider can register as an issuer under the same standard.