Skip to content

Trustless Work: milestone approval, fund release, dispute flow, and webhook status sync #209

@grantfox-development

Description

@grantfox-development

Context

This is the follow-up to the Trustless Work initialize-and-fund issue. Once an Escrow is FUNDED against a Project, VolunChain needs:

  1. A way to break the project into milestones (volunteer hour targets, deliverables).
  2. An approval flow that releases the milestone's portion of the escrow to the assigned volunteer.
  3. A dispute path when the volunteer believes work was completed but the organization refuses approval.
  4. A webhook receiver so on-chain state changes (release confirmed, dispute opened, dispute resolved) are reflected back into the local DB without polling.

This depends on the new Escrow model and TrustlessWorkClient introduced in the predecessor issue.

Proposed Solution

1. New Milestone Prisma model

model Milestone {
  id              String           @id @default(uuid())
  escrowId        String
  escrow          Escrow           @relation(fields: [escrowId], references: [id])
  title           String
  description     String?
  amount          Decimal          @db.Decimal(20, 7)
  order           Int
  status          MilestoneStatus  @default(PENDING)
  approvedAt      DateTime?
  releaseTxHash   String?
  disputedAt      DateTime?
  resolvedAt      DateTime?
  createdAt       DateTime         @default(now())
  updatedAt       DateTime         @updatedAt

  @@unique([escrowId, order])
}

enum MilestoneStatus {
  PENDING
  APPROVED
  RELEASED
  DISPUTED
  RESOLVED_FOR_RECEIVER
  RESOLVED_FOR_ISSUER
}

The sum of all Milestone.amount for a given Escrow must equal Escrow.amount — enforce in the use case, not the DB.

2. Extend TrustlessWorkClient

Add typed methods that wrap the corresponding TW endpoints:

  • approveMilestone(engagementId, milestoneIndex, signedApproval) → returns { releaseTxHash }.
  • disputeEscrow(engagementId, signedDispute).
  • resolveDispute(engagementId, decision, signedResolution) where decision ∈ { 'receiver', 'issuer', 'split' }.

All methods stay symmetric with the initialize/fund pattern: backend builds and returns the unsigned XDR; the caller (org / volunteer / platform) signs and submits the signed XDR back.

3. New endpoints

Method Path Auth Purpose
POST /projects/:projectId/milestones org owner Create the full milestone list (one shot, before any approval).
GET /projects/:projectId/milestones any authed List milestones with status.
POST /milestones/:id/approve/prepare org owner Build unsigned approval XDR.
POST /milestones/:id/approve/submit org owner Submit signed XDR; transition to RELEASED.
POST /escrows/:id/dispute/prepare assigned volunteer Build unsigned dispute XDR.
POST /escrows/:id/dispute/submit assigned volunteer Submit signed XDR; status DISPUTED.
POST /escrows/:id/dispute/resolve platform-only role Submit a platform-signed resolution.

The split into prepare / submit keeps signing client-side and avoids the backend ever holding a signing key for org or volunteer wallets. The platform's resolver key may be held server-side and signed in dispute/resolve.

4. Webhook receiver

POST /webhooks/trustless-work

  • Verifies the request via X-Trustless-Work-Signature HMAC against TRUSTLESS_WORK_WEBHOOK_SECRET.
  • Idempotent on TW event ID (store last-seen IDs in Redis with TTL).
  • Updates the corresponding Escrow and/or Milestone rows based on event type:
    • escrow.fundedEscrow.status = FUNDED, fundingTxHash.
    • milestone.releasedMilestone.status = RELEASED, releaseTxHash.
    • escrow.disputedEscrow.status = DISPUTED.
    • dispute.resolvedEscrow.status = RESOLVED, milestone status set per decision.
  • Logs every received event through the project's logger.

5. Authorization rules

  • Org owner: can create milestones, approve.
  • Assigned volunteer: can dispute. Determined via Volunteer / UserVolunteer join from the related Project.
  • Platform-only: dispute resolution. Gate via a new PLATFORM role check in authMiddleware.

Add explicit tests for each negative case (volunteer cannot approve, org cannot dispute, random user cannot do anything).

6. Tests

  • Unit tests for the new TrustlessWorkClient methods (mocked axios).
  • Use-case tests for milestone creation invariants (amounts sum, ordering).
  • Webhook handler tests: valid signature, invalid signature, replayed event ID (must no-op), unknown event type (must 200 + log).
  • Authorization matrix integration test.

Acceptance Criteria

  • Milestone model + MilestoneStatus enum migrated.
  • TrustlessWorkClient extended with approveMilestone, disputeEscrow, resolveDispute and unit-tested.
  • All prepare / submit endpoints implemented; backend never signs on behalf of org or volunteer.
  • Milestone-amount-sum invariant enforced and tested.
  • POST /webhooks/trustless-work validates HMAC, is idempotent, and updates DB state correctly for every supported event type.
  • Authorization matrix test passes (positive + negative cases for org / volunteer / platform / stranger).
  • TRUSTLESS_WORK_WEBHOOK_SECRET documented in .env.example and readme.md.
  • OpenAPI spec updated with all new endpoints and the Milestone schema.
  • All existing tests still pass.

Out of Scope

  • A user-facing dispute review dashboard (frontend concern).
  • Multi-currency / asset conversion.
  • Slashing / reputation effects of disputes.

Suggested Labels

enhancement, feature, stellar, trustless-work

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions