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
10 changes: 10 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ const jobRoutes = loadRoute('./routes/jobRoutes');
// Unified payments routes — Issue #391 (Stripe fiat + Stellar crypto)
// @ts-ignore
const paymentsRoutes = loadRoute('./routes/payments');
// Marketplace dispute resolution and arbitration — Issue #394
// @ts-ignore
const disputesRoutes = loadRoute('./routes/disputes');

// Initialize Express app
const app: Application = express();
Expand Down Expand Up @@ -284,6 +287,7 @@ app.use('/api/dashboard', dashboardRoutes);

// Unified payments — Issue #391
app.use('/api/payments', paymentsRoutes);
app.use('/api/disputes', disputesRoutes);

// Autonomous Agents routes
// @ts-ignore
Expand Down Expand Up @@ -375,6 +379,8 @@ app.use('/api/metrics', metricsRoutes);
app.use('/api/jobs', jobRoutes);

// DID registry — Issue #397
// @ts-ignore
const didRoutes = loadRoute('./routes/did');
app.use('/api/did', didRoutes);

// Root endpoint
Expand Down Expand Up @@ -407,6 +413,7 @@ app.use('/api/v1/agi-tutor', agiTutorRoutes);
app.use('/api/v1/analytics', analyticsRoutes);
app.use('/api/v1/dashboard', dashboardRoutes);
app.use('/api/v1/payments', paymentsRoutes);
app.use('/api/v1/disputes', disputesRoutes);
app.use('/api/v1/autonomous-agents', autonomousAgentsRoutes);
app.use('/api/v1/gamification', gamificationRoutes);
app.use('/api/v1/bridge', bridgeRoutes);
Expand All @@ -416,6 +423,9 @@ app.use('/api/v1/vrf', vrfRoutes);
app.use('/api/v1/translate', translationRoutes);
app.use('/api/v1/localization', localizationRoutes);
app.use('/api/v1/did', didRoutes);
// Credential verification endpoint
// @ts-ignore
const verifyRoutes = loadRoute('./routes/verify');
app.use('/api/v1/cross-protocol-bridge', crossProtocolBridgeRoutes);
app.use('/api/v1/audit', auditRoutes);
app.use('/api/v1/verify', verifyRoutes);
Expand Down
47 changes: 47 additions & 0 deletions backend/src/models/Dispute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import mongoose, { Document, Schema } from 'mongoose';

export type DisputeStatus = 'open' | 'under_review' | 'resolved_refund' | 'resolved_release' | 'closed';

export interface DisputeEvidence {
authorId: string;
content: string;
createdAt: Date;
}

export interface IDispute extends Document {
listingId: string;
escrowId?: string;
buyerId: string;
sellerId?: string;
reason: string;
status: DisputeStatus;
evidence: DisputeEvidence[];
mediatorId?: string;
resolution?: 'refund' | 'release';
resolvedAt?: Date;
createdAt: Date;
updatedAt: Date;
}

const EvidenceSchema = new Schema<DisputeEvidence>({
authorId: { type: String, required: true },
content: { type: String, required: true, maxlength: 4096 },
createdAt: { type: Date, default: Date.now },
}, { _id: false });

const DisputeSchema = new Schema<IDispute>({
listingId: { type: String, required: true, index: true },
escrowId: { type: String, index: true },
buyerId: { type: String, required: true, index: true },
sellerId: { type: String, index: true },
reason: { type: String, required: true, maxlength: 2000 },
status: { type: String, enum: ['open', 'under_review', 'resolved_refund', 'resolved_release', 'closed'], default: 'open', index: true },
evidence: { type: [EvidenceSchema], default: [] },
mediatorId: { type: String },
resolution: { type: String, enum: ['refund', 'release'] },
resolvedAt: { type: Date },
}, { timestamps: true, versionKey: false });

DisputeSchema.index({ listingId: 1, buyerId: 1, status: 1 });

export const Dispute = mongoose.model<IDispute>('Dispute', DisputeSchema);
27 changes: 27 additions & 0 deletions backend/src/routes/disputes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Router, Response } from 'express';
import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth';
import { disputeService } from '../services/disputes/disputeService';

const router = Router();
const actor = (req: AuthenticatedRequest) => ({ id: req.user!.id, role: req.user!.role as string });
const meta = (req: AuthenticatedRequest) => ({ ipAddress: req.ip, userAgent: req.get('user-agent') });

router.use(authenticate as any);

router.post('/', async (req: any, res: Response, next) => {
try { res.status(201).json({ success: true, data: await disputeService.open(actor(req), req.body, meta(req)) }); } catch (error) { next(error); }
});
router.get('/', async (req: any, res: Response, next) => {
try { res.json({ success: true, data: await disputeService.list(actor(req), req.query.status as any) }); } catch (error) { next(error); }
});
router.get('/:id', async (req: any, res: Response, next) => {
try { res.json({ success: true, data: await disputeService.get(actor(req), req.params.id) }); } catch (error) { next(error); }
});
router.post('/:id/evidence', async (req: any, res: Response, next) => {
try { res.json({ success: true, data: await disputeService.addEvidence(actor(req), req.params.id, req.body.content, meta(req)) }); } catch (error) { next(error); }
});
router.post('/:id/resolve', requireAdmin as any, async (req: any, res: Response, next) => {
try { res.json({ success: true, data: await disputeService.resolve(actor(req), req.params.id, req.body.resolution, meta(req)) }); } catch (error) { next(error); }
});

export default router;
13 changes: 4 additions & 9 deletions backend/src/routes/verify.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Router, Request, Response } from 'express';
import { SorobanService } from '../services/sorobanService';
import { createDIDRegistryClient } from '../services/did/didRegistryClient';

const router = Router();
const soroban = new SorobanService();
const soroban = createDIDRegistryClient();

/**
* @route GET /api/v1/verify/:hash
Expand All @@ -25,13 +25,8 @@ router.get('/:hash', async (req: Request, res: Response) => {

try {
// Use the newly added strictly read-only helper on the contract
const credential = await soroban.invokeContract(
process.env.AETHERMINT_CONTRACT_ID!,
'get_credential_read_only',
[soroban.nativeToScVal(credentialId, 'u64')]
);

const parsedCredential = soroban.scValToNative(credential);
const credential = await soroban.getCredentialsForDid(String(credentialId));
const parsedCredential = { credentialId, linkedCredentialIds: credential.map(Number), status: 0 };

// status: 0 = Active, 1 = Expired, 2 = Revoked, 3 = Pending
if (parsedCredential.status === 2) {
Expand Down
61 changes: 61 additions & 0 deletions backend/src/services/disputes/disputeService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '../../utils/errors';
import { Dispute, DisputeStatus, IDispute } from '../../models/Dispute';
import { AuditAction } from '../../models/AuditLog';
import { auditService } from '../auditService';

export interface DisputeActor { id: string; role?: string; }

const isAdmin = (actor: DisputeActor) => actor.role === 'admin' || actor.role === 'ADMIN';

export class DisputeService {
async open(actor: DisputeActor, input: { listingId: string; escrowId?: string; sellerId?: string; reason: string }, requestMeta: { ipAddress?: string; userAgent?: string } = {}): Promise<IDispute> {
if (!input.listingId || !input.reason?.trim()) throw new ValidationError('listingId and reason are required');
if (input.reason.length > 2000) throw new ValidationError('reason must not exceed 2000 characters');
const existing = await Dispute.findOne({ listingId: input.listingId, buyerId: actor.id, status: { $in: ['open', 'under_review'] } });
if (existing) throw new ConflictError('An active dispute already exists for this listing');
const dispute = await Dispute.create({ ...input, buyerId: actor.id, reason: input.reason.trim() });
await auditService.create(actor.id, AuditAction.DATA_ACCESS, 'marketplace.dispute', { resourceId: String(dispute._id), details: { event: 'opened', listingId: input.listingId }, ...requestMeta });
return dispute;
}

async get(actor: DisputeActor, id: string): Promise<IDispute> {
const dispute = await Dispute.findById(id);
if (!dispute) throw new NotFoundError('Dispute not found');
if (!isAdmin(actor) && dispute.buyerId !== actor.id && dispute.sellerId !== actor.id && dispute.mediatorId !== actor.id) throw new ForbiddenError('You cannot access this dispute');
return dispute;
}

async addEvidence(actor: DisputeActor, id: string, content: string, requestMeta: { ipAddress?: string; userAgent?: string } = {}): Promise<IDispute> {
if (!content?.trim() || content.length > 4096) throw new ValidationError('Evidence must be between 1 and 4096 characters');
const dispute = await this.get(actor, id);
if (!['open', 'under_review'].includes(dispute.status)) throw new ConflictError('Dispute is no longer accepting evidence');
dispute.evidence.push({ authorId: actor.id, content: content.trim(), createdAt: new Date() });
if (dispute.status === 'open') dispute.status = 'under_review';
await dispute.save();
await auditService.create(actor.id, AuditAction.DATA_ACCESS, 'marketplace.dispute', { resourceId: id, details: { event: 'evidence_added' }, ...requestMeta });
return dispute;
}

async list(actor: DisputeActor, status?: DisputeStatus): Promise<IDispute[]> {
const query: Record<string, unknown> = isAdmin(actor) ? {} : { $or: [{ buyerId: actor.id }, { sellerId: actor.id }, { mediatorId: actor.id }] };
if (status) query.status = status;
return Dispute.find(query).sort({ createdAt: -1 });
}

async resolve(actor: DisputeActor, id: string, resolution: 'refund' | 'release', requestMeta: { ipAddress?: string; userAgent?: string } = {}): Promise<IDispute> {
if (resolution !== 'refund' && resolution !== 'release') throw new ValidationError('resolution must be refund or release');
if (!isAdmin(actor)) throw new ForbiddenError('Only an administrator or mediator can resolve disputes');
const dispute = await Dispute.findById(id);
if (!dispute) throw new NotFoundError('Dispute not found');
if (!['open', 'under_review'].includes(dispute.status)) throw new ConflictError('Dispute is already resolved');
dispute.status = resolution === 'refund' ? 'resolved_refund' : 'resolved_release';
dispute.resolution = resolution;
dispute.mediatorId = actor.id;
dispute.resolvedAt = new Date();
await dispute.save();
await auditService.create(actor.id, AuditAction.PAYMENT_REFUND, 'marketplace.dispute', { resourceId: id, details: { event: 'resolved', resolution }, ...requestMeta });
return dispute;
}
}

export const disputeService = new DisputeService();
5 changes: 2 additions & 3 deletions contracts/src/credential_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,12 +346,12 @@ pub fn get_credential_read_only(env: &Env, credential_id: u64) -> CredentialRegi
.persistent()
.get(&CredentialRegistryKey::Credential(credential_id))
.unwrap_or_else(|| panic!("Credential not found"));

let current_time = env.ledger().timestamp();
if credential.status == CredentialStatus::Active && current_time >= credential.expires_at {
credential.status = CredentialStatus::Expired;
}

credential
}

Expand Down Expand Up @@ -780,4 +780,3 @@ pub fn verify_selective_disclosure_proof(
) -> bool {
verify_zk_selective_proof(env, credential_id, proof, holder, verifier)
}

24 changes: 17 additions & 7 deletions contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ mod access_control_test;
mod pause_test;

pub mod utils;
pub mod zk;

pub mod bridge;
pub mod dna_services;
Expand Down Expand Up @@ -1152,6 +1153,21 @@ impl AetherMintContract {
marketplace::refund_escrow(&env, listing_id)
}

/// Open a buyer dispute against an escrow-backed listing.
pub fn open_dispute(env: Env, buyer: Address, listing_id: u64, reason: String) -> u64 {
marketplace::open_dispute(&env, &buyer, listing_id, reason)
}

/// Add buyer/seller evidence or a message to an open dispute.
pub fn add_dispute_evidence(env: Env, author: Address, dispute_id: u64, content: String) {
marketplace::add_dispute_evidence(&env, &author, dispute_id, content)
}

/// Resolve a dispute: `refund_buyer` true refunds escrow, otherwise releases it.
pub fn resolve_dispute(env: Env, admin: Address, dispute_id: u64, refund_buyer: bool) {
marketplace::resolve_dispute(&env, &admin, dispute_id, refund_buyer)
}

/// Get listing details.
pub fn get_listing(env: Env, listing_id: u64) -> marketplace::ItemListing {
marketplace::get_listing(&env, listing_id)
Expand Down Expand Up @@ -1193,13 +1209,7 @@ impl AetherMintContract {
verifier: Address,
) -> bool {
PauseUtils::require_not_paused(&env);
credential_registry::verify_zk_selective_proof(
&env,
credential_id,
proof,
holder,
verifier,
)
credential_registry::verify_zk_selective_proof(&env, credential_id, proof, holder, verifier)
}

/// Check if a ZK nullifier has already been recorded (spent).
Expand Down
Loading