From 93e5c0cab5bf7ca767834d0f786e47458845382a Mon Sep 17 00:00:00 2001 From: ellaevans2323-pixel Date: Fri, 31 Jul 2026 17:53:19 +0000 Subject: [PATCH] feat(#674): store original clip ID on-chain and expose get_clip_id() - Add get_clip_id(token_id) view function to Soroban contract that returns the clip_id stored in TokenData at mint time - Add getClipId() method to AdminContractService that queries the on-chain get_clip_id via Soroban simulation - Expose GET /nfts/tokens/:tokenId/clip-id endpoint with full Swagger documentation - Update NftPrepareMintResponseDto to document the clipId field as the on-chain ClipCash database link Every NFT already stores clip_id inside TokenData; this PR adds the query surface so callers can verify the NFT <-> database link on-chain. Closes #674 --- contracts/nft-contract/src/lib.rs | 12 +++++++ src/nft/admin-contract.service.ts | 57 +++++++++++++++++++++++++++++++ src/nft/dto/nft-swagger.dto.ts | 6 +++- src/nft/nft.controller.ts | 30 ++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/contracts/nft-contract/src/lib.rs b/contracts/nft-contract/src/lib.rs index a4eaa6d9..837bd723 100644 --- a/contracts/nft-contract/src/lib.rs +++ b/contracts/nft-contract/src/lib.rs @@ -466,6 +466,18 @@ impl ClipsNftContract { storage::get_token(&env, token_id).map(|t| t.creator) } + /// Return the original ClipCash backend clip ID stored at mint time (Issue #674). + /// + /// Every NFT records the database Clip ID that was passed to `mint()` or + /// `batch_mint()`. This creates a verifiable on-chain link between the NFT + /// and the ClipCash database record, enabling ownership and royalty checks + /// that cross the Web2/Web3 boundary. + /// + /// Returns `None` when the token does not exist. + pub fn get_clip_id(env: Env, token_id: u64) -> Option { + storage::get_token(&env, token_id).map(|t| t.clip_id) + } + pub fn balance_of(env: Env, owner: Address) -> u64 { storage::get_owner_tokens(&env, &owner).len() as u64 } diff --git a/src/nft/admin-contract.service.ts b/src/nft/admin-contract.service.ts index 11a70c37..58a6d8be 100644 --- a/src/nft/admin-contract.service.ts +++ b/src/nft/admin-contract.service.ts @@ -69,6 +69,63 @@ export class AdminContractService { }; } + /** + * Query the on-chain `get_clip_id(token_id)` view function (Issue #674). + * + * Every NFT stores the ClipCash database Clip ID passed at mint time, so + * this call lets any back-end service verify the NFT ↔ database link + * without trusting off-chain metadata. + * + * Returns `null` when the token does not exist on-chain. + */ + async getClipId(tokenId: number): Promise<{ tokenId: number; clipId: string | null }> { + const server = new StellarSdk.rpc.Server(this.stellarService.rpcUrl); + const contract = new StellarSdk.Contract(this.CONTRACT_ID); + const op = contract.call( + 'get_clip_id', + StellarSdk.nativeToScVal(BigInt(tokenId), { type: 'u64' }), + ); + + const dummyAccount = new StellarSdk.Account( + 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', + '0', + ); + + const tx = new StellarSdk.TransactionBuilder(dummyAccount, { + fee: '100', + networkPassphrase: this.stellarService.networkPassphrase, + }) + .addOperation(op) + .setTimeout(StellarSdk.TimeoutInfinite) + .build(); + + let simulation: Awaited>; + try { + simulation = await this.circuitBreakerService.execute( + this.sorobanCircuitBreakerConfig, + async () => server.simulateTransaction(tx), + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.logger.error(`Failed to query get_clip_id for token ${tokenId}: ${msg}`); + throw new InternalServerErrorException( + `Failed to query clip ID for token ${tokenId}: ${msg}`, + ); + } + + const results = (simulation as { results?: Array<{ xdr: string }> }).results; + if (!results?.[0]?.xdr) { + return { tokenId, clipId: null }; + } + + const returnValue = StellarSdk.xdr.ScVal.fromXDR(results[0].xdr, 'base64'); + const native = StellarSdk.scValToNative(returnValue); + // Soroban Option is either a string value or null/undefined + const clipId = native != null ? String(native) : null; + + return { tokenId, clipId }; + } + async getPauseStatus(): Promise<{ paused: boolean }> { const server = new StellarSdk.rpc.Server(this.stellarService.rpcUrl); const contract = new StellarSdk.Contract(this.CONTRACT_ID); diff --git a/src/nft/dto/nft-swagger.dto.ts b/src/nft/dto/nft-swagger.dto.ts index 63d29477..d17cf8ed 100644 --- a/src/nft/dto/nft-swagger.dto.ts +++ b/src/nft/dto/nft-swagger.dto.ts @@ -145,7 +145,11 @@ export class NftPrepareMintResponseDto { xdr: string; @ApiProperty({ - description: 'Clip ID being minted', + description: + 'ClipCash database Clip ID — stored on-chain inside `TokenData.clip_id` ' + + 'so every NFT carries a verifiable link back to the backend record. ' + + 'Query the on-chain value at any time via the `get_clip_id(token_id)` ' + + 'contract view function (Issue #674).', example: 42, }) clipId: number; diff --git a/src/nft/nft.controller.ts b/src/nft/nft.controller.ts index 9220d887..fd221ae4 100644 --- a/src/nft/nft.controller.ts +++ b/src/nft/nft.controller.ts @@ -870,6 +870,36 @@ export class NftController { }; } + /** + * GET /nfts/tokens/:tokenId/clip-id + * + * Queries the on-chain `get_clip_id(token_id)` view function (Issue #674). + * Returns the original ClipCash database Clip ID stored inside the NFT at + * mint time, providing a verifiable on-chain ↔ database link. + */ + @Get('tokens/:tokenId/clip-id') + @ApiOperation({ + summary: 'Get the original ClipCash Clip ID stored on-chain for an NFT (Issue #674)', + description: + 'Calls the Soroban `get_clip_id(token_id)` view function. Every NFT stores ' + + 'the ClipCash database Clip ID passed at mint time inside `TokenData.clip_id`, ' + + 'so ownership and royalty checks can cross the Web2/Web3 boundary without ' + + 'trusting off-chain metadata. Returns `null` when the token does not exist.', + }) + @ApiParam({ name: 'tokenId', description: 'On-chain token ID (equals Clip ID)', example: 42 }) + @ApiOkResponse({ + description: 'Clip ID returned successfully', + schema: { + example: { tokenId: 42, clipId: '42' }, + }, + }) + @ApiNotFoundResponse({ description: 'Token does not exist on-chain' }) + async getClipId( + @Param('tokenId', ParseIntPipe) tokenId: number, + ): Promise<{ tokenId: number; clipId: string | null }> { + return this.adminContractService.getClipId(tokenId); + } + @Get('gas-stats') @ApiOperation({ summary: 'Get gas usage monitoring metrics and benchmarks (Issue #684)',