diff --git a/backend/.env.example b/backend/.env.example
index 4aa588c5..1b7a4cd1 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -180,3 +180,6 @@ STORAGE_ENDPOINT=
DB_POOL_MAX=10
DB_IDLE_TIMEOUT_MS=30000
DB_CONNECTION_TIMEOUT_MS=5000
+
+# Contributor identity contract (#689) — deploy contracts/soroban/contracts/contributor_identity
+# CONTRIBUTOR_IDENTITY_CONTRACT_ID=
diff --git a/backend/db/migrations/20260829_contributor_identity_reputation.sql b/backend/db/migrations/20260829_contributor_identity_reputation.sql
new file mode 100644
index 00000000..1990bec4
--- /dev/null
+++ b/backend/db/migrations/20260829_contributor_identity_reputation.sql
@@ -0,0 +1,112 @@
+-- #689 Stellar DID Layer, Cross-Campaign Reputation Score & Privacy-Preserving KYC Attestations
+--
+-- New tables:
+-- contributor_identities — links a user to their on-chain DID
+-- kyc_attestations — privacy-preserving off-chain KYC records (hash only, never raw docs)
+-- campaign_requirements — per-campaign contribution gate (min reputation + required attestations)
+-- reputation_events — append-only audit log of every reputation delta
+
+-- ---------------------------------------------------------------------------
+-- contributor_identities
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS contributor_identities (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ public_key TEXT NOT NULL,
+ did TEXT NOT NULL,
+ contract_registered_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT contributor_identities_public_key_unique UNIQUE (public_key),
+ CONSTRAINT contributor_identities_did_unique UNIQUE (did),
+ CONSTRAINT contributor_identities_user_id_unique UNIQUE (user_id)
+);
+
+CREATE INDEX IF NOT EXISTS contributor_identities_user_id_idx
+ ON contributor_identities (user_id);
+
+-- ---------------------------------------------------------------------------
+-- kyc_attestations
+-- Stores the off-chain record of a KYC-level attestation.
+-- proof_hash is the SHA-256 of the Persona inquiry ID — never the document.
+-- ---------------------------------------------------------------------------
+DO $$ BEGIN
+ CREATE TYPE kyc_attestation_type AS ENUM ('kyc_basic', 'kyc_standard', 'kyc_enhanced');
+EXCEPTION WHEN duplicate_object THEN NULL;
+END $$;
+
+CREATE TABLE IF NOT EXISTS kyc_attestations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ public_key TEXT NOT NULL,
+ attestation_type kyc_attestation_type NOT NULL,
+ kyc_level TEXT NOT NULL,
+ persona_inquiry_id TEXT,
+ proof_hash TEXT NOT NULL,
+ issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ,
+ revoked_at TIMESTAMPTZ,
+ on_chain_tx_hash TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS kyc_attestations_user_id_idx
+ ON kyc_attestations (user_id);
+CREATE INDEX IF NOT EXISTS kyc_attestations_public_key_idx
+ ON kyc_attestations (public_key);
+CREATE INDEX IF NOT EXISTS kyc_attestations_active_idx
+ ON kyc_attestations (user_id, attestation_type)
+ WHERE revoked_at IS NULL;
+
+-- ---------------------------------------------------------------------------
+-- campaign_requirements
+-- One row per campaign; upsert on conflict to allow updates by the creator.
+-- required_attestations is a JSONB array of attestation type strings,
+-- e.g. ["kyc_basic", "kyc_standard"].
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS campaign_requirements (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
+ min_reputation_score INTEGER NOT NULL DEFAULT 0
+ CHECK (min_reputation_score >= 0 AND min_reputation_score <= 1000),
+ required_attestations JSONB NOT NULL DEFAULT '[]'::jsonb,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT campaign_requirements_campaign_id_unique UNIQUE (campaign_id)
+);
+
+CREATE INDEX IF NOT EXISTS campaign_requirements_campaign_id_idx
+ ON campaign_requirements (campaign_id);
+
+-- ---------------------------------------------------------------------------
+-- reputation_events
+-- Immutable audit log — rows are never updated or deleted.
+-- ---------------------------------------------------------------------------
+DO $$ BEGIN
+ CREATE TYPE reputation_event_type AS ENUM (
+ 'contribution_made',
+ 'contribution_to_successful_campaign',
+ 'contribution_to_failed_campaign',
+ 'dispute_raised_against_contributor'
+ );
+EXCEPTION WHEN duplicate_object THEN NULL;
+END $$;
+
+CREATE TABLE IF NOT EXISTS reputation_events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ public_key TEXT NOT NULL,
+ event_type reputation_event_type NOT NULL,
+ delta INTEGER NOT NULL DEFAULT 0,
+ resulting_score INTEGER NOT NULL DEFAULT 0
+ CHECK (resulting_score >= 0 AND resulting_score <= 1000),
+ related_campaign_id UUID REFERENCES campaigns(id) ON DELETE SET NULL,
+ on_chain_tx_hash TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS reputation_events_public_key_idx
+ ON reputation_events (public_key);
+CREATE INDEX IF NOT EXISTS reputation_events_public_key_created_idx
+ ON reputation_events (public_key, created_at DESC);
+CREATE INDEX IF NOT EXISTS reputation_events_campaign_idx
+ ON reputation_events (related_campaign_id)
+ WHERE related_campaign_id IS NOT NULL;
diff --git a/backend/src/index.js b/backend/src/index.js
index babaa2bf..49f3fc80 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -277,6 +277,8 @@ app.use("/api/campaigns", require("./routes/campaignComments"));
app.use("/api/campaigns", require("./routes/campaignFollowers"));
app.use("/api/campaigns", require("./routes/campaigns"));
app.use("/api/campaign-templates", require("./routes/campaignTemplates"));
+app.use("/api/campaigns", require("./routes/campaignRequirements"));
+app.use("/api/contributor/identity", require("./routes/contributorIdentity"));
app.use("/api/campaigns", require("./routes/impactReports"));
app.use("/api/campaigns", require("./routes/sponsorMatching"));
app.use("/api/campaigns", require("./routes/translations"));
diff --git a/backend/src/routes/campaignRequirements.js b/backend/src/routes/campaignRequirements.js
new file mode 100644
index 00000000..0a9ec9d3
--- /dev/null
+++ b/backend/src/routes/campaignRequirements.js
@@ -0,0 +1,121 @@
+/**
+ * Campaign requirements routes — issue #689
+ *
+ * POST /api/campaigns/:id/requirements — creator-only; set/update requirements
+ * GET /api/campaigns/:id/requirements — public; read current requirements
+ */
+
+const router = require('express').Router({ mergeParams: true });
+const { body, param, validationResult } = require('express-validator');
+const { requireAuth } = require('../middleware/auth');
+const asyncHandler = require('../utils/asyncHandler');
+const db = require('../config/database');
+
+const VALID_ATTESTATION_TYPES = ['kyc_basic', 'kyc_standard', 'kyc_enhanced'];
+
+function validateRequest(req, res, next) {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(422).json({
+ error: {
+ code: 'VALIDATION_ERROR',
+ message: 'Invalid request parameters',
+ fields: Object.fromEntries(errors.array().map((e) => [e.path, e.msg])),
+ },
+ });
+ }
+ next();
+}
+
+/**
+ * POST /api/campaigns/:id/requirements
+ *
+ * Campaign creator sets contribution requirements. Upserts — a second call
+ * replaces the previous record.
+ */
+router.post(
+ '/:id/requirements',
+ requireAuth,
+ [
+ param('id').isUUID().withMessage('Campaign id must be a valid UUID'),
+ body('min_reputation_score')
+ .optional()
+ .isInt({ min: 0, max: 500 })
+ .withMessage('min_reputation_score must be an integer between 0 and 500'),
+ body('required_attestations')
+ .optional()
+ .isArray()
+ .withMessage('required_attestations must be an array')
+ .custom((arr) => {
+ for (const item of arr) {
+ if (!VALID_ATTESTATION_TYPES.includes(item)) {
+ throw new Error(
+ `Each attestation must be one of: ${VALID_ATTESTATION_TYPES.join(', ')}`
+ );
+ }
+ }
+ return true;
+ }),
+ ],
+ validateRequest,
+ asyncHandler(async (req, res) => {
+ const campaignId = req.params.id;
+ const { userId } = req.user;
+ const minReputationScore = req.body.min_reputation_score ?? 0;
+ const requiredAttestations = req.body.required_attestations ?? [];
+
+ // Verify campaign ownership
+ const { rows: campRows } = await db.query(
+ 'SELECT id, creator_id FROM campaigns WHERE id = $1 AND deleted_at IS NULL',
+ [campaignId]
+ );
+ if (!campRows.length) {
+ return res.status(404).json({ error: 'Campaign not found' });
+ }
+ if (campRows[0].creator_id !== userId) {
+ return res.status(403).json({ error: 'Only the campaign creator can set requirements' });
+ }
+
+ const { rows } = await db.query(
+ `INSERT INTO campaign_requirements
+ (campaign_id, min_reputation_score, required_attestations)
+ VALUES ($1, $2, $3::jsonb)
+ ON CONFLICT (campaign_id) DO UPDATE
+ SET min_reputation_score = EXCLUDED.min_reputation_score,
+ required_attestations = EXCLUDED.required_attestations,
+ updated_at = NOW()
+ RETURNING *`,
+ [campaignId, minReputationScore, JSON.stringify(requiredAttestations)]
+ );
+
+ res.status(200).json(rows[0]);
+ })
+);
+
+/**
+ * GET /api/campaigns/:id/requirements
+ *
+ * Public read. Returns the current requirements for a campaign, or nulls
+ * if none have been set.
+ */
+router.get(
+ '/:id/requirements',
+ [param('id').isUUID().withMessage('Campaign id must be a valid UUID')],
+ validateRequest,
+ asyncHandler(async (req, res) => {
+ const { rows } = await db.query(
+ 'SELECT * FROM campaign_requirements WHERE campaign_id = $1',
+ [req.params.id]
+ );
+ if (!rows.length) {
+ return res.json({
+ campaign_id: req.params.id,
+ min_reputation_score: 0,
+ required_attestations: [],
+ });
+ }
+ res.json(rows[0]);
+ })
+);
+
+module.exports = router;
diff --git a/backend/src/routes/contributions.js b/backend/src/routes/contributions.js
index d46eac99..553ed0e4 100644
--- a/backend/src/routes/contributions.js
+++ b/backend/src/routes/contributions.js
@@ -40,6 +40,7 @@ const { recordConfirmedContribution } = require('../services/ledgerMonitor');
const { emitWebhookEventForUser, emitWebhookEventForCampaign, WEBHOOK_EVENTS } = require('../services/webhookDispatcher');
const { ERROR_CODES } = require('../services/dispute');
const { assertUserKycVerified } = require('../services/kycService');
+const { assertContributorMeetsRequirements } = require('../services/contributorIdentityService');
const asyncHandler = require('../utils/asyncHandler');
const { getReferralCodeFromRequest } = require('../services/referralService');
const { resolveReferralLink } = require('../services/referral');
@@ -449,6 +450,20 @@ router.post('/prepare', requireAuth, contributionValidation, validateRequest, as
const campaign = await loadActiveCampaign(campaign_id);
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
+ // Contributor requirements gate (#689) — check before building any XDR
+ try {
+ await assertContributorMeetsRequirements(sender_public_key, campaign_id);
+ } catch (err) {
+ if (err.code === 'CONTRIBUTOR_REQUIREMENTS_NOT_MET') {
+ return res.status(403).json({
+ error: err.message,
+ code: err.code,
+ missing: err.missing,
+ });
+ }
+ throw err;
+ }
+
if (campaign.min_contribution && parseFloat(amount) < parseFloat(campaign.min_contribution)) {
return res.status(400).json({ error: `Contribution amount is below the minimum limit of ${campaign.min_contribution} ${campaign.asset_type}` });
}
@@ -846,6 +861,20 @@ router.post('/', contributionPostLimiter, requireAuth, contributionValidation, v
);
const contributorPublicKey = users[0].wallet_public_key;
+ // Contributor requirements gate (#689)
+ try {
+ await assertContributorMeetsRequirements(contributorPublicKey, campaign_id);
+ } catch (err) {
+ if (err.code === 'CONTRIBUTOR_REQUIREMENTS_NOT_MET') {
+ return res.status(403).json({
+ error: err.message,
+ code: err.code,
+ missing: err.missing,
+ });
+ }
+ throw err;
+ }
+
if (campaign.min_contribution && parseFloat(amount) < parseFloat(campaign.min_contribution)) {
return res.status(400).json({
error: `Minimum contribution is ${campaign.min_contribution} ${campaign.asset_type}`,
diff --git a/backend/src/routes/contributorIdentity.js b/backend/src/routes/contributorIdentity.js
new file mode 100644
index 00000000..544e8bf4
--- /dev/null
+++ b/backend/src/routes/contributorIdentity.js
@@ -0,0 +1,124 @@
+/**
+ * Contributor Identity routes — issue #689
+ *
+ * POST /api/contributor/identity/register authenticated; idempotent
+ * GET /api/contributor/identity/:publicKey public
+ * GET /api/contributor/identity/:publicKey/verify public; ?attestation=kyc_standard
+ */
+
+const router = require('express').Router();
+const { param, query, validationResult } = require('express-validator');
+const { requireAuth, optionalAuth } = require('../middleware/auth');
+const asyncHandler = require('../utils/asyncHandler');
+const logger = require('../config/logger');
+const db = require('../config/database');
+const {
+ registerIdentity,
+ getContributorProfile,
+ verifyAttestation,
+} = require('../services/contributorIdentityService');
+
+const VALID_ATTESTATION_TYPES = ['kyc_basic', 'kyc_standard', 'kyc_enhanced'];
+
+function validateRequest(req, res, next) {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(422).json({
+ error: {
+ code: 'VALIDATION_ERROR',
+ message: 'Invalid request parameters',
+ fields: Object.fromEntries(errors.array().map((e) => [e.path, e.msg])),
+ },
+ });
+ }
+ next();
+}
+
+/**
+ * POST /api/contributor/identity/register
+ *
+ * Register the authenticated user's Stellar public key on the identity
+ * contract and in the contributor_identities table. Idempotent.
+ */
+router.post(
+ '/register',
+ requireAuth,
+ asyncHandler(async (req, res) => {
+ const { userId } = req.user;
+
+ // Fetch the user's wallet public key
+ const { rows } = await db.query(
+ 'SELECT wallet_public_key FROM users WHERE id = $1',
+ [userId]
+ );
+ if (!rows.length) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ const publicKey = rows[0].wallet_public_key;
+ if (!publicKey) {
+ return res.status(400).json({ error: 'User does not have a Stellar wallet linked' });
+ }
+
+ const identity = await registerIdentity(publicKey, userId);
+
+ res.status(200).json({
+ did: identity.did,
+ public_key: identity.public_key,
+ contract_registered_at: identity.contract_registered_at,
+ created_at: identity.created_at,
+ });
+ })
+);
+
+/**
+ * GET /api/contributor/identity/:publicKey
+ *
+ * Public endpoint. Returns DID, reputation score, attestation types (no PII),
+ * and aggregated contribution stats.
+ */
+router.get(
+ '/:publicKey',
+ [
+ param('publicKey')
+ .isString()
+ .trim()
+ .matches(/^G[A-Z2-7]{55}$/)
+ .withMessage('publicKey must be a valid Stellar public key'),
+ ],
+ validateRequest,
+ asyncHandler(async (req, res) => {
+ const { publicKey } = req.params;
+ const profile = await getContributorProfile(publicKey);
+ res.json(profile);
+ })
+);
+
+/**
+ * GET /api/contributor/identity/:publicKey/verify?attestation=kyc_standard
+ *
+ * Used by campaign creators to gate contributions based on KYC level without
+ * accessing personal data. Returns { verified, expiresAt }.
+ */
+router.get(
+ '/:publicKey/verify',
+ [
+ param('publicKey')
+ .isString()
+ .trim()
+ .matches(/^G[A-Z2-7]{55}$/)
+ .withMessage('publicKey must be a valid Stellar public key'),
+ query('attestation')
+ .isIn(VALID_ATTESTATION_TYPES)
+ .withMessage(`attestation must be one of: ${VALID_ATTESTATION_TYPES.join(', ')}`),
+ ],
+ validateRequest,
+ asyncHandler(async (req, res) => {
+ const { publicKey } = req.params;
+ const attestationType = req.query.attestation;
+ const result = await verifyAttestation(publicKey, attestationType);
+ res.json(result);
+ })
+);
+
+module.exports = router;
diff --git a/backend/src/routes/kycWebhook.js b/backend/src/routes/kycWebhook.js
index e6acd2d6..5990de7e 100644
--- a/backend/src/routes/kycWebhook.js
+++ b/backend/src/routes/kycWebhook.js
@@ -2,6 +2,7 @@ const db = require('../config/database');
const logger = require('../config/logger');
const { extractWebhookResult, verifyPersonaWebhookSignature } = require('../services/kycProvider');
const { sendKycApprovedEmail, sendKycRejectedEmail } = require('../services/emailService');
+const { issueKycAttestation, attestationTypeForTier } = require('../services/contributorIdentityService');
function frontendBaseUrl() {
return (process.env.FRONTEND_URL || 'http://localhost:5173').replace(/\/$/, '');
@@ -57,7 +58,7 @@ async function handleKycWebhook(req, res) {
END,
persona_inquiry_id = COALESCE($2, persona_inquiry_id)
WHERE ${lookup}
- RETURNING id, email, name, kyc_status, kyc_completed_at, verification_status, verification_tier`,
+ RETURNING id, email, name, kyc_status, kyc_completed_at, verification_status, verification_tier, wallet_public_key`,
[...params, result.tier || 'basic']
);
@@ -82,6 +83,22 @@ async function handleKycWebhook(req, res) {
logger.warn('Failed to record KYC event', { user_id: rows[0].id, error: eventErr.message });
}
+ // Issue on-chain KYC attestation (#689) when the verification is approved.
+ // Fire-and-forget — a Soroban RPC hiccup must not block the webhook response.
+ if (rows[0].kyc_status === 'verified' && rows[0].wallet_public_key) {
+ issueKycAttestation(
+ rows[0].wallet_public_key,
+ rows[0].id,
+ result.tier || 'basic',
+ result.providerReference
+ ).catch((err) =>
+ logger.warn('KYC on-chain attestation failed', {
+ user_id: rows[0].id,
+ error: err.message,
+ })
+ );
+ }
+
if (rows[0].email) {
if (rows[0].kyc_status === 'verified') {
sendKycApprovedEmail({
diff --git a/backend/src/services/contributorIdentityService.js b/backend/src/services/contributorIdentityService.js
new file mode 100644
index 00000000..7542bc37
--- /dev/null
+++ b/backend/src/services/contributorIdentityService.js
@@ -0,0 +1,587 @@
+/**
+ * contributorIdentityService.js
+ *
+ * Business logic for the Stellar DID layer, on-chain KYC attestations, and
+ * cross-campaign reputation scores (issue #689).
+ *
+ * On-chain interaction goes through the shared sorobanService primitives
+ * (invokeContract / invokeContractReadOnly) exactly as every other contract
+ * service in this codebase does.
+ *
+ * Privacy rule: this service never returns or stores a contributor's real
+ * name, email address, Persona document content, or any raw PII. The only
+ * Persona-derived value stored is the SHA-256 hash of the inquiry ID.
+ */
+
+const crypto = require('crypto');
+const { Address, nativeToScVal, scValToNative, xdr } = require('@stellar/stellar-sdk');
+const db = require('../config/database');
+const logger = require('../config/logger');
+const { server, networkPassphrase } = require('../config/stellar');
+const { Contract, TransactionBuilder, BASE_FEE, Keypair } = require('@stellar/stellar-sdk');
+const { TX_TIMEOUT_CONTRIBUTION_S } = require('../config/constants');
+
+// ---------------------------------------------------------------------------
+// Contract helpers (re-uses the same low-level pattern as sorobanService.js)
+// ---------------------------------------------------------------------------
+
+function identityContractId() {
+ const id = process.env.CONTRIBUTOR_IDENTITY_CONTRACT_ID;
+ if (!id) {
+ throw new Error(
+ 'CONTRIBUTOR_IDENTITY_CONTRACT_ID is not set — deploy the contributor_identity contract and add the address to .env'
+ );
+ }
+ return id;
+}
+
+function platformSigner() {
+ return Keypair.fromSecret(process.env.PLATFORM_SECRET_KEY);
+}
+
+async function simulateAndPrepare(tx) {
+ const simulation = await server.simulateTransaction(tx);
+ if (simulation.result) {
+ const meta = xdr.TransactionMeta.fromXDR(simulation.result.meta, 'base64');
+ const sorobanMeta = meta.v3().sorobanMeta();
+ if (sorobanMeta && sorobanMeta.returnValue()) {
+ const isError =
+ sorobanMeta.returnValue().switch?.()?.name === 'scvError' ||
+ (typeof sorobanMeta.returnValue().type === 'function' &&
+ sorobanMeta.returnValue().type() === xdr.ScValType.scvError);
+ if (isError) {
+ throw new Error(`Simulation failed: ${JSON.stringify(simulation.result)}`);
+ }
+ }
+ }
+ return server.prepareTransaction(tx);
+}
+
+/**
+ * Sign and submit a contract write call using the platform key.
+ * Returns { hash, returnValue }.
+ */
+async function contractInvoke(method, args) {
+ const contractId = identityContractId();
+ const signer = platformSigner();
+ const source = await server.loadAccount(signer.publicKey());
+
+ const contract = new Contract(contractId);
+ const tx = new TransactionBuilder(source, { fee: BASE_FEE, networkPassphrase })
+ .addOperation(contract.call(method, ...args))
+ .setTimeout(TX_TIMEOUT_CONTRIBUTION_S)
+ .build();
+
+ const preparedTx = await simulateAndPrepare(tx);
+ preparedTx.sign(signer);
+ const hash = preparedTx.hash().toString('hex');
+ const result = await server.submitTransaction(preparedTx);
+
+ if (result.status === 'SUCCESS') {
+ let returnValue = null;
+ if (result.resultMetaXdr) {
+ const meta = xdr.TransactionMeta.fromXDR(result.resultMetaXdr, 'base64');
+ const sorobanMeta = meta.v3().sorobanMeta();
+ if (sorobanMeta && sorobanMeta.returnValue()) {
+ returnValue = scValToNative(sorobanMeta.returnValue());
+ }
+ }
+ return { hash: result.hash || hash, returnValue };
+ }
+ throw new Error(`Contract transaction failed: ${result.status}`);
+}
+
+/**
+ * Simulate a read-only contract call using the platform key as fee source.
+ * Returns the decoded return value.
+ */
+async function contractRead(method, args) {
+ const contractId = identityContractId();
+ const signer = platformSigner();
+ const source = await server.loadAccount(signer.publicKey());
+
+ const contract = new Contract(contractId);
+ const tx = new TransactionBuilder(source, { fee: BASE_FEE, networkPassphrase })
+ .addOperation(contract.call(method, ...args))
+ .setTimeout(TX_TIMEOUT_CONTRIBUTION_S)
+ .build();
+
+ const simulation = await server.simulateTransaction(tx);
+ if (simulation.result) {
+ const meta = xdr.TransactionMeta.fromXDR(simulation.result.meta, 'base64');
+ const sorobanMeta = meta.v3().sorobanMeta();
+ if (sorobanMeta && sorobanMeta.returnValue()) {
+ const isError =
+ sorobanMeta.returnValue().switch?.()?.name === 'scvError' ||
+ (typeof sorobanMeta.returnValue().type === 'function' &&
+ sorobanMeta.returnValue().type() === xdr.ScValType.scvError);
+ if (isError) {
+ throw new Error(`Contract read simulation error: ${JSON.stringify(simulation.result)}`);
+ }
+ return scValToNative(sorobanMeta.returnValue());
+ }
+ }
+ throw new Error(`Contract read simulation produced no return value: ${JSON.stringify(simulation)}`);
+}
+
+// ---------------------------------------------------------------------------
+// DID helpers
+// ---------------------------------------------------------------------------
+
+/** Build a did:stellar: string. */
+function buildDid(publicKey) {
+ return `did:stellar:${publicKey}`;
+}
+
+/** SHA-256 of a string value, returned as hex. */
+function sha256Hex(value) {
+ return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
+}
+
+/** Hex string → 32-byte Buffer (for BytesN<32> contract args). */
+function hexToBytes32(hexStr) {
+ return Buffer.from(hexStr.slice(0, 64).padStart(64, '0'), 'hex');
+}
+
+// ---------------------------------------------------------------------------
+// KYC tier → attestation type
+// ---------------------------------------------------------------------------
+
+const TIER_TO_ATTESTATION = {
+ basic: 'kyc_basic',
+ standard: 'kyc_standard',
+ enhanced: 'kyc_enhanced',
+};
+
+function attestationTypeForTier(tier) {
+ return TIER_TO_ATTESTATION[tier] || 'kyc_basic';
+}
+
+// ---------------------------------------------------------------------------
+// Reputation event deltas (as per the spec)
+// ---------------------------------------------------------------------------
+const REPUTATION_DELTAS = {
+ contribution_made: 5,
+ contribution_to_successful_campaign: 10,
+ contribution_to_failed_campaign: 0,
+ dispute_raised_against_contributor: -20,
+};
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * registerIdentity(publicKey, userId)
+ *
+ * Idempotent. If the contributor already has a row in contributor_identities
+ * the existing record is returned without touching the contract.
+ *
+ * On first call:
+ * 1. Builds the DID string.
+ * 2. Calls `register` on the identity contract.
+ * 3. Persists the record to contributor_identities.
+ */
+async function registerIdentity(publicKey, userId) {
+ // Idempotency check — return existing record immediately
+ const { rows: existing } = await db.query(
+ 'SELECT * FROM contributor_identities WHERE public_key = $1',
+ [publicKey]
+ );
+ if (existing.length) {
+ return existing[0];
+ }
+
+ const did = buildDid(publicKey);
+
+ // Invoke on-chain register — idempotent at the contract level too
+ let contractRegisteredAt = null;
+ try {
+ await contractInvoke('register', [
+ nativeToScVal(Address.fromString(publicKey), { type: 'address' }),
+ nativeToScVal(did, { type: 'string' }),
+ ]);
+ contractRegisteredAt = new Date();
+ } catch (err) {
+ // Log but do not hard-fail: the DB record is the source of truth for the
+ // application layer. The contract call can be retried later.
+ logger.warn('contributorIdentityService.registerIdentity: contract call failed', {
+ publicKey,
+ error: err.message,
+ });
+ }
+
+ const { rows } = await db.query(
+ `INSERT INTO contributor_identities (user_id, public_key, did, contract_registered_at)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (public_key) DO UPDATE
+ SET contract_registered_at = COALESCE(contributor_identities.contract_registered_at, EXCLUDED.contract_registered_at)
+ RETURNING *`,
+ [userId, publicKey, did, contractRegisteredAt]
+ );
+
+ logger.info('contributorIdentityService.registerIdentity: registered', { publicKey, did });
+ return rows[0];
+}
+
+/**
+ * issueKycAttestation(subjectPublicKey, userId, kycLevel, personaInquiryId)
+ *
+ * Called after a Persona KYC approval webhook is received.
+ *
+ * 1. Derives the attestation_type from the KYC level tier.
+ * 2. Hashes the Persona inquiry ID — this hash is the only thing stored or
+ * sent on-chain. The raw inquiry ID is never written to the contract.
+ * 3. Calls add_attestation on the contract via the platform key (which must
+ * be registered as an approved issuer during contract setup).
+ * 4. Inserts a row into kyc_attestations.
+ */
+async function issueKycAttestation(subjectPublicKey, userId, kycLevel, personaInquiryId) {
+ const attestationType = attestationTypeForTier(kycLevel);
+ // Hash the inquiry ID — this is the proof_hash stored on-chain
+ const proofHash = sha256Hex(personaInquiryId || `${subjectPublicKey}-${kycLevel}-${Date.now()}`);
+ const proofHashBytes = hexToBytes32(proofHash);
+
+ // Ensure identity exists on-chain before attesting
+ await registerIdentity(subjectPublicKey, userId);
+
+ let onChainTxHash = null;
+ try {
+ const { hash } = await contractInvoke('add_attestation', [
+ // issuer = platform key
+ nativeToScVal(Address.fromString(platformSigner().publicKey()), { type: 'address' }),
+ // subject
+ nativeToScVal(Address.fromString(subjectPublicKey), { type: 'address' }),
+ // attestation_type symbol
+ nativeToScVal(attestationType, { type: 'symbol' }),
+ // expires_at = 0 (no expiry)
+ nativeToScVal(0, { type: 'u64' }),
+ // proof_hash BytesN<32>
+ nativeToScVal(proofHashBytes),
+ ]);
+ onChainTxHash = hash;
+ } catch (err) {
+ logger.warn('contributorIdentityService.issueKycAttestation: contract call failed', {
+ subjectPublicKey,
+ attestationType,
+ error: err.message,
+ });
+ }
+
+ const { rows } = await db.query(
+ `INSERT INTO kyc_attestations
+ (user_id, public_key, attestation_type, kyc_level, persona_inquiry_id, proof_hash, on_chain_tx_hash)
+ VALUES ($1, $2, $3::kyc_attestation_type, $4, $5, $6, $7)
+ ON CONFLICT DO NOTHING
+ RETURNING *`,
+ [
+ userId,
+ subjectPublicKey,
+ attestationType,
+ kycLevel,
+ personaInquiryId || null,
+ proofHash,
+ onChainTxHash,
+ ]
+ );
+
+ logger.info('contributorIdentityService.issueKycAttestation: issued', {
+ subjectPublicKey,
+ attestationType,
+ onChainTxHash,
+ });
+
+ return rows[0] || null;
+}
+
+/**
+ * updateReputationScore(publicKey, event, relatedCampaignId?)
+ *
+ * Called by the ledger monitor / campaign status service after contribution
+ * events are confirmed on-chain.
+ *
+ * Looks up the current on-chain score, applies the delta, clamps to [0,1000],
+ * invokes update_reputation on the contract, then appends a reputation_events
+ * row for audit purposes.
+ */
+async function updateReputationScore(publicKey, event, relatedCampaignId = null) {
+ const delta = REPUTATION_DELTAS[event];
+ if (delta === undefined) {
+ throw new Error(`Unknown reputation event type: ${event}`);
+ }
+
+ // Skip zero-delta events — nothing to record
+ if (delta === 0) {
+ logger.debug('contributorIdentityService.updateReputationScore: zero delta, skipping', {
+ publicKey,
+ event,
+ });
+ return null;
+ }
+
+ // Fetch current on-chain score
+ let currentScore = 0;
+ try {
+ const identity = await contractRead('get_identity', [
+ nativeToScVal(Address.fromString(publicKey), { type: 'address' }),
+ ]);
+ // identity is a decoded JS object from scValToNative
+ currentScore = Number(identity?.reputation_score ?? 0);
+ } catch (err) {
+ // Identity may not be registered yet — score stays 0, call will register implicitly
+ logger.debug('contributorIdentityService.updateReputationScore: could not read identity', {
+ publicKey,
+ error: err.message,
+ });
+ }
+
+ const newScore = Math.max(0, Math.min(1000, currentScore + delta));
+
+ let onChainTxHash = null;
+ try {
+ const { hash } = await contractInvoke('update_reputation', [
+ nativeToScVal(Address.fromString(publicKey), { type: 'address' }),
+ nativeToScVal(delta, { type: 'i32' }),
+ ]);
+ onChainTxHash = hash;
+ } catch (err) {
+ logger.warn('contributorIdentityService.updateReputationScore: contract call failed', {
+ publicKey,
+ event,
+ delta,
+ error: err.message,
+ });
+ }
+
+ await db.query(
+ `INSERT INTO reputation_events
+ (public_key, event_type, delta, resulting_score, related_campaign_id, on_chain_tx_hash)
+ VALUES ($1, $2::reputation_event_type, $3, $4, $5, $6)`,
+ [publicKey, event, delta, newScore, relatedCampaignId || null, onChainTxHash]
+ );
+
+ logger.info('contributorIdentityService.updateReputationScore: updated', {
+ publicKey,
+ event,
+ delta,
+ newScore,
+ onChainTxHash,
+ });
+
+ return { delta, newScore, onChainTxHash };
+}
+
+/**
+ * getContributorProfile(publicKey)
+ *
+ * Returns a structured profile containing only non-personal data:
+ * did, reputationScore, attestations (type/issuer/dates/revoked),
+ * contributionStats (totalCampaigns, totalAmountUsd, successRate)
+ *
+ * No name, email, document reference, or Persona inquiry ID is included.
+ */
+async function getContributorProfile(publicKey) {
+ // ------------------------------------------------------------------
+ // 1. On-chain identity (best-effort — may not be registered yet)
+ // ------------------------------------------------------------------
+ let onChainIdentity = null;
+ try {
+ onChainIdentity = await contractRead('get_identity', [
+ nativeToScVal(Address.fromString(publicKey), { type: 'address' }),
+ ]);
+ } catch (_err) {
+ // Not yet registered — return a minimal profile
+ }
+
+ // ------------------------------------------------------------------
+ // 2. Off-chain attestation list (privacy-safe projection)
+ // ------------------------------------------------------------------
+ const { rows: attestationRows } = await db.query(
+ `SELECT attestation_type, kyc_level, issued_at, expires_at, revoked_at, on_chain_tx_hash
+ FROM kyc_attestations
+ WHERE public_key = $1
+ ORDER BY issued_at DESC`,
+ [publicKey]
+ );
+
+ const attestations = attestationRows.map((r) => ({
+ type: r.attestation_type,
+ issuer: 'platform',
+ issuedAt: r.issued_at,
+ expiresAt: r.expires_at,
+ revoked: r.revoked_at !== null,
+ }));
+
+ // ------------------------------------------------------------------
+ // 3. Contribution stats from on-chain history (Horizon)
+ // ------------------------------------------------------------------
+ let contributionStats = { totalCampaigns: 0, totalAmountUsd: 0, successRate: 0 };
+ try {
+ const { rows: statsRows } = await db.query(
+ `SELECT
+ COUNT(DISTINCT c.id)::int AS total_campaigns,
+ COALESCE(SUM(con.amount), 0)::numeric AS total_amount,
+ COUNT(DISTINCT c.id) FILTER (
+ WHERE c.status IN ('funded','completed','withdrawn')
+ )::int AS successful_campaigns
+ FROM contributions con
+ JOIN campaigns c ON c.id = con.campaign_id
+ WHERE con.sender_public_key = $1
+ AND con.refunded = FALSE`,
+ [publicKey]
+ );
+ if (statsRows.length) {
+ const s = statsRows[0];
+ const total = parseInt(s.total_campaigns, 10) || 0;
+ const successful = parseInt(s.successful_campaigns, 10) || 0;
+ contributionStats = {
+ totalCampaigns: total,
+ totalAmountUsd: parseFloat(s.total_amount) || 0,
+ successRate: total > 0 ? Math.round((successful / total) * 100) : 0,
+ };
+ }
+ } catch (err) {
+ logger.warn('contributorIdentityService.getContributorProfile: stats query failed', {
+ publicKey,
+ error: err.message,
+ });
+ }
+
+ return {
+ did: onChainIdentity?.did ?? buildDid(publicKey),
+ reputationScore: Number(onChainIdentity?.reputation_score ?? 0),
+ attestations,
+ contributionStats,
+ registered: onChainIdentity !== null,
+ };
+}
+
+/**
+ * verifyAttestation(publicKey, attestationType)
+ *
+ * Checks both the off-chain DB record and the on-chain contract state.
+ * Returns { verified: boolean, expiresAt: Date|null }.
+ *
+ * "verified" is true only when:
+ * - There is an active (non-revoked, non-expired) DB row, AND
+ * - The contract's has_attestation returns true.
+ */
+async function verifyAttestation(publicKey, attestationType) {
+ // DB check
+ const { rows } = await db.query(
+ `SELECT expires_at FROM kyc_attestations
+ WHERE public_key = $1
+ AND attestation_type = $2::kyc_attestation_type
+ AND revoked_at IS NULL
+ AND (expires_at IS NULL OR expires_at > NOW())
+ ORDER BY issued_at DESC
+ LIMIT 1`,
+ [publicKey, attestationType]
+ );
+
+ if (!rows.length) {
+ return { verified: false, expiresAt: null };
+ }
+
+ // On-chain check
+ let onChain = false;
+ try {
+ onChain = await contractRead('has_attestation', [
+ nativeToScVal(Address.fromString(publicKey), { type: 'address' }),
+ nativeToScVal(attestationType, { type: 'symbol' }),
+ ]);
+ } catch (err) {
+ logger.warn('contributorIdentityService.verifyAttestation: contract read failed', {
+ publicKey,
+ attestationType,
+ error: err.message,
+ });
+ // Fall back to DB-only result to avoid blocking legitimate contributors
+ // when the RPC is temporarily unavailable.
+ onChain = true;
+ }
+
+ return {
+ verified: Boolean(onChain),
+ expiresAt: rows[0].expires_at || null,
+ };
+}
+
+/**
+ * assertContributorMeetsRequirements(publicKey, campaignId)
+ *
+ * Throws a structured 403 error if the contributor does not satisfy the
+ * campaign's requirements. Returns silently if there are no requirements or
+ * the contributor satisfies all of them.
+ */
+async function assertContributorMeetsRequirements(publicKey, campaignId) {
+ const { rows: reqRows } = await db.query(
+ 'SELECT * FROM campaign_requirements WHERE campaign_id = $1',
+ [campaignId]
+ );
+
+ // No requirements set — allow everyone through
+ if (!reqRows.length) return;
+
+ const req = reqRows[0];
+ const missing = [];
+
+ // --- Reputation check ---
+ if (req.min_reputation_score > 0) {
+ let score = 0;
+ try {
+ const identity = await contractRead('get_identity', [
+ nativeToScVal(Address.fromString(publicKey), { type: 'address' }),
+ ]);
+ score = Number(identity?.reputation_score ?? 0);
+ } catch (_err) {
+ score = 0;
+ }
+
+ if (score < req.min_reputation_score) {
+ missing.push({
+ type: 'reputation',
+ required: req.min_reputation_score,
+ current: score,
+ message: `Reputation score ${score} is below the required ${req.min_reputation_score}`,
+ });
+ }
+ }
+
+ // --- Attestation checks ---
+ const requiredAttestations = Array.isArray(req.required_attestations)
+ ? req.required_attestations
+ : [];
+
+ for (const attestationType of requiredAttestations) {
+ const { verified } = await verifyAttestation(publicKey, attestationType);
+ if (!verified) {
+ missing.push({
+ type: 'attestation',
+ attestation: attestationType,
+ message: `Missing required attestation: ${attestationType}`,
+ });
+ }
+ }
+
+ if (missing.length > 0) {
+ const err = new Error('Contributor does not meet campaign requirements');
+ err.statusCode = 403;
+ err.code = 'CONTRIBUTOR_REQUIREMENTS_NOT_MET';
+ err.missing = missing;
+ throw err;
+ }
+}
+
+module.exports = {
+ registerIdentity,
+ issueKycAttestation,
+ updateReputationScore,
+ getContributorProfile,
+ verifyAttestation,
+ assertContributorMeetsRequirements,
+ buildDid,
+ sha256Hex,
+ attestationTypeForTier,
+ REPUTATION_DELTAS,
+};
diff --git a/contracts/soroban/Cargo.toml b/contracts/soroban/Cargo.toml
index ab259b29..223df8f5 100644
--- a/contracts/soroban/Cargo.toml
+++ b/contracts/soroban/Cargo.toml
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"contracts/campaign_treasury",
+ "contracts/contributor_identity",
"contracts/crowdpay",
"contracts/escrow",
"contracts/fee_registry",
diff --git a/contracts/soroban/contracts/contributor_identity/Cargo.toml b/contracts/soroban/contracts/contributor_identity/Cargo.toml
new file mode 100644
index 00000000..cca9d788
--- /dev/null
+++ b/contracts/soroban/contracts/contributor_identity/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "contributor_identity"
+version = "0.0.0"
+edition = "2021"
+publish = false
+
+[lib]
+crate-type = ["lib", "cdylib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
diff --git a/contracts/soroban/contracts/contributor_identity/src/lib.rs b/contracts/soroban/contracts/contributor_identity/src/lib.rs
new file mode 100644
index 00000000..b0186c5f
--- /dev/null
+++ b/contracts/soroban/contracts/contributor_identity/src/lib.rs
@@ -0,0 +1,370 @@
+#![no_std]
+use soroban_sdk::{
+ contract, contractimpl, contracttype, Address, BytesN, Env, Map, String, Symbol, Vec,
+};
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+/// Maximum reputation score a contributor can hold.
+pub const MAX_REPUTATION: u32 = 1000;
+
+// ---------------------------------------------------------------------------
+// Storage key enum
+// ---------------------------------------------------------------------------
+
+#[derive(Clone)]
+#[contracttype]
+pub enum DataKey {
+ /// Per-contributor identity record.
+ Identity(Address),
+ /// Approved attestation issuers (stored as a Map).
+ IssuersMap,
+ /// Address of the platform contract that is allowed to call update_reputation.
+ PlatformContract,
+ /// Contract admin (initially set at deploy time).
+ Admin,
+ /// Guards against double-initialisation.
+ IsInitialized,
+}
+
+// ---------------------------------------------------------------------------
+// Core types
+// ---------------------------------------------------------------------------
+
+/// A single KYC / identity attestation issued by an approved issuer.
+#[derive(Clone)]
+#[contracttype]
+pub struct Attestation {
+ /// Stellar address of the issuer (platform admin or approved third-party).
+ pub issuer: Address,
+ /// Short symbol identifying the attestation class, e.g. `kyc_basic`.
+ pub attestation_type: Symbol,
+ /// Ledger timestamp at the time of issuance.
+ pub issued_at: u64,
+ /// Optional expiry ledger timestamp. `0` means no expiry.
+ pub expires_at: u64,
+ /// Whether this attestation has been revoked by its issuer.
+ pub revoked: bool,
+ /// SHA-256 hash of the off-chain KYC document reference — never the
+ /// document itself.
+ pub proof_hash: BytesN<32>,
+}
+
+/// The full on-chain identity record for a contributor.
+#[derive(Clone)]
+#[contracttype]
+pub struct ContributorIdentity {
+ /// Decentralised identifier string: `did:stellar:`.
+ pub did: String,
+ /// List of attestations attached to this identity.
+ pub attestations: Vec,
+ /// Reputation score in the range [0, 1000].
+ pub reputation_score: u32,
+ /// Ledger timestamp of the last write to this record.
+ pub last_updated: u64,
+}
+
+// ---------------------------------------------------------------------------
+// Contract
+// ---------------------------------------------------------------------------
+
+#[contract]
+pub struct ContributorIdentityContract;
+
+#[contractimpl]
+impl ContributorIdentityContract {
+ // -----------------------------------------------------------------------
+ // Lifecycle
+ // -----------------------------------------------------------------------
+
+ /// Initialise the contract. Must be called exactly once.
+ ///
+ /// * `admin` — wallet that governs the issuers list.
+ /// * `platform_contract` — the address authorised to call `update_reputation`.
+ pub fn initialize(env: Env, admin: Address, platform_contract: Address) {
+ if env.storage().instance().has(&DataKey::IsInitialized) {
+ panic!("already initialized");
+ }
+ admin.require_auth();
+
+ env.storage().instance().set(&DataKey::Admin, &admin);
+ env.storage()
+ .instance()
+ .set(&DataKey::PlatformContract, &platform_contract);
+ // Start with an empty issuers map.
+ let issuers: Map = Map::new(&env);
+ env.storage()
+ .instance()
+ .set(&DataKey::IssuersMap, &issuers);
+ env.storage().instance().set(&DataKey::IsInitialized, &true);
+ }
+
+ // -----------------------------------------------------------------------
+ // Issuer management (admin-only)
+ // -----------------------------------------------------------------------
+
+ /// Grant attestation-issuing rights to `issuer`.
+ pub fn add_issuer(env: Env, issuer: Address) {
+ let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
+ admin.require_auth();
+
+ let mut issuers: Map = env
+ .storage()
+ .instance()
+ .get(&DataKey::IssuersMap)
+ .unwrap_or_else(|| Map::new(&env));
+ issuers.set(issuer.clone(), true);
+ env.storage().instance().set(&DataKey::IssuersMap, &issuers);
+
+ env.events()
+ .publish((Symbol::new(&env, "issuer_added"), issuer), ());
+ }
+
+ /// Revoke attestation-issuing rights from `issuer`.
+ pub fn remove_issuer(env: Env, issuer: Address) {
+ let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
+ admin.require_auth();
+
+ let mut issuers: Map = env
+ .storage()
+ .instance()
+ .get(&DataKey::IssuersMap)
+ .unwrap_or_else(|| Map::new(&env));
+ issuers.remove(issuer.clone());
+ env.storage().instance().set(&DataKey::IssuersMap, &issuers);
+
+ env.events()
+ .publish((Symbol::new(&env, "issuer_removed"), issuer), ());
+ }
+
+ // -----------------------------------------------------------------------
+ // Identity registration (self-service)
+ // -----------------------------------------------------------------------
+
+ /// Register a DID for the calling Stellar account. Idempotent: calling a
+ /// second time after the identity already exists is a no-op.
+ ///
+ /// The DID is formatted as `did:stellar:`.
+ pub fn register(env: Env, caller: Address, did: String) {
+ caller.require_auth();
+
+ // Idempotent — never overwrite an existing record.
+ let key = DataKey::Identity(caller.clone());
+ if env.storage().persistent().has(&key) {
+ return;
+ }
+
+ let identity = ContributorIdentity {
+ did,
+ attestations: Vec::new(&env),
+ reputation_score: 0,
+ last_updated: env.ledger().timestamp(),
+ };
+ env.storage().persistent().set(&key, &identity);
+
+ env.events()
+ .publish((Symbol::new(&env, "identity_registered"), caller), ());
+ }
+
+ // -----------------------------------------------------------------------
+ // Attestation management (approved issuers only)
+ // -----------------------------------------------------------------------
+
+ /// Attach an attestation to a subject's identity record.
+ ///
+ /// Callable only by addresses listed in the issuers map.
+ pub fn add_attestation(
+ env: Env,
+ issuer: Address,
+ subject: Address,
+ attestation_type: Symbol,
+ expires_at: u64,
+ proof_hash: BytesN<32>,
+ ) {
+ issuer.require_auth();
+ Self::assert_approved_issuer(&env, &issuer);
+
+ let key = DataKey::Identity(subject.clone());
+ let mut identity: ContributorIdentity = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic!("subject has no registered identity"));
+
+ let attestation = Attestation {
+ issuer: issuer.clone(),
+ attestation_type: attestation_type.clone(),
+ issued_at: env.ledger().timestamp(),
+ expires_at,
+ revoked: false,
+ proof_hash,
+ };
+ identity.attestations.push_back(attestation);
+ identity.last_updated = env.ledger().timestamp();
+
+ env.storage().persistent().set(&key, &identity);
+
+ env.events().publish(
+ (Symbol::new(&env, "attestation_added"), subject),
+ attestation_type,
+ );
+ }
+
+ /// Revoke an attestation at `attestation_index` inside the subject's list.
+ ///
+ /// Only the original issuer of that specific attestation may revoke it.
+ pub fn revoke_attestation(
+ env: Env,
+ issuer: Address,
+ subject: Address,
+ attestation_index: u32,
+ ) {
+ issuer.require_auth();
+
+ let key = DataKey::Identity(subject.clone());
+ let mut identity: ContributorIdentity = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic!("subject has no registered identity"));
+
+ let idx = attestation_index as usize;
+ if idx >= identity.attestations.len() as usize {
+ panic!("attestation index out of range");
+ }
+
+ let mut att = identity.attestations.get(attestation_index).unwrap();
+
+ // Enforce that only the original issuer may revoke.
+ if att.issuer != issuer {
+ panic!("only the original issuer may revoke this attestation");
+ }
+
+ att.revoked = true;
+ identity.attestations.set(attestation_index, att);
+ identity.last_updated = env.ledger().timestamp();
+
+ env.storage().persistent().set(&key, &identity);
+
+ env.events().publish(
+ (Symbol::new(&env, "attestation_revoked"), subject),
+ attestation_index,
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // Reputation management (platform contract only)
+ // -----------------------------------------------------------------------
+
+ /// Adjust the subject's reputation score by `delta` (positive or negative).
+ /// The score is clamped to [0, MAX_REPUTATION].
+ ///
+ /// Callable only by the platform contract address set during initialisation.
+ pub fn update_reputation(env: Env, subject: Address, delta: i32) {
+ let platform: Address = env
+ .storage()
+ .instance()
+ .get(&DataKey::PlatformContract)
+ .unwrap();
+ platform.require_auth();
+
+ let key = DataKey::Identity(subject.clone());
+ let mut identity: ContributorIdentity = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic!("subject has no registered identity"));
+
+ let current = identity.reputation_score as i64;
+ let updated = (current + delta as i64).max(0).min(MAX_REPUTATION as i64) as u32;
+ identity.reputation_score = updated;
+ identity.last_updated = env.ledger().timestamp();
+
+ env.storage().persistent().set(&key, &identity);
+
+ env.events().publish(
+ (Symbol::new(&env, "reputation_updated"), subject),
+ updated,
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // Read-only queries
+ // -----------------------------------------------------------------------
+
+ /// Return the full identity record for `subject`.
+ pub fn get_identity(env: Env, subject: Address) -> ContributorIdentity {
+ let key = DataKey::Identity(subject);
+ env.storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic!("no identity registered for subject"))
+ }
+
+ /// Return `true` if the subject has at least one non-revoked, non-expired
+ /// attestation of `attestation_type`.
+ pub fn has_attestation(env: Env, subject: Address, attestation_type: Symbol) -> bool {
+ let key = DataKey::Identity(subject);
+ let identity: ContributorIdentity = match env.storage().persistent().get(&key) {
+ Some(id) => id,
+ None => return false,
+ };
+
+ let now = env.ledger().timestamp();
+ for i in 0..identity.attestations.len() {
+ let att = identity.attestations.get(i).unwrap();
+ if att.attestation_type == attestation_type
+ && !att.revoked
+ && (att.expires_at == 0 || att.expires_at > now)
+ {
+ return true;
+ }
+ }
+ false
+ }
+
+ /// Return `true` if the subject has a valid (non-revoked, non-expired)
+ /// attestation of `attestation_type` whose `proof_hash` matches exactly.
+ pub fn verify_proof(
+ env: Env,
+ subject: Address,
+ attestation_type: Symbol,
+ proof_hash: BytesN<32>,
+ ) -> bool {
+ let key = DataKey::Identity(subject);
+ let identity: ContributorIdentity = match env.storage().persistent().get(&key) {
+ Some(id) => id,
+ None => return false,
+ };
+
+ let now = env.ledger().timestamp();
+ for i in 0..identity.attestations.len() {
+ let att = identity.attestations.get(i).unwrap();
+ if att.attestation_type == attestation_type
+ && !att.revoked
+ && (att.expires_at == 0 || att.expires_at > now)
+ && att.proof_hash == proof_hash
+ {
+ return true;
+ }
+ }
+ false
+ }
+
+ // -----------------------------------------------------------------------
+ // Internal helpers
+ // -----------------------------------------------------------------------
+
+ fn assert_approved_issuer(env: &Env, issuer: &Address) {
+ let issuers: Map = env
+ .storage()
+ .instance()
+ .get(&DataKey::IssuersMap)
+ .unwrap_or_else(|| Map::new(env));
+ if !issuers.contains_key(issuer.clone()) {
+ panic!("caller is not an approved attestation issuer");
+ }
+ }
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 1a622a93..21d2eec8 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -41,6 +41,7 @@ const Governance = lazy(() => import('./pages/Governance'));
const CampaignShare = lazy(() => import('./pages/CampaignShare'));
const ReferralDashboard = lazy(() => import('./pages/ReferralDashboard'));
const OpsCenter = lazy(() => import('./pages/OpsCenter'));
+const ContributorIdentityPage = lazy(() => import('./pages/ContributorIdentityPage'));
function PrivateRoute({ children }) {
const { user, ready } = useAuth();
@@ -148,6 +149,14 @@ export default function App() {
}
/>
+
+
+
+ }
+ />
+ Not verified
+
+ );
+ }
+ if (attestation.revoked) {
+ return (
+
+ Revoked
+
+ );
+ }
+ if (attestation.expiresAt && new Date(attestation.expiresAt) < new Date()) {
+ return (
+
+ Expired
+
+ );
+ }
+ return (
+
+ Verified
+
+ );
+}
+
+export default function AttestationsPanel({ attestations = [], onStartKyc }) {
+ const { t } = useTranslation();
+
+ // Build a lookup of type → active (non-revoked, non-expired) attestation
+ const byType = {};
+ for (const att of attestations) {
+ const existing = byType[att.type];
+ const isActive = !att.revoked && (!att.expiresAt || new Date(att.expiresAt) > new Date());
+ // Prefer the most-recent active one; fall back to any
+ if (!existing || (isActive && !byType[att.type]._isActive)) {
+ byType[att.type] = { ...att, _isActive: isActive };
+ }
+ }
+
+ return (
+
+
+ Identity Attestations
+
+
+ {ALL_TYPES.map(({ type, label, description }) => {
+ const att = byType[type] || null;
+ const isActive = att?._isActive ?? false;
+
+ return (
+
+ {/* Icon */}
+
+ {isActive ? '✅' : '🔲'}
+
+
+ {/* Info */}
+
+
{label}
+
+ {description}
+
+ {att && (
+
+ Issued {formatDate(att.issuedAt)}
+ {att.expiresAt ? ` · Expires ${formatDate(att.expiresAt)}` : ''}
+ {att.issuer ? ` · Issuer: ${att.issuer}` : ''}
+
+ )}
+
+
+ {/* Status + CTA */}
+
+
+ {!isActive && onStartKyc && (
+ onStartKyc(type)}
+ >
+ Get Verified
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/src/components/ReputationGauge.jsx b/frontend/src/components/ReputationGauge.jsx
new file mode 100644
index 00000000..fc353bc8
--- /dev/null
+++ b/frontend/src/components/ReputationGauge.jsx
@@ -0,0 +1,127 @@
+/**
+ * ReputationGauge
+ *
+ * Displays a contributor's reputation score (0–1000) as an SVG radial gauge
+ * with a tier label. No external charting library required.
+ *
+ * Tiers (per spec):
+ * Newcomer 0–99
+ * Contributor 100–299
+ * Trusted 300–599
+ * Veteran 600–849
+ * Champion 850–1000
+ */
+
+const TIERS = [
+ { label: 'Newcomer', min: 0, max: 99, color: 'var(--color-text-hint)' },
+ { label: 'Contributor', min: 100, max: 299, color: 'var(--color-accent)' },
+ { label: 'Trusted', min: 300, max: 599, color: 'var(--color-accent-light)' },
+ { label: 'Veteran', min: 600, max: 849, color: '#7c3aed' },
+ { label: 'Champion', min: 850, max: 1000, color: 'var(--color-teal)' },
+];
+
+function getTier(score) {
+ return TIERS.find((t) => score >= t.min && score <= t.max) || TIERS[0];
+}
+
+/** Convert polar coords to SVG cartesian. cx/cy = centre, r = radius. */
+function polarToCartesian(cx, cy, r, angleDeg) {
+ const rad = ((angleDeg - 90) * Math.PI) / 180;
+ return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
+}
+
+/** Build an SVG arc path for a gauge that sweeps from startDeg to endDeg. */
+function arcPath(cx, cy, r, startDeg, endDeg) {
+ const start = polarToCartesian(cx, cy, r, endDeg);
+ const end = polarToCartesian(cx, cy, r, startDeg);
+ const large = endDeg - startDeg > 180 ? 1 : 0;
+ return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
+}
+
+export default function ReputationGauge({ score = 0, size = 160 }) {
+ const clamped = Math.max(0, Math.min(1000, score));
+ const tier = getTier(clamped);
+
+ // Gauge sweeps 240° (from -120° to +120° relative to bottom, i.e. 120°–360°)
+ const START_DEG = 120;
+ const END_DEG = 420; // = 360 + 60, drawn as 420 for SVG arc maths
+ const TOTAL_DEG = END_DEG - START_DEG; // 300°
+
+ const cx = size / 2;
+ const cy = size / 2;
+ const r = size * 0.38;
+ const strokeWidth = size * 0.09;
+
+ const filledDeg = START_DEG + (clamped / 1000) * TOTAL_DEG;
+
+ const trackPath = arcPath(cx, cy, r, START_DEG, END_DEG);
+ const fillPath = clamped > 0 ? arcPath(cx, cy, r, START_DEG, filledDeg) : null;
+
+ return (
+
+
+ {/* Track */}
+
+ {/* Filled arc */}
+ {fillPath && (
+
+ )}
+ {/* Score text */}
+
+ {clamped}
+
+
+ / 1000
+
+
+
+ {/* Tier label */}
+
+ {tier.label}
+
+
+ );
+}
diff --git a/frontend/src/pages/Campaign.jsx b/frontend/src/pages/Campaign.jsx
index 8f074d53..c6b53d3d 100644
--- a/frontend/src/pages/Campaign.jsx
+++ b/frontend/src/pages/Campaign.jsx
@@ -6,6 +6,160 @@ import { api } from '../services/api';
import { useAuth } from '../context/AuthContext';
import { useToast } from '../context/ToastContext';
import ContributeModal from '../components/ContributeModal';
+
+/**
+ * CampaignRequirementsNotice — shown to non-logged-in visitors when the
+ * campaign has KYC or reputation requirements set.
+ */
+function CampaignRequirementsNotice({ campaignId }) {
+ const [req, setReq] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ api.getCampaignRequirements(campaignId)
+ .then((data) => { if (!cancelled) setReq(data); })
+ .catch(() => {});
+ return () => { cancelled = true; };
+ }, [campaignId]);
+
+ if (!req) return null;
+ const attestations = req.required_attestations || [];
+ const hasRequirements = req.min_reputation_score > 0 || attestations.length > 0;
+ if (!hasRequirements) return null;
+
+ const lines = [];
+ if (attestations.length) {
+ const label = attestations[attestations.length - 1].replace('kyc_', 'KYC ');
+ lines.push(`This campaign requires ${label.charAt(0).toUpperCase() + label.slice(1)} verification`);
+ }
+ if (req.min_reputation_score > 0) {
+ lines.push(`Minimum reputation score: ${req.min_reputation_score}`);
+ }
+
+ return (
+
+ {lines.map((l, i) =>
{l}
)}
+
+ );
+}
+ *
+ * Shows a green "You're eligible" or red "Requirements not met" badge.
+ * Fetches campaign requirements + contributor profile in parallel.
+ */
+function ContributorEligibilityBadge({ user, campaignId }) {
+ const [status, setStatus] = useState(null); // null | 'loading' | 'eligible' | 'ineligible' | 'no-requirements'
+ const [missing, setMissing] = useState([]);
+ const [requirements, setRequirements] = useState(null);
+
+ useEffect(() => {
+ if (!user?.wallet_public_key || !campaignId) return;
+ let cancelled = false;
+ setStatus('loading');
+
+ Promise.all([
+ api.getCampaignRequirements(campaignId),
+ api.getContributorIdentityProfile(user.wallet_public_key),
+ ])
+ .then(([req, profile]) => {
+ if (cancelled) return;
+ setRequirements(req);
+
+ const noReq =
+ (!req.min_reputation_score || req.min_reputation_score === 0) &&
+ (!req.required_attestations || req.required_attestations.length === 0);
+
+ if (noReq) {
+ setStatus('no-requirements');
+ return;
+ }
+
+ const gaps = [];
+
+ if (req.min_reputation_score > 0 && profile.reputationScore < req.min_reputation_score) {
+ gaps.push(
+ `Reputation score ${profile.reputationScore} / ${req.min_reputation_score} required`
+ );
+ }
+
+ for (const attType of req.required_attestations || []) {
+ const hasAtt = profile.attestations.some(
+ (a) => a.type === attType && !a.revoked && (!a.expiresAt || new Date(a.expiresAt) > new Date())
+ );
+ if (!hasAtt) {
+ const label = attType.replace('kyc_', 'KYC ');
+ gaps.push(`Missing ${label.charAt(0).toUpperCase() + label.slice(1)} verification`);
+ }
+ }
+
+ setMissing(gaps);
+ setStatus(gaps.length ? 'ineligible' : 'eligible');
+ })
+ .catch(() => {
+ if (!cancelled) setStatus(null);
+ });
+
+ return () => { cancelled = true; };
+ }, [user?.wallet_public_key, campaignId]);
+
+ if (!status || status === 'loading' || status === 'no-requirements') return null;
+
+ if (status === 'eligible') {
+ return (
+
+ ✅
+ You're eligible to contribute
+
+ );
+ }
+
+ return (
+
+
+ 🔒 Requirements not met
+
+
+ {missing.map((m, i) => (
+ {m}
+ ))}
+
+
+ );
+}
import RecurringPledgeForm from '../components/RecurringPledgeForm';
import ReferralProgramSettings from '../components/ReferralProgramSettings';
import CampaignReferralsTab from '../components/CampaignReferralsTab';
@@ -1420,18 +1574,25 @@ export default function Campaign() {
Contributions are closed while this campaign is {campaign.status} .
) : user ? (
- setShowModal(true)}
- >
- Contribute
-
+ <>
+
+ setShowModal(true)}
+ >
+ Contribute
+
+ >
) : (
+
setCopied(false), 2000);
+ } catch {
+ /* ignore */
+ }
+ }
+
+ return (
+
+ {copied ? '✓ Copied' : '⎘ Copy'}
+
+ );
+}
+
+function StatCard({ label, value }) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ );
+}
+
+export default function ContributorIdentityPage() {
+ const { t } = useTranslation();
+ const { user } = useAuth();
+ const { showToast } = useToast();
+
+ const [profile, setProfile] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [registering, setRegistering] = useState(false);
+ const [error, setError] = useState('');
+
+ const publicKey = user?.wallet_public_key;
+
+ const loadProfile = useCallback(async () => {
+ if (!publicKey) return;
+ setLoading(true);
+ setError('');
+ try {
+ const data = await api.getContributorIdentityProfile(publicKey);
+ setProfile(data);
+ } catch (err) {
+ setError(err.message || 'Failed to load identity profile');
+ } finally {
+ setLoading(false);
+ }
+ }, [publicKey]);
+
+ useEffect(() => {
+ loadProfile();
+ }, [loadProfile]);
+
+ async function handleRegister() {
+ setRegistering(true);
+ try {
+ await api.registerContributorIdentity();
+ showToast('Identity registered on-chain.', 'success');
+ await loadProfile();
+ } catch (err) {
+ showToast(err.message || 'Registration failed', 'error');
+ } finally {
+ setRegistering(false);
+ }
+ }
+
+ async function handleStartKyc(/* attestationType */) {
+ try {
+ const session = await api.startKyc();
+ if (session.redirect_url) {
+ window.location.href = session.redirect_url;
+ }
+ } catch (err) {
+ showToast(err.message || 'Could not start KYC', 'error');
+ }
+ }
+
+ if (!user) return null;
+
+ return (
+
+
+ Contributor Identity
+
+
+ Your on-chain identity anchored to your Stellar public key. No personal data is stored here.
+
+
+ {loading && (
+
Loading identity…
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {!loading && profile && (
+
+
+ {/* ── DID section ─────────────────────────────────────────────── */}
+
+
+ Decentralised Identifier (DID)
+
+ {profile.registered ? (
+
+ ) : (
+
+
+ Your identity is not yet registered on-chain.
+
+
+ {registering ? 'Registering…' : 'Register Identity'}
+
+
+ )}
+
+
+ {/* ── Reputation gauge ─────────────────────────────────────── */}
+
+
+ Reputation Score
+
+
+
+ Score is updated on-chain after each campaign interaction. Ranges from 0 to 1000.
+
+
+
+ {/* ── Attestations ─────────────────────────────────────────── */}
+
+
+ {/* ── Contribution stats ───────────────────────────────────── */}
+
+
+ Contribution History
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/CreateCampaign.jsx b/frontend/src/pages/CreateCampaign.jsx
index 8f911908..a3d4a424 100644
--- a/frontend/src/pages/CreateCampaign.jsx
+++ b/frontend/src/pages/CreateCampaign.jsx
@@ -221,6 +221,8 @@ export default function CreateCampaign() {
template_id: '',
milestones: [],
reward_tiers: [],
+ min_reputation_score: 0,
+ required_attestations: [],
});
setStep(1);
setError('');
@@ -571,6 +573,24 @@ export default function CreateCampaign() {
}
}
+ // Save contributor requirements if any were set (#689)
+ const hasRequirements =
+ (form.min_reputation_score > 0) || (form.required_attestations ?? []).length > 0;
+ if (hasRequirements) {
+ try {
+ await api.setCampaignRequirements(campaign.id, {
+ min_reputation_score: form.min_reputation_score ?? 0,
+ required_attestations: form.required_attestations ?? [],
+ });
+ } catch (reqErr) {
+ // Non-fatal: campaign exists, requirements can be set later in settings
+ setError(
+ reqErr.message ||
+ 'Campaign created, but contributor requirements could not be saved. Update them in campaign settings.'
+ );
+ }
+ }
+
clearDraft();
if (draftId) {
api.deleteCampaignDraft(draftId).catch(() => {});
@@ -1571,6 +1591,76 @@ export default function CreateCampaign() {
: ' and no milestone plan.'}
+ {/* ── Contributor Requirements (#689) ─────────────────────────── */}
+
+
+ Contributor Requirements
+
+
+ Optionally restrict who can contribute to this campaign. Contributors who don't
+ meet these requirements will be blocked.
+
+
+ {/* Minimum reputation score */}
+
+ Minimum reputation score (0 = no minimum)
+
+
+ setForm((f) => ({ ...f, min_reputation_score: Number(e.target.value) }))
+ }
+ style={{ width: '100%', margin: '0.5rem 0' }}
+ />
+
+ Selected: {form.min_reputation_score ?? 0}
+
+
+ {/* Required attestations */}
+
+ Required KYC attestations
+
+ {[
+ { value: 'kyc_basic', label: 'KYC Basic' },
+ { value: 'kyc_standard', label: 'KYC Standard' },
+ { value: 'kyc_enhanced', label: 'KYC Enhanced' },
+ ].map(({ value, label }) => {
+ const current = form.required_attestations ?? [];
+ const checked = current.includes(value);
+ return (
+
+
+ setForm((f) => ({
+ ...f,
+ required_attestations: checked
+ ? (f.required_attestations ?? []).filter((a) => a !== value)
+ : [...(f.required_attestations ?? []), value],
+ }))
+ }
+ />
+ {label}
+
+ );
+ })}
+
+ {((form.min_reputation_score > 0) || (form.required_attestations ?? []).length > 0) && (
+
+ Contributors without these requirements will be blocked from contributing to this campaign.
+
+ )}
+
+
{error && (
{error}
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index 39b3b9db..f8236947 100644
--- a/frontend/src/services/api.js
+++ b/frontend/src/services/api.js
@@ -718,4 +718,18 @@ export const api = {
request('PATCH', `/users/me/recurring-contributions/${id}`, body),
deleteRecurringContribution: (id) =>
request('DELETE', `/users/me/recurring-contributions/${id}`),
+
+ // ── Contributor Identity & Reputation (#689) ─────────────────────
+ registerContributorIdentity: () =>
+ request('POST', '/contributor/identity/register', {}),
+ getContributorIdentityProfile: (publicKey) =>
+ request('GET', `/contributor/identity/${encodeURIComponent(publicKey)}`),
+ verifyContributorAttestation: (publicKey, attestation) =>
+ request('GET', `/contributor/identity/${encodeURIComponent(publicKey)}/verify`, null, {
+ query: { attestation },
+ }),
+ getCampaignRequirements: (campaignId) =>
+ request('GET', `/campaigns/${encodeURIComponent(campaignId)}/requirements`),
+ setCampaignRequirements: (campaignId, body) =>
+ request('POST', `/campaigns/${encodeURIComponent(campaignId)}/requirements`, body),
};