Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hot-path-regexes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ox": patch
---

Improved validation performance in `Hex.assert`, `Value`, `TypedData`, `Siwe.isUri`, ABI overload resolution, and the `ox/zod` hex, address, quantity, and Solidity type schemas by reusing module-level regular expressions instead of recreating them on every call.
4 changes: 3 additions & 1 deletion src/core/Hex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import * as Json from './Json.js'
/** Root type for a Hex string. */
export type Hex = `0x${string}`

const hexRegex = /^0x[0-9a-fA-F]*$/

/**
* Asserts if the given value is {@link ox#Hex.Hex}.
*
Expand All @@ -38,7 +40,7 @@ export function assert(
const { even = false, strict = false } = options
if (typeof value !== 'string') throw new InvalidHexTypeError(value)
if (strict) {
if (!/^0x[0-9a-fA-F]*$/.test(value)) throw new InvalidHexValueError(value)
if (!hexRegex.test(value)) throw new InvalidHexValueError(value)
}
if (!value.startsWith('0x')) throw new InvalidHexValueError(value)
if (even && value.length % 2 !== 0) throw new InvalidLengthError(value as Hex)
Expand Down
32 changes: 21 additions & 11 deletions src/core/Siwe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ export const suffixRegex =
const siweDateTimeRegex =
/^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt]([01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/

// URI validation (see `isUri`).
const uriIllegalCharacterRegex = /[^a-z0-9:/?#[\]@!$&'()*+,;=.\-_~%]/i
const uriInvalidEscapeRegex = /%[^0-9a-f]/i
const uriIncompleteEscapeRegex = /%[0-9a-f](:?[^0-9a-f]|$)/i
const uriSchemeRegex = /^[a-z][a-z0-9+\-.]*$/
const uriPartsRegex =
/(?:([^:/?#]+):)?(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?/
const authorityHostRegex = /^(?:[a-z0-9._~!$&'()*+,;=:%-]*@)?(.*)$/i
const authorityIpLiteralRegex = /^\[([^\]]+)\](?::\d*)?$/
const authorityIpFutureRegex = /^v[0-9a-f]+\.[a-z0-9._~!$&'()*+,;=:-]+$/i
const authorityRegNameRegex = /^(?:[a-z0-9._~!$&'()*+,;=%-]*)(?::\d*)?$/i

function parseSiweDateTime(value: string): Date {
const match = value.match(siweDateTimeRegex)
if (!match) return new Date(Number.NaN)
Expand Down Expand Up @@ -315,11 +327,11 @@ export function generateNonce(): string {
// based on https://github.com/ogt/valid-url
export function isUri(value: string): false | string {
// check for illegal characters
if (/[^a-z0-9:/?#[\]@!$&'()*+,;=.\-_~%]/i.test(value)) return false
if (uriIllegalCharacterRegex.test(value)) return false

// check for hex escapes that aren't complete
if (/%[^0-9a-f]/i.test(value)) return false
if (/%[0-9a-f](:?[^0-9a-f]|$)/i.test(value)) return false
if (uriInvalidEscapeRegex.test(value)) return false
if (uriIncompleteEscapeRegex.test(value)) return false

// from RFC 3986
const splitted = splitUri(value)
Expand All @@ -342,7 +354,7 @@ export function isUri(value: string): false | string {
}

// scheme must begin with a letter, then consist of letters, digits, +, ., or -
if (!/^[a-z][a-z0-9+\-.]*$/.test(scheme.toLowerCase())) return false
if (!uriSchemeRegex.test(scheme.toLowerCase())) return false

let out = ''
// re-assemble the URL per section 5.3 in RFC 3986
Expand All @@ -358,12 +370,12 @@ export function isUri(value: string): false | string {
}

function isAuthority(value: string) {
const host = value.match(/^(?:[a-z0-9._~!$&'()*+,;=:%-]*@)?(.*)$/i)?.[1]
const host = value.match(authorityHostRegex)?.[1]
if (host === undefined) return false

const ipLiteral = host.match(/^\[([^\]]+)\](?::\d*)?$/)?.[1]
const ipLiteral = host.match(authorityIpLiteralRegex)?.[1]
if (ipLiteral) {
if (/^v[0-9a-f]+\.[a-z0-9._~!$&'()*+,;=:-]+$/i.test(ipLiteral)) return true
if (authorityIpFutureRegex.test(ipLiteral)) return true
try {
new URL(`http://[${ipLiteral}]`)
return true
Expand All @@ -372,13 +384,11 @@ function isAuthority(value: string) {
}
}

return /^(?:[a-z0-9._~!$&'()*+,;=%-]*)(?::\d*)?$/i.test(host)
return authorityRegNameRegex.test(host)
}

function splitUri(value: string) {
return value.match(
/(?:([^:/?#]+):)?(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?/,
)!
return value.match(uriPartsRegex)!
}

/**
Expand Down
12 changes: 8 additions & 4 deletions src/core/TypedData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,10 @@ export declare namespace encodeField {
| Errors.GlobalErrorType
}

const arraySuffixRegex = /(\[[0-9]*\])+$/
const structNameRegex = /^[A-Za-z0-9_$]+$/
const solidityTypeNameRegex = /^(u?int|bytes)[0-9]*$/

/** @internal */
export function findTypeDependencies(
value: {
Expand All @@ -1028,7 +1032,7 @@ export function findTypeDependencies(
results: Set<string> = new Set(),
): Set<string> {
const { primaryType: primaryType_, types } = value
const primaryType = primaryType_.replace(/(\[[0-9]*\])+$/, '')
const primaryType = primaryType_.replace(arraySuffixRegex, '')
if (results.has(primaryType) || types[primaryType] === undefined)
return results

Expand All @@ -1046,7 +1050,7 @@ export declare namespace findTypeDependencies {

/** @internal */
function validateField(type: string, types: TypedData) {
const baseType = type.replace(/(\[[0-9]*\])+$/, '')
const baseType = type.replace(arraySuffixRegex, '')
if (baseType === 'int' || baseType === 'uint')
throw new InvalidTypeError({ type })
// A field type must be a struct defined in `types` or an EIP-712 atomic or
Expand All @@ -1073,11 +1077,11 @@ function validateReference(type: string) {
// struct such as `intent` or `bytesInfo` is valid, while `uint7` or `bytes33`
// is rejected, since other implementations parse it as a Solidity type.
if (
!/^[A-Za-z0-9_$]+$/.test(type) ||
!structNameRegex.test(type) ||
type === 'address' ||
type === 'bool' ||
type === 'string' ||
/^(u?int|bytes)[0-9]*$/.test(type)
solidityTypeNameRegex.test(type)
)
throw new InvalidStructTypeError({ type })
}
9 changes: 6 additions & 3 deletions src/core/Value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export const exponents = {
ether: 18,
} as const

const decimalNumberRegex = /^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$/
const trailingZerosRegex = /(0+)$/

/**
* Formats a `bigint` Value to its string representation (divided by the given exponent).
*
Expand Down Expand Up @@ -39,7 +42,7 @@ export function format(value: bigint, decimals = 0) {
display.slice(0, display.length - decimals),
display.slice(display.length - decimals),
]
fraction = fraction.replace(/(0+)$/, '')
fraction = fraction.replace(trailingZerosRegex, '')
return `${negative ? '-' : ''}${integer || '0'}${
fraction ? `.${fraction}` : ''
}`
Expand Down Expand Up @@ -120,7 +123,7 @@ export function from(value: string, decimals = 0) {
// Require at least one digit overall. Rejects '', '.', '-', '-.' which
// the previous regex accepted (and which then either produced garbage or
// threw a raw `SyntaxError` from `BigInt('')`).
if (!/^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$/.test(value))
if (!decimalNumberRegex.test(value))
throw new InvalidDecimalNumberError({ value })

let [integer = '', fraction = '0'] = value.split('.')
Expand All @@ -130,7 +133,7 @@ export function from(value: string, decimals = 0) {
if (integer === '') integer = '0'

// trim trailing zeros.
fraction = fraction.replace(/(0+)$/, '')
fraction = fraction.replace(trailingZerosRegex, '')

// round off if the fraction is larger than the number of decimals.
if (decimals === 0) {
Expand Down
4 changes: 4 additions & 0 deletions src/core/_test/AbiItem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1583,6 +1583,10 @@ describe('fromAbi', () => {
{ arg: 1, abiParameter: { type: 'uint' }, expected: true },
{ arg: 1n, abiParameter: { type: 'uint' }, expected: true },
{ arg: 1n, abiParameter: { type: 'uint' }, expected: true },
{ arg: 2n ** 256n - 1n, abiParameter: { type: 'uint' }, expected: true },
{ arg: 2n ** 256n, abiParameter: { type: 'uint' }, expected: false },
{ arg: -(2n ** 255n), abiParameter: { type: 'int' }, expected: true },
{ arg: 2n ** 255n, abiParameter: { type: 'int' }, expected: false },
{ arg: 1, abiParameter: { type: 'int256' }, expected: true },
{ arg: 1, abiParameter: { type: 'uint256' }, expected: true },
{ arg: 1, abiParameter: { type: 'int69' }, expected: false },
Expand Down
8 changes: 3 additions & 5 deletions src/core/internal/abiItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,16 +602,14 @@ export function isArgOfType(
},
)

if (integerTypeRegex.test(abiParameterType)) {
const integerMatch = integerTypeRegex.exec(abiParameterType)
if (integerMatch) {
if (typeof arg !== 'bigint' && typeof arg !== 'number') return false
if (typeof arg === 'number' && !Number.isInteger(arg)) return false

// An overload whose integer range excludes the value does not match.
const signed = abiParameterType.startsWith('int')
const bits = Number.parseInt(
abiParameterType.replace(/^u?int/, '') || '256',
10,
)
const bits = Number.parseInt(integerMatch[1] ?? '256', 10)
const max = 2n ** (BigInt(bits) - (signed ? 1n : 0n)) - 1n
const min = signed ? -max - 1n : 0n
return arg >= min && arg <= max
Expand Down
7 changes: 3 additions & 4 deletions src/core/internal/frameNonce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as Errors from '../Errors.js'
import * as Hex from '../Hex.js'
import * as Quantity from './quantity.js'

const quantityRegex = /^0x(?:0|[1-9a-fA-F][0-9a-fA-F]*)$/

export function assert(keys: readonly bigint[], nonce?: bigint): void {
if (!Array.isArray(keys) || keys.length < 1 || keys.length > 16)
throw new InvalidError('Expected between 1 and 16 nonce keys.')
Expand All @@ -28,10 +30,7 @@ export function fromRpc(
): readonly bigint[] {
if (!Array.isArray(keys)) throw new InvalidError('Expected a nonce key list.')
const integer = (value: Hex.Hex): bigint => {
if (
typeof value !== 'string' ||
!/^0x(?:0|[1-9a-fA-F][0-9a-fA-F]*)$/.test(value)
)
if (typeof value !== 'string' || !quantityRegex.test(value))
throw new InvalidError('Expected a canonical RPC nonce quantity.')
return Hex.toBigInt(value)
}
Expand Down
6 changes: 3 additions & 3 deletions src/zod/Address.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/* eslint-disable jsdoc-js/require-jsdoc, jsdoc-js/require-description, jsdoc-js/require-example */
import * as z from 'zod/mini'

const addressRegex = /^0x[0-9a-fA-F]{40}$/

/** Address schema. */
export const Address = z
.templateLiteral([z.literal('0x'), z.string()])
.check(
z.refine((value) => /^0x[0-9a-fA-F]{40}$/.test(value), 'expected address'),
)
.check(z.refine((value) => addressRegex.test(value), 'expected address'))
7 changes: 3 additions & 4 deletions src/zod/FrameRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@ import * as z_Frame from './Frame.js'
import * as z_Hex from './Hex.js'
import * as z from 'zod/mini'

const quantityRegex = /^0x(0|[1-9a-f][0-9a-f]*)$/

const quantity = z_Hex.Hex.check(
z.refine(
(value) => /^0x(0|[1-9a-f][0-9a-f]*)$/.test(value),
'Invalid RPC quantity',
),
z.refine((value) => quantityRegex.test(value), 'Invalid RPC quantity'),
)

const rpc = z
Expand Down
4 changes: 3 additions & 1 deletion src/zod/Hex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
import * as core_Hex from '../core/Hex.js'
import * as z from 'zod/mini'

const hexRegex = /^0x[0-9a-fA-F]*$/

/** Hex string schema. */
export const Hex = z
.templateLiteral([z.literal('0x'), z.string()])
.check(z.refine((value) => /^0x[0-9a-fA-F]*$/.test(value), 'expected hex'))
.check(z.refine((value) => hexRegex.test(value), 'expected hex'))

/** Returns a byte-sized hex string schema. */
export function sizedHex(size: number) {
Expand Down
33 changes: 15 additions & 18 deletions src/zod/Solidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
import type * as abitype from 'abitype'
import * as z from 'zod/mini'

const identifierRegex = /^[a-zA-Z$_][a-zA-Z0-9$_]*$/
const bytesRegex = /^bytes([1-9]|1[0-9]|2[0-9]|3[0-2])?$/
const intRegex =
/^u?int(8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)?$/
const arrayWithoutTupleRegex =
/^(address|bool|function|string|bytes([1-9]|1[0-9]|2[0-9]|3[0-2])?|u?int(8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)?)(\[[0-9]{0,}\])+$/
const arrayWithTupleRegex = /^tuple(\[[0-9]{0,}\])+$/

export const Identifier = z
.string()
.check(
z.refine(
(value) => /^[a-zA-Z$_][a-zA-Z0-9$_]*$/.test(value),
'expected identifier',
),
z.refine((value) => identifierRegex.test(value), 'expected identifier'),
)

export const Address = z.literal('address')
Expand All @@ -17,8 +22,7 @@ export const Bytes = z
.string()
.check(
z.refine(
(value): value is abitype.SolidityBytes =>
/^bytes([1-9]|1[0-9]|2[0-9]|3[0-2])?$/.test(value),
(value): value is abitype.SolidityBytes => bytesRegex.test(value),
'expected Solidity bytes type',
),
)
Expand All @@ -29,10 +33,7 @@ export const Int = z
.string()
.check(
z.refine(
(value): value is abitype.SolidityInt =>
/^u?int(8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)?$/.test(
value,
),
(value): value is abitype.SolidityInt => intRegex.test(value),
'expected Solidity integer type',
),
)
Expand All @@ -41,9 +42,7 @@ export const ArrayWithoutTuple = z
.check(
z.refine(
(value): value is abitype.SolidityArrayWithoutTuple =>
/^(address|bool|function|string|bytes([1-9]|1[0-9]|2[0-9]|3[0-2])?|u?int(8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)?)(\[[0-9]{0,}\])+$/.test(
value,
),
arrayWithoutTupleRegex.test(value),
'expected Solidity array type',
),
)
Expand All @@ -52,7 +51,7 @@ export const ArrayWithTuple = z
.check(
z.refine(
(value): value is abitype.SolidityArrayWithTuple =>
/^tuple(\[[0-9]{0,}\])+$/.test(value),
arrayWithTupleRegex.test(value),
'expected Solidity tuple array type',
),
)
Expand All @@ -75,9 +74,7 @@ export function isType(type: string) {
type === 'bool' ||
type === 'function' ||
type === 'string' ||
/^bytes([1-9]|1[0-9]|2[0-9]|3[0-2])?$/.test(type) ||
/^u?int(8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)?$/.test(
type,
)
bytesRegex.test(type) ||
intRegex.test(type)
)
}
Loading