For the complete documentation index, see llms.txt. This page is also available as Markdown.

Pre-flight checks with UtilityChecker

A transfer the token refuses reverts, and the holder pays for the failed transaction. UtilityChecker answers, before anything is signed, whether a transfer would succeed and which check would refuse it. This page is for the front-end or back-end developer who shows that answer to a holder.

What UtilityChecker is

A read-only helper contract. Every function is a view that takes the address of a token, so one deployment answers for every T-REX token of a network.

Deployment

One implementation behind a UtilityCheckerProxy (ERC-1967, UUPS), deployed once per network by scripts/DeployUtilityChecker.s.sol, which records the proxy address in the deployments manifest of the network.

Upgrade

upgradeToAndCall, by the owner of the proxy only: the account that deployed it, or the account it transferred ownership to. The proxy address does not change.

State and roles

It stores its owner and its implementation, and nothing about any token. It holds no role in any AccessManager, and no contract of a token suite refers to it.

Trust statement. UtilityChecker decides nothing: the token makes every check again when the transfer executes. Its owner can replace its code, so a wrong answer can mislead a display. It cannot move or block a token. An application that does not accept this reads the direct views listed at the end of this page.

The interface

import { IIdentityFactory } from "@onchain-id/solidity/contracts/factory/IIdentityFactory.sol";
import { IClaimIssuer } from "@onchain-id/solidity/contracts/interface/IClaimIssuer.sol";

interface IUtilityChecker {
    struct ComplianceCheckDetails {
        string moduleName;
        uint256 allowedAmount;
        bool pass;
    }

    struct EligibilityCheckDetails {
        IClaimIssuer issuer;
        uint256 topic;
        bool pass;
    }

    function getFreezeStatus(address _token, address _from, address _to, uint256 _amount)
        external view returns (bool _frozen, uint256 _availableBalance);

    function getTransferStatus(address _token, address _from, address _to, uint256 _amount)
        external view returns (bool _freezeStatus, bool _eligibilityStatus, bool _complianceStatus);

    function getTransferDetails(address _token, address _from, address _to, uint256 _value)
        external view returns (ComplianceCheckDetails[] memory _details);

    function getVerifiedDetails(address _token, address _userAddress)
        external view
        returns (EligibilityCheckDetails[] memory _details, IIdentityFactory.AccountStatus _walletStatus);
}

The four functions

getTransferStatus

The overall answer for a transfer between two holders, as opposed to a mint, a burn or a forced transfer, which pass other checks. The three booleans are computed in this order, and all three are always computed.

Return value
True when
The token would otherwise revert with

_freezeStatus

Token.paused() is false, and getFreezeStatus returns _frozen false

EnforcedPause, FrozenWallet or ERC20InsufficientBalance

_eligibilityStatus

TREXRegistry.isVerified(to) is true. This is the call the token makes.

UnverifiedIdentity

_complianceStatus

Every entry of getTransferDetails has pass true. True when no RULE module is bound.

ComplianceNotFollowed

The token makes the same checks in the same order: pause, sender address frozen, recipient address frozen, free balance, eligibility of the recipient, rules. It stops at the first one that fails, where getTransferStatus reports the three groups together.

_complianceStatus is recomputed module by module. It is not a call to ModularCompliance.canTransfer, and differs from it in one case, listed under "What it does not tell you".

getFreezeStatus

Case

_frozen

_availableBalance

Token.isFrozen(from) or Token.isFrozen(to) is true

true

0, whatever the sender holds

Otherwise

true when amount is above _availableBalance

Token.balanceOf(from) minus Token.getFrozenTokens(from), the value of Token.freeBalanceOf(from)

_frozen is therefore also true for an amount above the balance when nothing is frozen. The pause is not part of this function.

getVerifiedDetails

Explains the eligibility of one address. In code order:

  1. Wallet status. IdentityFactory.getAccountStatus for the address on this chain. The factory is the one TREXRegistry.identityFactory() names.

  2. Identity. TREXRegistry.identity(userAddress). A revoked wallet still resolves here.

  3. Required topics. The selection isVerified makes: the topics defined for the type of the identity when there are any (getClaimTopicsForIdentityType), the default topics otherwise (getClaimTopics). The type comes from IdentityFactory.identityTypeOf.

  4. One entry per required topic, in the order of that list. For each topic, the function goes through getTrustedIssuersForClaimTopic(topic), reads the claim each issuer made on the identity, and asks the issuer isClaimValid.

Case
The entry of the topic

No topic is required

The array is empty.

No claim from any trusted issuer

Left empty: issuer is the zero address, topic is 0, pass is false. Take the topic from the list of required topics, at the same index.

A claim from one trusted issuer

issuer is that Claim Issuer, topic the topic, pass the answer of isClaimValid, or false when that call reverts.

Claims from several trusted issuers

The entry describes the last of them, in the order of getTrustedIssuersForClaimTopic.

The address resolves to no identity

The call reverts as soon as one required topic has a trusted issuer: there is no identity to read. Read TREXRegistry.identity(address) first.

_walletStatus

Meaning

None

The factory never linked this address. It can still be registered for this asset by a Registry Agent, and be eligible. Also returned for a registry that has no identityFactory() function. A TREXRegistry always has one.

Active

The address is linked to an identity in the global identity registry. An ONCHAINID's own address reports Active.

Revoked

The Identity Owner revoked the link. The address cannot receive, whatever the entries say.

isVerified decides, getVerifiedDetails explains. They differ in three cases. A revoked wallet: every entry can pass. Several trusted issuers for one topic: isVerified accepts any valid claim, the entry shows the last one. Eligibility checks turned off with disableEligibilityChecks: isVerified is true for every address.

getTransferDetails

Explains the rules. The function reads Token.compliance(), takes the modules of type RULE with getModulesByType, and builds the context the compliance builds for a transfer: both identities resolved, both wallets, the exact amount, no spender. Modules that are only SPENDER or TRACKER are not listed, since the compliance never asks them for an amount. With no RULE module, the array is empty.

Field
Content

moduleName

IModule.name() of the module, for example MaxBalancePerIdentityModule.

allowedAmount

IModule.allowedAmount(ctx): the largest amount this rule allows for this pair. type(uint256).max means no limit, 0 means refused.

pass

value <= allowedAmount.

For rules that are monotonic, the largest amount every rule accepts is the smallest allowedAmount of the array. A rule that is not monotonic, a multiple of a unit for instance, answers type(uint256).max or 0 for the amount asked, and says nothing about other amounts.

What it does not tell you

Not covered
In the transaction
Check it with

An amount of zero

Reverts with ZeroValue.

Refuse zero in the form.

The allowance, for transferFrom

ERC20InsufficientAllowance

Token.allowance(from, spender)

The spender policy, for transferFrom

SpenderNotAllowed, before the allowance is spent

ModularCompliance.canSpenderCall(spender, from, to, amount)

A sender with no identity, or a recipient with no identity while eligibility checks are off, while a RULE module is bound

canTransfer refuses outright: ComplianceNotFollowed. getTransferDetails still asks the modules and can report a pass.

ModularCompliance.canTransfer(from, to, amount), or TREXRegistry.identity(from) and TREXRegistry.identity(to), which must not be zero

The caller, for identityTransfer

NotLinkedIdentity when the caller is not the identity the sending address resolves to.

TREXRegistry.identity(from)

Positions on satellites

Only the balance on the reference chain is read, and the context is the one of a native movement.

Token.availableOf(wallet), with wallet the ERC-7930 interoperable address of the wallet on the satellite, and 31 Transfer on a satellite

What changes after the read

A freeze, a revoked claim or another transfer can change the outcome.

Handle the revert as well.

From answer to message

The messages are suggestions, in line with Integrate a wallet or a custodian.

Outcome
Revert it predicts
Tell the holder
What can be done

Token.paused() is true

EnforcedPause

The asset is halted by its issuer.

Try again later.

Token.isFrozen(from) or Token.isFrozen(to) is true

FrozenWallet

This address, or the recipient's, is frozen by the issuer's agents.

Contact the Issuer.

_frozen is true and no address is frozen

ERC20InsufficientBalance

The amount exceeds the free balance. Part of the balance is frozen, or active on a satellite.

Propose _availableBalance.

TREXRegistry.identity(to) is zero

UnverifiedIdentity

The recipient has no identity for this asset.

The recipient deploys an ONCHAINID and links its wallet: 02, 07.

_walletStatus is Revoked

UnverifiedIdentity

The recipient's wallet link was revoked.

Ask the recipient for another address. A revocation is final.

An entry with a zero issuer

UnverifiedIdentity

A credential the asset requires is missing: the topic at that index.

The recipient obtains it from a Claim Issuer the asset trusts: 05.

An entry with an issuer and pass false

UnverifiedIdentity

The credential from this Claim Issuer is expired, revoked or invalid.

The recipient renews it with that Claim Issuer.

_complianceStatus is false

ComplianceNotFollowed

A rule of the asset refuses this amount for this pair of holders: the modules whose pass is false.

Propose the smallest allowedAmount, and check it again. If it is 0, this amount is refused: another amount can pass only if the rule is not monotonic.

A sequence for a transfer form

One call first, each time the recipient or the amount changes. A detail call only for a boolean that is false. For transferFrom, add Token.allowance and canSpenderCall to the first step.

Calling it

Every function is a view. Call it with eth_call: no transaction, no signature, no gas paid by the holder. The functions loop over topics, issuers and modules: they are written for off-chain calls, not for a call inside a transaction.

The address to call is the proxy, published with the deployments of the network. The ABI is IUtilityChecker, in contracts/utils/ of the T-REX repository. Batching these reads in a multicall is the caller's choice.

The same reads without UtilityChecker

Question
Direct view

Is the asset paused?

Token.paused()

Is an address frozen?

Token.isFrozen(from), Token.isFrozen(to)

How much can the sender send?

Token.freeBalanceOf(from)

Is the recipient eligible?

TREXRegistry.isVerified(to)

Do the rules allow the amount?

ModularCompliance.canTransfer(from, to, amount)

The registry is Token.identityRegistry(), the compliance is Token.compliance(). These reads give the decision. UtilityChecker adds the reason, per topic and per module.

Checklist

  • The form calls getTransferStatus before the holder signs, and refuses an amount of zero itself.

  • getVerifiedDetails is not called for an address that resolves to no identity. An empty entry is matched to its topic by index, and _walletStatus is read before the entries.

  • A failed rule comes with a proposed amount, checked again.

  • For transferFrom, the allowance and canSpenderCall are read as well.

  • TREXRegistry.identity(from) is not zero, or ModularCompliance.canTransfer is read as well.

  • The address used is the proxy, and a revert after a passed check is still handled.