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.
UtilityCheckerdecides 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.
_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:
Wallet status.
IdentityFactory.getAccountStatusfor the address on this chain. The factory is the oneTREXRegistry.identityFactory()names.Identity.
TREXRegistry.identity(userAddress). A revoked wallet still resolves here.Required topics. The selection
isVerifiedmakes: the topics defined for the type of the identity when there are any (getClaimTopicsForIdentityType), the default topics otherwise (getClaimTopics). The type comes fromIdentityFactory.identityTypeOf.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 issuerisClaimValid.
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.
isVerifieddecides,getVerifiedDetailsexplains. They differ in three cases. A revoked wallet: every entry can pass. Several trusted issuers for one topic:isVerifiedaccepts any valid claim, the entry shows the last one. Eligibility checks turned off withdisableEligibilityChecks:isVerifiedis 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.
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
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.
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.
_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
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
getTransferStatusbefore the holder signs, and refuses an amount of zero itself.getVerifiedDetailsis not called for an address that resolves to no identity. An empty entry is matched to its topic by index, and_walletStatusis read before the entries.A failed rule comes with a proposed amount, checked again.
For
transferFrom, the allowance andcanSpenderCallare read as well.TREXRegistry.identity(from)is not zero, orModularCompliance.canTransferis read as well.The address used is the proxy, and a revert after a passed check is still handled.