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
152 changes: 139 additions & 13 deletions apps/api/src/modules/contracts/contracts.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -14,25 +15,25 @@ const OTHER_EMAIL = 'nobody@example.com';
describe('ContractsController', () => {
let app: NestFastifyApplication;
let jwtService: JwtService;
let contractsService: jest.Mocked<Pick<ContractsService, 'deploy' | 'deployConfigured' | 'uploadWasmOnly' | 'invoke' | 'getInfo'>>;
let contractsService: jest.Mocked<Pick<ContractsService, 'deploy' | 'deployConfigured' | 'uploadWasmOnly' | 'invoke' | 'getInfo' | 'storeUploadedWasm' | 'fetchWasmFromGit'>>;

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<string, string> = {
Expand All @@ -49,16 +50,16 @@ describe('ContractsController', () => {
controllers: [ContractsController],
providers: [
{ provide: ContractsService, useValue: contractsService },
JwtAuthGuard,
ContractAuthorizationGuard,
JwtAuthGuard,
ContractAuthorizationGuard,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string, defaultValue?: string) => configValues[key] ?? defaultValue),
getOrThrow: jest.fn((key: string) => {
if (configValues[key] === undefined) throw new Error(`Missing config: ${key}`);
return configValues[key];
}),
},
},
},
],
Expand All @@ -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 () => {
Expand All @@ -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', () => {
Expand Down
133 changes: 120 additions & 13 deletions apps/api/src/modules/contracts/contracts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,106 @@ import * as crypto from 'crypto';
@Controller('contracts')
export class ContractsController {
private static wizardSessions = new Map<string, { lastStep: number; wasmBuffer?: Buffer; admin?: string; salt?: string; args?: unknown[]; expiresAt: number }>();
private static readonly MAX_WASM_SIZE = 5 * 1024 * 1024; // 5MB
private static readonly wasmUrlHashCache = new Map<string, string>();
private static readonly wasmContentCache = new Map<string, Buffer>();

constructor(private readonly contractsService: ContractsService) {}

private async fetchWasmFromUrl(wasmUrl: string): Promise<Buffer> {
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<Buffer> {
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)
Expand All @@ -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);
}

Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/modules/contracts/contracts.module.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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],
Expand Down
Loading