diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..20a491c5 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir -p /home/feyishola/Desktop/mydev/opensource/starkpulse-frontend/apps/backend/src/contributor-registry/dto)" + ], + "additionalDirectories": [ + "/home/feyishola/Desktop/mydev/opensource/starkpulse-frontend/apps/backend/src/contributor-registry" + ] + } +} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 01bd26d2..e58483ca 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -54,6 +54,7 @@ import { SignalsModule } from './signals/signals.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { AppConfigModule } from './config/config.module'; import { CrowdfundModule } from './crowdfund/crowdfund.module'; +import { ContributorRegistryModule } from './contributor-registry/contributor-registry.module'; import { AuditModule } from './audit/audit.module'; import { AuditLogInterceptor } from './audit/interceptors/audit-log.interceptor'; import { SorobanEventsModule } from './soroban-events/soroban-events.module'; @@ -123,6 +124,7 @@ import { VestingWalletModule } from './vesting-wallet/vesting-wallet.module'; SearchModule, FeatureFlagsModule, CrowdfundModule, + ContributorRegistryModule, AppConfigModule, AuditModule, SorobanEventsModule, diff --git a/apps/backend/src/common/rate-limit/rate-limit.config.ts b/apps/backend/src/common/rate-limit/rate-limit.config.ts index f86d0e62..aca2cf47 100644 --- a/apps/backend/src/common/rate-limit/rate-limit.config.ts +++ b/apps/backend/src/common/rate-limit/rate-limit.config.ts @@ -309,3 +309,15 @@ export function getAnalyticsReadThrottleOverride() { default: getRateLimitSettings().analyticsRead, }; } + +export function getRegistryReadThrottleOverride() { + return { + default: getRateLimitSettings().crowdfundRead, + }; +} + +export function getRegistryWriteThrottleOverride() { + return { + default: getRateLimitSettings().portfolioWrite, + }; +} diff --git a/apps/backend/src/contributor-registry/contributor-registry.controller.ts b/apps/backend/src/contributor-registry/contributor-registry.controller.ts new file mode 100644 index 00000000..8b226b5a --- /dev/null +++ b/apps/backend/src/contributor-registry/contributor-registry.controller.ts @@ -0,0 +1,144 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { + getRegistryReadThrottleOverride, + getRegistryWriteThrottleOverride, +} from '../common/rate-limit/rate-limit.config'; +import { ContributorRegistryService } from './contributor-registry.service'; +import { + ContributorResponseDto, + NonceResponseDto, + RegisterContributorDto, + RegisterWithSigDto, + RegistrationXdrResponseDto, + ReputationResponseDto, + SubmitResponseDto, +} from './dto/contributor-registry.dto'; + +@ApiTags('contributor-registry') +@Controller('contributor-registry') +export class ContributorRegistryController { + constructor(private readonly svc: ContributorRegistryService) {} + + // ── Registration ────────────────────────────────────────────────────────────── + + @Post('register') + @Throttle(getRegistryWriteThrottleOverride()) + @ApiOperation({ + summary: 'Register a contributor (direct)', + description: + 'In mock mode: immediately stores the contributor and returns a placeholder XDR. ' + + 'In real mode: builds an unsigned Soroban transaction XDR that the contributor ' + + 'must sign with their Stellar wallet and submit independently.', + }) + @ApiResponse({ + status: 201, + description: 'Registration XDR built (or contributor stored in mock mode)', + type: RegistrationXdrResponseDto, + }) + @ApiResponse({ status: 400, description: 'Contract not configured or invalid input' }) + @ApiResponse({ status: 409, description: 'Contributor already registered or handle taken' }) + register(@Body() dto: RegisterContributorDto): Promise { + return this.svc.buildRegistrationXdr(dto); + } + + @Post('register-with-sig') + @Throttle(getRegistryWriteThrottleOverride()) + @ApiOperation({ + summary: 'Gasless contributor registration (testnet signing + submission)', + description: + 'The contributor signs a SorobanAuthorizationEntry off-chain (no XLM required). ' + + 'The server (relayer) builds the transaction, attaches the signed entry, and ' + + 'submits it — paying the network fees. ' + + 'Obtain the current nonce first via GET /contributor-registry/nonce/:address.', + }) + @ApiResponse({ + status: 201, + description: 'Transaction submitted successfully', + type: SubmitResponseDto, + }) + @ApiResponse({ status: 400, description: 'Invalid signed auth entry or contract not configured' }) + @ApiResponse({ status: 409, description: 'Contributor already registered or handle taken' }) + registerWithSig(@Body() dto: RegisterWithSigDto): Promise { + return this.svc.registerWithSignature(dto); + } + + // ── Lookups ─────────────────────────────────────────────────────────────────── + + @Get('wallet/:address') + @Throttle(getRegistryReadThrottleOverride()) + @ApiOperation({ + summary: 'Look up contributor by Stellar wallet address', + description: 'Returns contributor profile. Result is cached for 60 seconds.', + }) + @ApiParam({ name: 'address', example: 'GABC1234...', description: 'Stellar public key (G...)' }) + @ApiResponse({ + status: 200, + description: 'Contributor profile', + type: ContributorResponseDto, + }) + @ApiResponse({ status: 404, description: 'Contributor not found' }) + getByAddress(@Param('address') address: string): Promise { + return this.svc.getContributorByAddress(address); + } + + @Get('github/:handle') + @Throttle(getRegistryReadThrottleOverride()) + @ApiOperation({ + summary: 'Look up contributor by GitHub handle', + description: 'Returns contributor profile. Result is cached for 60 seconds.', + }) + @ApiParam({ name: 'handle', example: 'octocat', description: 'GitHub username' }) + @ApiResponse({ + status: 200, + description: 'Contributor profile', + type: ContributorResponseDto, + }) + @ApiResponse({ status: 404, description: 'Contributor not found' }) + getByGithub(@Param('handle') handle: string): Promise { + return this.svc.getContributorByGithub(handle); + } + + // ── Reputation ──────────────────────────────────────────────────────────────── + + @Get('reputation/:address') + @Throttle(getRegistryReadThrottleOverride()) + @ApiOperation({ + summary: 'Read contributor reputation score and tier', + description: + 'Returns the on-chain reputation score and derived tier. ' + + 'Result is cached for 60 seconds to reduce RPC load.', + }) + @ApiParam({ name: 'address', example: 'GABC1234...', description: 'Stellar public key (G...)' }) + @ApiResponse({ + status: 200, + description: 'Reputation data', + type: ReputationResponseDto, + }) + @ApiResponse({ status: 404, description: 'Contributor not found' }) + getReputation(@Param('address') address: string): Promise { + return this.svc.getReputation(address); + } + + // ── Nonce ───────────────────────────────────────────────────────────────────── + + @Get('nonce/:address') + @Throttle(getRegistryReadThrottleOverride()) + @ApiOperation({ + summary: 'Get registration nonce for off-chain signing', + description: + 'Returns the current per-address nonce required when building the ' + + 'SorobanAuthorizationEntry for register_contributor_with_sig. ' + + 'Cached for 5 seconds only — always fetch immediately before signing.', + }) + @ApiParam({ name: 'address', example: 'GABC1234...', description: 'Stellar public key (G...)' }) + @ApiResponse({ + status: 200, + description: 'Current nonce', + type: NonceResponseDto, + }) + getNonce(@Param('address') address: string): Promise { + return this.svc.getNonce(address); + } +} diff --git a/apps/backend/src/contributor-registry/contributor-registry.module.ts b/apps/backend/src/contributor-registry/contributor-registry.module.ts new file mode 100644 index 00000000..81316c6e --- /dev/null +++ b/apps/backend/src/contributor-registry/contributor-registry.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AppCacheModule } from '../cache/cache.module'; +import { StellarModule } from '../stellar/stellar.module'; +import { ContributorRegistryController } from './contributor-registry.controller'; +import { ContributorRegistryService } from './contributor-registry.service'; + +@Module({ + imports: [AppCacheModule, StellarModule], + controllers: [ContributorRegistryController], + providers: [ContributorRegistryService], + exports: [ContributorRegistryService], +}) +export class ContributorRegistryModule {} diff --git a/apps/backend/src/contributor-registry/contributor-registry.service.ts b/apps/backend/src/contributor-registry/contributor-registry.service.ts new file mode 100644 index 00000000..c7ef98d1 --- /dev/null +++ b/apps/backend/src/contributor-registry/contributor-registry.service.ts @@ -0,0 +1,499 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { + Address, + BASE_FEE, + Contract, + Keypair, + Networks, + TransactionBuilder, + nativeToScVal, + rpc, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; +import { + SorobanRpcError, + SorobanErrorCode, +} from '../stellar/services/soroban-rpc-client.service'; +import { CacheService } from '../cache/cache.service'; +import { config } from '../lib/config'; +import { SorobanRpcClientService } from '../stellar/services/soroban-rpc-client.service'; +import { + ContributorResponseDto, + NonceResponseDto, + RegisterContributorDto, + RegisterWithSigDto, + RegistrationXdrResponseDto, + ReputationResponseDto, + SubmitResponseDto, +} from './dto/contributor-registry.dto'; + +const CONTRIBUTOR_CACHE_TTL = 60_000; // 1 minute — profiles change infrequently +const REPUTATION_CACHE_TTL = 60_000; // 1 minute — can change via on_notify but short enough +const NONCE_CACHE_TTL = 5_000; // 5 seconds — time-sensitive for off-chain signing + +const CACHE_PREFIX_ADDRESS = 'contributor-registry:address'; +const CACHE_PREFIX_GITHUB = 'contributor-registry:github'; +const CACHE_PREFIX_REPUTATION = 'contributor-registry:reputation'; +const CACHE_PREFIX_NONCE = 'contributor-registry:nonce'; + +interface MockContributor { + address: string; + githubHandle: string; + reputationScore: number; + registeredAt: string; +} + +@Injectable() +export class ContributorRegistryService { + private readonly logger = new Logger(ContributorRegistryService.name); + + // In-memory store used when USE_MOCK_TRANSACTIONS=true (default in development) + private readonly mockContributors = new Map(); + private readonly mockGithubIndex = new Map(); // lowercase handle → address + private readonly mockNonces = new Map(); // address → nonce + + constructor( + private readonly cacheService: CacheService, + private readonly sorobanRpcClient: SorobanRpcClientService, + ) {} + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private get useMock(): boolean { + return config.featureFlags.useMockTransactions; + } + + private get contractId(): string | null { + return config.stellar.contracts.contributorRegistry; + } + + private get networkPassphrase(): string { + return config.stellar.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; + } + + private tierFromScore(score: number): string { + if (score >= 100) return 'Core'; + if (score >= 50) return 'Architect'; + if (score >= 10) return 'Builder'; + return 'Novice'; + } + + private requireContractId(): string { + const id = this.contractId; + if (!id) { + throw new BadRequestException( + 'Contributor registry contract address is not configured (STELLAR_CONTRACT_CONTRIBUTOR_REGISTRY)', + ); + } + return id; + } + + private relayerKeypair(): Keypair { + return Keypair.fromSecret(config.stellar.serverSecret.reveal()); + } + + // ── Registration ───────────────────────────────────────────────────────────── + + /** + * Direct registration — requires the contributor's own authorization. + * + * Mock mode: stores the contributor immediately and returns a placeholder XDR. + * Real mode: builds an unsigned Soroban transaction and returns the XDR for + * the contributor to sign with their Stellar wallet before submitting. + */ + async buildRegistrationXdr(dto: RegisterContributorDto): Promise { + if (this.useMock) { + return this.mockRegister(dto); + } + + const contractId = this.requireContractId(); + const account = await this.sorobanRpcClient.getAccount(dto.address); + const contract = new Contract(contractId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'register_contributor', + new Address(dto.address).toScVal(), + nativeToScVal(dto.githubHandle, { type: 'string' }), + ), + ) + .setTimeout(30) + .build(); + + const simulation = await this.sorobanRpcClient.simulateTransaction(tx); + const preparedTx = rpc.assembleTransaction(tx, simulation).build(); + + return { + unsignedXdr: preparedTx.toXDR(), + networkPassphrase: this.networkPassphrase, + }; + } + + /** + * Gasless / meta-transaction registration — server acts as the relayer. + * + * The contributor signs a SorobanAuthorizationEntry off-chain (no XLM needed). + * The server builds the transaction, attaches the signed entry, and submits + * using the relayer account (STELLAR_SERVER_SECRET) which pays the fees. + * + * Mock mode: stores the contributor immediately and returns a mock receipt. + */ + async registerWithSignature(dto: RegisterWithSigDto): Promise { + if (this.useMock) { + return this.mockRegisterWithSig(dto); + } + + const contractId = this.requireContractId(); + const relayer = this.relayerKeypair(); + const relayerAccount = await this.sorobanRpcClient.getAccount(relayer.publicKey()); + const contract = new Contract(contractId); + + // The `signature` parameter is arbitrary bytes attached for auditability; + // the cryptographic proof lives in the SorobanAuthorizationEntry. + const signatureBytes = dto.signatureHex + ? Buffer.from(dto.signatureHex, 'hex') + : Buffer.alloc(64, 0); + + const tx = new TransactionBuilder(relayerAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'register_contributor_with_sig', + nativeToScVal(dto.githubHandle, { type: 'string' }), + new Address(dto.address).toScVal(), + xdr.ScVal.scvBytes(signatureBytes), + ), + ) + .setTimeout(30) + .build(); + + const simulation = await this.sorobanRpcClient.simulateTransaction(tx); + + // Assemble (applies resource fee and simulation-derived soroban data) + const preparedTx = rpc.assembleTransaction(tx, simulation).build(); + + // Replace the simulation's unsigned auth entry with the contributor's signed one + const signedAuthEntry = xdr.SorobanAuthorizationEntry.fromXDR( + dto.signedAuthEntryXdr, + 'base64', + ); + preparedTx + .toEnvelope() + .v1() + .tx() + .operations()[0] + .body() + .invokeHostFunctionOp() + .auth([signedAuthEntry]); + + // Relayer signs to authorize the fee payment + preparedTx.sign(relayer); + + const result = await this.sorobanRpcClient.sendTransaction(preparedTx); + + this.logger.log( + `Gasless registration submitted: address=${dto.address} handle=${dto.githubHandle} hash=${result.hash}`, + ); + + return { + transactionHash: result.hash, + status: result.status === 'PENDING' ? 'PENDING' : 'SUCCESS', + }; + } + + // ── Lookups ─────────────────────────────────────────────────────────────────── + + async getContributorByAddress(address: string): Promise { + return this.cacheService.getOrSet( + `${CACHE_PREFIX_ADDRESS}:${address}`, + () => this.fetchContributorByAddress(address), + CONTRIBUTOR_CACHE_TTL, + ); + } + + async getContributorByGithub(githubHandle: string): Promise { + return this.cacheService.getOrSet( + `${CACHE_PREFIX_GITHUB}:${githubHandle.toLowerCase()}`, + () => this.fetchContributorByGithub(githubHandle), + CONTRIBUTOR_CACHE_TTL, + ); + } + + // ── Reputation ──────────────────────────────────────────────────────────────── + + async getReputation(address: string): Promise { + return this.cacheService.getOrSet( + `${CACHE_PREFIX_REPUTATION}:${address}`, + () => this.fetchReputation(address), + REPUTATION_CACHE_TTL, + ); + } + + // ── Nonce ───────────────────────────────────────────────────────────────────── + + async getNonce(address: string): Promise { + const nonce = await this.cacheService.getOrSet( + `${CACHE_PREFIX_NONCE}:${address}`, + () => this.fetchNonce(address), + NONCE_CACHE_TTL, + ); + return { address, nonce }; + } + + // ── Private mock implementations ────────────────────────────────────────────── + + private mockRegister(dto: RegisterContributorDto): RegistrationXdrResponseDto { + if (this.mockContributors.has(dto.address)) { + throw new ConflictException(`Contributor ${dto.address} is already registered`); + } + if (this.mockGithubIndex.has(dto.githubHandle.toLowerCase())) { + throw new ConflictException(`GitHub handle '${dto.githubHandle}' is already taken`); + } + + this.mockContributors.set(dto.address, { + address: dto.address, + githubHandle: dto.githubHandle, + reputationScore: 0, + registeredAt: new Date().toISOString(), + }); + this.mockGithubIndex.set(dto.githubHandle.toLowerCase(), dto.address); + + this.logger.log(`[mock] Registered contributor address=${dto.address} handle=${dto.githubHandle}`); + + return { + unsignedXdr: 'MOCK_UNSIGNED_XDR', + networkPassphrase: this.networkPassphrase, + }; + } + + private mockRegisterWithSig(dto: RegisterWithSigDto): SubmitResponseDto { + if (this.mockContributors.has(dto.address)) { + throw new ConflictException(`Contributor ${dto.address} is already registered`); + } + if (this.mockGithubIndex.has(dto.githubHandle.toLowerCase())) { + throw new ConflictException(`GitHub handle '${dto.githubHandle}' is already taken`); + } + + const nonce = this.mockNonces.get(dto.address) ?? 0; + this.mockContributors.set(dto.address, { + address: dto.address, + githubHandle: dto.githubHandle, + reputationScore: 0, + registeredAt: new Date().toISOString(), + }); + this.mockGithubIndex.set(dto.githubHandle.toLowerCase(), dto.address); + this.mockNonces.set(dto.address, nonce + 1); + + this.logger.log( + `[mock] Gasless-registered contributor address=${dto.address} handle=${dto.githubHandle}`, + ); + + return { + transactionHash: `mock_tx_${Date.now()}`, + status: 'SUCCESS', + ledger: Math.floor(Math.random() * 1_000_000) + 50_000_000, + }; + } + + // ── Private real-chain fetchers ──────────────────────────────────────────────── + + private async fetchContributorByAddress(address: string): Promise { + if (this.useMock) { + const contributor = this.mockContributors.get(address); + if (!contributor) { + throw new NotFoundException(`Contributor with address ${address} not found`); + } + return { ...contributor, tier: this.tierFromScore(contributor.reputationScore) }; + } + + const contractId = this.requireContractId(); + const relayer = this.relayerKeypair(); + const account = await this.sorobanRpcClient.getAccount(relayer.publicKey()); + const contract = new Contract(contractId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call('get_contributor', new Address(address).toScVal())) + .setTimeout(30) + .build(); + + let simulation: rpc.Api.SimulateTransactionResponse; + try { + simulation = await this.sorobanRpcClient.simulateTransaction(tx); + } catch (err) { + if (err instanceof SorobanRpcError && err.code === SorobanErrorCode.SIMULATION_FAILED) { + throw new NotFoundException(`Contributor with address ${address} not found`); + } + throw err; + } + + if (!simulation.result) { + throw new NotFoundException(`Contributor with address ${address} not found`); + } + + return this.parseContributorData(simulation.result.retval); + } + + private async fetchContributorByGithub(githubHandle: string): Promise { + if (this.useMock) { + const address = this.mockGithubIndex.get(githubHandle.toLowerCase()); + if (!address) { + throw new NotFoundException(`Contributor with GitHub handle '${githubHandle}' not found`); + } + // Re-use the address fetcher (no extra network call in mock mode) + return this.fetchContributorByAddress(address); + } + + const contractId = this.requireContractId(); + const relayer = this.relayerKeypair(); + const account = await this.sorobanRpcClient.getAccount(relayer.publicKey()); + const contract = new Contract(contractId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'get_contributor_by_github', + nativeToScVal(githubHandle, { type: 'string' }), + ), + ) + .setTimeout(30) + .build(); + + let simulation: rpc.Api.SimulateTransactionResponse; + try { + simulation = await this.sorobanRpcClient.simulateTransaction(tx); + } catch (err) { + if (err instanceof SorobanRpcError && err.code === SorobanErrorCode.SIMULATION_FAILED) { + throw new NotFoundException(`Contributor with GitHub handle '${githubHandle}' not found`); + } + throw err; + } + + if (!simulation.result) { + throw new NotFoundException(`Contributor with GitHub handle '${githubHandle}' not found`); + } + + return this.parseContributorData(simulation.result.retval); + } + + private async fetchReputation(address: string): Promise { + if (this.useMock) { + const contributor = this.mockContributors.get(address); + if (!contributor) { + throw new NotFoundException(`Contributor with address ${address} not found`); + } + return { + address, + reputationScore: contributor.reputationScore, + tier: this.tierFromScore(contributor.reputationScore), + }; + } + + const contractId = this.requireContractId(); + const relayer = this.relayerKeypair(); + const account = await this.sorobanRpcClient.getAccount(relayer.publicKey()); + const contract = new Contract(contractId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call('get_reputation', new Address(address).toScVal())) + .setTimeout(30) + .build(); + + let simulation: rpc.Api.SimulateTransactionResponse; + try { + simulation = await this.sorobanRpcClient.simulateTransaction(tx); + } catch (err) { + if (err instanceof SorobanRpcError && err.code === SorobanErrorCode.SIMULATION_FAILED) { + throw new NotFoundException(`Contributor with address ${address} not found`); + } + throw err; + } + + if (!simulation.result) { + throw new NotFoundException(`Contributor with address ${address} not found`); + } + + const score = Number(scValToNative(simulation.result.retval) as bigint); + + return { address, reputationScore: score, tier: this.tierFromScore(score) }; + } + + private async fetchNonce(address: string): Promise { + if (this.useMock) { + return this.mockNonces.get(address) ?? 0; + } + + const contractId = this.contractId; + if (!contractId) { + return 0; + } + + const relayer = this.relayerKeypair(); + const account = await this.sorobanRpcClient.getAccount(relayer.publicKey()); + const contract = new Contract(contractId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call('get_registration_nonce', new Address(address).toScVal())) + .setTimeout(30) + .build(); + + try { + const simulation = await this.sorobanRpcClient.simulateTransaction(tx); + + if (!rpc.Api.isSimulationSuccess(simulation) || !simulation.result) { + return 0; + } + + return Number(scValToNative(simulation.result.retval) as bigint); + } catch { + // Not initialized or address has no nonce yet — safe default + return 0; + } + } + + // ── XDR parsers ──────────────────────────────────────────────────────────────── + + private parseContributorData(retval: xdr.ScVal): ContributorResponseDto { + const data = scValToNative(retval) as { + address: string; + github_handle: string; + reputation_score: bigint; + registered_timestamp: bigint; + }; + + const score = Number(data.reputation_score); + + return { + address: data.address, + githubHandle: data.github_handle, + reputationScore: score, + tier: this.tierFromScore(score), + // Soroban timestamps are Unix seconds; convert to ISO string + registeredAt: new Date(Number(data.registered_timestamp) * 1000).toISOString(), + }; + } +} diff --git a/apps/backend/src/contributor-registry/dto/contributor-registry.dto.ts b/apps/backend/src/contributor-registry/dto/contributor-registry.dto.ts new file mode 100644 index 00000000..8c8f6d6d --- /dev/null +++ b/apps/backend/src/contributor-registry/dto/contributor-registry.dto.ts @@ -0,0 +1,132 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; + +const GITHUB_HANDLE_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/; +const GITHUB_HANDLE_MESSAGE = + 'githubHandle must be a valid GitHub username (1-39 alphanumeric or hyphen characters, no leading/trailing hyphens)'; + +export class RegisterContributorDto { + @ApiProperty({ + example: 'GABC1234...', + description: 'Stellar public key of the contributor (G...)', + }) + @IsString() + @IsNotEmpty() + address: string; + + @ApiProperty({ + example: 'octocat', + description: 'GitHub username of the contributor', + }) + @IsString() + @IsNotEmpty() + @Matches(GITHUB_HANDLE_PATTERN, { message: GITHUB_HANDLE_MESSAGE }) + githubHandle: string; +} + +export class RegisterWithSigDto { + @ApiProperty({ + example: 'GABC1234...', + description: 'Stellar public key of the contributor (G...)', + }) + @IsString() + @IsNotEmpty() + address: string; + + @ApiProperty({ + example: 'octocat', + description: 'GitHub username of the contributor', + }) + @IsString() + @IsNotEmpty() + @Matches(GITHUB_HANDLE_PATTERN, { message: GITHUB_HANDLE_MESSAGE }) + githubHandle: string; + + @ApiProperty({ + description: + 'Base64-encoded SorobanAuthorizationEntry signed by the contributor off-chain. ' + + 'Must authorize register_contributor_with_sig(github_handle, address, nonce).', + example: 'AAAAAQAAAA...', + }) + @IsString() + @IsNotEmpty() + signedAuthEntryXdr: string; + + @ApiPropertyOptional({ + description: + 'Hex-encoded raw Ed25519 signature bytes to pass as the contract `signature` parameter. ' + + 'Defaults to 64 zero bytes when omitted (the auth entry is the real proof).', + example: 'deadbeef...', + }) + @IsString() + @IsOptional() + signatureHex?: string; +} + +export class ContributorResponseDto { + @ApiProperty({ example: 'GABC1234...' }) + address: string; + + @ApiProperty({ example: 'octocat' }) + githubHandle: string; + + @ApiProperty({ example: 42 }) + reputationScore: number; + + @ApiProperty({ enum: ['Novice', 'Builder', 'Architect', 'Core'], example: 'Builder' }) + tier: string; + + @ApiProperty({ example: '2026-01-01T00:00:00.000Z' }) + registeredAt: string; +} + +export class ReputationResponseDto { + @ApiProperty({ example: 'GABC1234...' }) + address: string; + + @ApiProperty({ example: 42 }) + reputationScore: number; + + @ApiProperty({ enum: ['Novice', 'Builder', 'Architect', 'Core'], example: 'Builder' }) + tier: string; +} + +export class RegistrationXdrResponseDto { + @ApiProperty({ + description: + 'Base64-encoded unsigned transaction XDR. In mock mode this is a placeholder string; ' + + 'in real mode sign this with the contributor wallet and submit via a Stellar-compatible client.', + example: 'AAAAAQAAAA...', + }) + unsignedXdr: string; + + @ApiProperty({ + description: 'Network passphrase required when signing the transaction.', + example: 'Test SDF Network ; September 2015', + }) + networkPassphrase: string; +} + +export class SubmitResponseDto { + @ApiProperty({ example: 'abc123...' }) + transactionHash: string; + + @ApiProperty({ enum: ['SUCCESS', 'PENDING', 'ERROR'], example: 'SUCCESS' }) + status: string; + + @ApiPropertyOptional({ example: 50123456 }) + ledger?: number; +} + +export class NonceResponseDto { + @ApiProperty({ example: 'GABC1234...' }) + address: string; + + @ApiProperty({ + description: + 'Current registration nonce for the address. Include this value in the ' + + 'SorobanAuthorizationEntry scope when signing register_contributor_with_sig.', + example: 0, + }) + nonce: number; +} diff --git a/memory/MEMORY.md b/memory/MEMORY.md new file mode 100644 index 00000000..641506d7 --- /dev/null +++ b/memory/MEMORY.md @@ -0,0 +1,3 @@ +# Memory Index + +- [Project: Contributor Registry API](project_contributor_registry.md) — Backend module wrapping Soroban contributor registry contract with mock+real paths, caching, and gasless registration diff --git a/memory/project_contributor_registry.md b/memory/project_contributor_registry.md new file mode 100644 index 00000000..b98bf886 --- /dev/null +++ b/memory/project_contributor_registry.md @@ -0,0 +1,30 @@ +--- +name: project-contributor-registry +description: Backend contributor registry API — NestJS module wrapping the Soroban contributor_registry contract with mock + real paths, caching, and gasless registration +metadata: + type: project +--- + +Added `apps/backend/src/contributor-registry/` module implementing the contributor registry backend API. + +**Files created:** +- `dto/contributor-registry.dto.ts` — all request/response DTOs +- `contributor-registry.service.ts` — service with mock (in-memory Map) and real (SorobanRpcClientService) paths +- `contributor-registry.controller.ts` — HTTP routes +- `contributor-registry.module.ts` — NestJS module (imports AppCacheModule + StellarModule) + +**Files modified:** +- `app.module.ts` — registered ContributorRegistryModule +- `common/rate-limit/rate-limit.config.ts` — added `getRegistryReadThrottleOverride()` (delegates to crowdfundRead) and `getRegistryWriteThrottleOverride()` (delegates to portfolioWrite) + +**Routes:** +- `POST /contributor-registry/register` — direct registration; mock stores immediately, real returns unsigned XDR for client signing +- `POST /contributor-registry/register-with-sig` — gasless registration; server as relayer attaches client's signed SorobanAuthorizationEntry and submits +- `GET /contributor-registry/wallet/:address` — lookup by Stellar address (60s cache) +- `GET /contributor-registry/github/:handle` — lookup by GitHub handle (60s cache) +- `GET /contributor-registry/reputation/:address` — reputation score + tier (60s cache) +- `GET /contributor-registry/nonce/:address` — registration nonce for off-chain signing (5s cache) + +**Why:** Mock mode is gated by `config.featureFlags.useMockTransactions` (env `USE_MOCK_TRANSACTIONS=true` is the default in dev). Real mode uses `STELLAR_CONTRACT_CONTRIBUTOR_REGISTRY` env var for contract ID. + +**How to apply:** When working on this module, note that node_modules are not installed locally — `tsc --noEmit` will show pre-existing errors for the whole project, not just these files. The pattern to verify correctness is code review, not compilation.