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: 0 additions & 10 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ GITHUB_WEBHOOK_SECRET=change-me-webhook-secret
# --- Stellar / Soroban -----------------------------------------------------
# "testnet" | "futurenet" | "mainnet"
STELLAR_NETWORK=testnet
HORIZON_URL=https://horizon-testnet.stellar.org
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
# Network passphrase must match STELLAR_NETWORK; testnet default shown.
STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
Expand All @@ -49,22 +48,13 @@ 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=
# Contract ID for the recurring maintenance pool contract. Falls back to
# ESCROW_CONTRACT_ID if unset.
MAINTENANCE_POOL_CONTRACT_ID=

# Treasury / platform account that pays transaction fees and can act as a
# fallback signer for automated (non-custodial) release/refund operations.
# TODO: replace with a proper signing service (KMS / multi-sig) before
# handling real funds.
TREASURY_ADDRESS=
TREASURY_SECRET=

# Asset issuers for supported stablecoins on Stellar (Circle USDC on testnet
# has a well-known issuer; mainnet issuer differs).
USDC_ASSET_CODE=USDC
USDC_ASSET_ISSUER=

# --- Misc --------------------------------------------------------------
# NestJS log verbosity: error | warn | log | debug | verbose
LOG_LEVEL=debug
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,9 @@ See [`.env.example`](./.env.example) for the full annotated list. Highlights:
| `GITHUB_CLIENT_ID` / `_SECRET`, `GITHUB_OAUTH_CALLBACK_URL` | GitHub OAuth login app. |
| `GITHUB_API_TOKEN` | Token used by Octokit for repo/issue sync (PAT for now; see roadmap). |
| `GITHUB_WEBHOOK_SECRET` | HMAC-SHA256 secret configured on the GitHub webhook. |
| `STELLAR_NETWORK`, `HORIZON_URL`, `SOROBAN_RPC_URL`, `STELLAR_NETWORK_PASSPHRASE` | Stellar network config. |
| `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 the maintenance pool; falls back to `ESCROW_CONTRACT_ID`. |
| `TREASURY_ADDRESS` / `TREASURY_SECRET` | Platform signer used to submit release/refund transactions. |
| `USDC_ASSET_CODE` / `USDC_ASSET_ISSUER` | Stablecoin asset identity on Stellar. |
| `TREASURY_SECRET` | Platform signer used to submit release/refund transactions. |

## Escrow / Soroban integration

Expand All @@ -157,8 +155,8 @@ 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` (and `MAINTENANCE_POOL_CONTRACT_ID` if separate).
2. Set `TREASURY_ADDRESS` / `TREASURY_SECRET` to a funded Stellar account.
1. Set `ESCROW_CONTRACT_ID`.
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 —
Expand Down
2 changes: 2 additions & 0 deletions src/common/entities/escrow.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
OneToMany,
OneToOne,
Expand Down Expand Up @@ -39,6 +40,7 @@ import { AssetType, EscrowStatus } from '../enums';
* in EscrowService.fund (see assertExactlyOneParent).
*/
@Entity('escrows')
@Index('IDX_escrow_sponsor_status', ['sponsorId', 'status'])
@Check(
'CHK_escrow_at_most_one_parent',
`(
Expand Down
14 changes: 0 additions & 14 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,10 @@ export interface AppConfig {
};
stellar: {
network: string;
horizonUrl: string;
sorobanRpcUrl: string;
networkPassphrase: string;
escrowContractId: string;
maintenancePoolContractId: string;
treasuryAddress: string;
treasurySecret: string;
usdcAssetCode: string;
usdcAssetIssuer: string;
};
}

Expand Down Expand Up @@ -62,21 +57,12 @@ export default (): AppConfig => ({
},
stellar: {
network: process.env.STELLAR_NETWORK ?? 'testnet',
horizonUrl:
process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org',
sorobanRpcUrl:
process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org',
networkPassphrase:
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 ??
'',
treasuryAddress: process.env.TREASURY_ADDRESS ?? '',
treasurySecret: process.env.TREASURY_SECRET ?? '',
usdcAssetCode: process.env.USDC_ASSET_CODE ?? 'USDC',
usdcAssetIssuer: process.env.USDC_ASSET_ISSUER ?? '',
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/**
* Adds a composite index on escrows("sponsorId", "status") to serve
* sponsor-dashboard queries (src/sponsors/sponsors.service.ts) that filter
* `WHERE escrow.sponsorId = :sponsorId AND escrow.status = :status` —
* notably SponsorsService.budgetLocked, which runs on every dashboard load.
*/
export class AddEscrowSponsorIdStatusIndex1784600000000 implements MigrationInterface {
name = 'AddEscrowSponsorIdStatusIndex1784600000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_escrow_sponsor_status"
ON "escrows" ("sponsorId", "status")
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_escrow_sponsor_status"`,
);
}
}
140 changes: 138 additions & 2 deletions src/escrow/escrow.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
import { EscrowService } from './escrow.service';
import { SorobanClientService } from './soroban-client.service';
import { Escrow, Payment } from '../common/entities';
import { AssetType, EscrowStatus } from '../common/enums';
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 paymentRepo: {
create: jest.Mock;
save: jest.Mock;
find: jest.Mock;
};
let soroban: { invoke: jest.Mock };

beforeEach(async () => {
Expand All @@ -24,6 +28,7 @@
...data,
})),
save: jest.fn((data: Partial<Payment>) => Promise.resolve(data)),
find: jest.fn().mockResolvedValue([]),
};
soroban = {
invoke: jest.fn().mockResolvedValue({
Expand Down Expand Up @@ -218,6 +223,137 @@
});
});

describe('releasePartial', () => {
const lockedEscrow = () => ({
id: 'escrow-partial',
status: EscrowStatus.LOCKED,
amount: '100.0000000',
asset: AssetType.USDC,
milestoneId: 'milestone-1',
});

it('releases part of a LOCKED escrow and records a Payment while it stays LOCKED below the total', async () => {
const escrow = lockedEscrow();
escrowRepo.findOne.mockResolvedValue(escrow);
paymentRepo.find.mockResolvedValue([]);

const payment = await service.releasePartial(
'escrow-partial',
'30.0000000',
'GRECIPIENT',
'user-1',
);

expect(soroban.invoke).toHaveBeenCalledWith('release', [
'milestone-1',
'GRECIPIENT',
300_000_000n,
]);
expect(paymentRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
escrowId: 'escrow-partial',
recipientId: 'user-1',
recipientAddress: 'GRECIPIENT',
amount: '30.0000000',
asset: AssetType.USDC,
status: PaymentStatus.CONFIRMED,
}),
);
expect(payment.amount).toBe('30.0000000');
expect(escrow.status).toBe(EscrowStatus.LOCKED);
expect(escrowRepo.save).not.toHaveBeenCalled();
});

it('flips the escrow to RELEASED when a single partial release covers the full amount', async () => {
escrowRepo.findOne.mockResolvedValue(lockedEscrow());
paymentRepo.find.mockResolvedValue([]);

await service.releasePartial(
'escrow-partial',
'100.0000000',
'GRECIPIENT',
);

expect(escrowRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
id: 'escrow-partial',
status: EscrowStatus.RELEASED,
releaseTxHash: 'tx-hash-123',
}),
);
});

it('completes a partial-then-partial sequence only once the cumulative total reaches the amount', async () => {
escrowRepo.findOne.mockResolvedValue(lockedEscrow());
paymentRepo.find
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ amount: '40.0000000' }]);

await service.releasePartial('escrow-partial', '40.0000000', 'GA');
expect(escrowRepo.save).not.toHaveBeenCalled();

await service.releasePartial('escrow-partial', '60.0000000', 'GB');

expect(soroban.invoke).toHaveBeenNthCalledWith(2, 'release', [
'milestone-1',
'GB',
600_000_000n,
]);
expect(escrowRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
status: EscrowStatus.RELEASED,
releasedAt: expect.any(Date),

Check failure on line 305 in src/escrow/escrow.service.spec.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe assignment of an `any` value
}),
);
});

it('rejects a release that would exceed the remaining balance after prior partials', async () => {
escrowRepo.findOne.mockResolvedValue(lockedEscrow());
paymentRepo.find.mockResolvedValue([{ amount: '50.0000000' }]);

await expect(
service.releasePartial('escrow-partial', '60.0000000', 'GRECIPIENT'),
).rejects.toThrow(BadRequestException);

expect(soroban.invoke).not.toHaveBeenCalled();
expect(paymentRepo.save).not.toHaveBeenCalled();
expect(escrowRepo.save).not.toHaveBeenCalled();
});
});

describe('refund', () => {
it('rejects refunding an escrow that is not LOCKED', async () => {
escrowRepo.findOne.mockResolvedValue({
id: 'escrow-pending',
status: EscrowStatus.PENDING,
amount: '10',
asset: AssetType.USDC,
});

await expect(service.refund('escrow-pending')).rejects.toThrow(
BadRequestException,
);
expect(soroban.invoke).not.toHaveBeenCalled();
});

it('refunds a LOCKED escrow to the original funder', async () => {
escrowRepo.findOne.mockResolvedValue({
id: 'escrow-refund',
status: EscrowStatus.LOCKED,
amount: '25.0000000',
asset: AssetType.USDC,
bountyId: 'bounty-7',
});

const escrow = await service.refund('escrow-refund');

expect(soroban.invoke).toHaveBeenCalledWith('refund', ['bounty-7']);
expect(escrow.status).toBe(EscrowStatus.REFUNDED);
expect(escrow.refundTxHash).toBe('tx-hash-123');
expect(escrow.refundedAt).toBeInstanceOf(Date);
});
});

describe('assertValidSplits / splitRelease', () => {
it('throws when percentages do not sum to 100', () => {
expect(() =>
Expand Down
Loading
Loading