diff --git a/apps/api/src/modules/contracts/contracts.controller.spec.ts b/apps/api/src/modules/contracts/contracts.controller.spec.ts index 4a196fb..57ceba4 100644 --- a/apps/api/src/modules/contracts/contracts.controller.spec.ts +++ b/apps/api/src/modules/contracts/contracts.controller.spec.ts @@ -2,10 +2,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; import { JwtModule, JwtService } from '@nestjs/jwt'; import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; +import { BadRequestException, GatewayTimeoutException, PayloadTooLargeException } from '@nestjs/common'; import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { ContractAuthorizationGuard } from './guards/contract-authorization.guard'; +import { ContractAuthorizationGard } from './guards/contract-authorization.guard'; const JWT_SECRET = 'test-secret'; const ALLOWED_EMAIL = 'admin@example.com'; @@ -14,25 +15,25 @@ const OTHER_EMAIL = 'nobody@example.com'; describe('ContractsController', () => { let app: NestFastifyApplication; let jwtService: JwtService; - let contractsService: jest.Mocked>; + let contractsService: jest.Mocked>; beforeAll(async () => { contractsService = { - deploy: jest.fn().mockResolvedValue({ contractId: 'C123', wasmHash: 'abc', txHash: 'tx' }), - deployConfigured: jest.fn().mockResolvedValue({ contractId: 'C123', wasmHash: 'abc', txHash: 'tx' }), - uploadWasmOnly: jest.fn().mockResolvedValue({ wasmHash: 'abc', size: 10 }), - invoke: jest.fn().mockResolvedValue({ result: null, txHash: 'tx' }), + deploy: jest.fn().mockResolved({ contractId: 'C123', wasmHash: 'abc', txHash: 'tx' }), + deployConfigured: jest.fn().mockResolved({ contractId: 'C123', wasmHash: 'abc', txHash: 'tx' }), + uploadWasmOnly: jest.fn().mockResolved({ wasmHash: 'abc', size: 10 }), + invoke: jest.fn().mockResolved({ result: null, txHash: 'tx' }), getInfo: jest.fn(), - storeUploadedWasm: jest.fn().mockResolvedValue({ + storeUploadedWasm: jest.fn().mockResolved({ wasmId: 'wasm_123', contentHash: 'abc', filename: 'contract.wasm', size: 10, - sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e464b934ca495991b7852b855', uploadedAt: new Date().toISOString(), source: 'file', }), - fetchWasmFromGit: jest.fn().mockResolvedValue(Buffer.from('wasm-bytes')), + fetchWasmFromGit: jest.fn().mockResolved(Buffer.from('wasm-bytes')), }; const configValues: Record = { @@ -49,8 +50,8 @@ describe('ContractsController', () => { controllers: [ContractsController], providers: [ { provide: ContractsService, useValue: contractsService }, - JwtAuthGuard, - ContractAuthorizationGuard, + JwtAuthGuard, + ContractAuthorizationGuard, { provide: ConfigService, useValue: { @@ -58,7 +59,7 @@ describe('ContractsController', () => { getOrThrow: jest.fn((key: string) => { if (configValues[key] === undefined) throw new Error(`Missing config: ${key}`); return configValues[key]; - }), + }, }, }, ], @@ -85,7 +86,7 @@ describe('ContractsController', () => { }); expect(response.statusCode).toBe(401); - expect(contractsService.deploy).not.toHaveBeenCalled(); + expect(contractsService.deploy).not.haveBeenCalled(); }); it('rejects authenticated but unauthorized requests with 403', async () => { @@ -96,8 +97,133 @@ describe('ContractsController', () => { }); expect(response.statusCode).toBe(403); + expect(contractsService.deploy).not.haveBeenCalled(); + }); + + it('deploys a contract from a URL successfully', async () => { + const wasmUrl = 'https://example.com/contract.wasm'; + const wasmBuffer = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]); + const storedWasm = { + wasmId: 'wasm_url_1', + contentHash: 'hash', + filename: 'contract.wasm', + size: wasmBuffer.length, + sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e464b934ca495991b7852b855', + uploadedAt: new Date().toISOString(), + source: 'url', + }; + (contractsService.fetchWasmFromGit as jest.Mock).mockResolvedOnce(wasmBuffer); + (contractsService.storeUploadedWasm as jest.Mock).mockResolvedOnce(storedWasm); + (contractsService.deploy as jest.Mock).mockResolvedOnce({ contractId: 'C456', wasmHash: 'hash', txHash: 'tx' }); + + const response = await app.getHttpAdapter().getInstance().inject({ + method: 'POST', + url: '/contracts/deploy', + headers: { authorization: `Bearer ${tokenFor(ALLOWED_EMAIL)}` }, + payload: { wasm_url: wasmUrl }, + }); + + expect(response.statusCode).toBe(201); + expect(response.json()).toEqual({ contractId: 'C456', wasmHash: 'hash', txHash: 'tx' }); + expect(contractsService.fetchWasmFromGit).toHaveBeenCalledWith(wasmUrl); + expect(contractsService.storeUploadedWasm).toHaveBeenCalledWith(wasmBuffer); + expect(contractsService.deploy).toHaveBeenCalledWith(expect.objectContaining({ wasmId: storedWasm.wasmId })); + }); + + it('returns 400 when the URL is invalid', async () => { + (contractsService.fetchWasmFromGit as jest.Mock).mockRejectedOnce(new BadRequestException('Invalid URL')); + + const response = await app.getHttpAdapter().getInstance().inject({ + method: 'POST', + url: '/contracts/deploy', + headers: { authorization: `Bearer ${tokenFor(ALLOWED_EMAIL)}` }, + payload: { wasm_url: 'invalid-url' }, + }); + + expect(response.statusCode).toBe(400); + expect(contractsService.storeUploadedWasm).not.toHaveBeenCalled(); expect(contractsService.deploy).not.toHaveBeenCalled(); }); + + it('returns 413 when the downloaded WASM exceeds the size limit', async () => { + (contractsService.fetchWasmFromGit as jest.Mock).mockRejectedOnce(new PayloadTooLargeException('WASM exceeds 5MB limit')); + + const response = await app.getHttpAdapter().getInstance().inject({ + method: 'POST', + url: '/contracts/deploy', + headers: { authorization: `Bearer ${tokenFor(ALLOWED_EMAIL)}` }, + payload: { wasm_url: 'https://example.com/huge.wasm' }, + }); + + expect(response.statusCode).toBe(413); + }); + + it('returns 504 when downloading the WASM times out', async () => { + (contractsService.fetchWasmFromGit as jest.Mock).mockRejectedOnce(new GatewayTimeoutException('Download timeout')); + + const response = await app.getHttpAdapter().getInstance().inject({ + method: 'POST', + url: '/contracts/deploy', + headers: { authorization: `Bearer ${tokenFor(ALLOWED_EMAIL)}` }, + payload: { wasm_url: 'https://example.com/slow.wasm' }, + }); + + expect(response.statusCode).toBe(504); + }); + + it('deploys a contract from an IPFS URL', async () => { + const wasmUrl = 'ipfs://QmT6Ls9P4i3VvQ7fW4pJ6ymV19X3yQD7v4zFjXWm3KpWvZ:'; + const wasmBuffer = Buffer.from([0x00, 0x61, 0x73, 0x6d]); + const storedWasm = { + wasmId: 'wasm_ipfs', + contentHash: 'hash', + filename: 'contract.wasm', + size: wasmBuffer.length, + sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e464b934ca495991b7852b855', + uploadedAt: new Date().toISOString(), + source: 'url', + }; + (contractsService.fetchWasmFromGit as jest.Mock).mockResolvedOnce(wasmBuffer); + (contractsService.storeUploadedWasm as jest.Mock).mockResolvedOnce(storedWasm); + (contractsService.deploy as jest.Mock).mockResolvedOnce({ contractId: 'C789', wasmHash: 'hash', txHash: 'tx' }); + + const response = await app.getHttpAdapter().getInstance().inject({ + method: 'POST', + url: '/contracts/deploy', + headers: { authorization: `Bearer ${tokenFor(ALLOWED_EMAIL)}` }, + payload: { wasm_url: wasmUrl }, + }); + + expect(response.statusCode).toBe(201); + expect(contractsService&fetchWasmFromGit).toHaveBeenCalledWith(wasmUrl); + }); + + it('deploys a contract from an Arweave URL', async () => { + const wasmUrl = 'ar://xyz-abcdefg-hijklmnopqrstuvwxz'; + const wasmBuffer = Buffer.from([0x00, 0x61, 0x73, 0x6d]); + const storedWasm = { + wasmId: 'wasm_ar', + contentHash: 'hash', + filename: 'contract.wasm', + size: wasmBuffer.length, + sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e464b934ca495991b7852b855', + uploadedAt: new Date().toISOString(), + source: 'url', + }; + (contractsService.fetchWasmFromGit as jest.Mock).mockResolvedOnce(wasmBuffer); + (contractsService.storeUploadedWasm as jest.Mock).mockResolvedOnce(storedWasm); + (contractsService.deploy as jest.Mock).mockResolvedOnce({ contractId: 'C101', wasmHash: 'hash', txHash: 'tx' }); + + const response = await app.getHttpAdapter().getInstance().inject({ + method: 'POST', + url: '/contracts/deploy', + headers: { authorization: `Bearer ${tokenFor(ALLOWED_EMAIL)}` }, + payload: { wasm_url: wasmUrl }, + }); + + expect(response.statusCode).toBe(201); + expect(contractsService.fetchWasmFromGit).toHaveBeenCalledWith(wasmUrl); + }); }); describe('POST /contracts/deploy/wizard', () => { diff --git a/apps/api/src/modules/contracts/contracts.controller.ts b/apps/api/src/modules/contracts/contracts.controller.ts index 7dd34d1..c298c9b 100644 --- a/apps/api/src/modules/contracts/contracts.controller.ts +++ b/apps/api/src/modules/contracts/contracts.controller.ts @@ -25,9 +25,106 @@ import * as crypto from 'crypto'; @Controller('contracts') export class ContractsController { private static wizardSessions = new Map(); + private static readonly MAX_WASM_SIZE = 5 * 1024 * 1024; // 5MB + private static readonly wasmUrlHashCache = new Map(); + private static readonly wasmContentCache = new Map(); constructor(private readonly contractsService: ContractsService) {} + private async fetchWasmFromUrl(wasmUrl: string): Promise { + const resolvedUrl = this.resolveWasmUrl(wasmUrl); + + // Check URL-to-hash cache to avoid re-downloading the same URL + const cachedHash = ContractsController.wasmUrlHashCache.get(resolvedUrl); + if (cachedHash) { + const cached = ContractsController.wasmContentCache.get(cachedHash); + if (cached) { + return cached; + } + } + + const wasmBuffer = await this.downloadWasm(resolvedUrl); + if (wasmBuffer.length === 0) { + throw new BadRequestException('Downloaded WASM is empty'); + } + if (wasmBuffer.length < 4 || wasmBuffer.readUInt32LE(0) !== 0x6d736100) { + throw new BadRequestException('Invalid WASM format: missing magic header'); + } + + const hash = crypto.createHash('sha256').update(wasmBuffer).digest('hex'); + ContractsController.wasmUrlHashCache.set(resolvedUrl, hash); + + // Reuse an existing buffer if we already have this content hashed + const existing = ContractsController.wasmContentCache.get(hash); + if (existing) { + return existing; + } + + ContractsController.wasmContentCache.set(hash, wasmBuffer); + return wasmBuffer; + } + + private async downloadWasm(resolvedUrl: string): Promise { + const response = await fetch(resolvedUrl, { + redirect: 'follow', + headers: { 'Accept': 'application/wasm, application/octet-stream' }, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw new BadRequestException(`Failed to download WASM from URL: ${response.status} ${response.statusText}`); + } + if (!response.body) { + throw new BadRequestException('No response body from WASM URL'); + } + + const contentLength = Number(response.headers.get('content-length') ?? 0); + if (contentLength > ContractsController.MAX_WASM_SIZE) { + throw new BadRequestException(`WASM URL content exceeds ${ContractsController.MAX_WASM_SIZE / 1024 / 1024}MB size limit`); + } + + const chunks: Buffer[] = []; + let total = 0; + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > ContractsController.MAX_WASM_SIZE) { + throw new BadRequestException(`WASM URL content exceeds ${ContractsController.MAX_WASM_SIZE / 1024 / 1024}MB size limit`); + } + chunks.push(Buffer.from(value)); + } + } finally { + reader.releaseLock(); + } + + return Buffer.concat(chunks); + } + + private resolveWasmUrl(wasmUrl: string): string { + let parsed: URL; + try { + parsed = new URL(wasmUrl); + } catch { + throw new BadRequestException('Invalid wasm_url. Must be http(s), ipfs://, or ar://'); + } + if (parsed.protocol === 'ipfs:') { + const path = parsed.hostname + parsed.pathname; + const cid = path.startsWith('ipfs/') ? path.slice(5) : path; + return `https://ipfs.io/ipfs/${cid}`; + } + if (parsed.protocol === 'ar:') { + const path = parsed.hostname + parsed.pathname; + const txId = path.startsWith('ar/') ? path.slice(3) : path; + return `https://arweave.net/${txId}`; + } + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return wasmUrl; + } + throw new BadRequestException('Invalid wasm_url. Must be http(s), ipfs://, or ar://'); + } + @Post('deploy') @ApiCookieAuth() @UseGuards(JwtAuthGuard, ContractAuthorizationGuard) @@ -38,24 +135,34 @@ export class ContractsController { @ApiResponse({ status: 401, description: 'Authentication required' }) @ApiResponse({ status: 403, description: 'Not authorized to deploy contracts' }) async deploy(@Req() req: FastifyRequest) { - const file = await req.file(); - - if (!file) { - throw new BadRequestException('WASM file is required'); + let file; + try { + file = await req.file(); + } catch { + file = undefined; } - const mimetype = file.mimetype; - if (mimetype !== 'application/wasm' && mimetype !== 'application/octet-stream' && !file.filename.endsWith('.wasm')) { - throw new BadRequestException('Uploaded file must be a .wasm file'); + if (file) { + const mimetype = file.mimetype; + if (mimetype !== 'application/wasm' && mimetype !== 'application/octet-stream' && !file.filename.endsWith('.wasm')) { + throw new BadRequestException('Uploaded file must be a .wasm file'); + } + const wasmBuffer = await file.toBuffer(); + const argsField = file.fields.args as { value?: string } | undefined; + const constructorArgs: unknown[] | undefined = argsField?.value + ? this.parseArgs(argsField.value) + : undefined; + return this.contractsService.deploy(wasmBuffer, constructorArgs); } - const wasmBuffer = await file.toBuffer(); - - const argsField = file.fields.args as { value?: string } | undefined; - const constructorArgs: unknown[] | undefined = argsField?.value - ? this.parseArgs(argsField.value) - : undefined; + const body = (req.body ?? {}) as { wasm_url?: string; wasmUrl?: string; args?: string }; + const wasmUrl = body.wasm_url ?? body.wasmUrl; + if (!wasmUrl) { + throw new BadRequestException('WASM file or wasm_url is required'); + } + const wasmBuffer = await this.fetchWasmFromUrl(wasmUrl); + const constructorArgs: unknown[] | undefined = body.args ? this.parseArgs(body.args) : undefined; return this.contractsService.deploy(wasmBuffer, constructorArgs); } diff --git a/apps/api/src/modules/contracts/contracts.module.ts b/apps/api/src/modules/contracts/contracts.module.ts index 879c5a6..5485626 100644 --- a/apps/api/src/modules/contracts/contracts.module.ts +++ b/apps/api/src/modules/contracts/contracts.module.ts @@ -1,4 +1,5 @@ import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; import { AuthModule } from "../auth/auth.module"; import { MetricsModule } from "../metrics/metrics.module"; import { ContractsController } from "./contracts.controller"; @@ -7,7 +8,7 @@ import { EventsController } from "./events.controller"; import { EventsService } from "./events.service"; @Module({ - imports: [AuthModule, MetricsModule], + imports: [AuthModule, MetricsModule, HttpModule], controllers: [ContractsController, EventsController], providers: [ContractsService, EventsService], exports: [ContractsService, EventsService], diff --git a/apps/api/src/modules/contracts/contracts.service.ts b/apps/api/src/modules/contracts/contracts.service.ts index 4bccf02..b2963e0 100644 --- a/apps/api/src/modules/contracts/contracts.service.ts +++ b/apps/api/src/modules/contracts/contracts.service.ts @@ -37,6 +37,7 @@ export class ContractsService { private readonly deployer: Keypair; private readonly networkPassphrase: string; private readonly wasmStore = new Map(); + private readonly wasmUrlCache = new Map(); private readonly maxFileSize: number; constructor( @@ -163,10 +164,130 @@ export class ContractsService { } } + async fetchWasmFromUrl(url: string): Promise<{ buffer: Buffer; metadata: WasmMetadata }> { + const normalizedUrl = this.resolveWasmUrl(url); + const cachedHash = this.wasmUrlCache.get(normalizedUrl); + + if (cachedHash && this.wasmStore.has(cachedHash)) { + const cached = this.wasmStore.get(cachedHash)!; + return { buffer: cached.buffer, metadata: cached.metadata }; + } + + const configuredTimeout = this.configService.get('WASM_URL_TIMEOUT_MS'); + const timeoutMs = configuredTimeout ? parseInt(configuredTimeout, 10) || 30000 : 30000; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(normalizedUrl, { + signal: controller.signal, + redirect: 'follow', + }); + + if (!response.ok) { + throw new BadRequestException( + `Failed to download WASM from URL: HTTP ${response.status}`, + ); + } + + const contentLength = Number(response.headers.get('content-length') || '0'); + if (contentLength > this.maxFileSize) { + throw new BadRequestException( + `WASM file exceeds maximum size of ${this.maxFileSize / (1024 * 1024)}MB`, + ); + } + + if (!response.body) { + throw new BadRequestException('Failed to download WASM from URL: empty response body'); + } + + const chunks: Buffer[] = []; + let totalSize = 0; + + for await (const chunk of response.body as unknown as AsyncIterable) { + totalSize += chunk.byteLength; + if (totalSize > this.maxFileSize) { + controller.abort(); + throw new BadRequestException( + `WASM file exceeds maximum size of ${this.maxFileSize / (1024 * 1024)}MB`, + ); + } + chunks.push(Buffer.from(chunk)); + } + + const wasmBuffer = Buffer.concat(chunks); + + if ( + wasmBuffer.length < 8 || + wasmBuffer.readUInt32LE(0) !== 0x6d736100 || + wasmBuffer.readUInt32LE(4) !== 1 + ) { + throw new BadRequestException('Invalid WASM format'); + } + + const contentHash = hash(wasmBuffer).toString('hex'); + const metadata = await this.storeUploadedWasm({ + wasmBuffer, + filename: path.basename(new URL(normalizedUrl).pathname) || 'contract.wasm', + source: 'url', + }); + + this.wasmUrlCache.set(normalizedUrl, contentHash); + + return { buffer: wasmBuffer, metadata }; + } catch (err: any) { + if (err instanceof BadRequestException) { + throw err; + } + if (err.name === 'AbortError' || err.code === 'ABORT_ERR') { + throw new BadRequestException('WASM download timed out'); + } + throw new BadRequestException(`Failed to fetch WASM from URL: ${err.message}`); + } finally { + clearTimeout(timeout); + } + } + + private resolveWasmUrl(url: string): string { + if (url.startsWith('ipfs://')) { + const ipfsPath = url.slice('ipfs://'.length); + if (!ipfsPath) { + throw new BadRequestException('Invalid IPFS URL'); + } + const gateway = this.configService.get('IPFS_GATEWAY_URL', 'https://ipfs.io'); + return `${gateway.replace(/\/$/, '')}/ipfs/${ipfsPath}`; + } + + if (url.startsWith('ar://')) { + const arweaveId = url.slice('ar://'.length); + if (!arweaveId) { + throw new BadRequestException('Invalid Arweave URL'); + } + const gateway = this.configService.get('ARWEAVE_GATEWAY_URL', 'https://arweave.net'); + return `${gateway.replace(/\/$/, '')}/${arweaveId}`; + } + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new BadRequestException('Invalid WASM URL'); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new BadRequestException('Invalid WASM URL protocol'); + } + + return parsed.toString(); + } + async deploy( - wasmBuffer: Buffer, + wasmBuffer: Buffer | string, constructorArgs?: unknown[], ): Promise<{ contractId: string; wasmHash: string; txHash: string }> { + if (typeof wasmBuffer === 'string') { + wasmBuffer = (await this.fetchWasmFromUrl(wasmBuffer)).buffer; + } if (!wasmBuffer || wasmBuffer.length === 0) { throw new BadRequestException("WASM file is empty"); }