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
1 change: 1 addition & 0 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const envSchema = z.object({
.pipe(z.number().int().min(5).max(60)),
ANCHOR_PUBLIC_KEY: z.string().optional(), // For SEP-10 challenges
ANCHOR_SECRET_KEY: z.string().optional(), // For SEP-10 challenges
REGISTRY_CONTRACT_ID: z.string().optional(), // Registry contract address
SEP12_MAX_FILE_SIZE_MB: z
.string()
.default('20')
Expand Down
219 changes: 219 additions & 0 deletions backend/src/services/registry.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { RegistryService } from './registry.service';
import { AdvancedCacheService } from './advanced-cache.service';
import { stellarService } from './stellar.service';
import { redisService } from './redis.service';

// Mock dependencies
jest.mock('./advanced-cache.service');
jest.mock('./stellar.service');
jest.mock('./redis.service', () => ({
redisService: {
client: {},
},
}));

const mockCacheService = AdvancedCacheService as jest.MockedClass<typeof AdvancedCacheService>;
const mockStellarService = stellarService as jest.Mocked<typeof stellarService>;

describe('RegistryService', () => {
let registryService: RegistryService;
let mockCache: jest.Mocked<AdvancedCacheService>;

beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();

// Create mock cache service
mockCache = {
cacheAside: jest.fn(),
invalidate: jest.fn(),
invalidatePattern: jest.fn(),
} as any;

mockCacheService.mockImplementation(() => mockCache);

// Get instance of RegistryService
registryService = RegistryService.getInstance();
});

describe('getContract', () => {
it('should return cached contract info when available', async () => {
const mockContractInfo = {
address: 'GABC123',
version: '1.0.0',
contractType: 'AMM',
deployedAt: 1234567890,
active: true,
previousVersion: null,
};

mockCache.cacheAside.mockResolvedValue({
data: mockContractInfo,
fromCache: true,
});

const result = await registryService.getContract('AMM');

expect(result).toEqual(mockContractInfo);
expect(mockCache.cacheAside).toHaveBeenCalledWith(
'registry:contract:AMM',
expect.any(Function),
expect.any(Object)
);
});

it('should fetch fresh data when cache miss', async () => {
const mockContractInfo = {
address: 'GDEF456',
version: '2.0.0',
contractType: 'Lending',
deployedAt: 9876543210,
active: true,
previousVersion: 'GABC123',
};

mockCache.cacheAside.mockResolvedValue({
data: mockContractInfo,
fromCache: false,
});

const result = await registryService.getContract('Lending');

expect(result).toEqual(mockContractInfo);
});
});

describe('getAddress', () => {
it('should return contract address from cached info', async () => {
const mockAddress = 'GADDRESS123';
const mockContractInfo = {
address: mockAddress,
version: '1.0.0',
contractType: 'Bridge',
deployedAt: 1234567890,
active: true,
previousVersion: null,
};

mockCache.cacheAside.mockResolvedValue({
data: mockContractInfo,
fromCache: true,
});

const result = await registryService.getAddress('Bridge');

expect(result).toBe(mockAddress);
});
});

describe('getVersion', () => {
it('should return contract version from cached info', async () => {
const mockVersion = '3.1.4';
const mockContractInfo = {
address: 'GVER123',
version: mockVersion,
contractType: 'XLMWrapper',
deployedAt: 1234567890,
active: true,
previousVersion: null,
};

mockCache.cacheAside.mockResolvedValue({
data: mockContractInfo,
fromCache: true,
});

const result = await registryService.getVersion('XLMWrapper');

expect(result).toBe(mockVersion);
});
});

describe('isRegistered', () => {
it('should return true when contract is registered', async () => {
const mockContractInfo = {
address: 'GREG123',
version: '1.0.0',
contractType: 'Governance',
deployedAt: 1234567890,
active: true,
previousVersion: null,
};

mockCache.cacheAside.mockResolvedValue({
data: mockContractInfo,
fromCache: true,
});

const result = await registryService.isRegistered('Governance');

expect(result).toBe(true);
});

it('should return false when contract is not registered', async () => {
mockCache.cacheAside.mockRejectedValue(new Error('Contract not found'));

const result = await registryService.isRegistered('NonExistent');

expect(result).toBe(false);
});
});

describe('isActive', () => {
it('should return true when contract is active', async () => {
const mockContractInfo = {
address: 'GACT123',
version: '1.0.0',
contractType: 'ActiveContract',
deployedAt: 1234567890,
active: true,
previousVersion: null,
};

mockCache.cacheAside.mockResolvedValue({
data: mockContractInfo,
fromCache: true,
});

const result = await registryService.isActive('ActiveContract');

expect(result).toBe(true);
});

it('should return false when contract is inactive', async () => {
const mockContractInfo = {
address: 'GINACT123',
version: '1.0.0',
contractType: 'InactiveContract',
deployedAt: 1234567890,
active: false,
previousVersion: null,
};

mockCache.cacheAside.mockResolvedValue({
data: mockContractInfo,
fromCache: true,
});

const result = await registryService.isActive('InactiveContract');

expect(result).toBe(false);
});
});

describe('invalidateContractCache', () => {
it('should invalidate cache for a specific contract type', async () => {
await registryService.invalidateContractCache('AMM');

expect(mockCache.invalidate).toHaveBeenCalledWith('registry:contract:AMM');
});
});

describe('invalidateAllCache', () => {
it('should invalidate all registry cache', async () => {
await registryService.invalidateAllCache();

expect(mockCache.invalidatePattern).toHaveBeenCalledWith('registry:.*');
});
});
});
167 changes: 167 additions & 0 deletions backend/src/services/registry.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { Address, Contract, Network, SorobanRpc, xdr } from '@stellar/stellar-sdk';
import { config } from '../config/env';
import { configService } from './config.service';
import { redisService } from './redis.service';
import { AdvancedCacheService } from './advanced-cache.service';
import { stellarService } from './stellar.service';
import logger from '../utils/logger';

export interface ContractInfo {
address: string;
version: string;
contractType: string;
deployedAt: number;
active: boolean;
previousVersion: string | null;
}

export class RegistryService {
private static instance: RegistryService;
private cacheService: AdvancedCacheService;
private registryContractId: string;

private constructor() {
this.registryContractId = config.REGISTRY_CONTRACT_ID || '';
this.cacheService = new AdvancedCacheService(redisService.client, {
l1MaxSize: 100,
l1TtlSeconds: 60,
l2TtlSeconds: 300,
staleWhileRevalidateTtlSeconds: 60,
});
}

public static getInstance(): RegistryService {
if (!RegistryService.instance) {
RegistryService.instance = new RegistryService();
}
return RegistryService.instance;
}

private getContractClient(): Contract {
const rpc = stellarService.getSorobanRpc();
return new Contract(this.registryContractId);
}

/**
* Get contract information by type with caching
*/
public async getContract(contractType: string): Promise<ContractInfo> {
const cacheKey = `registry:contract:${contractType}`;

const result = await this.cacheService.cacheAside<ContractInfo>(
cacheKey,
async () => {
logger.debug(`Fetching contract info from registry for type: ${contractType}`);
const rpc = stellarService.getSorobanRpc();
const contract = this.getContractClient();

// Build the contract call
const tx = new xdr.TransactionBuilder(
new Address(config.ANCHOR_PUBLIC_KEY).toScAddress(),
{
fee: '100',
networkPassphrase: stellarService.getPassphrase(),
}
)
.addOperation(
contract.call('get_contract', xdr.ScVal.scvString(contractType))
)
.setTimeout(30)
.build();

const simulatedTx = await rpc.simulateTransaction(tx);
if (simulatedTx.error) {
throw new Error(`Failed to simulate transaction: ${simulatedTx.error}`);
}

if (!simulatedTx.result?.retval) {
throw new Error('No result returned from contract');
}

return this.parseContractInfo(simulatedTx.result.retval);
},
{
ttlSeconds: 60,
staleWhileRevalidate: true,
staleTtlSeconds: 30,
}
);

return result.data;
}

/**
* Get contract address by type with caching
*/
public async getAddress(contractType: string): Promise<string> {
const info = await this.getContract(contractType);
return info.address;
}

/**
* Get contract version by type with caching
*/
public async getVersion(contractType: string): Promise<string> {
const info = await this.getContract(contractType);
return info.version;
}

/**
* Check if contract is registered (cached)
*/
public async isRegistered(contractType: string): Promise<boolean> {
try {
await this.getContract(contractType);
return true;
} catch {
return false;
}
}

/**
* Check if contract is active (cached)
*/
public async isActive(contractType: string): Promise<boolean> {
const info = await this.getContract(contractType);
return info.active;
}

/**
* Invalidate cache for a specific contract type
*/
public async invalidateContractCache(contractType: string): Promise<void> {
const cacheKey = `registry:contract:${contractType}`;
await this.cacheService.invalidate(cacheKey);
logger.debug(`Invalidated cache for contract type: ${contractType}`);
}

/**
* Invalidate all registry cache
*/
public async invalidateAllCache(): Promise<void> {
await this.cacheService.invalidatePattern('registry:.*');
logger.debug('Invalidated all registry cache');
}

private parseContractInfo(scVal: xdr.ScVal): ContractInfo {
const map = scVal.map()!;
const get = (key: string) => {
const entry = map.find((e) => e.key().str() === key);
if (!entry) throw new Error(`Missing key: ${key}`);
return entry.val();
};

return {
address: get('address').address().toString(),
version: get('version').str().toString(),
contractType: get('contract_type').str().toString(),
deployedAt: Number(get('deployed_at').u64()),
active: get('active').bool(),
previousVersion: get('previous_version').option()
? get('previous_version').option()!.address().toString()
: null,
};
}
}

export const registryService = RegistryService.getInstance();
Loading