From 5de011463cd7690a1e73ec709d681d310cb6adbd Mon Sep 17 00:00:00 2001 From: Shredder401k Date: Thu, 28 May 2026 17:51:57 +0100 Subject: [PATCH] test(soroban): add comprehensive error handling and validation test suite --- packages/stellar/src/errors.ts | 344 +++++++++++- packages/stellar/src/index.ts | 1 + packages/stellar/src/soroban-errors.test.ts | 264 +++++++++ .../src/soroban-wasm-validation.test.ts | 220 ++++++++ packages/stellar/src/soroban.ts | 72 +++ .../stellar/src/transaction-envelope.test.ts | 501 ++++++++++++++++++ .../stellar/src/trustline-validation.test.ts | 406 ++++++++++++++ packages/stellar/src/trustline-validation.ts | 237 +++++++++ 8 files changed, 2043 insertions(+), 2 deletions(-) create mode 100644 packages/stellar/src/soroban-errors.test.ts create mode 100644 packages/stellar/src/soroban-wasm-validation.test.ts create mode 100644 packages/stellar/src/transaction-envelope.test.ts create mode 100644 packages/stellar/src/trustline-validation.test.ts create mode 100644 packages/stellar/src/trustline-validation.ts diff --git a/packages/stellar/src/errors.ts b/packages/stellar/src/errors.ts index a4f246c2..770bb8b0 100644 --- a/packages/stellar/src/errors.ts +++ b/packages/stellar/src/errors.ts @@ -30,7 +30,14 @@ export type StellarErrorCode = | 'MALFORMED_TRANSACTION' | 'ENDPOINT_UNREACHABLE' | 'RATE_LIMITED' - | 'UNKNOWN_ERROR'; + | 'UNKNOWN_ERROR' + | 'SOROBAN_CONTRACT_ERROR' + | 'SOROBAN_HOST_FUNCTION_ERROR' + | 'SOROBAN_CONTRACT_PANIC' + | 'SOROBAN_RESOURCE_LIMIT_EXCEEDED' + | 'SOROBAN_STORAGE_ERROR' + | 'SOROBAN_AUTH_ERROR' + | 'SOROBAN_WASM_ERROR'; /** * Parsed Stellar error with code and user-friendly information. @@ -149,6 +156,136 @@ const OPERATION_RESULT_CODES: Record = { + // Host function errors + 'scvUnexpectedType': { + title: 'Type Mismatch', + message: 'Contract received an unexpected value type. Check the argument types match the contract interface.', + retryable: false, + }, + 'scvMissingValue': { + title: 'Missing Value', + message: 'Contract expected a value but received none. Ensure all required arguments are provided.', + retryable: false, + }, + 'scvInvalidInput': { + title: 'Invalid Input', + message: 'Contract received invalid input data. Verify the input format and constraints.', + retryable: false, + }, + 'scvArithmeticError': { + title: 'Arithmetic Error', + message: 'Contract encountered an arithmetic error (overflow, underflow, or division by zero).', + retryable: false, + }, + 'scvIndexBounds': { + title: 'Index Out of Bounds', + message: 'Contract attempted to access an invalid index. Check array or vector bounds.', + retryable: false, + }, + 'scvInvalidAction': { + title: 'Invalid Action', + message: 'Contract attempted an invalid operation. Review the contract logic and state.', + retryable: false, + }, + + // Contract panics + 'scvContractPanic': { + title: 'Contract Panic', + message: 'Contract execution panicked unexpectedly. This indicates a critical error in the contract code.', + retryable: false, + }, + 'scvUnwrapFailed': { + title: 'Unwrap Failed', + message: 'Contract attempted to unwrap a None value. The contract expected data that was not present.', + retryable: false, + }, + 'scvAssertionFailed': { + title: 'Assertion Failed', + message: 'Contract assertion failed. A required condition was not met during execution.', + retryable: false, + }, + + // Resource limit errors + 'scvInsufficientRefundableFee': { + title: 'Insufficient Refundable Fee', + message: 'Transaction does not have enough refundable fee for contract execution. Increase the fee.', + retryable: true, + }, + 'scvExceededLimit': { + title: 'Resource Limit Exceeded', + message: 'Contract execution exceeded resource limits (CPU, memory, or storage). Optimize the contract or increase limits.', + retryable: false, + }, + 'scvInsufficientBalance': { + title: 'Insufficient Contract Balance', + message: 'Contract does not have sufficient balance to complete the operation.', + retryable: false, + }, + 'scvStorageExhausted': { + title: 'Storage Exhausted', + message: 'Contract storage limit reached. Remove unused data or increase storage allocation.', + retryable: false, + }, + 'scvCpuLimitExceeded': { + title: 'CPU Limit Exceeded', + message: 'Contract execution exceeded CPU instruction limit. Simplify the contract logic.', + retryable: false, + }, + 'scvMemoryLimitExceeded': { + title: 'Memory Limit Exceeded', + message: 'Contract execution exceeded memory limit. Reduce memory usage in the contract.', + retryable: false, + }, + + // Storage errors + 'scvStorageError': { + title: 'Storage Error', + message: 'Contract encountered a storage access error. The requested data may not exist.', + retryable: false, + }, + 'scvStorageKeyNotFound': { + title: 'Storage Key Not Found', + message: 'Contract attempted to access a non-existent storage key.', + retryable: false, + }, + + // Auth errors + 'scvAuthError': { + title: 'Authorization Error', + message: 'Contract authorization failed. Ensure the caller has the required permissions.', + retryable: false, + }, + 'scvInvalidSignature': { + title: 'Invalid Signature', + message: 'Contract received an invalid signature. Verify the signing key and signature format.', + retryable: false, + }, + + // WASM errors + 'scvWasmTrap': { + title: 'WASM Trap', + message: 'Contract WASM execution trapped. This indicates a low-level execution error.', + retryable: false, + }, + 'scvWasmMemoryError': { + title: 'WASM Memory Error', + message: 'Contract WASM encountered a memory access error.', + retryable: false, + }, + 'scvInvalidWasm': { + title: 'Invalid WASM', + message: 'Contract WASM binary is invalid or corrupted.', + retryable: false, + }, +}; + /** * Error guidance templates for common Stellar errors. */ @@ -380,6 +517,122 @@ const ERROR_GUIDANCE_MAP: Record = { { label: 'Stellar Documentation', url: 'https://developers.stellar.org/docs' }, ], }, + SOROBAN_CONTRACT_ERROR: { + template: { + title: 'Soroban Contract Error', + message: 'The Soroban contract execution failed. Review the contract error code for details.', + retryable: false, + }, + steps: [ + 'Check the contract error code and message', + 'Verify contract arguments match the expected types', + 'Ensure the contract is deployed and accessible', + 'Review contract logs for additional context', + ], + links: [ + { label: 'Soroban Errors', url: 'https://developers.stellar.org/docs/smart-contracts/errors' }, + { label: 'Contract Debugging', url: 'https://developers.stellar.org/docs/smart-contracts/debugging' }, + ], + }, + SOROBAN_HOST_FUNCTION_ERROR: { + template: { + title: 'Soroban Host Function Error', + message: 'A Soroban host function call failed. This indicates an issue with contract-host interaction.', + retryable: false, + }, + steps: [ + 'Verify the host function arguments are correct', + 'Check that the contract has permission to call the host function', + 'Review the host function documentation for requirements', + 'Ensure the contract environment is properly configured', + ], + links: [ + { label: 'Host Functions', url: 'https://developers.stellar.org/docs/smart-contracts/host-functions' }, + ], + }, + SOROBAN_CONTRACT_PANIC: { + template: { + title: 'Soroban Contract Panic', + message: 'The contract panicked during execution. This indicates a critical error in the contract code.', + retryable: false, + }, + steps: [ + 'Review the panic message for the root cause', + 'Check for unwrap() calls on None values', + 'Verify all assertions and require() conditions', + 'Test the contract with the same inputs in a local environment', + ], + links: [ + { label: 'Contract Debugging', url: 'https://developers.stellar.org/docs/smart-contracts/debugging' }, + { label: 'Error Handling', url: 'https://developers.stellar.org/docs/smart-contracts/errors' }, + ], + }, + SOROBAN_RESOURCE_LIMIT_EXCEEDED: { + template: { + title: 'Soroban Resource Limit Exceeded', + message: 'Contract execution exceeded resource limits (CPU, memory, or storage).', + retryable: false, + }, + steps: [ + 'Optimize contract code to reduce resource usage', + 'Increase transaction resource limits if possible', + 'Break complex operations into smaller transactions', + 'Review contract storage patterns for efficiency', + ], + links: [ + { label: 'Resource Limits', url: 'https://developers.stellar.org/docs/smart-contracts/resource-limits' }, + { label: 'Optimization Guide', url: 'https://developers.stellar.org/docs/smart-contracts/optimization' }, + ], + }, + SOROBAN_STORAGE_ERROR: { + template: { + title: 'Soroban Storage Error', + message: 'Contract encountered a storage access error.', + retryable: false, + }, + steps: [ + 'Verify the storage key exists before accessing', + 'Check storage permissions and access patterns', + 'Ensure storage is properly initialized', + 'Review contract storage limits', + ], + links: [ + { label: 'Contract Storage', url: 'https://developers.stellar.org/docs/smart-contracts/storage' }, + ], + }, + SOROBAN_AUTH_ERROR: { + template: { + title: 'Soroban Authorization Error', + message: 'Contract authorization failed. The caller does not have the required permissions.', + retryable: false, + }, + steps: [ + 'Verify the caller has the required authorization', + 'Check contract authorization requirements', + 'Ensure signatures are valid and properly formatted', + 'Review the contract access control logic', + ], + links: [ + { label: 'Authorization', url: 'https://developers.stellar.org/docs/smart-contracts/authorization' }, + ], + }, + SOROBAN_WASM_ERROR: { + template: { + title: 'Soroban WASM Error', + message: 'Contract WASM execution encountered an error.', + retryable: false, + }, + steps: [ + 'Verify the WASM binary is valid and not corrupted', + 'Check that the contract was compiled correctly', + 'Ensure the WASM version is compatible with the network', + 'Review WASM execution logs for details', + ], + links: [ + { label: 'Contract Deployment', url: 'https://developers.stellar.org/docs/smart-contracts/deployment' }, + { label: 'WASM Debugging', url: 'https://developers.stellar.org/docs/smart-contracts/debugging' }, + ], + }, }; /** @@ -422,8 +675,43 @@ export function parseStellarError( resultCode = resultCodeMatch[1]; } - // Check for network/timeout errors + // Check for Soroban contract errors if ( + errorMessage.includes('scv') || + errorMessage.includes('Soroban') || + errorMessage.includes('contract') + ) { + // Try to extract Soroban error code + const sorobanCodeMatch = errorMessage.match(/\b(scv[A-Z][a-zA-Z]+)\b/); + if (sorobanCodeMatch && SOROBAN_ERROR_CODES[sorobanCodeMatch[1]]) { + const sorobanCode = sorobanCodeMatch[1]; + const mapping = SOROBAN_ERROR_CODES[sorobanCode]; + title = mapping.title; + message = mapping.message; + retryable = mapping.retryable; + resultCode = sorobanCode; + + // Categorize Soroban error + if (sorobanCode.includes('Panic') || sorobanCode.includes('Unwrap') || sorobanCode.includes('Assertion')) { + errorCode = 'SOROBAN_CONTRACT_PANIC'; + } else if (sorobanCode.includes('Limit') || sorobanCode.includes('Exhausted')) { + errorCode = 'SOROBAN_RESOURCE_LIMIT_EXCEEDED'; + } else if (sorobanCode.includes('Storage')) { + errorCode = 'SOROBAN_STORAGE_ERROR'; + } else if (sorobanCode.includes('Auth') || sorobanCode.includes('Signature')) { + errorCode = 'SOROBAN_AUTH_ERROR'; + } else if (sorobanCode.includes('Wasm') || sorobanCode.includes('Trap')) { + errorCode = 'SOROBAN_WASM_ERROR'; + } else { + errorCode = 'SOROBAN_CONTRACT_ERROR'; + } + } else { + // Generic Soroban error without specific code + errorCode = 'SOROBAN_CONTRACT_ERROR'; + } + } + // Check for network/timeout errors + else if ( errorMessage.includes('timeout') || errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ECONNRESET') @@ -628,3 +916,55 @@ export function formatError(error: unknown, verbose = false): string { return formatted; } + +/** + * Map a Soroban contract error code to a typed application error. + * Provides a fallback for unknown error codes. + * + * @param sorobanErrorCode - The Soroban error code (e.g., "scvUnexpectedType") + * @returns Parsed error with user-friendly information + * + * @example + * ```typescript + * const error = mapSorobanError('scvUnexpectedType'); + * console.log(error.title); // "Type Mismatch" + * console.log(error.message); // User-friendly message + * ``` + */ +export function mapSorobanError(sorobanErrorCode: string): ParsedStellarError { + const mapping = SOROBAN_ERROR_CODES[sorobanErrorCode]; + + if (mapping) { + let errorCode: StellarErrorCode = 'SOROBAN_CONTRACT_ERROR'; + + // Categorize based on error code pattern + if (sorobanErrorCode.includes('Panic') || sorobanErrorCode.includes('Unwrap') || sorobanErrorCode.includes('Assertion')) { + errorCode = 'SOROBAN_CONTRACT_PANIC'; + } else if (sorobanErrorCode.includes('Limit') || sorobanErrorCode.includes('Exhausted')) { + errorCode = 'SOROBAN_RESOURCE_LIMIT_EXCEEDED'; + } else if (sorobanErrorCode.includes('Storage')) { + errorCode = 'SOROBAN_STORAGE_ERROR'; + } else if (sorobanErrorCode.includes('Auth') || sorobanErrorCode.includes('Signature')) { + errorCode = 'SOROBAN_AUTH_ERROR'; + } else if (sorobanErrorCode.includes('Wasm') || sorobanErrorCode.includes('Trap')) { + errorCode = 'SOROBAN_WASM_ERROR'; + } + + return { + code: errorCode, + title: mapping.title, + message: mapping.message, + retryable: mapping.retryable, + resultCode: sorobanErrorCode, + }; + } + + // Fallback for unknown Soroban error codes + return { + code: 'SOROBAN_CONTRACT_ERROR', + title: 'Unknown Soroban Error', + message: `Contract execution failed with error code: ${sorobanErrorCode}. Check the contract logs for details.`, + retryable: false, + resultCode: sorobanErrorCode, + }; +} diff --git a/packages/stellar/src/index.ts b/packages/stellar/src/index.ts index f884fe52..aeca1cb7 100644 --- a/packages/stellar/src/index.ts +++ b/packages/stellar/src/index.ts @@ -4,3 +4,4 @@ export * from './config'; export * from './mock'; export * from './errors'; export * from './soroban'; +export * from './trustline-validation'; diff --git a/packages/stellar/src/soroban-errors.test.ts b/packages/stellar/src/soroban-errors.test.ts new file mode 100644 index 00000000..d1c15c53 --- /dev/null +++ b/packages/stellar/src/soroban-errors.test.ts @@ -0,0 +1,264 @@ +/** + * Soroban Contract Error Code Taxonomy Tests + * + * Tests for mapping Soroban contract error codes to typed application errors. + */ + +import { describe, it, expect } from 'vitest'; +import { mapSorobanError, parseStellarError, getErrorGuidance } from './errors'; + +describe('Soroban Error Code Taxonomy', () => { + describe('mapSorobanError', () => { + it('should map type mismatch errors', () => { + const error = mapSorobanError('scvUnexpectedType'); + + expect(error.code).toBe('SOROBAN_CONTRACT_ERROR'); + expect(error.title).toBe('Type Mismatch'); + expect(error.message).toContain('unexpected value type'); + expect(error.retryable).toBe(false); + expect(error.resultCode).toBe('scvUnexpectedType'); + }); + + it('should map contract panic errors', () => { + const error = mapSorobanError('scvContractPanic'); + + expect(error.code).toBe('SOROBAN_CONTRACT_PANIC'); + expect(error.title).toBe('Contract Panic'); + expect(error.message).toContain('panicked unexpectedly'); + expect(error.retryable).toBe(false); + }); + + it('should map resource limit errors', () => { + const error = mapSorobanError('scvExceededLimit'); + + expect(error.code).toBe('SOROBAN_RESOURCE_LIMIT_EXCEEDED'); + expect(error.title).toBe('Resource Limit Exceeded'); + expect(error.message).toContain('exceeded resource limits'); + expect(error.retryable).toBe(false); + }); + + it('should map storage errors', () => { + const error = mapSorobanError('scvStorageError'); + + expect(error.code).toBe('SOROBAN_STORAGE_ERROR'); + expect(error.title).toBe('Storage Error'); + expect(error.message).toContain('storage access error'); + expect(error.retryable).toBe(false); + }); + + it('should map auth errors', () => { + const error = mapSorobanError('scvAuthError'); + + expect(error.code).toBe('SOROBAN_AUTH_ERROR'); + expect(error.title).toBe('Authorization Error'); + expect(error.message).toContain('authorization failed'); + expect(error.retryable).toBe(false); + }); + + it('should map WASM errors', () => { + const error = mapSorobanError('scvWasmTrap'); + + expect(error.code).toBe('SOROBAN_WASM_ERROR'); + expect(error.title).toBe('WASM Trap'); + expect(error.message).toContain('WASM execution trapped'); + expect(error.retryable).toBe(false); + }); + + it('should provide fallback for unknown error codes', () => { + const error = mapSorobanError('scvUnknownError'); + + expect(error.code).toBe('SOROBAN_CONTRACT_ERROR'); + expect(error.title).toBe('Unknown Soroban Error'); + expect(error.message).toContain('scvUnknownError'); + expect(error.retryable).toBe(false); + }); + }); + + describe('parseStellarError with Soroban errors', () => { + it('should parse Soroban contract errors from Error objects', () => { + const error = new Error('Contract failed: scvUnexpectedType - type mismatch'); + const parsed = parseStellarError(error); + + expect(parsed.code).toBe('SOROBAN_CONTRACT_ERROR'); + expect(parsed.title).toBe('Type Mismatch'); + expect(parsed.retryable).toBe(false); + }); + + it('should parse Soroban panic errors', () => { + const error = new Error('Soroban contract panicked: scvContractPanic'); + const parsed = parseStellarError(error); + + expect(parsed.code).toBe('SOROBAN_CONTRACT_PANIC'); + expect(parsed.title).toBe('Contract Panic'); + }); + + it('should parse resource limit errors', () => { + const error = new Error('scvCpuLimitExceeded: CPU limit exceeded'); + const parsed = parseStellarError(error); + + expect(parsed.code).toBe('SOROBAN_RESOURCE_LIMIT_EXCEEDED'); + expect(parsed.title).toBe('CPU Limit Exceeded'); + }); + + it('should parse storage errors', () => { + const error = new Error('scvStorageKeyNotFound: key not found'); + const parsed = parseStellarError(error); + + expect(parsed.code).toBe('SOROBAN_STORAGE_ERROR'); + expect(parsed.title).toBe('Storage Key Not Found'); + }); + + it('should parse auth errors', () => { + const error = new Error('scvInvalidSignature: signature verification failed'); + const parsed = parseStellarError(error); + + expect(parsed.code).toBe('SOROBAN_AUTH_ERROR'); + expect(parsed.title).toBe('Invalid Signature'); + }); + + it('should handle generic Soroban errors without specific code', () => { + const error = new Error('Soroban contract execution failed'); + const parsed = parseStellarError(error); + + expect(parsed.code).toBe('SOROBAN_CONTRACT_ERROR'); + }); + }); + + describe('Error Guidance for Soroban Errors', () => { + it('should provide guidance for contract errors', () => { + const guidance = getErrorGuidance('SOROBAN_CONTRACT_ERROR'); + + expect(guidance.template.title).toBe('Soroban Contract Error'); + expect(guidance.steps).toHaveLength(4); + expect(guidance.links).toHaveLength(2); + expect(guidance.links[0].label).toContain('Soroban'); + }); + + it('should provide guidance for panic errors', () => { + const guidance = getErrorGuidance('SOROBAN_CONTRACT_PANIC'); + + expect(guidance.template.title).toBe('Soroban Contract Panic'); + expect(guidance.steps.length).toBeGreaterThan(0); + expect(guidance.steps.some((s) => s.includes('panic'))).toBe(true); + }); + + it('should provide guidance for resource limit errors', () => { + const guidance = getErrorGuidance('SOROBAN_RESOURCE_LIMIT_EXCEEDED'); + + expect(guidance.template.title).toBe('Soroban Resource Limit Exceeded'); + expect(guidance.steps.some((s) => s.includes('resource'))).toBe(true); + }); + + it('should provide guidance for storage errors', () => { + const guidance = getErrorGuidance('SOROBAN_STORAGE_ERROR'); + + expect(guidance.template.title).toBe('Soroban Storage Error'); + expect(guidance.steps.some((s) => s.includes('storage'))).toBe(true); + }); + + it('should provide guidance for auth errors', () => { + const guidance = getErrorGuidance('SOROBAN_AUTH_ERROR'); + + expect(guidance.template.title).toBe('Soroban Authorization Error'); + expect(guidance.steps.some((s) => s.includes('authorization'))).toBe(true); + }); + + it('should provide guidance for WASM errors', () => { + const guidance = getErrorGuidance('SOROBAN_WASM_ERROR'); + + expect(guidance.template.title).toBe('Soroban WASM Error'); + expect(guidance.steps.some((s) => s.includes('WASM'))).toBe(true); + }); + }); + + describe('Error Code Coverage', () => { + const knownSorobanCodes = [ + 'scvUnexpectedType', + 'scvMissingValue', + 'scvInvalidInput', + 'scvArithmeticError', + 'scvIndexBounds', + 'scvInvalidAction', + 'scvContractPanic', + 'scvUnwrapFailed', + 'scvAssertionFailed', + 'scvInsufficientRefundableFee', + 'scvExceededLimit', + 'scvInsufficientBalance', + 'scvStorageExhausted', + 'scvCpuLimitExceeded', + 'scvMemoryLimitExceeded', + 'scvStorageError', + 'scvStorageKeyNotFound', + 'scvAuthError', + 'scvInvalidSignature', + 'scvWasmTrap', + 'scvWasmMemoryError', + 'scvInvalidWasm', + ]; + + it('should map all known Soroban error codes', () => { + for (const code of knownSorobanCodes) { + const error = mapSorobanError(code); + + expect(error).toBeDefined(); + expect(error.code).toMatch(/^SOROBAN_/); + expect(error.title).toBeTruthy(); + expect(error.message).toBeTruthy(); + expect(typeof error.retryable).toBe('boolean'); + } + }); + + it('should categorize error codes correctly', () => { + // Host function errors + expect(mapSorobanError('scvUnexpectedType').code).toBe('SOROBAN_CONTRACT_ERROR'); + expect(mapSorobanError('scvArithmeticError').code).toBe('SOROBAN_CONTRACT_ERROR'); + + // Panic errors + expect(mapSorobanError('scvContractPanic').code).toBe('SOROBAN_CONTRACT_PANIC'); + expect(mapSorobanError('scvUnwrapFailed').code).toBe('SOROBAN_CONTRACT_PANIC'); + + // Resource limit errors + expect(mapSorobanError('scvExceededLimit').code).toBe('SOROBAN_RESOURCE_LIMIT_EXCEEDED'); + expect(mapSorobanError('scvCpuLimitExceeded').code).toBe('SOROBAN_RESOURCE_LIMIT_EXCEEDED'); + + // Storage errors + expect(mapSorobanError('scvStorageError').code).toBe('SOROBAN_STORAGE_ERROR'); + expect(mapSorobanError('scvStorageKeyNotFound').code).toBe('SOROBAN_STORAGE_ERROR'); + + // Auth errors + expect(mapSorobanError('scvAuthError').code).toBe('SOROBAN_AUTH_ERROR'); + expect(mapSorobanError('scvInvalidSignature').code).toBe('SOROBAN_AUTH_ERROR'); + + // WASM errors + expect(mapSorobanError('scvWasmTrap').code).toBe('SOROBAN_WASM_ERROR'); + expect(mapSorobanError('scvInvalidWasm').code).toBe('SOROBAN_WASM_ERROR'); + }); + }); + + describe('Fallback Behavior', () => { + it('should handle unknown error codes gracefully', () => { + const unknownCodes = [ + 'scvNewErrorCode', + 'scvFutureError', + 'scvCustomError', + ]; + + for (const code of unknownCodes) { + const error = mapSorobanError(code); + + expect(error.code).toBe('SOROBAN_CONTRACT_ERROR'); + expect(error.title).toBe('Unknown Soroban Error'); + expect(error.message).toContain(code); + expect(error.retryable).toBe(false); + } + }); + + it('should include error code in fallback message', () => { + const error = mapSorobanError('scvCustomError123'); + + expect(error.message).toContain('scvCustomError123'); + expect(error.message).toContain('Check the contract logs'); + }); + }); +}); diff --git a/packages/stellar/src/soroban-wasm-validation.test.ts b/packages/stellar/src/soroban-wasm-validation.test.ts new file mode 100644 index 00000000..3d0033ae --- /dev/null +++ b/packages/stellar/src/soroban-wasm-validation.test.ts @@ -0,0 +1,220 @@ +/** + * Soroban WASM Binary Size Validation Tests + * + * Tests for validating WASM binary size against Soroban deployment constraints. + */ + +import { describe, it, expect } from 'vitest'; +import { validateWasmSize, assertValidWasmSize, MAX_WASM_SIZE_BYTES } from './soroban'; + +describe('Soroban WASM Size Validation', () => { + describe('validateWasmSize', () => { + it('should accept WASM binary under size limit', () => { + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES - 1000); + const result = validateWasmSize(wasm); + + expect(result.valid).toBe(true); + expect(result.size).toBe(MAX_WASM_SIZE_BYTES - 1000); + expect(result.maxSize).toBe(MAX_WASM_SIZE_BYTES); + expect(result.error).toBeUndefined(); + }); + + it('should accept WASM binary at exact size limit', () => { + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES); + const result = validateWasmSize(wasm); + + expect(result.valid).toBe(true); + expect(result.size).toBe(MAX_WASM_SIZE_BYTES); + }); + + it('should reject WASM binary over size limit', () => { + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES + 1); + const result = validateWasmSize(wasm); + + expect(result.valid).toBe(false); + expect(result.size).toBe(MAX_WASM_SIZE_BYTES + 1); + expect(result.maxSize).toBe(MAX_WASM_SIZE_BYTES); + expect(result.error).toBeDefined(); + }); + + it('should include size and limit in error message', () => { + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES + 5000); + const result = validateWasmSize(wasm); + + expect(result.error).toContain(`${MAX_WASM_SIZE_BYTES + 5000} bytes`); + expect(result.error).toContain(`${MAX_WASM_SIZE_BYTES} bytes`); + expect(result.error).toContain('5000 bytes'); + }); + + it('should work with Uint8Array', () => { + const wasm = new Uint8Array(MAX_WASM_SIZE_BYTES - 100); + const result = validateWasmSize(wasm); + + expect(result.valid).toBe(true); + expect(result.size).toBe(MAX_WASM_SIZE_BYTES - 100); + }); + + it('should handle empty WASM binary', () => { + const wasm = Buffer.alloc(0); + const result = validateWasmSize(wasm); + + expect(result.valid).toBe(true); + expect(result.size).toBe(0); + }); + + it('should handle very small WASM binary', () => { + const wasm = Buffer.alloc(100); + const result = validateWasmSize(wasm); + + expect(result.valid).toBe(true); + expect(result.size).toBe(100); + }); + }); + + describe('assertValidWasmSize', () => { + it('should not throw for valid WASM size', () => { + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES - 1000); + + expect(() => assertValidWasmSize(wasm)).not.toThrow(); + }); + + it('should throw for oversized WASM binary', () => { + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES + 1); + + expect(() => assertValidWasmSize(wasm)).toThrow(); + }); + + it('should throw with descriptive error message', () => { + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES + 5000); + + expect(() => assertValidWasmSize(wasm)).toThrow(/exceeds maximum allowed size/); + expect(() => assertValidWasmSize(wasm)).toThrow(/5000 bytes/); + }); + + it('should work with Uint8Array', () => { + const wasm = new Uint8Array(MAX_WASM_SIZE_BYTES + 1); + + expect(() => assertValidWasmSize(wasm)).toThrow(); + }); + }); + + describe('Boundary Testing', () => { + it('should accept binary at size limit minus 1', () => { + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES - 1); + const result = validateWasmSize(wasm); + + expect(result.valid).toBe(true); + }); + + it('should accept binary at exact size limit', () => { + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES); + const result = validateWasmSize(wasm); + + expect(result.valid).toBe(true); + }); + + it('should reject binary at size limit plus 1', () => { + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES + 1); + const result = validateWasmSize(wasm); + + expect(result.valid).toBe(false); + }); + + it('should reject binary significantly over limit', () => { + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES * 2); + const result = validateWasmSize(wasm); + + expect(result.valid).toBe(false); + expect(result.error).toContain(`${MAX_WASM_SIZE_BYTES} bytes`); + }); + }); + + describe('Error Message Quality', () => { + it('should provide actionable error message', () => { + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES + 10000); + const result = validateWasmSize(wasm); + + expect(result.error).toContain('exceeds maximum allowed size'); + expect(result.error).toContain('Reduce contract size'); + }); + + it('should show exact size difference', () => { + const oversizeBy = 12345; + const wasm = Buffer.alloc(MAX_WASM_SIZE_BYTES + oversizeBy); + const result = validateWasmSize(wasm); + + expect(result.error).toContain(`${oversizeBy} bytes`); + }); + + it('should include both actual and max size', () => { + const actualSize = MAX_WASM_SIZE_BYTES + 5000; + const wasm = Buffer.alloc(actualSize); + const result = validateWasmSize(wasm); + + expect(result.error).toContain(`${actualSize} bytes`); + expect(result.error).toContain(`${MAX_WASM_SIZE_BYTES} bytes`); + }); + }); + + describe('Integration Scenarios', () => { + it('should validate before deployment workflow', () => { + // Simulate a deployment workflow + const contractWasm = Buffer.alloc(MAX_WASM_SIZE_BYTES - 1000); + + // Step 1: Validate size + const validation = validateWasmSize(contractWasm); + expect(validation.valid).toBe(true); + + // Step 2: If valid, proceed with deployment + if (validation.valid) { + // Deployment logic would go here + expect(true).toBe(true); + } + }); + + it('should block deployment for oversized binary', () => { + const contractWasm = Buffer.alloc(MAX_WASM_SIZE_BYTES + 1000); + + // Validation should fail + const validation = validateWasmSize(contractWasm); + expect(validation.valid).toBe(false); + + // Deployment should be blocked + if (!validation.valid) { + expect(validation.error).toBeDefined(); + // Would show error to user and prevent deployment + } + }); + + it('should provide clear feedback for size optimization', () => { + const oversizeBy = 8192; // 8 KB over + const contractWasm = Buffer.alloc(MAX_WASM_SIZE_BYTES + oversizeBy); + + const validation = validateWasmSize(contractWasm); + + expect(validation.valid).toBe(false); + expect(validation.error).toContain(`${oversizeBy} bytes`); + + // User knows exactly how much to reduce + const reductionNeeded = validation.size! - validation.maxSize; + expect(reductionNeeded).toBe(oversizeBy); + }); + }); + + describe('Size Limit Constant', () => { + it('should have correct maximum size', () => { + expect(MAX_WASM_SIZE_BYTES).toBe(65536); // 64 KB + }); + + it('should use consistent limit across validations', () => { + const wasm1 = Buffer.alloc(MAX_WASM_SIZE_BYTES); + const wasm2 = Buffer.alloc(MAX_WASM_SIZE_BYTES + 1); + + const result1 = validateWasmSize(wasm1); + const result2 = validateWasmSize(wasm2); + + expect(result1.maxSize).toBe(result2.maxSize); + expect(result1.maxSize).toBe(MAX_WASM_SIZE_BYTES); + }); + }); +}); diff --git a/packages/stellar/src/soroban.ts b/packages/stellar/src/soroban.ts index 94eb83cf..eb31a0ea 100644 --- a/packages/stellar/src/soroban.ts +++ b/packages/stellar/src/soroban.ts @@ -13,6 +13,20 @@ export type InvokeContractResult | { ok: true; result: T } | { ok: false; error: AppError }; +/** + * Maximum Soroban contract WASM binary size in bytes. + * Based on Soroban network deployment constraints. + * @see https://developers.stellar.org/docs/smart-contracts/limits-and-fees + */ +export const MAX_WASM_SIZE_BYTES = 65536; // 64 KB + +export interface WasmValidationResult { + valid: boolean; + size?: number; + maxSize: number; + error?: string; +} + const SOROBAN_RPC_URLS = { mainnet: 'https://soroban-mainnet.stellar.org', testnet: 'https://soroban-testnet.stellar.org', @@ -262,3 +276,61 @@ export async function invokeContractMethod( }; } } + +/** + * Validates Soroban contract WASM binary size against network deployment constraints. + * + * @param wasmBinary - The WASM binary as Buffer or Uint8Array + * @returns Validation result with size information and error details + * + * @example + * ```typescript + * const wasm = fs.readFileSync('contract.wasm'); + * const result = validateWasmSize(wasm); + * if (!result.valid) { + * console.error(result.error); + * console.log(`Binary size: ${result.size} bytes, Max: ${result.maxSize} bytes`); + * } + * ``` + */ +export function validateWasmSize(wasmBinary: Buffer | Uint8Array): WasmValidationResult { + const size = wasmBinary.length; + + if (size > MAX_WASM_SIZE_BYTES) { + return { + valid: false, + size, + maxSize: MAX_WASM_SIZE_BYTES, + error: `WASM binary size (${size} bytes) exceeds maximum allowed size (${MAX_WASM_SIZE_BYTES} bytes). Reduce contract size by ${size - MAX_WASM_SIZE_BYTES} bytes.`, + }; + } + + return { + valid: true, + size, + maxSize: MAX_WASM_SIZE_BYTES, + }; +} + +/** + * Validates WASM binary before deployment and throws if invalid. + * + * @param wasmBinary - The WASM binary to validate + * @throws Error if WASM binary exceeds size limit + * + * @example + * ```typescript + * try { + * assertValidWasmSize(wasmBinary); + * // Proceed with deployment + * } catch (error) { + * console.error('Deployment blocked:', error.message); + * } + * ``` + */ +export function assertValidWasmSize(wasmBinary: Buffer | Uint8Array): void { + const result = validateWasmSize(wasmBinary); + if (!result.valid) { + throw new Error(result.error); + } +} diff --git a/packages/stellar/src/transaction-envelope.test.ts b/packages/stellar/src/transaction-envelope.test.ts new file mode 100644 index 00000000..58799f6e --- /dev/null +++ b/packages/stellar/src/transaction-envelope.test.ts @@ -0,0 +1,501 @@ +/** + * Stellar Transaction Envelope Serialization Tests + * + * Tests for multi-operation transaction envelope serialization to verify: + * - Operation order preservation through serialize/deserialize + * - Signature validity after serialization round-trip + * - No data loss in multi-operation batches + */ + +import { + Keypair, + TransactionBuilder, + Networks, + Operation, + Asset, + Account, + Transaction, +} from 'stellar-sdk'; +import { describe, it, expect, beforeEach } from 'vitest'; + +describe('Transaction Envelope Serialization', () => { + let sourceKeypair: Keypair; + let destinationKeypair: Keypair; + let account: Account; + + beforeEach(() => { + sourceKeypair = Keypair.random(); + destinationKeypair = Keypair.random(); + account = new Account(sourceKeypair.publicKey(), '1000'); + }); + + describe('Multi-Operation Serialization Round-Trip', () => { + it('should preserve operation order through serialize/deserialize', () => { + // Create a transaction with multiple operations in specific order + const transaction = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: Asset.native(), + amount: '10', + }) + ) + .addOperation( + Operation.changeTrust({ + asset: new Asset('USD', sourceKeypair.publicKey()), + limit: '1000', + }) + ) + .addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: new Asset('USD', sourceKeypair.publicKey()), + amount: '5', + }) + ) + .setTimeout(30) + .build(); + + // Serialize to XDR + const xdr = transaction.toXDR(); + + // Deserialize from XDR + const deserialized = TransactionBuilder.fromXDR(xdr, Networks.TESTNET) as Transaction; + + // Verify operation count + expect(deserialized.operations).toHaveLength(3); + + // Verify operation order is preserved + expect(deserialized.operations[0].type).toBe('payment'); + expect(deserialized.operations[1].type).toBe('changeTrust'); + expect(deserialized.operations[2].type).toBe('payment'); + + // Verify operation details + const payment1 = deserialized.operations[0] as any; + expect(payment1.destination).toBe(destinationKeypair.publicKey()); + expect(payment1.amount).toBe('10'); + + const changeTrust = deserialized.operations[1] as any; + expect(changeTrust.line.code).toBe('USD'); + expect(changeTrust.limit).toBe('1000'); + + const payment2 = deserialized.operations[2] as any; + expect(payment2.destination).toBe(destinationKeypair.publicKey()); + expect(payment2.amount).toBe('5'); + }); + + it('should preserve all operation types used by templates', () => { + const issuerKeypair = Keypair.random(); + const asset = new Asset('TOKEN', issuerKeypair.publicKey()); + + const transaction = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.createAccount({ + destination: destinationKeypair.publicKey(), + startingBalance: '2', + }) + ) + .addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: Asset.native(), + amount: '100', + }) + ) + .addOperation( + Operation.changeTrust({ + asset, + limit: '10000', + }) + ) + .addOperation( + Operation.setOptions({ + homeDomain: 'example.com', + }) + ) + .addOperation( + Operation.manageData({ + name: 'config', + value: Buffer.from('test-data'), + }) + ) + .setTimeout(30) + .build(); + + const xdr = transaction.toXDR(); + const deserialized = TransactionBuilder.fromXDR(xdr, Networks.TESTNET) as Transaction; + + expect(deserialized.operations).toHaveLength(5); + expect(deserialized.operations[0].type).toBe('createAccount'); + expect(deserialized.operations[1].type).toBe('payment'); + expect(deserialized.operations[2].type).toBe('changeTrust'); + expect(deserialized.operations[3].type).toBe('setOptions'); + expect(deserialized.operations[4].type).toBe('manageData'); + + // Verify operation data integrity + const createAccount = deserialized.operations[0] as any; + expect(createAccount.destination).toBe(destinationKeypair.publicKey()); + expect(createAccount.startingBalance).toBe('2'); + + const manageData = deserialized.operations[4] as any; + expect(manageData.name).toBe('config'); + expect(manageData.value?.toString()).toBe('test-data'); + }); + + it('should handle mixed operation types without data loss', () => { + const asset1 = new Asset('USD', Keypair.random().publicKey()); + const asset2 = new Asset('EUR', Keypair.random().publicKey()); + + const transaction = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.changeTrust({ + asset: asset1, + limit: '5000', + }) + ) + .addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: asset1, + amount: '100', + }) + ) + .addOperation( + Operation.changeTrust({ + asset: asset2, + limit: '3000', + }) + ) + .addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: asset2, + amount: '50', + }) + ) + .setTimeout(30) + .build(); + + const xdr = transaction.toXDR(); + const deserialized = TransactionBuilder.fromXDR(xdr, Networks.TESTNET) as Transaction; + + expect(deserialized.operations).toHaveLength(4); + + // Verify alternating pattern is preserved + expect(deserialized.operations[0].type).toBe('changeTrust'); + expect(deserialized.operations[1].type).toBe('payment'); + expect(deserialized.operations[2].type).toBe('changeTrust'); + expect(deserialized.operations[3].type).toBe('payment'); + + // Verify asset details + const trust1 = deserialized.operations[0] as any; + expect(trust1.line.code).toBe('USD'); + expect(trust1.line.issuer).toBe(asset1.issuer); + + const payment1 = deserialized.operations[1] as any; + expect(payment1.asset.code).toBe('USD'); + expect(payment1.amount).toBe('100'); + + const trust2 = deserialized.operations[2] as any; + expect(trust2.line.code).toBe('EUR'); + expect(trust2.line.issuer).toBe(asset2.issuer); + + const payment2 = deserialized.operations[3] as any; + expect(payment2.asset.code).toBe('EUR'); + expect(payment2.amount).toBe('50'); + }); + }); + + describe('Signature Preservation', () => { + it('should preserve signatures after serialization round-trip', () => { + const transaction = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: Asset.native(), + amount: '10', + }) + ) + .setTimeout(30) + .build(); + + // Sign the transaction + transaction.sign(sourceKeypair); + + // Verify signature exists + expect(transaction.signatures).toHaveLength(1); + const originalSignature = transaction.signatures[0]; + + // Serialize and deserialize + const xdr = transaction.toXDR(); + const deserialized = TransactionBuilder.fromXDR(xdr, Networks.TESTNET) as Transaction; + + // Verify signature is preserved + expect(deserialized.signatures).toHaveLength(1); + expect(deserialized.signatures[0].signature()).toEqual(originalSignature.signature()); + expect(deserialized.signatures[0].hint()).toEqual(originalSignature.hint()); + }); + + it('should preserve multiple signatures after round-trip', () => { + const signer1 = Keypair.random(); + const signer2 = Keypair.random(); + + const transaction = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: Asset.native(), + amount: '10', + }) + ) + .setTimeout(30) + .build(); + + // Sign with multiple signers + transaction.sign(sourceKeypair); + transaction.sign(signer1); + transaction.sign(signer2); + + expect(transaction.signatures).toHaveLength(3); + + // Serialize and deserialize + const xdr = transaction.toXDR(); + const deserialized = TransactionBuilder.fromXDR(xdr, Networks.TESTNET) as Transaction; + + // Verify all signatures are preserved + expect(deserialized.signatures).toHaveLength(3); + for (let i = 0; i < 3; i++) { + expect(deserialized.signatures[i].signature()).toEqual( + transaction.signatures[i].signature() + ); + expect(deserialized.signatures[i].hint()).toEqual(transaction.signatures[i].hint()); + } + }); + + it('should maintain signature validity after serialization', () => { + const transaction = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: Asset.native(), + amount: '10', + }) + ) + .setTimeout(30) + .build(); + + transaction.sign(sourceKeypair); + + // Get transaction hash before serialization + const originalHash = transaction.hash(); + + // Serialize and deserialize + const xdr = transaction.toXDR(); + const deserialized = TransactionBuilder.fromXDR(xdr, Networks.TESTNET) as Transaction; + + // Verify transaction hash is identical (signatures are valid for same tx) + expect(deserialized.hash()).toEqual(originalHash); + + // Verify signature can be verified against the deserialized transaction + const signature = deserialized.signatures[0]; + const signatureBase = deserialized.hash(); + + // The signature should be valid for the deserialized transaction + expect(sourceKeypair.verify(signatureBase, signature.signature())).toBe(true); + }); + }); + + describe('Multi-Operation Batch Serialization', () => { + it('should handle large multi-operation batches', () => { + const builder = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }); + + // Add 10 operations + for (let i = 0; i < 10; i++) { + builder.addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: Asset.native(), + amount: `${i + 1}`, + }) + ); + } + + const transaction = builder.setTimeout(30).build(); + transaction.sign(sourceKeypair); + + const xdr = transaction.toXDR(); + const deserialized = TransactionBuilder.fromXDR(xdr, Networks.TESTNET) as Transaction; + + expect(deserialized.operations).toHaveLength(10); + + // Verify each operation amount is correct and in order + for (let i = 0; i < 10; i++) { + const op = deserialized.operations[i] as any; + expect(op.amount).toBe(`${i + 1}`); + } + + // Verify signature is preserved + expect(deserialized.signatures).toHaveLength(1); + }); + + it('should preserve operation source accounts in multi-op batches', () => { + const opSource1 = Keypair.random(); + const opSource2 = Keypair.random(); + + const transaction = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + source: opSource1.publicKey(), + destination: destinationKeypair.publicKey(), + asset: Asset.native(), + amount: '10', + }) + ) + .addOperation( + Operation.payment({ + source: opSource2.publicKey(), + destination: destinationKeypair.publicKey(), + asset: Asset.native(), + amount: '20', + }) + ) + .addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: Asset.native(), + amount: '30', + }) + ) + .setTimeout(30) + .build(); + + const xdr = transaction.toXDR(); + const deserialized = TransactionBuilder.fromXDR(xdr, Networks.TESTNET) as Transaction; + + expect(deserialized.operations).toHaveLength(3); + + // Verify operation sources + expect(deserialized.operations[0].source).toBe(opSource1.publicKey()); + expect(deserialized.operations[1].source).toBe(opSource2.publicKey()); + expect(deserialized.operations[2].source).toBeUndefined(); // Uses transaction source + }); + }); + + describe('Byte-Level Serialization Correctness', () => { + it('should produce identical XDR on repeated serialization', () => { + const transaction = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: Asset.native(), + amount: '10', + }) + ) + .addOperation( + Operation.changeTrust({ + asset: new Asset('USD', sourceKeypair.publicKey()), + limit: '1000', + }) + ) + .setTimeout(30) + .build(); + + transaction.sign(sourceKeypair); + + // Serialize multiple times + const xdr1 = transaction.toXDR(); + const xdr2 = transaction.toXDR(); + + // Should produce identical output + expect(xdr1).toBe(xdr2); + }); + + it('should produce identical XDR after deserialize-serialize cycle', () => { + const transaction = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: Asset.native(), + amount: '10', + }) + ) + .addOperation( + Operation.changeTrust({ + asset: new Asset('USD', sourceKeypair.publicKey()), + limit: '1000', + }) + ) + .setTimeout(30) + .build(); + + transaction.sign(sourceKeypair); + + const originalXdr = transaction.toXDR(); + const deserialized = TransactionBuilder.fromXDR(originalXdr, Networks.TESTNET) as Transaction; + const reserializedXdr = deserialized.toXDR(); + + // Should produce identical XDR + expect(reserializedXdr).toBe(originalXdr); + }); + + it('should handle base64 and hex encoding consistently', () => { + const transaction = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + destination: destinationKeypair.publicKey(), + asset: Asset.native(), + amount: '10', + }) + ) + .setTimeout(30) + .build(); + + transaction.sign(sourceKeypair); + + // Serialize in different formats + const base64Xdr = transaction.toXDR('base64'); + const hexXdr = transaction.toXDR('hex'); + + // Deserialize from both formats + const fromBase64 = TransactionBuilder.fromXDR(base64Xdr, Networks.TESTNET) as Transaction; + const fromHex = TransactionBuilder.fromXDR(hexXdr, Networks.TESTNET) as Transaction; + + // Both should produce identical transactions + expect(fromBase64.hash()).toEqual(fromHex.hash()); + expect(fromBase64.toXDR()).toBe(fromHex.toXDR()); + }); + }); +}); diff --git a/packages/stellar/src/trustline-validation.test.ts b/packages/stellar/src/trustline-validation.test.ts new file mode 100644 index 00000000..3abde5fa --- /dev/null +++ b/packages/stellar/src/trustline-validation.test.ts @@ -0,0 +1,406 @@ +/** + * Trustline Validation Tests + * + * Tests for validating Stellar trustlines before asset issuance template deployment. + */ + +import { describe, it, expect } from 'vitest'; +import { Keypair } from 'stellar-sdk'; +import { + validateTrustlines, + canEstablishTrustlines, + validateAssetIssuanceDeployment, + formatTrustlineError, + MAX_TRUSTLINES_PER_ACCOUNT, +} from './trustline-validation'; +import type { Horizon } from 'stellar-sdk'; + +describe('Trustline Validation', () => { + const accountId = Keypair.random().publicKey(); + const issuer1 = Keypair.random().publicKey(); + const issuer2 = Keypair.random().publicKey(); + + describe('validateTrustlines', () => { + it('should accept valid account with required trustlines', async () => { + const accountData = { + balances: [ + { + asset_type: 'credit_alphanum4', + asset_code: 'USD', + asset_issuer: issuer1, + balance: '100', + limit: '1000', + is_authorized: true, + is_authorized_to_maintain_liabilities: false, + }, + ], + } as Horizon.ServerApi.AccountRecord; + + const result = await validateTrustlines( + accountId, + [{ code: 'USD', issuer: issuer1 }], + accountData + ); + + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.missingTrustlines).toBeUndefined(); + }); + + it('should reject when trustline does not exist', async () => { + const accountData = { + balances: [], + } as Horizon.ServerApi.AccountRecord; + + const result = await validateTrustlines( + accountId, + [{ code: 'USD', issuer: issuer1 }], + accountData + ); + + expect(result.valid).toBe(false); + expect(result.error).toContain('Missing or invalid trustlines'); + expect(result.missingTrustlines).toHaveLength(1); + expect(result.missingTrustlines?.[0].asset).toBe('USD'); + expect(result.missingTrustlines?.[0].reason).toBe('Trustline does not exist'); + }); + + it('should reject when trustline is not authorized', async () => { + const accountData = { + balances: [ + { + asset_type: 'credit_alphanum4', + asset_code: 'USD', + asset_issuer: issuer1, + balance: '0', + limit: '1000', + is_authorized: false, + is_authorized_to_maintain_liabilities: false, + }, + ], + } as Horizon.ServerApi.AccountRecord; + + const result = await validateTrustlines( + accountId, + [{ code: 'USD', issuer: issuer1 }], + accountData + ); + + expect(result.valid).toBe(false); + expect(result.missingTrustlines).toHaveLength(1); + expect(result.missingTrustlines?.[0].reason).toBe('Trustline exists but is not authorized'); + }); + + it('should reject when trustline limit is maxed out', async () => { + const accountData = { + balances: [ + { + asset_type: 'credit_alphanum4', + asset_code: 'USD', + asset_issuer: issuer1, + balance: '1000', + limit: '1000', + is_authorized: true, + is_authorized_to_maintain_liabilities: false, + }, + ], + } as Horizon.ServerApi.AccountRecord; + + const result = await validateTrustlines( + accountId, + [{ code: 'USD', issuer: issuer1 }], + accountData + ); + + expect(result.valid).toBe(false); + expect(result.missingTrustlines).toHaveLength(1); + expect(result.missingTrustlines?.[0].reason).toBe('Trustline limit is maxed out'); + }); + + it('should accept native XLM without trustline', async () => { + const accountData = { + balances: [], + } as Horizon.ServerApi.AccountRecord; + + const result = await validateTrustlines( + accountId, + [{ code: 'XLM', issuer: '' }], + accountData + ); + + expect(result.valid).toBe(true); + }); + + it('should validate multiple trustlines', async () => { + const accountData = { + balances: [ + { + asset_type: 'credit_alphanum4', + asset_code: 'USD', + asset_issuer: issuer1, + balance: '100', + limit: '1000', + is_authorized: true, + is_authorized_to_maintain_liabilities: false, + }, + { + asset_type: 'credit_alphanum4', + asset_code: 'EUR', + asset_issuer: issuer2, + balance: '50', + limit: '500', + is_authorized: true, + is_authorized_to_maintain_liabilities: false, + }, + ], + } as Horizon.ServerApi.AccountRecord; + + const result = await validateTrustlines( + accountId, + [ + { code: 'USD', issuer: issuer1 }, + { code: 'EUR', issuer: issuer2 }, + ], + accountData + ); + + expect(result.valid).toBe(true); + }); + + it('should identify multiple missing trustlines', async () => { + const accountData = { + balances: [], + } as Horizon.ServerApi.AccountRecord; + + const result = await validateTrustlines( + accountId, + [ + { code: 'USD', issuer: issuer1 }, + { code: 'EUR', issuer: issuer2 }, + ], + accountData + ); + + expect(result.valid).toBe(false); + expect(result.missingTrustlines).toHaveLength(2); + }); + + it('should reject invalid account address', async () => { + const result = await validateTrustlines( + 'INVALID', + [{ code: 'USD', issuer: issuer1 }] + ); + + expect(result.valid).toBe(false); + expect(result.error).toBe('Invalid account address format'); + }); + + it('should accept trustline with maintain liabilities authorization', async () => { + const accountData = { + balances: [ + { + asset_type: 'credit_alphanum4', + asset_code: 'USD', + asset_issuer: issuer1, + balance: '100', + limit: '1000', + is_authorized: false, + is_authorized_to_maintain_liabilities: true, + }, + ], + } as Horizon.ServerApi.AccountRecord; + + const result = await validateTrustlines( + accountId, + [{ code: 'USD', issuer: issuer1 }], + accountData + ); + + expect(result.valid).toBe(true); + }); + }); + + describe('canEstablishTrustlines', () => { + it('should allow establishing trustlines under limit', () => { + const accountData = { + balances: [ + { asset_type: 'native' }, + { asset_type: 'credit_alphanum4' }, + { asset_type: 'credit_alphanum4' }, + ], + } as Horizon.ServerApi.AccountRecord; + + const result = canEstablishTrustlines(accountData, 5); + + expect(result).toBe(true); + }); + + it('should reject when at maximum trustline limit', () => { + const balances = [{ asset_type: 'native' }]; + for (let i = 0; i < MAX_TRUSTLINES_PER_ACCOUNT; i++) { + balances.push({ asset_type: 'credit_alphanum4' }); + } + + const accountData = { + balances, + } as Horizon.ServerApi.AccountRecord; + + const result = canEstablishTrustlines(accountData, 1); + + expect(result).toBe(false); + }); + + it('should reject when additional trustlines would exceed limit', () => { + const balances = [{ asset_type: 'native' }]; + for (let i = 0; i < MAX_TRUSTLINES_PER_ACCOUNT - 2; i++) { + balances.push({ asset_type: 'credit_alphanum4' }); + } + + const accountData = { + balances, + } as Horizon.ServerApi.AccountRecord; + + const result = canEstablishTrustlines(accountData, 3); + + expect(result).toBe(false); + }); + + it('should not count native balance as trustline', () => { + const accountData = { + balances: [ + { asset_type: 'native' }, + { asset_type: 'credit_alphanum4' }, + ], + } as Horizon.ServerApi.AccountRecord; + + const result = canEstablishTrustlines(accountData, MAX_TRUSTLINES_PER_ACCOUNT - 1); + + expect(result).toBe(true); + }); + }); + + describe('validateAssetIssuanceDeployment', () => { + it('should accept valid deployment', async () => { + const accountData = { + balances: [ + { + asset_type: 'credit_alphanum4', + asset_code: 'USD', + asset_issuer: issuer1, + balance: '100', + limit: '1000', + is_authorized: true, + is_authorized_to_maintain_liabilities: false, + }, + ], + } as Horizon.ServerApi.AccountRecord; + + const result = await validateAssetIssuanceDeployment( + accountId, + [{ code: 'USD', issuer: issuer1 }], + accountData + ); + + expect(result.valid).toBe(true); + }); + + it('should reject when trustlines are missing', async () => { + const accountData = { + balances: [], + } as Horizon.ServerApi.AccountRecord; + + const result = await validateAssetIssuanceDeployment( + accountId, + [{ code: 'USD', issuer: issuer1 }], + accountData + ); + + expect(result.valid).toBe(false); + expect(result.missingTrustlines).toHaveLength(1); + }); + + it('should reject when account cannot establish additional trustlines', async () => { + const balances = [{ asset_type: 'native' }]; + for (let i = 0; i < MAX_TRUSTLINES_PER_ACCOUNT; i++) { + balances.push({ + asset_type: 'credit_alphanum4', + asset_code: `TOKEN${i}`, + asset_issuer: Keypair.random().publicKey(), + balance: '0', + limit: '1000', + is_authorized: true, + is_authorized_to_maintain_liabilities: false, + }); + } + + const accountData = { + balances, + } as Horizon.ServerApi.AccountRecord; + + const result = await validateAssetIssuanceDeployment( + accountId, + [{ code: 'NEWTOKEN', issuer: issuer1 }], + accountData + ); + + expect(result.valid).toBe(false); + expect(result.error).toContain('maximum trustline limit'); + }); + }); + + describe('formatTrustlineError', () => { + it('should return empty string for valid result', () => { + const result = { valid: true, maxSize: 1000 }; + const formatted = formatTrustlineError(result); + + expect(formatted).toBe(''); + }); + + it('should format error with missing trustlines', () => { + const result = { + valid: false, + error: 'Missing or invalid trustlines for 2 asset(s)', + missingTrustlines: [ + { asset: 'USD', issuer: issuer1, reason: 'Trustline does not exist' }, + { asset: 'EUR', issuer: issuer2, reason: 'Trustline not authorized' }, + ], + }; + + const formatted = formatTrustlineError(result); + + expect(formatted).toContain('Missing or invalid trustlines'); + expect(formatted).toContain('USD'); + expect(formatted).toContain('EUR'); + expect(formatted).toContain('Trustline does not exist'); + expect(formatted).toContain('Trustline not authorized'); + expect(formatted).toContain('To fix this:'); + }); + + it('should format error without missing trustlines', () => { + const result = { + valid: false, + error: 'Invalid account address', + }; + + const formatted = formatTrustlineError(result); + + expect(formatted).toBe('Invalid account address'); + }); + + it('should include remediation steps', () => { + const result = { + valid: false, + error: 'Missing trustlines', + missingTrustlines: [ + { asset: 'USD', issuer: issuer1, reason: 'Trustline does not exist' }, + ], + }; + + const formatted = formatTrustlineError(result); + + expect(formatted).toContain('Establish trustlines'); + expect(formatted).toContain('authorized by the issuer'); + expect(formatted).toContain('limits are not maxed out'); + }); + }); +}); diff --git a/packages/stellar/src/trustline-validation.ts b/packages/stellar/src/trustline-validation.ts new file mode 100644 index 00000000..c6505b86 --- /dev/null +++ b/packages/stellar/src/trustline-validation.ts @@ -0,0 +1,237 @@ +/** + * Stellar Trustline Validation + * + * Validates that necessary Stellar trustlines exist or can be established + * before deploying asset issuance templates. + */ + +import { Asset, Horizon } from 'stellar-sdk'; + +export interface TrustlineValidationResult { + valid: boolean; + error?: string; + missingTrustlines?: Array<{ + asset: string; + issuer: string; + reason: string; + }>; +} + +export interface TrustlineInfo { + asset_type: string; + asset_code?: string; + asset_issuer?: string; + balance: string; + limit: string; + is_authorized: boolean; + is_authorized_to_maintain_liabilities: boolean; +} + +/** + * Maximum number of trustlines an account can have. + * Based on Stellar protocol limits. + */ +export const MAX_TRUSTLINES_PER_ACCOUNT = 1000; + +/** + * Validates that required trustlines exist for an account. + * + * @param accountId - The Stellar account address + * @param requiredAssets - Array of assets that require trustlines + * @param accountData - Account data from Horizon (optional, will fetch if not provided) + * @returns Validation result with details about missing trustlines + * + * @example + * ```typescript + * const result = await validateTrustlines( + * 'GABC...', + * [{ code: 'USD', issuer: 'GDEF...' }] + * ); + * if (!result.valid) { + * console.error('Missing trustlines:', result.missingTrustlines); + * } + * ``` + */ +export async function validateTrustlines( + accountId: string, + requiredAssets: Array<{ code: string; issuer: string }>, + accountData?: Horizon.ServerApi.AccountRecord +): Promise { + // Validate account address format + if (!accountId || accountId.length !== 56 || !accountId.startsWith('G')) { + return { + valid: false, + error: 'Invalid account address format', + }; + } + + // Native XLM doesn't require trustlines + const nonNativeAssets = requiredAssets.filter( + (asset) => asset.code !== 'XLM' && asset.code !== 'native' + ); + + if (nonNativeAssets.length === 0) { + return { valid: true }; + } + + // Get account trustlines + const trustlines = accountData?.balances || []; + const missingTrustlines: Array<{ + asset: string; + issuer: string; + reason: string; + }> = []; + + // Check each required asset + for (const requiredAsset of nonNativeAssets) { + const trustline = trustlines.find( + (t) => + t.asset_type !== 'native' && + t.asset_code === requiredAsset.code && + t.asset_issuer === requiredAsset.issuer + ); + + if (!trustline) { + missingTrustlines.push({ + asset: requiredAsset.code, + issuer: requiredAsset.issuer, + reason: 'Trustline does not exist', + }); + } else { + // Check if trustline is authorized + if (!trustline.is_authorized && !trustline.is_authorized_to_maintain_liabilities) { + missingTrustlines.push({ + asset: requiredAsset.code, + issuer: requiredAsset.issuer, + reason: 'Trustline exists but is not authorized', + }); + } + + // Check if trustline limit is maxed out + if (trustline.limit !== '0' && trustline.balance === trustline.limit) { + missingTrustlines.push({ + asset: requiredAsset.code, + issuer: requiredAsset.issuer, + reason: 'Trustline limit is maxed out', + }); + } + } + } + + if (missingTrustlines.length > 0) { + return { + valid: false, + error: `Missing or invalid trustlines for ${missingTrustlines.length} asset(s)`, + missingTrustlines, + }; + } + + return { valid: true }; +} + +/** + * Checks if an account can establish new trustlines. + * + * @param accountData - Account data from Horizon + * @param additionalTrustlines - Number of additional trustlines needed + * @returns Whether the account can establish the trustlines + * + * @example + * ```typescript + * const canEstablish = canEstablishTrustlines(accountData, 2); + * if (!canEstablish) { + * console.error('Account has reached maximum trustline limit'); + * } + * ``` + */ +export function canEstablishTrustlines( + accountData: Horizon.ServerApi.AccountRecord, + additionalTrustlines: number +): boolean { + const currentTrustlines = accountData.balances.filter( + (b) => b.asset_type !== 'native' + ).length; + + return currentTrustlines + additionalTrustlines <= MAX_TRUSTLINES_PER_ACCOUNT; +} + +/** + * Validates trustlines before asset issuance template deployment. + * + * @param accountId - The account that will issue the asset + * @param assets - Assets to be issued + * @param accountData - Account data from Horizon (optional) + * @returns Validation result with actionable error messages + * + * @example + * ```typescript + * const result = await validateAssetIssuanceDeployment( + * 'GABC...', + * [{ code: 'USD', issuer: 'GDEF...' }] + * ); + * if (!result.valid) { + * console.error(result.error); + * } + * ``` + */ +export async function validateAssetIssuanceDeployment( + accountId: string, + assets: Array<{ code: string; issuer: string }>, + accountData?: Horizon.ServerApi.AccountRecord +): Promise { + const trustlineResult = await validateTrustlines(accountId, assets, accountData); + + if (!trustlineResult.valid) { + return trustlineResult; + } + + // Check if account can establish additional trustlines if needed + if (accountData) { + const missingCount = trustlineResult.missingTrustlines?.length || 0; + if (missingCount > 0 && !canEstablishTrustlines(accountData, missingCount)) { + return { + valid: false, + error: `Account has reached maximum trustline limit (${MAX_TRUSTLINES_PER_ACCOUNT})`, + missingTrustlines: trustlineResult.missingTrustlines, + }; + } + } + + return { valid: true }; +} + +/** + * Formats trustline validation errors into user-friendly messages. + * + * @param result - Trustline validation result + * @returns Formatted error message + * + * @example + * ```typescript + * const result = await validateTrustlines(...); + * if (!result.valid) { + * console.error(formatTrustlineError(result)); + * } + * ``` + */ +export function formatTrustlineError(result: TrustlineValidationResult): string { + if (result.valid) { + return ''; + } + + let message = result.error || 'Trustline validation failed'; + + if (result.missingTrustlines && result.missingTrustlines.length > 0) { + message += '\n\nMissing trustlines:'; + result.missingTrustlines.forEach((missing) => { + message += `\n- ${missing.asset} (${missing.issuer}): ${missing.reason}`; + }); + + message += '\n\nTo fix this:'; + message += '\n1. Establish trustlines for the missing assets'; + message += '\n2. Ensure trustlines are authorized by the issuer'; + message += '\n3. Verify trustline limits are not maxed out'; + } + + return message; +}