ModulesTokens

MultiToken

MultiToken is a specification for contracts that manage multiple token types. This module is an approximation of EIP-1155 written in the Compact programming language for the Midnight network.

ERC1155 Compatibility

Even though Midnight is not EVM-compatible, this implementation attempts to be an approximation of the standard. Some features and behaviors are either not possible, not possible yet, or changed because of the vastly different tech stack and Compact language constraints.

Notable changes

  • Uint<128> as value and id type - Since 256-bit unsigned integers are not supported, the library uses the Compact type Uint<128>.

Features and specifications NOT supported

  • Events - Midnight does not currently support events, but this is planned on being supported in the future.
  • Uint256 type - There's ongoing research on ways to support uint256 in the future.
  • Interface - Compact currently does not have a way to define a contract interface. This library offers modules of contracts with free floating circuits; nevertheless, there's no means of enforcing that all circuits are provided.
  • Batch mint, burn, transfer - Without support for dynamic arrays, batching transfers is difficult to do without a hacky solution. For instance, we could change the to and fromAddress parameters to be vectors. This would change the signature and would be both difficult to use and easy to misuse.
  • Querying batched balances - This can be somewhat supported. The issue, without dynamic arrays, is that the module circuit must use Vector<n> for accounts and ids; therefore, the implementing contract must explicitly define the number of balances to query in the circuit i.e.
balanceOfBatch_10(
   accounts: Vector<10, Either<Bytes<32>, ContractAddress>>,
   ids: Vector<10, Uint<128>>
): Vector<10, Uint<128>>

Since this module does not offer mint or transfer batching, balance batching is also not included at this time.

  • Introspection - Compact currently cannot support contract-to-contract queries for introspection. ERC165 (or an equivalent thereof) is NOT included in the contract.
  • Safe transfers - The lack of an introspection mechanism means safe transfers of any kind can not be supported.

Contract-to-contract calls

Contract-to-contract calls are currently not supported in the Compact language. Due to this limitation, the current iteration of MultiToken disallows transfers and mints to the ContractAddress type. Transferring tokens to a contract may result in those tokens being locked forever. The MultiToken module, however, does provide unsafe circuit variants for users who wish to experiment with sending tokens to contracts.

The unsafe circuits will eventually be deprecated after Compact supports contract-to-contract calls—meaning transferFrom, _mint, etc. are planned to eventually allow the recipients to be of the ContractAddress type.

Identity Model

MultiToken uses a witness-derived identity scheme. Each caller proves knowledge of a secret key by injecting it through the wit_MultiTokenSK witness. The module computes the caller's account identifier as persistentHash(secretKey), a commitment that hides the key while providing a stable, pseudonymous on-chain identity. That identifier is the Bytes<32> (left) branch of the Either<Bytes<32>, ContractAddress> account type and is what setApprovalForAll and transferFrom check ownership and operator rights against.

What is public on the ledger:

  • _balances, keyed by token id and then by account identifier (or contract address).
  • _operatorApprovals, keyed by owner and then operator.
  • _uri.

What the caller holds privately:

  • The 32-byte secretKey. It never reaches the ledger; only its hash does.

A transfer discloses the sender, the recipient, the token id, and the amount, because the balance map is updated with those values. An operator approval discloses the caller's identifier, the operator, and the flag.

Because the identifier is H(secretKey) with no per-deployment salt, the same key produces the same identity in every contract. Users who want cross-contract unlinkability can use a different secret key per contract at the wallet layer.

To derive an account identifier off-chain (for example, to pass as the recipient of a mint), use computeAccountId from the Utils module:

import "./node_modules/@openzeppelin/compact-contracts/utils/Utils"
  prefix Utils_;

export pure circuit computeAccountId(secretKey: Bytes<32>): Bytes<32> {
  return Utils_computeAccountId(secretKey);
}

A contract that re-exports it this way exposes pureCircuits.computeAccountId(secretKey) to its TypeScript side. The Utils guide shows the equivalent computation with the Compact runtime.

The TypeScript witness implementation lives with the DApp, not with the library. The witnesses in the compact-contracts repository are test-only and are not published; consumers author their own wit_MultiTokenSK that returns the caller's 32-byte secret key.

The secretKey must never be logged or persisted in plaintext. Loss of the key means loss of the tokens held under that identity, and key exposure allows impersonation. Use crypto.getRandomValues() or equivalent to generate keys.

Usage

Import the MultiToken module into the implementing contract. It's recommended to prefix the module with MultiToken_ to avoid circuit signature clashes.

pragma language_version >= 0.26.0;

import CompactStandardLibrary;
import "./node_modules/@openzeppelin/compact-contracts/token/MultiToken"
  prefix MultiToken_;

constructor(
  uri: Opaque<"string">,
) {
  MultiToken_initialize(uri);
}

Next, expose the circuits that users may call in the contract.

This library enables extensibility by following the rules of the Module/Contract Pattern. Note that circuits with a preceding underscore (_likeThis) are meant to be building blocks for implementing contracts. Exposing _mint without some sort of access control, for example, would allow ANYONE to mint tokens.

export circuit uri(id: Uint<128>): Opaque<"string"> {
  return MultiToken_uri(id);
}

export circuit balanceOf(account: Either<Bytes<32>, ContractAddress>, id: Uint<128>): Uint<128> {
  return MultiToken_balanceOf(account, id);
}

(...)

The following example is a simple multi-token contract that creates both a fixed-supply fungible token and an NFT using the same contract.

// MultiTokenTwoTokenTypes.compact

pragma language_version >= 0.26.0;

import CompactStandardLibrary;
import "./node_modules/@openzeppelin/compact-contracts/token/MultiToken"
  prefix MultiToken_;

constructor(
  _uri: Opaque<"string">,
  recipient: Either<Bytes<32>, ContractAddress>,
  fungibleFixedSupply: Uint<128>,
) {
  // `initialize` sets the URI (base) for all tokens minted from this contract
  MultiToken_initialize(_uri);

  // Token id `123` is a fungible token (with a fixed supply of `fungibleFixedSupply`)
  const fungibleTokenId = 123;
  MultiToken__mint(recipient, fungibleTokenId, fungibleFixedSupply);

  // Token id `987` is a non-fungible token (so the supply is only 1)
  const nonFungibleTokenId = 987;
  MultiToken__mint(recipient, nonFungibleTokenId, 1);
}

export circuit uri(id: Uint<128>): Opaque<"string"> {
  return MultiToken_uri(id);
}

export circuit balanceOf(account: Either<Bytes<32>, ContractAddress>, id: Uint<128>): Uint<128> {
  return MultiToken_balanceOf(account, id);
}

export circuit setApprovalForAll(
  operator: Either<Bytes<32>, ContractAddress>,
  approved: Boolean
): [] {
  return MultiToken_setApprovalForAll(operator, approved);
}

export circuit isApprovedForAll(
  account: Either<Bytes<32>, ContractAddress>,
  operator: Either<Bytes<32>, ContractAddress>
): Boolean {
  return MultiToken_isApprovedForAll(account, operator);
}

export circuit transferFrom(
  fromAddress: Either<Bytes<32>, ContractAddress>,
  to: Either<Bytes<32>, ContractAddress>,
  id: Uint<128>,
  value: Uint<128>,
): [] {
  return MultiToken_transferFrom(fromAddress, to, id, value);
}