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
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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.
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions src/bounties/bounties.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 8 additions & 1 deletion src/bounties/bounties.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bounty> {
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({
Expand All @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions src/common/entities/escrow.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
37 changes: 37 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { AssetType } from '../common/enums';

export interface AppConfig {
env: string;
port: number;
Expand Down Expand Up @@ -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<AssetType, string>;
/**
* 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;
};
}

Expand Down Expand Up @@ -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,
),
},
});
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE "escrows" DROP COLUMN IF EXISTS "deadline"`,
);
await queryRunner.query(
`ALTER TABLE "escrows" DROP COLUMN IF EXISTS "onChainId"`,
);
}
}
2 changes: 2 additions & 0 deletions src/escrow/escrow-response.mapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ function makeEscrow(overrides: Partial<Escrow> = {}): Escrow {
maintenancePoolId: null,
sponsorId: 'sponsor_1',
contractId: null,
onChainId: null,
deadline: null,
amount: '100.0000000',
asset: AssetType.USDC,
status: EscrowStatus.FAILED,
Expand Down
2 changes: 2 additions & 0 deletions src/escrow/escrow.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading