addNft never restores isCurrentlyOwned, making re-acquired NFTs permanently invisible
- Dominant language
- TypeScript
- Stars
- 413
- Forks
- 308
- Avg merge
- 1d 4h
- Merged PRs (30d)
- 253
Description
# `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](#why-existing-mitigations-dont-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](#why-this-is-rarely-reported) — but both make it far easier to hit.
## Steps to reproduce
1. On a custom EVM network, own an ERC-1155 token and import it manually
(**Import NFT** → contract address + token id). It displays correctly.
2. Send the entire balance away, so the wallet's balance for that id drops to `0`.
The entry disappears from the UI, as expected.
3. 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).
4. 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`:
```js
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:
```js
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:
```ts
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:
```js
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.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start at #addMultipleNfts in the @metamask/assets-controllers package and trace the addNftVerifyOwnership path, including checkAndUpdateSingleNftOwnershipStatus. Confirm how an existing entry is merged after ownership verification, then add coverage for re-importing a previously unowned NFT and verify that the entry becomes visible again with isCurrentlyOwned set to true.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100