|
| 1 | +import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common'; |
| 2 | +import StellarSdk from '@stellar/stellar-sdk'; |
| 3 | +import { |
| 4 | + MintSignatureVerificationService, |
| 5 | + buildMintChallenge, |
| 6 | +} from './mint-signature-verification.service'; |
| 7 | +import { NftMetadataService } from './nft-metadata.service'; |
| 8 | +import { IpfsUploadService, NftMetadata } from './ipfs-upload.service'; |
| 9 | +import { RoyaltyConfigurationService } from './royalty-configuration.service'; |
| 10 | +import { NftService } from './nft.service'; |
| 11 | +import { NftConfig } from './nft.config'; |
| 12 | +import { RoyaltyQueryService } from './royalty-query.service'; |
| 13 | +import { CircuitBreakerService } from '../common/circuit-breaker/circuit-breaker.service'; |
| 14 | +import { ConfigService } from '../config/config.service'; |
| 15 | + |
| 16 | +/** |
| 17 | + * End-to-end coverage of the backend-to-Soroban mint flow (Issue #678): |
| 18 | + * metadata upload -> wallet signature verification -> mint -> royalty query |
| 19 | + * on resale. Each stage uses the real service (no stubbing of the class |
| 20 | + * under test); only true external boundaries — Soroban RPC, IPFS HTTP, and |
| 21 | + * Redis — are mocked. This is deliberately separate from each service's own |
| 22 | + * *.spec.ts, which tests that service in isolation; here the goal is to |
| 23 | + * confirm the pieces compose correctly end-to-end and share consistent data |
| 24 | + * (the same clipId/wallet/royaltyBps) across every stage. |
| 25 | + * |
| 26 | + * The payloads below intentionally match the Swagger examples in |
| 27 | + * CreateMintDto (mint-clip.dto.ts) — clipId '42', the same creator wallet, |
| 28 | + * and royaltyBps 1000 — so the documented API examples are the same ones |
| 29 | + * this suite actually exercises. |
| 30 | + */ |
| 31 | +describe('Backend-to-contract mint flow integration (Issue #678)', () => { |
| 32 | + const CREATOR_WALLET = 'GC6XOTK6L6LGBKIWH3IRUZPVUY4COGEMW4J5YINOSPKO27YKTUUHTZF3'; |
| 33 | + const PLATFORM_WALLET = 'GDV76E6XN6A3Q3WXVZ4KPRQ7L6E6XN6A3Q3WXVZ4KPRQ7L6E6XN6'; |
| 34 | + const CLIP_ID = 42; |
| 35 | + const ROYALTY_BPS = 1000; |
| 36 | + |
| 37 | + const royaltyConfigurationService = new RoyaltyConfigurationService({ |
| 38 | + creatorRoyaltyBps: ROYALTY_BPS, |
| 39 | + platformRoyaltyBps: 100, |
| 40 | + platformWallet: PLATFORM_WALLET, |
| 41 | + royaltyAssetCode: 'native', |
| 42 | + royaltyAssetContractId: '', |
| 43 | + } as ConfigService); |
| 44 | + |
| 45 | + const metadataService = new NftMetadataService(royaltyConfigurationService); |
| 46 | + |
| 47 | + const circuitBreakerMock = { |
| 48 | + execute: jest.fn().mockImplementation((_config: unknown, fn: () => unknown) => fn()), |
| 49 | + }; |
| 50 | + |
| 51 | + const ipfsService = new IpfsUploadService( |
| 52 | + circuitBreakerMock as unknown as CircuitBreakerService, |
| 53 | + { |
| 54 | + ipfsProvider: 'pinata', |
| 55 | + pinataJwt: 'test-pinata-jwt', |
| 56 | + ipfsApiUrl: 'https://api.pinata.cloud/pinning/pinJSONToIPFS', |
| 57 | + nftStorageApiKey: '', |
| 58 | + } as ConfigService, |
| 59 | + ); |
| 60 | + |
| 61 | + const signatureService = new MintSignatureVerificationService(); |
| 62 | + |
| 63 | + beforeEach(() => { |
| 64 | + jest.clearAllMocks(); |
| 65 | + circuitBreakerMock.execute.mockImplementation((_config: unknown, fn: () => unknown) => fn()); |
| 66 | + global.fetch = jest.fn(); |
| 67 | + }); |
| 68 | + |
| 69 | + // ── 1. Mock backend signature ────────────────────────────────────────── |
| 70 | + |
| 71 | + describe('wallet signature verification', () => { |
| 72 | + it('accepts a real Ed25519 signature over the canonical mint challenge', () => { |
| 73 | + const wallet = StellarSdk.Keypair.random(); |
| 74 | + const challenge = buildMintChallenge(CLIP_ID, wallet.publicKey()); |
| 75 | + const signature = wallet.sign(Buffer.from(challenge, 'utf8')).toString('hex'); |
| 76 | + |
| 77 | + expect(() => |
| 78 | + signatureService.verify(CLIP_ID, wallet.publicKey(), signature), |
| 79 | + ).not.toThrow(); |
| 80 | + }); |
| 81 | + |
| 82 | + it('accepts the same signature encoded as base64', () => { |
| 83 | + const wallet = StellarSdk.Keypair.random(); |
| 84 | + const challenge = buildMintChallenge(CLIP_ID, wallet.publicKey()); |
| 85 | + const signature = wallet.sign(Buffer.from(challenge, 'utf8')).toString('base64'); |
| 86 | + |
| 87 | + expect(() => |
| 88 | + signatureService.verify(CLIP_ID, wallet.publicKey(), signature), |
| 89 | + ).not.toThrow(); |
| 90 | + }); |
| 91 | + }); |
| 92 | + |
| 93 | + // ── 2. Metadata upload ────────────────────────────────────────────────── |
| 94 | + |
| 95 | + describe('metadata build + IPFS upload', () => { |
| 96 | + it('builds OpenSea-compatible metadata and uploads it, returning an ipfs:// URI', async () => { |
| 97 | + const metadata = metadataService.build({ |
| 98 | + id: CLIP_ID, |
| 99 | + title: 'Amazing Clip', |
| 100 | + caption: 'A test clip', |
| 101 | + clipUrl: 'https://cdn.example.com/video.mp4', |
| 102 | + thumbnail: 'https://cdn.example.com/thumb.jpg', |
| 103 | + duration: 27, |
| 104 | + viralityScore: 88, |
| 105 | + createdAt: new Date('2026-03-01T00:00:00.000Z'), |
| 106 | + royaltyBps: ROYALTY_BPS, |
| 107 | + royaltyRecipient: CREATOR_WALLET, |
| 108 | + }); |
| 109 | + |
| 110 | + expect(metadata.seller_fee_basis_points).toBe(ROYALTY_BPS); |
| 111 | + expect(metadata.royalty).toMatchObject({ bps: ROYALTY_BPS, percent: 10 }); |
| 112 | + |
| 113 | + (global.fetch as jest.Mock).mockResolvedValue({ |
| 114 | + ok: true, |
| 115 | + json: async () => ({ IpfsHash: 'bafyIntegrationTestCid' }), |
| 116 | + }); |
| 117 | + |
| 118 | + const uri = await ipfsService.uploadMetadata(metadata, CLIP_ID); |
| 119 | + |
| 120 | + expect(uri).toBe('ipfs://bafyIntegrationTestCid'); |
| 121 | + expect(global.fetch).toHaveBeenCalledTimes(1); |
| 122 | + }); |
| 123 | + }); |
| 124 | + |
| 125 | + // ── 3. Mint transaction ───────────────────────────────────────────────── |
| 126 | + |
| 127 | + describe('mint transaction', () => { |
| 128 | + function makeNftService(overrides: Partial<NftConfig> = {}): NftService { |
| 129 | + const config = Object.assign(new NftConfig(), { |
| 130 | + creatorRoyaltyBps: ROYALTY_BPS, |
| 131 | + platformRoyaltyBps: 100, |
| 132 | + platformWallet: PLATFORM_WALLET, |
| 133 | + ...overrides, |
| 134 | + }); |
| 135 | + return new NftService(config); |
| 136 | + } |
| 137 | + |
| 138 | + it('mints using the uploaded metadata URI and a verified wallet signature', async () => { |
| 139 | + // Signature stage (reuses the same challenge/verify pair as above). |
| 140 | + const wallet = StellarSdk.Keypair.random(); |
| 141 | + const challenge = buildMintChallenge(CLIP_ID, wallet.publicKey()); |
| 142 | + const signature = wallet.sign(Buffer.from(challenge, 'utf8')).toString('hex'); |
| 143 | + signatureService.verify(CLIP_ID, wallet.publicKey(), signature); |
| 144 | + |
| 145 | + // Metadata + upload stage. |
| 146 | + const metadata = metadataService.build({ |
| 147 | + id: CLIP_ID, |
| 148 | + title: 'Amazing Clip', |
| 149 | + caption: null, |
| 150 | + clipUrl: 'https://cdn.example.com/video.mp4', |
| 151 | + thumbnail: null, |
| 152 | + duration: 27, |
| 153 | + viralityScore: null, |
| 154 | + createdAt: new Date('2026-03-01T00:00:00.000Z'), |
| 155 | + royaltyBps: ROYALTY_BPS, |
| 156 | + royaltyRecipient: CREATOR_WALLET, |
| 157 | + }); |
| 158 | + (global.fetch as jest.Mock).mockResolvedValue({ |
| 159 | + ok: true, |
| 160 | + json: async () => ({ IpfsHash: 'bafyMintFlowCid' }), |
| 161 | + }); |
| 162 | + const metadataUri = await ipfsService.uploadMetadata(metadata, CLIP_ID); |
| 163 | + |
| 164 | + // Mint stage. |
| 165 | + const nftService = makeNftService(); |
| 166 | + const result = await nftService.mintClip({ |
| 167 | + clipId: String(CLIP_ID), |
| 168 | + creatorWallet: CREATOR_WALLET, |
| 169 | + metadataUri, |
| 170 | + royaltyBps: ROYALTY_BPS, |
| 171 | + }); |
| 172 | + |
| 173 | + expect(result.txHash).toMatch(new RegExp(`^sim_tx_${CLIP_ID}_`)); |
| 174 | + expect(result.transaction.metadataUri).toBe('ipfs://bafyMintFlowCid'); |
| 175 | + expect(result.transaction.royalties).toEqual([ |
| 176 | + { wallet: CREATOR_WALLET, bps: ROYALTY_BPS, label: 'creator' }, |
| 177 | + { wallet: PLATFORM_WALLET, bps: 100, label: 'platform' }, |
| 178 | + ]); |
| 179 | + }); |
| 180 | + }); |
| 181 | + |
| 182 | + // ── 4. Royalty on resale ──────────────────────────────────────────────── |
| 183 | + |
| 184 | + describe('royalty query on resale', () => { |
| 185 | + function makeRoyaltyQueryService() { |
| 186 | + const stellarService = { |
| 187 | + rpcUrl: 'https://soroban-testnet.stellar.org', |
| 188 | + networkPassphrase: 'Test SDF Network ; September 2015', |
| 189 | + }; |
| 190 | + const redisService = { get: jest.fn().mockResolvedValue(null), setex: jest.fn() }; |
| 191 | + const cb = { execute: jest.fn((_config: unknown, fn: () => unknown) => fn()) }; |
| 192 | + return new RoyaltyQueryService( |
| 193 | + stellarService as any, |
| 194 | + redisService as any, |
| 195 | + cb as any, |
| 196 | + ); |
| 197 | + } |
| 198 | + |
| 199 | + it('resolves the royalty owed on a resale from the on-chain split', async () => { |
| 200 | + const royaltyQueryService = makeRoyaltyQueryService(); |
| 201 | + |
| 202 | + // The token was minted with a 1000 bps (10%) creator royalty above; |
| 203 | + // simulate the chain reporting that same split on a resale query. |
| 204 | + jest.spyOn(royaltyQueryService as any, 'queryOnChainRoyalty').mockResolvedValue({ |
| 205 | + royaltyBps: ROYALTY_BPS, |
| 206 | + recipient: CREATOR_WALLET, |
| 207 | + }); |
| 208 | + |
| 209 | + const info = await royaltyQueryService.getRoyaltyInfo(String(CLIP_ID)); |
| 210 | + |
| 211 | + expect(info).toEqual({ royaltyBps: ROYALTY_BPS, recipient: CREATOR_WALLET }); |
| 212 | + |
| 213 | + // A resale at 100 XLM (1_000_000_000 stroops) should owe 10% = 10 XLM, |
| 214 | + // using the same BPS math the contract applies in transfer_with_royalty. |
| 215 | + const salePriceStroops = 1_000_000_000; |
| 216 | + const expectedRoyaltyStroops = (salePriceStroops * info.royaltyBps) / 10_000; |
| 217 | + expect(expectedRoyaltyStroops).toBe(100_000_000); |
| 218 | + }); |
| 219 | + }); |
| 220 | + |
| 221 | + // ── 5. Failure scenarios ──────────────────────────────────────────────── |
| 222 | + |
| 223 | + describe('failure scenarios', () => { |
| 224 | + it('rejects a signature that does not match the signing wallet', () => { |
| 225 | + const signer = StellarSdk.Keypair.random(); |
| 226 | + const impersonated = StellarSdk.Keypair.random(); |
| 227 | + const challenge = buildMintChallenge(CLIP_ID, impersonated.publicKey()); |
| 228 | + const signature = signer.sign(Buffer.from(challenge, 'utf8')).toString('hex'); |
| 229 | + |
| 230 | + expect(() => |
| 231 | + signatureService.verify(CLIP_ID, impersonated.publicKey(), signature), |
| 232 | + ).toThrow(UnauthorizedException); |
| 233 | + }); |
| 234 | + |
| 235 | + it('rejects a signature over a tampered clipId (replay across clips)', () => { |
| 236 | + const wallet = StellarSdk.Keypair.random(); |
| 237 | + const signature = wallet |
| 238 | + .sign(Buffer.from(buildMintChallenge(CLIP_ID, wallet.publicKey()), 'utf8')) |
| 239 | + .toString('hex'); |
| 240 | + |
| 241 | + // Same signature, different clipId — must not verify. |
| 242 | + expect(() => |
| 243 | + signatureService.verify(CLIP_ID + 1, wallet.publicKey(), signature), |
| 244 | + ).toThrow(UnauthorizedException); |
| 245 | + }); |
| 246 | + |
| 247 | + it('rejects a malformed Stellar wallet address before touching crypto', () => { |
| 248 | + expect(() => |
| 249 | + signatureService.verify(CLIP_ID, 'not-a-stellar-address', 'a'.repeat(128)), |
| 250 | + ).toThrow(UnauthorizedException); |
| 251 | + }); |
| 252 | + |
| 253 | + it('fails metadata upload validation when royalty info is missing', async () => { |
| 254 | + const invalidMetadata = { |
| 255 | + name: 'Bad', |
| 256 | + description: 'Missing royalty block', |
| 257 | + image: 'https://cdn.example.com/thumb.jpg', |
| 258 | + animation_url: 'https://cdn.example.com/video.mp4', |
| 259 | + attributes: [], |
| 260 | + seller_fee_basis_points: ROYALTY_BPS, |
| 261 | + } as unknown as NftMetadata; |
| 262 | + |
| 263 | + await expect( |
| 264 | + ipfsService.uploadMetadata(invalidMetadata, CLIP_ID), |
| 265 | + ).rejects.toBeInstanceOf(BadRequestException); |
| 266 | + expect(global.fetch).not.toHaveBeenCalled(); |
| 267 | + }); |
| 268 | + |
| 269 | + it('rejects minting when the platform wallet is not configured', async () => { |
| 270 | + const config = Object.assign(new NftConfig(), { |
| 271 | + creatorRoyaltyBps: ROYALTY_BPS, |
| 272 | + platformRoyaltyBps: 100, |
| 273 | + platformWallet: '', |
| 274 | + }); |
| 275 | + const nftService = new NftService(config); |
| 276 | + |
| 277 | + await expect( |
| 278 | + nftService.mintClip({ clipId: String(CLIP_ID), creatorWallet: CREATOR_WALLET }), |
| 279 | + ).rejects.toBeInstanceOf(BadRequestException); |
| 280 | + }); |
| 281 | + |
| 282 | + it('rejects a batch mint over the 50-clip limit', async () => { |
| 283 | + const config = Object.assign(new NftConfig(), { |
| 284 | + creatorRoyaltyBps: ROYALTY_BPS, |
| 285 | + platformRoyaltyBps: 100, |
| 286 | + platformWallet: PLATFORM_WALLET, |
| 287 | + }); |
| 288 | + const nftService = new NftService(config); |
| 289 | + |
| 290 | + const clips = Array.from({ length: 51 }, (_, i) => ({ clipId: String(i + 1) })); |
| 291 | + |
| 292 | + await expect( |
| 293 | + nftService.batchMintClips({ creatorWallet: CREATOR_WALLET, clips } as any), |
| 294 | + ).rejects.toBeInstanceOf(BadRequestException); |
| 295 | + }); |
| 296 | + |
| 297 | + it('surfaces NotFoundException when the chain has no royalty data for a resale query', async () => { |
| 298 | + const royaltyQueryService = makeRoyaltyQueryServiceForFailure(); |
| 299 | + |
| 300 | + jest |
| 301 | + .spyOn(royaltyQueryService as any, 'queryOnChainRoyalty') |
| 302 | + .mockRejectedValue(new NotFoundException('Royalty data not found for mint address 999')); |
| 303 | + |
| 304 | + await expect(royaltyQueryService.getRoyaltyInfo('999')).rejects.toBeInstanceOf( |
| 305 | + NotFoundException, |
| 306 | + ); |
| 307 | + }); |
| 308 | + |
| 309 | + function makeRoyaltyQueryServiceForFailure() { |
| 310 | + const stellarService = { |
| 311 | + rpcUrl: 'https://soroban-testnet.stellar.org', |
| 312 | + networkPassphrase: 'Test SDF Network ; September 2015', |
| 313 | + }; |
| 314 | + const redisService = { get: jest.fn().mockResolvedValue(null), setex: jest.fn() }; |
| 315 | + const cb = { execute: jest.fn((_config: unknown, fn: () => unknown) => fn()) }; |
| 316 | + return new RoyaltyQueryService(stellarService as any, redisService as any, cb as any); |
| 317 | + } |
| 318 | + }); |
| 319 | +}); |
0 commit comments