Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
112 changes: 112 additions & 0 deletions backend/db/migrations/20260829_contributor_identity_reputation.sql
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
121 changes: 121 additions & 0 deletions backend/src/routes/campaignRequirements.js
Original file line number Diff line number Diff line change
@@ -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;
29 changes: 29 additions & 0 deletions backend/src/routes/contributions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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}` });
}
Expand Down Expand Up @@ -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}`,
Expand Down
Loading