一个面向“Recipient 地址输入”流程的小型校验工具集。
- Core: 校验器(validator)是可组合的函数(可同步也可异步)。
- Hook:
useAddressValidation管理输入值以及最近一次校验结果。 - 步骤明细(steps): 通过 hook / 组合器运行时会带上
steps[](每个 validator 的详细结果),支持不阻塞的检查。 - 缓存:
- 自动校验缓存(
validateOnChange=true): 基于@tanstack/react-query(同scopeId + validatorKey + input命中缓存,避免重复校验)。 - 手动校验(
validate()): 每次都会重新执行 validator(忽略缓存新鲜度),但会写回缓存供后续 UI/逻辑复用。 - 同步校验缓存: 基于
lodash-es/memoize(例如createAddressFormatValidator内部对scopeId + input做内存缓存)。
- 自动校验缓存(
快速上手(先看这个)
最简单的用法:把 debouncedSetValue 绑到输入框(用户打字),等 isValidating=false 且 isValid=true 时,直接使用 address 作为最终可用地址。
import { useAddressValidation, type AddressValidationScope } from '@megaeth-labs/wallet-hooks'
export function RecipientInput({ scopes }: { scopes: AddressValidationScope[] }) {
const { value, debouncedSetValue, isValid, isValidating, error, address, validate } = useAddressValidation({
scopes,
validateOnChange: true,
})
const onSubmit = async () => {
// 常见场景:实时校验已完成,直接用 address 就够
if (isValidating || !isValid || !address) return
console.log(address)
// 如果你希望“点击提交时强制以当前输入做一次立即校验”
//(例如不禁用按钮、或者 validateOnChange=false),可以改成:
// const result = await validate()
// if (result.isValid) console.log(result.address)
}
// <Input value={value} onChangeText={debouncedSetValue} />
// <Button disabled={isValidating || !isValid} onPress={onSubmit} />
// {(!isValidating && error) ? <Text>{error}</Text> : null}
return null
}
想加更多规则(禁止转给自己、域名解析、合约地址提示、通讯录校验等),直接看后面的 Recipes 或 真实 UI 示例。
概念
scopes
scopes 表示当前允许的链/网络范围(多链校验:任一 scope 校验通过即可),每个 scope 可用两种形式:
Caip2ChainId(例如eip155:1、solana:mainnet)INetwork(来自@megaeth-labs/wallet-types)
AddressValidationResult
isValid: 最终是否可把输入当作“可用收款地址”。error: 最终阻塞错误信息(一般用于 UI 主错误提示)。errorCode: 错误码,便于国际化和细粒度错误处理(见ADDRESS_VALIDATION_ERROR_CODE)。仅当isValid=false时存在(并且一定存在)。address: 最终可用地址(例如域名 resolve 后得到的地址)。当isValid=true时一定存在。steps: 执行过的每一步 validator 步骤明细(用于 UI 展示/调试):name,level(error/info)input,isValid,error,errorCode?,address?
类型上它是一个可判别联合(discriminated union),推荐这样用:
const result = await validate()
if (result.isValid) {
result.address // string
} else {
result.errorCode // AddressValidationErrorCode
}
AddressValidationStepLevel
有两档:
error: 失败会阻塞最终结果(isValid=false)info: 失败不阻塞,记录在steps里供 UI 使用
UI 层面的样式区分(如黄色警告 vs 灰色提示)由调用方根据 validator name 或自定义属性决定。
ADDRESS_VALIDATION_ERROR_CODE
错误码常量,类型 AddressValidationErrorCode 由此推导:
import { ADDRESS_VALIDATION_ERROR_CODE } from '@megaeth-labs/wallet-hooks'
ADDRESS_VALIDATION_ERROR_CODE.EMPTY // 空输入
ADDRESS_VALIDATION_ERROR_CODE.SKIPPED // 非空输入但该校验器选择跳过(如:不是域名 / 未配置错误提示的列表校验未命中)
ADDRESS_VALIDATION_ERROR_CODE.INVALID_FORMAT // 地址格式错误
ADDRESS_VALIDATION_ERROR_CODE.NOT_IN_LIST // 不在列表中
ADDRESS_VALIDATION_ERROR_CODE.SELF_TRANSFER // 发送给自己
ADDRESS_VALIDATION_ERROR_CODE.IS_CONTRACT // 是合约地址
ADDRESS_VALIDATION_ERROR_CODE.NOT_CONTRACT // 不是合约地址
ADDRESS_VALIDATION_ERROR_CODE.DOMAIN_UNSUPPORTED // 域名不支持当前网络
ADDRESS_VALIDATION_ERROR_CODE.DOMAIN_RESOLVE_FAILED // 域名解析失败
ADDRESS_VALIDATION_ERROR_CODE.DOMAIN_INVALID_RESULT // 域名解析结果无效
ADDRESS_VALIDATION_ERROR_CODE.RPC_ERROR // RPC 调用错误
ADDRESS_VALIDATION_ERROR_CODE.UNKNOWN // 未知错误
AddressValidator 与 AddressValidatorEntry
AddressValidator:(input: string) => AddressValidationResult | Promise<AddressValidationResult>AddressValidatorEntry:{ name?, level?, validator }或者直接传一个AddressValidator
为什么要分成两种:
- 易用: 不需要写步骤明细/等级时,直接传函数就行。
- 可观测: 需要在 UI 展示某一步的提示时,用
ValidatorEntry传入name/level(便于生成steps[])。
示例:
// 纯 AddressValidator(自动命名 validator1,level 默认 error)
const v1 = createAddressFormatValidator(scope)
// 带元信息的 Entry(命名步骤 + 非阻塞 info)
const v2 = {
name: 'recent',
level: 'info',
validator: createInListValidator({ list: recentList }),
}
// 组合器接收 AddressValidatorEntry,并返回一个 AddressValidator
const validator = createAllOfValidator(v1, v2)
API
Hook
useAddressValidation(options)
useAddressValidation({
initialValue?: string
scopes?: AddressValidationScope[]
validator?: AddressValidator
validateOnChange?: boolean // 默认 true
debounceMs?: number // 防抖延迟,默认 300ms
settleMs?: number // UI 软提示延迟,默认 600ms
mode?: 'address' | 'addressOrDomain' // 默认 'address'
resolveDomain?: (domain: string, scopeId: Caip2ChainId) => Promise<string | null>
queryOptions?: {
validatorKey?: string // 区分不同 validator 的缓存 key
staleTime?: number // 缓存过期时间,默认 5 分钟
gcTime?: number // 缓存保留时间,默认 10 分钟
retry?: boolean | number // 是否重试,默认 false
refetchOnWindowFocus?: boolean // 默认 false
refetchOnMount?: boolean // 默认 false
}
})
validateOnChange:
true(默认):调用setValue时自动触发校验(内部自动防抖)false:只更新值,不触发校验。适合 RPC 校验很贵的场景,在 blur / 点击 Next 时手动调用validate()
queryOptions:
基于 @tanstack/react-query 的缓存配置。缓存 key 由 [scopesKey, validatorKey, input] 组成(其中 scopesKey 是 scopes 里所有 scopeId 去重+排序后用 | 拼接)。
- 当你传入自定义
validator时,queryOptions.validatorKey在类型层面是必填(避免不同页面/不同规则复用同一缓存导致串结果)。 - 运行时如果没提供
validatorKey,hook 也会自动生成一个每个 hook 实例唯一的 key 作为兜底(仍建议显式传入,便于跨步骤复用缓存)。 - 相同地址 + 相同 scopes + 相同 validatorKey → 命中缓存,不重复校验
- 适合多步骤流程(如 Send Step1 → Step2)复用校验结果
- 如果你的校验逻辑依赖外部可变状态(如通讯录/最近列表/当前账户/自定义 resolver 等),建议让
validatorKey覆盖这些“语义依赖”(否则自动校验可能复用旧结果)。外部状态变化后也可以通过validate()强制刷新。
mode:
mode会影响 hook 的默认校验策略(未传自定义validator时)以及looksLikeDomain/looksComplete这类 UI heuristic 的判定。- 如果你传了自定义
validator并且它支持域名输入,也建议把mode设为'addressOrDomain',让 UI heuristic 与实际输入形态一致。
重要:validator 必须用 useMemo 稳定引用
缓存 key 不包含 validator 函数引用。如果每次渲染都传入新的 validator 函数,缓存会复用但 validator 逻辑可能已变,导致结果不一致。
// ✅ 正确:用 useMemo 稳定 validator
const validator = useMemo(
() => createAllOfValidator(createAddressFormatValidator(scope), createNoSelfTransferValidator({ currentAddress })),
[scope, currentAddress]
)
const { isValid } = useAddressValidation({
validator,
queryOptions: { validatorKey: 'send-recipient' }
})
// ❌ 错误:每次渲染都创建新函数
const { isValid } = useAddressValidation({
validator: createAllOfValidator(createAddressFormatValidator(scope), createNoSelfTransferValidator({ currentAddress })), // 每次都是新引用
queryOptions: { validatorKey: 'send-recipient' }
})
返回:
{
value: string
isValid: boolean
isValidating: boolean
isSettled: boolean
looksComplete: boolean
looksLikeAddress: boolean
looksLikeDomain: boolean
error: string
errorCode?: AddressValidationErrorCode
address?: string
steps?: AddressValidationResult['steps']
setValue(value: string): void
debouncedSetValue(value: string): void
validate(): Promise<AddressValidationResult>
reset(): void
}
setValue vs debouncedSetValue:
setValue: 立即更新值并触发校验,适合粘贴、选择联系人等场景debouncedSetValue: 立即更新显示值,防抖触发校验,适合用户打字输入场景
validate():
- 立即以当前
value触发一次校验,并更新内部缓存/状态。 - 无论缓存是否命中/是否新鲜,都会重新执行 validator(适合提交时“强制校验”、或外部数据变化(如通讯录/最近列表)后刷新)。
isValidating:
- 包含 debounce 等待期 + 实际校验执行期,只要“校验结果还没 ready”就是
true - 推荐用
isValidating || !isValid来判断按钮是否可点
scopes 变化自动重新校验:
当 scopes 变化时(如用户切换网络/允许链集合变化),hook 会自动用当前 value 重新触发校验,无需手动 useEffect。
受控 / 非受控输入说明
useAddressValidation 内部维护自己的 value 状态,并且 validate() 永远以当前 value 为输入(没有 validate(rawValue) 这种 API)。
- 推荐(受控):把输入框的
value直接绑定到 hook 的value,并在输入变化时调用debouncedSetValue/setValue(示例见本文 “快速上手”)。 - 可用(非受控):你可以不把
value绑定到输入框,但必须在onChange/onBlur/ 提交等时机把“当前文本”显式传给setValue/debouncedSetValue,否则 hook 的校验结果不会跟随输入变化。 - 注意:如果存在“外部回填”(选择联系人、扫码粘贴、点历史地址等),非受控输入还需要你自己把回填值同步给 hook(通常直接
setValue(addr))。
与 react-hook-form 集成
如果你在表单里使用,建议明确单一数据源,避免“RHF 一份值 + hook 一份值”的双状态漂移:
- 推荐:不用 hook,直接用 validator 做表单校验(更贴合 RHF 的使用方式;支持 async):
const validator = useMemo(() => createAddressFormatValidator(scopeId), [scopeId])
<Controller
control={control}
name="recipient"
rules={{
validate: async (v) => {
const r = await validator(v)
return r.isValid || r.error || 'Invalid address'
},
}}
render={({ field: { value, onChange, onBlur }, fieldState: { error } }) => (
<>
<Input value={value} onChangeText={onChange} onBlur={onBlur} />
{error?.message ? <Text>{error.message}</Text> : null}
</>
)}
/>
- 如果你需要
isValidating/steps/address来做 UI:可以使用 hook,但要确保所有更新都同时写入 RHF 与 hook(例如在onChangeText里同时field.onChange(text)+debouncedSetValue(text);外部回填时同时field.onChange(addr)+setValue(addr)),并在提交时用validate()拿到最终address(例如域名解析后的地址)。
核心 validators(validators.ts)
createAddressFormatValidator(scope): 校验地址格式(按 chain scope)。createInListValidator({ list, scope?, base?, normalize?, error?, errorCode? }): 校验地址是否在某个列表中(推荐传scope/base做 scope-aware 比较;或传normalize自定义比较策略)。createOnChainValidator({ scope, request?, onRpcError?, validate }): 需要 RPC 的通用校验器构建器(支持显式传入request,并在自动获取 RPC 失败时触发onRpcError)。
辅助函数(core.ts)
用于自定义 validator:
import {
emptyAddressValidationResult,
skippedAddressValidationResult,
invalidAddressValidationResult,
validAddressValidationResult,
ADDRESS_VALIDATION_ERROR_CODE,
} from '@megaeth-labs/wallet-hooks'
emptyAddressValidationResult // { isValid: false, error: '', errorCode: 'EMPTY' }
skippedAddressValidationResult // { isValid: false, error: '', errorCode: 'SKIPPED' }
invalidAddressValidationResult('error message', ADDRESS_VALIDATION_ERROR_CODE.UNKNOWN) // { isValid: false, error, errorCode }
validAddressValidationResult(address) // { isValid: true, error: '', address }
可选 validators(enhance/validators.ts)
createDomainToAddressValidator({ scope, resolve? }): 通过 resolver 将 Web3 域名解析为可用地址并校验(不传resolve时使用内置resolveWeb3Domain,目前默认支持 ENS(.eth))。createEvmContractAddressValidator({ scope, mode?, request?, onRpcError? }): EVM 专用的合约地址判定(基于eth_getCode)。当 RPC 不可用/调用失败时返回RPC_ERROR(可配合level: 'info'作为非阻塞提示)。mode: 'notContract' | 'isContract'(默认notContract)
createNoSelfTransferValidator({ currentAddress, scope?, base?, normalize?, error? }): 校验是否发送给自己(推荐传scope/base做 scope-aware 比较;或传normalize自定义比较策略)。resolveWeb3Domain(domain, scopeId): 内置域名解析器(目前默认支持 ENS(.eth);其他 TLD 可由调用方自定义 resolver)。
组合器(combinators)
createAnyOfValidator(...entries: AddressValidatorEntry[]): OR 逻辑,命中第一个通过即返回,并返回已执行步骤的steps。createAllOfValidator(...entries: AddressValidatorEntry[]): AND 管道,按顺序执行;会把上一步产出的address传给下一步。info失败不阻塞。createBranchValidator({ when, onMatch: AddressValidatorEntry, onMismatch: AddressValidatorEntry }): 按条件走不同分支。
不使用 hook(非 React 场景)
如果你在 React 之外(例如 service / util / test)需要校验,直接调用组合后的 validator:
const validator = createAllOfValidator(
{ name: 'address', validator: createAddressFormatValidator(scope) },
{ name: 'recent', level: 'info', validator: createInListValidator({ list: recentList }) },
)
const result = await validator(input)
常见组合(Recipes)
1) 仅校验地址(快,同步)
const { setValue, validate } = useAddressValidation({ scopes: [scope] })
// 默认 validator 是 createAddressFormatValidator(scope) 的多链版(scopes 任一通过即可)
2) 地址 或 域名(域名必须能 resolve 成可用地址)
import { addressUtils } from '@megaeth-labs/wallet-utils'
const addressOrDomain = createBranchValidator({
when: (s) => addressUtils.isValidWeb3Domain(s),
// 你可以传入自定义 resolve(支持 SNS/UD 等),或者不传用内置 ENS(.eth) 解析:
// - createDomainToAddressValidator({ scope })
onMatch: { name: 'domain', validator: createDomainToAddressValidator({ scope, resolve }) }, // 或者 { scope }
onMismatch: { name: 'address', validator: createAddressFormatValidator(scope) },
})
3) 必须在通讯录中(阻塞)
const validator = createAllOfValidator(
{ name: 'address', validator: createAddressFormatValidator(scope) },
{ name: 'addressBook', validator: createInListValidator({ list: addressBook, error: 'Not in address book' }) },
)
4) 是否最近收款人(仅 info,不阻塞)
const validator = createAllOfValidator(
{ name: 'address', validator: createAddressFormatValidator(scope) },
{ name: 'recent', level: 'info', validator: createInListValidator({ list: recentList }) },
)
5) 合约地址检查(info,不阻塞)
const validator = createAllOfValidator(
{ name: 'address', validator: createAddressFormatValidator(scope) },
{ name: 'notContract', level: 'info', validator: createEvmContractAddressValidator({ scope, mode: 'notContract' }) },
)
6) 禁止发送给自己
const validator = createAllOfValidator(
{ name: 'address', validator: createAddressFormatValidator(scope) },
{ name: 'notSelf', validator: createNoSelfTransferValidator({ currentAddress: myAddress }) },
)
7) 获取所有步骤结果(UI 用)
const result = await validate()
result.isValid
result.address
result.errorCode // 用于国际化
result.steps?.forEach((s) => {
// s.level: error/info
// s.isValid: 该检查是否通过
// s.errorCode: 用于国际化
})
在真实 UI 中使用 useAddressValidation
展示“阻塞错误 + 非阻塞提示 + loading”
import {
createAllOfValidator,
createAddressFormatValidator,
createNoSelfTransferValidator,
createAddressBookValidatorEntry,
createRecentSendValidatorEntry,
useAddressValidation,
ADDRESS_VALIDATION_ERROR_CODE,
} from '@megaeth-labs/wallet-hooks'
import { useMemo } from 'react'
import { Text, View, ActivityIndicator } from 'react-native'
export function RecipientField({ scope, currentAddress }: { scope: string; currentAddress: string }) {
const validator = useMemo(
() =>
createAllOfValidator(
{ name: 'address', validator: createAddressFormatValidator(scope) },
// 禁止发送给自己
{ name: 'notSelf', validator: createNoSelfTransferValidator({ currentAddress }) },
// 不在通讯录则提示,但不阻塞
createAddressBookValidatorEntry({
scope,
level: 'info',
error: 'Not in address book',
}),
// 是最近收款人则显示徽标
createRecentSendValidatorEntry({ scope, level: 'info' }),
),
[scope, currentAddress],
)
const {
value,
setValue,
debouncedSetValue,
isValid,
isValidating,
error,
errorCode,
steps,
validate,
} = useAddressValidation({
scopes: [scope],
validator,
validateOnChange: true,
debounceMs: 300,
queryOptions: {
validatorKey: 'recipientField',
staleTime: 5 * 60 * 1000, // 5 分钟缓存
},
})
// 非阻塞提示(根据 name 区分样式)
const hints = steps?.filter((s) => s.level === 'info' && !s.isValid && !!s.error) ?? []
const badges = steps?.filter((s) => s.level === 'info' && s.isValid).map((s) => s.name) ?? []
return (
<View>
{/* 你的 Input:打字用 debouncedSetValue,粘贴用 setValue */}
{/* <Input value={value} onChangeText={debouncedSetValue} onPaste={(e) => setValue(e.text)} /> */}
{/* loading 状态 */}
{isValidating && <ActivityIndicator />}
{/* 阻塞错误(通常只显示一个) */}
{!isValid && error ? <Text style={{ color: 'red' }}>{error}</Text> : null}
{/* 非阻塞提示(调用方根据 name 决定样式) */}
{hints.map((h) => (
<Text key={h.name} style={{ color: h.name === 'notContract' ? 'orange' : 'gray' }}>
{h.error}
</Text>
))}
{/* badges */}
{badges.map((name) => (
<Text key={name}>{name}</Text>
))}
{/* 提交 */}
{/* <Button onPress={async () => { const r = await validate(); if (r.isValid) onNext(r.address!) }} /> */}
</View>
)
}
说明:
error来自最终阻塞决策(isValid=false时)。errorCode可用于国际化:i18n.t(\error.${errorCode}`)`。isValidating用于显示 loading 状态。steps里的level: 'info'都是非阻塞的,UI 样式由调用方根据name决定。
Service 增强(通讯录 / 最近发送)
这些能力在 enhance/service.ts 中提供,属于与 wallet-service 强耦合的纯函数(不是 React hooks):
-
这些函数会在运行时惰性加载
wallet-service(避免仅 import 文件就触发 service 初始化副作用)。 -
createAddressBookValidatorEntry默认level: 'info'(不阻塞);如果你要“必须在通讯录中”,请显式传level: 'error'并提供error文案。 -
getAddressBookAddresses({ scope? }) -
getRecentSendAddresses({ scope? }) -
createAddressBookValidatorEntry({ scope?, level?, error? }) -
createRecentSendValidatorEntry({ scope?, level?, error? })
示例:
const addressBook = createAddressBookValidatorEntry({ scope, level: 'error', error: 'Not in address book' })
const recent = createRecentSendValidatorEntry({ scope, level: 'info' })
const validator = createAllOfValidator(
{ name: 'address', validator: createAddressFormatValidator(scope) },
addressBook,
recent,
)
注意:
- 这些函数在校验时读取
wallet-service的最新 state。 - 如果通讯录/最近列表变了,重新调用
validate()即可刷新steps。
调用方自定义
自定义黑名单地址(如零地址)
import {
emptyAddressValidationResult,
invalidAddressValidationResult,
validAddressValidationResult,
ADDRESS_VALIDATION_ERROR_CODE,
type AddressValidator,
} from '@megaeth-labs/wallet-hooks'
const zeroAddressValidator = (): AddressValidator => {
const blacklist = new Set([
'0x0000000000000000000000000000000000000000',
'0x000000000000000000000000000000000000dead',
])
return (address) => {
const input = address.trim().toLowerCase()
if (!input) return emptyAddressValidationResult
if (blacklist.has(input)) {
return invalidAddressValidationResult('Cannot send to zero/burn address', ADDRESS_VALIDATION_ERROR_CODE.UNKNOWN)
}
return validAddressValidationResult(address.trim())
}
}
const validator = createAllOfValidator(
createAddressFormatValidator(scope),
{ name: 'notZero', validator: zeroAddressValidator() },
)