From 0ec7b5dd59156d54fb53afd64e9b87e62696f59a Mon Sep 17 00:00:00 2001 From: petahade Date: Thu, 25 Jun 2026 18:37:35 +0000 Subject: [PATCH] feat(nft): add royalty splitting validation and startup guards Extend RoyaltyConfigurationService with platform royalty bps validation, combined creator+platform total guard against the 10 000 bps protocol cap, and a validateRoyaltyConfiguration() method used at bootstrap to reject misconfigured environments (missing/invalid platform wallet, negative or non-integer bps values, totals that exceed the protocol limit). buildRoyaltyMap() now enforces these constraints on every mint, so both the creator (1 000 bps default) and platform (100 bps default) royalty entries are always encoded, submitted, and persisted correctly on-chain. --- src/main.ts | 15 ++++++ src/nft/royalty-configuration.service.ts | 69 ++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/main.ts b/src/main.ts index 2841a21..2cd6c72 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import * as bodyParser from 'body-parser'; import * as fs from 'fs'; import * as path from 'path'; import { AppModule } from './app.module'; +import { RoyaltyConfigurationService } from './nft/royalty-configuration.service'; import { PayoutsService } from './payouts/payouts.service'; import { StellarWebhookService } from './subscriptions/stellar-webhook.service'; import { MetricsInterceptor } from './metrics/metrics.interceptor'; @@ -52,6 +53,20 @@ async function bootstrap() { process.exit(1); } + // Validate royalty configuration on startup + const royaltyConfigService = app.get(RoyaltyConfigurationService); + try { + royaltyConfigService.validateRoyaltyConfiguration(); + logger.log( + `Royalty configuration validated: ` + + `creatorRoyaltyBps=${royaltyConfigService.getCreatorRoyaltyBps()}, ` + + `platformRoyaltyBps=${royaltyConfigService.getPlatformRoyaltyBps()}`, + ); + } catch (error) { + logger.error(`Invalid royalty configuration: ${error.message}`); + process.exit(1); + } + // Swagger setup - only available in non-production environments const swaggerConfig = new DocumentBuilder() .setTitle('ClipCash API') diff --git a/src/nft/royalty-configuration.service.ts b/src/nft/royalty-configuration.service.ts index 8d500b2..faaa8f6 100644 --- a/src/nft/royalty-configuration.service.ts +++ b/src/nft/royalty-configuration.service.ts @@ -1,7 +1,12 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import StellarSdk from '@stellar/stellar-sdk'; import { ConfigService } from '../config/config.service'; -import { CLIP_ROYALTY_BPS_MAX } from '../common/validators/is-valid-royalty-bps.validator'; +import { + CLIP_ROYALTY_BPS_MAX, + DEFAULT_ROYALTY_BPS_MAX, +} from '../common/validators/is-valid-royalty-bps.validator'; + +export const ROYALTY_PROTOCOL_MAX_BPS = DEFAULT_ROYALTY_BPS_MAX; export interface RoyaltyMapEntry { key: unknown; @@ -42,12 +47,70 @@ export class RoyaltyConfigurationService { } } + validatePlatformRoyaltyBps(bps: number): void { + if (!Number.isInteger(bps) || isNaN(bps) || bps < 0) { + throw new BadRequestException( + `Invalid platformRoyaltyBps: ${bps}. Must be a non-negative integer.`, + ); + } + } + + validateCombinedRoyaltyBps(creatorBps: number, platformBps: number): void { + const total = creatorBps + platformBps; + if (total > ROYALTY_PROTOCOL_MAX_BPS) { + throw new BadRequestException( + `Combined royalty (${total} bps = ${creatorBps} creator + ${platformBps} platform) exceeds protocol maximum of ${ROYALTY_PROTOCOL_MAX_BPS} bps.`, + ); + } + } + + /** + * Full startup validation of royalty environment configuration. + * Throws Error (not BadRequestException) so bootstrap can catch it and exit. + */ + validateRoyaltyConfiguration(): void { + const platformWallet = this.config.platformWallet; + if (!platformWallet) { + throw new Error('PLATFORM_WALLET_ADDRESS must be configured'); + } + if (!StellarSdk.StrKey.isValidEd25519PublicKey(platformWallet)) { + throw new Error( + `PLATFORM_WALLET_ADDRESS "${platformWallet}" is not a valid Stellar Ed25519 public key`, + ); + } + + const platformBps = this.config.platformRoyaltyBps; + if (!Number.isInteger(platformBps) || isNaN(platformBps) || platformBps < 0) { + throw new Error( + `PLATFORM_ROYALTY_BPS must be a non-negative integer, got: ${platformBps}`, + ); + } + + const creatorBps = this.config.creatorRoyaltyBps; + if (!Number.isInteger(creatorBps) || isNaN(creatorBps) || creatorBps < 0) { + throw new Error( + `CREATOR_ROYALTY_BPS must be a non-negative integer, got: ${creatorBps}`, + ); + } + + const total = creatorBps + platformBps; + if (total > ROYALTY_PROTOCOL_MAX_BPS) { + throw new Error( + `Combined royalty (${total} bps = ${creatorBps} creator + ${platformBps} platform) exceeds protocol maximum of ${ROYALTY_PROTOCOL_MAX_BPS} bps`, + ); + } + } + buildRoyaltyMap( creatorWallet: string, clipRoyaltyBps?: number | null, ): RoyaltyMapEntry[] { const creatorRoyaltyBps = this.getCreatorRoyaltyBps(clipRoyaltyBps); const platformWallet = this.getPlatformWallet(); + const platformBps = this.getPlatformRoyaltyBps(); + + this.validatePlatformRoyaltyBps(platformBps); + this.validateCombinedRoyaltyBps(creatorRoyaltyBps, platformBps); return [ { @@ -56,9 +119,7 @@ export class RoyaltyConfigurationService { }, { key: StellarSdk.Address.fromString(platformWallet).toScVal(), - value: StellarSdk.nativeToScVal(this.getPlatformRoyaltyBps(), { - type: 'u32', - }), + value: StellarSdk.nativeToScVal(platformBps, { type: 'u32' }), }, ]; }