Overview
CrowdPay's multisig escrow model prevents unilateral withdrawals, but it provides no mechanism for dispute resolution when a creator and contributor disagree. A contributor who believes a campaign is fraudulent has no recourse — they cannot reclaim funds, and the platform has no structured process for handling the dispute. This issue implements a complete dispute resolution workflow: a contributor can raise a dispute, the platform freezes the campaign escrow by adding a third signer and raising the threshold to 3, evidence is submitted by both parties, a platform arbitrator reviews and issues a decision, and the escrow is either released to the creator or refunded to contributors.
What needs to be built
backend/src/services/dispute.js — Dispute engine
-
POST /api/campaigns/:id/disputes — contributor-initiated:
- Validates: contributor has made at least one confirmed contribution to this campaign
- Creates a
disputes record with status: open
- Triggers the escrow freeze: builds and submits a
SetOptions transaction that adds the platform arbitrator key as a third signer (weight 1) and raises the medium threshold from 2 to 3
- Sends email notifications to the creator and the platform ops team
- Returns
{ disputeId, status: 'open', frozenAt }
-
POST /api/disputes/:id/evidence — either party (creator or contributor) can submit:
- Accepts
{ text, attachmentUrls: string[] } — text description + links to external evidence
- Creates
dispute_evidence record with submitted_by and role: 'creator' | 'contributor'
-
POST /api/admin/disputes/:id/decide — platform arbitrator only:
- Accepts
{ decision: 'release_to_creator' | 'refund_contributors', reason: string }
release_to_creator: removes the arbitrator signer and lowers the threshold back to 2; creator can withdraw normally
refund_contributors: builds a transaction that sends the campaign balance back to all contributors proportionally; requires creator + platform signatures
- Updates dispute status to
resolved; emails both parties with the decision and reason
Frontend — Campaign page additions
-
"Raise Dispute" button — visible to contributors only after the campaign deadline has passed and no withdrawal has been initiated; requires confirmation modal
-
Dispute status banner on the campaign page — visible to all once a dispute is open
-
Evidence submission form (in the dispute detail page): text area + "Add URL" inputs
-
Admin dispute queue (/admin/disputes): list of open disputes with campaign name, dispute age, evidence count; "Review" button per dispute
Database migrations
-
disputes: id, campaign_id, raised_by, status (open | under_review | resolved), decision, reason, arbitrator_signer_added, created_at, resolved_at
-
dispute_evidence: id, dispute_id, submitted_by, role, text, attachment_urls (jsonb), submitted_at
Acceptance criteria
Overview
CrowdPay's multisig escrow model prevents unilateral withdrawals, but it provides no mechanism for dispute resolution when a creator and contributor disagree. A contributor who believes a campaign is fraudulent has no recourse — they cannot reclaim funds, and the platform has no structured process for handling the dispute. This issue implements a complete dispute resolution workflow: a contributor can raise a dispute, the platform freezes the campaign escrow by adding a third signer and raising the threshold to 3, evidence is submitted by both parties, a platform arbitrator reviews and issues a decision, and the escrow is either released to the creator or refunded to contributors.
What needs to be built
backend/src/services/dispute.js— Dispute enginePOST /api/campaigns/:id/disputes— contributor-initiated:disputesrecord withstatus: openSetOptionstransaction that adds the platform arbitrator key as a third signer (weight 1) and raises the medium threshold from 2 to 3{ disputeId, status: 'open', frozenAt }POST /api/disputes/:id/evidence— either party (creator or contributor) can submit:{ text, attachmentUrls: string[] }— text description + links to external evidencedispute_evidencerecord withsubmitted_byandrole: 'creator' | 'contributor'POST /api/admin/disputes/:id/decide— platform arbitrator only:{ decision: 'release_to_creator' | 'refund_contributors', reason: string }release_to_creator: removes the arbitrator signer and lowers the threshold back to 2; creator can withdraw normallyrefund_contributors: builds a transaction that sends the campaign balance back to all contributors proportionally; requires creator + platform signaturesresolved; emails both parties with the decision and reasonFrontend — Campaign page additions
"Raise Dispute" button — visible to contributors only after the campaign deadline has passed and no withdrawal has been initiated; requires confirmation modal
Dispute status banner on the campaign page — visible to all once a dispute is open
Evidence submission form (in the dispute detail page): text area + "Add URL" inputs
Admin dispute queue (
/admin/disputes): list of open disputes with campaign name, dispute age, evidence count; "Review" button per disputeDatabase migrations
disputes:id,campaign_id,raised_by,status(open|under_review|resolved),decision,reason,arbitrator_signer_added,created_at,resolved_atdispute_evidence:id,dispute_id,submitted_by,role,text,attachment_urls(jsonb),submitted_atAcceptance criteria
403 NOT_A_CONTRIBUTORrefund_contributorsdecision correctly computes each contributor's proportional refund amount — sum of all refund amounts equals the campaign balancerelease_to_creatordecision, the arbitrator signer is removed and the threshold returns to 2 — creator can withdraw without the arbitrator's involvementPOST /api/contributionsreturns409 CAMPAIGN_DISPUTED