Skip to content

Commit daac4dc

Browse files
authored
Merge pull request Savitura#731 from TheHalalHunter/feat/689-stellar-did-reputation-kyc-attestations
feat(Savitura#689): Stellar DID layer, cross-campaign reputation score & privacy-preserving KYC attestations
2 parents 6d1d99b + 14859f4 commit daac4dc

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)