Skip to content

Commit 14859f4

Browse files
feat(Savitura#689): Stellar DID layer, reputation score & privacy-preserving KYC attestations
## What Implements issue Savitura#689 end-to-end across the Soroban contract layer, backend services/routes, and frontend UI. ## Soroban contract (contracts/soroban/contracts/contributor_identity/) - New contributor_identity Rust contract (soroban-sdk 21.3.0) - Stores ContributorIdentity { did, attestations, reputation_score, last_updated } in persistent storage keyed by contributor Address - Functions: initialize, �dd_issuer, emove_issuer, egister (idempotent), �dd_attestation, evoke_attestation, update_reputation (clamped 0-1000), get_identity, has_attestation, �erify_proof - has_attestation enforces revocation on-chain (&& !att.revoked) - �erify_proof enforces hash match on-chain (&& att.proof_hash == proof_hash) - Added to Cargo workspace ## Database (backend/db/migrations/20260829_contributor_identity_reputation.sql) - contributor_identities (user_id, public_key, did, contract_registered_at) - kyc_attestations (proof_hash only — never raw docs or Persona inquiry content) - campaign_requirements (min_reputation_score 0-1000, required_attestations JSONB) - eputation_events append-only audit log ## Backend service (backend/src/services/contributorIdentityService.js) - egisterIdentity — builds DID, calls contract, stores DB row; idempotent - issueKycAttestation — proof_hash = SHA-256(personaInquiryId), no PII on-chain - updateReputationScore — deltas: +5 contribution_made, +10 contribution_to_successful_campaign, 0 failed, -20 dispute - getContributorProfile — returns zero personal data (no name/email/doc/inquiry ID) - �erifyAttestation — dual DB + on-chain check with RPC fallback - �ssertContributorMeetsRequirements — throws 403 CONTRIBUTOR_REQUIREMENTS_NOT_MET with structured missing[] array ## Backend routes - POST/GET /api/contributor/identity/register|:publicKey|:publicKey/verify - POST/GET /api/campaigns/:id/requirements (creator-only write, public read) - Contribution gate added to both /prepare and custodial POST / - KYC webhook fires issueKycAttestation fire-and-forget after Persona approval ## Frontend - ReputationGauge — SVG radial gauge, tiers: Newcomer/Contributor/Trusted/Veteran/Champion - AttestationsPanel — 3 KYC tiers with Get Verified CTAs, no PII displayed - ContributorIdentityPage at /profile/identity — DID, gauge, attestations, stats - Campaign page: ContributorEligibilityBadge (eligible / requirements not met + gap) and CampaignRequirementsNotice for logged-out visitors - CreateCampaign step 3: reputation slider (0-500) + KYC attestation checkboxes ## Acceptance criteria - register creates verifiable on-chain ContributorIdentity record - has_attestation returns false for revoked attestation (enforced on-chain) - verify_proof returns false on hash mismatch (enforced on-chain) - +10 reputation on campaign success - 403 CONTRIBUTOR_REQUIREMENTS_NOT_MET for score 299 vs min 300 - Eligibility indicator shows KYC gap (Basic vs Standard) - getContributorProfile schema contains zero personal data Closes Savitura#689
1 parent f7632e8 commit 14859f4

18 files changed

Lines changed: 2297 additions & 11 deletions

File tree

backend/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,6 @@ STORAGE_ENDPOINT=
180180
DB_POOL_MAX=10
181181
DB_IDLE_TIMEOUT_MS=30000
182182
DB_CONNECTION_TIMEOUT_MS=5000
183+
184+
# Contributor identity contract (#689) — deploy contracts/soroban/contracts/contributor_identity
185+
# CONTRIBUTOR_IDENTITY_CONTRACT_ID=
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
-- #689 Stellar DID Layer, Cross-Campaign Reputation Score & Privacy-Preserving KYC Attestations
2+
--
3+
-- New tables:
4+
-- contributor_identities — links a user to their on-chain DID
5+
-- kyc_attestations — privacy-preserving off-chain KYC records (hash only, never raw docs)
6+
-- campaign_requirements — per-campaign contribution gate (min reputation + required attestations)
7+
-- reputation_events — append-only audit log of every reputation delta
8+
9+
-- ---------------------------------------------------------------------------
10+
-- contributor_identities
11+
-- ---------------------------------------------------------------------------
12+
CREATE TABLE IF NOT EXISTS contributor_identities (
13+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
14+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
15+
public_key TEXT NOT NULL,
16+
did TEXT NOT NULL,
17+
contract_registered_at TIMESTAMPTZ,
18+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
19+
CONSTRAINT contributor_identities_public_key_unique UNIQUE (public_key),
20+
CONSTRAINT contributor_identities_did_unique UNIQUE (did),
21+
CONSTRAINT contributor_identities_user_id_unique UNIQUE (user_id)
22+
);
23+
24+
CREATE INDEX IF NOT EXISTS contributor_identities_user_id_idx
25+
ON contributor_identities (user_id);
26+
27+
-- ---------------------------------------------------------------------------
28+
-- kyc_attestations
29+
-- Stores the off-chain record of a KYC-level attestation.
30+
-- proof_hash is the SHA-256 of the Persona inquiry ID — never the document.
31+
-- ---------------------------------------------------------------------------
32+
DO $$ BEGIN
33+
CREATE TYPE kyc_attestation_type AS ENUM ('kyc_basic', 'kyc_standard', 'kyc_enhanced');
34+
EXCEPTION WHEN duplicate_object THEN NULL;
35+
END $$;
36+
37+
CREATE TABLE IF NOT EXISTS kyc_attestations (
38+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
39+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
40+
public_key TEXT NOT NULL,
41+
attestation_type kyc_attestation_type NOT NULL,
42+
kyc_level TEXT NOT NULL,
43+
persona_inquiry_id TEXT,
44+
proof_hash TEXT NOT NULL,
45+
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
46+
expires_at TIMESTAMPTZ,
47+
revoked_at TIMESTAMPTZ,
48+
on_chain_tx_hash TEXT,
49+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
50+
);
51+
52+
CREATE INDEX IF NOT EXISTS kyc_attestations_user_id_idx
53+
ON kyc_attestations (user_id);
54+
CREATE INDEX IF NOT EXISTS kyc_attestations_public_key_idx
55+
ON kyc_attestations (public_key);
56+
CREATE INDEX IF NOT EXISTS kyc_attestations_active_idx
57+
ON kyc_attestations (user_id, attestation_type)
58+
WHERE revoked_at IS NULL;
59+
60+
-- ---------------------------------------------------------------------------
61+
-- campaign_requirements
62+
-- One row per campaign; upsert on conflict to allow updates by the creator.
63+
-- required_attestations is a JSONB array of attestation type strings,
64+
-- e.g. ["kyc_basic", "kyc_standard"].
65+
-- ---------------------------------------------------------------------------
66+
CREATE TABLE IF NOT EXISTS campaign_requirements (
67+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
68+
campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
69+
min_reputation_score INTEGER NOT NULL DEFAULT 0
70+
CHECK (min_reputation_score >= 0 AND min_reputation_score <= 1000),
71+
required_attestations JSONB NOT NULL DEFAULT '[]'::jsonb,
72+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
73+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
74+
CONSTRAINT campaign_requirements_campaign_id_unique UNIQUE (campaign_id)
75+
);
76+
77+
CREATE INDEX IF NOT EXISTS campaign_requirements_campaign_id_idx
78+
ON campaign_requirements (campaign_id);
79+
80+
-- ---------------------------------------------------------------------------
81+
-- reputation_events
82+
-- Immutable audit log — rows are never updated or deleted.
83+
-- ---------------------------------------------------------------------------
84+
DO $$ BEGIN
85+
CREATE TYPE reputation_event_type AS ENUM (
86+
'contribution_made',
87+
'contribution_to_successful_campaign',
88+
'contribution_to_failed_campaign',
89+
'dispute_raised_against_contributor'
90+
);
91+
EXCEPTION WHEN duplicate_object THEN NULL;
92+
END $$;
93+
94+
CREATE TABLE IF NOT EXISTS reputation_events (
95+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
96+
public_key TEXT NOT NULL,
97+
event_type reputation_event_type NOT NULL,
98+
delta INTEGER NOT NULL DEFAULT 0,
99+
resulting_score INTEGER NOT NULL DEFAULT 0
100+
CHECK (resulting_score >= 0 AND resulting_score <= 1000),
101+
related_campaign_id UUID REFERENCES campaigns(id) ON DELETE SET NULL,
102+
on_chain_tx_hash TEXT,
103+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
104+
);
105+
106+
CREATE INDEX IF NOT EXISTS reputation_events_public_key_idx
107+
ON reputation_events (public_key);
108+
CREATE INDEX IF NOT EXISTS reputation_events_public_key_created_idx
109+
ON reputation_events (public_key, created_at DESC);
110+
CREATE INDEX IF NOT EXISTS reputation_events_campaign_idx
111+
ON reputation_events (related_campaign_id)
112+
WHERE related_campaign_id IS NOT NULL;

backend/src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,8 @@ app.use("/api/campaigns", require("./routes/campaignComments"));
277277
app.use("/api/campaigns", require("./routes/campaignFollowers"));
278278
app.use("/api/campaigns", require("./routes/campaigns"));
279279
app.use("/api/campaign-templates", require("./routes/campaignTemplates"));
280+
app.use("/api/campaigns", require("./routes/campaignRequirements"));
281+
app.use("/api/contributor/identity", require("./routes/contributorIdentity"));
280282
app.use("/api/campaigns", require("./routes/impactReports"));
281283
app.use("/api/campaigns", require("./routes/sponsorMatching"));
282284
app.use("/api/campaigns", require("./routes/translations"));
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Campaign requirements routes — issue #689
3+
*
4+
* POST /api/campaigns/:id/requirements — creator-only; set/update requirements
5+
* GET /api/campaigns/:id/requirements — public; read current requirements
6+
*/
7+
8+
const router = require('express').Router({ mergeParams: true });
9+
const { body, param, validationResult } = require('express-validator');
10+
const { requireAuth } = require('../middleware/auth');
11+
const asyncHandler = require('../utils/asyncHandler');
12+
const db = require('../config/database');
13+
14+
const VALID_ATTESTATION_TYPES = ['kyc_basic', 'kyc_standard', 'kyc_enhanced'];
15+
16+
function validateRequest(req, res, next) {
17+
const errors = validationResult(req);
18+
if (!errors.isEmpty()) {
19+
return res.status(422).json({
20+
error: {
21+
code: 'VALIDATION_ERROR',
22+
message: 'Invalid request parameters',
23+
fields: Object.fromEntries(errors.array().map((e) => [e.path, e.msg])),
24+
},
25+
});
26+
}
27+
next();
28+
}
29+
30+
/**
31+
* POST /api/campaigns/:id/requirements
32+
*
33+
* Campaign creator sets contribution requirements. Upserts — a second call
34+
* replaces the previous record.
35+
*/
36+
router.post(
37+
'/:id/requirements',
38+
requireAuth,
39+
[
40+
param('id').isUUID().withMessage('Campaign id must be a valid UUID'),
41+
body('min_reputation_score')
42+
.optional()
43+
.isInt({ min: 0, max: 500 })
44+
.withMessage('min_reputation_score must be an integer between 0 and 500'),
45+
body('required_attestations')
46+
.optional()
47+
.isArray()
48+
.withMessage('required_attestations must be an array')
49+
.custom((arr) => {
50+
for (const item of arr) {
51+
if (!VALID_ATTESTATION_TYPES.includes(item)) {
52+
throw new Error(
53+
`Each attestation must be one of: ${VALID_ATTESTATION_TYPES.join(', ')}`
54+
);
55+
}
56+
}
57+
return true;
58+
}),
59+
],
60+
validateRequest,
61+
asyncHandler(async (req, res) => {
62+
const campaignId = req.params.id;
63+
const { userId } = req.user;
64+
const minReputationScore = req.body.min_reputation_score ?? 0;
65+
const requiredAttestations = req.body.required_attestations ?? [];
66+
67+
// Verify campaign ownership
68+
const { rows: campRows } = await db.query(
69+
'SELECT id, creator_id FROM campaigns WHERE id = $1 AND deleted_at IS NULL',
70+
[campaignId]
71+
);
72+
if (!campRows.length) {
73+
return res.status(404).json({ error: 'Campaign not found' });
74+
}
75+
if (campRows[0].creator_id !== userId) {
76+
return res.status(403).json({ error: 'Only the campaign creator can set requirements' });
77+
}
78+
79+
const { rows } = await db.query(
80+
`INSERT INTO campaign_requirements
81+
(campaign_id, min_reputation_score, required_attestations)
82+
VALUES ($1, $2, $3::jsonb)
83+
ON CONFLICT (campaign_id) DO UPDATE
84+
SET min_reputation_score = EXCLUDED.min_reputation_score,
85+
required_attestations = EXCLUDED.required_attestations,
86+
updated_at = NOW()
87+
RETURNING *`,
88+
[campaignId, minReputationScore, JSON.stringify(requiredAttestations)]
89+
);
90+
91+
res.status(200).json(rows[0]);
92+
})
93+
);
94+
95+
/**
96+
* GET /api/campaigns/:id/requirements
97+
*
98+
* Public read. Returns the current requirements for a campaign, or nulls
99+
* if none have been set.
100+
*/
101+
router.get(
102+
'/:id/requirements',
103+
[param('id').isUUID().withMessage('Campaign id must be a valid UUID')],
104+
validateRequest,
105+
asyncHandler(async (req, res) => {
106+
const { rows } = await db.query(
107+
'SELECT * FROM campaign_requirements WHERE campaign_id = $1',
108+
[req.params.id]
109+
);
110+
if (!rows.length) {
111+
return res.json({
112+
campaign_id: req.params.id,
113+
min_reputation_score: 0,
114+
required_attestations: [],
115+
});
116+
}
117+
res.json(rows[0]);
118+
})
119+
);
120+
121+
module.exports = router;

backend/src/routes/contributions.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const { recordConfirmedContribution } = require('../services/ledgerMonitor');
4040
const { emitWebhookEventForUser, emitWebhookEventForCampaign, WEBHOOK_EVENTS } = require('../services/webhookDispatcher');
4141
const { ERROR_CODES } = require('../services/dispute');
4242
const { assertUserKycVerified } = require('../services/kycService');
43+
const { assertContributorMeetsRequirements } = require('../services/contributorIdentityService');
4344
const asyncHandler = require('../utils/asyncHandler');
4445
const { getReferralCodeFromRequest } = require('../services/referralService');
4546
const { resolveReferralLink } = require('../services/referral');
@@ -449,6 +450,20 @@ router.post('/prepare', requireAuth, contributionValidation, validateRequest, as
449450
const campaign = await loadActiveCampaign(campaign_id);
450451
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
451452

453+
// Contributor requirements gate (#689) — check before building any XDR
454+
try {
455+
await assertContributorMeetsRequirements(sender_public_key, campaign_id);
456+
} catch (err) {
457+
if (err.code === 'CONTRIBUTOR_REQUIREMENTS_NOT_MET') {
458+
return res.status(403).json({
459+
error: err.message,
460+
code: err.code,
461+
missing: err.missing,
462+
});
463+
}
464+
throw err;
465+
}
466+
452467
if (campaign.min_contribution && parseFloat(amount) < parseFloat(campaign.min_contribution)) {
453468
return res.status(400).json({ error: `Contribution amount is below the minimum limit of ${campaign.min_contribution} ${campaign.asset_type}` });
454469
}
@@ -846,6 +861,20 @@ router.post('/', contributionPostLimiter, requireAuth, contributionValidation, v
846861
);
847862
const contributorPublicKey = users[0].wallet_public_key;
848863

864+
// Contributor requirements gate (#689)
865+
try {
866+
await assertContributorMeetsRequirements(contributorPublicKey, campaign_id);
867+
} catch (err) {
868+
if (err.code === 'CONTRIBUTOR_REQUIREMENTS_NOT_MET') {
869+
return res.status(403).json({
870+
error: err.message,
871+
code: err.code,
872+
missing: err.missing,
873+
});
874+
}
875+
throw err;
876+
}
877+
849878
if (campaign.min_contribution && parseFloat(amount) < parseFloat(campaign.min_contribution)) {
850879
return res.status(400).json({
851880
error: `Minimum contribution is ${campaign.min_contribution} ${campaign.asset_type}`,

0 commit comments

Comments
 (0)