From 83dcf96d00d4b80ef231a530704878892f9d7e62 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Wed, 7 Jan 2026 23:34:51 -0800 Subject: [PATCH 01/10] json schmea, types, handler --- package.json | 1 + pnpm-lock.yaml | 29 ++++++++ src/json/handler.ts | 165 ++++++++++++++++++++++++++++++++++++++++++++ src/json/index.ts | 11 +++ src/json/schema.ts | 79 +++++++++++++++++++++ src/json/types.ts | 58 ++++++++++++++++ 6 files changed, 343 insertions(+) create mode 100644 src/json/handler.ts create mode 100644 src/json/index.ts create mode 100644 src/json/schema.ts create mode 100644 src/json/types.ts diff --git a/package.json b/package.json index d28cd7f..d818395 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ }, "dependencies": { "@solana/web3.js": "^1.95.3", + "ajv": "^8.17.1", "ethers": "^6.0.0", "tronweb": "6.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42103ba..3bd392f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@solana/web3.js': specifier: ^1.95.3 version: 1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) + ajv: + specifier: ^8.17.1 + version: 8.17.1 ethers: specifier: ^6.0.0 version: 6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -674,6 +677,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -1083,6 +1089,9 @@ packages: fast-stable-stringify@1.0.0: resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -1474,6 +1483,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1723,6 +1735,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -2871,6 +2887,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -3321,6 +3344,8 @@ snapshots: fast-stable-stringify@1.0.0: {} + fast-uri@3.1.0: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -3900,6 +3925,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} @@ -4102,6 +4129,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 diff --git a/src/json/handler.ts b/src/json/handler.ts new file mode 100644 index 0000000..92888de --- /dev/null +++ b/src/json/handler.ts @@ -0,0 +1,165 @@ +import Ajv from 'ajv'; +import { createHash } from 'crypto'; +import { Shield } from '../shield'; +import { requestSchema, operationRequirements } from './schema'; +import type { + JsonRequest, + JsonResponse, + JsonSuccessResponse, + JsonErrorResponse, + ValidateResult, + IsSupportedResult, + GetSupportedYieldIdsResult, + ErrorCode +} from './types'; + +// SECURITY: Pre-compiled schema validator (prevents ReDoS on repeated calls) +const ajv = new Ajv({ allErrors: true, strict: true }); +const validateSchema = ajv.compile(requestSchema); + +// SECURITY: Input size limit (100KB) +const MAX_INPUT_SIZE = 100 * 1024; + +// Single Shield instance (stateless, safe to reuse) +const shield = new Shield(); + +/** + * Computes SHA-256 hash of request for integrity verification. + * Allows consumers to verify response corresponds to their request. + */ +function computeRequestHash(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} + +/** + * Main entry point for JSON interface. + * + * SECURITY GUARANTEES: + * 1. Input is validated against strict JSON schema before processing + * 2. All responses include request hash for integrity verification + * 3. Unknown properties are rejected (no injection via extra fields) + * 4. All string inputs have length limits + * 5. Function is pure (no side effects, no network calls) + */ +export function handleJsonRequest(jsonInput: string): string { + const requestHash = computeRequestHash(jsonInput); + + // SECURITY: Check input size before parsing + if (jsonInput.length > MAX_INPUT_SIZE) { + return JSON.stringify(errorResponse( + 'SCHEMA_VALIDATION_ERROR', + `Input exceeds maximum size of ${MAX_INPUT_SIZE} bytes`, + requestHash + )); + } + + // Step 1: Parse JSON + let request: unknown; + try { + request = JSON.parse(jsonInput); + } catch (e) { + return JSON.stringify(errorResponse( + 'PARSE_ERROR', + 'Invalid JSON syntax', + requestHash, + { parseError: e instanceof Error ? e.message : String(e) } + )); + } + + // Step 2: Validate against schema (SECURITY: strict validation) + if (!validateSchema(request)) { + return JSON.stringify(errorResponse( + 'SCHEMA_VALIDATION_ERROR', + 'Request does not match expected schema', + requestHash, + { validationErrors: validateSchema.errors } + )); + } + + const validRequest = request as unknown as JsonRequest; + + // Step 3: Check operation-specific required fields + const requiredFields = operationRequirements[validRequest.operation]; + for (const field of requiredFields) { + if (!(field in validRequest) || validRequest[field as keyof JsonRequest] === undefined) { + return JSON.stringify(errorResponse( + 'MISSING_REQUIRED_FIELD', + `Operation '${validRequest.operation}' requires field '${field}'`, + requestHash + )); + } + } + + // Step 4: Route to appropriate handler + try { + switch (validRequest.operation) { + case 'validate': + return JSON.stringify(handleValidate(validRequest, requestHash)); + case 'isSupported': + return JSON.stringify(handleIsSupported(validRequest, requestHash)); + case 'getSupportedYieldIds': + return JSON.stringify(handleGetSupportedYieldIds(requestHash)); + } + } catch (e) { + // SECURITY: Never expose internal error details in production + return JSON.stringify(errorResponse( + 'INTERNAL_ERROR', + 'An unexpected error occurred', + requestHash + )); + } +} + +function handleValidate(request: JsonRequest, requestHash: string): JsonResponse { + const result = shield.validate({ + yieldId: request.yieldId!, + unsignedTransaction: request.unsignedTransaction!, + userAddress: request.userAddress!, + args: request.args, + context: request.context, + }); + + return successResponse({ + isValid: result.isValid, + reason: result.reason, + details: result.details, + detectedType: result.detectedType, + }, requestHash); +} + +function handleIsSupported(request: JsonRequest, requestHash: string): JsonResponse { + return successResponse({ + supported: shield.isSupported(request.yieldId!), + yieldId: request.yieldId!, + }, requestHash); +} + +function handleGetSupportedYieldIds(requestHash: string): JsonResponse { + return successResponse({ + yieldIds: shield.getSupportedYieldIds(), + }, requestHash); +} + +// Helper functions for consistent response formatting +function successResponse(result: T, requestHash: string): JsonSuccessResponse { + return { + ok: true, + apiVersion: '1.0', + result, + meta: { requestHash } + }; +} + +function errorResponse( + code: ErrorCode, + message: string, + requestHash: string, + details?: unknown +): JsonErrorResponse { + return { + ok: false, + apiVersion: '1.0', + error: { code, message, details }, + meta: { requestHash } + }; +} \ No newline at end of file diff --git a/src/json/index.ts b/src/json/index.ts new file mode 100644 index 0000000..d8d1a9b --- /dev/null +++ b/src/json/index.ts @@ -0,0 +1,11 @@ +export { handleJsonRequest } from './handler'; +export type { + JsonRequest, + JsonResponse, + JsonSuccessResponse, + JsonErrorResponse, + ValidateResult, + IsSupportedResult, + GetSupportedYieldIdsResult, + ErrorCode +} from './types'; \ No newline at end of file diff --git a/src/json/schema.ts b/src/json/schema.ts new file mode 100644 index 0000000..21ff650 --- /dev/null +++ b/src/json/schema.ts @@ -0,0 +1,79 @@ +// JSON Schema for request validation (Ajv format) +export const requestSchema = { + type: 'object', + required: ['apiVersion', 'operation'], + additionalProperties: false, // SECURITY: Reject unknown properties + properties: { + apiVersion: { + type: 'string', + enum: ['1.0'] // Explicit version whitelist + }, + operation: { + type: 'string', + enum: ['validate', 'isSupported', 'getSupportedYieldIds'] + }, + yieldId: { + type: 'string', + minLength: 1, + maxLength: 256 // Reasonable limit + }, + unsignedTransaction: { + type: 'string', + minLength: 1, + maxLength: 102400 // 100KB limit for transaction data + }, + userAddress: { + type: 'string', + minLength: 1, + maxLength: 128 + }, + args: { + type: 'object', + additionalProperties: false, // Security: reject unknown fields + properties: { + // Currently used by Tron + validatorAddress: { type: 'string', maxLength: 128 }, + validatorAddresses: { type: 'array', items: { type: 'string', maxLength: 128 }, maxItems: 100 }, + + // Future use - include for forward compatibility + amount: { type: 'string', maxLength: 78 }, // Max uint256 is 78 digits + tronResource: { type: 'string', enum: ['BANDWIDTH', 'ENERGY'] }, + providerId: { type: 'string', maxLength: 256 }, + duration: { type: 'number', minimum: 0 }, + inputToken: { type: 'string', maxLength: 128 }, + subnetId: { type: 'number', minimum: 0 }, + feeConfigurationId: { type: 'string', maxLength: 256 }, + cosmosPubKey: { type: 'string', maxLength: 256 }, + tezosPubKey: { type: 'string', maxLength: 256 }, + nominatorAddress: { type: 'string', maxLength: 128 }, + nftIds: { type: 'array', items: { type: 'string', maxLength: 256 }, maxItems: 100 }, + } + }, + context: { + type: 'object', + additionalProperties: false, + properties: { + feeConfiguration: { + type: 'array', + maxItems: 100, + items: { + type: 'object', + additionalProperties: false, + properties: { + depositFeeBps: { type: 'number', minimum: 0, maximum: 10000 }, + feeRecipientAddress: { type: 'string', maxLength: 128 }, + allocatorVaultAddress: { type: 'string', maxLength: 128 } + } + } + } + } + } + } +}; + +// Operation-specific required fields +export const operationRequirements = { + validate: ['yieldId', 'unsignedTransaction', 'userAddress'], + isSupported: ['yieldId'], + getSupportedYieldIds: [] +}; \ No newline at end of file diff --git a/src/json/types.ts b/src/json/types.ts new file mode 100644 index 0000000..20a0633 --- /dev/null +++ b/src/json/types.ts @@ -0,0 +1,58 @@ +import type { ActionArguments, ValidationContext } from '../types'; + +export interface JsonRequest { + apiVersion: '1.0'; + operation: 'validate' | 'isSupported' | 'getSupportedYieldIds'; + yieldId?: string; + unsignedTransaction?: string; + userAddress?: string; + args?: ActionArguments; + context?: ValidationContext; +} + +export interface JsonSuccessResponse { + ok: true; + apiVersion: '1.0'; + result: T; + meta: { + requestHash: string; // SHA-256 of request for integrity verification + }; +} + +export interface JsonErrorResponse { + ok: false; + apiVersion: '1.0'; + error: { + code: ErrorCode; + message: string; + details?: unknown; + }; + meta: { + requestHash: string; + }; +} + +export type JsonResponse = JsonSuccessResponse | JsonErrorResponse; + +export type ErrorCode = + | 'PARSE_ERROR' // Invalid JSON syntax + | 'SCHEMA_VALIDATION_ERROR' // Failed Ajv validation + | 'MISSING_REQUIRED_FIELD' // Operation-specific required field missing + | 'INTERNAL_ERROR'; // Unexpected error (should never happen) + +// Result types for each operation +export interface ValidateResult { + isValid: boolean; + reason?: string; + details?: unknown; + detectedType?: string; +} + +export interface IsSupportedResult { + supported: boolean; + yieldId: string; +} + +export interface GetSupportedYieldIdsResult { + yieldIds: string[]; +} \ No newline at end of file From f7879502cb0d30fb4c84be08950e508246780c54 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Thu, 8 Jan 2026 21:11:58 -0800 Subject: [PATCH 02/10] cli --- package.json | 3 +++ rslib.config.ts | 11 +++++++++++ src/cli.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 12 ++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 src/cli.ts diff --git a/package.json b/package.json index d818395..f439cd5 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "publishConfig": { "access": "public" }, + "bin": { + "shield": "./dist/cli.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/rslib.config.ts b/rslib.config.ts index c48bd05..8286133 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -15,6 +15,17 @@ export default defineConfig({ }, }, }, + // CLI build (no .d.ts needed) + { + format: 'cjs', + syntax: ['es2022'], + dts: false, + source: { + entry: { + cli: path.join(__dirname, 'src', 'cli.ts'), + }, + } + }, ], source: { tsconfigPath: path.join(__dirname, 'tsconfig.build.json'), diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..392ec1e --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import { handleJsonRequest } from './json'; + +const MAX_INPUT_SIZE = 100 * 1024; // 100KB + +async function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ''; + + process.stdin.setEncoding('utf8'); + + process.stdin.on('data', (chunk) => { + data += chunk; + // SECURITY: Enforce size limit during streaming + if (data.length > MAX_INPUT_SIZE) { + reject(new Error('Input exceeds maximum size')); + } + }); + + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', reject); + }); +} + +async function main(): Promise { + try { + const input = await readStdin(); + const output = handleJsonRequest(input); + process.stdout.write(output + '\n'); + process.exit(0); + } catch (error) { + // SECURITY: Output valid JSON even on catastrophic failure + const errorResponse = { + ok: false, + apiVersion: '1.0', + error: { + code: 'INTERNAL_ERROR', + message: 'Failed to process request' + }, + meta: { requestHash: 'unavailable' } + }; + process.stdout.write(JSON.stringify(errorResponse) + '\n'); + process.exit(1); + } +} + +main(); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 66e4663..a4f65dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,3 +7,15 @@ export type { FeeConfiguration, } from './types'; export { TronResourceType } from './types'; + +export { handleJsonRequest } from './json'; +export type { + JsonRequest, + JsonResponse, + JsonSuccessResponse, + JsonErrorResponse, + ValidateResult, + IsSupportedResult, + GetSupportedYieldIdsResult, + ErrorCode, +} from './json'; \ No newline at end of file From dad658577d498380990063009f3b88826cb4f0c9 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Thu, 8 Jan 2026 21:52:10 -0800 Subject: [PATCH 03/10] unit tests --- src/json/handler.test.ts | 362 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 src/json/handler.test.ts diff --git a/src/json/handler.test.ts b/src/json/handler.test.ts new file mode 100644 index 0000000..df9076a --- /dev/null +++ b/src/json/handler.test.ts @@ -0,0 +1,362 @@ +import { handleJsonRequest } from './handler'; + +describe('handleJsonRequest', () => { + // Helper to parse response + const call = (req: object | string) => { + const input = typeof req === 'string' ? req : JSON.stringify(req); + return JSON.parse(handleJsonRequest(input)); + }; + + describe('validate operation', () => { + // Use the same test data as shield.test.ts + const userAddress = '0x742d35cc6634c0532925a3b844bc9e7595f0beb8'; + const referralAddress = '0x371240E80Bf84eC2bA8b55aE2fD0B467b16Db2be'; + + const validLidoStakeTx = { + to: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', // Lido stETH + from: userAddress, + value: '0xde0b6b3a7640000', // 1 ETH + data: '0xa1903eab' + referralAddress.slice(2).padStart(64, '0'), // submit(referral) + chainId: 1, + }; + + const validLidoUnstakeTx = { + to: '0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1', // Lido withdrawal queue + from: userAddress, + value: '0x0', + data: + '0xd6681042' + // requestWithdrawals function selector + '0000000000000000000000000000000000000000000000000000000000000040' + // amounts offset + '000000000000000000000000' + + userAddress.slice(2) + // owner + '0000000000000000000000000000000000000000000000000000000000000001' + // array length + '0000000000000000000000000000000000000000000000000de0b6b3a7640000', // amount + chainId: 1, + }; + + it('should validate a correct Lido stake transaction', () => { + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(validLidoStakeTx), + userAddress: userAddress, + }); + + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); + expect(response.result.detectedType).toBe('STAKE'); + expect(response.meta.requestHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('should validate a correct Lido unstake transaction', () => { + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(validLidoUnstakeTx), + userAddress: userAddress, + }); + + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); + expect(response.result.detectedType).toBe('UNSTAKE'); + }); + + it('should reject transaction with wrong referral address', () => { + const invalidReferralTx = { + ...validLidoStakeTx, + data: '0xa1903eab' + '0000000000000000000000000000000000000000000000000000000000000000', // wrong referral + }; + + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(invalidReferralTx), + userAddress: userAddress, + }); + + expect(response.ok).toBe(true); // Request succeeded + expect(response.result.isValid).toBe(false); // But validation failed + expect(response.result.reason).toContain('No matching operation pattern found'); + }); + + it('should reject transaction with wrong contract address', () => { + const wrongContractTx = { + ...validLidoStakeTx, + to: '0x0000000000000000000000000000000000000000', // wrong contract + }; + + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(wrongContractTx), + userAddress: userAddress, + }); + + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(false); + }); + + it('should reject transaction with wrong from address', () => { + const wrongFromTx = { + ...validLidoStakeTx, + from: '0x0000000000000000000000000000000000000001', // different from userAddress + }; + + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(wrongFromTx), + userAddress: userAddress, + }); + + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(false); + }); + + it('should return error for missing yieldId', () => { + const response = call({ + apiVersion: '1.0', + operation: 'validate', + unsignedTransaction: JSON.stringify(validLidoStakeTx), + userAddress: userAddress, + }); + + expect(response.ok).toBe(false); + expect(response.error.code).toBe('MISSING_REQUIRED_FIELD'); + }); + + it('should return error for missing unsignedTransaction', () => { + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + userAddress: userAddress, + }); + + expect(response.ok).toBe(false); + expect(response.error.code).toBe('MISSING_REQUIRED_FIELD'); + }); + + it('should return error for missing userAddress', () => { + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(validLidoStakeTx), + }); + + expect(response.ok).toBe(false); + expect(response.error.code).toBe('MISSING_REQUIRED_FIELD'); + }); + }); + + describe('isSupported operation', () => { + it('should return supported: true for known yield', () => { + const response = call({ + apiVersion: '1.0', + operation: 'isSupported', + yieldId: 'ethereum-eth-lido-staking', + }); + + expect(response.ok).toBe(true); + expect(response.result.supported).toBe(true); + }); + + it('should return supported: false for unknown yield', () => { + const response = call({ + apiVersion: '1.0', + operation: 'isSupported', + yieldId: 'unknown-yield-xyz', + }); + + expect(response.ok).toBe(true); + expect(response.result.supported).toBe(false); + }); + }); + + describe('getSupportedYieldIds operation', () => { + it('should return list of all supported yields', () => { + const response = call({ + apiVersion: '1.0', + operation: 'getSupportedYieldIds', + }); + + expect(response.ok).toBe(true); + expect(response.result.yieldIds).toContain('ethereum-eth-lido-staking'); + expect(response.result.yieldIds).toContain('solana-sol-native-multivalidator-staking'); + }); + }); + + describe('security: schema validation', () => { + it('should reject invalid JSON', () => { + const response = call('{ invalid json }'); + + expect(response.ok).toBe(false); + expect(response.error.code).toBe('PARSE_ERROR'); + }); + + it('should reject unknown apiVersion', () => { + const response = call({ + apiVersion: '2.0', + operation: 'getSupportedYieldIds', + }); + + expect(response.ok).toBe(false); + expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); + }); + + it('should reject unknown operation', () => { + const response = call({ + apiVersion: '1.0', + operation: 'deleteEverything', + }); + + expect(response.ok).toBe(false); + expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); + }); + + it('should reject unknown properties (no injection)', () => { + const response = call({ + apiVersion: '1.0', + operation: 'getSupportedYieldIds', + __proto__: { admin: true }, // Attempted prototype pollution + maliciousField: 'value', + }); + + expect(response.ok).toBe(false); + expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); + }); + + it('should reject oversized yieldId', () => { + const response = call({ + apiVersion: '1.0', + operation: 'isSupported', + yieldId: 'x'.repeat(300), // Exceeds 256 char limit + }); + + expect(response.ok).toBe(false); + expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); + }); + }); + + describe('security: tampering detection', () => { + const userAddress = '0x742d35cc6634c0532925a3b844bc9e7595f0beb8'; + const referralAddress = '0x371240E80Bf84eC2bA8b55aE2fD0B467b16Db2be'; + + const validLidoStakeTx = { + to: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + from: userAddress, + value: '0xde0b6b3a7640000', + data: '0xa1903eab' + referralAddress.slice(2).padStart(64, '0'), + chainId: 1, + }; + + it('should reject transaction with appended data', () => { + const tamperedTx = { + ...validLidoStakeTx, + data: validLidoStakeTx.data + 'deadbeef', + }; + + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(tamperedTx), + userAddress: userAddress, + }); + + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(false); + }); + + it('should reject transaction with modified function selector', () => { + const tamperedTx = { + ...validLidoStakeTx, + data: '0xdeadbeef' + validLidoStakeTx.data.slice(10), + }; + + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(tamperedTx), + userAddress: userAddress, + }); + + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(false); + }); + + it('should reject truncated transaction data', () => { + const truncatedTx = { + ...validLidoStakeTx, + data: validLidoStakeTx.data.slice(0, -8), // Remove last 4 bytes + }; + + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(truncatedTx), + userAddress: userAddress, + }); + + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(false); + }); + + it('should reject malformed JSON in unsignedTransaction', () => { + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: '{ invalid json }', + userAddress: userAddress, + }); + + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(false); + }); + }); + + describe('security: input limits', () => { + it('should reject oversized input', () => { + const hugeInput = JSON.stringify({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: 'x'.repeat(200 * 1024), // 200KB - over 100KB limit + userAddress: '0x742d35cc6634c0532925a3b844bc9e7595f0beb8', + }); + + const response = JSON.parse(handleJsonRequest(hugeInput)); + + expect(response.ok).toBe(false); + expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); + expect(response.error.message).toContain('exceeds maximum size'); + }); + }); + + describe('response integrity', () => { + it('should include consistent requestHash for same input', () => { + const input = { apiVersion: '1.0', operation: 'getSupportedYieldIds' }; + + const response1 = call(input); + const response2 = call(input); + + expect(response1.meta.requestHash).toBe(response2.meta.requestHash); + }); + + it('should include different requestHash for different input', () => { + const response1 = call({ apiVersion: '1.0', operation: 'getSupportedYieldIds' }); + const response2 = call({ apiVersion: '1.0', operation: 'isSupported', yieldId: 'x' }); + + expect(response1.meta.requestHash).not.toBe(response2.meta.requestHash); + }); + }); +}); \ No newline at end of file From d1811e99a9ccd525691b7731b4b7872b70491161 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Thu, 8 Jan 2026 22:29:43 -0800 Subject: [PATCH 04/10] update readme --- README.md | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 175 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d8f960a..17f630a 100644 --- a/README.md +++ b/README.md @@ -26,15 +26,15 @@ const shield = new Shield(); // Get transaction from Yield API const response = await fetch('https://api.yield.xyz/v1/actions/enter', { method: 'POST', - headers: { + headers: { 'Content-Type': 'application/json', - 'X-API-Key': process.env.YIELD_API_KEY // Your API key + 'X-API-Key': process.env.YIELD_API_KEY, // Your API key }, body: JSON.stringify({ yieldId: 'ethereum-eth-lido-staking', address: userWalletAddress, - arguments: { amount: '0.01' } - }) + arguments: { amount: '0.01' }, + }), }); const action = await response.json(); @@ -45,9 +45,9 @@ for (const transaction of action.transactions) { unsignedTransaction: transaction.unsignedTransaction, yieldId: action.yieldId, userAddress: userWalletAddress, - args: action.arguments // Optional + args: action.arguments, // Optional }); - + if (!result.isValid) { throw new Error(`Invalid transaction: ${result.reason}`); } @@ -58,6 +58,171 @@ for (const transaction of action.transactions) { Shield automatically detects and validates transaction types through pattern matching. Each transaction must match exactly one known pattern to be considered valid. +## Using Shield from Other Languages + +Shield is written in TypeScript, but can be used from **any programming language** via its CLI (Command Line Interface). + +### Why Two Approaches? + +| Your Language | How to Use Shield | +| ---------------------------------- | ------------------------------------------------- | +| TypeScript/JavaScript | Import the library directly (see [Usage](#usage)) | +| Python, Go, Ruby, Rust, Java, etc. | Use the CLI via subprocess | + +The CLI approach means you get the **exact same validation logic** without rewriting Shield in your language. + +### The JSON Protocol + +The CLI reads JSON from stdin and writes JSON to stdout: + +**Input:** + +```json +{ + "apiVersion": "1.0", + "operation": "validate", + "yieldId": "ethereum-eth-lido-staking", + "unsignedTransaction": "{\"to\":\"0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84\",...}", + "userAddress": "0x742d35cc6634c0532925a3b844bc9e7595f0beb8" +} +``` + +**Output:** + +```json +{ + "ok": true, + "apiVersion": "1.0", + "result": { + "isValid": true, + "detectedType": "STAKE" + }, + "meta": { + "requestHash": "a1b2c3..." + } +} +``` + +### Operations + +| Operation | Required Fields | Description | +| ---------------------- | ----------------------------------------------- | ----------------------------- | +| `validate` | `yieldId`, `unsignedTransaction`, `userAddress` | Validate a transaction | +| `isSupported` | `yieldId` | Check if a yield is supported | +| `getSupportedYieldIds` | (none) | List all supported yields | + +### CLI Examples (Bash) + +```bash +# List supported yields +echo '{"apiVersion":"1.0","operation":"getSupportedYieldIds"}' | npx @yieldxyz/shield + +# Check if a yield is supported +echo '{"apiVersion":"1.0","operation":"isSupported","yieldId":"ethereum-eth-lido-staking"}' | npx @yieldxyz/shield + +# Validate a transaction +echo '{"apiVersion":"1.0","operation":"validate","yieldId":"ethereum-eth-lido-staking","unsignedTransaction":"{...}","userAddress":"0x..."}' | npx @yieldxyz/shield +``` + +### Python Example + +```python +import subprocess +import json + +def validate_transaction(yield_id: str, unsigned_tx: str, user_address: str) -> dict: + request = { + "apiVersion": "1.0", + "operation": "validate", + "yieldId": yield_id, + "unsignedTransaction": unsigned_tx, + "userAddress": user_address + } + + result = subprocess.run( + ["npx", "@yieldxyz/shield"], + input=json.dumps(request), + capture_output=True, + text=True + ) + + return json.loads(result.stdout) + +# Usage +response = validate_transaction( + "ethereum-eth-lido-staking", + '{"to":"0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84",...}', + "0x742d35cc6634c0532925a3b844bc9e7595f0beb8" +) + +if response["ok"] and response["result"]["isValid"]: + print("Transaction is valid!") +else: + print(f"Blocked: {response['result'].get('reason')}") +``` + +### Go Example + +```go +package main + +import ( + "bytes" + "encoding/json" + "os/exec" +) + +func validateTransaction(yieldId, unsignedTx, userAddress string) (bool, error) { + request := map[string]string{ + "apiVersion": "1.0", + "operation": "validate", + "yieldId": yieldId, + "unsignedTransaction": unsignedTx, + "userAddress": userAddress, + } + + input, _ := json.Marshal(request) + + cmd := exec.Command("npx", "@yieldxyz/shield") + cmd.Stdin = bytes.NewReader(input) + + output, err := cmd.Output() + if err != nil { + return false, err + } + + var resp struct { + Ok bool `json:"ok"` + Result struct { + IsValid bool `json:"isValid"` + } `json:"result"` + } + json.Unmarshal(output, &resp) + + return resp.Ok && resp.Result.IsValid, nil +} +``` + +### Ruby Example + +```ruby +require 'json' +require 'open3' + +def validate_transaction(yield_id, unsigned_tx, user_address) + request = { + apiVersion: "1.0", + operation: "validate", + yieldId: yield_id, + unsignedTransaction: unsigned_tx, + userAddress: user_address + } + + stdout, _status = Open3.capture2("npx @yieldxyz/shield", stdin_data: request.to_json) + JSON.parse(stdout) +end +``` + ## Supported Yield IDs - `ethereum-eth-lido-staking` @@ -71,6 +236,7 @@ Shield automatically detects and validates transaction types through pattern mat Validates a transaction by auto-detecting its type. **Parameters:** + ```typescript { unsignedTransaction: string; // Transaction from Yield API @@ -82,6 +248,7 @@ Validates a transaction by auto-detecting its type. ``` **Returns:** + ```typescript { isValid: boolean; @@ -102,6 +269,7 @@ Get all supported yield IDs. ## Error Messages Common validation failures: + - `"Invalid referral address"` - Wrong referral in transaction - `"Withdrawal owner does not match user address"` - Ownership mismatch - `"Transaction validation failed: No matching operation pattern found"` - Transaction doesn't match any supported pattern @@ -109,4 +277,4 @@ Common validation failures: ## License -MIT \ No newline at end of file +MIT From cc9f28dc46dc5863962f223055ccfdf735f0c73a Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Thu, 8 Jan 2026 22:43:49 -0800 Subject: [PATCH 05/10] lint --- .github/workflows/ci.yml | 34 +++--- .github/workflows/release.yml | 24 ++-- package.json | 2 +- rslib.config.ts | 2 +- src/cli.ts | 12 +- src/index.ts | 2 +- src/json/handler.test.ts | 203 ++++++++++++++++++---------------- src/json/handler.ts | 148 +++++++++++++++---------- src/json/index.ts | 12 +- src/json/schema.ts | 84 +++++++------- src/json/types.ts | 12 +- 11 files changed, 293 insertions(+), 242 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e08f20..c1b4e29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,26 +16,26 @@ jobs: strategy: matrix: node-version: [20.17.0, 22.x] - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - + - name: Install pnpm uses: pnpm/action-setup@v2 with: version: 10.12.2 - + - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - + - name: Setup pnpm cache uses: actions/cache@v3 with: @@ -43,19 +43,19 @@ jobs: key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - + - name: Install dependencies run: pnpm install --frozen-lockfile - + - name: Run linter run: pnpm run lint - + - name: Run tests run: pnpm run test:coverage - + - name: Build package run: pnpm run build - + - name: Upload coverage to Codecov if: matrix.node-version == '20.17.0' uses: codecov/codecov-action@v3 @@ -68,28 +68,28 @@ jobs: security: name: Security Audit runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20.17.0 - + - name: Install pnpm uses: pnpm/action-setup@v2 with: version: 10.12.2 - + - name: Install dependencies run: pnpm install --frozen-lockfile - + - name: Run pnpm audit run: pnpm audit --audit-level=moderate continue-on-error: true - + - name: Check for dependency updates run: pnpm outdated - continue-on-error: true \ No newline at end of file + continue-on-error: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10a123c..11a8cfa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,30 +11,30 @@ jobs: environment: production permissions: contents: read - id-token: write - + id-token: write + steps: - name: Checkout code uses: actions/checkout@v4 with: - ref: main - + ref: main + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20.17.0 registry-url: 'https://registry.npmjs.org' - + - name: Install pnpm uses: pnpm/action-setup@v2 with: version: 10.12.2 - + - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - + - name: Setup pnpm cache uses: actions/cache@v3 with: @@ -42,17 +42,17 @@ jobs: key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - + - name: Install dependencies run: pnpm install --frozen-lockfile - + - name: Run tests run: pnpm test - + - name: Build package run: pnpm run build - + - name: Publish to NPM run: pnpm publish --access public --no-git-checks --provenance env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package.json b/package.json index f439cd5..4506a78 100644 --- a/package.json +++ b/package.json @@ -64,4 +64,4 @@ "ts-jest": "^29.0.0", "typescript": "^5.0.0" } -} \ No newline at end of file +} diff --git a/rslib.config.ts b/rslib.config.ts index 8286133..bd84ba0 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -24,7 +24,7 @@ export default defineConfig({ entry: { cli: path.join(__dirname, 'src', 'cli.ts'), }, - } + }, }, ], source: { diff --git a/src/cli.ts b/src/cli.ts index 392ec1e..47994cc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,9 +6,9 @@ const MAX_INPUT_SIZE = 100 * 1024; // 100KB async function readStdin(): Promise { return new Promise((resolve, reject) => { let data = ''; - + process.stdin.setEncoding('utf8'); - + process.stdin.on('data', (chunk) => { data += chunk; // SECURITY: Enforce size limit during streaming @@ -16,7 +16,7 @@ async function readStdin(): Promise { reject(new Error('Input exceeds maximum size')); } }); - + process.stdin.on('end', () => resolve(data)); process.stdin.on('error', reject); }); @@ -35,13 +35,13 @@ async function main(): Promise { apiVersion: '1.0', error: { code: 'INTERNAL_ERROR', - message: 'Failed to process request' + message: 'Failed to process request', }, - meta: { requestHash: 'unavailable' } + meta: { requestHash: 'unavailable' }, }; process.stdout.write(JSON.stringify(errorResponse) + '\n'); process.exit(1); } } -main(); \ No newline at end of file +main(); diff --git a/src/index.ts b/src/index.ts index a4f65dd..780aa49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,4 +18,4 @@ export type { IsSupportedResult, GetSupportedYieldIdsResult, ErrorCode, -} from './json'; \ No newline at end of file +} from './json'; diff --git a/src/json/handler.test.ts b/src/json/handler.test.ts index df9076a..9b5d49a 100644 --- a/src/json/handler.test.ts +++ b/src/json/handler.test.ts @@ -13,145 +13,149 @@ describe('handleJsonRequest', () => { const referralAddress = '0x371240E80Bf84eC2bA8b55aE2fD0B467b16Db2be'; const validLidoStakeTx = { - to: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', // Lido stETH - from: userAddress, - value: '0xde0b6b3a7640000', // 1 ETH - data: '0xa1903eab' + referralAddress.slice(2).padStart(64, '0'), // submit(referral) - chainId: 1, + to: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', // Lido stETH + from: userAddress, + value: '0xde0b6b3a7640000', // 1 ETH + data: '0xa1903eab' + referralAddress.slice(2).padStart(64, '0'), // submit(referral) + chainId: 1, }; const validLidoUnstakeTx = { - to: '0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1', // Lido withdrawal queue - from: userAddress, - value: '0x0', - data: + to: '0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1', // Lido withdrawal queue + from: userAddress, + value: '0x0', + data: '0xd6681042' + // requestWithdrawals function selector '0000000000000000000000000000000000000000000000000000000000000040' + // amounts offset '000000000000000000000000' + userAddress.slice(2) + // owner '0000000000000000000000000000000000000000000000000000000000000001' + // array length '0000000000000000000000000000000000000000000000000de0b6b3a7640000', // amount - chainId: 1, + chainId: 1, }; it('should validate a correct Lido stake transaction', () => { - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: JSON.stringify(validLidoStakeTx), userAddress: userAddress, - }); + }); - expect(response.ok).toBe(true); - expect(response.result.isValid).toBe(true); - expect(response.result.detectedType).toBe('STAKE'); - expect(response.meta.requestHash).toMatch(/^[a-f0-9]{64}$/); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); + expect(response.result.detectedType).toBe('STAKE'); + expect(response.meta.requestHash).toMatch(/^[a-f0-9]{64}$/); }); it('should validate a correct Lido unstake transaction', () => { - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: JSON.stringify(validLidoUnstakeTx), userAddress: userAddress, - }); + }); - expect(response.ok).toBe(true); - expect(response.result.isValid).toBe(true); - expect(response.result.detectedType).toBe('UNSTAKE'); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); + expect(response.result.detectedType).toBe('UNSTAKE'); }); it('should reject transaction with wrong referral address', () => { - const invalidReferralTx = { + const invalidReferralTx = { ...validLidoStakeTx, - data: '0xa1903eab' + '0000000000000000000000000000000000000000000000000000000000000000', // wrong referral - }; + data: + '0xa1903eab' + + '0000000000000000000000000000000000000000000000000000000000000000', // wrong referral + }; - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: JSON.stringify(invalidReferralTx), userAddress: userAddress, - }); + }); - expect(response.ok).toBe(true); // Request succeeded - expect(response.result.isValid).toBe(false); // But validation failed - expect(response.result.reason).toContain('No matching operation pattern found'); + expect(response.ok).toBe(true); // Request succeeded + expect(response.result.isValid).toBe(false); // But validation failed + expect(response.result.reason).toContain( + 'No matching operation pattern found', + ); }); it('should reject transaction with wrong contract address', () => { - const wrongContractTx = { + const wrongContractTx = { ...validLidoStakeTx, to: '0x0000000000000000000000000000000000000000', // wrong contract - }; + }; - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: JSON.stringify(wrongContractTx), userAddress: userAddress, - }); + }); - expect(response.ok).toBe(true); - expect(response.result.isValid).toBe(false); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(false); }); it('should reject transaction with wrong from address', () => { - const wrongFromTx = { + const wrongFromTx = { ...validLidoStakeTx, from: '0x0000000000000000000000000000000000000001', // different from userAddress - }; + }; - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: JSON.stringify(wrongFromTx), userAddress: userAddress, - }); + }); - expect(response.ok).toBe(true); - expect(response.result.isValid).toBe(false); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(false); }); it('should return error for missing yieldId', () => { - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', unsignedTransaction: JSON.stringify(validLidoStakeTx), userAddress: userAddress, - }); + }); - expect(response.ok).toBe(false); - expect(response.error.code).toBe('MISSING_REQUIRED_FIELD'); + expect(response.ok).toBe(false); + expect(response.error.code).toBe('MISSING_REQUIRED_FIELD'); }); it('should return error for missing unsignedTransaction', () => { - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', userAddress: userAddress, - }); + }); - expect(response.ok).toBe(false); - expect(response.error.code).toBe('MISSING_REQUIRED_FIELD'); + expect(response.ok).toBe(false); + expect(response.error.code).toBe('MISSING_REQUIRED_FIELD'); }); it('should return error for missing userAddress', () => { - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: JSON.stringify(validLidoStakeTx), - }); + }); - expect(response.ok).toBe(false); - expect(response.error.code).toBe('MISSING_REQUIRED_FIELD'); + expect(response.ok).toBe(false); + expect(response.error.code).toBe('MISSING_REQUIRED_FIELD'); }); }); @@ -188,14 +192,16 @@ describe('handleJsonRequest', () => { expect(response.ok).toBe(true); expect(response.result.yieldIds).toContain('ethereum-eth-lido-staking'); - expect(response.result.yieldIds).toContain('solana-sol-native-multivalidator-staking'); + expect(response.result.yieldIds).toContain( + 'solana-sol-native-multivalidator-staking', + ); }); }); describe('security: schema validation', () => { it('should reject invalid JSON', () => { const response = call('{ invalid json }'); - + expect(response.ok).toBe(false); expect(response.error.code).toBe('PARSE_ERROR'); }); @@ -224,7 +230,7 @@ describe('handleJsonRequest', () => { const response = call({ apiVersion: '1.0', operation: 'getSupportedYieldIds', - __proto__: { admin: true }, // Attempted prototype pollution + __proto__: { admin: true }, // Attempted prototype pollution maliciousField: 'value', }); @@ -236,7 +242,7 @@ describe('handleJsonRequest', () => { const response = call({ apiVersion: '1.0', operation: 'isSupported', - yieldId: 'x'.repeat(300), // Exceeds 256 char limit + yieldId: 'x'.repeat(300), // Exceeds 256 char limit }); expect(response.ok).toBe(false); @@ -249,103 +255,103 @@ describe('handleJsonRequest', () => { const referralAddress = '0x371240E80Bf84eC2bA8b55aE2fD0B467b16Db2be'; const validLidoStakeTx = { - to: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', - from: userAddress, - value: '0xde0b6b3a7640000', - data: '0xa1903eab' + referralAddress.slice(2).padStart(64, '0'), - chainId: 1, + to: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + from: userAddress, + value: '0xde0b6b3a7640000', + data: '0xa1903eab' + referralAddress.slice(2).padStart(64, '0'), + chainId: 1, }; it('should reject transaction with appended data', () => { - const tamperedTx = { + const tamperedTx = { ...validLidoStakeTx, data: validLidoStakeTx.data + 'deadbeef', - }; + }; - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: JSON.stringify(tamperedTx), userAddress: userAddress, - }); + }); - expect(response.ok).toBe(true); - expect(response.result.isValid).toBe(false); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(false); }); it('should reject transaction with modified function selector', () => { - const tamperedTx = { + const tamperedTx = { ...validLidoStakeTx, data: '0xdeadbeef' + validLidoStakeTx.data.slice(10), - }; + }; - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: JSON.stringify(tamperedTx), userAddress: userAddress, - }); + }); - expect(response.ok).toBe(true); - expect(response.result.isValid).toBe(false); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(false); }); it('should reject truncated transaction data', () => { - const truncatedTx = { + const truncatedTx = { ...validLidoStakeTx, data: validLidoStakeTx.data.slice(0, -8), // Remove last 4 bytes - }; + }; - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: JSON.stringify(truncatedTx), userAddress: userAddress, - }); + }); - expect(response.ok).toBe(true); - expect(response.result.isValid).toBe(false); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(false); }); it('should reject malformed JSON in unsignedTransaction', () => { - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: '{ invalid json }', userAddress: userAddress, - }); + }); - expect(response.ok).toBe(true); - expect(response.result.isValid).toBe(false); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(false); }); }); describe('security: input limits', () => { it('should reject oversized input', () => { - const hugeInput = JSON.stringify({ + const hugeInput = JSON.stringify({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: 'x'.repeat(200 * 1024), // 200KB - over 100KB limit userAddress: '0x742d35cc6634c0532925a3b844bc9e7595f0beb8', - }); + }); - const response = JSON.parse(handleJsonRequest(hugeInput)); + const response = JSON.parse(handleJsonRequest(hugeInput)); - expect(response.ok).toBe(false); - expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); - expect(response.error.message).toContain('exceeds maximum size'); + expect(response.ok).toBe(false); + expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); + expect(response.error.message).toContain('exceeds maximum size'); }); }); - + describe('response integrity', () => { it('should include consistent requestHash for same input', () => { const input = { apiVersion: '1.0', operation: 'getSupportedYieldIds' }; - + const response1 = call(input); const response2 = call(input); @@ -353,10 +359,17 @@ describe('handleJsonRequest', () => { }); it('should include different requestHash for different input', () => { - const response1 = call({ apiVersion: '1.0', operation: 'getSupportedYieldIds' }); - const response2 = call({ apiVersion: '1.0', operation: 'isSupported', yieldId: 'x' }); + const response1 = call({ + apiVersion: '1.0', + operation: 'getSupportedYieldIds', + }); + const response2 = call({ + apiVersion: '1.0', + operation: 'isSupported', + yieldId: 'x', + }); expect(response1.meta.requestHash).not.toBe(response2.meta.requestHash); }); }); -}); \ No newline at end of file +}); diff --git a/src/json/handler.ts b/src/json/handler.ts index 92888de..9c9b1e0 100644 --- a/src/json/handler.ts +++ b/src/json/handler.ts @@ -2,15 +2,15 @@ import Ajv from 'ajv'; import { createHash } from 'crypto'; import { Shield } from '../shield'; import { requestSchema, operationRequirements } from './schema'; -import type { - JsonRequest, - JsonResponse, +import type { + JsonRequest, + JsonResponse, JsonSuccessResponse, JsonErrorResponse, - ValidateResult, - IsSupportedResult, + ValidateResult, + IsSupportedResult, GetSupportedYieldIdsResult, - ErrorCode + ErrorCode, } from './types'; // SECURITY: Pre-compiled schema validator (prevents ReDoS on repeated calls) @@ -33,7 +33,7 @@ function computeRequestHash(input: string): string { /** * Main entry point for JSON interface. - * + * * SECURITY GUARANTEES: * 1. Input is validated against strict JSON schema before processing * 2. All responses include request hash for integrity verification @@ -43,14 +43,16 @@ function computeRequestHash(input: string): string { */ export function handleJsonRequest(jsonInput: string): string { const requestHash = computeRequestHash(jsonInput); - + // SECURITY: Check input size before parsing if (jsonInput.length > MAX_INPUT_SIZE) { - return JSON.stringify(errorResponse( - 'SCHEMA_VALIDATION_ERROR', - `Input exceeds maximum size of ${MAX_INPUT_SIZE} bytes`, - requestHash - )); + return JSON.stringify( + errorResponse( + 'SCHEMA_VALIDATION_ERROR', + `Input exceeds maximum size of ${MAX_INPUT_SIZE} bytes`, + requestHash, + ), + ); } // Step 1: Parse JSON @@ -58,22 +60,23 @@ export function handleJsonRequest(jsonInput: string): string { try { request = JSON.parse(jsonInput); } catch (e) { - return JSON.stringify(errorResponse( - 'PARSE_ERROR', - 'Invalid JSON syntax', - requestHash, - { parseError: e instanceof Error ? e.message : String(e) } - )); + return JSON.stringify( + errorResponse('PARSE_ERROR', 'Invalid JSON syntax', requestHash, { + parseError: e instanceof Error ? e.message : String(e), + }), + ); } // Step 2: Validate against schema (SECURITY: strict validation) if (!validateSchema(request)) { - return JSON.stringify(errorResponse( - 'SCHEMA_VALIDATION_ERROR', - 'Request does not match expected schema', - requestHash, - { validationErrors: validateSchema.errors } - )); + return JSON.stringify( + errorResponse( + 'SCHEMA_VALIDATION_ERROR', + 'Request does not match expected schema', + requestHash, + { validationErrors: validateSchema.errors }, + ), + ); } const validRequest = request as unknown as JsonRequest; @@ -81,12 +84,17 @@ export function handleJsonRequest(jsonInput: string): string { // Step 3: Check operation-specific required fields const requiredFields = operationRequirements[validRequest.operation]; for (const field of requiredFields) { - if (!(field in validRequest) || validRequest[field as keyof JsonRequest] === undefined) { - return JSON.stringify(errorResponse( - 'MISSING_REQUIRED_FIELD', - `Operation '${validRequest.operation}' requires field '${field}'`, - requestHash - )); + if ( + !(field in validRequest) || + validRequest[field as keyof JsonRequest] === undefined + ) { + return JSON.stringify( + errorResponse( + 'MISSING_REQUIRED_FIELD', + `Operation '${validRequest.operation}' requires field '${field}'`, + requestHash, + ), + ); } } @@ -102,15 +110,20 @@ export function handleJsonRequest(jsonInput: string): string { } } catch (e) { // SECURITY: Never expose internal error details in production - return JSON.stringify(errorResponse( - 'INTERNAL_ERROR', - 'An unexpected error occurred', - requestHash - )); + return JSON.stringify( + errorResponse( + 'INTERNAL_ERROR', + 'An unexpected error occurred', + requestHash, + ), + ); } } -function handleValidate(request: JsonRequest, requestHash: string): JsonResponse { +function handleValidate( + request: JsonRequest, + requestHash: string, +): JsonResponse { const result = shield.validate({ yieldId: request.yieldId!, unsignedTransaction: request.unsignedTransaction!, @@ -119,47 +132,64 @@ function handleValidate(request: JsonRequest, requestHash: string): JsonResponse context: request.context, }); - return successResponse({ - isValid: result.isValid, - reason: result.reason, - details: result.details, - detectedType: result.detectedType, - }, requestHash); + return successResponse( + { + isValid: result.isValid, + reason: result.reason, + details: result.details, + detectedType: result.detectedType, + }, + requestHash, + ); } -function handleIsSupported(request: JsonRequest, requestHash: string): JsonResponse { - return successResponse({ - supported: shield.isSupported(request.yieldId!), - yieldId: request.yieldId!, - }, requestHash); +function handleIsSupported( + request: JsonRequest, + requestHash: string, +): JsonResponse { + return successResponse( + { + supported: shield.isSupported(request.yieldId!), + yieldId: request.yieldId!, + }, + requestHash, + ); } -function handleGetSupportedYieldIds(requestHash: string): JsonResponse { - return successResponse({ - yieldIds: shield.getSupportedYieldIds(), - }, requestHash); +function handleGetSupportedYieldIds( + requestHash: string, +): JsonResponse { + return successResponse( + { + yieldIds: shield.getSupportedYieldIds(), + }, + requestHash, + ); } // Helper functions for consistent response formatting -function successResponse(result: T, requestHash: string): JsonSuccessResponse { +function successResponse( + result: T, + requestHash: string, +): JsonSuccessResponse { return { ok: true, apiVersion: '1.0', result, - meta: { requestHash } + meta: { requestHash }, }; } function errorResponse( - code: ErrorCode, - message: string, + code: ErrorCode, + message: string, requestHash: string, - details?: unknown + details?: unknown, ): JsonErrorResponse { return { ok: false, apiVersion: '1.0', error: { code, message, details }, - meta: { requestHash } + meta: { requestHash }, }; -} \ No newline at end of file +} diff --git a/src/json/index.ts b/src/json/index.ts index d8d1a9b..28d57e0 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -1,11 +1,11 @@ export { handleJsonRequest } from './handler'; -export type { - JsonRequest, - JsonResponse, - JsonSuccessResponse, +export type { + JsonRequest, + JsonResponse, + JsonSuccessResponse, JsonErrorResponse, ValidateResult, IsSupportedResult, GetSupportedYieldIdsResult, - ErrorCode -} from './types'; \ No newline at end of file + ErrorCode, +} from './types'; diff --git a/src/json/schema.ts b/src/json/schema.ts index 21ff650..4cc3872 100644 --- a/src/json/schema.ts +++ b/src/json/schema.ts @@ -2,41 +2,45 @@ export const requestSchema = { type: 'object', required: ['apiVersion', 'operation'], - additionalProperties: false, // SECURITY: Reject unknown properties + additionalProperties: false, // SECURITY: Reject unknown properties properties: { - apiVersion: { - type: 'string', - enum: ['1.0'] // Explicit version whitelist + apiVersion: { + type: 'string', + enum: ['1.0'], // Explicit version whitelist }, - operation: { - type: 'string', - enum: ['validate', 'isSupported', 'getSupportedYieldIds'] + operation: { + type: 'string', + enum: ['validate', 'isSupported', 'getSupportedYieldIds'], }, - yieldId: { + yieldId: { type: 'string', minLength: 1, - maxLength: 256 // Reasonable limit + maxLength: 256, // Reasonable limit }, - unsignedTransaction: { + unsignedTransaction: { type: 'string', minLength: 1, - maxLength: 102400 // 100KB limit for transaction data + maxLength: 102400, // 100KB limit for transaction data }, - userAddress: { + userAddress: { type: 'string', minLength: 1, - maxLength: 128 + maxLength: 128, }, args: { - type: 'object', - additionalProperties: false, // Security: reject unknown fields - properties: { + type: 'object', + additionalProperties: false, // Security: reject unknown fields + properties: { // Currently used by Tron validatorAddress: { type: 'string', maxLength: 128 }, - validatorAddresses: { type: 'array', items: { type: 'string', maxLength: 128 }, maxItems: 100 }, - + validatorAddresses: { + type: 'array', + items: { type: 'string', maxLength: 128 }, + maxItems: 100, + }, + // Future use - include for forward compatibility - amount: { type: 'string', maxLength: 78 }, // Max uint256 is 78 digits + amount: { type: 'string', maxLength: 78 }, // Max uint256 is 78 digits tronResource: { type: 'string', enum: ['BANDWIDTH', 'ENERGY'] }, providerId: { type: 'string', maxLength: 256 }, duration: { type: 'number', minimum: 0 }, @@ -46,34 +50,38 @@ export const requestSchema = { cosmosPubKey: { type: 'string', maxLength: 256 }, tezosPubKey: { type: 'string', maxLength: 256 }, nominatorAddress: { type: 'string', maxLength: 128 }, - nftIds: { type: 'array', items: { type: 'string', maxLength: 256 }, maxItems: 100 }, - } + nftIds: { + type: 'array', + items: { type: 'string', maxLength: 256 }, + maxItems: 100, + }, + }, }, context: { - type: 'object', - additionalProperties: false, - properties: { + type: 'object', + additionalProperties: false, + properties: { feeConfiguration: { - type: 'array', - maxItems: 100, - items: { + type: 'array', + maxItems: 100, + items: { type: 'object', additionalProperties: false, properties: { - depositFeeBps: { type: 'number', minimum: 0, maximum: 10000 }, - feeRecipientAddress: { type: 'string', maxLength: 128 }, - allocatorVaultAddress: { type: 'string', maxLength: 128 } - } - } - } - } - } - } + depositFeeBps: { type: 'number', minimum: 0, maximum: 10000 }, + feeRecipientAddress: { type: 'string', maxLength: 128 }, + allocatorVaultAddress: { type: 'string', maxLength: 128 }, + }, + }, + }, + }, + }, + }, }; // Operation-specific required fields export const operationRequirements = { validate: ['yieldId', 'unsignedTransaction', 'userAddress'], isSupported: ['yieldId'], - getSupportedYieldIds: [] -}; \ No newline at end of file + getSupportedYieldIds: [], +}; diff --git a/src/json/types.ts b/src/json/types.ts index 20a0633..068352d 100644 --- a/src/json/types.ts +++ b/src/json/types.ts @@ -15,7 +15,7 @@ export interface JsonSuccessResponse { apiVersion: '1.0'; result: T; meta: { - requestHash: string; // SHA-256 of request for integrity verification + requestHash: string; // SHA-256 of request for integrity verification }; } @@ -34,11 +34,11 @@ export interface JsonErrorResponse { export type JsonResponse = JsonSuccessResponse | JsonErrorResponse; -export type ErrorCode = - | 'PARSE_ERROR' // Invalid JSON syntax +export type ErrorCode = + | 'PARSE_ERROR' // Invalid JSON syntax | 'SCHEMA_VALIDATION_ERROR' // Failed Ajv validation - | 'MISSING_REQUIRED_FIELD' // Operation-specific required field missing - | 'INTERNAL_ERROR'; // Unexpected error (should never happen) + | 'MISSING_REQUIRED_FIELD' // Operation-specific required field missing + | 'INTERNAL_ERROR'; // Unexpected error (should never happen) // Result types for each operation export interface ValidateResult { @@ -55,4 +55,4 @@ export interface IsSupportedResult { export interface GetSupportedYieldIdsResult { yieldIds: string[]; -} \ No newline at end of file +} From d7ceb00a5e2ffc88b85d15897957e16570e417e0 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Thu, 8 Jan 2026 22:46:39 -0800 Subject: [PATCH 06/10] lint --- src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 47994cc..e007572 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -44,4 +44,4 @@ async function main(): Promise { } } -main(); +void main(); From 140dc75f8e27e1abe6388aa45a5ea1ddbe6525cd Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Thu, 8 Jan 2026 22:59:54 -0800 Subject: [PATCH 07/10] args and context passing tests, default operation handler --- src/json/handler.test.ts | 74 ++++++++++++++++++++++++++++++++++++++++ src/json/handler.ts | 11 ++++++ 2 files changed, 85 insertions(+) diff --git a/src/json/handler.test.ts b/src/json/handler.test.ts index 9b5d49a..ec7d472 100644 --- a/src/json/handler.test.ts +++ b/src/json/handler.test.ts @@ -159,6 +159,80 @@ describe('handleJsonRequest', () => { }); }); + describe('optional parameters: args and context', () => { + const userAddress = '0x742d35cc6634c0532925a3b844bc9e7595f0beb8'; + const referralAddress = '0x371240E80Bf84eC2bA8b55aE2fD0B467b16Db2be'; + + const validLidoStakeTx = { + to: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + from: userAddress, + value: '0xde0b6b3a7640000', + data: '0xa1903eab' + referralAddress.slice(2).padStart(64, '0'), + chainId: 1, + }; + + it('should forward args parameter to Shield validator', () => { + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(validLidoStakeTx), + userAddress: userAddress, + args: { + amount: '1000000000000000000', + }, + }); + + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); + }); + + it('should forward context parameter to Shield validator', () => { + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(validLidoStakeTx), + userAddress: userAddress, + context: { + feeConfiguration: [ + { + depositFeeBps: 100, + feeRecipientAddress: '0x1234567890123456789012345678901234567890', + }, + ], + }, + }); + + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); + }); + + it('should forward both args and context parameters together', () => { + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(validLidoStakeTx), + userAddress: userAddress, + args: { + amount: '1000000000000000000', + }, + context: { + feeConfiguration: [ + { + depositFeeBps: 50, + feeRecipientAddress: '0x1234567890123456789012345678901234567890', + }, + ], + }, + }); + + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); + }); + }); + describe('isSupported operation', () => { it('should return supported: true for known yield', () => { const response = call({ diff --git a/src/json/handler.ts b/src/json/handler.ts index 9c9b1e0..2489737 100644 --- a/src/json/handler.ts +++ b/src/json/handler.ts @@ -107,6 +107,17 @@ export function handleJsonRequest(jsonInput: string): string { return JSON.stringify(handleIsSupported(validRequest, requestHash)); case 'getSupportedYieldIds': return JSON.stringify(handleGetSupportedYieldIds(requestHash)); + default: { + // SECURITY: Defense-in-depth - schema validation should prevent this + const exhaustiveCheck: never = validRequest.operation; + return JSON.stringify( + errorResponse( + 'INTERNAL_ERROR', + `Unknown operation: ${exhaustiveCheck}`, + requestHash, + ), + ); + } } } catch (e) { // SECURITY: Never expose internal error details in production From b77a3f80415cf52c1881a5848294d1a6843dcb24 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Thu, 8 Jan 2026 23:01:16 -0800 Subject: [PATCH 08/10] lint --- src/json/handler.test.ts | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/json/handler.test.ts b/src/json/handler.test.ts index ec7d472..2106d4c 100644 --- a/src/json/handler.test.ts +++ b/src/json/handler.test.ts @@ -188,48 +188,48 @@ describe('handleJsonRequest', () => { }); it('should forward context parameter to Shield validator', () => { - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: JSON.stringify(validLidoStakeTx), userAddress: userAddress, context: { - feeConfiguration: [ + feeConfiguration: [ { - depositFeeBps: 100, - feeRecipientAddress: '0x1234567890123456789012345678901234567890', + depositFeeBps: 100, + feeRecipientAddress: '0x1234567890123456789012345678901234567890', }, - ], + ], }, - }); + }); - expect(response.ok).toBe(true); - expect(response.result.isValid).toBe(true); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); }); it('should forward both args and context parameters together', () => { - const response = call({ + const response = call({ apiVersion: '1.0', operation: 'validate', yieldId: 'ethereum-eth-lido-staking', unsignedTransaction: JSON.stringify(validLidoStakeTx), userAddress: userAddress, args: { - amount: '1000000000000000000', + amount: '1000000000000000000', }, context: { - feeConfiguration: [ + feeConfiguration: [ { - depositFeeBps: 50, - feeRecipientAddress: '0x1234567890123456789012345678901234567890', + depositFeeBps: 50, + feeRecipientAddress: '0x1234567890123456789012345678901234567890', }, - ], + ], }, - }); + }); - expect(response.ok).toBe(true); - expect(response.result.isValid).toBe(true); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); }); }); From 8866dea72df2e8106004a7c00ad3668b23f543ab Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Thu, 15 Jan 2026 16:40:55 -0800 Subject: [PATCH 09/10] fix: track input byte length --- src/cli.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index e007572..29b5b30 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,19 +5,25 @@ const MAX_INPUT_SIZE = 100 * 1024; // 100KB async function readStdin(): Promise { return new Promise((resolve, reject) => { - let data = ''; + const chunks: Buffer[] = []; + let totalBytes = 0; - process.stdin.setEncoding('utf8'); - - process.stdin.on('data', (chunk) => { - data += chunk; + // Don't set encoding - keep as Buffer for accurate byte counting + process.stdin.on('data', (chunk: Buffer) => { + totalBytes += chunk.length; // Buffer.length is actual bytes // SECURITY: Enforce size limit during streaming - if (data.length > MAX_INPUT_SIZE) { + if (totalBytes > MAX_INPUT_SIZE) { reject(new Error('Input exceeds maximum size')); + return; } + chunks.push(chunk); + }); + + process.stdin.on('end', () => { + const data = Buffer.concat(chunks).toString('utf8'); + resolve(data); }); - process.stdin.on('end', () => resolve(data)); process.stdin.on('error', reject); }); } From cac31399ef21a8be7dc9c13b1e3070ef0a380ebc Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Fri, 16 Jan 2026 22:13:49 -0800 Subject: [PATCH 10/10] feat: add cross-platform binary releases with CLI, language examples, and security attestations --- .github/workflows/release.yml | 121 ++++++++++++ .gitignore | 14 +- README.md | 148 +++++---------- docs/integration-go.md | 134 ++++++++++++++ docs/integration-python.md | 148 +++++++++++++++ docs/integration-rust.md | 156 ++++++++++++++++ examples/README.md | 77 ++++++++ examples/go/main.go | 93 ++++++++++ examples/python/shield_example.py | 110 +++++++++++ examples/rust/Cargo.toml | 10 + examples/rust/src/main.rs | 103 +++++++++++ package.json | 6 + pnpm-lock.yaml | 294 +++++++++++++++++++++++++++++- scripts/build-sea.js | 51 ++++++ sea-config.json | 5 + 15 files changed, 1368 insertions(+), 102 deletions(-) create mode 100644 docs/integration-go.md create mode 100644 docs/integration-python.md create mode 100644 docs/integration-rust.md create mode 100644 examples/README.md create mode 100644 examples/go/main.go create mode 100644 examples/python/shield_example.py create mode 100644 examples/rust/Cargo.toml create mode 100644 examples/rust/src/main.rs create mode 100644 scripts/build-sea.js create mode 100644 sea-config.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11a8cfa..9491db2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,14 @@ on: release: types: [published] +# SECURITY: Limit permissions at workflow level +permissions: + contents: read + jobs: + # ========================================== + # Job 1: Publish to NPM + # ========================================== publish: name: Publish to NPM runs-on: ubuntu-latest @@ -56,3 +63,117 @@ jobs: run: pnpm publish --access public --no-git-checks --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # ========================================== + # Job 2: Build binaries for all platforms + # ========================================== + build-binaries: + name: Build Binary (${{ matrix.platform }}-${{ matrix.arch }}) + runs-on: ${{ matrix.os }} + # SECURITY: Only grant write permission where needed + permissions: + contents: write + attestations: write # SECURITY: For artifact attestation + id-token: write # SECURITY: For OIDC signing + + strategy: + matrix: + include: + - os: ubuntu-latest + platform: linux + arch: x64 + - os: macos-latest + platform: darwin + arch: arm64 + - os: macos-13 + platform: darwin + arch: x64 + - os: windows-latest + platform: win32 + arch: x64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 10.12.2 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # SECURITY: Run audit before building + - name: Security audit + run: pnpm audit --audit-level=high + continue-on-error: true + + - name: Build TypeScript + run: pnpm build + + - name: Bundle CLI for SEA + run: pnpm build:cli:bundle + + - name: Prepare SEA blob + run: pnpm build:sea:prepare + + - name: Build binary + run: pnpm build:sea + + - name: Rename binary (Unix) + if: matrix.platform != 'win32' + run: mv shield-${{ matrix.platform }}-* shield-${{ matrix.platform }}-${{ matrix.arch }} + + - name: Rename binary (Windows) + if: matrix.platform == 'win32' + shell: bash + run: mv shield.exe shield-windows-${{ matrix.arch }}.exe + + # SECURITY: Generate SHA256 checksum for integrity verification + - name: Generate checksum (Unix) + if: matrix.platform != 'win32' + run: | + shasum -a 256 shield-${{ matrix.platform }}-${{ matrix.arch }} > shield-${{ matrix.platform }}-${{ matrix.arch }}.sha256 + cat shield-${{ matrix.platform }}-${{ matrix.arch }}.sha256 + + - name: Generate checksum (Windows) + if: matrix.platform == 'win32' + shell: pwsh + run: | + $hash = Get-FileHash -Algorithm SHA256 shield-windows-${{ matrix.arch }}.exe + "$($hash.Hash.ToLower()) shield-windows-${{ matrix.arch }}.exe" | Out-File -Encoding utf8 shield-windows-${{ matrix.arch }}.exe.sha256 + Get-Content shield-windows-${{ matrix.arch }}.exe.sha256 + + # SECURITY: Generate artifact attestation (proves binary was built by this workflow) + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + with: + subject-path: 'shield-*' + + - name: Upload binary and checksum to Release + uses: softprops/action-gh-release@v2 + with: + files: | + shield-${{ matrix.platform }}-${{ matrix.arch }}${{ matrix.platform == 'win32' && '.exe' || '' }} + shield-${{ matrix.platform }}-${{ matrix.arch }}${{ matrix.platform == 'win32' && '.exe' || '' }}.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 80bfb02..3679bed 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,16 @@ coverage/ *.tmp *.temp .tmp/ -.temp/ \ No newline at end of file +.temp/ + +# SEA build artifacts +sea-prep.blob +shield-* +shield.exe +dist/cli.bundled.js + +# Example build artifacts +examples/rust/target/ +examples/rust/Cargo.lock +examples/*/shield +examples/*/shield.exe \ No newline at end of file diff --git a/README.md b/README.md index 17f630a..759b0bb 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,36 @@ ## Installation +### For TypeScript/JavaScript Projects + ```bash npm install @yieldxyz/shield ``` +### For Other Languages (Standalone Binary) + +Download the pre-built binary for your platform from [GitHub Releases](https://github.com/stakekit/shield/releases): + +| Platform | Download | +| --------------------- | ------------------------ | +| Linux (x64) | `shield-linux-x64` | +| macOS (Apple Silicon) | `shield-darwin-arm64` | +| macOS (Intel) | `shield-darwin-x64` | +| Windows | `shield-windows-x64.exe` | + +```bash +# Example: Download for macOS Apple Silicon +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-darwin-arm64 -o shield +chmod +x shield + +# Verify integrity (recommended) +curl -LO https://github.com/stakekit/shield/releases/latest/download/shield-darwin-arm64.sha256 +shasum -a 256 -c shield-darwin-arm64.sha256 +# Expected output: shield-darwin-arm64: OK +``` + +See the [examples/](./examples/) directory for complete integration examples in Python, Go, and Rust. + ## Usage ```typescript @@ -124,105 +150,6 @@ echo '{"apiVersion":"1.0","operation":"isSupported","yieldId":"ethereum-eth-lido echo '{"apiVersion":"1.0","operation":"validate","yieldId":"ethereum-eth-lido-staking","unsignedTransaction":"{...}","userAddress":"0x..."}' | npx @yieldxyz/shield ``` -### Python Example - -```python -import subprocess -import json - -def validate_transaction(yield_id: str, unsigned_tx: str, user_address: str) -> dict: - request = { - "apiVersion": "1.0", - "operation": "validate", - "yieldId": yield_id, - "unsignedTransaction": unsigned_tx, - "userAddress": user_address - } - - result = subprocess.run( - ["npx", "@yieldxyz/shield"], - input=json.dumps(request), - capture_output=True, - text=True - ) - - return json.loads(result.stdout) - -# Usage -response = validate_transaction( - "ethereum-eth-lido-staking", - '{"to":"0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84",...}', - "0x742d35cc6634c0532925a3b844bc9e7595f0beb8" -) - -if response["ok"] and response["result"]["isValid"]: - print("Transaction is valid!") -else: - print(f"Blocked: {response['result'].get('reason')}") -``` - -### Go Example - -```go -package main - -import ( - "bytes" - "encoding/json" - "os/exec" -) - -func validateTransaction(yieldId, unsignedTx, userAddress string) (bool, error) { - request := map[string]string{ - "apiVersion": "1.0", - "operation": "validate", - "yieldId": yieldId, - "unsignedTransaction": unsignedTx, - "userAddress": userAddress, - } - - input, _ := json.Marshal(request) - - cmd := exec.Command("npx", "@yieldxyz/shield") - cmd.Stdin = bytes.NewReader(input) - - output, err := cmd.Output() - if err != nil { - return false, err - } - - var resp struct { - Ok bool `json:"ok"` - Result struct { - IsValid bool `json:"isValid"` - } `json:"result"` - } - json.Unmarshal(output, &resp) - - return resp.Ok && resp.Result.IsValid, nil -} -``` - -### Ruby Example - -```ruby -require 'json' -require 'open3' - -def validate_transaction(yield_id, unsigned_tx, user_address) - request = { - apiVersion: "1.0", - operation: "validate", - yieldId: yield_id, - unsignedTransaction: unsigned_tx, - userAddress: user_address - } - - stdout, _status = Open3.capture2("npx @yieldxyz/shield", stdin_data: request.to_json) - JSON.parse(stdout) -end -``` - ## Supported Yield IDs - `ethereum-eth-lido-staking` @@ -275,6 +202,29 @@ Common validation failures: - `"Transaction validation failed: No matching operation pattern found"` - Transaction doesn't match any supported pattern - `"Transaction validation failed: Ambiguous transaction pattern detected"` - Transaction matches multiple patterns +## Security + +Shield is designed with security as a top priority: + +- **Input Validation**: All inputs are validated against strict JSON schemas with size limits (100KB max) +- **Pattern Matching**: Transactions must match exactly one known pattern to be valid +- **No Network Access**: The CLI binary has no network capabilities - it only reads stdin and writes stdout +- **Checksum Verification**: All release binaries include SHA256 checksums for integrity verification + +### Verifying Binary Integrity + +Always verify downloaded binaries: + +```bash +# Download binary and checksum +curl -LO https://github.com/stakekit/shield/releases/latest/download/shield-darwin-arm64 +curl -LO https://github.com/stakekit/shield/releases/latest/download/shield-darwin-arm64.sha256 + +# Verify +shasum -a 256 -c shield-darwin-arm64.sha256 +# Expected: shield-darwin-arm64: OK +``` + ## License MIT diff --git a/docs/integration-go.md b/docs/integration-go.md new file mode 100644 index 0000000..3e35765 --- /dev/null +++ b/docs/integration-go.md @@ -0,0 +1,134 @@ +# Go Integration + +Shield can be called from Go as a subprocess. No Node.js runtime required when using the standalone binary. + +## Installation + +Download the Shield binary for your platform from [GitHub Releases](https://github.com/stakekit/shield/releases): + +```bash +# Linux (x64) +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-linux-x64 -o shield +chmod +x shield + +# macOS (Apple Silicon) +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-darwin-arm64 -o shield +chmod +x shield + +# macOS (Intel) +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-darwin-x64 -o shield +chmod +x shield + +# Windows (PowerShell) +Invoke-WebRequest -Uri https://github.com/stakekit/shield/releases/latest/download/shield-windows-x64.exe -OutFile shield.exe +``` + +## Verify Download Integrity + +After downloading, verify the SHA256 checksum to ensure the binary hasn't been tampered with: + +```bash +# Download checksum file +curl -LO https://github.com/stakekit/shield/releases/latest/download/shield-darwin-arm64.sha256 + +# Verify (should output: shield-darwin-arm64: OK) +shasum -a 256 -c shield-darwin-arm64.sha256 +``` + +## Example Usage + +```go +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" +) + +type ShieldRequest struct { + ApiVersion string `json:"apiVersion"` + Operation string `json:"operation"` + YieldId string `json:"yieldId,omitempty"` + UnsignedTransaction string `json:"unsignedTransaction,omitempty"` + UserAddress string `json:"userAddress,omitempty"` +} + +type ShieldResponse struct { + Ok bool `json:"ok"` + Result struct { + IsValid bool `json:"isValid"` + Reason string `json:"reason,omitempty"` + DetectedType string `json:"detectedType,omitempty"` + YieldIds []string `json:"yieldIds,omitempty"` + } `json:"result"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func CallShield(shieldPath string, request ShieldRequest) (*ShieldResponse, error) { + inputJSON, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + cmd := exec.Command(shieldPath) + cmd.Stdin = bytes.NewReader(inputJSON) + + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("shield process failed: %w", err) + } + + var response ShieldResponse + if err := json.Unmarshal(output, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &response, nil +} + +func main() { + // Example 1: Get supported yield IDs + resp, err := CallShield("./shield", ShieldRequest{ + ApiVersion: "1.0", + Operation: "getSupportedYieldIds", + }) + if err != nil { + panic(err) + } + fmt.Printf("Supported yields: %v\n", resp.Result.YieldIds) + + // Example 2: Validate a transaction + tx := `{"to":"0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84","from":"0x742d35cc6634c0532925a3b844bc9e7595f0beb8","value":"0xde0b6b3a7640000","data":"0xa1903eab000000000000000000000000742d35cc6634c0532925a3b844bc9e7595f0beb8","chainId":1}` + + resp, err = CallShield("./shield", ShieldRequest{ + ApiVersion: "1.0", + Operation: "validate", + YieldId: "ethereum-eth-lido-staking", + UnsignedTransaction: tx, + UserAddress: "0x742d35cc6634c0532925a3b844bc9e7595f0beb8", + }) + if err != nil { + panic(err) + } + + if resp.Ok && resp.Result.IsValid { + fmt.Printf("✅ Valid transaction (type: %s)\n", resp.Result.DetectedType) + } else if resp.Ok { + fmt.Printf("❌ Invalid: %s\n", resp.Result.Reason) + } else { + fmt.Printf("⚠️ Error: %s - %s\n", resp.Error.Code, resp.Error.Message) + } +} +``` + +## Running the Example + +```bash +# Save the above code to main.go, then: +go run main.go +``` diff --git a/docs/integration-python.md b/docs/integration-python.md new file mode 100644 index 0000000..4a97bad --- /dev/null +++ b/docs/integration-python.md @@ -0,0 +1,148 @@ +# Python Integration + +Shield can be called from Python as a subprocess. No Node.js runtime required when using the standalone binary. + +## Installation + +Download the Shield binary for your platform from [GitHub Releases](https://github.com/stakekit/shield/releases): + +```bash +# Linux (x64) +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-linux-x64 -o shield +chmod +x shield + +# macOS (Apple Silicon) +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-darwin-arm64 -o shield +chmod +x shield + +# macOS (Intel) +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-darwin-x64 -o shield +chmod +x shield + +# Windows (PowerShell) +Invoke-WebRequest -Uri https://github.com/stakekit/shield/releases/latest/download/shield-windows-x64.exe -OutFile shield.exe +``` + +## Verify Download Integrity + +After downloading, verify the SHA256 checksum to ensure the binary hasn't been tampered with: + +```bash +# Download checksum file +curl -LO https://github.com/stakekit/shield/releases/latest/download/shield-darwin-arm64.sha256 + +# Verify (should output: shield-darwin-arm64: OK) +shasum -a 256 -c shield-darwin-arm64.sha256 +``` + +## Example Usage + +```python +import subprocess +import json +from dataclasses import dataclass +from typing import Optional, Union, List + +@dataclass +class ShieldResult: + is_valid: bool + reason: Optional[str] = None + detected_type: Optional[str] = None + yield_ids: Optional[List[str]] = None + +@dataclass +class ShieldError: + code: str + message: str + +def call_shield( + shield_path: str, + operation: str, + yield_id: Optional[str] = None, + unsigned_tx: Optional[str] = None, + user_address: Optional[str] = None +) -> tuple[bool, Union[ShieldResult, ShieldError]]: + """ + Call Shield binary with a request. + + Args: + shield_path: Path to the Shield binary + operation: "validate" or "getSupportedYieldIds" + yield_id: The yield integration ID (required for validate) + unsigned_tx: JSON string of the unsigned transaction (required for validate) + user_address: The user's wallet address (required for validate) + + Returns: + (ok, result) - If ok is True, result is ShieldResult. Otherwise, ShieldError. + """ + request = { + "apiVersion": "1.0", + "operation": operation, + } + if yield_id: + request["yieldId"] = yield_id + if unsigned_tx: + request["unsignedTransaction"] = unsigned_tx + if user_address: + request["userAddress"] = user_address + + result = subprocess.run( + [shield_path], + input=json.dumps(request), + capture_output=True, + text=True, + ) + + response = json.loads(result.stdout) + + if response["ok"]: + return True, ShieldResult( + is_valid=response["result"].get("isValid", False), + reason=response["result"].get("reason"), + detected_type=response["result"].get("detectedType"), + yield_ids=response["result"].get("yieldIds"), + ) + else: + return False, ShieldError( + code=response["error"]["code"], + message=response["error"]["message"], + ) + + +if __name__ == "__main__": + # Example 1: Get supported yield IDs + ok, result = call_shield("./shield", "getSupportedYieldIds") + if ok: + print(f"Supported yields: {result.yield_ids}") + + # Example 2: Validate a transaction + tx = json.dumps({ + "to": "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84", + "from": "0x742d35cc6634c0532925a3b844bc9e7595f0beb8", + "value": "0xde0b6b3a7640000", + "data": "0xa1903eab000000000000000000000000742d35cc6634c0532925a3b844bc9e7595f0beb8", + "chainId": 1, + }) + + ok, result = call_shield( + "./shield", + "validate", + yield_id="ethereum-eth-lido-staking", + unsigned_tx=tx, + user_address="0x742d35cc6634c0532925a3b844bc9e7595f0beb8" + ) + + if ok and result.is_valid: + print(f"✅ Valid transaction (type: {result.detected_type})") + elif ok: + print(f"❌ Invalid: {result.reason}") + else: + print(f"⚠️ Error: {result.code} - {result.message}") +``` + +## Running the Example + +```bash +# Save the above code to shield_example.py, then: +python shield_example.py +``` diff --git a/docs/integration-rust.md b/docs/integration-rust.md new file mode 100644 index 0000000..512ed55 --- /dev/null +++ b/docs/integration-rust.md @@ -0,0 +1,156 @@ +# Rust Integration + +Shield can be called from Rust as a subprocess. No Node.js runtime required when using the standalone binary. + +## Installation + +Download the Shield binary for your platform from [GitHub Releases](https://github.com/stakekit/shield/releases): + +```bash +# Linux (x64) +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-linux-x64 -o shield +chmod +x shield + +# macOS (Apple Silicon) +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-darwin-arm64 -o shield +chmod +x shield + +# macOS (Intel) +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-darwin-x64 -o shield +chmod +x shield +``` + +## Verify Download Integrity + +After downloading, verify the SHA256 checksum to ensure the binary hasn't been tampered with: + +```bash +# Download checksum file +curl -LO https://github.com/stakekit/shield/releases/latest/download/shield-darwin-arm64.sha256 + +# Verify (should output: shield-darwin-arm64: OK) +shasum -a 256 -c shield-darwin-arm64.sha256 +``` + +## Dependencies + +Add to your `Cargo.toml`: + +```toml +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +``` + +## Example Usage + +```rust +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::process::{Command, Stdio}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ShieldRequest { + api_version: String, + operation: String, + #[serde(skip_serializing_if = "Option::is_none")] + yield_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + unsigned_transaction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user_address: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct ShieldResponse { + ok: bool, + result: Option, + error: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct ShieldResult { + is_valid: Option, + reason: Option, + detected_type: Option, + yield_ids: Option>, +} + +#[derive(Deserialize, Debug)] +struct ShieldError { + code: String, + message: String, +} + +fn call_shield(shield_path: &str, request: ShieldRequest) -> Result> { + let input = serde_json::to_string(&request)?; + + let mut child = Command::new(shield_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + child.stdin.as_mut().unwrap().write_all(input.as_bytes())?; + + let output = child.wait_with_output()?; + let response: ShieldResponse = serde_json::from_slice(&output.stdout)?; + + Ok(response) +} + +fn main() -> Result<(), Box> { + // Example 1: Get supported yield IDs + let response = call_shield("./shield", ShieldRequest { + api_version: "1.0".to_string(), + operation: "getSupportedYieldIds".to_string(), + yield_id: None, + unsigned_transaction: None, + user_address: None, + })?; + + if let Some(result) = &response.result { + println!("Supported yields: {:?}", result.yield_ids); + } + + // Example 2: Validate a transaction + let tx = r#"{"to":"0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84","from":"0x742d35cc6634c0532925a3b844bc9e7595f0beb8","value":"0xde0b6b3a7640000","data":"0xa1903eab000000000000000000000000742d35cc6634c0532925a3b844bc9e7595f0beb8","chainId":1}"#; + + let response = call_shield("./shield", ShieldRequest { + api_version: "1.0".to_string(), + operation: "validate".to_string(), + yield_id: Some("ethereum-eth-lido-staking".to_string()), + unsigned_transaction: Some(tx.to_string()), + user_address: Some("0x742d35cc6634c0532925a3b844bc9e7595f0beb8".to_string()), + })?; + + if response.ok { + if let Some(result) = response.result { + if result.is_valid.unwrap_or(false) { + println!("✅ Valid transaction (type: {:?})", result.detected_type); + } else { + println!("❌ Invalid: {:?}", result.reason); + } + } + } else if let Some(error) = response.error { + println!("⚠️ Error: {} - {}", error.code, error.message); + } + + Ok(()) +} +``` + +## Running the Example + +```bash +# Create a new Rust project +cargo new shield_example +cd shield_example + +# Add dependencies to Cargo.toml, copy the code to src/main.rs +# Copy the shield binary to the project root + +cargo run +``` diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..31c2754 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,77 @@ +# Shield Integration Examples + +This directory contains runnable examples for integrating Shield with different programming languages. + +## Prerequisites + +Download the Shield binary for your platform from [GitHub Releases](https://github.com/stakekit/shield/releases): + +```bash +# Linux (x64) +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-linux-x64 -o shield +chmod +x shield + +# macOS (Apple Silicon) +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-darwin-arm64 -o shield +chmod +x shield + +# macOS (Intel) +curl -L https://github.com/stakekit/shield/releases/latest/download/shield-darwin-x64 -o shield +chmod +x shield + +# Windows (PowerShell) +Invoke-WebRequest -Uri https://github.com/stakekit/shield/releases/latest/download/shield-windows-x64.exe -OutFile shield.exe +``` + +## Verify Download Integrity (Recommended) + +Always verify the SHA256 checksum before using the binary: + +```bash +# Download checksum (use the same platform as your binary) +curl -LO https://github.com/stakekit/shield/releases/latest/download/shield-darwin-arm64.sha256 + +# Verify +shasum -a 256 -c shield-darwin-arm64.sha256 +# Expected output: shield-darwin-arm64: OK +``` + +## Python + +```bash +cd python +cp /path/to/shield ./shield # Copy binary here +python shield_example.py +``` + +## Go + +```bash +cd go +cp /path/to/shield ./shield # Copy binary here +go run main.go +``` + +## Rust + +```bash +cd rust +cp /path/to/shield ./shield # Copy binary here +cargo run +``` + +## What the Examples Do + +Each example demonstrates: + +1. **`getSupportedYieldIds`** - Lists all supported yield integration IDs +2. **`validate`** - Validates a sample Ethereum Lido staking transaction + +## Expected Output + +``` +Supported yields: ["solana-sol-native-multivalidator-staking", "ethereum-eth-lido-staking", "tron-trx-native-staking"] +❌ Invalid: Transaction validation failed: ... +``` + +The validation fails because the example uses a sample transaction that doesn't match the expected pattern. In real usage, you would pass actual unsigned transactions from your staking flow. diff --git a/examples/go/main.go b/examples/go/main.go new file mode 100644 index 0000000..3f1a6cf --- /dev/null +++ b/examples/go/main.go @@ -0,0 +1,93 @@ +// Shield Go Integration Example +// +// Usage: +// 1. Download the Shield binary for your platform +// 2. Place it in this directory as ./shield (or ./shield.exe on Windows) +// 3. Run: go run main.go +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" +) + +type ShieldRequest struct { + ApiVersion string `json:"apiVersion"` + Operation string `json:"operation"` + YieldId string `json:"yieldId,omitempty"` + UnsignedTransaction string `json:"unsignedTransaction,omitempty"` + UserAddress string `json:"userAddress,omitempty"` +} + +type ShieldResponse struct { + Ok bool `json:"ok"` + Result struct { + IsValid bool `json:"isValid"` + Reason string `json:"reason,omitempty"` + DetectedType string `json:"detectedType,omitempty"` + YieldIds []string `json:"yieldIds,omitempty"` + } `json:"result"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func CallShield(shieldPath string, request ShieldRequest) (*ShieldResponse, error) { + inputJSON, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + cmd := exec.Command(shieldPath) + cmd.Stdin = bytes.NewReader(inputJSON) + + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("shield process failed: %w", err) + } + + var response ShieldResponse + if err := json.Unmarshal(output, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &response, nil +} + +func main() { + // Example 1: Get supported yield IDs + resp, err := CallShield("./shield", ShieldRequest{ + ApiVersion: "1.0", + Operation: "getSupportedYieldIds", + }) + if err != nil { + panic(err) + } + fmt.Printf("Supported yields: %v\n", resp.Result.YieldIds) + + // Example 2: Validate a transaction + tx := `{"to":"0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84","from":"0x742d35cc6634c0532925a3b844bc9e7595f0beb8","value":"0xde0b6b3a7640000","data":"0xa1903eab000000000000000000000000742d35cc6634c0532925a3b844bc9e7595f0beb8","chainId":1}` + + resp, err = CallShield("./shield", ShieldRequest{ + ApiVersion: "1.0", + Operation: "validate", + YieldId: "ethereum-eth-lido-staking", + UnsignedTransaction: tx, + UserAddress: "0x742d35cc6634c0532925a3b844bc9e7595f0beb8", + }) + if err != nil { + panic(err) + } + + if resp.Ok && resp.Result.IsValid { + fmt.Printf("✅ Valid transaction (type: %s)\n", resp.Result.DetectedType) + } else if resp.Ok { + fmt.Printf("❌ Invalid: %s\n", resp.Result.Reason) + } else { + fmt.Printf("⚠️ Error: %s - %s\n", resp.Error.Code, resp.Error.Message) + } +} + diff --git a/examples/python/shield_example.py b/examples/python/shield_example.py new file mode 100644 index 0000000..5075932 --- /dev/null +++ b/examples/python/shield_example.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Shield Python Integration Example + +Usage: + 1. Download the Shield binary for your platform + 2. Place it in this directory as ./shield (or ./shield.exe on Windows) + 3. Run: python shield_example.py +""" +import subprocess +import json +from dataclasses import dataclass +from typing import Optional, Union, List + +@dataclass +class ShieldResult: + is_valid: bool + reason: Optional[str] = None + detected_type: Optional[str] = None + yield_ids: Optional[List[str]] = None + +@dataclass +class ShieldError: + code: str + message: str + +def call_shield( + shield_path: str, + operation: str, + yield_id: Optional[str] = None, + unsigned_tx: Optional[str] = None, + user_address: Optional[str] = None +) -> tuple[bool, Union[ShieldResult, ShieldError]]: + """ + Call Shield binary with a request. + + Args: + shield_path: Path to the Shield binary + operation: "validate" or "getSupportedYieldIds" + yield_id: The yield integration ID (required for validate) + unsigned_tx: JSON string of the unsigned transaction (required for validate) + user_address: The user's wallet address (required for validate) + + Returns: + (ok, result) - If ok is True, result is ShieldResult. Otherwise, ShieldError. + """ + request = { + "apiVersion": "1.0", + "operation": operation, + } + if yield_id: + request["yieldId"] = yield_id + if unsigned_tx: + request["unsignedTransaction"] = unsigned_tx + if user_address: + request["userAddress"] = user_address + + result = subprocess.run( + [shield_path], + input=json.dumps(request), + capture_output=True, + text=True, + ) + + response = json.loads(result.stdout) + + if response["ok"]: + return True, ShieldResult( + is_valid=response["result"].get("isValid", False), + reason=response["result"].get("reason"), + detected_type=response["result"].get("detectedType"), + yield_ids=response["result"].get("yieldIds"), + ) + else: + return False, ShieldError( + code=response["error"]["code"], + message=response["error"]["message"], + ) + + +if __name__ == "__main__": + # Example 1: Get supported yield IDs + ok, result = call_shield("./shield", "getSupportedYieldIds") + if ok: + print(f"Supported yields: {result.yield_ids}") + + # Example 2: Validate a transaction + tx = json.dumps({ + "to": "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84", + "from": "0x742d35cc6634c0532925a3b844bc9e7595f0beb8", + "value": "0xde0b6b3a7640000", + "data": "0xa1903eab000000000000000000000000742d35cc6634c0532925a3b844bc9e7595f0beb8", + "chainId": 1, + }) + + ok, result = call_shield( + "./shield", + "validate", + yield_id="ethereum-eth-lido-staking", + unsigned_tx=tx, + user_address="0x742d35cc6634c0532925a3b844bc9e7595f0beb8" + ) + + if ok and result.is_valid: + print(f"✅ Valid transaction (type: {result.detected_type})") + elif ok: + print(f"❌ Invalid: {result.reason}") + else: + print(f"⚠️ Error: {result.code} - {result.message}") + diff --git a/examples/rust/Cargo.toml b/examples/rust/Cargo.toml new file mode 100644 index 0000000..60526c8 --- /dev/null +++ b/examples/rust/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "shield_example" +version = "0.1.0" +edition = "2021" +description = "Shield Rust Integration Example" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + diff --git a/examples/rust/src/main.rs b/examples/rust/src/main.rs new file mode 100644 index 0000000..f60d53c --- /dev/null +++ b/examples/rust/src/main.rs @@ -0,0 +1,103 @@ +// Shield Rust Integration Example +// +// Usage: +// 1. Download the Shield binary for your platform +// 2. Place it in this directory as ./shield +// 3. Run: cargo run + +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::process::{Command, Stdio}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ShieldRequest { + api_version: String, + operation: String, + #[serde(skip_serializing_if = "Option::is_none")] + yield_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + unsigned_transaction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user_address: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct ShieldResponse { + ok: bool, + result: Option, + error: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct ShieldResult { + is_valid: Option, + reason: Option, + detected_type: Option, + yield_ids: Option>, +} + +#[derive(Deserialize, Debug)] +struct ShieldError { + code: String, + message: String, +} + +fn call_shield(shield_path: &str, request: ShieldRequest) -> Result> { + let input = serde_json::to_string(&request)?; + + let mut child = Command::new(shield_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + child.stdin.as_mut().unwrap().write_all(input.as_bytes())?; + + let output = child.wait_with_output()?; + let response: ShieldResponse = serde_json::from_slice(&output.stdout)?; + + Ok(response) +} + +fn main() -> Result<(), Box> { + // Example 1: Get supported yield IDs + let response = call_shield("./shield", ShieldRequest { + api_version: "1.0".to_string(), + operation: "getSupportedYieldIds".to_string(), + yield_id: None, + unsigned_transaction: None, + user_address: None, + })?; + + if let Some(result) = &response.result { + println!("Supported yields: {:?}", result.yield_ids); + } + + // Example 2: Validate a transaction + let tx = r#"{"to":"0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84","from":"0x742d35cc6634c0532925a3b844bc9e7595f0beb8","value":"0xde0b6b3a7640000","data":"0xa1903eab000000000000000000000000742d35cc6634c0532925a3b844bc9e7595f0beb8","chainId":1}"#; + + let response = call_shield("./shield", ShieldRequest { + api_version: "1.0".to_string(), + operation: "validate".to_string(), + yield_id: Some("ethereum-eth-lido-staking".to_string()), + unsigned_transaction: Some(tx.to_string()), + user_address: Some("0x742d35cc6634c0532925a3b844bc9e7595f0beb8".to_string()), + })?; + + if response.ok { + if let Some(result) = response.result { + if result.is_valid.unwrap_or(false) { + println!("✅ Valid transaction (type: {:?})", result.detected_type); + } else { + println!("❌ Invalid: {:?}", result.reason); + } + } + } else if let Some(error) = response.error { + println!("⚠️ Error: {} - {}", error.code, error.message); + } + + Ok(()) +} + diff --git a/package.json b/package.json index 4506a78..1271c0a 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,10 @@ ], "scripts": { "build": "rslib build", + "build:cli:bundle": "esbuild src/cli.ts --bundle --platform=node --target=node20 --outfile=dist/cli.bundled.js", + "build:sea:prepare": "node --experimental-sea-config sea-config.json", + "build:sea": "node scripts/build-sea.js", + "build:binary": "pnpm build && pnpm build:cli:bundle && pnpm build:sea:prepare && pnpm build:sea", "dev": "rslib build --watch", "test": "jest", "test:coverage": "jest --coverage", @@ -56,10 +60,12 @@ "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", + "esbuild": "^0.27.2", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.0.0", + "postject": "1.0.0-alpha.6", "prettier": "^3.2.5", "ts-jest": "^29.0.0", "typescript": "^5.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bd392f..db86bfd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@typescript-eslint/parser': specifier: ^6.21.0 version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + esbuild: + specifier: ^0.27.2 + version: 0.27.2 eslint: specifier: ^8.57.0 version: 8.57.1 @@ -48,12 +51,15 @@ importers: jest: specifier: ^29.0.0 version: 29.7.0(@types/node@20.19.19) + postject: + specifier: 1.0.0-alpha.6 + version: 1.0.0-alpha.6 prettier: specifier: ^3.2.5 version: 3.6.2 ts-jest: specifier: ^29.0.0 - version: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.19))(typescript@5.9.3) + version: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.27.2)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.19))(typescript@5.9.3) typescript: specifier: ^5.0.0 version: 5.9.3 @@ -232,6 +238,162 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -865,6 +1027,10 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -970,6 +1136,11 @@ packages: es6-promisify@5.0.0: resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1694,6 +1865,11 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2291,6 +2467,84 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -3099,6 +3353,8 @@ snapshots: commander@2.20.3: {} + commander@9.5.0: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -3189,6 +3445,35 @@ snapshots: dependencies: es6-promise: 4.2.8 + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + escalade@3.2.0: {} escape-string-regexp@2.0.0: {} @@ -4098,6 +4383,10 @@ snapshots: dependencies: find-up: 4.1.0 + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.0: @@ -4293,7 +4582,7 @@ snapshots: dependencies: typescript: 5.9.3 - ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.19))(typescript@5.9.3): + ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.27.2)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.19))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -4311,6 +4600,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.28.4) + esbuild: 0.27.2 jest-util: 29.7.0 tslib@2.7.0: {} diff --git a/scripts/build-sea.js b/scripts/build-sea.js new file mode 100644 index 0000000..bbb194c --- /dev/null +++ b/scripts/build-sea.js @@ -0,0 +1,51 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const platform = os.platform(); +const arch = os.arch(); +const binaryName = + platform === 'win32' ? 'shield.exe' : `shield-${platform}-${arch}`; +const nodePath = process.execPath; + +console.log(`Building SEA for ${platform}-${arch}...`); + +// Step 1: Copy node binary +console.log('Copying Node.js binary...'); +fs.copyFileSync(nodePath, binaryName); + +// Step 2: Platform-specific injection +console.log('Injecting SEA blob...'); + +if (platform === 'darwin') { + // macOS: remove signature, inject, re-sign + execSync(`codesign --remove-signature ${binaryName}`, { stdio: 'inherit' }); + execSync( + `npx postject ${binaryName} NODE_SEA_BLOB sea-prep.blob ` + + `--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 ` + + `--macho-segment-name NODE_SEA`, + { stdio: 'inherit' }, + ); + execSync(`codesign --sign - ${binaryName}`, { stdio: 'inherit' }); +} else if (platform === 'win32') { + // Windows + execSync( + `npx postject ${binaryName} NODE_SEA_BLOB sea-prep.blob ` + + `--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2`, + { stdio: 'inherit' }, + ); +} else { + // Linux + execSync( + `npx postject ${binaryName} NODE_SEA_BLOB sea-prep.blob ` + + `--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2`, + { stdio: 'inherit' }, + ); +} + +// Step 3: Verify +const stats = fs.statSync(binaryName); +console.log( + `✅ Built ${binaryName} (${(stats.size / 1024 / 1024).toFixed(1)} MB)`, +); diff --git a/sea-config.json b/sea-config.json new file mode 100644 index 0000000..5d74547 --- /dev/null +++ b/sea-config.json @@ -0,0 +1,5 @@ +{ + "main": "dist/cli.bundled.js", + "output": "sea-prep.blob", + "disableExperimentalSEAWarning": true +} \ No newline at end of file