diff --git a/.changeset/bigint-field-type-implementation.md b/.changeset/bigint-field-type-implementation.md new file mode 100644 index 00000000..4777fed8 --- /dev/null +++ b/.changeset/bigint-field-type-implementation.md @@ -0,0 +1,38 @@ +--- +'@openzeppelin/ui-builder-types': minor +'@openzeppelin/ui-builder-ui': minor +'@openzeppelin/ui-builder-renderer': minor +'@openzeppelin/ui-builder-utils': minor +'@openzeppelin/ui-builder-adapter-evm': minor +'@openzeppelin/ui-builder-adapter-stellar': minor +'@openzeppelin/ui-builder-app': patch +--- + +Add BigInt field type for safe handling of large integers beyond JavaScript Number precision + +**Breaking Behavior Changes:** + +- Large integer types (64-bit and above) now map to `bigint` field type instead of `number` + - **EVM**: `uint64`, `uint128`, `uint256`, `int64`, `int128`, `int256` + - **Stellar**: `U64`, `U128`, `U256`, `I64`, `I128`, `I256` +- BigInt values are stored and transmitted as strings to prevent precision loss + +**New Features:** + +- Added new `BigIntField` component with built-in integer validation +- Added `createBigIntTransform()` for proper string-based value handling +- BigInt field now available in UI Builder field type dropdown under "Numeric" category + +**Improvements:** + +- Fixes uint256 truncation issue (#194) +- Prevents precision loss for values exceeding `Number.MAX_SAFE_INTEGER` (2^53-1) +- Simplified field generation by removing redundant type-specific validation logic from adapters +- Component-based validation ensures consistency across all blockchain ecosystems + +**Technical Details:** + +- `BigIntField` uses string storage to handle arbitrary-precision integers +- Integer-only validation via regex (`/^-?\d+$/`) +- Compatible field types properly ordered with `bigint` as recommended type +- Transform functions ensure safe conversion between UI and blockchain formats diff --git a/packages/adapter-evm/src/mapping/__tests__/field-generator.test.ts b/packages/adapter-evm/src/mapping/__tests__/field-generator.test.ts new file mode 100644 index 00000000..be1437f7 --- /dev/null +++ b/packages/adapter-evm/src/mapping/__tests__/field-generator.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import type { FunctionParameter } from '@openzeppelin/ui-builder-types'; + +import { generateEvmDefaultField } from '../field-generator'; + +// Helper to create a mock function parameter +const createParam = (type: string, name: string, description?: string): FunctionParameter => ({ + name, + type, + displayName: name, + description, +}); + +describe('EVM Field Generator', () => { + describe('generateEvmDefaultField', () => { + it('should generate a number field for small integer types', () => { + const param = createParam('uint32', 'count'); + const field = generateEvmDefaultField(param); + + expect(field.type).toBe('number'); + expect(field.validation.required).toBe(true); + expect(field.validation.pattern).toBeUndefined(); + }); + + it('should generate a bigint field for uint128', () => { + const param = createParam('uint128', 'amount'); + const field = generateEvmDefaultField(param); + + expect(field.type).toBe('bigint'); + expect(field.validation.required).toBe(true); + // BigIntField component handles its own validation and UI guidance + }); + + it('should generate a bigint field for uint256', () => { + const param = createParam('uint256', 'value'); + const field = generateEvmDefaultField(param); + + expect(field.type).toBe('bigint'); + expect(field.validation.required).toBe(true); + // BigIntField component handles its own validation and UI guidance + }); + + it('should generate a bigint field for int128', () => { + const param = createParam('int128', 'delta'); + const field = generateEvmDefaultField(param); + + expect(field.type).toBe('bigint'); + expect(field.validation.required).toBe(true); + // BigIntField component handles its own validation and UI guidance + }); + + it('should generate a bigint field for int256', () => { + const param = createParam('int256', 'offset'); + const field = generateEvmDefaultField(param); + + expect(field.type).toBe('bigint'); + expect(field.validation.required).toBe(true); + // BigIntField component handles its own validation and UI guidance + }); + + it('should preserve parameter description in helper text', () => { + const param = createParam('uint256', 'tokenId', 'The unique identifier of the NFT'); + const field = generateEvmDefaultField(param); + + expect(field.type).toBe('bigint'); + expect(field.helperText).toBe('The unique identifier of the NFT'); + }); + + it('should generate a blockchain-address field for address type', () => { + const param = createParam('address', 'recipient'); + const field = generateEvmDefaultField(param); + + expect(field.type).toBe('blockchain-address'); + expect(field.validation.required).toBe(true); + expect(field.validation.pattern).toBeUndefined(); + }); + + it('should generate array field with proper element config for uint256[]', () => { + const param = createParam('uint256[]', 'amounts'); + const field = generateEvmDefaultField(param); + + expect(field.type).toBe('array'); + expect(field.elementFieldConfig).toBeDefined(); + expect(field.elementFieldConfig?.type).toBe('bigint'); + // Integer validation is handled by BigIntField component internally + }); + + it('should include proper field metadata', () => { + const param = createParam('uint256', 'value'); + const field = generateEvmDefaultField(param); + + expect(field.id).toBeDefined(); + expect(field.name).toBe('value'); + expect(field.label).toBe('Value'); + expect(field.placeholder).toContain('value'); + expect(field.width).toBe('full'); + }); + }); +}); diff --git a/packages/adapter-evm/src/mapping/__tests__/type-mapper.test.ts b/packages/adapter-evm/src/mapping/__tests__/type-mapper.test.ts new file mode 100644 index 00000000..2b0f1302 --- /dev/null +++ b/packages/adapter-evm/src/mapping/__tests__/type-mapper.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +import { getEvmCompatibleFieldTypes, mapEvmParamTypeToFieldType } from '../type-mapper'; + +describe('EVM Type Mapper', () => { + describe('mapEvmParamTypeToFieldType', () => { + it('should map small integer types to number field', () => { + expect(mapEvmParamTypeToFieldType('uint')).toBe('number'); + expect(mapEvmParamTypeToFieldType('uint8')).toBe('number'); + expect(mapEvmParamTypeToFieldType('uint16')).toBe('number'); + expect(mapEvmParamTypeToFieldType('uint32')).toBe('number'); + expect(mapEvmParamTypeToFieldType('int')).toBe('number'); + expect(mapEvmParamTypeToFieldType('int8')).toBe('number'); + expect(mapEvmParamTypeToFieldType('int16')).toBe('number'); + expect(mapEvmParamTypeToFieldType('int32')).toBe('number'); + }); + + it('should map 64-bit and larger integer types to bigint field to avoid precision loss', () => { + // These types can hold values larger than JavaScript's Number.MAX_SAFE_INTEGER (2^53-1) + expect(mapEvmParamTypeToFieldType('uint64')).toBe('bigint'); + expect(mapEvmParamTypeToFieldType('uint128')).toBe('bigint'); + expect(mapEvmParamTypeToFieldType('uint256')).toBe('bigint'); + expect(mapEvmParamTypeToFieldType('int64')).toBe('bigint'); + expect(mapEvmParamTypeToFieldType('int128')).toBe('bigint'); + expect(mapEvmParamTypeToFieldType('int256')).toBe('bigint'); + }); + + it('should map address to blockchain-address field', () => { + expect(mapEvmParamTypeToFieldType('address')).toBe('blockchain-address'); + }); + + it('should map bool to checkbox field', () => { + expect(mapEvmParamTypeToFieldType('bool')).toBe('checkbox'); + }); + + it('should map string to text field', () => { + expect(mapEvmParamTypeToFieldType('string')).toBe('text'); + }); + + it('should map bytes types to text/textarea fields', () => { + expect(mapEvmParamTypeToFieldType('bytes')).toBe('textarea'); + expect(mapEvmParamTypeToFieldType('bytes32')).toBe('text'); + }); + + it('should map array types correctly', () => { + expect(mapEvmParamTypeToFieldType('uint256[]')).toBe('array'); + expect(mapEvmParamTypeToFieldType('address[]')).toBe('array'); + expect(mapEvmParamTypeToFieldType('string[]')).toBe('array'); + }); + + it('should map tuple types to object', () => { + expect(mapEvmParamTypeToFieldType('tuple')).toBe('object'); + }); + + it('should map array of tuples to array-object', () => { + expect(mapEvmParamTypeToFieldType('tuple[]')).toBe('array-object'); + expect(mapEvmParamTypeToFieldType('tuple[5]')).toBe('array-object'); + }); + + it('should default to text for unknown types', () => { + expect(mapEvmParamTypeToFieldType('unknown')).toBe('text'); + expect(mapEvmParamTypeToFieldType('custom')).toBe('text'); + }); + }); + + describe('getEvmCompatibleFieldTypes', () => { + it('should return bigint as first compatible type for 64-bit and larger unsigned integers', () => { + // Verify uint64 + const compatibleTypesUint64 = getEvmCompatibleFieldTypes('uint64'); + expect(compatibleTypesUint64[0]).toBe('bigint'); // First (recommended) type + expect(compatibleTypesUint64).toContain('number'); + expect(compatibleTypesUint64).toContain('amount'); + expect(compatibleTypesUint64).toContain('text'); + + // Verify uint128 + const compatibleTypesUint128 = getEvmCompatibleFieldTypes('uint128'); + expect(compatibleTypesUint128[0]).toBe('bigint'); // First (recommended) type + expect(compatibleTypesUint128).toContain('number'); + expect(compatibleTypesUint128).toContain('amount'); + expect(compatibleTypesUint128).toContain('text'); + + // Verify uint256 + const compatibleTypesUint256 = getEvmCompatibleFieldTypes('uint256'); + expect(compatibleTypesUint256[0]).toBe('bigint'); // First (recommended) type + expect(compatibleTypesUint256).toContain('number'); + expect(compatibleTypesUint256).toContain('amount'); + expect(compatibleTypesUint256).toContain('text'); + }); + + it('should return bigint as first compatible type for 64-bit and larger signed integers', () => { + // Verify int64 + const compatibleTypesInt64 = getEvmCompatibleFieldTypes('int64'); + expect(compatibleTypesInt64[0]).toBe('bigint'); // First (recommended) type + expect(compatibleTypesInt64).toContain('number'); + expect(compatibleTypesInt64).toContain('text'); + + // Verify int128 + const compatibleTypesInt128 = getEvmCompatibleFieldTypes('int128'); + expect(compatibleTypesInt128[0]).toBe('bigint'); // First (recommended) type + expect(compatibleTypesInt128).toContain('number'); + expect(compatibleTypesInt128).toContain('text'); + + // Verify int256 + const compatibleTypesInt256 = getEvmCompatibleFieldTypes('int256'); + expect(compatibleTypesInt256[0]).toBe('bigint'); // First (recommended) type + expect(compatibleTypesInt256).toContain('number'); + expect(compatibleTypesInt256).toContain('text'); + }); + + it('should return number as first compatible type for small integers', () => { + // Small integers that fit within JavaScript Number precision + const compatibleTypes = getEvmCompatibleFieldTypes('uint32'); + expect(compatibleTypes[0]).toBe('number'); // First (recommended) type + expect(compatibleTypes).toContain('amount'); + expect(compatibleTypes).toContain('text'); + }); + + it('should return compatible field types for address', () => { + const compatibleTypes = getEvmCompatibleFieldTypes('address'); + expect(compatibleTypes[0]).toBe('blockchain-address'); // First (recommended) type + expect(compatibleTypes).toContain('text'); + }); + + it('should return compatible field types for bool', () => { + const compatibleTypes = getEvmCompatibleFieldTypes('bool'); + expect(compatibleTypes[0]).toBe('checkbox'); // First (recommended) type + expect(compatibleTypes).toContain('select'); + expect(compatibleTypes).toContain('radio'); + expect(compatibleTypes).toContain('text'); + }); + + it('should return compatible field types for arrays', () => { + const compatibleTypes = getEvmCompatibleFieldTypes('uint256[]'); + expect(compatibleTypes[0]).toBe('array'); // First (recommended) type + expect(compatibleTypes).toContain('textarea'); + expect(compatibleTypes).toContain('text'); + }); + }); +}); diff --git a/packages/adapter-evm/src/mapping/constants.ts b/packages/adapter-evm/src/mapping/constants.ts index e560b71c..971bec2e 100644 --- a/packages/adapter-evm/src/mapping/constants.ts +++ b/packages/adapter-evm/src/mapping/constants.ts @@ -2,6 +2,12 @@ import type { FieldType } from '@openzeppelin/ui-builder-types'; /** * EVM-specific type mapping to default form field types. + * + * Note: Large integer types (uint128, uint256, int128, int256) are mapped to 'bigint' + * instead of 'number' to avoid JavaScript's Number precision limitations. + * JavaScript's Number type can only safely represent integers up to 2^53 - 1, + * but these types can hold much larger values. The BigIntField component stores values + * as strings and the EVM adapter handles BigInt conversion automatically. */ export const EVM_TYPE_TO_FIELD_TYPE: Record = { address: 'blockchain-address', @@ -10,16 +16,16 @@ export const EVM_TYPE_TO_FIELD_TYPE: Record = { uint8: 'number', uint16: 'number', uint32: 'number', - uint64: 'number', - uint128: 'number', - uint256: 'number', + uint64: 'bigint', + uint128: 'bigint', + uint256: 'bigint', int: 'number', int8: 'number', int16: 'number', int32: 'number', - int64: 'number', - int128: 'number', - int256: 'number', + int64: 'bigint', + int128: 'bigint', + int256: 'bigint', bool: 'checkbox', bytes: 'textarea', bytes32: 'text', diff --git a/packages/adapter-evm/src/mapping/field-generator.ts b/packages/adapter-evm/src/mapping/field-generator.ts index 717eed9a..4bcaf3b8 100644 --- a/packages/adapter-evm/src/mapping/field-generator.ts +++ b/packages/adapter-evm/src/mapping/field-generator.ts @@ -26,10 +26,10 @@ function extractArrayElementType(parameterType: string): string | null { } /** - * Get default validation rules for a parameter type. - * Only includes serializable validation rules - no custom functions. + * Get default validation rules for a parameter. + * Field-specific validation is handled by the field components themselves. */ -function getDefaultValidationForType(): FieldValidation { +function getDefaultValidation(): FieldValidation { return { required: true }; } @@ -40,6 +40,7 @@ export function generateEvmDefaultField( parameter: FunctionParameter ): FormFieldType { const fieldType = mapEvmParamTypeToFieldType(parameter.type) as T; + const baseField: FormFieldType = { id: `field-${Math.random().toString(36).substring(2, 9)}`, name: parameter.name || parameter.type, // Use type if name missing @@ -48,7 +49,7 @@ export function generateEvmDefaultField( placeholder: `Enter ${parameter.displayName || parameter.name || parameter.type}`, helperText: parameter.description || '', defaultValue: getDefaultValueForType(fieldType) as FieldValue, - validation: getDefaultValidationForType(), + validation: getDefaultValidation(), width: 'full', }; @@ -64,7 +65,7 @@ export function generateEvmDefaultField( elementType: elementFieldType, elementFieldConfig: { type: elementFieldType, - validation: getDefaultValidationForType(), + validation: getDefaultValidation(), placeholder: `Enter ${elementType}`, }, }; diff --git a/packages/adapter-evm/src/mapping/type-mapper.ts b/packages/adapter-evm/src/mapping/type-mapper.ts index 0d20c4d4..438ea87c 100644 --- a/packages/adapter-evm/src/mapping/type-mapper.ts +++ b/packages/adapter-evm/src/mapping/type-mapper.ts @@ -60,16 +60,16 @@ export function getEvmCompatibleFieldTypes(parameterType: string): FieldType[] { uint8: ['number', 'amount', 'text'], uint16: ['number', 'amount', 'text'], uint32: ['number', 'amount', 'text'], - uint64: ['number', 'amount', 'text'], - uint128: ['number', 'amount', 'text'], - uint256: ['number', 'amount', 'text'], + uint64: ['bigint', 'number', 'amount', 'text'], + uint128: ['bigint', 'number', 'amount', 'text'], + uint256: ['bigint', 'number', 'amount', 'text'], int: ['number', 'text'], int8: ['number', 'text'], int16: ['number', 'text'], int32: ['number', 'text'], - int64: ['number', 'text'], - int128: ['number', 'text'], - int256: ['number', 'text'], + int64: ['bigint', 'number', 'text'], + int128: ['bigint', 'number', 'text'], + int256: ['bigint', 'number', 'text'], bool: ['checkbox', 'select', 'radio', 'text'], string: ['text', 'textarea', 'email', 'password'], bytes: ['textarea', 'text'], diff --git a/packages/adapter-stellar/src/mapping/constants.ts b/packages/adapter-stellar/src/mapping/constants.ts index 6264769d..708fffcd 100644 --- a/packages/adapter-stellar/src/mapping/constants.ts +++ b/packages/adapter-stellar/src/mapping/constants.ts @@ -3,6 +3,12 @@ import type { FieldType } from '@openzeppelin/ui-builder-types'; /** * Stellar/Soroban-specific type mapping to default form field types. * Based on Soroban type system: https://developers.stellar.org/docs/learn/fundamentals/contract-development/types + * + * Note: Large integer types (U64, U128, U256, I64, I128, I256) are mapped to 'bigint' + * instead of 'number' to avoid JavaScript's Number precision limitations. + * JavaScript's Number type can only safely represent integers up to 2^53 - 1, + * but these types can hold much larger values. The BigIntField component stores values + * as strings and the Stellar adapter handles conversion automatically. */ export const STELLAR_TYPE_TO_FIELD_TYPE: Record = { // Address types @@ -15,15 +21,15 @@ export const STELLAR_TYPE_TO_FIELD_TYPE: Record = { // Numeric types - unsigned integers U32: 'number', - U64: 'number', - U128: 'number', - U256: 'number', + U64: 'bigint', + U128: 'bigint', + U256: 'bigint', // Numeric types - signed integers I32: 'number', - I64: 'number', - I128: 'number', - I256: 'number', + I64: 'bigint', + I128: 'bigint', + I256: 'bigint', // Boolean type Bool: 'checkbox', diff --git a/packages/adapter-stellar/src/mapping/type-mapper.ts b/packages/adapter-stellar/src/mapping/type-mapper.ts index 5cf7d909..56999a91 100644 --- a/packages/adapter-stellar/src/mapping/type-mapper.ts +++ b/packages/adapter-stellar/src/mapping/type-mapper.ts @@ -133,15 +133,15 @@ export function getStellarCompatibleFieldTypes(parameterType: string): FieldType // Unsigned integers U32: ['number', 'amount', 'text'], - U64: ['number', 'amount', 'text'], - U128: ['number', 'amount', 'text'], - U256: ['number', 'amount', 'text'], + U64: ['bigint', 'number', 'amount', 'text'], + U128: ['bigint', 'number', 'amount', 'text'], + U256: ['bigint', 'number', 'amount', 'text'], // Signed integers I32: ['number', 'amount', 'text'], - I64: ['number', 'amount', 'text'], - I128: ['number', 'amount', 'text'], - I256: ['number', 'amount', 'text'], + I64: ['bigint', 'number', 'text'], + I128: ['bigint', 'number', 'text'], + I256: ['bigint', 'number', 'text'], // Boolean Bool: ['checkbox', 'select', 'radio', 'text'], diff --git a/packages/adapter-stellar/test/mapping/field-generator.test.ts b/packages/adapter-stellar/test/mapping/field-generator.test.ts index ce37f75c..35569015 100644 --- a/packages/adapter-stellar/test/mapping/field-generator.test.ts +++ b/packages/adapter-stellar/test/mapping/field-generator.test.ts @@ -59,7 +59,7 @@ describe('generateStellarDefaultField', () => { expect(result).toMatchObject({ name: 'amount', label: 'Amount', - type: 'number', + type: 'bigint', placeholder: 'Enter Amount', helperText: 'Token amount', width: 'full', diff --git a/packages/adapter-stellar/test/mapping/type-mapper.test.ts b/packages/adapter-stellar/test/mapping/type-mapper.test.ts index d8ef111b..7b2d4846 100644 --- a/packages/adapter-stellar/test/mapping/type-mapper.test.ts +++ b/packages/adapter-stellar/test/mapping/type-mapper.test.ts @@ -28,32 +28,32 @@ describe('mapStellarParameterTypeToFieldType', () => { expect(mapStellarParameterTypeToFieldType('U32')).toBe('number'); }); - it('should map U64 to number', () => { - expect(mapStellarParameterTypeToFieldType('U64')).toBe('number'); + it('should map U64 to bigint', () => { + expect(mapStellarParameterTypeToFieldType('U64')).toBe('bigint'); }); - it('should map U128 to number', () => { - expect(mapStellarParameterTypeToFieldType('U128')).toBe('number'); + it('should map U128 to bigint', () => { + expect(mapStellarParameterTypeToFieldType('U128')).toBe('bigint'); }); - it('should map U256 to number', () => { - expect(mapStellarParameterTypeToFieldType('U256')).toBe('number'); + it('should map U256 to bigint', () => { + expect(mapStellarParameterTypeToFieldType('U256')).toBe('bigint'); }); it('should map I32 to number', () => { expect(mapStellarParameterTypeToFieldType('I32')).toBe('number'); }); - it('should map I64 to number', () => { - expect(mapStellarParameterTypeToFieldType('I64')).toBe('number'); + it('should map I64 to bigint', () => { + expect(mapStellarParameterTypeToFieldType('I64')).toBe('bigint'); }); - it('should map I128 to number', () => { - expect(mapStellarParameterTypeToFieldType('I128')).toBe('number'); + it('should map I128 to bigint', () => { + expect(mapStellarParameterTypeToFieldType('I128')).toBe('bigint'); }); - it('should map I256 to number', () => { - expect(mapStellarParameterTypeToFieldType('I256')).toBe('number'); + it('should map I256 to bigint', () => { + expect(mapStellarParameterTypeToFieldType('I256')).toBe('bigint'); }); it('should map Bytes to bytes', () => { @@ -123,10 +123,10 @@ describe('getStellarCompatibleFieldTypes', () => { expect(result).toContain('text'); }); - it('should return compatible types for numeric types', () => { - const numericTypes = ['U32', 'U64', 'U128', 'U256', 'I32', 'I64', 'I128', 'I256']; + it('should return compatible types for small numeric types', () => { + const smallNumericTypes = ['U32', 'I32']; - numericTypes.forEach((type) => { + smallNumericTypes.forEach((type) => { const result = getStellarCompatibleFieldTypes(type); expect(result).toContain('number'); expect(result).toContain('amount'); @@ -134,6 +134,17 @@ describe('getStellarCompatibleFieldTypes', () => { }); }); + it('should return compatible types for large numeric types (64-bit+)', () => { + const largeNumericTypes = ['U64', 'U128', 'U256', 'I64', 'I128', 'I256']; + + largeNumericTypes.forEach((type) => { + const result = getStellarCompatibleFieldTypes(type); + expect(result[0]).toBe('bigint'); // First (recommended) type + expect(result).toContain('number'); + expect(result).toContain('text'); + }); + }); + it('should return compatible types for Bool', () => { const result = getStellarCompatibleFieldTypes('Bool'); expect(result).toContain('checkbox'); diff --git a/packages/builder/src/components/UIBuilder/StepFormCustomization/TypeConversionWarning.tsx b/packages/builder/src/components/UIBuilder/StepFormCustomization/TypeConversionWarning.tsx index 7ce33f26..37120772 100644 --- a/packages/builder/src/components/UIBuilder/StepFormCustomization/TypeConversionWarning.tsx +++ b/packages/builder/src/components/UIBuilder/StepFormCustomization/TypeConversionWarning.tsx @@ -30,7 +30,7 @@ export function TypeConversionWarning({ if ( (originalParameterType?.includes('uint') || originalParameterType?.includes('int')) && - !['number', 'amount'].includes(selectedType) + !['number', 'amount', 'bigint'].includes(selectedType) ) { return 'Converting from a numeric type may cause data validation issues during form submission.'; } diff --git a/packages/builder/src/components/UIBuilder/StepFormCustomization/utils/fieldTypeUtils.ts b/packages/builder/src/components/UIBuilder/StepFormCustomization/utils/fieldTypeUtils.ts index e9969423..08e5619a 100644 --- a/packages/builder/src/components/UIBuilder/StepFormCustomization/utils/fieldTypeUtils.ts +++ b/packages/builder/src/components/UIBuilder/StepFormCustomization/utils/fieldTypeUtils.ts @@ -31,6 +31,7 @@ export function getFieldTypeLabel(type: FieldType): string { email: 'Email Input', password: 'Password Input', number: 'Number Input', + bigint: 'Big Integer', amount: 'Token Amount', select: 'Dropdown Select', radio: 'Radio Buttons', @@ -60,6 +61,7 @@ export function getFieldTypeOption(type: FieldType, disabled = false): FieldType const DEFAULT_FIELD_TYPES: FieldType[] = [ 'text', 'number', + 'bigint', 'checkbox', 'radio', 'select', @@ -105,7 +107,7 @@ export function getFieldTypeGroups( // Define field type categories const fieldTypeCategories: Record = { text: ['text', 'textarea', 'bytes', 'email', 'password'], - numeric: ['number', 'amount'], + numeric: ['number', 'bigint', 'amount'], selection: ['select', 'radio', 'checkbox', 'enum'], blockchain: ['blockchain-address'], }; diff --git a/packages/builder/src/core/factories/__tests__/EVMAdapterIntegration.test.ts b/packages/builder/src/core/factories/__tests__/EVMAdapterIntegration.test.ts index 661aaa4a..faffac26 100644 --- a/packages/builder/src/core/factories/__tests__/EVMAdapterIntegration.test.ts +++ b/packages/builder/src/core/factories/__tests__/EVMAdapterIntegration.test.ts @@ -218,10 +218,10 @@ describe('EVM Adapter Integration Tests', () => { expect(recipientField?.type).toBe('blockchain-address'); expect(recipientField?.transforms).toBeDefined(); - // Check amount field (number type) - parameter name has underscore in the mock + // Check amount field (bigint type for uint256) - parameter name has underscore in the mock const amountField = formSchema.fields.find((f) => f.name === '_value'); expect(amountField).toBeDefined(); - expect(amountField?.type).toBe('number'); + expect(amountField?.type).toBe('bigint'); expect(amountField?.transforms).toBeDefined(); // Test transforms for address field @@ -238,14 +238,15 @@ describe('EVM Adapter Integration Tests', () => { expect(recipientField.transforms.output('invalid-address')).toBe(''); } - // Test transforms for number field + // Test transforms for bigint field (stores as string to avoid precision loss) if (amountField?.transforms?.input && amountField?.transforms?.output) { - // Input transform (blockchain -> UI) + // Input transform (blockchain -> UI) - converts to string expect(amountField.transforms.input(1000)).toBe('1000'); + expect(amountField.transforms.input('1000')).toBe('1000'); - // Output transform (UI -> blockchain) - expect(amountField.transforms.output('1000')).toBe(1000); - expect(amountField.transforms.output('not-a-number')).toBe(0); + // Output transform (UI -> blockchain) - keeps as string + expect(amountField.transforms.output('1000')).toBe('1000'); + expect(amountField.transforms.output('not-a-number')).toBe(''); } }); @@ -269,10 +270,10 @@ describe('EVM Adapter Integration Tests', () => { expect(spenderField).toBeDefined(); expect(spenderField?.type).toBe('blockchain-address'); - // Check amount field (number type) - parameter name has underscore in the mock + // Check amount field (bigint type for uint256) - parameter name has underscore in the mock const amountField = formSchema.fields.find((f) => f.name === '_value'); expect(amountField).toBeDefined(); - expect(amountField?.type).toBe('number'); + expect(amountField?.type).toBe('bigint'); }); }); @@ -368,19 +369,22 @@ describe('EVM Adapter Integration Tests', () => { expect(uint8Field.transforms.output('-1')).toBe(-1); // No range validation in transform } - // Test uint256 field + // Test uint256 field - now uses bigint to handle large values safely const uint256Function = intFixture.functions.find((f) => f.id === 'function-uint256'); expect(uint256Function).toBeDefined(); const uint256Schema = factory.generateFormSchema(adapter, intFixture, 'function-uint256'); expect(uint256Schema.fields).toHaveLength(1); const uint256Field = uint256Schema.fields[0]; - expect(uint256Field.type).toBe('number'); + expect(uint256Field.type).toBe('bigint'); - // Transform validation + // Transform validation - bigint stores as string to avoid precision loss if (uint256Field.transforms?.output) { - // Should handle large numbers (up to JS number limit) - expect(uint256Field.transforms.output('1000000000')).toBe(1000000000); + // Should handle large numbers as strings + expect(uint256Field.transforms.output('1000000000')).toBe('1000000000'); + expect(uint256Field.transforms.output('340282366920938463463374607431768211455')).toBe( + '340282366920938463463374607431768211455' + ); } }); }); diff --git a/packages/renderer/src/components/fieldRegistry.ts b/packages/renderer/src/components/fieldRegistry.ts index 83c48c0f..76ed7270 100644 --- a/packages/renderer/src/components/fieldRegistry.ts +++ b/packages/renderer/src/components/fieldRegistry.ts @@ -8,6 +8,7 @@ import { ArrayField, ArrayObjectField, BaseFieldProps, + BigIntField, BooleanField, BytesField, CodeEditorField, @@ -35,6 +36,7 @@ export const fieldComponents: Record< > = { text: TextField, number: NumberField, + bigint: BigIntField, 'blockchain-address': AddressField, checkbox: BooleanField, radio: RadioField, diff --git a/packages/renderer/src/utils/formUtils.ts b/packages/renderer/src/utils/formUtils.ts index e8955bd4..9551cc1b 100644 --- a/packages/renderer/src/utils/formUtils.ts +++ b/packages/renderer/src/utils/formUtils.ts @@ -95,6 +95,29 @@ export function createNumberTransform(): FieldTransforms { }; } +/** + * Creates a transform for bigint fields (large integers beyond JS Number precision) + * + * @returns Transform functions for bigint fields + */ +export function createBigIntTransform(): FieldTransforms { + return { + input: (value: unknown): string => { + if (value === undefined || value === null) return ''; + return String(value); + }, + output: (value: unknown): string => { + // Keep as string to avoid precision loss + const str = String(value || ''); + // Return empty string if not a valid integer format + if (str && !/^-?\d+$/.test(str)) { + return ''; + } + return str; + }, + }; +} + /** * Creates a transform for boolean fields * @@ -190,6 +213,8 @@ export function createTransformForFieldType( case 'number': case 'amount': return createNumberTransform() as FieldTransforms; + case 'bigint': + return createBigIntTransform() as FieldTransforms; case 'checkbox': return createBooleanTransform() as FieldTransforms; case 'text': diff --git a/packages/types/src/forms/fields.ts b/packages/types/src/forms/fields.ts index 4ddfebd5..760baa46 100644 --- a/packages/types/src/forms/fields.ts +++ b/packages/types/src/forms/fields.ts @@ -14,6 +14,7 @@ export type FormValues = Record; export type FieldType = | 'text' | 'number' + | 'bigint' // Large integer field for values beyond JavaScript Number precision | 'checkbox' | 'radio' | 'select' @@ -45,6 +46,7 @@ export type FieldValue = T extends | 'bytes' | 'code-editor' | 'blockchain-address' + | 'bigint' // BigInt field stores value as string ? string : T extends 'number' | 'amount' ? number diff --git a/packages/ui/src/components/fields/BigIntField.tsx b/packages/ui/src/components/fields/BigIntField.tsx new file mode 100644 index 00000000..4ce03311 --- /dev/null +++ b/packages/ui/src/components/fields/BigIntField.tsx @@ -0,0 +1,252 @@ +import React from 'react'; +import { Controller, FieldValues } from 'react-hook-form'; + +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; +import { BaseFieldProps } from './BaseField'; +import { + ErrorMessage, + getAccessibilityProps, + getValidationStateClasses, + handleEscapeKey, + INTEGER_HTML_PATTERN, + INTEGER_INPUT_PATTERN, + INTEGER_PATTERN, + validateField, +} from './utils'; + +/** + * BigIntField component properties + */ +export interface BigIntFieldProps + extends BaseFieldProps { + /** + * Custom validation function for big integer string values + */ + validateBigInt?: (value: string) => boolean | string; +} + +/** + * Big integer input field component for large numbers beyond JavaScript's Number precision. + * + * This component is designed for blockchain integer types like uint128, uint256, int128, int256 + * that can hold values larger than JavaScript's Number.MAX_SAFE_INTEGER (2^53 - 1). + * + * Architecture flow: + * 1. Form schemas are generated from contract functions using adapters + * 2. TransactionForm renders the overall form structure with React Hook Form + * 3. DynamicFormField selects BigIntField for large integer types + * 4. This component handles string-based integer input with validation + * 5. Adapters convert the string to BigInt when submitting transactions + * + * The component includes: + * - Integer-only validation (no decimals allowed) + * - String-based storage to avoid JavaScript Number precision issues + * - Integration with React Hook Form + * - Automatic error handling and reporting + * - Full accessibility support with ARIA attributes + * - Keyboard navigation + */ +export function BigIntField({ + id, + label, + placeholder, + helperText, + control, + name, + width = 'full', + validation, + validateBigInt, + readOnly, +}: BigIntFieldProps): React.ReactElement { + const isRequired = !!validation?.required; + const errorId = `${id}-error`; + const descriptionId = `${id}-description`; + + // Ensure validation includes integer-only pattern + // This will be handled by validateField utility, avoiding duplication + const enhancedValidation = { + ...validation, + pattern: validation?.pattern || INTEGER_PATTERN, + }; + + // Validation function for big integer strings + const validateBigIntValue = (value: string): string | true => { + // Run standard validation with integer pattern (includes pattern validation) + const standardValidation = validateField(value, enhancedValidation); + if (standardValidation !== true) return standardValidation as string; + + // Run custom validator if provided + if (validateBigInt) { + const customValidation = validateBigInt(value); + if (customValidation !== true && typeof customValidation === 'string') { + return customValidation; + } + } + + return true; + }; + + return ( +
+ {label && ( + + )} + + { + // Skip validation if empty and check required separately + if (value === undefined || value === null || value === '') { + return validation?.required ? 'This field is required' : true; + } + + // Handle incomplete inputs + if (value === '-') { + return 'Please enter a complete number'; + } + + // For string values, validate as big integer + if (typeof value === 'string') { + return validateBigIntValue(value); + } + + // Convert numbers to strings (in case value is accidentally a number) + if (typeof value === 'number') { + return validateBigIntValue(value.toString()); + } + + return true; + }, + }} + disabled={readOnly} + render={({ field, fieldState: { error, isTouched } }) => { + const hasError = !!error; + const shouldShowError = hasError && isTouched; + const validationClasses = getValidationStateClasses(error, isTouched); + + // Handle input change + const handleInputChange = (e: React.ChangeEvent): void => { + const rawValue = e.target.value; + + // Allow empty string + if (rawValue === '') { + field.onChange(''); + return; + } + + // Allow standalone minus sign for negative numbers + if (rawValue === '-') { + field.onChange(rawValue); + return; + } + + // Check if the input is a valid integer format + // Allow only digits and optional leading minus sign (using input pattern) + if (INTEGER_INPUT_PATTERN.test(rawValue)) { + field.onChange(rawValue); + } + // If invalid format, don't update (prevents non-numeric input) + }; + + // Handle keyboard events + const handleKeyDown = (e: React.KeyboardEvent): void => { + // Allow only numeric characters, backspace, delete, tab, escape, enter, arrows, hyphen + const allowedKeys = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'Backspace', + 'Delete', + 'Tab', + 'Escape', + 'Enter', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + '-', + ]; + + // Allow Ctrl/Cmd+A, C, V, X, Z for common editing operations + if ( + (e.ctrlKey || e.metaKey) && + ['a', 'c', 'v', 'x', 'z'].includes(e.key.toLowerCase()) + ) { + return; + } + + if (!allowedKeys.includes(e.key) && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + } + + // Handle escape key to clear input + if (e.key === 'Escape') { + handleEscapeKey(field.onChange, field.value)(e); + return; + } + }; + + // Get accessibility attributes + const accessibilityProps = getAccessibilityProps({ + id, + hasError, + isRequired, + hasHelperText: !!helperText, + }); + + return ( + <> + + + {/* Display helper text */} + {helperText && ( +
+ {helperText} +
+ )} + + {/* Display error message */} + + + ); + }} + /> +
+ ); +} + +// Set displayName manually for better debugging +BigIntField.displayName = 'BigIntField'; diff --git a/packages/ui/src/components/fields/index.ts b/packages/ui/src/components/fields/index.ts index 2f5f9133..ff2b0da3 100644 --- a/packages/ui/src/components/fields/index.ts +++ b/packages/ui/src/components/fields/index.ts @@ -7,6 +7,7 @@ export * from './AmountField'; export * from './ArrayField'; export * from './ArrayObjectField'; export * from './BaseField'; +export * from './BigIntField'; export * from './BooleanField'; export * from './BytesField'; export * from './CodeEditorField'; diff --git a/packages/ui/src/components/fields/utils/index.ts b/packages/ui/src/components/fields/utils/index.ts index e7219bf6..91b829c6 100644 --- a/packages/ui/src/components/fields/utils/index.ts +++ b/packages/ui/src/components/fields/utils/index.ts @@ -5,5 +5,6 @@ // Re-export all utilities from their respective files export * from './accessibility'; export { ErrorMessage } from './ErrorMessage'; +export * from './integerValidation'; export * from './layout'; export * from './validation'; diff --git a/packages/ui/src/components/fields/utils/integerValidation.ts b/packages/ui/src/components/fields/utils/integerValidation.ts new file mode 100644 index 00000000..cd074004 --- /dev/null +++ b/packages/ui/src/components/fields/utils/integerValidation.ts @@ -0,0 +1,27 @@ +/** + * Shared integer validation patterns for BigInt fields and validation utilities. + * + * These patterns ensure consistent validation across the application. + */ + +/** + * Integer validation pattern - requires at least one digit + * Used for validation to ensure complete integers are entered + * Matches: -123, 0, 456 + * Does not match: -, abc, 12.3 + */ +export const INTEGER_PATTERN = /^-?\d+$/; + +/** + * Integer input pattern - allows partial input during typing + * Used during input to allow users to type minus sign first + * Matches: -, -1, 123, (empty string) + * Does not match: abc, 12.3 + */ +export const INTEGER_INPUT_PATTERN = /^-?\d*$/; + +/** + * HTML pattern attribute for integer inputs + * Must use [0-9] instead of \d for HTML5 pattern attribute + */ +export const INTEGER_HTML_PATTERN = '-?[0-9]*'; diff --git a/packages/ui/src/components/fields/utils/validation.ts b/packages/ui/src/components/fields/utils/validation.ts index 358fd57e..65fb23b3 100644 --- a/packages/ui/src/components/fields/utils/validation.ts +++ b/packages/ui/src/components/fields/utils/validation.ts @@ -5,6 +5,8 @@ import { FieldError } from 'react-hook-form'; import { FieldValidation, MapEntry } from '@openzeppelin/ui-builder-types'; +import { INTEGER_PATTERN } from './integerValidation'; + /** * Determines if a field has an error */ @@ -146,6 +148,10 @@ export function validateField(value: unknown, validation?: FieldValidation): str : validation.pattern; if (!pattern.test(value)) { + // Provide more specific error message for integer validation + if (!INTEGER_PATTERN.test(value) && /\d/.test(value)) { + return 'Value must be a valid integer (no decimals)'; + } return 'Value does not match the required pattern'; } } diff --git a/packages/utils/src/fieldDefaults.ts b/packages/utils/src/fieldDefaults.ts index 65bde180..e14b20d8 100644 --- a/packages/utils/src/fieldDefaults.ts +++ b/packages/utils/src/fieldDefaults.ts @@ -24,6 +24,7 @@ export function getDefaultValueForType(fieldType: T): Field case 'map': return [] as FieldValue; // Empty array of key-value pairs case 'blockchain-address': + case 'bigint': case 'text': case 'textarea': case 'bytes':