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
59 changes: 54 additions & 5 deletions app/backend/src/contracts/contract-registry.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
Get,
HttpCode,
HttpStatus,
Param,
Post,
Put,
Req,
Res,
UseGuards,
Expand All @@ -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({
Expand All @@ -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',
Expand Down Expand Up @@ -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')
Expand All @@ -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);
}
}
169 changes: 168 additions & 1 deletion app/backend/src/contracts/contract-registry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -36,7 +38,10 @@ interface RegistryRecord {
effectiveTime?: string;
wasmHash: string;
contractVersion: number;
schemaVersion: string;
schemaCompatibility: ContractSchemaCompatibilityDto;
deploymentId?: string;
initParams?: Record<string, unknown>;
metadata?: Record<string, unknown>;
publishedBy: string;
version: number;
Expand Down Expand Up @@ -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 ?? {},
},
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>)
: undefined,
metadata:
row.metadata && typeof row.metadata === 'object'
? (row.metadata as Record<string, unknown>)
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, unknown>): 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<string, unknown>;
const min = typeof candidate.min === 'string' ? candidate.min : fallback.min;
const max = typeof candidate.max === 'string' ? candidate.max : fallback.max;
return { min, max };
}
}
56 changes: 56 additions & 0 deletions app/backend/src/contracts/contract-registry.service.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading