Smart Contract Reference (V3)
Technical reference for the V3 XDC NFT stack. There are five distinct contracts; users only interact with the XdcNftStakingVault proxy and (during the migration window) the XdcNftMigrator.
XdcNftStakingVault (proxy)
TransparentUpgradeableProxy, ERC-7201 storage
XdcNftMigratorV2
Live one-shot migrator (remaps ids ≥ 10000), non-upgradeable. Supersedes the paused XdcNftMigrator 0x45e2…7dFb.
LegacyMigratorBypassFacet
Facet added to legacy Diamond 0x7a5d…aA17
XdcNftStakingVault — the staking engine
XdcNftStakingVault — the staking engineUser functions
stake(uint256 tokenId, uint256 shares)
Pulls shares of psXDC v3 from msg.sender and stakes them against tokenId. Settles pending boost first.
withdraw(uint256 tokenId, uint256 shares)
Returns shares of psXDC v3 from the NFT to msg.sender. Reverts if the NFT is locked.
claim(uint256 tokenId, bool unwrap)
Pays out the NFT's earned boost. If unwrap == true, redeems the boost shares to native XDC; otherwise transfers shares.
lock(uint256 tokenId, uint64 until)
Sets lockEnd, adds lockBonus to the NFT's weight. Disables withdraw/merge/burnAndRedeem.
unlock(uint256 tokenId)
Removes lockBonus once lockEnd has passed.
merge(uint256 tokenIdA, uint256 tokenIdB)
Burns two same-rarity NFTs, mints one higher-rarity NFT via XdcStakedNFT.mintMerged, settles boost on both.
burnAndRedeem(uint256 tokenId, bool unwrap)
Burns the NFT and returns the underlying shares (or unwraps them to XDC) in one transaction.
notifyBoost(uint256 amount) payable
FEE_ROUTER_ROLE only (granted to the harvester). Receives amount native XDC, mints psXDC v3 shares, bumps rewardPerWeightStored. Reverts if totalWeight == 0.
Migrator-only functions
mintAndStake(address to, uint256 tokenId, uint8 rarity, uint256 shares)
MIGRATOR_ROLE
Mints tokenId on the collection with the given rarity and immediately stakes shares against it for to.
mintAndStakeLocked(address to, uint256 tokenId, uint8 rarity, uint256 shares, uint64 lockEnd, uint256 lockBoost)
MIGRATOR_ROLE
Same, but preserves the legacy NFT's lock state.
Read-only helpers
nftState(uint256 tokenId)
Full state bundle: rarity, stakedShares, level, lockEnd, lockBoost, rewardIndex, weight
earned(uint256 tokenId)
Pending boost earned by the NFT (not yet claimed)
totalWeight()
Global weight across every staked NFT
rewardPerWeightStored()
The Synthetix accumulator's running total
VAULT_STORAGE_SLOT()
ERC-7201 namespaced storage slot (constant, for upgrade verification)
Admin
pause()/unpause()—PAUSER_ROLE. Halts stake/withdraw/claim; boost can still be received.recoverOrphanedShares(uint256 tokenId, address to)—DEFAULT_ADMIN_ROLE, onlywhenPausedand only for burned NFTs.setLevelStakedNeeded(...)/setLockBoost(...)— only callable whiletotalWeight == 0.
XdcStakedNFT — the collection
XdcStakedNFT — the collectionmintWithId(address to, uint256 tokenId, uint8 rarity)
MINTER_ROLE (granted to migrator)
Mints a legacy-tokenId NFT. Reverts TokenIdOutOfRange for tokenId == 0 or tokenId ≥ 10000 — the ≥ 10000 band is reserved for merges. This is exactly why XdcNftMigratorV2 remaps high legacy ids before minting.
mintMerged(address to, uint8 rarity)
MINTER_ROLE (granted to vault)
Mints a fresh higher-rarity NFT (10000+ range).
burn(uint256 tokenId)
MINTER_ROLE
Used by merge and burnAndRedeem flows.
setRarityURI(uint8 rarity, string uri)
URI_SETTER_ROLE
Updates the per-rarity tokenURI
rarityOf(uint256 tokenId)
view
Per-token rarity
The collection is non-upgradeable.
XdcNftMigratorV2 — the live V2 → V3 migrator
XdcNftMigratorV2 — the live V2 → V3 migratorThe live migrator is XdcNftMigratorV2 (0x36Fe…f026). It is a drop-in successor to the original XdcNftMigrator (now paused) that adds legacy-id remapping. Same migrate / migrateBatch surface; the only behavioural change is for legacy ids ≥ 10000.
migrate(uint256 oldTokenId, uint256 minSharesOut)
One-shot migration of a single legacy NFT. Caller must approve(migrator, oldTokenId) first.
migrateBatch(uint256[] tokenIds, uint256[] minSharesOuts)
Loop wrapper. msg.sender stays the user (audit fix C-3).
legacyDiamond()
The legacy Diamond address (0x7a5d…aA17) — required for locked-NFT migration.
oldFacade()
The legacy ERC-721 façade address (0x9D45…76a0).
Id remapping. XdcStakedNFT.mintWithId rejects ids ≥ 10000 (reserved for merges), so a legacy NFT minted in that band could never be minted 1:1. XdcNftMigratorV2 detects oldTokenId ≥ 10000, allocates a free id in the 5558–9999 reserve band, mints the v3 NFT under that new id, and emits LegacyIdRemapped(oldTokenId, newTokenId). Rarity, staked value, and lock state are preserved — only the numeric id changes, and only for the ~21 affected legacy NFTs. Every legacy id below 10000 is still preserved 1:1.
Locked NFTs revert with LegacyDiamondRequiredForLockedNft(tokenId) if legacyDiamond == address(0) (i.e. the migrator was deployed before the bypass facet was cut in).
Migration mechanics in detail: Migrate XDC NFTs to V3. Locked-NFT specifics: Locked NFTs & Legacy Diamond Bypass.
XdcNftBoostHarvester — the boost pipe
XdcNftBoostHarvester — the boost pipefeed(uint256 amount) payable
treasury
Directly forwards amount native XDC into vault.notifyBoost.
harvest(uint256 sharesToRedeem)
treasury
Redeems sharesToRedeem of psXDC v3 through redeemWithQueue; the resulting XDC is forwarded to notifyBoost.
forwardPending()
anyone
Pushes any XDC sitting in the harvester (e.g. from queued redemption settling) into notifyBoost.
Full design write-up: Boost Harvester (technical).
LegacyMigratorBypassFacet — diamond facet
LegacyMigratorBypassFacet — diamond facetAdded to the legacy Diamond via diamondCut. Only one mutator, only callable by the migrator:
migratorPrepareForBurn(address asset, uint256 tokenId)
migrator only
Clears the diamond's tokenLocked flag so burnAndRedeem succeeds on a locked NFT. For lockedFromV2 NFTs it first enforces the original v2 unlockTimestamp guard (a still-active lock cannot escape), then clears the flag. It makes no external call: the diamond custodies the psXDC and pays from its own reserve. (The original facet called primeV2.burnToRedeem here — that path was removed because the v2 staker is drained to ~0, so the call reverted and was never needed.)
isMigratorBypassNeeded(address asset, uint256 tokenId)
view
Informational — true if the migrator needs to call migratorPrepareForBurn before burning.
lockedFromV2UnlockTimestamp(address asset, uint256 tokenId)
view
Reads the real lockedFromV2 unlock time from legacy storage.
The facet reads via LegacyAppStorageMirror, which exposes the actual storage flag rather than the façade-synthesised view — important because the legacy StakerGetterFacet.getNFTData view can misreport lockedFromV2. See Locked NFTs & Legacy Diamond Bypass for the full caveat.
Events worth indexing
Staked / Withdrawn / Claimed / Locked / Merged / BurnedAndRedeemed
vault
Standard user actions
BoostNotified(uint256 amountIn, uint256 sharesMinted, uint256 rewardPerWeightStored, uint256 totalWeight)
vault
Each notifyBoost — drives boost APR calculation
MintedAndStaked / MintedAndStakedLocked
vault
Migrator created a new NFT
Migrated / MigratedLocked
migrator
One-shot migration completed
LegacyIdRemapped(uint256 oldTokenId, uint256 newTokenId)
migrator (V2)
A legacy id ≥ 10000 was remapped to a free 5558–9999 id
MigratorBypassPrepared
legacy diamond (via facet)
Confirms the bypass facet routed the call
→ Deployed Contracts & Addresses → Staking Mechanics → Reward Model
Last updated