From 89f01f811b4f3166a9043c7f9aacb425a0af2382 Mon Sep 17 00:00:00 2001 From: Jaydbrown Date: Wed, 26 Aug 2026 21:44:59 +0100 Subject: [PATCH] fix(escrow): align Soroban invocations with mergefi-contracts + wire PR-opened webhook Addresses #157, #158, #161, #168. #158 - EscrowService.fund() now submits the real escrow::fund signature (issue_id: u64, sponsor, token, amount: i128, deadline: u64) instead of [funder, uuidReference, amount]. issue_id is the linked GitHub issue id for bounties (threaded from BountiesService) or a deterministic FNV-1a-64 hash of the parent UUID for milestone/pool escrows; token comes from stellar.assetContractIds; deadline from the bounty/milestone or the configured default window. New Escrow.onChainId / Escrow.deadline columns (+ migration) persist the key so release/refund reuse it. #161 - EscrowService.release() and splitRelease() converge on the contract's single release(issue_id, recipients: Vec<(Address, u32)>) entrypoint; the non-existent split_release method is gone. A single recipient is the [(addr, 10_000)] degenerate case. SorobanClientService.toScVal encodes the (Address, u32) tuple vector. #157 - EscrowService resolves the deployed contract per escrow instance (maintenance-pool deployment for pool escrows, bounty escrow contract otherwise), persists it on Escrow.contractId, and threads it through every soroban.invoke(..., { contractId }) call - fund/release/splitRelease/ releasePartial/poolWithdraw/refund. stellar.maintenancePoolContractId is restored as live config. #168 - GithubWebhooksService.handlePullRequest handles opened/reopened PRs, moving a linked CLAIMED bounty to IN_REVIEW when the PR is actually opened rather than only synthetically at merge time. Also repairs duplicated imports/declarations in escrow.service.spec.ts left by an earlier main merge (blocked tsc). --- .env.example | 14 ++ README.md | 20 +- src/bounties/bounties.service.spec.ts | 22 ++ src/bounties/bounties.service.ts | 9 +- src/common/entities/escrow.entity.ts | 21 ++ src/config/configuration.ts | 37 +++ ...800000000-AddEscrowOnChainIdAndDeadline.ts | 42 ++++ src/escrow/escrow-response.mapper.spec.ts | 2 + src/escrow/escrow.controller.spec.ts | 2 + src/escrow/escrow.service.spec.ts | 207 ++++++++++++++-- src/escrow/escrow.service.ts | 230 ++++++++++++++---- src/escrow/soroban-client.service.spec.ts | 68 ++++++ src/escrow/soroban-client.service.ts | 74 +++++- src/github/github-webhooks.service.spec.ts | 67 +++++ src/github/github-webhooks.service.ts | 63 +++++ src/milestones/milestones.service.ts | 1 + 16 files changed, 783 insertions(+), 96 deletions(-) create mode 100644 src/database/migrations/1784800000000-AddEscrowOnChainIdAndDeadline.ts diff --git a/.env.example b/.env.example index 7a59773..e4d18e2 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,20 @@ STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 # for the current contract IDs. Leave blank to run escrow calls in "dry # run" mode (persisted locally, no on-chain effect) instead. ESCROW_CONTRACT_ID= +# Optional separate contract for maintenance-pool escrows; falls back to +# ESCROW_CONTRACT_ID when unset. EscrowService targets this for pool +# fund/release/refund calls (#157). +MAINTENANCE_POOL_CONTRACT_ID= + +# Soroban token (SAC) contract addresses for each supported asset. Passed as +# the required `token` argument to escrow::fund (#158). Leave blank when no +# contracts are deployed (calls dry-run regardless). +USDC_TOKEN_CONTRACT_ID= +XLM_TOKEN_CONTRACT_ID= +# Fallback escrow deadline (seconds from fund time) used as escrow::fund's +# `deadline` argument when the funding bounty/milestone has none of its own. +# Default: 7776000 (90 days). +ESCROW_DEADLINE_SECONDS=7776000 # Treasury / platform account that pays transaction fees and can act as a # fallback signer for automated (non-custodial) release/refund operations. diff --git a/README.md b/README.md index ebfa2cb..0111fd4 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Design principles: | `users` | User + linked `GithubAccount` records, role management, Stellar address linking. | | `github` | Repository/issue sync via Octokit (`github-sync.service.ts`) and inbound webhook handling with HMAC-SHA256 signature verification (`github-webhooks.service.ts`, `webhook-signature.util.ts`). On a merged PR, resolves the linked issue → bounty and triggers escrow release. | | `bounties` | Paid-issue lifecycle: create, fund, claim, review, merge, pay, refund, expire. State machine in `bounty-state-machine.ts`. | -| `escrow` | Orchestrates fund/release/split-release/refund against the escrow contract via `SorobanClientService`, and keeps `Escrow`/`Payment` rows in sync. | +| `escrow` | Orchestrates fund/release/refund against the escrow contract via `SorobanClientService`, and keeps `Escrow`/`Payment` rows in sync. Single-recipient and team-split payouts both go through the contract's one `release(issue_id, recipients)` entrypoint. | | `teams` | Team bounties: create a team with percentage splits (e.g. frontend 40 / backend 40 / testing 20), assign it to a bounty, validated to sum to 100%. | | `milestones` | Fund an entire milestone's budget up front; distribute it incrementally as issues resolve (`resolveIssue`), splitting the remaining budget across still-open issues. | | `maintenance-pool` | Recurring sponsor deposits into a shared pool; maintainers assign rewards out of the running balance for maintenance-type work. | @@ -135,13 +135,16 @@ See [`.env.example`](./.env.example) for the full annotated list. Highlights: | `GITHUB_WEBHOOK_SECRET` | HMAC-SHA256 secret configured on the GitHub webhook. | | `STELLAR_NETWORK`, `SOROBAN_RPC_URL`, `STELLAR_NETWORK_PASSPHRASE` | Stellar network config. | | `ESCROW_CONTRACT_ID` | Deployed escrow contract ID from `mergefi-contracts`. **Not set in this environment** — see below. | +| `MAINTENANCE_POOL_CONTRACT_ID` | Optional separate contract for maintenance-pool escrows; falls back to `ESCROW_CONTRACT_ID`. | +| `USDC_TOKEN_CONTRACT_ID`, `XLM_TOKEN_CONTRACT_ID` | Soroban token (SAC) contract addresses, passed as `escrow::fund`'s required `token` argument. | +| `ESCROW_DEADLINE_SECONDS` | Fallback `escrow::fund` deadline (seconds from fund time) when the bounty/milestone has none. Default 90 days. | | `TREASURY_SECRET` | Platform signer used to submit release/refund transactions. | ## Escrow / Soroban integration `src/escrow/soroban-client.service.ts` wraps `@stellar/stellar-sdk`'s `rpc.Server` to build, simulate, sign, and submit Soroban contract -invocations (`fund` / `release` / `split_release` / `refund`) against the +invocations (`fund` / `release` / `refund`) against the escrow contract. `src/escrow/escrow.service.ts` is the orchestration layer: it calls the client, then persists `Escrow`/`Payment` rows and drives the `Bounty`/`Milestone`/`MaintenancePool` state alongside it. @@ -155,12 +158,13 @@ rest of the system (state transitions, DB writes, split-percentage math, webhook-triggered releases) can still be exercised end-to-end in tests and local dev. Once real contracts are deployed: -1. Set `ESCROW_CONTRACT_ID`. +1. Set `ESCROW_CONTRACT_ID` (and `MAINTENANCE_POOL_CONTRACT_ID` if the pool + uses a separate deployment), plus `USDC_TOKEN_CONTRACT_ID` / + `XLM_TOKEN_CONTRACT_ID` for the token argument. 2. Set `TREASURY_SECRET` to a funded Stellar account. -3. Confirm the contract's `fund`/`release`/`split_release`/`refund` function - signatures match the ones documented at the top of - `soroban-client.service.ts` (adjust argument encoding there if not — - TODOs are marked inline). +3. Confirm the contract's `fund`/`release`/`refund` function signatures match + the ones documented at the top of `soroban-client.service.ts` (adjusted in + this change to track `mergefi-contracts`' `contracts/escrow/src/lib.rs`). No private keys for end users are ever stored — only the platform treasury signer, and only as an env var for this MVP (see Roadmap: move to KMS/multi-sig). @@ -296,7 +300,7 @@ npm run test:e2e Unit tests cover critical domains including: - `src/bounties/bounty-state-machine.spec.ts` — the bounty lifecycle state machine. - `src/teams/team-split.util.spec.ts` — team payout split percentage math. -- `src/escrow/escrow.service.spec.ts` — escrow fund/release/split-release/refund orchestration (Soroban client mocked). +- `src/escrow/escrow.service.spec.ts` — escrow fund/release/refund orchestration (Soroban client mocked). - `src/github/webhook-signature.util.spec.ts` — GitHub webhook HMAC-SHA256 signature verification. - `src/github/github-webhooks.service.spec.ts` — webhook-to-escrow release logic. - `src/bounties/bounties.service.spec.ts` — bounty core management. diff --git a/src/bounties/bounties.service.spec.ts b/src/bounties/bounties.service.spec.ts index ba42bc8..40dac81 100644 --- a/src/bounties/bounties.service.spec.ts +++ b/src/bounties/bounties.service.spec.ts @@ -90,6 +90,28 @@ describe('BountiesService', () => { expect(bounty.status).toBe(BountyStatus.FUNDED); }); + it('threads the linked GitHub issue id and deadline into escrow.fund (#158)', async () => { + const deadline = new Date('2026-12-01T00:00:00.000Z'); + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.OPEN, + amount: '100', + asset: AssetType.USDC, + sponsorId: 'sponsor-1', + deadline, + issue: { githubIssueId: '2891234567' }, + }); + + await service.fund('b1', 'GFUNDER'); + + expect(escrowService.fund).toHaveBeenCalledWith( + expect.objectContaining({ + onChainIssueId: '2891234567', + deadline, + }), + ); + }); + it('rejects funding a bounty that is already FUNDED', async () => { bountyRepo.findOne.mockResolvedValue({ id: 'b1', diff --git a/src/bounties/bounties.service.ts b/src/bounties/bounties.service.ts index f31a605..cc35975 100644 --- a/src/bounties/bounties.service.ts +++ b/src/bounties/bounties.service.ts @@ -37,7 +37,11 @@ export class BountiesService { /** Sponsor funds the bounty: locks the amount in the escrow contract and moves OPEN -> FUNDED. */ async fund(id: string, funderAddress: string): Promise { - const bounty = await this.findOne(id); + const bounty = await this.bountyRepo.findOne({ + where: { id }, + relations: { issue: true }, + }); + if (!bounty) throw new NotFoundException(`Bounty ${id} not found`); assertTransition(bounty.status, BountyStatus.FUNDED); const escrow = await this.escrowService.fund({ @@ -46,6 +50,9 @@ export class BountiesService { funderAddress, bountyId: bounty.id, sponsorId: bounty.sponsorId, + // The on-chain escrow contract is keyed by the GitHub issue id (#158). + onChainIssueId: bounty.issue?.githubIssueId ?? null, + deadline: bounty.deadline, }); bounty.escrow = escrow; diff --git a/src/common/entities/escrow.entity.ts b/src/common/entities/escrow.entity.ts index ca8aa68..48dd224 100644 --- a/src/common/entities/escrow.entity.ts +++ b/src/common/entities/escrow.entity.ts @@ -98,6 +98,27 @@ export class Escrow { @Column({ type: 'varchar', nullable: true }) contractId: string | null; + /** + * The `u64` key this escrow is stored under in the on-chain escrow + * contract — `escrow::fund`'s `issue_id` argument (#158). For bounty + * escrows this is the linked GitHub issue's numeric id; for milestone / + * maintenance-pool escrows it is derived from the parent UUID until those + * move to their own sibling contracts. Stored as a decimal string + * because a u64 exceeds JS's safe integer range. Persisted at fund time + * so release/refund reference exactly the key fund created. + */ + @Column({ type: 'varchar', nullable: true }) + onChainId: string | null; + + /** + * Unix-timestamp deadline handed to `escrow::fund` (#158); once it passes + * the contract opens its permissionless refund path. Mirrored here for + * the audit trail and so refunds can be reasoned about without an RPC + * round-trip. + */ + @Column({ type: 'timestamptz', nullable: true }) + deadline: Date | null; + @Column({ type: 'decimal', precision: 20, scale: 7 }) amount: string; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index cb523f0..68ff245 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,3 +1,5 @@ +import { AssetType } from '../common/enums'; + export interface AppConfig { env: string; port: number; @@ -25,7 +27,30 @@ export interface AppConfig { sorobanRpcUrl: string; networkPassphrase: string; escrowContractId: string; + /** + * Optional separate deployment for maintenance-pool escrows. Falls back + * to `escrowContractId` when unset. Threaded through to + * `SorobanClientService.invoke(..., { contractId })` by `EscrowService` + * so maintenance-pool fund/release/refund calls target this contract + * instead of the single bounty escrow contract (#157). + */ + maintenancePoolContractId: string; treasurySecret: string; + /** + * Soroban token (SAC) contract addresses per supported asset. The real + * `escrow::fund(issue_id, sponsor, token, amount, deadline)` takes the + * token contract as a required argument (#158); this is where that + * address is resolved from `Escrow.asset`. Left blank in environments + * with no deployed contracts (calls dry-run regardless). + */ + assetContractIds: Record; + /** + * Fallback escrow deadline, in seconds from fund time, used as the real + * `escrow::fund`'s `deadline` argument when the funding bounty/milestone + * carries no explicit deadline of its own (#158). The contract's + * refund-after-deadline mechanism depends on this being set. + */ + escrowDeadlineSeconds: number; }; } @@ -63,6 +88,18 @@ export default (): AppConfig => ({ process.env.STELLAR_NETWORK_PASSPHRASE ?? 'Test SDF Network ; September 2015', escrowContractId: process.env.ESCROW_CONTRACT_ID ?? '', + maintenancePoolContractId: + process.env.MAINTENANCE_POOL_CONTRACT_ID ?? + process.env.ESCROW_CONTRACT_ID ?? + '', treasurySecret: process.env.TREASURY_SECRET ?? '', + assetContractIds: { + USDC: process.env.USDC_TOKEN_CONTRACT_ID ?? '', + XLM: process.env.XLM_TOKEN_CONTRACT_ID ?? '', + }, + escrowDeadlineSeconds: parseInt( + process.env.ESCROW_DEADLINE_SECONDS ?? '7776000', + 10, + ), }, }); diff --git a/src/database/migrations/1784800000000-AddEscrowOnChainIdAndDeadline.ts b/src/database/migrations/1784800000000-AddEscrowOnChainIdAndDeadline.ts new file mode 100644 index 0000000..6c14bf7 --- /dev/null +++ b/src/database/migrations/1784800000000-AddEscrowOnChainIdAndDeadline.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds `escrows.onChainId` and `escrows.deadline` (#158). + * + * `onChainId` records the `u64` key an escrow is stored under in the + * on-chain escrow contract (`escrow::fund`'s `issue_id`), captured at fund + * time so a later `release`/`refund` targets exactly the same key. Held as + * `varchar` because a u64 overruns JS's safe-integer range and Postgres + * `bigint` maps to a JS `string` in TypeORM anyway. + * + * `deadline` mirrors the unix-timestamp deadline passed to `escrow::fund`, + * after which the contract's permissionless refund path opens. + * + * Both are nullable with no backfill: rows created before this change ran + * in Soroban dry-run mode (no real on-chain state to reconcile against), + * and `EscrowService` falls back to the parent id / configured default + * when either is absent. + */ +export class AddEscrowOnChainIdAndDeadline1784800000000 + implements MigrationInterface +{ + name = 'AddEscrowOnChainIdAndDeadline1784800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "escrows" ADD COLUMN IF NOT EXISTS "onChainId" varchar`, + ); + await queryRunner.query( + `ALTER TABLE "escrows" ADD COLUMN IF NOT EXISTS "deadline" timestamptz`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "escrows" DROP COLUMN IF EXISTS "deadline"`, + ); + await queryRunner.query( + `ALTER TABLE "escrows" DROP COLUMN IF EXISTS "onChainId"`, + ); + } +} diff --git a/src/escrow/escrow-response.mapper.spec.ts b/src/escrow/escrow-response.mapper.spec.ts index 6827ca9..8228d66 100644 --- a/src/escrow/escrow-response.mapper.spec.ts +++ b/src/escrow/escrow-response.mapper.spec.ts @@ -13,6 +13,8 @@ function makeEscrow(overrides: Partial = {}): Escrow { maintenancePoolId: null, sponsorId: 'sponsor_1', contractId: null, + onChainId: null, + deadline: null, amount: '100.0000000', asset: AssetType.USDC, status: EscrowStatus.FAILED, diff --git a/src/escrow/escrow.controller.spec.ts b/src/escrow/escrow.controller.spec.ts index 7673cd1..4e73927 100644 --- a/src/escrow/escrow.controller.spec.ts +++ b/src/escrow/escrow.controller.spec.ts @@ -19,6 +19,8 @@ function makeEscrowWithLeakyMetadata(): Escrow { maintenancePoolId: null, sponsorId: 'sponsor_1', contractId: null, + onChainId: null, + deadline: null, amount: '100.0000000', asset: AssetType.USDC, status: EscrowStatus.FAILED, diff --git a/src/escrow/escrow.service.spec.ts b/src/escrow/escrow.service.spec.ts index 97b3e92..2b6c378 100644 --- a/src/escrow/escrow.service.spec.ts +++ b/src/escrow/escrow.service.spec.ts @@ -4,21 +4,24 @@ import { BadRequestException } from '@nestjs/common'; import { EscrowService } from './escrow.service'; import { SorobanClientService } from './soroban-client.service'; import { Escrow, Payment, User } from '../common/entities'; -import { AssetType, EscrowStatus } from '../common/enums'; -import { Escrow, Payment } from '../common/entities'; import { AssetType, EscrowStatus, PaymentStatus } from '../common/enums'; describe('EscrowService', () => { let service: EscrowService; let escrowRepo: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock }; - let paymentRepo: { create: jest.Mock; save: jest.Mock }; let userRepo: { find: jest.Mock }; let paymentRepo: { create: jest.Mock; save: jest.Mock; find: jest.Mock; }; - let soroban: { invoke: jest.Mock }; + let soroban: { + invoke: jest.Mock; + escrowContractId: string; + maintenancePoolContractId: string; + tokenContractId: jest.Mock; + escrowDeadlineSeconds: number; + }; beforeEach(async () => { escrowRepo = { @@ -46,6 +49,12 @@ describe('EscrowService', () => { returnValue: null, status: 'SUCCESS', }), + // Dry-run defaults: no contract configured, so EscrowService persists a + // null contractId and passes an empty options object to invoke(). + escrowContractId: '', + maintenancePoolContractId: '', + tokenContractId: jest.fn().mockReturnValue(''), + escrowDeadlineSeconds: 7776000, }; const module: TestingModule = await Test.createTestingModule({ @@ -68,16 +77,62 @@ describe('EscrowService', () => { asset: AssetType.USDC, funderAddress: 'GABC...FUNDER', bountyId: 'bounty-1', + onChainIssueId: '4242', }); - expect(soroban.invoke).toHaveBeenCalledWith( - 'fund', - expect.arrayContaining(['GABC...FUNDER', 'bounty-1']), - ); + // escrow::fund(issue_id, sponsor, token, amount, deadline) (#158) + const [method, args] = soroban.invoke.mock.calls[0] as [ + string, + unknown[], + ]; + expect(method).toBe('fund'); + expect(args[0]).toBe(4242n); + expect(args[1]).toBe('GABC...FUNDER'); + expect(args[3]).toBe(1_000_000_000n); + expect(typeof args[4]).toBe('bigint'); expect(escrow.status).toBe(EscrowStatus.LOCKED); expect(escrow.fundTxHash).toBe('tx-hash-123'); }); + it('sends the token address and a deadline, and persists them on the row (#158)', async () => { + soroban.tokenContractId.mockReturnValue('CUSDCTOKEN'); + + const deadline = new Date('2026-12-31T00:00:00.000Z'); + const escrow = await service.fund({ + amount: '10.0000000', + asset: AssetType.USDC, + funderAddress: 'GFUNDER', + bountyId: 'bounty-1', + onChainIssueId: '77', + deadline, + }); + + const [, args] = soroban.invoke.mock.calls[0] as [string, unknown[]]; + expect(args[2]).toBe('CUSDCTOKEN'); + expect(args[4]).toBe(BigInt(Math.floor(deadline.getTime() / 1000))); + expect(escrow.onChainId).toBe('77'); + expect(escrow.deadline).toBe(deadline); + }); + + it('derives a stable numeric on-chain id when the caller supplies none', async () => { + const first = await service.fund({ + amount: '1.0000000', + asset: AssetType.USDC, + funderAddress: 'GFUNDER', + milestoneId: 'milestone-1', + }); + const second = await service.fund({ + amount: '1.0000000', + asset: AssetType.USDC, + funderAddress: 'GFUNDER', + milestoneId: 'milestone-1', + }); + + expect(first.onChainId).toMatch(/^\d+$/); + expect(first.onChainId).toBe(second.onChainId); + expect(() => BigInt(first.onChainId as string)).not.toThrow(); + }); + it('persists the denormalized sponsorId on the created escrow row', async () => { const escrow = await service.fund({ amount: '100.0000000', @@ -240,6 +295,7 @@ describe('EscrowService', () => { amount: '100.0000000', asset: AssetType.USDC, milestoneId: 'milestone-1', + onChainId: '9100', }); it('releases part of a LOCKED escrow and records a Payment while it stays LOCKED below the total', async () => { @@ -254,11 +310,11 @@ describe('EscrowService', () => { 'user-1', ); - expect(soroban.invoke).toHaveBeenCalledWith('release', [ - 'milestone-1', - 'GRECIPIENT', - 300_000_000n, - ]); + expect(soroban.invoke).toHaveBeenCalledWith( + 'release', + [9100n, 'GRECIPIENT', 300_000_000n], + {}, + ); expect(paymentRepo.save).toHaveBeenCalledWith( expect.objectContaining({ escrowId: 'escrow-partial', @@ -304,11 +360,12 @@ describe('EscrowService', () => { await service.releasePartial('escrow-partial', '60.0000000', 'GB'); - expect(soroban.invoke).toHaveBeenNthCalledWith(2, 'release', [ - 'milestone-1', - 'GB', - 600_000_000n, - ]); + expect(soroban.invoke).toHaveBeenNthCalledWith( + 2, + 'release', + [9100n, 'GB', 600_000_000n], + {}, + ); expect(escrowRepo.save).toHaveBeenCalledWith( expect.objectContaining({ status: EscrowStatus.RELEASED, @@ -353,17 +410,73 @@ describe('EscrowService', () => { amount: '25.0000000', asset: AssetType.USDC, bountyId: 'bounty-7', + onChainId: '7007', }); const escrow = await service.refund('escrow-refund'); - expect(soroban.invoke).toHaveBeenCalledWith('refund', ['bounty-7']); + expect(soroban.invoke).toHaveBeenCalledWith('refund', [7007n], {}); expect(escrow.status).toBe(EscrowStatus.REFUNDED); expect(escrow.refundTxHash).toBe('tx-hash-123'); expect(escrow.refundedAt).toBeInstanceOf(Date); }); }); + describe('contract targeting (#157)', () => { + it('persists the escrow contract id on a bounty escrow and targets it on fund', async () => { + soroban.escrowContractId = 'CESCROW'; + soroban.maintenancePoolContractId = 'CESCROW'; + + const escrow = await service.fund({ + amount: '100.0000000', + asset: AssetType.USDC, + funderAddress: 'GFUNDER', + bountyId: 'bounty-1', + }); + + expect(escrow.contractId).toBe('CESCROW'); + expect(soroban.invoke).toHaveBeenCalledWith('fund', expect.any(Array), { + contractId: 'CESCROW', + }); + }); + + it('targets the maintenance-pool contract for a pool escrow', async () => { + soroban.escrowContractId = 'CESCROW'; + soroban.maintenancePoolContractId = 'CPOOL'; + + const escrow = await service.fund({ + amount: '100.0000000', + asset: AssetType.USDC, + funderAddress: 'GFUNDER', + maintenancePoolId: 'pool-1', + }); + + expect(escrow.contractId).toBe('CPOOL'); + expect(soroban.invoke).toHaveBeenCalledWith('fund', expect.any(Array), { + contractId: 'CPOOL', + }); + }); + + it('reuses the escrow row contract id on a later release', async () => { + escrowRepo.findOne.mockResolvedValue({ + id: 'escrow-pinned', + status: EscrowStatus.LOCKED, + amount: '10', + asset: AssetType.USDC, + bountyId: 'bounty-9', + contractId: 'CPINNED', + }); + + await service.release('escrow-pinned', 'GRECIPIENT', 'user-1'); + + expect(soroban.invoke).toHaveBeenCalledWith( + 'release', + expect.any(Array), + { contractId: 'CPINNED' }, + ); + }); + }); + describe('assertValidSplits / splitRelease', () => { it('throws when percentages do not sum to 100', () => { expect(() => @@ -427,13 +540,14 @@ describe('EscrowService', () => { expect(totalStroops).toBe(1_000_000_000n); }); - it('sends basis points on-chain that sum to exactly 10,000', async () => { + it('calls the contract release entrypoint with (issue_id, Vec<(Address, u32)>) whose bps sum to 10,000 (#161)', async () => { escrowRepo.findOne.mockResolvedValue({ id: 'escrow-bps', status: EscrowStatus.LOCKED, amount: '100.0000000', asset: AssetType.USDC, bountyId: 'bounty-bps', + onChainId: '8801', }); await service.splitRelease('escrow-bps', [ @@ -442,10 +556,55 @@ describe('EscrowService', () => { { recipientAddress: 'GC', percentage: 33.334 }, ]); - const invokeCall = soroban.invoke.mock.calls[0] as unknown[]; - const splitArgs = invokeCall[1] as unknown[]; - const bps = splitArgs[2] as number[]; - expect(bps.reduce((a, b) => a + b, 0)).toBe(10_000); + const [method, args] = soroban.invoke.mock.calls[0] as [ + string, + unknown[], + ]; + expect(method).toBe('release'); + expect(args[0]).toBe(8801n); + const recipients = args[1] as Array<[string, number]>; + expect(recipients.map((r) => r[0])).toEqual(['GA', 'GB', 'GC']); + expect(recipients.reduce((sum, r) => sum + r[1], 0)).toBe(10_000); + }); + + it('never invokes a split_release method (#161)', async () => { + escrowRepo.findOne.mockResolvedValue({ + id: 'escrow-4', + status: EscrowStatus.LOCKED, + amount: '100', + asset: AssetType.USDC, + bountyId: 'bounty-4', + onChainId: '4004', + }); + + await service.splitRelease('escrow-4', [ + { recipientAddress: 'GA', percentage: 50 }, + { recipientAddress: 'GB', percentage: 50 }, + ]); + + const methods = soroban.invoke.mock.calls.map((c) => c[0]); + expect(methods).not.toContain('split_release'); + }); + }); + + describe('release convergence (#161)', () => { + it('releases a single recipient as the degenerate [(addr, 10000)] split', async () => { + escrowRepo.findOne.mockResolvedValue({ + id: 'escrow-solo', + status: EscrowStatus.LOCKED, + amount: '50', + asset: AssetType.USDC, + bountyId: 'bounty-3', + onChainId: '3003', + }); + + await service.release('escrow-solo', 'GRECIPIENT', 'user-1'); + + expect(soroban.invoke).toHaveBeenCalledWith( + 'release', + [3003n, [['GRECIPIENT', 10_000]]], + {}, + ); }); }); }); diff --git a/src/escrow/escrow.service.ts b/src/escrow/escrow.service.ts index 6e8706c..24edc88 100644 --- a/src/escrow/escrow.service.ts +++ b/src/escrow/escrow.service.ts @@ -14,8 +14,15 @@ import { isValidMoneyAmount, stroopsToAmount, } from '../common/validators/money.validator'; -import { SorobanClientService } from './soroban-client.service'; -import { apportionBasisPoints, splitStroops } from './split-math.util'; +import { + ContractInvocationResult, + SorobanClientService, +} from './soroban-client.service'; +import { + apportionBasisPoints, + splitStroops, + TOTAL_BASIS_POINTS, +} from './split-math.util'; export interface FundEscrowInput { amount: string; @@ -32,6 +39,21 @@ export interface FundEscrowInput { * which aren't sponsor-attributed. */ sponsorId?: string | null; + /** + * The `u64` key to store this escrow under on-chain — `escrow::fund`'s + * `issue_id` (#158). For a bounty this is the linked GitHub issue's + * numeric id, supplied by the caller. Omitted for milestone / + * maintenance-pool escrows, where a stable id is derived from the parent + * UUID until those move to their own sibling contracts. + */ + onChainIssueId?: string | number | null; + /** + * Deadline passed to `escrow::fund`, after which the contract's + * permissionless refund path opens (#158). Defaults to + * `now + stellar.escrowDeadlineSeconds` when the funding bounty/milestone + * has none of its own. + */ + deadline?: Date | null; } export interface SplitRecipient { @@ -66,19 +88,26 @@ export class EscrowService { maintenancePoolId: input.maintenancePoolId ?? null, sponsorId: input.sponsorId ?? null, }); + escrow.contractId = this.resolveContractId(input) || null; + escrow.onChainId = this.resolveOnChainId(input); + const deadline = this.resolveDeadline(input); + escrow.deadline = deadline; await this.escrowRepo.save(escrow); try { - const referenceId = - input.bountyId ?? - input.milestoneId ?? - input.maintenancePoolId ?? - escrow.id; - const result = await this.soroban.invoke('fund', [ - input.funderAddress, - referenceId, - this.toStroops(input.amount), - ]); + // escrow::fund(issue_id: u64, sponsor: Address, token: Address, + // amount: i128, deadline: u64) -> Result<(), Error> (#158) + const result = await this.soroban.invoke( + 'fund', + [ + BigInt(escrow.onChainId), + input.funderAddress, + this.resolveTokenAddress(input.asset), + this.toStroops(input.amount), + BigInt(Math.floor(deadline.getTime() / 1000)), + ], + this.contractOpts(escrow), + ); escrow.status = EscrowStatus.LOCKED; escrow.fundTxHash = result.txHash; @@ -103,15 +132,9 @@ export class EscrowService { this.assertLocked(escrow); await this.assertRecipientsMatchUsers([{ recipientAddress, recipientId }]); - const result = await this.invokeOnLockedEscrow(escrow, 'release', () => - this.soroban.invoke('release', [ - escrow.bountyId ?? - escrow.milestoneId ?? - escrow.maintenancePoolId ?? - escrow.id, - recipientAddress, - ]), - ); + const result = await this.invokeRelease(escrow, 'release', [ + [recipientAddress, TOTAL_BASIS_POINTS], + ]); escrow.status = EscrowStatus.RELEASED; escrow.releaseTxHash = result.txHash; @@ -157,12 +180,10 @@ export class EscrowService { // exactly 10,000 (100.00%), used both on-chain and to derive the ledger. const bps = apportionBasisPoints(recipients.map((r) => r.percentage)); - const result = await this.invokeOnLockedEscrow(escrow, 'splitRelease', () => - this.soroban.invoke('split_release', [ - escrow.bountyId ?? escrow.milestoneId ?? escrow.id, - recipients.map((r) => r.recipientAddress), - bps, - ]), + const result = await this.invokeRelease( + escrow, + 'splitRelease', + recipients.map((r, i) => [r.recipientAddress, bps[i]] as [string, number]), ); const shares = splitStroops(totalStroops, bps); @@ -229,11 +250,15 @@ export class EscrowService { escrow, 'releasePartial', () => - this.soroban.invoke('release', [ - escrow.milestoneId ?? escrow.bountyId ?? escrow.id, - recipientAddress, - this.toStroops(amount), - ]), + this.soroban.invoke( + 'release', + [ + this.onChainKeyFor(escrow), + recipientAddress, + this.toStroops(amount), + ], + this.contractOpts(escrow), + ), ); const payment = await this.paymentRepo.save( @@ -289,11 +314,15 @@ export class EscrowService { await this.assertRecipientsMatchUsers([{ recipientAddress, recipientId }]); const result = await this.invokeOnLockedEscrow(escrow, 'poolWithdraw', () => - this.soroban.invoke('withdraw', [ - escrow.maintenancePoolId ?? escrow.id, - recipientAddress, - this.toStroops(amount), - ]), + this.soroban.invoke( + 'withdraw', + [ + this.onChainKeyFor(escrow), + recipientAddress, + this.toStroops(amount), + ], + this.contractOpts(escrow), + ), ); return this.paymentRepo.save( @@ -315,12 +344,11 @@ export class EscrowService { this.assertLocked(escrow); const result = await this.invokeOnLockedEscrow(escrow, 'refund', () => - this.soroban.invoke('refund', [ - escrow.bountyId ?? - escrow.milestoneId ?? - escrow.maintenancePoolId ?? - escrow.id, - ]), + this.soroban.invoke( + 'refund', + [this.onChainKeyFor(escrow)], + this.contractOpts(escrow), + ), ); escrow.status = EscrowStatus.REFUNDED; @@ -407,6 +435,107 @@ export class EscrowService { } } + /** + * The escrow contract's single payout entrypoint (#161): + * `release(issue_id: u64, recipients: Vec<(Address, u32)>)`. A single + * recipient is just the degenerate `[(addr, 10_000)]` case of the same + * call a team split makes — there is no separate `split_release` method on + * the deployed contract. Basis points must sum to exactly 10,000. + */ + private invokeRelease( + escrow: Escrow, + operation: string, + recipients: Array<[string, number]>, + ): Promise { + return this.invokeOnLockedEscrow(escrow, operation, () => + this.soroban.invoke( + 'release', + [this.onChainKeyFor(escrow), recipients], + this.contractOpts(escrow), + ), + ); + } + + /** + * The deployed contract a new escrow instance should be held by (#157): + * the maintenance-pool deployment for pool escrows, the bounty escrow + * contract otherwise. Resolved once at fund time and persisted on the row. + */ + private resolveContractId(input: { + maintenancePoolId?: string | null; + }): string { + return input.maintenancePoolId + ? this.soroban.maintenancePoolContractId + : this.soroban.escrowContractId; + } + + /** + * `invoke()` options pinning a call to the contract this escrow was funded + * in. Empty for rows created before `contractId` was persisted and in + * dry-run environments — the client then falls back to `ESCROW_CONTRACT_ID`. + */ + private contractOpts(escrow: Escrow): { contractId?: string } { + return escrow.contractId ? { contractId: escrow.contractId } : {}; + } + + /** + * The `u64` key `escrow::fund` should store this escrow under (#158). A + * bounty carries the linked GitHub issue's numeric id (passed as + * `onChainIssueId`); milestone / maintenance-pool escrows, which belong on + * their own sibling contracts (#157), get a stable u64 derived from the + * parent UUID until then. + */ + private resolveOnChainId(input: FundEscrowInput): string { + const explicit = input.onChainIssueId; + if (explicit != null && `${explicit}`.trim() !== '') { + const value = `${explicit}`.trim(); + return /^\d+$/.test(value) ? value : this.deriveOnChainId(value); + } + return this.deriveOnChainId( + input.bountyId ?? input.milestoneId ?? input.maintenancePoolId ?? '', + ); + } + + /** Deterministic FNV-1a-64 hash of a non-numeric reference into a `u64` string. */ + private deriveOnChainId(seed: string): string { + let hash = 14695981039346656037n; + for (let i = 0; i < seed.length; i++) { + hash ^= BigInt(seed.charCodeAt(i)); + hash = BigInt.asUintN(64, hash * 1099511628211n); + } + return hash.toString(); + } + + /** + * The on-chain key for an already-persisted escrow: the `onChainId` + * captured at fund time, or the derived fallback for rows funded before + * that column existed. Always numeric so it round-trips through `BigInt`. + */ + private onChainKeyFor(escrow: Escrow): bigint { + if (escrow.onChainId != null && escrow.onChainId !== '') { + return BigInt(escrow.onChainId); + } + return BigInt( + this.deriveOnChainId( + escrow.bountyId ?? + escrow.milestoneId ?? + escrow.maintenancePoolId ?? + escrow.id, + ), + ); + } + + /** Resolves the funding deadline: the parent's own, or the configured default window. */ + private resolveDeadline(input: FundEscrowInput): Date { + if (input.deadline) return input.deadline; + return new Date(Date.now() + this.soroban.escrowDeadlineSeconds * 1000); + } + + /** Soroban token (SAC) contract address backing an escrow asset (#158). */ + private resolveTokenAddress(asset: AssetType): string { + return this.soroban.tokenContractId(asset); + } + /** Validates that split percentages sum to 100.00, within floating point tolerance. */ assertValidSplits(recipients: SplitRecipient[]): void { if (recipients.length === 0) { @@ -426,13 +555,12 @@ export class EscrowService { } /** - * The illustrative split_release contract returns a single i128 (the total - * released, in stroops) rather than a per-recipient breakdown, so the - * recorded Payment rows cannot yet be derived from `result.returnValue` - * (see the interface TODO in soroban-client.service.ts). Until the deployed - * contract returns per-recipient amounts, reconcile the scalar total against - * the locally computed total and surface any divergence as a warning for the - * reconciliation job, rather than silently discarding it (#43). + * The deployed `release` entrypoint returns `Result<(), Error>` — no + * payout figure — so `result.returnValue` is normally null and the + * recorded Payment rows come from the locally computed shares. If a future + * contract revision returns a scalar stroop total, reconcile it against + * the local total and surface any divergence as a warning for the + * reconciliation job rather than discarding it (#43). */ private reconcileSplitResult( escrowId: string, @@ -443,7 +571,7 @@ export class EscrowService { if (returned === null) return; if (returned !== totalStroops) { this.logger.warn( - `split_release returnValue (${returned} stroops) diverges from the ` + + `release returnValue (${returned} stroops) diverges from the ` + `recorded total (${totalStroops} stroops) for escrow ${escrowId}`, ); } diff --git a/src/escrow/soroban-client.service.spec.ts b/src/escrow/soroban-client.service.spec.ts index 31b9285..9a94714 100644 --- a/src/escrow/soroban-client.service.spec.ts +++ b/src/escrow/soroban-client.service.spec.ts @@ -1,5 +1,6 @@ import { ConfigService } from '@nestjs/config'; import { nativeToScVal, rpc } from '@stellar/stellar-sdk'; +import { AssetType } from '../common/enums'; import { AppConfig } from '../config/configuration'; import { SorobanClientService } from './soroban-client.service'; @@ -15,7 +16,10 @@ const baseStellarConfig: StellarConfig = { sorobanRpcUrl: 'http://localhost:8000/rpc', networkPassphrase: 'Test SDF Network ; September 2015', escrowContractId: '', + maintenancePoolContractId: '', treasurySecret: '', + assetContractIds: { USDC: '', XLM: '' }, + escrowDeadlineSeconds: 7776000, }; function makeService( @@ -71,6 +75,39 @@ describe('SorobanClientService', () => { }); }); + describe('contract resolvers (#157)', () => { + it('exposes the configured escrow contract id', () => { + expect(makeService({ escrowContractId: 'CESCROW' }).escrowContractId).toBe( + 'CESCROW', + ); + }); + + it('returns the dedicated maintenance-pool contract id when set', () => { + expect( + makeService({ + escrowContractId: 'CESCROW', + maintenancePoolContractId: 'CPOOL', + }).maintenancePoolContractId, + ).toBe('CPOOL'); + }); + + it('falls back to the escrow contract id when no pool deployment is configured', () => { + expect( + makeService({ escrowContractId: 'CESCROW' }).maintenancePoolContractId, + ).toBe('CESCROW'); + }); + + it('resolves per-asset token contract ids and the deadline window', () => { + const service = makeService({ + assetContractIds: { USDC: 'CUSDC', XLM: 'CXLM' }, + escrowDeadlineSeconds: 1234, + }); + expect(service.tokenContractId(AssetType.USDC)).toBe('CUSDC'); + expect(service.tokenContractId(AssetType.XLM)).toBe('CXLM'); + expect(service.escrowDeadlineSeconds).toBe(1234); + }); + }); + describe('invoke (dry-run gating)', () => { it('returns a deterministic dry-run result without touching the RPC server when unconfigured', async () => { const sendSpy = jest.spyOn(rpc.Server.prototype, 'sendTransaction'); @@ -276,5 +313,36 @@ describe('SorobanClientService', () => { expect(nativeToScValMock).toHaveBeenCalledWith(42); expect(encoded).toBe(42); }); + + it('encodes a [address, basisPoints] pair as an (Address, u32) tuple (#161)', () => { + const address = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAWHV'; + + const encoded = ( + service as unknown as { toScVal(v: unknown): unknown } + ).toScVal([address, 5000]); + + expect(nativeToScValMock).toHaveBeenCalledWith(5000, { type: 'u32' }); + expect(encoded).toEqual([address, 5000]); + }); + + it('encodes a Vec<(Address, u32)> recipients list element-by-element (#161)', () => { + const a = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAWHV'; + const b = + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBAAAA'; + + const encoded = ( + service as unknown as { toScVal(v: unknown): unknown } + ).toScVal([ + [a, 6000], + [b, 4000], + ]); + + expect(encoded).toEqual([ + [a, 6000], + [b, 4000], + ]); + }); }); }); diff --git a/src/escrow/soroban-client.service.ts b/src/escrow/soroban-client.service.ts index 3c3a9d4..e1716ed 100644 --- a/src/escrow/soroban-client.service.ts +++ b/src/escrow/soroban-client.service.ts @@ -11,6 +11,7 @@ import { rpc, scValToNative, } from '@stellar/stellar-sdk'; +import { AssetType } from '../common/enums'; import { AppConfig } from '../config/configuration'; export interface ContractInvocationResult { @@ -24,18 +25,20 @@ export interface ContractInvocationResult { * Thin wrapper around the Stellar/Soroban RPC client used to invoke the * escrow smart contract deployed by the sibling `mergefi-contracts` repo. * - * TODO(mergefi-contracts): this client assumes a contract exposing - * `fund`, `release`, `refund`, `split_release` and `withdraw` functions with - * the signatures documented below. Adjust argument encoding once the real - * contract interface (from the Soroban contract's generated bindings) is - * available. Until ESCROW_CONTRACT_ID is configured, calls run in - * "simulate-only" dry-run mode and never submit a real transaction. + * The bounty/milestone escrow signatures below track `mergefi-contracts`' + * `contracts/escrow/src/lib.rs`. Adjust argument encoding once the real + * generated bindings are available. Until ESCROW_CONTRACT_ID is configured, + * calls run in "simulate-only" dry-run mode and never submit a real + * transaction. * - * Expected bounty/milestone escrow interface (Rust, illustrative): - * fn fund(env: Env, funder: Address, bounty_id: BytesN<32>, amount: i128, token: Address) - * fn release(env: Env, bounty_id: BytesN<32>, recipient: Address) -> i128 - * fn split_release(env: Env, bounty_id: BytesN<32>, recipients: Vec
, bps: Vec) -> i128 - * fn refund(env: Env, bounty_id: BytesN<32>) -> i128 + * Bounty/milestone escrow interface (Rust): + * fn fund(env, issue_id: u64, sponsor: Address, token: Address, amount: i128, deadline: u64) -> Result<(), Error> + * fn release(env, issue_id: u64, recipients: Vec<(Address, u32)>) -> Result<(), Error> + * fn refund(env, issue_id: u64) -> Result<(), Error> + * + * `release` is the single payout entrypoint — a lone recipient is just the + * degenerate `[(addr, 10_000)]` case of the same `(address, basis_points)` + * vector a team split uses; there is no separate `split_release` (#161). * * The `mergefi-maintenance-pool` contract is a distinct deposit/withdraw * model (no lock step, no split): a running on-chain balance topped up by @@ -66,6 +69,32 @@ export class SorobanClientService { ); } + /** Deployed contract ID for single-issue bounty/milestone escrows. */ + get escrowContractId(): string { + return this.stellar.escrowContractId; + } + + /** + * Deployed contract ID for maintenance-pool escrows, falling back to the + * bounty escrow contract when no separate pool deployment is configured + * (#157). + */ + get maintenancePoolContractId(): string { + return ( + this.stellar.maintenancePoolContractId || this.stellar.escrowContractId + ); + } + + /** Soroban token (SAC) contract address for a supported escrow asset (#158). */ + tokenContractId(asset: AssetType): string { + return this.stellar.assetContractIds[asset] ?? ''; + } + + /** Fallback escrow deadline, in seconds from now, for escrow::fund (#158). */ + get escrowDeadlineSeconds(): number { + return this.stellar.escrowDeadlineSeconds; + } + private getTreasuryKeypair(): Keypair | null { if (!this.stellar.treasurySecret) return null; return Keypair.fromSecret(this.stellar.treasurySecret); @@ -176,7 +205,19 @@ export class SorobanClientService { ); } - private toScVal(value: unknown) { + private toScVal(value: unknown): unknown { + if (Array.isArray(value)) { + // Vec<...> arguments, including the Vec<(Address, u32)> recipients + // list escrow::release takes. A [address, basisPoints] pair encodes + // as an (Address, u32) tuple; anything else element-by-element (#161). + if (this.isRecipientTuple(value)) { + return nativeToScVal([ + new Address(value[0]).toScVal(), + nativeToScVal(value[1], { type: 'u32' }), + ]); + } + return nativeToScVal(value.map((element) => this.toScVal(element))); + } if ( typeof value === 'string' && value.length >= 32 && @@ -194,4 +235,13 @@ export class SorobanClientService { } return nativeToScVal(value); } + + /** A `[stellarAddress, basisPoints]` pair destined for a `(Address, u32)` tuple. */ + private isRecipientTuple(value: unknown[]): value is [string, number] { + return ( + value.length === 2 && + typeof value[0] === 'string' && + typeof value[1] === 'number' + ); + } } diff --git a/src/github/github-webhooks.service.spec.ts b/src/github/github-webhooks.service.spec.ts index 0ecccd0..eddf365 100644 --- a/src/github/github-webhooks.service.spec.ts +++ b/src/github/github-webhooks.service.spec.ts @@ -168,6 +168,73 @@ describe('GithubWebhooksService', () => { expect(bountiesService.markPrClosedWithoutMerge).not.toHaveBeenCalled(); }); + describe('PR opened / reopened (#168)', () => { + it('moves a linked CLAIMED bounty to IN_REVIEW when its PR is opened', async () => { + issueRepo.findOne.mockResolvedValue({ + id: 'issue-1', + bounty: { id: 'bounty-1' }, + }); + bountyRepo.findOne.mockResolvedValue({ + id: 'bounty-1', + status: 'claimed', + }); + + const payload = { + action: 'opened', + number: 5, + pull_request: { + html_url: 'https://github.com/acme/repo/pull/5', + number: 5, + merged: false, + body: 'Fixes #21', + }, + repository: { id: 999, full_name: 'acme/repo' }, + }; + + const event = await service.handleEvent( + 'pull_request', + 'delivery-open', + payload, + true, + ); + + expect(bountiesService.markInReview).toHaveBeenCalledWith( + 'bounty-1', + 'https://github.com/acme/repo/pull/5', + 5, + ); + expect(bountiesService.markMergedAndRelease).not.toHaveBeenCalled(); + expect(event.status).toBe(WebhookEventStatus.PROCESSED); + }); + + it('leaves a bounty that is not CLAIMED untouched on a reopened PR', async () => { + issueRepo.findOne.mockResolvedValue({ + id: 'issue-1', + bounty: { id: 'bounty-1' }, + }); + bountyRepo.findOne.mockResolvedValue({ + id: 'bounty-1', + status: 'in_review', + }); + + const payload = { + action: 'reopened', + number: 6, + pull_request: { + html_url: 'x', + number: 6, + merged: false, + body: 'closes #22', + }, + repository: { id: 1, full_name: 'a/b' }, + }; + + await service.handleEvent('pull_request', 'delivery-reopen', payload, true); + + expect(bountiesService.markInReview).not.toHaveBeenCalled(); + }); + }); + describe('per-linked-issue isolation on a merged PR (#47)', () => { function mockIssueAndBounty( byNumber: Record, diff --git a/src/github/github-webhooks.service.ts b/src/github/github-webhooks.service.ts index addcef7..5c1c622 100644 --- a/src/github/github-webhooks.service.ts +++ b/src/github/github-webhooks.service.ts @@ -159,6 +159,10 @@ export class GithubWebhooksService { private async handlePullRequest( payload: GithubPullRequestPayload, ): Promise { + if (payload.action === 'opened' || payload.action === 'reopened') { + return this.handlePullRequestOpened(payload); + } + if (payload.action === 'closed' && !payload.pull_request.merged) { return this.handlePullRequestClosedWithoutMerge(payload); } @@ -270,6 +274,65 @@ export class GithubWebhooksService { return matches.map((m) => parseInt(m[3], 10)); } + /** + * When a PR is opened (or reopened) against a linked issue, move that + * issue's bounty from CLAIMED to IN_REVIEW at the moment the PR actually + * exists — rather than only synthetically at merge time (#168). Bounties + * not in CLAIMED are left untouched (idempotent no-op), matching the + * merged-PR branch's own `markInReview` guard. + */ + private async handlePullRequestOpened( + payload: GithubPullRequestPayload, + ): Promise { + const issueNumbers = [ + ...new Set( + this.extractLinkedIssueNumbers(payload.pull_request.body ?? ''), + ), + ]; + if (issueNumbers.length === 0) { + return []; + } + + const outcomes: LinkedIssueOutcome[] = []; + for (const number of issueNumbers) { + try { + const issue = await this.issueRepo.findOne({ + where: { + number, + repository: { githubRepoId: String(payload.repository.id) }, + }, + relations: { repository: true, bounty: true }, + }); + if (!issue?.bounty) { + outcomes.push({ issueNumber: number, outcome: 'skipped' }); + continue; + } + + const bounty = await this.bountyRepo.findOne({ + where: { id: issue.bounty.id }, + }); + if (!bounty || bounty.status !== BountyStatus.CLAIMED) { + outcomes.push({ issueNumber: number, outcome: 'skipped' }); + continue; + } + + await this.bountiesService.markInReview( + bounty.id, + payload.pull_request.html_url, + payload.pull_request.number, + ); + outcomes.push({ issueNumber: number, outcome: 'succeeded' }); + } catch (err) { + outcomes.push({ + issueNumber: number, + outcome: 'failed', + error: (err as Error).message, + }); + } + } + return outcomes; + } + /** * When a PR is closed without merging, move linked bounties from * IN_REVIEW back to CLAIMED so they become claimable again. diff --git a/src/milestones/milestones.service.ts b/src/milestones/milestones.service.ts index 3a6222c..c281322 100644 --- a/src/milestones/milestones.service.ts +++ b/src/milestones/milestones.service.ts @@ -58,6 +58,7 @@ export class MilestonesService { funderAddress, milestoneId: milestone.id, sponsorId: milestone.sponsorId, + deadline: milestone.deadline, }); milestone.escrow = escrow;