MetaMask / MetaMask/core

addNft never restores isCurrentlyOwned, making re-acquired NFTs permanently invisible

Open
#9,787 1 comment 7 reactions 0 assignees View on GitHub
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.