Skip to content

E18HT/icd-solana

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ICD-Solana

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.


Why

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.


Architecture

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).


Verification logic

Any gating application — whether client-side JavaScript or an on-chain program invoking via CPI — runs the same five checks:

  1. Credential account exists at the expected PDA.
  2. revoked == false.
  3. expires_at == 0 OR expires_at > clock.unix_timestamp.
  4. kyc_level >= required_minimum.
  5. issuer is 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.


KYC levels

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).


Repository layout

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

Build, deploy, test

Prerequisites

  • 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

One-time setup

# 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 2

Build

anchor build

First 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 ID

Test locally

anchor test

This 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

Deploy to Devnet

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.ts

Register a test issuer

mkdir -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"

Issue a credential

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" \
    365

Verify a credential

ANCHOR_PROVIDER_URL=https://api.devnet.solana.com \
ANCHOR_WALLET=~/.config/solana/id.json \
npx ts-node scripts/verify.ts <subject-pubkey> <issuer-authority-pubkey> 2

Revoke a credential

ANCHOR_PROVIDER_URL=https://api.devnet.solana.com \
ANCHOR_WALLET=~/.config/solana/id.json \
npx ts-node scripts/revoke.ts .keys/issuer.json <subject-pubkey>

Browser gate demo

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.


Integration pattern (downstream gating programs)

A consuming program that wants to gate an instruction on credential validity should:

  1. Accept the credential PDA as a read-only account in its instruction context.
  2. Verify the PDA derivation matches ["credential", subject, accepted_issuer] for one of its accepted issuers.
  3. Deserialize the account and check revoked, expires_at against Clock::sysvar, and kyc_level against 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>,
}

Threat model (reference implementation, v0.1)

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).

Roadmap

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.

Attribution and provenance

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.


License

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.

About

Open-source soulbound KYC/AML credential primitive for Solana. Apache-2.0.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors