From 472a78de3e9327200dcb9ab576d0e6bf37df14bf Mon Sep 17 00:00:00 2001 From: Gutopro Date: Sat, 27 Jun 2026 11:36:17 +0100 Subject: [PATCH 1/3] feat(treasury): add stream preview calculator API endpoint --- .../src/treasury/dto/stream-preview.dto.ts | 94 ++++++++++++ .../treasury/treasury-preview.service.spec.ts | 142 ++++++++++++++++++ .../src/treasury/treasury.controller.ts | 29 ++++ apps/backend/src/treasury/treasury.service.ts | 44 ++++++ 4 files changed, 309 insertions(+) create mode 100644 apps/backend/src/treasury/dto/stream-preview.dto.ts create mode 100644 apps/backend/src/treasury/treasury-preview.service.spec.ts diff --git a/apps/backend/src/treasury/dto/stream-preview.dto.ts b/apps/backend/src/treasury/dto/stream-preview.dto.ts new file mode 100644 index 00000000..95de611b --- /dev/null +++ b/apps/backend/src/treasury/dto/stream-preview.dto.ts @@ -0,0 +1,94 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsNumber, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * Request body for the treasury stream preview endpoint. + * All inputs are read-only — no transaction is submitted. + */ +export class StreamPreviewDto { + @ApiProperty({ + description: 'Stellar address of the beneficiary', + example: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }) + @IsString() + @IsNotEmpty() + beneficiary: string; + + @ApiPropertyOptional({ + description: + 'Unix timestamp (seconds) to evaluate the stream at. ' + + 'Defaults to the current server time when omitted.', + example: 1735689600, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Type(() => Number) + atTime?: number; +} + +/** + * Read-only preview of a treasury stream's unlocked, claimed, and remaining + * amounts at a given point in time, without submitting any transaction. + */ +export class StreamPreviewResponseDto { + @ApiProperty({ + description: 'Stellar address of the beneficiary', + example: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }) + beneficiary: string; + + @ApiProperty({ + description: 'Total amount allocated to the stream (stroops)', + example: '1000000000', + }) + totalAmount: string; + + @ApiProperty({ + description: 'Amount already claimed from the stream (stroops)', + example: '250000000', + }) + claimedAmount: string; + + @ApiProperty({ + description: + 'Amount currently unlocked and claimable at `atTime` (stroops). ' + + 'Computed using the same linear-vesting formula as the on-chain contract.', + example: '100000000', + }) + unlockedAmount: string; + + @ApiProperty({ + description: 'Amount not yet claimed: totalAmount - claimedAmount (stroops)', + example: '750000000', + }) + remainingAmount: string; + + @ApiProperty({ + description: 'Stream start time as a Unix timestamp in seconds', + example: 1735689600, + }) + startTime: number; + + @ApiProperty({ + description: 'Stream duration in seconds', + example: 2592000, + }) + duration: number; + + @ApiProperty({ + description: + 'Unix timestamp (seconds) at which the preview was calculated. ' + + 'Equal to the `atTime` input when provided, otherwise the server clock.', + example: 1735776000, + }) + previewAt: number; + + @ApiProperty({ + description: + 'Whether the stream is currently active (started and not yet fully elapsed).', + example: true, + }) + isActive: boolean; +} diff --git a/apps/backend/src/treasury/treasury-preview.service.spec.ts b/apps/backend/src/treasury/treasury-preview.service.spec.ts new file mode 100644 index 00000000..ba543b14 --- /dev/null +++ b/apps/backend/src/treasury/treasury-preview.service.spec.ts @@ -0,0 +1,142 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TreasuryService } from './treasury.service'; +import { TreasurySorobanClient } from './treasury-soroban.client'; +import { TreasuryStreamNotFoundException } from './exceptions/treasury.exceptions'; +import type { RawStreamData } from './treasury-stream.util'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const NOW_SECS = 1_735_776_000; +const START_SECS = 1_735_689_600; +const DURATION_SECS = 2_592_000; // 30 days + +const baseStream: RawStreamData = { + beneficiary: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + totalAmount: BigInt(1_000_000_000), + claimedAmount: BigInt(0), + startTime: BigInt(START_SECS), + duration: BigInt(DURATION_SECS), +}; + +// ── Mock client ─────────────────────────────────────────────────────────────── + +const mockGetStream = jest.fn(); + +const mockSorobanClient = { + getStream: mockGetStream, + allocateBudget: jest.fn(), + rotateBeneficiary: jest.fn(), +}; + +// ── Test suite ──────────────────────────────────────────────────────────────── + +describe('TreasuryService.previewStream', () => { + let service: TreasuryService; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TreasuryService, + { provide: TreasurySorobanClient, useValue: mockSorobanClient }, + ], + }).compile(); + + service = module.get(TreasuryService); + }); + + it('returns unlocked, claimed, and remaining for an active stream', async () => { + mockGetStream.mockResolvedValue(baseStream); + + // At halfway through the 30-day stream: 50% unlocked = 500_000_000 + const halfwayPoint = START_SECS + DURATION_SECS / 2; + + const result = await service.previewStream({ + beneficiary: baseStream.beneficiary, + atTime: halfwayPoint, + }); + + expect(result.beneficiary).toBe(baseStream.beneficiary); + expect(result.totalAmount).toBe('1000000000'); + expect(result.claimedAmount).toBe('0'); + expect(result.unlockedAmount).toBe('500000000'); + expect(result.remainingAmount).toBe('1000000000'); + expect(result.previewAt).toBe(halfwayPoint); + expect(result.isActive).toBe(true); + }); + + it('returns full remaining amount when stream has fully elapsed', async () => { + const fullyClaimed: RawStreamData = { + ...baseStream, + claimedAmount: BigInt(250_000_000), + }; + mockGetStream.mockResolvedValue(fullyClaimed); + + const afterEnd = START_SECS + DURATION_SECS + 1000; + + const result = await service.previewStream({ + beneficiary: baseStream.beneficiary, + atTime: afterEnd, + }); + + expect(result.unlockedAmount).toBe('750000000'); + expect(result.remainingAmount).toBe('750000000'); + expect(result.isActive).toBe(false); + }); + + it('returns zero unlocked before stream starts', async () => { + mockGetStream.mockResolvedValue(baseStream); + + const beforeStart = START_SECS - 1; + + const result = await service.previewStream({ + beneficiary: baseStream.beneficiary, + atTime: beforeStart, + }); + + expect(result.unlockedAmount).toBe('0'); + expect(result.isActive).toBe(false); + }); + + it('defaults previewAt to current server time when atTime is omitted', async () => { + mockGetStream.mockResolvedValue(baseStream); + + const before = Math.floor(Date.now() / 1000); + const result = await service.previewStream({ + beneficiary: baseStream.beneficiary, + }); + const after = Math.floor(Date.now() / 1000); + + expect(result.previewAt).toBeGreaterThanOrEqual(before); + expect(result.previewAt).toBeLessThanOrEqual(after); + }); + + it('throws TreasuryStreamNotFoundException when stream is not found', async () => { + mockGetStream.mockResolvedValue(null); + + await expect( + service.previewStream({ beneficiary: 'GNOBODY' }), + ).rejects.toThrow(TreasuryStreamNotFoundException); + }); + + it('matches contract math: unlocked = (total * elapsed) / duration - claimed', async () => { + const partial: RawStreamData = { + ...baseStream, + claimedAmount: BigInt(100_000_000), + }; + mockGetStream.mockResolvedValue(partial); + + // 25% through the stream + const quarterPoint = START_SECS + DURATION_SECS / 4; + + const result = await service.previewStream({ + beneficiary: baseStream.beneficiary, + atTime: quarterPoint, + }); + + // (1_000_000_000 * 25%) - 100_000_000 = 250_000_000 - 100_000_000 = 150_000_000 + expect(result.unlockedAmount).toBe('150000000'); + expect(result.remainingAmount).toBe('900000000'); + }); +}); diff --git a/apps/backend/src/treasury/treasury.controller.ts b/apps/backend/src/treasury/treasury.controller.ts index bddd3516..8fbe4002 100644 --- a/apps/backend/src/treasury/treasury.controller.ts +++ b/apps/backend/src/treasury/treasury.controller.ts @@ -6,6 +6,7 @@ import { HttpStatus, Param, Post, + Query, UseGuards, } from '@nestjs/common'; import { @@ -25,6 +26,7 @@ import { StreamStateDto, } from './dto/stream-response.dto'; import { RotateBeneficiaryDto } from './dto/rotate-beneficiary.dto'; +import { StreamPreviewDto, StreamPreviewResponseDto } from './dto/stream-preview.dto'; import { TreasuryService } from './treasury.service'; @ApiTags('treasury') @@ -123,4 +125,31 @@ export class TreasuryController { ): Promise { return this.treasuryService.rotateBeneficiary(dto); } + + @Get('streams/preview') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Preview unlocked treasury stream amounts for a beneficiary', + description: + 'Read-only endpoint that computes unlocked, claimed, and remaining ' + + 'stream amounts using the same linear-vesting formula as the on-chain ' + + 'contract. No transaction is submitted. Useful for dashboards and ' + + 'debugging tools.', + }) + @ApiResponse({ + status: 200, + description: 'Stream preview calculated successfully', + type: StreamPreviewResponseDto, + }) + @ApiResponse({ status: 400, description: 'Invalid beneficiary address or atTime' }) + @ApiResponse({ status: 404, description: 'No stream found for beneficiary' }) + @ApiResponse({ + status: 503, + description: 'Treasury not configured / RPC down', + }) + async previewStream( + @Query() dto: StreamPreviewDto, + ): Promise { + return this.treasuryService.previewStream(dto); + } } diff --git a/apps/backend/src/treasury/treasury.service.ts b/apps/backend/src/treasury/treasury.service.ts index a65bfe37..617790cb 100644 --- a/apps/backend/src/treasury/treasury.service.ts +++ b/apps/backend/src/treasury/treasury.service.ts @@ -4,6 +4,7 @@ import { AllocateBudgetResponseDto, StreamStateDto, } from './dto/stream-response.dto'; +import { StreamPreviewDto, StreamPreviewResponseDto } from './dto/stream-preview.dto'; import { RotateBeneficiaryDto } from './dto/rotate-beneficiary.dto'; import { TreasuryStreamNotFoundException } from './exceptions/treasury.exceptions'; import { TreasurySorobanClient } from './treasury-soroban.client'; @@ -64,6 +65,49 @@ export class TreasuryService { return this.toStreamStateDto(stream); } + /** + * Read-only preview: compute unlocked, claimed, and remaining amounts for a + * beneficiary at a given time without submitting any transaction. + * + * Uses the same linear-vesting formula as the on-chain contract so the result + * matches what a claim call would see at `atTime`. + * + * @throws TreasuryStreamNotFoundException when no stream exists for the beneficiary. + */ + async previewStream(dto: StreamPreviewDto): Promise { + const { beneficiary, atTime } = dto; + const previewAt = atTime ?? Math.floor(Date.now() / 1000); + + this.logger.log( + `Previewing stream for ${beneficiary} at t=${previewAt}`, + ); + + const stream = await this.sorobanClient.getStream(beneficiary); + if (!stream) { + throw new TreasuryStreamNotFoundException(beneficiary); + } + + const now = BigInt(previewAt); + const unlockedAmount = calculateUnlocked(now, stream); + const remainingAmount = stream.totalAmount - stream.claimedAmount; + + const streamStart = Number(stream.startTime); + const streamEnd = streamStart + Number(stream.duration); + const isActive = previewAt >= streamStart && previewAt < streamEnd; + + return { + beneficiary: stream.beneficiary, + totalAmount: stream.totalAmount.toString(), + claimedAmount: stream.claimedAmount.toString(), + unlockedAmount: unlockedAmount.toString(), + remainingAmount: remainingAmount.toString(), + startTime: streamStart, + duration: Number(stream.duration), + previewAt, + isActive, + }; + } + /** * Admin flow: rotate beneficiary for a treasury stream, preserving accrued * claim state. From 6d626e296e61ad8cdd63748e07ed65725cdb17dd Mon Sep 17 00:00:00 2001 From: Gutopro Date: Mon, 29 Jun 2026 14:23:38 +0100 Subject: [PATCH 2/3] fix: prefix unused NOW_SECS with underscore to satisfy no-unused-vars lint rule --- apps/backend/src/treasury/treasury-preview.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/treasury/treasury-preview.service.spec.ts b/apps/backend/src/treasury/treasury-preview.service.spec.ts index ba543b14..7c7f4e00 100644 --- a/apps/backend/src/treasury/treasury-preview.service.spec.ts +++ b/apps/backend/src/treasury/treasury-preview.service.spec.ts @@ -6,7 +6,7 @@ import type { RawStreamData } from './treasury-stream.util'; // ── Fixtures ────────────────────────────────────────────────────────────────── -const NOW_SECS = 1_735_776_000; +const _NOW_SECS = 1_735_776_000; const START_SECS = 1_735_689_600; const DURATION_SECS = 2_592_000; // 30 days From 78ac693b02578e423ac9fa1d2ff21e67fd06af64 Mon Sep 17 00:00:00 2001 From: Gutopro Date: Mon, 29 Jun 2026 22:03:26 +0100 Subject: [PATCH 3/3] fix: remove unused _NOW_SECS variable to fix CI lint error --- apps/backend/src/treasury/treasury-preview.service.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/backend/src/treasury/treasury-preview.service.spec.ts b/apps/backend/src/treasury/treasury-preview.service.spec.ts index 7c7f4e00..58bde4ad 100644 --- a/apps/backend/src/treasury/treasury-preview.service.spec.ts +++ b/apps/backend/src/treasury/treasury-preview.service.spec.ts @@ -6,7 +6,6 @@ import type { RawStreamData } from './treasury-stream.util'; // ── Fixtures ────────────────────────────────────────────────────────────────── -const _NOW_SECS = 1_735_776_000; const START_SECS = 1_735_689_600; const DURATION_SECS = 2_592_000; // 30 days