diff --git a/.env.example b/.env.example index 8d52e26..bb4126c 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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 diff --git a/README.md b/README.md index 1f871f2..df87556 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 — diff --git a/src/common/entities/escrow.entity.ts b/src/common/entities/escrow.entity.ts index ff2556f..ca8aa68 100644 --- a/src/common/entities/escrow.entity.ts +++ b/src/common/entities/escrow.entity.ts @@ -3,6 +3,7 @@ import { Column, CreateDateColumn, Entity, + Index, JoinColumn, OneToMany, OneToOne, @@ -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', `( diff --git a/src/config/configuration.ts b/src/config/configuration.ts index d2943e5..cb523f0 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -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; }; } @@ -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 ?? '', }, }); diff --git a/src/database/migrations/1784600000000-AddEscrowSponsorIdStatusIndex.ts b/src/database/migrations/1784600000000-AddEscrowSponsorIdStatusIndex.ts new file mode 100644 index 0000000..ed6d25c --- /dev/null +++ b/src/database/migrations/1784600000000-AddEscrowSponsorIdStatusIndex.ts @@ -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 { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_escrow_sponsor_status" + ON "escrows" ("sponsorId", "status") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_escrow_sponsor_status"`, + ); + } +} diff --git a/src/escrow/escrow.service.spec.ts b/src/escrow/escrow.service.spec.ts index dd9dd9e..c70bc01 100644 --- a/src/escrow/escrow.service.spec.ts +++ b/src/escrow/escrow.service.spec.ts @@ -4,12 +4,16 @@ import { BadRequestException } from '@nestjs/common'; 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 () => { @@ -24,6 +28,7 @@ describe('EscrowService', () => { ...data, })), save: jest.fn((data: Partial) => Promise.resolve(data)), + find: jest.fn().mockResolvedValue([]), }; soroban = { invoke: jest.fn().mockResolvedValue({ @@ -218,6 +223,137 @@ describe('EscrowService', () => { }); }); + 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), + }), + ); + }); + + 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(() => diff --git a/src/escrow/soroban-client.service.spec.ts b/src/escrow/soroban-client.service.spec.ts new file mode 100644 index 0000000..31b9285 --- /dev/null +++ b/src/escrow/soroban-client.service.spec.ts @@ -0,0 +1,280 @@ +import { ConfigService } from '@nestjs/config'; +import { nativeToScVal, rpc } from '@stellar/stellar-sdk'; +import { AppConfig } from '../config/configuration'; +import { SorobanClientService } from './soroban-client.service'; + +jest.mock('@stellar/stellar-sdk', () => ({ + ...jest.requireActual('@stellar/stellar-sdk'), + nativeToScVal: jest.fn((value: unknown) => value), +})); + +type StellarConfig = AppConfig['stellar']; + +const baseStellarConfig: StellarConfig = { + network: 'testnet', + sorobanRpcUrl: 'http://localhost:8000/rpc', + networkPassphrase: 'Test SDF Network ; September 2015', + escrowContractId: '', + treasurySecret: '', +}; + +function makeService( + overrides: Partial = {}, +): SorobanClientService { + const configService = { + get: jest.fn(() => ({ ...baseStellarConfig, ...overrides })), + }; + return new SorobanClientService( + configService as unknown as ConfigService, + ); +} + +const txResponse = (overrides: Record) => + overrides as unknown as rpc.Api.GetTransactionResponse; + +const nativeToScValMock = nativeToScVal as unknown as jest.Mock; + +describe('SorobanClientService', () => { + beforeEach(() => { + nativeToScValMock.mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('isConfigured', () => { + it('is false when no contract ID and no treasury secret are set', () => { + expect(makeService().isConfigured()).toBe(false); + }); + + it('is false when only the contract ID is set', () => { + expect( + makeService({ escrowContractId: 'CCONTRACT' }).isConfigured(), + ).toBe(false); + }); + + it('is false when only the treasury secret is set', () => { + expect(makeService({ treasurySecret: 'SSECRET' }).isConfigured()).toBe( + false, + ); + }); + + it('is true once both contract ID and treasury secret are set', () => { + expect( + makeService({ + escrowContractId: 'CCONTRACT', + treasurySecret: 'SSECRET', + }).isConfigured(), + ).toBe(true); + }); + }); + + 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'); + const getTxSpy = jest.spyOn(rpc.Server.prototype, 'getTransaction'); + + const result = await makeService().invoke('release', ['ref-1']); + + expect(result.status).toBe('DRY_RUN'); + expect(result.txHash).toMatch(/^dry-run-release-/); + expect(result.ledger).toBeNull(); + expect(result.returnValue).toBeNull(); + expect(sendSpy).not.toHaveBeenCalled(); + expect(getTxSpy).not.toHaveBeenCalled(); + }); + }); + + describe('invoke (configured pipeline)', () => { + let service: SorobanClientService; + let assembleSpy: jest.SpyInstance; + + beforeEach(() => { + service = makeService({ + escrowContractId: 'CCONTRACT1234567890', + treasurySecret: 'SSECRET', + }); + jest + .spyOn(rpc.Server.prototype, 'getAccount') + .mockResolvedValue({} as never); + jest + .spyOn(rpc.Server.prototype, 'simulateTransaction') + .mockResolvedValue({} as never); + jest + .spyOn(rpc.Server.prototype, 'sendTransaction') + .mockResolvedValue({ + status: 'PENDING', + hash: 'mock-hash', + } as never); + jest + .spyOn(rpc.Server.prototype, 'getTransaction') + .mockResolvedValue( + txResponse({ status: 'SUCCESS', ledger: 42, returnValue: null }), + ); + assembleSpy = jest.spyOn(rpc, 'assembleTransaction'); + }); + + it('simulates, assembles, signs, submits, and polls to a finalized result', async () => { + const result = await service.invoke('fund', ['GFUNDER', 'bounty-1']); + + expect(assembleSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + txHash: 'mock-hash', + ledger: 42, + returnValue: null, + status: 'SUCCESS', + }); + }); + + it('converts a non-null transaction return value via scValToNative', async () => { + jest + .spyOn(rpc.Server.prototype, 'getTransaction') + .mockResolvedValue( + txResponse({ status: 'SUCCESS', ledger: 7, returnValue: 'native' }), + ); + + const result = await service.invoke('release', ['ref-1']); + + expect(result.returnValue).toBe('native'); + }); + + it('throws when simulation fails', async () => { + const isSimulationErrorSpy = jest + .spyOn(rpc.Api, 'isSimulationError') + .mockReturnValue(true); + jest + .spyOn(rpc.Server.prototype, 'simulateTransaction') + .mockResolvedValue({ error: 'bad args' } as never); + + await expect(service.invoke('refund', ['ref-1'])).rejects.toThrow( + 'Soroban simulation failed: bad args', + ); + expect(isSimulationErrorSpy).toHaveBeenCalled(); + }); + + it('throws when transaction submission errors', async () => { + jest + .spyOn(rpc.Server.prototype, 'sendTransaction') + .mockResolvedValue({ + status: 'ERROR', + errorResult: { code: -1 }, + } as never); + + await expect(service.invoke('refund', ['ref-1'])).rejects.toThrow( + 'Soroban transaction submission failed', + ); + }); + }); + + describe('pollTransaction (via invoke)', () => { + let service: SorobanClientService; + + beforeEach(() => { + service = makeService({ + escrowContractId: 'CCONTRACT1234567890', + treasurySecret: 'SSECRET', + }); + jest + .spyOn(rpc.Server.prototype, 'getAccount') + .mockResolvedValue({} as never); + jest + .spyOn(rpc.Server.prototype, 'simulateTransaction') + .mockResolvedValue({} as never); + jest + .spyOn(rpc.Server.prototype, 'sendTransaction') + .mockResolvedValue({ + status: 'PENDING', + hash: 'mock-hash', + } as never); + jest.useFakeTimers(); + }); + + it('retries while the RPC reports NOT_FOUND and resolves once finalized', async () => { + const getTxSpy = jest + .spyOn(rpc.Server.prototype, 'getTransaction') + .mockResolvedValueOnce(txResponse({ status: 'NOT_FOUND' })) + .mockResolvedValueOnce(txResponse({ status: 'NOT_FOUND' })) + .mockResolvedValue( + txResponse({ status: 'SUCCESS', ledger: 9, returnValue: null }), + ); + + const pending = service.invoke('release', ['ref-1']); + await jest.advanceTimersByTimeAsync(20_000); + const result = await pending; + + expect(getTxSpy).toHaveBeenCalledTimes(3); + expect(result.status).toBe('SUCCESS'); + expect(result.ledger).toBe(9); + }); + + it('times out after exhausting all attempts when the transaction stays NOT_FOUND', async () => { + const getTxSpy = jest + .spyOn(rpc.Server.prototype, 'getTransaction') + .mockResolvedValue(txResponse({ status: 'NOT_FOUND' })); + + const settled = service.invoke('refund', ['ref-1']).then( + () => null, + (err: Error) => err, + ); + await jest.advanceTimersByTimeAsync(21_000); + const err = await settled; + + expect(getTxSpy).toHaveBeenCalledTimes(10); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain( + 'Timed out waiting for Soroban transaction mock-hash to finalize', + ); + }); + }); + + describe('toScVal argument encoding (via private hook)', () => { + let service: SorobanClientService; + + beforeEach(() => { + service = makeService(); + }); + + it('detects uppercase alphanumeric strings of >= 32 chars as Stellar addresses', () => { + const address = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAWHV'; + + const encoded = ( + service as unknown as { toScVal(v: unknown): unknown } + ).toScVal(address); + + expect(encoded).toBe(address); + expect(nativeToScValMock).not.toHaveBeenCalled(); + }); + + it('encodes bigints as i128', () => { + const encoded = ( + service as unknown as { toScVal(v: unknown): unknown } + ).toScVal(1_000_000n); + + expect(nativeToScValMock).toHaveBeenCalledWith(1_000_000n, { + type: 'i128', + }); + expect(encoded).toBe(1_000_000n); + }); + + it('encodes short/plain strings with generic native encoding', () => { + const encoded = ( + service as unknown as { toScVal(v: unknown): unknown } + ).toScVal('bounty-1'); + + expect(nativeToScValMock).toHaveBeenCalledWith('bounty-1'); + expect(encoded).toBe('bounty-1'); + }); + + it('encodes numbers with generic native encoding', () => { + const encoded = ( + service as unknown as { toScVal(v: unknown): unknown } + ).toScVal(42); + + expect(nativeToScValMock).toHaveBeenCalledWith(42); + expect(encoded).toBe(42); + }); + }); +});