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 && ( + + )} +
+
+ ); + })} +
+
+ ); +} 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 ( +
+ + + {/* 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 ? ( - + <> + + + ) : (
+ setCopied(false), 2000); + } catch { + /* ignore */ + } + } + + return ( + + ); +} + +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 ? ( +
+ {profile.did} + + + View on Stellar Expert ↗ + +
+ ) : ( +
+

+ Your identity is not yet registered on-chain. +

+ +
+ )} +
+ + {/* ── 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 */} + + + 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 */} + + {[ + { 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 ( + + ); + })} + + {((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), };