diff --git a/app/backend/src/contracts/contract-registry.controller.ts b/app/backend/src/contracts/contract-registry.controller.ts index 87efcc3b..fec8c868 100644 --- a/app/backend/src/contracts/contract-registry.controller.ts +++ b/app/backend/src/contracts/contract-registry.controller.ts @@ -4,7 +4,9 @@ import { Get, HttpCode, HttpStatus, + Param, Post, + Put, Req, Res, UseGuards, @@ -17,10 +19,13 @@ import { RequireScopes } from '../auth/decorators/require-scopes.decorator'; import { RateLimitGroupTag } from '../auth/decorators/rate-limit-group.decorator'; import { ContractRegistryService } from './contract-registry.service'; import { + ContractDeploymentItemDto, + ContractDeploymentsResponseDto, ContractRegistryResponseDto, PublishContractRegistryDto, RollbackContractRegistryDto, -} from './dto/contract-registry.dto'; + UpsertContractDeploymentDto, +} from './dto'; @ApiTags('contracts') @ApiHeader({ @@ -34,6 +39,25 @@ import { export class ContractRegistryController { constructor(private readonly contractRegistryService: ContractRegistryService) {} + @Get('registry/deployments') + @ApiOperation({ + summary: 'List active contract deployments for the current network', + }) + @ApiResponse({ status: 200, type: ContractDeploymentsResponseDto }) + getDeployments() { + return this.contractRegistryService.getDeployments(); + } + + @Get('registry/deployments/:name') + @ApiOperation({ + summary: 'Get active deployment metadata for a contract name', + }) + @ApiResponse({ status: 200, type: ContractDeploymentItemDto }) + @ApiResponse({ status: 404, description: 'Deployment entry not found for contract name' }) + getDeploymentByName(@Param('name') name: string) { + return this.contractRegistryService.getDeploymentByName(name); + } + @Get('registry') @ApiOperation({ summary: 'Fetch the authoritative contract registry for the active network', @@ -63,8 +87,32 @@ export class ContractRegistryController { @ApiOperation({ summary: 'Publish deployment artifacts into the contract registry', }) - publish(@Body() body: PublishContractRegistryDto) { - return this.contractRegistryService.publish(body, 'api'); + publish(@Body() body: PublishContractRegistryDto, @Req() req: Request) { + const actor = req.apiKey?.id ?? 'api'; + return this.contractRegistryService.publish(body, actor); + } + + @Put('registry/deployments/:name') + @HttpCode(HttpStatus.OK) + @RequireScopes('admin') + @RateLimitGroupTag('authenticated') + @ApiOperation({ + summary: 'Upsert deployment metadata for one contract (admin only)', + }) + @ApiResponse({ status: 200, type: ContractDeploymentItemDto }) + upsertDeployment( + @Param('name') name: string, + @Body() body: UpsertContractDeploymentDto, + @Req() req: Request, + ) { + const actor = req.apiKey?.id ?? 'api'; + return this.contractRegistryService.upsertDeployment( + { + ...body, + name, + }, + actor, + ); } @Post('registry/rollback') @@ -74,7 +122,8 @@ export class ContractRegistryController { @ApiOperation({ summary: 'Rollback the active registry entry for a contract to a previous version', }) - rollback(@Body() body: RollbackContractRegistryDto) { - return this.contractRegistryService.rollback(body, 'api'); + rollback(@Body() body: RollbackContractRegistryDto, @Req() req: Request) { + const actor = req.apiKey?.id ?? 'api'; + return this.contractRegistryService.rollback(body, actor); } } diff --git a/app/backend/src/contracts/contract-registry.service.ts b/app/backend/src/contracts/contract-registry.service.ts index e9406e67..bd055c7c 100644 --- a/app/backend/src/contracts/contract-registry.service.ts +++ b/app/backend/src/contracts/contract-registry.service.ts @@ -11,9 +11,11 @@ import { AppConfigService } from '../config'; import { SupabaseService } from '../supabase/supabase.service'; import { ContractRegistryEntryDto, + ContractSchemaCompatibilityDto, PublishContractRegistryDto, RollbackContractRegistryDto, -} from './dto/contract-registry.dto'; + UpsertContractDeploymentDto, +} from './dto'; import { ContractRegistryPublishedEvent, ContractRegistryRolledBackEvent, @@ -36,7 +38,10 @@ interface RegistryRecord { effectiveTime?: string; wasmHash: string; contractVersion: number; + schemaVersion: string; + schemaCompatibility: ContractSchemaCompatibilityDto; deploymentId?: string; + initParams?: Record; metadata?: Record; publishedBy: string; version: number; @@ -77,7 +82,11 @@ export class ContractRegistryService { id: record.contractId, wasmHash: record.wasmHash, version: record.contractVersion, + schemaVersion: record.schemaVersion, + schemaCompatibility: record.schemaCompatibility, + networkPassphrase: record.networkPassphrase, deploymentId: record.deploymentId, + initParams: record.initParams ?? {}, updatedAt: record.updatedAt, metadata: record.metadata ?? {}, }, @@ -98,6 +107,122 @@ export class ContractRegistryService { }; } + async getDeployments() { + const records = await this.readRecords(); + const deployments = records + .filter((record) => record.active) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((record) => this.toDeploymentItem(record)); + + return { + network: this.configService.network, + deployments, + }; + } + + async getDeploymentByName(name: string) { + const records = await this.readRecords(); + const normalizedName = name.trim().toLowerCase(); + const record = records.find( + (candidate) => candidate.active && candidate.name === normalizedName, + ); + + if (!record) { + throw new NotFoundException( + `No active deployment metadata found for contract ${name}`, + ); + } + + return this.toDeploymentItem(record); + } + + async upsertDeployment( + dto: UpsertContractDeploymentDto, + actor = 'deployment_automation', + ) { + if (dto.network !== this.configService.network) { + throw new BadRequestException( + `network must match active backend network (${this.configService.network})`, + ); + } + + this.validatePassphrase(dto.networkPassphrase); + const normalizedName = dto.name.trim().toLowerCase(); + const now = new Date().toISOString(); + const records = await this.readRecords(); + + const currentActive = records.find( + (record) => record.active && record.name === normalizedName, + ); + + const nextVersion = + records.reduce((max, record) => Math.max(max, record.version), this.fallbackVersion) + + 1; + + const retained = records.map((record) => { + if (record.name !== normalizedName) return record; + return { ...record, active: false, updatedAt: now }; + }); + + const nextRecord: RegistryRecord = { + name: normalizedName, + network: dto.network, + contractId: dto.contractId, + previousContractId: undefined, + effectiveLedger: undefined, + effectiveTime: undefined, + wasmHash: dto.wasmHash, + contractVersion: dto.contractVersion ?? (currentActive?.contractVersion ?? 1), + schemaVersion: dto.schemaVersion, + schemaCompatibility: dto.schemaCompatibility, + deploymentId: dto.deploymentId, + initParams: dto.initParams, + metadata: dto.metadata, + publishedBy: actor, + version: nextVersion, + createdAt: now, + updatedAt: now, + networkPassphrase: dto.networkPassphrase, + active: true, + }; + + const updated = [...retained, nextRecord]; + this.fallbackVersion = nextVersion; + this.writeFallback(updated); + await this.persistSnapshot(updated); + + await this.auditService.log( + 'contract_registry', + 'registry.deployment.upsert', + normalizedName, + { + actor, + network: dto.network, + registryVersion: nextVersion, + before: currentActive + ? { + contractId: currentActive.contractId, + wasmHash: currentActive.wasmHash, + contractVersion: currentActive.contractVersion, + schemaVersion: currentActive.schemaVersion, + schemaCompatibility: currentActive.schemaCompatibility, + networkPassphrase: currentActive.networkPassphrase, + } + : null, + after: { + contractId: nextRecord.contractId, + wasmHash: nextRecord.wasmHash, + contractVersion: nextRecord.contractVersion, + schemaVersion: nextRecord.schemaVersion, + schemaCompatibility: nextRecord.schemaCompatibility, + networkPassphrase: nextRecord.networkPassphrase, + }, + }, + ); + + return this.toDeploymentItem(nextRecord); + } + async publish( dto: PublishContractRegistryDto, actor = 'deployment_automation', @@ -349,7 +474,10 @@ export class ContractRegistryService { contractId: contract.contractId, wasmHash: contract.wasmHash, contractVersion: contract.contractVersion ?? 1, + schemaVersion: contract.schemaVersion ?? '1.0.0', + schemaCompatibility: contract.schemaCompatibility ?? { min: '1.0.0', max: '1.0.0' }, deploymentId: dto.deploymentId, + initParams: contract.initParams, metadata: contract.metadata, publishedBy: actor, version, @@ -395,7 +523,13 @@ export class ContractRegistryService { effectiveTime: row.effective_time ? String(row.effective_time) : undefined, wasmHash: String(row.wasm_hash), contractVersion: Number(row.contract_version), + schemaVersion: String(row.schema_version ?? '1.0.0'), + schemaCompatibility: this.readSchemaCompatibility(row), deploymentId: row.deployment_id ? String(row.deployment_id) : undefined, + initParams: + row.init_params && typeof row.init_params === 'object' + ? (row.init_params as Record) + : undefined, metadata: row.metadata && typeof row.metadata === 'object' ? (row.metadata as Record) @@ -429,7 +563,10 @@ export class ContractRegistryService { effective_time: record.effectiveTime ?? null, wasm_hash: record.wasmHash, contract_version: record.contractVersion, + schema_version: record.schemaVersion, + schema_compatibility: record.schemaCompatibility, deployment_id: record.deploymentId ?? null, + init_params: record.initParams ?? {}, metadata: record.metadata ?? {}, published_by: record.publishedBy, version: record.version, @@ -447,4 +584,34 @@ export class ContractRegistryService { ); } } + + private toDeploymentItem(record: RegistryRecord) { + return { + name: record.name, + network: record.network, + networkPassphrase: record.networkPassphrase, + contractId: record.contractId, + wasmHash: record.wasmHash, + contractVersion: record.contractVersion, + schemaVersion: record.schemaVersion, + schemaCompatibility: record.schemaCompatibility, + initParams: record.initParams ?? {}, + metadata: record.metadata ?? {}, + updatedAt: record.updatedAt, + registryVersion: record.version, + deploymentId: record.deploymentId, + }; + } + + private readSchemaCompatibility(row: Record): ContractSchemaCompatibilityDto { + const fallback: ContractSchemaCompatibilityDto = { min: '1.0.0', max: '1.0.0' }; + + const raw = row.schema_compatibility; + if (!raw || typeof raw !== 'object') return fallback; + + const candidate = raw as Record; + const min = typeof candidate.min === 'string' ? candidate.min : fallback.min; + const max = typeof candidate.max === 'string' ? candidate.max : fallback.max; + return { min, max }; + } } diff --git a/app/backend/src/contracts/contract-registry.service.unit.spec.ts b/app/backend/src/contracts/contract-registry.service.unit.spec.ts index 4ef0e153..0dce4396 100644 --- a/app/backend/src/contracts/contract-registry.service.unit.spec.ts +++ b/app/backend/src/contracts/contract-registry.service.unit.spec.ts @@ -146,6 +146,62 @@ describe('ContractRegistryService', () => { ); }); + it('upserts a single deployment and exposes it through deployment reads', async () => { + const upserted = await service.upsertDeployment({ + name: 'quickex', + network: 'testnet', + networkPassphrase: 'Test SDF Network ; September 2015', + contractId: 'C777', + wasmHash: '0x777', + contractVersion: 7, + schemaVersion: '1.2.0', + schemaCompatibility: { min: '1.0.0', max: '2.0.0' }, + initParams: { admin: 'GADMIN' }, + metadata: { source: 'admin-upsert' }, + deploymentId: 'deploy-777', + }); + + expect(upserted).toEqual( + expect.objectContaining({ + name: 'quickex', + contractId: 'C777', + wasmHash: '0x777', + schemaVersion: '1.2.0', + }), + ); + + const byName = await service.getDeploymentByName('quickex'); + expect(byName.contractId).toBe('C777'); + + const all = await service.getDeployments(); + expect(all.deployments).toHaveLength(1); + expect(all.deployments[0]?.schemaCompatibility).toEqual({ + min: '1.0.0', + max: '2.0.0', + }); + + expect(mockAuditService.log).toHaveBeenCalledWith( + 'contract_registry', + 'registry.deployment.upsert', + 'quickex', + expect.any(Object), + ); + }); + + it('rejects upsert when request network does not match active backend network', async () => { + await expect( + service.upsertDeployment({ + name: 'quickex', + network: 'mainnet', + networkPassphrase: 'Public Global Stellar Network ; September 2015', + contractId: 'C111', + wasmHash: '0x111', + schemaVersion: '1.0.0', + schemaCompatibility: { min: '1.0.0', max: '1.0.0' }, + }), + ).rejects.toThrow(BadRequestException); + }); + describe('Dual-read finalization', () => { it('finalizes dual-read by clearing previousContractId', async () => { // Setup: publish with dual-read config diff --git a/app/backend/src/contracts/dto/contract-registry.dto.ts b/app/backend/src/contracts/dto/contract-registry.dto.ts index a406785c..04eadeea 100644 --- a/app/backend/src/contracts/dto/contract-registry.dto.ts +++ b/app/backend/src/contracts/dto/contract-registry.dto.ts @@ -1,9 +1,11 @@ +import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ArrayMaxSize, ArrayMinSize, IsArray, IsBoolean, + IsIn, IsInt, IsISO8601, IsNotEmpty, @@ -15,7 +17,20 @@ import { Min, ValidateNested, } from 'class-validator'; -import { Type } from 'class-transformer'; + +export class ContractSchemaCompatibilityDto { + @ApiProperty({ example: '1.0.0' }) + @IsString() + @IsNotEmpty() + @Matches(/^\d+\.\d+\.\d+$/) + min: string; + + @ApiProperty({ example: '2.0.0' }) + @IsString() + @IsNotEmpty() + @Matches(/^\d+\.\d+\.\d+$/) + max: string; +} export class ContractRegistryEntryDto { @ApiProperty({ example: 'quickex' }) @@ -54,7 +69,7 @@ export class ContractRegistryEntryDto { @IsISO8601() effectiveTime?: string; - @ApiProperty({ example: 'abcdef1234567890' }) + @ApiProperty({ example: '0xabcdef1234567890' }) @IsString() @IsNotEmpty() wasmHash: string; @@ -66,6 +81,23 @@ export class ContractRegistryEntryDto { @Max(100_000) contractVersion?: number; + @ApiPropertyOptional({ example: '1.2.0', description: 'Contract schema version' }) + @IsOptional() + @IsString() + @Matches(/^\d+\.\d+\.\d+$/) + schemaVersion?: string; + + @ApiPropertyOptional({ type: ContractSchemaCompatibilityDto }) + @IsOptional() + @ValidateNested() + @Type(() => ContractSchemaCompatibilityDto) + schemaCompatibility?: ContractSchemaCompatibilityDto; + + @ApiPropertyOptional({ example: { admin: 'G...' }, description: 'Deployment init params' }) + @IsOptional() + @IsObject() + initParams?: Record; + @ApiPropertyOptional({ example: { source: 'testnet-deploy' } }) @IsOptional() @IsObject() @@ -92,6 +124,67 @@ export class PublishContractRegistryDto { contracts: ContractRegistryEntryDto[]; } +export class UpsertContractDeploymentDto { + @ApiProperty({ example: 'quickex' }) + @IsString() + @IsNotEmpty() + @Matches(/^[a-z0-9_-]+$/i) + name: string; + + @ApiProperty({ example: 'testnet', enum: ['testnet', 'mainnet'] }) + @IsString() + @IsIn(['testnet', 'mainnet']) + network: 'testnet' | 'mainnet'; + + @ApiProperty({ example: 'Test SDF Network ; September 2015' }) + @IsString() + @IsNotEmpty() + networkPassphrase: string; + + @ApiProperty({ example: 'CD2J6K7T3YJ77QXZP3EXAMPLE' }) + @IsString() + @IsNotEmpty() + contractId: string; + + @ApiProperty({ example: '0xabcdef1234567890' }) + @IsString() + @IsNotEmpty() + wasmHash: string; + + @ApiPropertyOptional({ example: 1, default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(100_000) + contractVersion?: number; + + @ApiProperty({ example: '1.2.0' }) + @IsString() + @IsNotEmpty() + @Matches(/^\d+\.\d+\.\d+$/) + schemaVersion: string; + + @ApiProperty({ type: ContractSchemaCompatibilityDto }) + @ValidateNested() + @Type(() => ContractSchemaCompatibilityDto) + schemaCompatibility: ContractSchemaCompatibilityDto; + + @ApiPropertyOptional({ example: { admin: 'G...' } }) + @IsOptional() + @IsObject() + initParams?: Record; + + @ApiPropertyOptional({ example: 'deploy-2026-05-30T18:00:00Z' }) + @IsOptional() + @IsString() + deploymentId?: string; + + @ApiPropertyOptional({ example: { source: 'manual-admin-upsert' } }) + @IsOptional() + @IsObject() + metadata?: Record; +} + export class RollbackContractRegistryDto { @ApiProperty({ example: 'quickex' }) @IsString() @@ -122,10 +215,64 @@ export class ContractRegistryResponseDto { example: { quickex: { id: 'CD2J6K7T3YJ77QXZP3EXAMPLE', - wasmHash: 'abcdef1234567890', + wasmHash: '0xabcdef1234567890', version: 1, + schemaVersion: '1.2.0', + schemaCompatibility: { + min: '1.0.0', + max: '2.0.0', + }, }, }, }) data: Record; } + +export class ContractDeploymentItemDto { + @ApiProperty({ example: 'quickex' }) + name: string; + + @ApiProperty({ example: 'testnet' }) + network: string; + + @ApiProperty({ example: 'Test SDF Network ; September 2015' }) + networkPassphrase: string; + + @ApiProperty({ example: 'CD2J6K7T3YJ77QXZP3EXAMPLE' }) + contractId: string; + + @ApiProperty({ example: '0xabcdef1234567890' }) + wasmHash: string; + + @ApiProperty({ example: 3 }) + contractVersion: number; + + @ApiProperty({ example: '1.2.0' }) + schemaVersion: string; + + @ApiProperty({ type: ContractSchemaCompatibilityDto }) + schemaCompatibility: ContractSchemaCompatibilityDto; + + @ApiPropertyOptional({ example: { admin: 'G...' } }) + initParams?: Record; + + @ApiPropertyOptional({ example: { source: 'deploy-script' } }) + metadata?: Record; + + @ApiProperty({ example: '2026-06-02T11:54:30Z' }) + updatedAt: string; + + @ApiProperty({ example: 8 }) + registryVersion: number; + + @ApiPropertyOptional({ example: 'deploy-2026-06-02T11:54:30Z' }) + deploymentId?: string; +} + +export class ContractDeploymentsResponseDto { + @ApiProperty({ example: 'testnet' }) + network: string; + + @ApiProperty({ type: [ContractDeploymentItemDto] }) + deployments: ContractDeploymentItemDto[]; +} diff --git a/app/backend/src/contracts/dto/index.ts b/app/backend/src/contracts/dto/index.ts new file mode 100644 index 00000000..98cb75ef --- /dev/null +++ b/app/backend/src/contracts/dto/index.ts @@ -0,0 +1 @@ +export * from './contract-registry.dto'; diff --git a/app/backend/src/ingestion/__tests__/soroban-event-indexer.service.unit.spec.ts b/app/backend/src/ingestion/__tests__/soroban-event-indexer.service.unit.spec.ts index 548df9c1..b9f017c6 100644 --- a/app/backend/src/ingestion/__tests__/soroban-event-indexer.service.unit.spec.ts +++ b/app/backend/src/ingestion/__tests__/soroban-event-indexer.service.unit.spec.ts @@ -179,7 +179,7 @@ describe("SorobanEventIndexerService", () => { const record = makeEscrowDepositedRaw(100, "100-1"); mockHorizonPage([record]); - const result = await svc.indexLedgerRange(CONTRACT_ID, 100, 200, true); + const result = await svc.indexLedgerRange(CONTRACT_ID, 100, 200, undefined, true); expect(result.processed).toBe(1); expect(mocks.escrowRepo.upsertEvent).toHaveBeenCalledTimes(1); diff --git a/app/backend/src/support-bundle/__tests__/support-bundle.service.unit.spec.ts b/app/backend/src/support-bundle/__tests__/support-bundle.service.unit.spec.ts index 359436c2..d159141d 100644 --- a/app/backend/src/support-bundle/__tests__/support-bundle.service.unit.spec.ts +++ b/app/backend/src/support-bundle/__tests__/support-bundle.service.unit.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SupportBundleService } from '../support-bundle.service'; +import { AppConfigService } from '../../config'; import { ContractRegistryService } from '../../contracts/contract-registry.service'; import { IndexerLagService } from '../../indexer-lag/indexer-lag.service'; import { IndexerCheckpointRepository } from '../../ingestion/indexer-checkpoint.repository'; @@ -32,12 +33,13 @@ describe('SupportBundleService', () => { }; const mockIndexerLagService = { - status: jest.fn().mockResolvedValue({ + getStatus: jest.fn().mockReturnValue({ currentNetworkLedger: 50000000, lastIndexedLedger: 49999500, lagLedgers: 500, isLagging: false, isEnabled: true, + isOverridden: false, thresholdLedgers: 1000, }), }; @@ -133,7 +135,9 @@ describe('SupportBundleService', () => { }); it('should handle missing indexer status gracefully', async () => { - indexerLagService.status.mockRejectedValue(new Error('Indexer status unavailable')); + indexerLagService.getStatus.mockImplementation(() => { + throw new Error('Indexer status unavailable'); + }); const bundle = await service.generateBundle(false); diff --git a/app/backend/supabase/migrations/20260603090000_add_contract_registry_schema_compatibility.sql b/app/backend/supabase/migrations/20260603090000_add_contract_registry_schema_compatibility.sql new file mode 100644 index 00000000..73d88969 --- /dev/null +++ b/app/backend/supabase/migrations/20260603090000_add_contract_registry_schema_compatibility.sql @@ -0,0 +1,7 @@ +alter table if exists public.contract_registry_entries + add column if not exists schema_version text not null default '1.0.0', + add column if not exists schema_compatibility jsonb not null default '{"min":"1.0.0","max":"1.0.0"}'::jsonb, + add column if not exists init_params jsonb not null default '{}'::jsonb; + +create index if not exists contract_registry_schema_version_idx + on public.contract_registry_entries (network, contract_name, schema_version, is_active);