Skip to main content

Fungible Asset Precompile (ERC-20)

Every Polymesh fungible asset is callable as an ERC-20 token from contracts and standard Ethereum tooling, at a deterministic address derived from its AssetId. No deployment, registration, or opt-in is needed — existing and future assets are exposed automatically.

Calls route into pallet-asset and pallet-settlement, so identity, compliance, transfer restrictions, freezes, and agent permissions are all enforced exactly as they are for a Substrate transaction. An ERC-20 transfer that would break a Polymesh rule reverts.

Enabled on Mainnet, Testnet, and Develop.

It is not a drop-in ERC-20

The ABI is standard, but the behaviour is not in several places that mainstream tooling depends on. Infinite approvals, zero-value transfers, self-transfers, and transfers to un-onboarded addresses all revert. Read ERC-20 conformance and deviations before porting an existing integration.

Read the shared semantics first

Precompiles covers the rules common to every Polymesh precompile — how EVM callers resolve to Polymesh accounts, why failures revert rather than return false, delegate-call and read-only restrictions, and gas. This page covers only what is specific to fungible assets.

Precompile addresses​

The address embeds the asset's 16-byte AssetId, followed by the matcher id 0x0008 and two reserved zero bytes:

0x <asset_id: 16 bytes> 0008 0000
└── 32 hex chars ──┘ └─ 8 hex ─┘

An asset with AssetId 0x0198c4b0f0d54a8fa1a2d3f4e5b6c7d8 is therefore reachable at:

0x0198c4b0f0d54a8fa1a2d3f4e5b6c7d800080000

In JavaScript:

/** Precompile address for a Polymesh fungible asset. */
function fungibleAssetPrecompile(assetId) {
const id = assetId.replace(/^0x/, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(id)) throw new Error('AssetId must be 16 bytes');
return `0x${id}00080000`;
}

In Solidity:

library PolymeshAsset {
/// @dev Precompile address for a Polymesh fungible asset.
function precompile(bytes16 assetId) internal pure returns (address) {
return address((uint160(uint128(assetId)) << 32) | 0x00080000);
}
}

The address is stable, computable offline, and identical on every network where the asset exists.

Getting an AssetId​

AssetId is a 16-byte UUID-like identifier assigned when the asset is created. Tickers are optional labels linked to an asset, not the identifier itself. You can obtain it from:

  • the Polymesh SDK (asset.id),
  • the REST API or SubQuery indexer,
  • a block explorer's asset page,
  • or on-chain storage: asset.tickerAssetId(ticker) maps a registered ticker to its AssetId.

Interface​

The Solidity interface is IFungibleAsset, combining OpenZeppelin's IERC20, IERC20Metadata, and IERC20Permit with Polymesh, ERC-3643 and ERC-7943 extensions:

The interface below is abridged for readability. The complete source, with NatSpec, is FungibleAssetStub.sol — import it to call this precompile from a contract, or compile it (solc --abi, forge inspect IFungibleAsset abi) for an ABI to use with viem, ethers or web3. The runtime bytecode the precompile serves is FungibleAssetStub.bin.

interface IFungibleAsset {
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
event ForcedTransfer(address indexed from, address indexed to, uint256 amount);

// ERC-20
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);

// ERC-20 metadata
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function decimals() external view returns (uint8);

// EIP-2612 permit — declared, NOT implemented (see below)
function permit(
address owner, address spender, uint256 value,
uint256 deadline, uint8 v, bytes32 r, bytes32 s
) external;
function nonces(address owner) external view returns (uint256);
function DOMAIN_SEPARATOR() external view returns (bytes32);

// Polymesh extensions
function mint(uint256 value) external returns (bool);
function burn(uint256 value) external returns (bool);

// ERC-7943
function canTransfer(address from, address to, uint256 value) external view returns (bool);
function forcedTransfer(address from, address to, uint256 amount) external returns (bool);
function canSend(address account) external view returns (bool);
function canReceive(address account) external view returns (bool);
function setFrozenTokens(address account, uint256 amount) external returns (bool);
function getFrozenTokens(address account) external view returns (uint256);

// ERC-3643
function pause() external;
function unpause() external;
function setName(string calldata name) external;
function setSymbol(string calldata symbol) external;
function setAddressFrozen(address account, bool freeze) external;
function freezePartialTokens(address account, uint256 amount) external;
function unfreezePartialTokens(address account, uint256 amount) external;
function isAgent(address account) external view returns (bool);
}

Method reference​

Core ERC-20​

MethodRunsNotes
totalSupply()reads Asset::AssetsThe asset's total issued supply.
balanceOf(account)reads Asset::AssetBalanceThe account's own asset holding — see balances.
transfer(to, value)Settlement::transfer_fundsCaller's account → to, full compliance and settlement path. Emits Transfer.
approve(spender, value)Asset::approveEmits Approval.
allowance(owner, spender)reads Asset::Allowances0 when nothing is approved.
transferFrom(from, to, value)Settlement::transfer_funds (with allowance)Caller is the spender; the runtime draws down the allowance. Emits Transfer.

Metadata​

MethodBehaviour
name()The asset's name; empty string if unset.
symbol()The unique ticker linked to the asset, trailing padding trimmed. Empty string if no ticker is linked.
decimals()Always 6 — see decimals.

Issuance and redemption​

MethodRunsNotes
mint(value)Asset::issueIssues to the caller's own account. Caller needs issuance permission for the asset. Emits Transfer(0x0, caller, value).
burn(value)Asset::redeemRedeems from the caller's own account. Emits Transfer(caller, 0x0, value).
mint and burn take no address

Unlike the mint(to, amount) / burn(from, amount) shape common in OpenZeppelin-derived tokens, these always act on the calling account. To issue to a third party, mint then transfer.

ERC-7943 methods​

ERC-7943 (uRWA) defines a minimal interface for regulated, transfer-restricted tokens. All six of its functions and both of its events are implemented here, with the deviations noted in Standards conformance.

MethodRunsNotes
canTransfer(from, to, value)Asset::asset_transfer_reportview. Runs the full transfer report — compliance rules, transfer restrictions and statistics, frozen state, balances — and returns true if it reports no errors.
forcedTransfer(from, to, amount)Asset::controller_transfer_toMoves amount from from to to, bypassing compliance and frozen state. Caller must be an agent with controller-transfer permission. Emits ForcedTransfer(from, to, amount).
canSend(account)Asset::transfer_is_allowed_for_holderview. Whether the account may send this asset at all — asset existence, asset freeze, holder freeze, and the sender side of compliance. Takes no amount.
canReceive(account)Asset::transfer_is_allowed_for_holderview. Whether the account may receive this asset at all. Does not consider a holder freeze, which only restricts sending.
setFrozenTokens(account, amount)Asset::set_frozen_tokensSets the account's frozen balance to exactly amount. Agent only. Emits Frozen.
getFrozenTokens(account)reads the frozen balanceview. The account's currently frozen balance. 0 when nothing is frozen.

canTransfer is the useful primitive for contracts that must branch on compliance instead of absorbing a revert — an AMM, escrow, or router can test a leg before committing to it:

if (!asset.canTransfer(msg.sender, address(this), amount)) {
// route around it, queue it, or surface a clear error
revert TransferNotPermitted();
}

It is charged at the worst-case transfer-report weight and refunded down to actual consumption.

canTransfer returning true does not guarantee the transfer succeeds

It evaluates the asset-level transfer report: asset existence and fungibility, both parties' identities, granularity, balances, receiver DID validity, frozen state, transfer restrictions, and compliance rules.

It does not model: a zero value, a self-transfer, the spender's allowance when used ahead of transferFrom, or whether the transfer can execute atomically. Each of those reverts despite canTransfer reporting true.

Treat it as "compliance and balances permit this", not "this call will not revert".

EIP-2612 permit is not implemented​

permit, nonces, and DOMAIN_SEPARATOR appear in the ABI — so ABI-based feature detection will report the token as permit-capable — but all three revert:

permit is not implemented yet
nonces is not implemented yet
domain_separator is not implemented yet

If your integration probes DOMAIN_SEPARATOR() to decide whether to offer gasless approvals, that probe reverts rather than returning a value. Detect this and fall back to approve.

ERC-3643 methods​

ERC-3643 (T-REX) describes the administrative surface of a permissioned token. The precompile implements the subset below; these methods are agent-only unless noted, and each maps onto an existing Polymesh capability. The identity-registry, compliance-contract, batch-operation and recovery parts of ERC-3643 are not implemented — see Standards conformance.

MethodRunsNotes
pause()Asset::freezeHalts every transfer of the asset. Emits Paused.
unpause()Asset::unfreezeResumes transfers. Emits Unpaused.
setName(name)Asset::rename_assetRenames the asset.
setSymbol(symbol)Asset::register_unique_ticker then Asset::link_ticker_to_asset_idRegisters a new ticker to the caller's identity and links it to the asset — see below.
setAddressFrozen(account, freeze)Asset::set_holder_frozenFreezes or unfreezes the account entirely for this asset. Emits AddressFrozen.
freezePartialTokens(account, amount)Asset::freeze_partial_tokensFreezes amount in addition to what is already frozen. Emits TokensFrozen.
unfreezePartialTokens(account, amount)Asset::unfreeze_partial_tokensReleases amount from the frozen balance. Emits TokensUnfrozen.
isAgent(account)reads external agent membershipview. false for an account with no identity.

setSymbol registers a ticker​

On most ERC-3643 tokens setSymbol writes a string. On Polymesh a ticker is a distinct, globally unique, registered object, so setSymbol does two things: it registers the ticker to the caller's identity, then links it to the asset.

The consequences are worth planning for:

  • The ticker must be available. One already registered to another identity causes a revert.
  • Registering charges the standard ticker registration fee, so the call costs POLYX beyond gas.
  • The ticker is at most 12 characters; longer input reverts with Invalid symbol; Ticker is too long.
  • The caller needs permission to register a ticker and to link it, not merely agent rights over the asset.

Freezing: relative and absolute​

freezePartialTokens is relative and setFrozenTokens is absolute. Calling freezePartialTokens(account, 100) twice leaves 200 frozen; calling setFrozenTokens(account, 100) twice leaves 100.

A frozen balance reduces what the holder can send:

available = balance − locked − frozen

A transfer above that fails with InsufficientBalance; a transfer by a fully frozen holder fails with InvalidTransferSenderIsFrozen. Freezing restricts sending only — a frozen holder can still receive, and canReceive ignores the freeze.

forcedTransfer deliberately bypasses both, which is what allows an agent to seize from a frozen holder without unfreezing them first.

Polymesh applies freezes to a holder, which may be an account key or a portfolio. Only the account form has an EVM address, so these methods reach account holders only; portfolios must be frozen through the Substrate API. See Freezing an individual holder.

Standards conformance​

The precompile implements the ERC-20 interface in full, plus parts of ERC-3643 and ERC-7943. The table records where it falls short of each standard, so an integrator can tell what is safe to rely on.

StandardImplementedKnown deviations
ERC-20Core interface and metadataBehavioural deviations only — see ERC-20 conformance and deviations
EIP-2612Declared in the ABINot implemented; permit, nonces and DOMAIN_SEPARATOR revert
ERC-7943All six functions and both eventsNot advertised through ERC-165; the standard's four custom errors are not raised; forcedTransfer does not emit Transfer; canTransfer returns false for excluded reasons; view functions can revert on out-of-range input
ERC-3643pause, unpause, setName, setSymbol, isAgent, setAddressFrozen, freezePartialTokens, unfreezePartialTokensIdentity registry, compliance contract, onchainID, batch operations and recovery are not implemented

Four ERC-7943 points are worth spelling out, because each changes how a contract should be written:

  • No ERC-165. IFungibleAsset declares no supportsInterface, so a contract cannot detect ERC-7943 — or ERC-20 — by interface id. Assume the interface from the address range instead.
  • No typed errors. ERC-7943 defines ERC7943CannotSend, ERC7943CannotReceive, ERC7943CannotTransfer and ERC7943InsufficientUnfrozenBalance. Failures revert with Error(string) instead, so a try/catch cannot discriminate on the error type — match on the revert string, or pre-check with canTransfer.
  • forcedTransfer emits only ForcedTransfer, not the ERC-20 Transfer that the standard also requires. An indexer tracking balances from Transfer logs alone will miss a seizure; read ForcedTransfer as well.
  • canTransfer is not purely a permission check. It returns false for insufficient balance, granularity and overflow — which ERC-7943 says it must not — so a false result does not by itself mean the parties are ineligible. It can also revert for a value above 2^128-1 rather than returning false.

Events​

EventEmitted by
Transfer(address indexed from, address indexed to, uint256 value)transfer, transferFrom, mint (from = 0x0), burn (to = 0x0)
Approval(address indexed owner, address indexed spender, uint256 value)approve
ForcedTransfer(address indexed from, address indexed to, uint256 amount)forcedTransfer
Frozen(address indexed account, uint256 amount)setFrozenTokens
TokensFrozen(address indexed account, uint256 amount)freezePartialTokens
TokensUnfrozen(address indexed account, uint256 amount)unfreezePartialTokens
AddressFrozen(address indexed account, bool freeze, address indexed owner)setAddressFrozen
Paused(address userAddress)pause
Unpaused(address userAddress)unpause

These are EVM logs. The underlying Asset and Settlement pallets emit their normal Substrate events at the same time, so Substrate-side indexers see these transfers exactly as they see any other and need no precompile-specific handling.

Revert reasons​

Revert reasonCause
Asset not foundNo asset exists for the AssetId embedded in the called address.
Asset is not fungibleThe AssetId belongs to an NFT collection. This precompile is fungible-only.
Extrinsic returned an error: <message>The underlying pallet call failed; its own error message is appended — compliance failure, insufficient balance, missing permission, frozen asset, and so on.
Instruction was not executed; Most likely the instruction is missing an affirmation from the receiver/mediatorThe transfer could not complete atomically — see below.
Invalid caller / Invalid account idAn address could not be resolved to a valid Polymesh account.
Balance conversion failedA uint256 value does not fit Polymesh's Balance type, or vice versa.
permit is not implemented yet (and nonces, domain_separator)EIP-2612 methods are declared but unimplemented.
Invalid symbol; Ticker is too longsetSymbol was given more than 12 characters.

Plus the shared guards documented in Precompiles: PrecompileDelegateDenied for delegate calls, and StateChangeDenied for state-changing methods reached through staticcall or another read-only execution frame. eth_call is a dry run rather than a read-only frame, so a simulated write reports success and commits nothing.

Common pallet errors​

These arrive wrapped as Extrinsic returned an error: <name>. The names below are Polymesh pallet errors, not Solidity ones, so they will not look familiar to an EVM developer — this table is the translation.

Pallet errorMeans
ZeroAmountvalue was 0. See zero-value transfers.
SenderSameAsReceiverSender and recipient are the same holder. See self-transfers.
IdentityNotFoundForAccountPortfolioAn account involved in the transfer is not linked to an identity — including address(0). See recipients must be onboarded.
InvalidGranularityThe asset is indivisible and value is not a whole multiple of 1_000_000. See decimals.
InsufficientBalanceNot enough unlocked balance in the sender's account holding.
InsufficientAllowanceThe spender's allowance is lower than value.
InvalidTransferComplianceFailureThe asset's compliance rules reject this sender/receiver pair.
InvalidTransferFrozenAssetThe asset is frozen.
InvalidTransferInvalidReceiverDIDThe recipient's identity exists but is not active.
UnauthorizedAgentThe caller lacks the agent permission a privileged method needs (mint, forcedTransfer, and the ERC-3643 methods).
InvalidTransferSenderIsFrozenThe sending holder is frozen for this asset. See freezing.
InsufficientFrozenBalanceunfreezePartialTokens was asked to release more than is frozen.
FreezePartialTokenMustNotExceedHoldersBalancefreezePartialTokens would freeze more than the holder holds.
ReceiverAffirmationRequiredforcedTransfer's destination requires the receiver to affirm incoming transfers.

Unknown selectors panic rather than revert​

Calling a selector the interface does not define returns an ABI-encoded Panic(0x41) — rendered by most tooling as "memory allocation error" — rather than a plain revert. This is upstream pallet-revive behaviour: calldata that fails to decode against the precompile's interface is reported as a panic.

It matters if you probe for optional methods by calling them and catching the failure: a try/catch on a Panic behaves differently from one on an Error(string), and the panic code is misleading about the cause. Prefer feature-detecting from a known interface version rather than by probing.

ERC-20 conformance and deviations​

The precompile implements the ERC-20 ABI faithfully, but a Polymesh asset is a regulated instrument, not a free-floating token balance. Several behaviours therefore diverge from what the ERC-20 standard mandates or what tooling has come to assume.

Read this section before porting an existing ERC-20 integration. The deviations are not obscure edge cases — infinite approvals, zero-value transfers, and self-transfers are all things mainstream routers, vaults, and wallets do routinely.

Summary​

BehaviourERC-20 expectationPolymeshImpact
Infinite approvalapprove(spender, 2^256-1) succeedsReverts above 2^128-1High — most wallets and DEX routers send type(uint256).max
Zero-value transferMUST succeed and emit TransferReverts (ZeroAmount)High — common in batch, sweep, and settlement loops
Self-transferPermittedReverts (SenderSameAsReceiver)Medium — routers and vaults do this incidentally
Transfer to address(0)Permitted (burn idiom)Reverts (IdentityNotFoundForAccountPortfolio)Medium — use burn instead
Unonboarded recipientAny address can receiveReverts unless the recipient has an active identityHigh — you cannot airdrop to arbitrary EVM addresses
Deferred settlementn/aReverts if the transfer cannot settle atomicallyMedium — affects assets requiring affirmation or mediation
decimals()Reflects divisibilityAlways 6, even for indivisible assetsMedium — indivisible assets silently require multiples of 1_000_000
transferFrom by ownerConsumes allowanceConsumes none when from is the callerLow — more permissive than the standard
permitEIP-2612 optional extensionDeclared in the ABI but revertsMedium — breaks gasless-approval feature detection
symbol()A short symbol stringEmpty when no ticker is linkedLow
Unknown selectorRevertPanic(0x41)Low

Infinite approvals revert​

approve(spender, type(uint256).max) — the near-universal "approve once, forever" idiom — reverts with Balance conversion failed.

Polymesh balances are u128, and the precompile converts uint256 arguments with a checked conversion. The boundary is exact: 2^128 - 1 succeeds, 2^128 and anything above reverts.

Unlimited allowance is supported natively — an allowance of exactly 2^128 - 1 is treated by the runtime as infinite and is never decremented on spend. The precompile simply cannot be reached with the value standard tooling sends.

Two consequences to design around:

  • Approve 2^128 - 1, not 2^256 - 1, if you want an unlimited allowance. Any integration that hardcodes type(uint256).max needs a Polymesh-specific path.
  • allowance() reads back 2^128 - 1 for an unlimited allowance, not type(uint256).max. Tooling that detects "already infinitely approved" by comparing against type(uint256).max will conclude the allowance is finite and try to re-approve — which then reverts.
uint256 constant POLYMESH_MAX_ALLOWANCE = type(uint128).max;

// instead of: token.approve(spender, type(uint256).max)
token.approve(spender, POLYMESH_MAX_ALLOWANCE);

Zero-value transfers revert​

ERC-20 states that transfers of 0 MUST be treated as normal transfers and MUST fire the Transfer event. On Polymesh, transfer(to, 0) and transferFrom(from, to, 0) revert with ZeroAmount.

This is enforced by the settlement layer for every transfer path, not by the precompile, so it applies to Substrate transfers too.

Batch loops, sweep functions, fee-distribution routines, and accounting code frequently transfer a computed amount that can legitimately be zero, on the assumption that the call is a harmless no-op. On Polymesh that reverts the whole transaction. Guard zero amounts in your own code rather than relying on the token to absorb them.

Self-transfers revert​

transfer(msg.sender, value) — sender and recipient resolving to the same holder — reverts with SenderSameAsReceiver. ERC-20 imposes no such restriction, and it is a documented no-op in most implementations.

Routers, vaults, and aggregators hit this incidentally when a computed route happens to start and end at the same address. As with zero-value transfers, guard for it in your own code.

Recipients must have an identity​

Every party to a Polymesh asset transfer must be linked to an identity. If the resolved recipient account has no linked DID, the transfer reverts with IdentityNotFoundForAccountPortfolio; if it has one that is not active, with InvalidTransferInvalidReceiverDID.

This has consequences with no ERC-20 equivalent:

  • You cannot transfer to an arbitrary EVM address. Airdrops, faucets, and "send to any address" flows do not work unless every recipient has been onboarded.
  • transfer(address(0), value) reverts with IdentityNotFoundForAccountPortfolio. The transfer-to-zero-address burn idiom is not supported — use burn, which redeems from the caller's own holding and emits the same Transfer(caller, 0x0, value) log.
  • Contracts need their own identity. A contract calling the precompile acts as its own account, not as the user who called it, so a vault or router that custodies assets must itself be onboarded and compliant. See Precompiles.

balanceOf is unaffected — it returns 0 for an unonboarded address rather than reverting.

Transfers must complete atomically​

transfer and transferFrom go through the settlement engine, which creates an instruction and immediately attempts to execute it. If it cannot complete within the same call — most often because the receiver has mandatory receiver affirmation enabled, or the asset requires mediator affirmation — the precompile reverts and the instruction is rolled back.

This keeps ERC-20 semantics honest: transfer returning true always means the tokens actually moved, and no half-finished instruction is left behind for the caller to discover later. The cost is that assets or counterparties requiring affirmation or mediation cannot be transferred through the ERC-20 interface at all — those transfers must use the Substrate settlement flow.

Note that the call still pays the full transfer weight before reverting; a failed deferred transfer is not a cheap failure.

decimals() is always 6, even for indivisible assets​

decimals() returns 6 unconditionally. Polymesh fungible balances are stored in units of 10^-6, so this is an honest description of the storage unit — but it does not describe the asset's actual granularity.

An indivisible asset only accepts amounts that are whole multiples of 1_000_000. Any other amount reverts with InvalidGranularity. Because the token advertises 6 decimals, standard tooling will happily let a user enter 1.5 and generate a transfer of 1_500_000 — which reverts.

There is no way to detect this through the ERC-20 interface. If your integration supports arbitrary assets, either read the asset's divisible flag from chain state, or handle InvalidGranularity and surface a meaningful error. Anything assuming an 18-decimal default — a hardcoded parseEther, a 1e18 constant — will additionally be wrong by twelve orders of magnitude.

transferFrom by the owner consumes no allowance​

When from resolves to the calling account, transferFrom(from, to, value) behaves exactly like transfer(to, value) and draws down no allowance. Widely used implementations (including OpenZeppelin's) deduct from the caller's self-allowance in this case, so a contract that relies on transferFrom self-calls to meter its own spending will find the meter never moves.

This is more permissive than the standard, not less, so it does not cause reverts — but do not use a self-allowance as a spending limit.

Balances and allowances are account-level​

balanceOf reads the account's own asset holding — the account-level holding model introduced in chain v8 — not the sum of the linked identity's portfolios. An address whose assets sit in DID portfolios reports 0.

Allowances likewise sit between two accounts for one asset. An allowance grants no portfolio access and no agent permissions. Because the precompile is a front-end onto pallet-asset's own allowance storage, an allowance granted through the precompile is visible to and usable by the equivalent Substrate calls, and vice versa — see Allowances.

Transfers within one identity skip compliance​

When sender and recipient accounts resolve to the same identity, the transfer is executed directly rather than through a settlement instruction, and compliance rules and transfer restrictions are not evaluated. This is consistent with Polymesh's model — compliance governs who may hold an asset, and beneficial ownership has not changed — but it means a transfer between two of your own accounts is not a valid test that compliance would permit a transfer to a third party.

symbol() can be empty​

Assets are identified by AssetId; a ticker is an optional, separately registered link. An asset with no linked unique ticker returns an empty symbol(). Never treat the symbol as an identifier — the address (and the AssetId inside it) is the identifier.

Where a ticker is linked, trailing padding is trimmed, so a 7-character ticker returns a 7-character string.

Fungible assets only​

This precompile serves fungible assets only. An AssetId belonging to an NFT collection reverts with Asset is not fungible on every method, before any runtime call is made — use the Non-Fungible Asset precompile at address prefix 9 instead. There is no ERC-1155 precompile.

Addresses exist even when the asset does not

Precompile addresses are matched on address shape alone, without consulting storage, so eth_getCode returns the stub bytecode for any address of the form 0x<16 bytes>00080000 — including ones whose AssetId belongs to an NFT collection or to no asset at all.

An explorer or indexer scanning address ranges will therefore find "contracts" at addresses that back nothing. Every call to them reverts (Asset not found or Asset is not fungible), so they are inert, but their existence is not a signal that an asset exists.

Block explorer verification​

The precompile serves a stub contract (FungibleAssetStub) whose ABI mirrors IFungibleAsset, so eth_getCode returns real bytecode and explorers can verify and render a proper Read/Write method UI. Verify one address per network; twin-matching covers the rest. See Block explorer support for the workflow and the helper scripts.