Skip to content

Stellar DID Layer, Cross-Campaign Reputation Score & Privacy-Preserving KYC Attestations #689

Description

@grantfox-oss

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

Acceptance criteria

  • register on the identity contract creates an on-chain ContributorIdentity record verifiable via getLedgerEntries from the Soroban RPC
  • has_attestation returns false for a revoked attestation even if the attestation type exists — revocation is enforced on-chain, not just in the database
  • verify_proof returns false if a valid attestation exists but the provided proof_hash does not match — the hash verification is cryptographically enforced
  • Reputation score correctly increments by +10 when a campaign the contributor funded reaches its goal — confirmed by funding a campaign on testnet, triggering the goal completion event, and querying the contract
  • A contribution to a campaign with min_reputation_score: 300 is blocked with 403 CONTRIBUTOR_REQUIREMENTS_NOT_MET for a contributor whose on-chain reputation score is 299
  • The contributor eligibility indicator on the campaign page shows the correct gap for a contributor who has KYC Basic but the campaign requires KYC Standard
  • getContributorProfile returns zero personal data — no name, email, document reference, or Persona inquiry ID — confirmed by a schema check on the API response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions