addNft never restores isCurrentlyOwned, making re-acquired NFTs permanently invisible
Repo: MetaMask/core — package @metamask/assets-controllers
Summary
When an NFT leaves a wallet and is later re-acquired, it becomes permanently invisible in the
UI and cannot be recovered by re-importing it. The manual import reports success, but the
asset never reappears.
The root cause is that addNft updates an existing state entry with an object spread that
silently preserves a stale isCurrentlyOwned: false, even on the manual-import path where
ownership has just been verified on-chain one call earlier.
This is not the same case that was fixed in 104.0.0 — see
Why existing mitigations don't cover this.
Environment
|
|
| MetaMask extension |
13.41.0 (released 2026-07-24) |
@metamask/assets-controllers |
^109.4.0 |
| Token standard |
ERC-1155 |
| Network |
custom EVM network (private subnet) |
The bug is not specific to ERC-1155 or to custom networks — see
Why this is rarely reported — but both make it far easier to hit.
Steps to reproduce
- On a custom EVM network, own an ERC-1155 token and import it manually
(Import NFT → contract address + token id). It displays correctly.
- Send the entire balance away, so the wallet's balance for that id drops to
0.
The entry disappears from the UI, as expected.
- Have the balance returned to the same address by a transaction the wallet did not
originate (e.g. a custodial platform transfers it back, or any third party sends it).
- Try to import the same contract address + token id again.
Expected: the NFT is visible again — ownership is real and verifiable on-chain.
Actual: the import succeeds without any error, but the NFT never appears anywhere in
the UI. There is no way to recover it: the entry is hidden, so it cannot be deleted and
re-added from the UI either. The only workaround is wiping extension storage
(fresh browser profile / reinstall), which also destroys the vault.
Verified against the chain: balanceOf(userAddress, tokenId) returns a non-zero value
throughout step 4.
Root cause
1. The entry is flagged, not removed
The wallet originates the transaction in step 2, so the extension calls
checkAndUpdateSingleNftOwnershipStatus after it confirms. That method writes
isCurrentlyOwned: false and keeps the entry in state. The UI filters it out, so it
is invisible but still present.
2. Nothing ever re-checks ownership afterwards
In step 3 the incoming transfer is not originated by the wallet, so no post-transaction
ownership check fires. checkAndUpdateAllNftsOwnershipStatus — which would fix the
state — has no poll, no timer, and no subscription to account/network changes; it is only
invoked directly from the UI.
3. addNft refuses to repair the flag
This is the actual defect. In #addMultipleNfts:
if (
!differentMetadata &&
existingEntry.isCurrentlyOwned &&
!hasNewFields
) {
continue;
}
const indexToUpdate = allNftsForUserPerChain[chainId].findIndex(
(nft) =>
nft.address.toLowerCase() ===
checksumHexAddress.toLowerCase() && nft.tokenId === tokenId,
);
if (indexToUpdate !== -1) {
allNftsForUserPerChain[chainId][indexToUpdate] = {
...existingEntry,
...nftMetadata,
};
}
Note that the guard deliberately does not skip when isCurrentlyOwned is false — the
flag is checked precisely so that a not-currently-owned entry falls through to the update
path. The intent to refresh such entries is clearly there.
But the update itself is { ...existingEntry, ...nftMetadata }, and nftMetadata never
carries an isCurrentlyOwned field. The stale false from existingEntry therefore
survives the merge untouched, and the entry stays invisible.
By contrast, a brand-new entry is created with the flag set:
const newEntry: Nft = {
address: checksumHexAddress,
tokenId,
favorite: false,
isCurrentlyOwned: true,
...nftMetadata,
};
4. Ownership was already proven on this path
Manual import does not call addNft directly — it goes through addNftVerifyOwnership,
which verifies ownership on-chain first and aborts otherwise:
if (
!(await this.isNftOwner(
addressToSearch,
address,
tokenId,
networkClientId,
))
) {
throw new Error('This NFT is not owned by the user');
}
So by the time the merge above runs, the controller has just confirmed that the user
does own the token — and then writes isCurrentlyOwned: false back into state anyway.
Why existing mitigations don't cover this
104.0.0 contains:
BREAKING: checkAndUpdateAllNftsOwnershipStatus now removes NFTs confirmed as
unowned from state instead of setting isCurrentlyOwned: false on them
That change addresses the batch sweep only. The path that actually runs in this scenario is
checkAndUpdateSingleNftOwnershipStatus, restored in #8435, which per its own description
"always writes the updated NFT to state and returns it" — i.e. it still flags and retains.
The result is an asymmetry: the batch method removes stale entries, the single method
creates them, and nothing schedules the batch method on its own.
Why this is rarely reported
- On Ethereum Mainnet, NFT autodetection independently re-adds assets and masks the stale
entry. Autodetection does not run on custom networks, where manual import is the only path.
- The round-trip itself is unusual for ERC-721: an NFT that leaves a wallet is normally sold,
not returned. For ERC-1155 — gaming, ticketing, custodial platforms — send-and-receive-back
of the same id is routine.
- The failure is silent. The import shows success, so users conclude the transfer failed
rather than reporting a wallet display bug.
Proposed fix
Restore the flag when refreshing an existing entry on a path where ownership is known to
hold. Minimal version:
if (indexToUpdate !== -1) {
allNftsForUserPerChain[chainId][indexToUpdate] = {
...existingEntry,
...nftMetadata,
...(source === Source.Custom ? { isCurrentlyOwned: true } : {}),
};
}
This cannot produce a false positive: Source.Custom is reached through
addNftVerifyOwnership, which has already thrown if the user is not the owner.
A cleaner variant would have addNftVerifyOwnership pass an explicit
ownershipVerified: true down to addNft, so the guarantee is carried by the call rather
than inferred from source. Happy to open a PR with tests for whichever shape you prefer.
addNftnever restoresisCurrentlyOwned, making re-acquired NFTs permanently invisibleRepo:
MetaMask/core— package@metamask/assets-controllersSummary
When an NFT leaves a wallet and is later re-acquired, it becomes permanently invisible in the
UI and cannot be recovered by re-importing it. The manual import reports success, but the
asset never reappears.
The root cause is that
addNftupdates an existing state entry with an object spread thatsilently preserves a stale
isCurrentlyOwned: false, even on the manual-import path whereownership has just been verified on-chain one call earlier.
This is not the same case that was fixed in
104.0.0— seeWhy existing mitigations don't cover this.
Environment
@metamask/assets-controllers^109.4.0The bug is not specific to ERC-1155 or to custom networks — see
Why this is rarely reported — but both make it far easier to hit.
Steps to reproduce
(Import NFT → contract address + token id). It displays correctly.
0.The entry disappears from the UI, as expected.
originate (e.g. a custodial platform transfers it back, or any third party sends it).
Expected: the NFT is visible again — ownership is real and verifiable on-chain.
Actual: the import succeeds without any error, but the NFT never appears anywhere in
the UI. There is no way to recover it: the entry is hidden, so it cannot be deleted and
re-added from the UI either. The only workaround is wiping extension storage
(fresh browser profile / reinstall), which also destroys the vault.
Verified against the chain:
balanceOf(userAddress, tokenId)returns a non-zero valuethroughout step 4.
Root cause
1. The entry is flagged, not removed
The wallet originates the transaction in step 2, so the extension calls
checkAndUpdateSingleNftOwnershipStatusafter it confirms. That method writesisCurrentlyOwned: falseand keeps the entry in state. The UI filters it out, so itis invisible but still present.
2. Nothing ever re-checks ownership afterwards
In step 3 the incoming transfer is not originated by the wallet, so no post-transaction
ownership check fires.
checkAndUpdateAllNftsOwnershipStatus— which would fix thestate — has no poll, no timer, and no subscription to account/network changes; it is only
invoked directly from the UI.
3.
addNftrefuses to repair the flagThis is the actual defect. In
#addMultipleNfts:Note that the guard deliberately does not skip when
isCurrentlyOwnedisfalse— theflag is checked precisely so that a not-currently-owned entry falls through to the update
path. The intent to refresh such entries is clearly there.
But the update itself is
{ ...existingEntry, ...nftMetadata }, andnftMetadatanevercarries an
isCurrentlyOwnedfield. The stalefalsefromexistingEntrythereforesurvives the merge untouched, and the entry stays invisible.
By contrast, a brand-new entry is created with the flag set:
4. Ownership was already proven on this path
Manual import does not call
addNftdirectly — it goes throughaddNftVerifyOwnership,which verifies ownership on-chain first and aborts otherwise:
So by the time the merge above runs, the controller has just confirmed that the user
does own the token — and then writes
isCurrentlyOwned: falseback into state anyway.Why existing mitigations don't cover this
104.0.0contains:That change addresses the batch sweep only. The path that actually runs in this scenario is
checkAndUpdateSingleNftOwnershipStatus, restored in #8435, which per its own description"always writes the updated NFT to state and returns it" — i.e. it still flags and retains.
The result is an asymmetry: the batch method removes stale entries, the single method
creates them, and nothing schedules the batch method on its own.
Why this is rarely reported
entry. Autodetection does not run on custom networks, where manual import is the only path.
not returned. For ERC-1155 — gaming, ticketing, custodial platforms — send-and-receive-back
of the same id is routine.
rather than reporting a wallet display bug.
Proposed fix
Restore the flag when refreshing an existing entry on a path where ownership is known to
hold. Minimal version:
This cannot produce a false positive:
Source.Customis reached throughaddNftVerifyOwnership, which has already thrown if the user is not the owner.A cleaner variant would have
addNftVerifyOwnershippass an explicitownershipVerified: truedown toaddNft, so the guarantee is carried by the call ratherthan inferred from
source. Happy to open a PR with tests for whichever shape you prefer.