TypeScript and viem
The same shape off-chain: resolve the tier from bytecode, read the word, and never let a revert become a zero.
All docs
Nothing about reading a declaration is chain-specific or contract-specific. The whole client surface is one bytecode read and a handful of view calls.
export const Behavior = {
FEE_ON_TRANSFER: 1n << 0n,
REBASING: 1n << 1n,
TRANSFER_HOOK: 1n << 2n,
PAUSABLE: 1n << 3n,
BLOCKLIST: 1n << 4n,
NON_TRANSFERABLE: 1n << 5n,
UPGRADEABLE: 1n << 6n,
MINTABLE: 1n << 7n,
SEIZABLE: 1n << 8n,
} as const
const abi = parseAbi([
'function behaviorFlags() view returns (uint256)',
'function extensions() view returns (bytes4[])',
'function extensionData(bytes4) view returns (bytes)',
'function computeFee(address, address, uint256) view returns (uint256)',
'function maximumFee() view returns (uint256)',
'function detectTransferRestriction(address, address, uint256) view returns (uint8)',
'function transferHookGasLimit() view returns (uint32)',
])Verifying from bytecode
The clone check is forty-five bytes of string comparison and needs no contract call at all — the same check the Solidity library performs, in a language with fewer excuses.
const PROLOGUE = '363d3d373d3d3d363d73' // 10 bytes
const EPILOGUE = '5af43d82803e903d91602b57fd5bf3' // 15 bytes
/** EIP-1167: prologue, 20-byte implementation address, epilogue. */
export function isCloneOf(code: Hex | undefined, runtime: Address) {
if (code?.length !== 2 + 45 * 2) return false
const body = code.slice(2).toLowerCase()
return (
body.startsWith(PROLOGUE) &&
body.endsWith(EPILOGUE) &&
body.slice(20, 60) === runtime.slice(2).toLowerCase()
)
}Resolving a token
The tier and the word come back together, because the word without the tier is a number whose meaning you have not established yet. Note what the unknown branch does not do: it does not return zero.
export type Tier = 'verified' | 'self-declared' | 'unknown'
export async function resolve(token: Address) {
const code = await client.getCode({ address: token })
try {
const flags = await client.readContract({
address: token, abi, functionName: 'behaviorFlags',
})
const tier: Tier = isCloneOf(code, BERC_RUNTIME_V1)
? 'verified'
: 'self-declared'
return { tier, flags }
} catch {
// Not 0n. The token has told you nothing, which is not the
// same as telling you it does nothing.
return { tier: 'unknown' as Tier, flags: null }
}
}Cache what this returns, both halves. The word is fixed for the lifetime of the deployment, and a verified clone can never stop being one, so a second call is a wasted round trip rather than a refresh.
What will actually arrive
/** Pass the cached flags in. Resolving per call defeats the point. */
export async function amountThatWillArrive(
token: Address, flags: bigint,
from: Address, to: Address, amount: bigint,
): Promise<bigint> {
if ((flags & Behavior.FEE_ON_TRANSFER) === 0n) return amount
const fee = await client.readContract({
address: token, abi, functionName: 'computeFee',
args: [from, to, amount],
})
return amount - fee
}For quotes produced ahead of execution, swap computeFee for maximumFee and subtract that instead. The number is conservative, needs no per-quote call, and survives the authority raising the rate before the quote lands.
Screening a transfer
/** Returns null when the transfer is allowed. */
export async function transferBlockedBecause(
token: Address, flags: bigint,
from: Address, to: Address, amount: bigint,
): Promise<number | null> {
if ((flags & (Behavior.PAUSABLE | Behavior.BLOCKLIST)) === 0n) return null
const code = await client.readContract({
address: token, abi, functionName: 'detectTransferRestriction',
args: [from, to, amount],
})
return code === 0 ? null : code
}