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
14 changes: 14 additions & 0 deletions contracts/nft-contract/bindings/clips-nft-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ export class ClipsNftContractClient {
return `https://clips.cash/metadata/${tokenId.toString()}`;
}

/**
* Calculate the royalty amount owed on `salePriceStroops` at
* `royaltyBps` basis points (Issue #680). Reusable helper shared by
* `transferWithRoyalty` and off-chain royalty estimates. Rounds down
* (truncates toward zero) and returns `0n` for a zero sale price, zero
* BPS, or BPS above the 10 000 (100%) maximum.
*/
calculateRoyalty(salePriceStroops: bigint, royaltyBps: number): bigint {
if (royaltyBps === 0 || salePriceStroops === 0n || royaltyBps > 10000) {
return 0n;
}
return (salePriceStroops * BigInt(royaltyBps)) / 10000n;
}

/**
* Calculate fractional royalty for decimal assets (Issue #685)
*/
Expand Down
20 changes: 20 additions & 0 deletions contracts/nft-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,25 @@ impl ClipsNftContract {
Ok(())
}

/// Calculate the royalty amount owed on `sale_price` at `royalty_bps`
/// basis points (Issue #680). Reusable helper shared by
/// `transfer_with_royalty` and callers estimating a royalty ahead of a
/// sale, so the math is defined in exactly one place instead of being
/// duplicated across transfer and payout flows.
///
/// Rounding: truncates toward zero (integer division) — any fractional
/// stroop is rounded down. The multiplication is done in `u128` so it
/// cannot overflow even at `sale_price == u64::MAX`.
///
/// Returns `0` when `sale_price` is `0`, `royalty_bps` is `0`, or
/// `royalty_bps` exceeds `storage::ROYALTY_BPS_MAX` (10 000 = 100%).
pub fn calculate_royalty(_env: Env, sale_price: u64, royalty_bps: u32) -> u64 {
if sale_price == 0 || royalty_bps == 0 || royalty_bps > storage::ROYALTY_BPS_MAX {
return 0;
}
((sale_price as u128) * (royalty_bps as u128) / (storage::ROYALTY_BPS_MAX as u128)) as u64
}

/// Calculate fractional royalty for assets with custom decimal precision (Issue #685).
///
/// Uses checked arithmetic (Issue #689): `sale_price * royalty_bps` is
Expand Down Expand Up @@ -390,6 +409,7 @@ impl ClipsNftContract {
.or_else(|| storage::get_default_royalty_bps(&env))
.unwrap_or(0);

let royalty_amount: u64 = Self::calculate_royalty(env.clone(), sale_price, royalty_bps);
// Checked arithmetic (Issue #689): `royalty_bps` is capped at
// ROYALTY_BPS_MAX (10 000), so `sale_price * royalty_bps` only
// overflows `u64` for `sale_price > u64::MAX / 10_000`
Expand Down
77 changes: 77 additions & 0 deletions contracts/nft-contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,83 @@ fn test_royalty_calculation_one_bps_precision() {
assert_eq!(royalty, 1);
}

// ─────────────────────────────────────────────────────────────
// calculate_royalty — reusable helper (Issue #680)
// ─────────────────────────────────────────────────────────────

#[test]
fn test_calculate_royalty_ten_percent() {
let (_env, _cid, client) = setup_env();
assert_eq!(client.calculate_royalty(&500, &1000), 50);
}

#[test]
fn test_calculate_royalty_zero_sale_price_is_zero() {
let (_env, _cid, client) = setup_env();
assert_eq!(client.calculate_royalty(&0, &1000), 0);
}

#[test]
fn test_calculate_royalty_zero_bps_is_zero() {
let (_env, _cid, client) = setup_env();
assert_eq!(client.calculate_royalty(&1_000_000, &0), 0);
}

#[test]
fn test_calculate_royalty_max_bps_returns_full_sale_price() {
let (_env, _cid, client) = setup_env();
assert_eq!(client.calculate_royalty(&300, &10_000), 300);
}

#[test]
fn test_calculate_royalty_above_max_bps_returns_zero() {
// BPS above ROYALTY_BPS_MAX (10 000) is invalid — treated the same as
// `calculate_fractional_royalty`, returning 0 rather than panicking.
let (_env, _cid, client) = setup_env();
assert_eq!(client.calculate_royalty(&1_000, &10_001), 0);
}

#[test]
fn test_calculate_royalty_rounds_down() {
// 101 * 250 / 10_000 = 2.525 -> truncates to 2 (rounding documented as
// "toward zero" on the helper itself).
let (_env, _cid, client) = setup_env();
assert_eq!(client.calculate_royalty(&101, &250), 2);
}

#[test]
fn test_calculate_royalty_does_not_overflow_near_u64_max() {
// sale_price near u64::MAX would overflow a plain u64 multiplication
// before dividing; the helper promotes to u128 internally to stay safe.
let (_env, _cid, client) = setup_env();
let sale_price = u64::MAX - 1;
let royalty = client.calculate_royalty(&sale_price, &1);
// 1 BPS of (u64::MAX - 1), floor-divided by 10_000.
let expected = (((sale_price as u128) * 1u128) / 10_000u128) as u64;
assert_eq!(royalty, expected);
}

#[test]
fn test_calculate_royalty_matches_transfer_with_royalty() {
// The helper must produce the same amount that transfer_with_royalty
// actually pays out, since transfer_with_royalty now delegates to it.
let (env, _cid, client) = setup_env();
let admin = Address::generate(&env);
let creator = Address::generate(&env);
let buyer = Address::generate(&env);

env.mock_all_auths();
client.initialize(&admin);
client.mint(&creator, &1, &s(&env, "clip"), &s(&env, "uri"), &false);
client.set_default_royalty_bps(&750);

let sale_price: u64 = 4_000;
let expected = client.calculate_royalty(&sale_price, &750);

let info = client.transfer_with_royalty(&creator, &buyer, &1, &sale_price);
assert_eq!(info.royalty_amount, expected);
}

// ─────────────────────────────────────────────────────────────
// Transfer
// ─────────────────────────────────────────────────────────────
Expand Down
53 changes: 53 additions & 0 deletions src/nft/dto/royalty-query.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,57 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min } from 'class-validator';
import {
IsValidRoyaltyBps,
CLIP_ROYALTY_BPS_MAX,
} from '../../common/validators/decorators';

/** Query params for GET /nfts/royalty/estimate (Issue #680). */
export class RoyaltyEstimateQueryDto {
@ApiProperty({
description: 'Sale price in stroops (1 XLM = 10,000,000 stroops) to estimate the royalty on',
example: 100_000_000,
minimum: 0,
})
@Type(() => Number)
@IsInt()
@Min(0)
salePrice!: number;

@ApiPropertyOptional({
description:
'Royalty rate in basis points (100 = 1%) to apply. Defaults to the configured creator royalty rate when omitted.',
example: 1000,
minimum: 0,
maximum: CLIP_ROYALTY_BPS_MAX,
})
@IsOptional()
@Type(() => Number)
@IsValidRoyaltyBps({ max: CLIP_ROYALTY_BPS_MAX })
royaltyBps?: number;
}

/** Response for GET /nfts/royalty/estimate (Issue #680). */
export class RoyaltyEstimateResponseDto {
@ApiProperty({
description: 'Sale price in stroops the estimate was computed from',
example: 100_000_000,
})
salePrice!: number;

@ApiProperty({
description: 'Royalty rate applied, in basis points (100 = 1%)',
example: 1000,
})
royaltyBps!: number;

@ApiProperty({
description:
'Royalty amount owed in stroops, rounded down to the nearest stroop (salePrice * royaltyBps / 10_000)',
example: 10_000_000,
})
royaltyAmount!: number;
}

/** Successful on-chain royalty query response. */
export class RoyaltyQueryResponseDto {
Expand Down
48 changes: 48 additions & 0 deletions src/nft/nft.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import {
RoyaltyQueryResponseDto,
RoyaltyNotFoundDto,
RoyaltyUnauthorizedDto,
RoyaltyEstimateQueryDto,
RoyaltyEstimateResponseDto,
} from './dto/royalty-query.dto';
import {
BurnNftDto,
Expand Down Expand Up @@ -631,6 +633,52 @@ export class NftController {
});
}

/**
* GET /nfts/royalty/estimate
* Estimates the royalty owed on a given sale price without requiring a
* minted token, using the same BPS math the Soroban contract's
* `calculate_royalty` helper uses (Issue #680). Useful for showing a
* "you'll receive ~X" estimate in the UI before a resale actually happens.
*/
@Get('royalty/estimate')
@ApiOperation({
summary: 'Estimate the royalty owed on a sale price (Issue #680)',
description:
'Pure calculation — does not touch the chain. Mirrors the Soroban contract\'s ' +
'`calculate_royalty(sale_price, royalty_bps)` helper so estimates match what the ' +
'contract actually pays out on `transfer_with_royalty`. Rounds down (truncates toward ' +
'zero) any fractional stroop. When `royaltyBps` is omitted, the configured creator ' +
'royalty rate is used.',
})
@ApiQuery({
name: 'salePrice',
description: 'Sale price in stroops (1 XLM = 10,000,000 stroops)',
example: 100_000_000,
})
@ApiQuery({
name: 'royaltyBps',
description: 'Royalty rate in basis points (100 = 1%). Defaults to the platform creator royalty rate.',
example: 1000,
required: false,
})
@ApiOkResponse({
description: 'Royalty estimate calculated successfully',
type: RoyaltyEstimateResponseDto,
})
@ApiBadRequestResponse({ description: 'Invalid salePrice or royaltyBps' })
getRoyaltyEstimate(
@Query() query: RoyaltyEstimateQueryDto,
): RoyaltyEstimateResponseDto {
const royaltyBps = this.royaltyConfigurationService.getCreatorRoyaltyBps(
query.royaltyBps,
);
const royaltyAmount = this.royaltyConfigurationService.calculateRoyalty(
query.salePrice,
royaltyBps,
);
return { salePrice: query.salePrice, royaltyBps, royaltyAmount };
}

/**
* GET /nfts/:mintAddress/royalty
* Queries the on-chain royalty info for a minted NFT.
Expand Down
42 changes: 42 additions & 0 deletions src/nft/royalty-configuration.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,48 @@ describe('RoyaltyConfigurationService', () => {
expect(map[0].description).toBe(description);
});

describe('calculateRoyalty', () => {
it('computes 10% royalty on a sale price', () => {
expect(service.calculateRoyalty(100_000_000, 1000)).toBe(10_000_000);
});

it('returns 0 for a zero sale price', () => {
expect(service.calculateRoyalty(0, 1000)).toBe(0);
});

it('returns 0 for zero royalty bps', () => {
expect(service.calculateRoyalty(1_000_000, 0)).toBe(0);
});

it('rounds down fractional stroops', () => {
// 101 * 250 / 10_000 = 2.525 -> floors to 2
expect(service.calculateRoyalty(101, 250)).toBe(2);
});

it('matches the on-chain calculate_royalty helper for the same inputs', () => {
// Mirrors contracts/nft-contract/src/test.rs::test_calculate_royalty_ten_percent
expect(service.calculateRoyalty(500, 1000)).toBe(50);
});

it('rejects royaltyBps above the allowed maximum', () => {
expect(() => service.calculateRoyalty(1000, 1501)).toThrow(
BadRequestException,
);
});

it('rejects a negative sale price', () => {
expect(() => service.calculateRoyalty(-1, 1000)).toThrow(
BadRequestException,
);
});

it('rejects a non-integer sale price', () => {
expect(() => service.calculateRoyalty(1.5, 1000)).toThrow(
BadRequestException,
);
});
});

it('throws when platform wallet is missing', () => {
const missingWalletConfig = {
...config,
Expand Down
22 changes: 22 additions & 0 deletions src/nft/royalty-configuration.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,28 @@ export class RoyaltyConfigurationService {
return code === 'native' ? { code } : { code, contractId };
}

/**
* Calculate the royalty amount owed on `salePrice` at `royaltyBps` basis
* points (Issue #680). Mirrors the Soroban contract's `calculate_royalty`
* helper so estimates returned by the API match what the chain will
* actually pay out.
*
* Rounding: truncates toward zero (`Math.floor`), matching the contract's
* integer-division semantics — any fractional stroop is rounded down.
*/
calculateRoyalty(salePrice: number, royaltyBps: number): number {
this.validateRoyaltyBps(royaltyBps);
if (!Number.isInteger(salePrice) || salePrice < 0) {
throw new BadRequestException(
`Invalid salePrice: ${salePrice}. Must be a non-negative integer.`,
);
}
if (salePrice === 0 || royaltyBps === 0) {
return 0;
}
return Math.floor((salePrice * royaltyBps) / ROYALTY_PROTOCOL_MAX_BPS);
}

validateRoyaltyBps(bps: number): void {
if (!Number.isInteger(bps) || bps < 0 || bps > CLIP_ROYALTY_BPS_MAX) {
throw new BadRequestException(
Expand Down
Loading
Loading