Alert Source Discuss
⚠️ Draft Standards Track: ERC

ERC-7722: Opaque Token

A token specification designed to enhance privacy by concealing balance information.

Authors Ivica Aračić (@ivica7), Ante Bešlić (@ethSplit), Mirko Katanić (@mkatanic), SWIAT
Created 2024-06-09
Discussion Link https://ethereum-magicians.org/t/erc-7722-opaque-token/20249

Abstract

This ERC proposes a specification for an opaque token that enhances privacy by concealing balance information. Privacy is achieved by representing balances as off-chain data encapsulated in hashes, referred to as “baskets”. These baskets can be reorganized, transferred, and managed through token functions on-chain.

Motivation

Smart contract accounts serve as well-defined identities that can have reusable claims and attestations attached to them, making them highly useful for various applications. However, this strength also introduces a significant privacy challenge when these identities are used to hold tokens. Specifically, in the case of ERC-20 compatible tokens, where balances are stored directly on-chain in plain text, the transparency of these balances can compromise the privacy of the account holder. This creates a dilemma: while the reuse of claims and attestations tied to a smart contract account can be advantageous, it also increases the risk of exposing sensitive financial information, particularly when these well-defined identities are associated with publicly visible token holdings.

This proposal aims to conceal balances on-chain, allowing the use of smart contract accounts to hold tokens without compromising privacy or integrity.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

The concept revolves around representing token balances on-chain as hashed values, called baskets, which obscure the actual balance information. These baskets combine a random salt, a unique token ID, and the token’s value, making it impossible to derive the token’s value directly from the blockchain.

The token interface allows for creating, transferring, issuing, and reorganizing (splitting and joining) these baskets. To prevent unauthorized changes and maintain integrity, oracle services verify that the total value of baskets remains consistent during reorganizations. Additionally, differential privacy techniques, such as overlaying noise and empty transfers, further protect privacy by making it difficult to trace token movements and determine actual transaction details.

Baskets

Balances are represented on-chain as hashes of the form:

keccak256(abi.encode(salt, tokenId, value))

// where  salt (bytes32)    - random 32bytes to increase the entropy and
//                            make brute-forcing the hash impossible
//        tokenId (bytes32) - a unique tokenId within token's smart contract instance
//        value (uint256)   - the value of the position

For the remainder of this document, we refer to these hashes as “baskets” because they conceal the balance information in an opaque manner, similar to how a covered basket hides its contents.

Token Interface

An opaque token MUST implement the following interface.

interface OpaqueToken {
  //
  // TYPES
  //

  struct SIGNATURE {
    uint8 v; bytes32 r; bytes32 s;
  }

  struct ORACLECONFIG {
    uint8 minNumberOfOracles; // min. number of oracle signatures required for reorg
    address[] oracles;        // valid oracles
  }

  //
  // EVENTS
  //

  /**
   * @dev MUST be emitted when new token is created
   * @param initiatedBy address that created and controls the token
   * @param tokenId identifier of the token
   * @param totalSupplyBasket initial supply basket, containing total supply of tokens
   * @param ref custom reference as used by initiator
   */
  event CreateToken(address initiatedBy, bytes32 tokenId, bytes32 totalSupplyBasket, bytes32 ref);

  /**
   * @dev MUST be emitted on issuance
   * @param initiatedBy address that initiated issuance
   * @param baskets baskets that were issued to the receiver
   * @param receiver address that received baskets
   * @param ref custom reference as used by initiator
   */
  event Issue(address initiatedBy, bytes32[] baskets, address receiver, bytes32 ref);

  /**
   * @dev MUST be emitted when holder baskets are restructured
   * @param initiatedBy address that initiated reorg and owner of all baskets
   * @param basketsIn baskets that are restructured and no longer exist
   * @param basketsOut baskets that are newly created
   * @param ref custom reference as used by initiator
   */
  event ReorgHolderBaskets(address initiatedBy, bytes32[] basketsIn, bytes32[] basketsOut, bytes32 ref);

  /**
   * @dev MUST be emitted when supply baskets are restructured
   * @param initiatedBy address that initiated reorg
   * @param basketsIn supply baskets that are restructured and no longer exist
   * @param basketsOut supply baskets that are newly created
   * @param ref custom reference as used by initiator
   */
  event ReorgSupplyBaskets(address initiatedBy, bytes32[] basketsIn, bytes32[] basketsOut, bytes32 ref);

  /**
   * @dev MUST be emitted when baskets are transferred from one address to another
   * @param initiatedBy address that initiated the transfer
   * @param receiver address that is the new owner of baskets
   * @param baskets baskets that were transferred
   * @param ref custom reference as used by initiator
   */
  event Transfer(address initiatedBy, address receiver, bytes32[] baskets, bytes32 ref);

  /**
   * @dev MUST be emitted on redeem
   * @param initiatedBy address that initiated redeem
   * @param baskets baskets that were redeemed
   * @param ref custom reference as used by initiator
   */
  event Redeem(address initiatedBy, bytes32[] baskets, bytes32 ref);

  //
  // FUNCTIONS
  //

  /**
   * @dev returns the configuration for this token
   */
  function oracleConfig() external view returns (ORACLECONFIG memory);

  /**
   * @dev returns the address of the basket owner
   */
  function owner(bytes32 basket) external view returns (address);

  /**
   * @dev returns the total supply for a `tokenId``
   * All token investors are allowed to fetch this value from the token operator's off-chain storage.
   */
  function totalSupply(bytes32 tokenId) external view returns (bytes32);

  /**
   * @dev returns the operator of this token, who is also responsible for providing the main
   * off-chain storage source.
   */
  function operator() external view returns (address);
  
  /**
   * @dev Allows the token operator to create a new token with the specified `tokenId` and an initial 
   * `totalSupplyBasket`. The `totalSupplyBasket` can be partitioned using {reorgSupplyBaskets} as needed 
   * when calling {issue}. The `ref` parameter can be used freely by the caller for any reference purpose.
   */
  function createToken(
      bytes32 tokenId,
      bytes32 totalSupplyBasket,
      bytes32 ref
  ) external;

  /**
   * @dev Allows the token operator to issue tokens by assigning `supplyBaskets` to a `receiver` which 
   * becomes the owner of these baskets. 
   */
  function issue(
      bytes32[] calldata supplyBaskets,
      address receiver,
      bytes32 ref
  ) external;
  
  /**
   * @dev transfers `baskets` to a `receiver` who becomes the new owner of these baskets. 
   */
  function transfer(
      bytes32[] calldata baskets,
      address receiver,
      bytes32 ref
  ) external;

  /**
   * @dev reorganizes a set of holder baskets (`basketsIn`) to a new set (`basketsOut`) having
   * the same value, i.e., the sum of all values from input baskets equals the sum of values
   * in output baskets. In order to ensure the integrity, external oracle service is required that
   * will sign the reorg proposal requested by the basket owner, which is passed as `reorgOracleSignatures`.
   * The minimum number of oracle signatures is defined in the oracle configuration.
   */
  function reorgHolderBaskets(
      SIGNATURE[] calldata reorgOracleSignatures,
      bytes32[] calldata basketsIn,
      bytes32[] calldata basketsOut,
      bytes32 ref
  ) external;

  /**
   * @dev same as {reorgHolderBaskets}, but for the available supply baskets.
   */
  function reorgSupplyBaskets(
      SIGNATURE[] calldata reorgOracleSignatures,
      bytes32[] calldata basketsIn,
      bytes32[] calldata basketsOut,
      bytes32 ref
  ) external;

  /**
   * @dev redeems holder's `baskets` and returns them to available supply
   */
  function redeem(
      bytes32[] calldata baskets,
      bytes32 ref
  ) external;

}

User Roles

There are two roles in Opaque Token:

  • Token Operator: One who creates a token and issues positions in it, and controls it’s non-circulating supply (held in supply baskets). Will use createToken, reorgSupplyBasket and issue functions. Also has ability to force actions through forceTransfer and forceReorg functions.
  • Token User: address that holds circulating tokens (held in owned baskets). Will use reorgSupplyBaskets, transfer and redeem functions.

Off-chain Data Endpoints

  • The operator of the token (e.g., issuer or registrar) MUST provide the off-chain storage that implements the GET basket and PUT basket REST endpoints as described in this section.
  • The operator MUST ensure the availability of the basket data and will share it on need-to-know basis with all eligible holders, i.e., with all address that either were holding the basket in the past or are currently the holder of the basket.
  • To ensure data is only shared with and can be written by eligible holders, the operator MUST implement authentication for both endpoints. The concrete authentication schema is not specified here and my depend on the environment of the token operator.
  • The operator MUST allow an existing token holder to PUT basket
  • The operator MUST allow the current or historical basket holder to GET basket
  • Token holders SHOULD store a copy of the data about their own baskets in their own off-chain storage for the case that operator’s service is unavailable.

REST API Endpoints for creating and querying baskets:

  Endpoint: PUT baskets
  Description: will store baskets if the `basket` hash is matching `data`.
  PostData: 
  [
    {
      basket: keccak256(abi.encode(salt, tokenId, value)),
      data: {
        salt: <bytes32>,
        tokenId: <bytes32>,
        value: <uint256>
      }
    },
    ...
  ]

  Endpoint: GET baskets?basket-hash=<bytes32>
  Description: will return the list of baskets depending on the query parameters.
  Query Parameters:
    - basket-hash (optional): returns one basket matching the requested hash
    - if no query parameter is set, then the endpoint will return all baskets of the requestor
  Response:
  [
    {
      basket: keccak256(abi.encode(salt, tokenId, value)),
      data: {
        salt: <bytes32>,
        tokenId: <bytes32>,
        value: <uint256>
      }
    },
    ...
  ]

reorg Endpoint

To ensure the integrity of a reorg and avoid accidental or fraudulent issues or redeems, an oracle services is required.

  • Oracles MUST provide a POST reorg REST Endpoint as described in this section
  • Oracles MUST sign any reorg proposal request where
    • the sum of values in input baskets grouped by tokenId is equal the sum of values of the output baskets grouped by tokenId.
    • item.basket hash matches keccak256(abi.encode(data.salt, data.tokenId, data.value))
  • The reorg endpoint MUST be stateless
  • Oracle MUST NOT persist data from the request for later analysis.
  • The reorg endpoint SHOULD NOT require authentication and can be used by anyone without restrictions.
Endpoint: POST reorg
PostData:
{
  in: [
    {
      basket: keccak256(abi.encode(salt, tokenId, value)),
      data: {
        salt: <bytes32>,
        tokenId: <bytes32>,
        value: <uint256>
      }
    },
    ...
  ],
  out: [
    {
      basket: keccak256(abi.encode(salt, tokenId, value)),
      data: {
        salt: <bytes32>,
        tokenId: <bytes32>,
        value: <uint256>
      }
    },
    ...
  ]
}

Response: {
    // hash is signed with oracles private key
    // basketsIn and basketsIn are bytes32[]
    signature: sign(keccak256(abi.encode(basketsIn, basketsOut)))
}

Example for valid reorg requests (salt and hashes are omitted for better readability):

in : (..., token1, 10), (..., token1, 30), (..., token2, 5), (..., token2, 95)
out: (..., token1, 40), (..., token2, 100)

in : (..., token1, 40), (..., token2, 100)
out: (..., token1, 10), (..., token1, 30), (..., token2, 5), (..., token2, 95)

Overlaying Noise (Differential Privacy)

To further enhance privacy and obscure transaction details, an additional layer of noise need to be introduced through reorgs and empty transfers. For example, received baskets can be reorganized into new baskets to prevent information leakage to the previous owner. Additionally, null-value baskets can be sent to random receivers (empty transfers), making it difficult for observers to determine who is transferring to whom.

Example with reorg and null-value basket transfers:

A owns basket-a1{..., value:10}
B owns basket-b1{..., value:5}, basket-b2{..., value:15}, ...
A: transfer basket-a1 to B
B: reorg [basket-a1, basket-b1, basket-b2]
      to [basket-b3{..., value:10}, basket-b4{..., value:10}, basket-b5:{..., value:10},
            basket-b6:{..., value:0}, basket-b7:{..., value:0}]
      where sum of inputs is the sum of outputs
B: transfer basket-b5{value:10} to C
B: transfer basket-b6{value:0}  to D
B: transfer basket-b7{value:0}  to E

If B would directly send basket-a1 to C, A would know what C is receiving, however, now that B has reorg’ed the baskets, A can not know anymore what has been sent to C.

Moreover, observers still see who is communicating with whom, but since there is noise introduced, they can not tell which of these transfers are actually transferring real values.

Rationale

Breaking the ERC-20 Compatibility

The transparency inherent in ERC-20 tokens presents a significant issue for reusable blockchain identities. To address this, we prioritize privacy over ERC-20 compatibility, ensuring the confidentiality of token balances.

Reorg Oracles

The trusted oracles and the minimum number of required signatures can be configured to achieve the desired level of decentralization.

The basket holder proposes the input and output baskets for the reorg, while the oracles are responsible for verifying that the sums of the values on both sides (input and output) are equal. This system allows for mutual control, ensuring that no single party can manipulate the process.

Fraudulent oracles can be tracked back on-chain, i.e., the system ensures weak-integrity at minimum.

To further strengthen the integrity, it would also be possible to apply Zero-Knowledge Proofs (ZKP) to provide reorg proofs, however, we have chosen to use oracles for efficiency and simplicity reasons.

Off-chain Data Storage

We have chosen the token operator, which in most cases will be the issuer or registrar, as the initial and main source for off-chain data. This is acceptable, since they must know anyway which investor holds which positions to manage lifecycle events on the token. While this approach may not be suitable for every use case within the broader Ethereum ecosystem, it fits well the financial instruments in the regulated environment of the financial industry, which rely on strict KYC and token operation procedures.

Backwards Compatibility

  • Opaque Token is not compatible with ERC-20 for reasons explained in the Rationale section.

Security Considerations

Fraudulent Oracles

Oracles Collecting Confidential Data

Confidential Data Loss

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Ivica Aračić (@ivica7), Ante Bešlić (@ethSplit), Mirko Katanić (@mkatanic), SWIAT, "ERC-7722: Opaque Token [DRAFT]," Ethereum Improvement Proposals, no. 7722, June 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7722.