betterERC
Docs/Clients

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.

behavior.ts
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.

behavior.ts
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.

behavior.ts
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

behavior.ts
/** 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

behavior.ts
/** 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
}