Skip to content

Commit 7306f01

Browse files
authored
Merge pull request #208 from hardcordev/fix/escrow-index-tests-config-94-95-96-97
fix: escrow sponsor/status index, dead stellar config removal, releasePartial/refund and SorobanClientService test coverage
2 parents 097bfea + 858663e commit 7306f01

7 files changed

Lines changed: 448 additions & 32 deletions

File tree

.env.example

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ GITHUB_WEBHOOK_SECRET=change-me-webhook-secret
4040
# --- Stellar / Soroban -----------------------------------------------------
4141
# "testnet" | "futurenet" | "mainnet"
4242
STELLAR_NETWORK=testnet
43-
HORIZON_URL=https://horizon-testnet.stellar.org
4443
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
4544
# Network passphrase must match STELLAR_NETWORK; testnet default shown.
4645
STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
@@ -49,22 +48,13 @@ STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
4948
# for the current contract IDs. Leave blank to run escrow calls in "dry
5049
# run" mode (persisted locally, no on-chain effect) instead.
5150
ESCROW_CONTRACT_ID=
52-
# Contract ID for the recurring maintenance pool contract. Falls back to
53-
# ESCROW_CONTRACT_ID if unset.
54-
MAINTENANCE_POOL_CONTRACT_ID=
5551

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

63-
# Asset issuers for supported stablecoins on Stellar (Circle USDC on testnet
64-
# has a well-known issuer; mainnet issuer differs).
65-
USDC_ASSET_CODE=USDC
66-
USDC_ASSET_ISSUER=
67-
6858
# --- Misc --------------------------------------------------------------
6959
# NestJS log verbosity: error | warn | log | debug | verbose
7060
LOG_LEVEL=debug

README.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,9 @@ See [`.env.example`](./.env.example) for the full annotated list. Highlights:
133133
| `GITHUB_CLIENT_ID` / `_SECRET`, `GITHUB_OAUTH_CALLBACK_URL` | GitHub OAuth login app. |
134134
| `GITHUB_API_TOKEN` | Token used by Octokit for repo/issue sync (PAT for now; see roadmap). |
135135
| `GITHUB_WEBHOOK_SECRET` | HMAC-SHA256 secret configured on the GitHub webhook. |
136-
| `STELLAR_NETWORK`, `HORIZON_URL`, `SOROBAN_RPC_URL`, `STELLAR_NETWORK_PASSPHRASE` | Stellar network config. |
136+
| `STELLAR_NETWORK`, `SOROBAN_RPC_URL`, `STELLAR_NETWORK_PASSPHRASE` | Stellar network config. |
137137
| `ESCROW_CONTRACT_ID` | Deployed escrow contract ID from `mergefi-contracts`. **Not set in this environment** — see below. |
138-
| `MAINTENANCE_POOL_CONTRACT_ID` | Optional separate contract for the maintenance pool; falls back to `ESCROW_CONTRACT_ID`. |
139-
| `TREASURY_ADDRESS` / `TREASURY_SECRET` | Platform signer used to submit release/refund transactions. |
140-
| `USDC_ASSET_CODE` / `USDC_ASSET_ISSUER` | Stablecoin asset identity on Stellar. |
138+
| `TREASURY_SECRET` | Platform signer used to submit release/refund transactions. |
141139

142140
## Escrow / Soroban integration
143141

@@ -157,8 +155,8 @@ rest of the system (state transitions, DB writes, split-percentage math,
157155
webhook-triggered releases) can still be exercised end-to-end in tests and
158156
local dev. Once real contracts are deployed:
159157

160-
1. Set `ESCROW_CONTRACT_ID` (and `MAINTENANCE_POOL_CONTRACT_ID` if separate).
161-
2. Set `TREASURY_ADDRESS` / `TREASURY_SECRET` to a funded Stellar account.
158+
1. Set `ESCROW_CONTRACT_ID`.
159+
2. Set `TREASURY_SECRET` to a funded Stellar account.
162160
3. Confirm the contract's `fund`/`release`/`split_release`/`refund` function
163161
signatures match the ones documented at the top of
164162
`soroban-client.service.ts` (adjust argument encoding there if not —

src/common/entities/escrow.entity.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
Column,
44
CreateDateColumn,
55
Entity,
6+
Index,
67
JoinColumn,
78
OneToMany,
89
OneToOne,
@@ -39,6 +40,7 @@ import { AssetType, EscrowStatus } from '../enums';
3940
* in EscrowService.fund (see assertExactlyOneParent).
4041
*/
4142
@Entity('escrows')
43+
@Index('IDX_escrow_sponsor_status', ['sponsorId', 'status'])
4244
@Check(
4345
'CHK_escrow_at_most_one_parent',
4446
`(

src/config/configuration.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,10 @@ export interface AppConfig {
2222
};
2323
stellar: {
2424
network: string;
25-
horizonUrl: string;
2625
sorobanRpcUrl: string;
2726
networkPassphrase: string;
2827
escrowContractId: string;
29-
maintenancePoolContractId: string;
30-
treasuryAddress: string;
3128
treasurySecret: string;
32-
usdcAssetCode: string;
33-
usdcAssetIssuer: string;
3429
};
3530
}
3631

@@ -62,21 +57,12 @@ export default (): AppConfig => ({
6257
},
6358
stellar: {
6459
network: process.env.STELLAR_NETWORK ?? 'testnet',
65-
horizonUrl:
66-
process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org',
6760
sorobanRpcUrl:
6861
process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org',
6962
networkPassphrase:
7063
process.env.STELLAR_NETWORK_PASSPHRASE ??
7164
'Test SDF Network ; September 2015',
7265
escrowContractId: process.env.ESCROW_CONTRACT_ID ?? '',
73-
maintenancePoolContractId:
74-
process.env.MAINTENANCE_POOL_CONTRACT_ID ??
75-
process.env.ESCROW_CONTRACT_ID ??
76-
'',
77-
treasuryAddress: process.env.TREASURY_ADDRESS ?? '',
7866
treasurySecret: process.env.TREASURY_SECRET ?? '',
79-
usdcAssetCode: process.env.USDC_ASSET_CODE ?? 'USDC',
80-
usdcAssetIssuer: process.env.USDC_ASSET_ISSUER ?? '',
8167
},
8268
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
/**
4+
* Adds a composite index on escrows("sponsorId", "status") to serve
5+
* sponsor-dashboard queries (src/sponsors/sponsors.service.ts) that filter
6+
* `WHERE escrow.sponsorId = :sponsorId AND escrow.status = :status` —
7+
* notably SponsorsService.budgetLocked, which runs on every dashboard load.
8+
*/
9+
export class AddEscrowSponsorIdStatusIndex1784600000000 implements MigrationInterface {
10+
name = 'AddEscrowSponsorIdStatusIndex1784600000000';
11+
12+
public async up(queryRunner: QueryRunner): Promise<void> {
13+
await queryRunner.query(`
14+
CREATE INDEX IF NOT EXISTS "IDX_escrow_sponsor_status"
15+
ON "escrows" ("sponsorId", "status")
16+
`);
17+
}
18+
19+
public async down(queryRunner: QueryRunner): Promise<void> {
20+
await queryRunner.query(
21+
`DROP INDEX IF EXISTS "IDX_escrow_sponsor_status"`,
22+
);
23+
}
24+
}

src/escrow/escrow.service.spec.ts

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import { BadRequestException } from '@nestjs/common';
44
import { EscrowService } from './escrow.service';
55
import { SorobanClientService } from './soroban-client.service';
66
import { Escrow, Payment } from '../common/entities';
7-
import { AssetType, EscrowStatus } from '../common/enums';
7+
import { AssetType, EscrowStatus, PaymentStatus } from '../common/enums';
88

99
describe('EscrowService', () => {
1010
let service: EscrowService;
1111
let escrowRepo: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock };
12-
let paymentRepo: { create: jest.Mock; save: jest.Mock };
12+
let paymentRepo: {
13+
create: jest.Mock;
14+
save: jest.Mock;
15+
find: jest.Mock;
16+
};
1317
let soroban: { invoke: jest.Mock };
1418

1519
beforeEach(async () => {
@@ -24,6 +28,7 @@ describe('EscrowService', () => {
2428
...data,
2529
})),
2630
save: jest.fn((data: Partial<Payment>) => Promise.resolve(data)),
31+
find: jest.fn().mockResolvedValue([]),
2732
};
2833
soroban = {
2934
invoke: jest.fn().mockResolvedValue({
@@ -218,6 +223,137 @@ describe('EscrowService', () => {
218223
});
219224
});
220225

226+
describe('releasePartial', () => {
227+
const lockedEscrow = () => ({
228+
id: 'escrow-partial',
229+
status: EscrowStatus.LOCKED,
230+
amount: '100.0000000',
231+
asset: AssetType.USDC,
232+
milestoneId: 'milestone-1',
233+
});
234+
235+
it('releases part of a LOCKED escrow and records a Payment while it stays LOCKED below the total', async () => {
236+
const escrow = lockedEscrow();
237+
escrowRepo.findOne.mockResolvedValue(escrow);
238+
paymentRepo.find.mockResolvedValue([]);
239+
240+
const payment = await service.releasePartial(
241+
'escrow-partial',
242+
'30.0000000',
243+
'GRECIPIENT',
244+
'user-1',
245+
);
246+
247+
expect(soroban.invoke).toHaveBeenCalledWith('release', [
248+
'milestone-1',
249+
'GRECIPIENT',
250+
300_000_000n,
251+
]);
252+
expect(paymentRepo.save).toHaveBeenCalledWith(
253+
expect.objectContaining({
254+
escrowId: 'escrow-partial',
255+
recipientId: 'user-1',
256+
recipientAddress: 'GRECIPIENT',
257+
amount: '30.0000000',
258+
asset: AssetType.USDC,
259+
status: PaymentStatus.CONFIRMED,
260+
}),
261+
);
262+
expect(payment.amount).toBe('30.0000000');
263+
expect(escrow.status).toBe(EscrowStatus.LOCKED);
264+
expect(escrowRepo.save).not.toHaveBeenCalled();
265+
});
266+
267+
it('flips the escrow to RELEASED when a single partial release covers the full amount', async () => {
268+
escrowRepo.findOne.mockResolvedValue(lockedEscrow());
269+
paymentRepo.find.mockResolvedValue([]);
270+
271+
await service.releasePartial(
272+
'escrow-partial',
273+
'100.0000000',
274+
'GRECIPIENT',
275+
);
276+
277+
expect(escrowRepo.save).toHaveBeenCalledWith(
278+
expect.objectContaining({
279+
id: 'escrow-partial',
280+
status: EscrowStatus.RELEASED,
281+
releaseTxHash: 'tx-hash-123',
282+
}),
283+
);
284+
});
285+
286+
it('completes a partial-then-partial sequence only once the cumulative total reaches the amount', async () => {
287+
escrowRepo.findOne.mockResolvedValue(lockedEscrow());
288+
paymentRepo.find
289+
.mockResolvedValueOnce([])
290+
.mockResolvedValueOnce([{ amount: '40.0000000' }]);
291+
292+
await service.releasePartial('escrow-partial', '40.0000000', 'GA');
293+
expect(escrowRepo.save).not.toHaveBeenCalled();
294+
295+
await service.releasePartial('escrow-partial', '60.0000000', 'GB');
296+
297+
expect(soroban.invoke).toHaveBeenNthCalledWith(2, 'release', [
298+
'milestone-1',
299+
'GB',
300+
600_000_000n,
301+
]);
302+
expect(escrowRepo.save).toHaveBeenCalledWith(
303+
expect.objectContaining({
304+
status: EscrowStatus.RELEASED,
305+
releasedAt: expect.any(Date),
306+
}),
307+
);
308+
});
309+
310+
it('rejects a release that would exceed the remaining balance after prior partials', async () => {
311+
escrowRepo.findOne.mockResolvedValue(lockedEscrow());
312+
paymentRepo.find.mockResolvedValue([{ amount: '50.0000000' }]);
313+
314+
await expect(
315+
service.releasePartial('escrow-partial', '60.0000000', 'GRECIPIENT'),
316+
).rejects.toThrow(BadRequestException);
317+
318+
expect(soroban.invoke).not.toHaveBeenCalled();
319+
expect(paymentRepo.save).not.toHaveBeenCalled();
320+
expect(escrowRepo.save).not.toHaveBeenCalled();
321+
});
322+
});
323+
324+
describe('refund', () => {
325+
it('rejects refunding an escrow that is not LOCKED', async () => {
326+
escrowRepo.findOne.mockResolvedValue({
327+
id: 'escrow-pending',
328+
status: EscrowStatus.PENDING,
329+
amount: '10',
330+
asset: AssetType.USDC,
331+
});
332+
333+
await expect(service.refund('escrow-pending')).rejects.toThrow(
334+
BadRequestException,
335+
);
336+
expect(soroban.invoke).not.toHaveBeenCalled();
337+
});
338+
339+
it('refunds a LOCKED escrow to the original funder', async () => {
340+
escrowRepo.findOne.mockResolvedValue({
341+
id: 'escrow-refund',
342+
status: EscrowStatus.LOCKED,
343+
amount: '25.0000000',
344+
asset: AssetType.USDC,
345+
bountyId: 'bounty-7',
346+
});
347+
348+
const escrow = await service.refund('escrow-refund');
349+
350+
expect(soroban.invoke).toHaveBeenCalledWith('refund', ['bounty-7']);
351+
expect(escrow.status).toBe(EscrowStatus.REFUNDED);
352+
expect(escrow.refundTxHash).toBe('tx-hash-123');
353+
expect(escrow.refundedAt).toBeInstanceOf(Date);
354+
});
355+
});
356+
221357
describe('assertValidSplits / splitRelease', () => {
222358
it('throws when percentages do not sum to 100', () => {
223359
expect(() =>

0 commit comments

Comments
 (0)