Skip to main content

Token Model

When a token is bridged to a destination chain where it does not natively exist, SkyBridge deploys a deterministic wrapper contract via CREATE3. There are two wrapper types:

  • SkyToken - ERC-20 (with ERC-677 transferAndCall support)
  • SkyNFT - ERC-721 with enumerable, URI storage, and EIP-2981 royalties

Deployment via CREATE3

Both token types are deployed with a CREATE3 library, giving the address a salt-derived determinism that is independent of the deployer's nonce. For a given salt (derived from the source chain selector + source token address), the wrapper address is the same on every destination chain that shares the same deploying Diamond address. In practice that's all 11 fleet chains, since the Diamond itself is CREATE2-deployed to a uniform address there - see Divergent-address chains below for the one exception.

  • SkyTokenDeployerFacet.deployChild(salt, tokenData) - deploys a SkyToken
  • SkyNFTDeployerFacet.deployNFTChild(salt, data) - deploys a SkyNFT

Both deployer facets are restricted to onlySelf (internal Diamond self-call only; cannot be invoked directly).

Address prediction (off-chain, no gas): predictTokenAddress(salt) / predictNFTAddress(salt).

Divergent-address chains

Create3.sol's addressOf(salt) derives the resulting address from address(this) at call time - i.e. the calling Diamond, since deployChild/deployNFTChild execute in the Diamond's delegatecall context. The real formula is f(salt, deployingDiamond), not f(salt) alone - the salt is not sufficient by itself to predict an address across chains.

This is invisible across the 11-chain fleet because every fleet Diamond sits at the same CREATE2 address (0x14fbb1eD5BC098B4Ea236dcE0941EDB02e967b44), so a fixed salt happens to produce a fixed token address everywhere.

Robinhood Chain breaks that assumption. Its Diamond (0xEF69D1115EC4742F7CECa7cAAD8DdcB5E4695456) sits at a different address than the fleet - not because the logic differs, but because its genesis facets were compiled from a later source snapshot (see Diamond & Facets → Divergent-address chains for the root cause). Since the deploying Diamond differs, every CREATE3 salt evaluated on Robinhood produces a different result than on the fleet:

TokenFleet address (11 chains)Robinhood address
sAVI (legacy)0xEA11FD3a…0xC39bBb223F389d84D90F89062303D3F17C0bFB6F
sAFX0x7F340bb1Bd152E70761C70665E9D7c0aA3c4d3F50x2E67f423F705E23d6cBFd745c121875DEB0f1508

Both Robinhood addresses are correct, on-chain-verified deployments - not misdeploys. Any tooling, indexer, or UI config that hardcodes the fleet token address needs an explicit Robinhood override rather than assuming uniformity. Full address list: Official Addresses.


SkyToken

SkyToken is deployed with the following state at construction:

FieldValue for bridge-deployed tokens
s_owneraddress(0) (set from tokenData.owner)
s_ccipAdminDiamond address
b_parentTokenSource chain token address
b_parentSourceChainSelectorCCIP chain selector of the source chain

Ownership and admin assignment (from constructor):

When s_owner == address(0) (bridge-deployed case), the constructor grants DEFAULT_ADMIN_ROLE, CCIP_OPERATOR_ROLE, and OPERATOR_ROLE to s_ccipAdmin (the Diamond). In other words:

  • owner() returns address(0) - the Diamond holds admin via the AccessControl role system, not the owner field.
  • getCCIPAdmin() returns the Diamond address - this is how Chainlink's token admin registry identifies the bridge as the token's controller for CCIP lock/release or mint/burn.

The Diamond can rotate s_ccipAdmin via setCCIPAdmin(address) (requires CCIP_OPERATOR_ROLE).

Mint/burn: CCIP_OPERATOR_ROLE holders (the Diamond) can mint and burn tokens to process cross-chain transfers.


SkyNFT

SkyNFT preserves the original collection's name and symbol (no "wrapped" prefix). It is deployed with:

FieldValue
originTokenAddress of the original NFT contract on the source chain (immutable)
originChainSelectorCCIP chain selector of the source chain (immutable)
RoyaltiesEIP-2981 default royalty from the original collection (clamped: fractions > 10,000/10,000 are set to (address(0), 0) to prevent on-chain revert)

Ownership:

owner() is a pure function that hard-codes address(0). This is the same end state as SkyToken - the Diamond holds control - but via a different code path: the SkyNFT constructor grants DEFAULT_ADMIN_ROLE and CCIP_OPERATOR_ROLE directly to the _bridge argument (the Diamond address).

Mint/burn: CCIP_OPERATOR_ROLE (the Diamond) calls mint(to, tokenId, uri) on arrival and burn(tokenId) on departure back to the source chain.


Token bridging lifecycle

ERC-20 (source chain)

  • Canonical token (the original): locked in the Diamond.
  • Bridge-deployed SkyToken: burned from the user's wallet.

ERC-20 (destination chain)

  • If token exists: unlock from Diamond or mint more SkyTokens.
  • If token does not exist: BridgeReceiverFacet triggers SkyTokenDeployerFacet.deployChild(...) in the same CCIP message execution, then mints to the receiver.

The same logic applies to ERC-721 with ERC721BridgeReceiverFacet and SkyNFTDeployerFacet.


Notes on fee-on-transfer and rebasing tokens

  • Fee-on-transfer tokens are rejected at the EntryPoint level (pre/post balance check).
  • Rebasing tokens should be explicitly blacklisted via setTokenBlacklisted.
  • Centralized-function tokens (pause/blocklist controls) retain those controls in the wrapped form only if the original token's admin also controls the destination chain.

See CCIP Tokens for a full list of caveats.