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
94 changes: 94 additions & 0 deletions apps/backend/src/treasury/dto/stream-preview.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
141 changes: 141 additions & 0 deletions apps/backend/src/treasury/treasury-preview.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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 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>(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');
});
});
29 changes: 29 additions & 0 deletions apps/backend/src/treasury/treasury.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
HttpStatus,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
Expand All @@ -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')
Expand Down Expand Up @@ -126,4 +128,31 @@ export class TreasuryController {
): Promise<AllocateBudgetResponseDto> {
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<StreamPreviewResponseDto> {
return this.treasuryService.previewStream(dto);
}
}
44 changes: 44 additions & 0 deletions apps/backend/src/treasury/treasury.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<StreamPreviewResponseDto> {
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.
Expand Down
Loading