CrowdPay currently treats every contribution as an anonymous Stellar transaction. There is no concept of contributor identity that persists across campaigns — a contributor who has funded 20 campaigns successfully is indistinguishable from a brand-new wallet. This creates two problems: creators have no signal about contributor trustworthiness, and contributors have no portable reputation that rewards their history of support. This issue implements a contributor identity system built on Stellar: a decentralised identifier (DID) anchored to the contributor's Stellar public key, a reputation score derived from verifiable on-chain history (contribution count, consistency, amounts, campaign success rate), and a privacy-preserving KYC attestation system where verified contributors can prove they have passed identity checks to a campaign without revealing their personal information to the platform.
-
Contract storage: identity_registry: Map<Address, ContributorIdentity> where ContributorIdentity = { did: String, attestations: Vec<Attestation>, reputation_score: u32, last_updated: u64 }
-
Attestation struct: { issuer: Address, attestation_type: Symbol, issued_at: u64, expires_at: Option<u64>, revoked: bool, proof_hash: BytesN<32> } — proof_hash is the SHA-256 hash of the off-chain KYC document reference, never the document itself
-
Contract functions:
register(did) — callable by any Stellar account; creates a ContributorIdentity record; DID format: did:stellar:<publicKey>:<campaignNetworkId>; emits IdentityRegistered
add_attestation(subject, attestation_type, expires_at, proof_hash) — callable only by approved attestation issuers (stored in a separate issuers map controlled by the platform admin); adds an attestation to the subject's identity record
revoke_attestation(subject, attestation_index) — callable by the original issuer; sets revoked: true on the attestation
update_reputation(subject, delta: i32) — callable only by the platform contract address; adjusts reputation_score by delta (positive or negative); clamped to 0–1000
get_identity(subject) -> ContributorIdentity — read-only
has_attestation(subject, attestation_type) -> bool — read-only; returns true only if a non-revoked, non-expired attestation of the given type exists
verify_proof(subject, attestation_type, proof_hash) -> bool — read-only; returns true if the attestation exists and the provided hash matches
backend/src/services/contributorIdentity.js — Identity service
-
registerIdentity(publicKey) — invokes register on the contract; generates the DID string; stores { publicKey, did, contractRegisteredAt } in contributor_identities table
-
issueKycAttestation(subjectPublicKey, kycLevel, proofDocumentHash) — called after Persona KYC approval; invokes add_attestation on the contract with attestation_type: Symbol("kyc_basic" | "kyc_standard" | "kyc_enhanced"); stores the off-chain document reference in kyc_attestations table (never the document itself — only the hash and the Persona inquiry ID)
-
updateReputationScore(publicKey, event) — called by the contribution indexer after each contribution lands:
contribution_made → +5 points
contribution_to_successful_campaign → +10 points (triggered when a campaign reaches its goal and the contributor had funded it)
contribution_to_failed_campaign → 0 change
dispute_raised_against_contributor → -20 points
- Score update invokes
update_reputation on the contract
-
getContributorProfile(publicKey) — fetches on-chain identity + reputation from the contract; fetches on-chain contribution history from Horizon; returns a structured profile: { did, reputationScore, attestations: [{ type, issuer, issuedAt, expiresAt, revoked }], contributionStats: { totalCampaigns, totalAmountUsd, successRate } }
backend/src/routes/ — New API endpoints
-
POST /api/contributor/identity/register — authenticated; registers the caller's Stellar public key on the identity contract; idempotent (no-op if already registered)
-
GET /api/contributor/identity/:publicKey — public endpoint; returns the contributor's on-chain profile (no personal data — only DID, reputation score, attestation types, and aggregated stats)
-
GET /api/contributor/identity/:publicKey/verify?attestation=kyc_standard — returns { verified: boolean, expiresAt } — used by campaign creators to gate contributions based on KYC level without accessing the contributor's personal data
-
POST /api/campaigns/:id/requirements — campaign-creator-only; sets contribution requirements: { minReputationScore, requiredAttestations: ['kyc_basic' | 'kyc_standard' | 'kyc_enhanced'] }; stored in campaign_requirements table
-
Contribution gate: POST /api/campaigns/:id/contributions checks campaign_requirements before processing; if the contributor does not meet the requirements, returns 403 CONTRIBUTOR_REQUIREMENTS_NOT_MET with a list of missing attestations and the contributor's current reputation score
Frontend — Contributor profile and campaign requirements
-
Contributor profile page (/profile/identity):
- DID displayed with copy button and link to the identity contract on Stellar Expert
- Reputation score: 0–1000 displayed as a radial gauge with tier labels (Newcomer 0–99, Contributor 100–299, Trusted 300–599, Veteran 600–849, Champion 850–1000)
- Attestations panel: list of all attestations with type, issuer, issue date, expiry, and revocation status; "Get Verified" button for each missing attestation type (links to the KYC flow)
- Contribution history summary: total campaigns funded, total amount contributed (USD equivalent), campaign success rate
-
Campaign creation form — "Contributor Requirements" section:
- Minimum reputation score slider (0–500; 0 = no minimum)
- Required attestation checkboxes: KYC Basic, KYC Standard, KYC Enhanced
- Preview: "Contributors without these requirements will be blocked from contributing to this campaign"
-
Campaign page — contributor eligibility indicator:
- If the user is logged in and their public key is registered: show a "You're eligible" green badge or a "Requirements not met" red badge with the specific gap (e.g. "You need KYC Standard verification — your current level is Basic")
- If the user is not logged in: show "This campaign requires KYC Standard verification"
Database migrations
-
contributor_identities: id, user_id, public_key, did, contract_registered_at, created_at
-
kyc_attestations: id, user_id, public_key, attestation_type, kyc_level, persona_inquiry_id, proof_hash, issued_at, expires_at, revoked_at
-
campaign_requirements: id, campaign_id, min_reputation_score, required_attestations (jsonb array), created_at
-
reputation_events: id, public_key, event_type, delta, resulting_score, related_campaign_id, created_at
Overview
CrowdPay currently treats every contribution as an anonymous Stellar transaction. There is no concept of contributor identity that persists across campaigns — a contributor who has funded 20 campaigns successfully is indistinguishable from a brand-new wallet. This creates two problems: creators have no signal about contributor trustworthiness, and contributors have no portable reputation that rewards their history of support. This issue implements a contributor identity system built on Stellar: a decentralised identifier (DID) anchored to the contributor's Stellar public key, a reputation score derived from verifiable on-chain history (contribution count, consistency, amounts, campaign success rate), and a privacy-preserving KYC attestation system where verified contributors can prove they have passed identity checks to a campaign without revealing their personal information to the platform.
What needs to be built
contracts/soroban/contributor_identity/— Soroban identity contract (Rust)Contract storage:
identity_registry: Map<Address, ContributorIdentity>whereContributorIdentity = { did: String, attestations: Vec<Attestation>, reputation_score: u32, last_updated: u64 }Attestationstruct:{ issuer: Address, attestation_type: Symbol, issued_at: u64, expires_at: Option<u64>, revoked: bool, proof_hash: BytesN<32> }—proof_hashis the SHA-256 hash of the off-chain KYC document reference, never the document itselfContract functions:
register(did)— callable by any Stellar account; creates aContributorIdentityrecord; DID format:did:stellar:<publicKey>:<campaignNetworkId>; emitsIdentityRegisteredadd_attestation(subject, attestation_type, expires_at, proof_hash)— callable only by approved attestation issuers (stored in a separateissuersmap controlled by the platform admin); adds an attestation to the subject's identity recordrevoke_attestation(subject, attestation_index)— callable by the original issuer; setsrevoked: trueon the attestationupdate_reputation(subject, delta: i32)— callable only by the platform contract address; adjustsreputation_scorebydelta(positive or negative); clamped to 0–1000get_identity(subject) -> ContributorIdentity— read-onlyhas_attestation(subject, attestation_type) -> bool— read-only; returnstrueonly if a non-revoked, non-expired attestation of the given type existsverify_proof(subject, attestation_type, proof_hash) -> bool— read-only; returnstrueif the attestation exists and the provided hash matchesbackend/src/services/contributorIdentity.js— Identity serviceregisterIdentity(publicKey)— invokesregisteron the contract; generates the DID string; stores{ publicKey, did, contractRegisteredAt }incontributor_identitiestableissueKycAttestation(subjectPublicKey, kycLevel, proofDocumentHash)— called after Persona KYC approval; invokesadd_attestationon the contract withattestation_type: Symbol("kyc_basic" | "kyc_standard" | "kyc_enhanced"); stores the off-chain document reference inkyc_attestationstable (never the document itself — only the hash and the Persona inquiry ID)updateReputationScore(publicKey, event)— called by the contribution indexer after each contribution lands:contribution_made→ +5 pointscontribution_to_successful_campaign→ +10 points (triggered when a campaign reaches its goal and the contributor had funded it)contribution_to_failed_campaign→ 0 changedispute_raised_against_contributor→ -20 pointsupdate_reputationon the contractgetContributorProfile(publicKey)— fetches on-chain identity + reputation from the contract; fetches on-chain contribution history from Horizon; returns a structured profile:{ did, reputationScore, attestations: [{ type, issuer, issuedAt, expiresAt, revoked }], contributionStats: { totalCampaigns, totalAmountUsd, successRate } }backend/src/routes/— New API endpointsPOST /api/contributor/identity/register— authenticated; registers the caller's Stellar public key on the identity contract; idempotent (no-op if already registered)GET /api/contributor/identity/:publicKey— public endpoint; returns the contributor's on-chain profile (no personal data — only DID, reputation score, attestation types, and aggregated stats)GET /api/contributor/identity/:publicKey/verify?attestation=kyc_standard— returns{ verified: boolean, expiresAt }— used by campaign creators to gate contributions based on KYC level without accessing the contributor's personal dataPOST /api/campaigns/:id/requirements— campaign-creator-only; sets contribution requirements:{ minReputationScore, requiredAttestations: ['kyc_basic' | 'kyc_standard' | 'kyc_enhanced'] }; stored incampaign_requirementstableContribution gate:
POST /api/campaigns/:id/contributionscheckscampaign_requirementsbefore processing; if the contributor does not meet the requirements, returns403 CONTRIBUTOR_REQUIREMENTS_NOT_METwith a list of missing attestations and the contributor's current reputation scoreFrontend — Contributor profile and campaign requirements
Contributor profile page (
/profile/identity):Campaign creation form — "Contributor Requirements" section:
Campaign page — contributor eligibility indicator:
Database migrations
contributor_identities:id,user_id,public_key,did,contract_registered_at,created_atkyc_attestations:id,user_id,public_key,attestation_type,kyc_level,persona_inquiry_id,proof_hash,issued_at,expires_at,revoked_atcampaign_requirements:id,campaign_id,min_reputation_score,required_attestations(jsonb array),created_atreputation_events:id,public_key,event_type,delta,resulting_score,related_campaign_id,created_atAcceptance criteria
registeron the identity contract creates an on-chainContributorIdentityrecord verifiable viagetLedgerEntriesfrom the Soroban RPChas_attestationreturnsfalsefor a revoked attestation even if the attestation type exists — revocation is enforced on-chain, not just in the databaseverify_proofreturnsfalseif a valid attestation exists but the providedproof_hashdoes not match — the hash verification is cryptographically enforcedmin_reputation_score: 300is blocked with403 CONTRIBUTOR_REQUIREMENTS_NOT_METfor a contributor whose on-chain reputation score is 299getContributorProfilereturns zero personal data — no name, email, document reference, or Persona inquiry ID — confirmed by a schema check on the API response