Locked NFTs & Legacy Diamond Bypass
Locked legacy XDC NFTs cannot be burnt-and-redeemed through the standard V2 façade, because the tokenLocked flag blocks the burn. Without intervention, every locked NFT would be unmigratable until its lock expires.
The V3 stack solves this with a tiny facet, LegacyMigratorBypassFacet, added to the legacy Diamond via diamondCut. The facet exposes a single mutator that only the V3 migrator can call, which clears the diamond's tokenLocked flag so the standard burnAndRedeem succeeds inside the same atomic migration transaction. The diamond custodies the psXDC backing every NFT and pays the redemption from its own reserve; the facet makes no external call.
End-to-end flow (locked NFT)
User Migrator Old Façade Legacy Diamond (with bypass facet) Vault psXDC v3
│ │ │ │ │ │
│ migrate ─────►│ │ │ │ │
│ │ ownerOf, getNFTData ─►│ │ │ │
│ │ transferFrom(user→me)─► │ │ │
│ │ try claim(tokenId) ──►│ │ // best-effort: folds pending v2 rewards into staked
│ │ migratorPrepareForBurn(asset, id) ────────►│ // enforces any live v2 unlock window, then clears tokenLocked
│ │ burnAndRedeem(id) ──►│ │ │ │
│ │ …bridge → v3 shares (via PrimeStakedXDC_V3MigrationBridge)… │
│ │ mintAndStakeLocked(user, id, rarity, shares, lockEnd, lockBoost) ────────────►│ │
│ │ MigratedLocked(user, id, …) ⏎ │ │
│ ◄──── user owns new tokenId with original v2 lockEnd preserved on the v3 vault ────────────────┘All the standard migration guarantees still hold: same tokenId, atomic execution, no reward forfeiture, no custodial risk. The only additional step is the migratorPrepareForBurn call.
What migratorPrepareForBurn actually does
The facet has exactly one mutator:
Caller restriction: only the configured migrator (
XdcNftMigratorV2) can call it. The migrator address is baked into the facet at deployment.Effect: clears the
tokenLocked[asset][tokenId]flag in the legacy Diamond's storage. If the NFT islockedFromV2, it first checks the real v2unlockTimestampand revertsV2NftStillLockedif the lock is still active; otherwise it clears the flag. It makes no external call (noprimeV2.burnToRedeem); the Diamond already custodies the psXDC and pays it onburnAndRedeem.No new privileges: the facet does not enable anything else. Once
tokenLockedis cleared, the legacyburnAndRedeemflow runs normally.
Two informational view selectors are also added in the same cut so the migrator (and indexers) can introspect lock state without storage tricks:
isMigratorBypassNeeded(address asset, uint256 tokenId)
true if the migrator needs to call migratorPrepareForBurn before burning
lockedFromV2UnlockTimestamp(address asset, uint256 tokenId)
the real lockedFromV2 unlock time (reads storage directly, not via the legacy view)
The legacy getNFTData caveat
This is the most important thing to know if you're auditing or debugging the bypass:
The legacy
StakerGetterFacet.getNFTData(asset, tokenId)view function synthesises the returned struct'slockedData.lockedFromV2field from the separatetokenLocked[asset][tokenId]mapping. It does not report the storagelockedFromV2flag.
So a token whose view-returned lockedFromV2 == true may actually have been locked via lockNFT (storage lockedFromV2 == false). The facet reads the real storage flag via LegacyAppStorageMirror, so its behaviour is independent of the façade aliasing:
For both lock origins it clears
tokenLockedso the burn succeeds.For genuine storage-
lockedFromV2tokens it additionally enforces the original v2 unlock window (revertingV2NftStillLockedif the lock has not yet expired).In neither case does it touch the v2 staker; the Diamond already holds the underlying psXDC and pays it on
burnAndRedeem.
Indexers and integrators that need to know the actual lock origin should use the facet's view functions instead of the legacy getNFTData field.
What happens on the V3 side
Once the legacy burn succeeds the migrator continues exactly as for an unlocked NFT: bridge the redeemed psXDC into V3 shares, then call:
lockEnd is the original V2 unlock timestamp. This is the key property: a user cannot dodge the lock by routing through the migrator. On the V3 side, the NFT remains locked (withdraw, merge, burnAndRedeem revert) until the same time it would have unlocked on V2.
Lock boost of migrated locks
The boost units are whatever the migrator passes, its migratorLockBoostUnits setting. That setting was 0 on every migrator, so the 20 NFTs migrated with a running V2 lock kept their unlock date but earned at the unlocked weight (a Common NFT at level 0 weighed 4× instead of 16×), while a native 365-day lock gets +12.
Corrected on 2 Oct 2026:
The vault was upgraded with
adminSetLockBoost, and the 13 NFTs whose lock was still running were given +12 units until their original unlock date (tx). Boost already earned was settled first and did not change.The owners of all 20 were compensated for the two boost distributions they were under-weighted in (14 Sep and 2 Oct 2026): 505.49 psXDC per NFT still locked, 437.70 psXDC per NFT whose lock had ended in September, 9,635.27 psXDC in total, sent directly to their wallets.
The current migrator's
migratorLockBoostUnitswas set to 12, so any locked NFT migrated from now on carries the 365-day boost until its unlock date.
What if I migrate a locked NFT before lockEnd?
That's the supported case. The migrator preserves the lock and the V3 vault honours it. You won't be able to withdraw, merge, or burnAndRedeem your V3 NFT until lockEnd passes, but you will:
Earn the boost slice on the locked weight (rarity + level + the 365-day tier's +12) until the lock ends.
Earn the base NAV through the underlying psXDC v3 shares.
Be able to
claimthe boost at any time during the lock.
After lockEnd the lock bonus stops automatically: the NFT's effective weight drops back to its unlocked value, and the stale bonus is cleared on your next interaction (or by anyone calling the permissionless pokeExpired). Leaving an expired lock in place does not keep the boost slice — re-lock at any tier if you want it back.
Failure modes (handled by revert)
Migrator deployed but bypass facet not yet cut into the Diamond
LegacyDiamondRequiredForLockedNft(tokenId); the user keeps the legacy NFT
A genuine lockedFromV2 NFT whose original v2 lock has not yet expired
V2NftStillLocked(tokenId, unlockTs). The facet refuses to clear the lock early; user keeps the legacy NFT until the lock expires
Slippage exceeded on the V3 bridge
Whole migration reverts, user keeps the legacy NFT
Every failure mode is "fail closed": the user's legacy NFT remains in place. No mid-state outcomes.
Live deployment
Legacy Diamond
Bypass facet (live)
Migrator (bound to bypass facet, used Jul 2026; role revoked 9 Aug 2026)
Migrator (current MIGRATOR_ROLE holder)
Bypass facet (original, superseded)
Migrator (original, paused)
The original cut was executed by the legacy Diamond's defaultAdmin (the protocol multisig). The remediation (June 2026) was rolled out as a diamondCut Replace swapping the original facet's migratorPrepareForBurn implementation for the v2-staker-free version above, a new XdcNftMigratorV2 with id-remapping, the corresponding role swap (granting MIGRATOR_ROLE/MINTER_ROLE to the new migrator and revoking the old), and pausing the original migrator. The cut adds only migratorPrepareForBurn(address,uint256) (with isMigratorBypassNeeded and lockedFromV2UnlockTimestamp as informational view selectors). Storage layout is unaffected; no existing selector was modified.
→ Migrate XDC NFTs to V3 → Smart Contract Reference (V3) → Deployed Contracts & Addresses
Last updated