ERC-6909 Minimal Multi-Token Standard

The ERC-6909 token standard is a streamlined alternative to the ERC-1155 token standard.

The ERC-1155 standard introduced a multiple-token interface that allows a single smart contract to incorporate fungible and non-fungible tokens (i.e., ERC20 & ERC721).

ERC-1155 addressed several challenges, such as reducing deployment costs, minimizing redundant bytecode on the Ethereum blockchain, and streamlining token approvals for multi-token trading.

However, it introduced some bloat and gas inefficiency due to the mandated callbacks for every transfer, the forced inclusion of batch transfer, and a lack of fine-grained control of the single-operator approvals scheme. The ERC-6909 addresses these drawbacks by eliminating contract-level callbacks and batch transfers and replacing the single-operator permission scheme with a hybrid (allowance-operator) permission scheme for granular token management.

Note: The following section assumes familiarity with the ERC-1155 standard and its concepts. If you are unfamiliar, please review the article before proceeding.

The Contrast Between ERC-6909 and ERC-1155 Standard

ERC-6909 removes the callback requirement for transfers

The ERC-1155 specification requires that safeTransferFrom and safeBatchTransferFrom check that the receiving account is a contract. If so, it MUST call the ERC1155TokenReceiver interface functions (onERC1155Received, onERC1155BatchReceived) on the recipient contract account to check if it accepts transfers.

These callbacks are helpful in some instances. However, they are unnecessary external calls to the recipient that wants to opt out of this behavior. The callbacks impact the gas cost and codesize of recipient contract accounts, as they require implementing multiple callbacks (i.e., via onERC1155Received, onERC1155BatchReceived) and return magic 4-byte values to receive the tokens. In contrast, ERC-6909 implementers are allowed to decide on their callback architecture.

ERC-6909 omits batch transfer logic

Batch transfers, while sometimes beneficial, are deliberately omitted from the ERC-6909 standard to allow developers to implement batch transfer logic tailored to specific execution environments. Developers can implement batch transfers as they see fit and do not have to add an additional batch transfer function simply to follow the standard.

The safeBatchTransferFrom function, shown below, executes batch transfers in the ERC-1155 standard. However, its forced inclusion adds bloat for applications that do not need them:

// ERC-1155
function safeBatchTransferFrom(
    address _from,
    address _to,
    uint256[] calldata _ids,
    uint256[] calldata _values,
    bytes calldata _data
) external;

Here is the ERC-6909 transferFrom function. We can see that the batch feature and _data parameter have been eliminated.

// ERC-6909
function transferFrom(
    address sender,
    address receiver,
    uint256 id,
    uint256 amount
) public returns (bool) {
    if (sender != msg.sender && !isOperator[sender][msg.sender]) {
        uint256 senderAllowance = allowance[sender][msg.sender][id];
        if (senderAllowance < amount) revert InsufficientPermission();
        if (senderAllowance != type(uint256).max) {
            allowance[sender][msg.sender][id] = senderAllowance - amount;
        }
    }
    if (balanceOf[sender][id] < amount) revert InsufficientBalance();
    balanceOf[sender][id] -= amount;
    balanceOf[receiver][id] += amount;
    emit Transfer(msg.sender, sender, receiver, id, amount);
    return true;
}

ERC-6909 supports both global approvals and granular allowances

// in ERC-1155 →
function setApprovalForAll(
    address _operator,
    bool _approved
) external;

The setApprovalForAll function, shown above, is a global operator model in the ERC-1155 that allows an account to authorize another account to manage (act as an operator) all token IDs on their behalf. Once authorized, operators have unrestricted access to transfer any amount of any token ID owned by the authorizing account.

While this approach simplifies delegation, it lacks fine-grained control:

  • There is no way to grant permissions specific to individual token IDs or amounts.
  • This all-or-nothing approach will not align with scenarios requiring controlled permissions.

To introduce granular control, the ERC-6909 hybrid operator permission scheme incorporates the following:

  • operator model from ERC-1155,
  • and the allowance model inspired by ERC-20.

The operator model in ERC-6909

In the ERC-6909 setOperator function, shown below, the spender variable is set as an operator and granted global permissions to transfer all token IDs owned by the account without allowance restrictions.

function setOperator(address spender, bool approved) public returns (bool) {
    isOperator[msg.sender][spender] = approved;
    emit OperatorSet(msg.sender, spender, approved);
   return true;
}

The allowance model in ERC-6909

The allowance model introduces a token-specific and amount-specific control system where an account can set a limited allowance for a specific token ID.

For example, Alice can permit Bob to transfer 100 units of Token ID 42 without granting access to other token IDs or unlimited amounts, using the approve function in ERC-6909 shown next.

function approve(address spender, uint256 id, uint256 amount) public returns (bool) {
    allowance[msg.sender][spender][id] = amount;
    emit Approval(msg.sender, spender, id, amount);
    return true;
}

The spender variable in approve is an account authorized to transfer specific amounts of a specific token ID on behalf of the token owner.

For instance, a token owner can allow spender to transfer <= 100 units of a particular Token ID. Alternatively, they may also grant infinite approval for a specific token ID by setting the allowance to type(uint256).max.

ERC-6909 does not specify whether allowances set to type(uint256).max should be deducted or not. Instead, this behavior is left to the implementer’s discretion, similar to ERC-20.

Core Data Structures

ERC-6909 implementations use three mappings for updating the state of account balances and approvals.

balanceOf: Owner Balance of an ID

balanceOf mapping tracks the balance of a specific token ID held by an address (owner). The owner => (id => amount) structure in the mapping indicates that a single owner can hold multiple tokens and track their balances via their respective IDs.

mapping(address owner => mapping(uint256 id => uint256 amount)) public balanceOf;

allowance: Spender Allowance of an ID

The allowance mapping defines how much of a specific token (ID) a spender can transfer on an owner’s behalf. It facilitates fine-grained control over token spending.

mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) public allowance;

For example, allowance[0xDEF...][0x123...][5] will return the amount of tokens (with ID 5) owner 0xDEF... has allowed spender 0x123... to transfer.

isOperator: Operator Approval Status

mapping(address owner => mapping(address operator => bool isOperator)) public isOperator;

This mapping tracks whether a spender has been approved as an operator for all tokens owned by an address. For example, isOperator[0x123...][0xABC...] returns true if address 0xABC... is allowed to spend tokens owned by address 0x123...; otherwise, it returns false.

Core ERC-6909 Functions & their Data Parameters

The Transfer Functions

This specification does not follow the “safe transfer mechanism” as seen in ERC-721 and ERC-1155, as the naming convention was deemed misleading, given they make external calls to arbitrary contracts. The ERC-6909 uses the transfer and transferFrom functions with the following details.

Transfer:

The ERC-6909 transfer function behaves the same way as an ERC-20 transfer, except that it applies to a specific token ID. The function takes the receiver address, the token’s ID, and the amount to be transferred as input parameters and updates balances using the balanceOf mapping. As in the ERC-20 transfer function, it is necessary to return true if the transaction is executed successfully

//ERC-20 interface transfer function

function transfer(address _to, uint256 _value) public returns (bool)

    // ERC-6909  transfer function Reference Implementation
    // @notice Transfers an amount of an id from the caller to a receiver.
    // @param receiver The address of the receiver.
    // @param id The id of the token.
    // @param amount The amount of the token.

    function transfer(address receiver, uint256 id, uint256 amount) public returns (bool) {
        if (balanceOf[msg.sender][id] < amount) revert InsufficientBalance(msg.sender, id);
        balanceOf[msg.sender][id] -= amount;
        balanceOf[receiver][id] += amount;
        emit Transfer(msg.sender, msg.sender, receiver, id, amount);
        return true;
    }

transferFrom:

ERC-6909’s transferFrom function differs from ERC-20’s by requiring a token ID. Additionally, it checks for operator approval in addition to allowance

The function first checks if (sender != msg.sender && !isOperator[sender][msg.sender]), ensuring that the caller (msg.sender) is either:

  • The owner (sender), or
  • An approved operator (isOperator[sender][msg.sender] == true).

If msg.sender is not the owner or an approved operator, the function checks whether the caller has enough allowance for the transfer. If an allowance exists but is not set to unlimited (type(uint256).max), the transferred amount is deducted from the allowance.

Furthermore, the standard specifies that the function SHOULD NOT subtract amount from the caller’s allowance of token id if the caller is an operator or the sender.

// ERC-6909 transferFrom

function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public returns (bool) {

    if (sender != msg.sender && !isOperator[sender][msg.sender]) {
        uint256 senderAllowance = allowance[sender][msg.sender][id];
        if (senderAllowance < amount) revert InsufficientPermission();
        if (senderAllowance != type(uint256).max) {
            allowance[sender][msg.sender][id] = senderAllowance - amount;
        }
    }

    if (balanceOf[sender][id] < amount) revert InsufficientBalance();

    balanceOf[sender][id] -= amount;
    balanceOf[receiver][id] += amount;
    emit Transfer(msg.sender, sender, receiver, id, amount);
    return true;
}

approve:

The approve function allows the caller (msg.sender) to grant a specific allowance of a token (ID) to a spender. This updates the allowance mapping to reflect the new allowance and emits an Approval event.

function approve(address spender, uint256 id, uint256 amount) public returns (bool) {
    allowance[msg.sender][spender][id] = amount;
    emit Approval(msg.sender, spender, id, amount);
    return true;
}

setOperator:

The setOperator function allows the caller (msg.sender) to grant or revoke operator permissions for a specific address (spender) on their behalf by setting the approved parameter to true or false. The function updates the isOperator mapping accordingly and emits an OperatorSet event to notify external listeners of the change.

function setOperator(address spender, bool approved) public returns (bool) {
    isOperator[msg.sender][spender] = approved;
    emit OperatorSet(msg.sender, spender, approved);
    return true;
}

Events & Loggings in ERC-6909

ERC-6909 defines key events to track token transfers, approvals, and operator permissions within a multi-token contract.

1. Transfer Event:

/// @notice The event emitted when a transfer occurs.

event Transfer(address caller, address indexed sender, address indexed receiver, uint256 indexed id, uint256 amount);

The Transfer event in ERC-6909 is used to track token movements and must be emitted under the following conditions:

  • The event is emitted when an amount of a token id is transferred from one account to another. It logs the sender, receiver, token ID, and the amount transferred.
  • When new tokens are created, the event must be emitted with the sender as the zero address (0x0).
  • When tokens are destroyed, the event must be emitted with the receiver as the zero address (0x0) to indicate token removal.

2. OperatorSet Event:

/// @notice The event emitted when an operator is set.
event OperatorSet(address indexed owner, address indexed spender, bool approved);

The OperatorSet event is emitted whenever an owner assigns or revokes operator permissions for another address. The event logs the owner’s address, the spender’s address, and the updated approval status (true for granted, false for revoked).

3. Approval Event:

/// @notice The event emitted when an approval occurs.
event Approval(address indexed owner, address indexed spender, uint256 indexed id, uint256 amount);

The Approval event must be emitted when an owner sets or updates an approval for a spender to transfer a specific amount of a given token ID on their behalf. The event logs the owner, spender, token id, and approved amount.

Now that we have explored the differences between ERC-6909 and ERC-1155, as well as the core methods and events in ERC-6909, let’s examine some real-life uses of the standard.

How Uniswap v4 PoolManager implements ERC-6909.

In Uniswap v3, the factory/pool model creates new token pairs by deploying a separate contract for each pool using the UniswapV3Factory contract. This method increases gas costs since every new pool requires a new contract deployment.

In contrast, Uniswap v4 introduces a singleton contract (PoolManager.sol), which manages all liquidity pools as part of its internal state rather than requiring separate contract deployments. This design significantly reduces gas costs for pool creation.

Also, transactions involving multiple Uniswap pools in previous versions required token transfers and redundant state updates across multiple contracts. In Uniswap v4, instead of transferring ERC-20 tokens in and out of pools, the PoolManager contract can centrally hold the ERC-6909 representations of users’ ERC-20 tokens.

For example, if a user provides liquidity for token A, they can later choose to withdraw their share and receive token A as an ERC-20 transfer to their wallet. However, if they opt to leave their tokens inside the protocol, the Uniswap v4 PoolManager can mint ERC-6909 representations of their token balances to the LP instead of transferring ERC-20 tokens out of the contract — saving a cross-contract call. These ERC-6909 balances allow users to trade or interact within the protocol without needing to move tokens between wallets.

This means that when the user later trades token A for token B, instead of transferring ERC-20 tokens from their wallet, Uniswap simply updates their ERC-6909 balance within the pool.

Note: ERC-6909 is not used as LP tokens in Uniswap V4.

ERC-6909 metadata considerations for singleton defi architectures and NFT collections

Here is the IERC6909Metadata interface which defines how the ERC-6909 standard associate metadata, such as name, symbols, and decimals, can be associated with individual tokens.

Note that the name, symbol, and decimals functions below can change based on the id, allowing different tokens within the ERC-6909 to have different names, symbols, and decimals.

/// @notice Contains metadata about individual tokens.
interface IERC6909Metadata is IERC6909 {
    /// @notice Name of a given token.
    /// @param id The id of the token.
    /// @return name The name of the token.
    function name(uint256 id) external view returns (string memory);

    /// @notice Symbol of a given token.
    /// @param id The id of the token.
    /// @return symbol The symbol of the token.
    function symbol(uint256 id) external view returns (string memory);

    /// @notice Decimals of a given token.
    /// @param id The id of the token.
    /// @return decimals The decimals of the token.
    function decimals(uint256 id) external view returns (uint8);
}

For a DeFi protocol, we might have several LP tokens, and we might want to standardize them to all have the same number of decimals, such as 18. However, we might want the name and symbol to reflect different assets held by the pool.

In contrast, for NFTs, the decimals value should always be set to 1, since NFTs are indivisible.

In a typical NFT collection (e.g., ERC-721), all tokens share the same name and symbol to represent the collection as a whole (e.g., “CryptoPunks” with the symbol “PUNK”). ERC-6909 allows us to follow the ERC-712 convention, where all NFTs in a collection share the same metadata.

Implementation example of ERC-6909 non-fungible token.

The ERC-6909 specification does not explicitly define a unique approach for supporting non-fungible tokens. However, the ID bits-splitting technique described in the ERC-1155 specification may be used to implement non-fungible tokens in ERC-6909. This approach encodes the collection ID and item ID into a single uint256 token ID using bit-shifting and addition operations.

function getTokenId(uint256 collectionId, uint256 itemId) public pure returns (uint256) {
    return (collectionId << 128) + itemId;
}

The ERC6909MultiCollectionNFT contract below is an example of a non-fungible token (NFT) implementation that uses getTokenId to generate a token ID from collectionId and itemId.

The mintNFT function ensures that each tokenId can only be minted once, regardless of the address. It tracks whether an NFT tokenId has been minted globally using the mintedTokens mapping.

Since the amount variable is set to 1 in mintNFT , the _mint(to, tokenId, amount) call in the function will mint only one copy for a tokenid. In any case where amount > 1, the token would become fungible instead of non-fungible.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "./ERC6909.sol";

contract ERC6909MultiCollectionNFT is ERC6909 {
    struct NFT {
        string uri;
    }

    mapping(uint256 => NFT) private _tokens;
    mapping(uint256 => string) private _collectionURIs;
    mapping(uint256 => bool) public mintedTokens;

    event MintedNFT(address indexed to, uint256 indexed collectionId, uint256 indexed itemId, uint256 tokenId, string uri);

    // Compute Token ID by concatenating collectionId and itemId
    function getTokenId(uint256 collectionId, uint256 itemId) public pure returns (uint256) {
        return (collectionId << 128) + itemId;
    }


    function _mint(address to, uint256 tokenId, uint256 amount) internal {
        balanceOf[to][tokenId] += amount;
        emit Transfer(msg.sender, address(0), to, tokenId, amount);
    }

    function mintNFT(address to, uint256 collectionId, uint256 itemId, string memory uri) external {
        uint256 amount = 1;
        uint256 tokenId = getTokenId(collectionId, itemId);

        require(!mintedTokens[tokenId], "ERC6909MultiCollectionNFT: Token already minted");
        require(amount == 1, "ERC6909MultiCollectionNFT: Token copies must be 1");

        _tokens[tokenId] = NFT(uri);

        mintedTokens[tokenId] = true; // Mark as minted
        _mint(to, tokenId, amount); // amount is defined as 1.

        emit MintedNFT(to, collectionId, itemId, tokenId, uri);
    }

    function nftBalanceOf(address owner, uint256 tokenId) public view returns (uint256) {
        return balanceOf[owner][tokenId];

    }
}

Recall that the _mint call in the mintNFT, above, updates the balanceOf mapping to 1, since the mints are entirely non-fungible. Hence, the nftBalanceOf function is expected always to return 1 in this contract if the owner address indeed minted tokenId.

To transfer token ownership, the nftTransfer function below ensures that only the NFT owner can initiate a transfer by verifying their balance before allowing the only existing unit to be transferred.

function nftTransfer(address to, uint256 tokenId) external {
    require(balanceOf[tokenId][msg.sender] == 1, "ERC6909MultiCollectionNFT: This should be non-fungible.");
    require(to != address(0), "ERC6909MultiCollectionNFT: transfer to zero address");

    transfer(to, tokenId, 1);
    // the amount in this case is equal to 1.

    emit Transfer(msg.sender, address(0), to, tokenId, 1);
}

ERC-6909 content URI extensions and metadata URI JSON schema

To standardize metadata access in ERC-6909, the optional IERC6909ContentURI interface defines two URI functions(contractURI and tokenURI) for retrieving metadata at contract and token levels. The ERC-6909 standard does not mandate that tokens have URI metadata associated with them. However, if an implementation includes these URI functions, the returned URIs should point to JSON files that conform to the ERC-6909 Metadata URI JSON Schema.

pragma solidity ^0.8.19;

import "./IERC6909.sol";

/// @title ERC6909 Content URI Interface
interface IERC6909ContentURI is IERC6909 {
    /// @notice Contract level URI
    /// @return uri The contract level URI.
    function contractURI() external view returns (string memory);

    /// @notice Token level URI
    /// @param id The id of the token.
    /// @return uri The token level URI.
    function tokenURI(uint256 id) external view returns (string memory);
}

As shown in the code above, the ERC-6909 IERC6909ContentURI interface defines two optional URI functions, namely contractURI and tokenURI; each with their corresponding URI JSON schema. The contractURI function (does not take any arguments) returns a single URI pointing to contract-level metadata, while the tokenURI() returns a URI specific to each token ID.

Below is an example of how to structure the Contract URI JSON Schema, as specified by the ERC-6909 standard.

{
  "title": "Contract Metadata",
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "The name of the contract."
    },
    "description": {
      "type": "string",
      "description": "The description of the contract."
    },
    "image_url": {
      "type": "string",
      "format": "uri",
      "description": "The URL of the image representing the contract."
    },
    "banner_image_url": {
      "type": "string",
      "format": "uri",
      "description": "The URL of the banner image of the contract."
    },
    "external_link": {
      "type": "string",
      "format": "uri",
      "description": "The external link of the contract."
    },
    "editors": {
      "type": "array",
      "items": {
        "type": "string",
        "description": "An Ethereum address representing an authorized editor of the contract."
      },
      "description": "An array of Ethereum addresses representing editors (authorized editors) of the contract."
    },
    "animation_url": {
      "type": "string",
      "description": "An animation URL for the contract."
    }
  },
  "required": ["name"]
}

The tokenURI function, on the other hand, takes in a uint256 parameter id of a token and returns the token URI. The function MAY revert if the token id does not exist. Clients interacting with the contract MUST replace every occurrence of {id} in the URI with the actual token ID to access the correct metadata associated with that token.

Below is the implementation of the tokenURI function, returning a static URI template that follows a placeholder format:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "./ERC6909.sol";
import "./interfaces/IERC6909ContentURI.sol";

contract ERC6909ContentURI is ERC6909, IERC6909ContentURI {
    /// @notice The contract level URI.
    string public contractURI;

    /// @notice The URI for each id.
    /// @return The URI of the token.
    function tokenURI(uint256) public pure override returns (string memory) {
        return "<baseuri>/{id}";
    }
}

Here is an example of how to structure the URI JSON Schema, as specified by the ERC-6909 standard.

{
  "title": "Asset Metadata",
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "Identifies the token"
    },
    "description": {
      "type": "string",
      "description": "Describes the token"
    },
    "image": {
      "type": "string",
      "description": "A URI pointing to an image resource."
    },
    "animation_url": {
      "type": "string",
      "description": "An animation URL for the token."
    }
  },
  "required": ["name", "description", "image"]
}

Allowance & Operator ambiguity in ERC-6909 specification.

Consider a scenario where an account (A) grants operator permissions to another account (B) and also sets an allowance for B to transfer a specific amount of tokens.

If B initiates a transfer on behalf of A, the implementation must determine the correct order of checks and how allowances interact with operator permissions.

The ambiguity concerns the order of checks. Should the contract:

  1. Check the allowance first, reverting if it is insufficient, even if B has operator permissions.
  2. Check operator permissions first, allowing the transfer to proceed regardless of the allowance.

In the allowanceFirst contract below, if account B has operator permissions but an insufficient allowance, the allowance check will fail, causing the transaction to revert. This could be counterintuitive because operator permissions typically imply unrestricted access, and users might expect the transaction to succeed.

Conversely, in the operatorFirst contract, if the implementation checks operator permissions first, it will bypass the allowance check, and the transaction will succeed based on the operator’s unrestricted access.

contract operatorFirst {

function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public {
    // check if `isOperator` first
    if (msg.sender != sender && !isOperator[sender][msg.sender]) {
        require(allowance[sender][msg.sender][id] >= amount, "insufficient allowance");
        allowance[sender][msg.sender][id] -= amount;
    }

    // -- snip --
    }
}

contract allowanceFirst{

    function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public {
     // check if allowance is sufficient first
    if (msg.sender != sender && allowance[sender][msg.sender][id] < amount) {
        require(isOperator[sender][msg.sender], "insufficient allowance");
    }

    // ERROR: when allowance is insufficient, this panics due to arithmetic underflow, regardless of
    // whether the caller has operator permissions.
    allowance[sender][msg.sender][id] -= amount;

    // -- snip --
 }
}

The standard deliberately leaves the decision on permission checks unconstrained, giving implementers the flexibility to choose. When an account has both operator permissions and an insufficient allowance, the transfer behavior depends on the order in which the checks are performed.

Conclusion

The ERC-6909 standard significantly improves on the efficiency of ERC-1155 by removing batching and the mandatory callbacks in the transfer functions. Removing batching allows for case-by-case optimization, particularly for rollups or gas-sensitive environments.

It also introduced scalable control of token approval through the hybrid operator permission scheme.

Acknowledgements

We would like to thank vectorized, jtriley, and neodaoist (co-authors of ERC-6909) for reviewing this article.

Coding a Solidity rebase token

Coding a Solidity rebase token A “rebase token” (sometimes “rebasing token”) is an ERC-20 token where the total supply, and the balances of token holders, can change without transfers, minting, or burning. DeFi protocols often use rebasing tokens to track the amount of asset it owes to a depositor — including profit the protocol made. […]

The Diamond Proxy Pattern Explained

The Diamond Proxy Pattern Explained The Diamond Pattern (ERC-2535) is a proxy pattern where the proxy contract uses multiple implementation contracts simultaneously, unlike the Transparent Upgradeable Proxy and UUPS, which rely on just one implementation contract at a time. The proxy contract determines which implementation contract to delegatecall based on the function selector of the […]

Q Number Format

Q Number Format Q number format is a notation for describing binary fixed-point numbers. A fixed-point number is a popular design pattern in Solidity for storing fractional values, since the language does not support floating point numbers. Thus, to “capture” the fractional portion of a number, we multiply the fraction by a whole number so […]

Introduction to Proxies

Introduction to Proxies Proxy contracts enable smart contracts to retain their state while allowing their logic to be upgraded. By default, smart contracts cannot be upgraded because deployed bytecode cannot be modified. The only mechanism in the EVM to change bytecode is to deploy a new contract. However, the storage in this new contract would […]

Featured Jobs

RareSkills Researcher

As a RareSkills researcher, you will be contributing to the technical content we post on our website.

Apply Now
Rust/Solana Auditor

We’re looking for someone to design and implement security measures and defense-in-depth controls to prevent and limit vulnerabilities.

Apply Now
Full Stack Developer

We’re looking for a Senior Full-Stack Engineer to play a foundational role in working across the entire offchain stack of products.

Apply Now
Rust Developer

We are seeking a talented Rust Developer to build a robust, scalable blockchain indexers and analytic backend.

Apply Now