@lifi/sdk
Version:
LI.FI Any-to-Any Cross-Chain-Swap SDK
530 lines (483 loc) • 14.1 kB
text/typescript
import type { Address, Client, Hex, TypedDataDomain } from 'viem'
import {
encodeAbiParameters,
keccak256,
pad,
parseAbiParameters,
toBytes,
toHex,
zeroHash,
} from 'viem'
import { getCode, multicall, readContract } from 'viem/actions'
import { eip2612Abi } from '../abi.js'
import { getActionWithFallback } from '../getActionWithFallback.js'
import { getMulticallAddress, isDelegationDesignatorCode } from '../utils.js'
import {
DAI_LIKE_PERMIT_TYPEHASH,
EIP712_DOMAIN_TYPEHASH,
EIP712_DOMAIN_TYPEHASH_WITH_SALT,
eip2612Types,
} from './constants.js'
import type { NativePermitData } from './types.js'
/**
* Blocklist of EIP-7702 delegation addresses that should not use native permits
* These delegations may have incompatibilities or security concerns with permit signatures
* Addresses should be lowercase without 0x prefix for case-insensitive matching
*/
const EIP7702_DELEGATION_BLOCKLIST: string[] = [
// OKX delegation isn't compatible at this moment
'36d3CBD83961868398d056EfBf50f5CE15528c0D',
]
type GetNativePermitParams = {
chainId: number
tokenAddress: Address
spenderAddress: Address
amount: bigint
}
function makeDomainSeparator({
name,
version,
chainId,
verifyingContract,
withSalt = false,
}: {
name: string
version: string
chainId: number
verifyingContract: Address
withSalt?: boolean
}): Hex {
const nameHash = keccak256(toBytes(name))
const versionHash = keccak256(toBytes(version))
const encoded = withSalt
? encodeAbiParameters(
parseAbiParameters('bytes32, bytes32, bytes32, address, bytes32'),
[
EIP712_DOMAIN_TYPEHASH_WITH_SALT,
nameHash,
versionHash,
verifyingContract,
pad(toHex(chainId), { size: 32 }),
]
)
: encodeAbiParameters(
parseAbiParameters('bytes32, bytes32, bytes32, uint256, address'),
[
EIP712_DOMAIN_TYPEHASH,
nameHash,
versionHash,
BigInt(chainId),
verifyingContract,
]
)
return keccak256(encoded)
}
function validateDomainSeparator({
name,
version,
chainId,
verifyingContract,
domainSeparator,
}: {
name: string
version: string
chainId: number
verifyingContract: Address
domainSeparator: Hex
}): { isValid: boolean; domain: TypedDataDomain } {
if (!name || !domainSeparator) {
return {
isValid: false,
domain: {},
}
}
for (const withSalt of [false, true]) {
const computedDS = makeDomainSeparator({
name,
version,
chainId,
verifyingContract,
withSalt,
})
if (domainSeparator.toLowerCase() === computedDS.toLowerCase()) {
return {
isValid: true,
domain: withSalt
? {
name,
version,
verifyingContract,
salt: pad(toHex(chainId), { size: 32 }),
}
: {
name,
version,
chainId,
verifyingContract,
},
}
}
}
return {
isValid: false,
domain: {},
}
}
/**
* Checks if the account can use native permits based on its code.
* Returns true if:
* 1. Account has no code (EOA)
* 2. Account is EOA and has EIP-7702 delegation designator code (and not in blocklist)
*
* @param client - The Viem client instance
* @returns Promise<boolean> - Whether the account can use native permits
*/
const canAccountUseNativePermits = async (client: Client): Promise<boolean> => {
try {
const accountCode = await getActionWithFallback(
client,
getCode,
'getCode',
{
address: client.account!.address,
}
)
// If no code (0x or undefined), it's an EOA - can use native permits
if (!accountCode || accountCode === '0x') {
return true
}
// If has code but it's EIP-7702 delegation designator - check blocklist
if (isDelegationDesignatorCode(accountCode)) {
// Check if the code contains any blocklisted delegation address
const codeLC = accountCode.toLowerCase()
const isBlocked = EIP7702_DELEGATION_BLOCKLIST.some(
(blockedAddress: string) =>
codeLC.includes(blockedAddress.toLowerCase())
)
if (isBlocked) {
return false
}
return true
}
// If has code but not EIP-7702 delegation - cannot use native permits
// Smart Accounts like Kernel (ZeroDev) can't produce ECDSA signatures, so we can't use native permits in current implementation
return false
} catch {
// If we can't check the code, assume it's not safe to use native permits
return false
}
}
/**
* Attempts to retrieve contract data using EIP-5267 eip712Domain() function
* @link https://eips.ethereum.org/EIPS/eip-5267
* @param client - The Viem client instance
* @param chainId - The chain ID
* @param tokenAddress - The token contract address
* @returns Contract data if EIP-5267 is supported, undefined otherwise
*/
const getEIP712DomainData = async (
client: Client,
chainId: number,
tokenAddress: Address
) => {
try {
const multicallAddress = await getMulticallAddress(chainId)
const contractCalls = [
{
address: tokenAddress,
abi: eip2612Abi,
functionName: 'eip712Domain',
},
{
address: tokenAddress,
abi: eip2612Abi,
functionName: 'nonces',
args: [client.account!.address],
},
] as const
if (multicallAddress) {
try {
const [eip712DomainResult, noncesResult] = await getActionWithFallback(
client,
multicall,
'multicall',
{
contracts: contractCalls,
multicallAddress,
}
)
if (
eip712DomainResult.status !== 'success' ||
noncesResult.status !== 'success' ||
!eip712DomainResult.result ||
noncesResult.result === undefined
) {
// Fall back to individual calls if multicall fails
throw new Error('EIP-5267 multicall failed')
}
const [, name, version, tokenChainId, verifyingContract, salt] =
eip712DomainResult.result
if (
Number(tokenChainId) !== chainId ||
verifyingContract.toLowerCase() !== tokenAddress.toLowerCase()
) {
return undefined
}
// Build domain object directly from EIP-5267 data
// Use the actual salt value returned by EIP-5267 - this is the canonical salt that the contract uses
const hasSalt = salt !== zeroHash
const domain = hasSalt
? {
name,
version,
verifyingContract: tokenAddress,
salt,
}
: {
name,
version,
chainId,
verifyingContract: tokenAddress,
}
return {
name,
version,
domain,
permitTypehash: undefined, // EIP-5267 doesn't provide permit typehash directly
nonce: noncesResult.result,
}
} catch {
// Fall through to individual calls
}
}
// Fallback to individual contract calls
const [eip712DomainResult, noncesResult] = (await Promise.allSettled(
contractCalls.map((call) =>
getActionWithFallback(client, readContract, 'readContract', call)
)
)) as [
PromiseSettledResult<
[Hex, string, string, bigint, Address, Hex, bigint[]]
>,
PromiseSettledResult<bigint>,
]
if (
eip712DomainResult.status !== 'fulfilled' ||
noncesResult.status !== 'fulfilled'
) {
return undefined
}
const [, name, version, tokenChainId, verifyingContract, salt] =
eip712DomainResult.value
if (
Number(tokenChainId) !== chainId ||
verifyingContract.toLowerCase() !== tokenAddress.toLowerCase()
) {
return undefined
}
// Build domain object directly from EIP-5267 data
// Use the actual salt value returned by EIP-5267 - this is the canonical salt that the contract uses
const hasSalt = salt !== zeroHash
const domain = hasSalt
? {
name,
version,
verifyingContract: tokenAddress,
salt,
}
: {
name,
version,
chainId,
verifyingContract: tokenAddress,
}
return {
name,
version,
domain,
permitTypehash: undefined, // EIP-5267 doesn't provide permit typehash directly
nonce: noncesResult.value,
}
} catch {
return undefined
}
}
const getContractData = async (
client: Client,
chainId: number,
tokenAddress: Address
) => {
try {
// First try EIP-5267 approach - returns domain object directly
const eip5267Data = await getEIP712DomainData(client, chainId, tokenAddress)
if (eip5267Data) {
return eip5267Data
}
// Fallback to legacy approach - validates and returns domain object
const multicallAddress = await getMulticallAddress(chainId)
const contractCalls = [
{
address: tokenAddress,
abi: eip2612Abi,
functionName: 'name',
},
{
address: tokenAddress,
abi: eip2612Abi,
functionName: 'DOMAIN_SEPARATOR',
},
{
address: tokenAddress,
abi: eip2612Abi,
functionName: 'PERMIT_TYPEHASH',
},
{
address: tokenAddress,
abi: eip2612Abi,
functionName: 'nonces',
args: [client.account!.address],
},
{
address: tokenAddress,
abi: eip2612Abi,
functionName: 'version',
},
] as const
if (multicallAddress) {
try {
const [
nameResult,
domainSeparatorResult,
permitTypehashResult,
noncesResult,
versionResult,
] = await getActionWithFallback(client, multicall, 'multicall', {
contracts: contractCalls,
multicallAddress,
})
if (
nameResult.status !== 'success' ||
domainSeparatorResult.status !== 'success' ||
noncesResult.status !== 'success' ||
!nameResult.result ||
!domainSeparatorResult.result ||
noncesResult.result === undefined
) {
// Fall back to individual calls if multicall fails
throw new Error('Multicall failed')
}
// Validate domain separator and create domain object
const { isValid, domain } = validateDomainSeparator({
name: nameResult.result,
version: versionResult.result ?? '1',
chainId,
verifyingContract: tokenAddress,
domainSeparator: domainSeparatorResult.result,
})
if (!isValid) {
return undefined
}
return {
name: nameResult.result,
domain,
permitTypehash: permitTypehashResult.result,
nonce: noncesResult.result,
version: versionResult.result ?? '1',
}
} catch {
// Fall through to individual calls
}
}
const [
nameResult,
domainSeparatorResult,
permitTypehashResult,
noncesResult,
versionResult,
] = (await Promise.allSettled(
contractCalls.map((call) =>
getActionWithFallback(client, readContract, 'readContract', call)
)
)) as [
PromiseSettledResult<string>,
PromiseSettledResult<Hex>,
PromiseSettledResult<Hex>,
PromiseSettledResult<bigint>,
PromiseSettledResult<string>,
]
if (
nameResult.status !== 'fulfilled' ||
domainSeparatorResult.status !== 'fulfilled' ||
noncesResult.status !== 'fulfilled'
) {
return undefined
}
const name = nameResult.value
const version =
versionResult.status === 'fulfilled' ? versionResult.value : '1'
// Validate domain separator and create domain object
const { isValid, domain } = validateDomainSeparator({
name,
version,
chainId,
verifyingContract: tokenAddress,
domainSeparator: domainSeparatorResult.value,
})
if (!isValid) {
return undefined
}
return {
name,
domain,
permitTypehash:
permitTypehashResult.status === 'fulfilled'
? permitTypehashResult.value
: undefined,
nonce: noncesResult.value,
version,
}
} catch {
return undefined
}
}
/**
* Retrieves native permit data (EIP-2612) for a token on a specific chain
* @link https://eips.ethereum.org/EIPS/eip-2612
* @param client - The Viem client instance
* @param chain - The extended chain object containing chain details
* @param tokenAddress - The address of the token to check for permit support
* @returns {Promise<NativePermitData>} Object containing permit data including name, version, nonce and support status
*/
export const getNativePermit = async (
client: Client,
{ chainId, tokenAddress, spenderAddress, amount }: GetNativePermitParams
): Promise<NativePermitData | undefined> => {
// Check if the account can use native permits (EOA or EIP-7702 delegated account)
const canUsePermits = await canAccountUseNativePermits(client)
if (!canUsePermits) {
return undefined
}
const contractData = await getContractData(client, chainId, tokenAddress)
if (!contractData) {
return undefined
}
// We don't support DAI-like permits yet (e.g. DAI on Ethereum)
// https://eips.ethereum.org/EIPS/eip-2612#backwards-compatibility
if (contractData.permitTypehash === DAI_LIKE_PERMIT_TYPEHASH) {
return undefined
}
const deadline = BigInt(Math.floor(Date.now() / 1000) + 30 * 60).toString() // 30 minutes
const message = {
owner: client.account!.address,
spender: spenderAddress,
value: amount.toString(),
nonce: contractData.nonce.toString(),
deadline,
}
return {
primaryType: 'Permit',
domain: contractData.domain,
types: eip2612Types,
message,
}
}